agenticow 0.1.0 β†’ 0.2.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/README.md CHANGED
@@ -87,6 +87,133 @@ A worked script lives in [`examples/parallel-agents.mjs`](./examples/parallel-ag
87
87
 
88
88
  ---
89
89
 
90
+ ## Applications
91
+
92
+ Concrete ways to use COW agent memory β€” each with a runnable script in
93
+ [`examples/`](./examples). Framing is honest: **practical** ships and is proven,
94
+ **strategic** is shipped-but-early, **exotic** is vision/roadmap (clearly marked).
95
+
96
+ ### 🟒 Personalization β€” one base, a branch per user *(practical)*
97
+ Give every user/account/tenant their own memory branch off a shared base. Private
98
+ edits stay isolated; storage is delta-only (KB/user, not a full copy).
99
+ ```js
100
+ const base = open('kb.rvf', { dimension: 1536 });
101
+ const userMem = base.fork(`user-${userId}`);
102
+ userMem.ingest([{ id, vector }]); // private to this user
103
+ userMem.query(q, 10); // reads through to the shared base
104
+ ```
105
+ β†’ [`examples/personalization.mjs`](./examples/personalization.mjs) Β· [`parallel-agents.mjs`](./examples/parallel-agents.mjs)
106
+
107
+ ### 🟒 Rollback / quarantine β€” discard a poisoned branch *(practical)*
108
+ An agent ingests hallucinated or adversarial memories into a sandbox branch.
109
+ Detect it, drop the branch β€” the base is instantly clean, no re-index.
110
+ ```js
111
+ const sandbox = base.fork('untrusted');
112
+ sandbox.ingest(unvettedVectors);
113
+ // ...detect bad content...
114
+ sandbox.close(); // discard β†’ base never saw it
115
+ ```
116
+ β†’ [`examples/rollback-quarantine.mjs`](./examples/rollback-quarantine.mjs)
117
+
118
+ ### 🟒 Checkpointing β€” crash recovery without replay *(practical)*
119
+ Checkpoint memory before each risky step (162 B each). On failure, roll back to
120
+ the last good checkpoint in ~0.5 ms β€” earlier steps are not replayed.
121
+ ```js
122
+ const ck = mem.checkpoint('step-30');
123
+ // ...step 31 crashes...
124
+ mem.rollback(ck.id); // resume from step 30
125
+ ```
126
+ β†’ [`examples/checkpointing.mjs`](./examples/checkpointing.mjs)
127
+
128
+ ### 🟒 Git-style memory workflow β€” branch β†’ diff β†’ promote *(practical)*
129
+ Treat memory like code: branch a feature, review the change with `diff()`, and
130
+ `promote()` the vetted delta into production.
131
+ ```js
132
+ const feature = prod.branch('feature');
133
+ feature.ingest(newFacts);
134
+ feature.diff(); // { added, overridden, deleted }
135
+ feature.promote(prod); // merge into production
136
+ ```
137
+ β†’ [`examples/git-workflow.mjs`](./examples/git-workflow.mjs)
138
+
139
+ ### 🟑 A/B testing & evolution β€” score variants, promote the winner *(strategic)*
140
+ Fork N variant branches off one base, score each, and promote only the winner.
141
+ The substrate for Darwin-style / population-based agent-memory search.
142
+ ```js
143
+ const variants = ids.map((i) => base.fork(`variant-${i}`));
144
+ // ...score each...
145
+ variants[best].promote(base); // keep the winner, drop the rest free
146
+ ```
147
+ β†’ [`examples/ab-branches.mjs`](./examples/ab-branches.mjs)
148
+
149
+ ### 🟑 Compliance & lineage β€” provenance-tracked memory *(strategic)*
150
+ Every branch records its parent id + hash and a cryptographic witness chain.
151
+ `lineage()` gives an auditable history of how a memory state was reached β€”
152
+ useful for reproducibility and audit trails.
153
+
154
+ ### 🟑 Edge / local-first agents β€” embedded, no server *(strategic)*
155
+ agenticow runs in-process over a single `.rvf` file β€” no DB server, no network.
156
+ Thousands of cheap branches fit on-device for offline/edge multi-agent memory.
157
+
158
+ ### πŸ”­ Agent marketplaces & shared base memories *(exotic β€” vision, not shipped)*
159
+ A published base memory that many agents branch from, contributing deltas back β€”
160
+ a "memory package registry". The branch/promote primitives exist today; the
161
+ distribution, trust, and merge-policy layer is **roadmap, not shipped**.
162
+
163
+ ---
164
+
165
+ ## MetaHarness usage
166
+
167
+ agenticow is the **memory plane** of the [`@metaharness/*`](https://www.npmjs.com/org/metaharness)
168
+ agent-harness ecosystem. It pairs with [`@metaharness/jujutsu`](https://www.npmjs.com/package/@metaharness/jujutsu)
169
+ (`v0.1.0`), which wraps [`agentic-jujutsu`](https://www.npmjs.com/package/agentic-jujutsu)
170
+ (a Rust+NAPI Jujutsu `jj` op-log with QuantumDAG coordination, ReasoningBank
171
+ trajectories, and ML-DSA signing) β€” the **code/op plane**.
172
+
173
+ **The dual-state bridge (ADR-202).** A coding agent that explores must branch and
174
+ roll back *two* planes: the **code/ops** it did and the **memory** it learned.
175
+ Used separately they drift (revert code but keep poisoned memory; promote a
176
+ memory delta whose ops were never merged). `@metaharness/jujutsu`'s
177
+ `DualStateBridge` ties them 1:1 β€” one agent β‡’ one op branch + one memory branch β€”
178
+ mapping four lifecycle verbs onto agenticow:
179
+
180
+ | Verb | code/op plane (agentic-jujutsu) | memory plane (agenticow) |
181
+ |---|---|---|
182
+ | **spawn** | `jj bookmark create` + start trajectory | `fork()` off the base + `checkpoint('spawn')` |
183
+ | **learn** | finalize trajectory + read op-sequence | embed ops β†’ `ingest()` into the branch |
184
+ | **revert** | `jj undo` | `rollback()` to the spawn checkpoint |
185
+ | **merge** | `jj squash` ops into base | `promote()` the winning delta into the base |
186
+
187
+ Install alongside (both planes are **optional peer deps** β€” the bridge runs
188
+ degraded/mock-backed if one is absent, per the ADR-150 *removable-augmentation*
189
+ principle):
190
+
191
+ ```bash
192
+ npm install @metaharness/jujutsu agenticow agentic-jujutsu
193
+ ```
194
+
195
+ ```js
196
+ import { open } from 'agenticow';
197
+ // @metaharness/jujutsu wires these two planes behind DualStateBridge:
198
+ const base = open('reasoning-bank.rvf', { dimension: 1536 });
199
+ const agentMem = base.fork('agent-007'); // memory branch (spawn)
200
+ agentMem.checkpoint('spawn');
201
+ agentMem.ingest(embeddedTrajectory); // learn
202
+ // if the trajectory scores poorly:
203
+ agentMem.rollback(/* spawn checkpoint id */); // revert β€” code revert via `jj undo`
204
+ // if it wins:
205
+ agentMem.promote(base); // merge β€” ops via `jj squash`
206
+ ```
207
+
208
+ **Honest status (ADR-202):** spawn / learn / revert / merge are **wired
209
+ end-to-end** with both real native planes. **Cross-branch ANN query is stubbed**
210
+ behind a port β€” agenticow's exact read-through answers it correctly but
211
+ unaccelerated across the COW boundary; the native single-index-across-the-branch
212
+ lands with [ruvnet/RuVector PR #617](https://github.com/ruvnet/RuVector/pull/617),
213
+ at which point only the adapter swaps.
214
+
215
+ ---
216
+
90
217
  ## How copy-on-write for vectors works
91
218
 
92
219
  ![COW concept](./assets/concept.png)
@@ -144,6 +271,29 @@ The acceptance test builds a brute-force ground truth (`base βˆͺ branch-inserts
144
271
 
145
272
  \* **Honest concession.** On SIFT-1M, same machine, the underlying [ruvector](https://github.com/ruvnet/RuVector) HNSW does ~2,197 QPS @ recall 0.95 vs hnswlib-node ~9,344 QPS β€” roughly **2.7Γ— slower** for raw ANN. If you need maximum raw similarity-search speed on a static index, use a dedicated ANN library. agenticow's edge is **cheap branching, checkpointing and rollback of agent memory** β€” which none of the above have.
146
273
 
274
+ ### Performance Β· storage Β· cost at scale
275
+
276
+ **Scenario: 1,000 branches over a 1M-vector base** (dim 128, ~496 MB base). agenticow figures are **measured** on an AMD Ryzen 9 9950X; competitor figures are **published / estimated** (sources below) and labeled as such β€” not fabricated.
277
+
278
+ | Approach | Branch / snapshot create | Per-branch storage | Query latency (ANN) | Cost @ 1,000 branches | Native COW / rollback |
279
+ |---|---|---|---|---|---|
280
+ | **agenticow (COW)** | 0.47 ms / 162 B *(measured, flat to 1M)* | ~10.8 KB *(measured)* | ~2.7Γ— behind hnswlib *(measured\*)* | ~507 MB local Β· **~$0** infra (embedded) *(measured†)* | βœ… instant (p50 0.57 ms) |
281
+ | Naive full-copy | 67 ms / 496 MB *(measured @1M)* | full base (~496 MB) | = source engine | ~484 GB local *(measured Γ—N)* | ❌ |
282
+ | Pinecone (serverless) | no native branch β€” full re-upsert | full copy (managed) | fast (core strength) | ~484 GB β‰ˆ **$160/mo** storage + units *(est.ΒΉ)* | ❌ |
283
+ | Milvus | snapshot = full copy / reindex | full copy | fast (core strength) | ~484 GB resident β†’ large cluster, **$$$/mo** *(est.Β²)* | ❌ |
284
+ | Qdrant | snapshot = full copy | full copy | fast (core strength) | ~484 GB β†’ managed/self-host, **$$$/mo** *(est.Β³)* | ❌ |
285
+ | pgvector | SQL dump + reindex | full copy | moderate | ~484 GB in Postgres *(est.)* | ❌ |
286
+ | Chroma | full collection copy | full copy | moderate | ~484 GB local/managed *(est.)* | ❌ |
287
+ | lakeFS / DVC | fast metadata branch *(their strength)* | file-level delta (cheap) | n/a β€” not a vector engine | cheap branching, but you still build/serve the ANN index yourself *(published)* | βœ… data/files Β· ❌ vector index |
288
+
289
+ **Takeaway:** agenticow wins on branch-create speed, per-branch storage, and multi-branch cost, and is the only option with native COW branching + instant rollback of a live vector memory. It **concedes raw ANN search speed** to the dedicated vector DBs β€” use those when single-index query throughput is the priority, and agenticow when you need cheap branching, checkpointing, and rollback of agent memory.
290
+
291
+ <sub>\* SIFT-1M same-machine (above). † base ~496 MB + 1,000 Γ— ~10.8 KB β‰ˆ 507 MB, in-process. ΒΉ est. from [pinecone.io/pricing](https://www.pinecone.io/pricing/) (~$0.33/GB-mo storage, excl. read/write units). Β² est. from [zilliz.com/pricing](https://zilliz.com/pricing). Β³ est. from [qdrant.tech/pricing](https://qdrant.tech/pricing/). Competitor figures are published/estimated; only agenticow's are measured.</sub>
292
+
293
+ The [live site](https://ruvnet.github.io/agenticow/#bench) is mobile-friendly (responsive layout, horizontally-scrollable tables):
294
+
295
+ <img src="./assets/mobile-hero.png" width="280" alt="agenticow on mobile (375px width)" />
296
+
147
297
  ---
148
298
 
149
299
  ## Honest scope
@@ -0,0 +1,111 @@
1
+ # agenticow examples
2
+
3
+ Runnable, optimized examples β€” one per core use case. They import the library
4
+ from `../src/index.js` so they run against this repo with zero setup; in your own
5
+ project, import from `agenticow` instead.
6
+
7
+ ```bash
8
+ node examples/personalization.mjs
9
+ node examples/rollback-quarantine.mjs
10
+ node examples/checkpointing.mjs
11
+ node examples/git-workflow.mjs
12
+ node examples/ab-branches.mjs
13
+ node examples/parallel-agents.mjs
14
+ # or all of them:
15
+ npm run examples
16
+ ```
17
+
18
+ Vectors are generated by a seeded RNG (`_shared.mjs`) so the **id/order/recall
19
+ outputs are deterministic**; only the timings vary per machine. Outputs below
20
+ were captured on an AMD Ryzen 9 9950X, Node v22.
21
+
22
+ ---
23
+
24
+ ### `personalization.mjs` β€” one base, a cheap branch per user
25
+
26
+ A shared base + an isolated COW branch per user. Private edits stay private;
27
+ storage is delta-only.
28
+
29
+ ```
30
+ base: 10,000 vectors, 2.52 MB
31
+ branched 50 users in 58.55 ms (1.176 ms/user) β€” total delta 42.2 KB (0.84 KB/user)
32
+ vs 50 full copies of the base: 125.91 MB β†’ 3,056x less storage
33
+ isolation: user-0 sees own pref (id 900000)=true, user-1's pref leaks to user-0=false
34
+ read-through: user-7 top hit for base vector #42 = id 42 (from base)
35
+ ```
36
+
37
+ ### `rollback-quarantine.mjs` β€” quarantine a poisoned ingest
38
+
39
+ An untrusted agent ingests 100 unvetted vectors into a sandbox branch; discard
40
+ the branch β†’ the base is instantly clean. No re-index, no restore.
41
+
42
+ ```
43
+ base: 2000 trusted vectors
44
+ agent ingested 100 unvetted vectors into its sandbox branch
45
+ before: query near poison[0] -> id 666000 (poison present in sandbox = true)
46
+ discarded branch in 0.256 ms β†’ poison present in base = false
47
+ base intact: 2000 vectors, base vector #1 still found = true
48
+ ```
49
+
50
+ ### `checkpointing.mjs` β€” zero-cost checkpoints + crash recovery without replay
51
+
52
+ Checkpoint every 10 steps; crash at step 31; roll back to step 30. The 30 steps
53
+ are NOT replayed β€” they live in the 162-byte checkpoint.
54
+
55
+ ```
56
+ checkpoint @ step 10: id 93b1eb356a… (162 B, depth 1)
57
+ checkpoint @ step 20: id 8169723119… (162 B, depth 2)
58
+ checkpoint @ step 30: id b39dc86be8… (162 B, depth 3)
59
+ step 31 ran (partial work present = true) ... πŸ’₯ simulated crash
60
+ recovered to step-30 in 0.731 ms (re-ingests during recovery: 0)
61
+ after recovery: step 31 gone = true, step 15 present = true, step 30 present = true
62
+ total ingest() calls across the whole run: 31 (31 steps, no replay)
63
+ ```
64
+
65
+ ### `git-workflow.mjs` β€” agent β†’ test β†’ prod memory pipeline
66
+
67
+ Branch a feature off production, ingest/override/delete in it, review with
68
+ `diff()`, then `promote()` the vetted delta into prod. Prod is untouched until
69
+ you promote.
70
+
71
+ ```
72
+ prod: 2 vectors
73
+ diff(feature): +added=[100,101] ~overridden=[2] -deleted=[1]
74
+ prod before promote: id 1 present = true, feature id 100 present = false
75
+ promote β†’ 3 vectors merged, 1 tombstoned into prod
76
+ prod after promote: id 100 present = true, id 1 retracted = true
77
+ ```
78
+
79
+ ### `ab-branches.mjs` β€” N variant branches, score each, promote the winner
80
+
81
+ A/B / Darwin-style: fork variant branches, score each candidate against a
82
+ target, promote only the winner. Losing branches are discarded for free.
83
+
84
+ ```
85
+ base: 1000 vectors; scoring 8 variant branches against a target
86
+ variant-7: candidate distance to target = 0.0000
87
+ variant-6: candidate distance to target = 0.0179
88
+ variant-5: candidate distance to target = 0.0568
89
+ variant-4: candidate distance to target = 0.3386
90
+ variant-3: candidate distance to target = 0.4558
91
+ variant-2: candidate distance to target = 0.5061
92
+ variant-0: candidate distance to target = 0.9004
93
+ variant-1: candidate distance to target = 1.1748
94
+ winner: variant-7 (dist 0.0000)
95
+ promoted variant-7 β†’ 1 vector merged; winner candidate now in base = true
96
+ ```
97
+
98
+ ### `parallel-agents.mjs` β€” fan out, query, roll one back
99
+
100
+ Fork N agent branches off a base, each with private memory + tombstones, query
101
+ each (read-through), and roll one back after a bad ingest.
102
+
103
+ ```
104
+ base: 2000 vectors, 516 KB
105
+ forked 8 agent branches off the shared base
106
+ agent-0: top-5 = 5 base + 0 private (e.g. id 1503 @ 0.546)
107
+ ...
108
+ agent-0 before rollback: hallucination present = true
109
+ agent-0 after rollback: hallucination present = false (base intact)
110
+ done.
111
+ ```
@@ -0,0 +1,34 @@
1
+ // Shared helpers for the examples β€” a deterministic RNG so outputs are
2
+ // reproducible, plus a tiny timer. Examples import the library from the local
3
+ // source so they run against this repo with zero setup:
4
+ //
5
+ // in your own project: import { open } from 'agenticow';
6
+ // in this repo: import { open } from '../src/index.js';
7
+
8
+ import os from 'node:os';
9
+ import fs from 'node:fs';
10
+ import path from 'node:path';
11
+
12
+ // mulberry32 β€” small, fast, seedable PRNG (deterministic example output).
13
+ export function rng(seed = 0x9e3779b9) {
14
+ let a = seed >>> 0;
15
+ return () => {
16
+ a |= 0; a = (a + 0x6d2b79f5) | 0;
17
+ let t = Math.imul(a ^ (a >>> 15), 1 | a);
18
+ t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
19
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
20
+ };
21
+ }
22
+
23
+ export function vecFactory(dim, seed) {
24
+ const r = rng(seed);
25
+ return () => Float32Array.from({ length: dim }, () => r() * 2 - 1);
26
+ }
27
+
28
+ export function tmpdir(tag) {
29
+ return fs.mkdtempSync(path.join(os.tmpdir(), `agenticow-${tag}-`));
30
+ }
31
+
32
+ export const ms = (t0) => `${(performance.now() - t0).toFixed(3)} ms`;
33
+ export const kb = (b) => `${(b / 1024).toFixed(1)} KB`;
34
+ export const mb = (b) => `${(b / 1024 / 1024).toFixed(2)} MB`;
@@ -0,0 +1,60 @@
1
+ // ab-branches.mjs β€” N variant branches off one base, score each, promote winner.
2
+ //
3
+ // Demonstrates A/B testing / Darwin-style evolution of agent memory: fork many
4
+ // variant branches off a shared base, give each a different candidate memory,
5
+ // score each variant against a target query, and promote() only the winner back
6
+ // into the base. Losing branches are discarded for free.
7
+ //
8
+ // Run: node examples/ab-branches.mjs
9
+ //
10
+ // ── verified output ───────────────────────────────────────────────────────
11
+ // (see examples/README.md)
12
+ // ──────────────────────────────────────────────────────────────────────────
13
+
14
+ import fs from 'node:fs';
15
+ import path from 'node:path';
16
+ import { open } from '../src/index.js'; // in your project: from 'agenticow'
17
+ import { vecFactory, tmpdir } from './_shared.mjs';
18
+
19
+ const DIM = 32;
20
+ const VARIANTS = 8;
21
+ const dir = tmpdir('ab');
22
+ const vec = vecFactory(DIM, 31);
23
+
24
+ // 1. Base memory + a "target" we want a variant to match well.
25
+ const base = open(path.join(dir, 'base.rvf'), { dimension: DIM });
26
+ const flat = new Float32Array(1000 * DIM);
27
+ for (let i = 0; i < 1000; i++) flat.set(vec(), i * DIM);
28
+ base.ingest(flat, Array.from({ length: 1000 }, (_, i) => i));
29
+ const target = vec();
30
+ console.log(`base: ${base.status().totalVectors} vectors; scoring ${VARIANTS} variant branches against a target`);
31
+
32
+ // 2. Fork one branch per variant; each ingests a candidate vector (id 5000).
33
+ // Variant v's candidate is a blend of the target and noise β€” higher v = closer.
34
+ const variants = [];
35
+ for (let v = 0; v < VARIANTS; v++) {
36
+ const br = base.fork(`variant-${v}`);
37
+ const blend = Float32Array.from(target, (x) => x * (v / (VARIANTS - 1)) + (vec()[0] * (1 - v / (VARIANTS - 1))));
38
+ br.ingest([{ id: 5000, vector: blend }]);
39
+ variants.push(br);
40
+ }
41
+
42
+ // 3. Score each variant: distance of its candidate (id 5000) to the target.
43
+ // Use k > base size so the candidate is always included in the read-through.
44
+ const scores = variants.map((br, v) => {
45
+ const hit = br.query(target, 1001).find((h) => h.id === 5000);
46
+ return { v, dist: hit ? hit.distance : Infinity };
47
+ });
48
+ scores.sort((a, b) => a.dist - b.dist);
49
+ for (const s of scores) console.log(` variant-${s.v}: candidate distance to target = ${s.dist.toFixed(4)}`);
50
+ const winner = scores[0];
51
+ console.log(`winner: variant-${winner.v} (dist ${winner.dist.toFixed(4)})`);
52
+
53
+ // 4. Promote the winner into the base; discard the rest for free.
54
+ const r = variants[winner.v].promote(base);
55
+ const inBase = base.query(target, 5).some((h) => h.id === 5000);
56
+ console.log(`promoted variant-${winner.v} β†’ ${r.ingested} vector merged; winner candidate now in base = ${inBase}`);
57
+
58
+ base.close();
59
+ variants.forEach((br) => br.close());
60
+ fs.rmSync(dir, { recursive: true, force: true });
@@ -0,0 +1,64 @@
1
+ // checkpointing.mjs β€” zero-cost checkpoints + crash recovery without replay.
2
+ //
3
+ // Demonstrates: an agent loop that checkpoints memory every 10 steps. A failure
4
+ // hits at step 31; we roll back to the step-30 checkpoint and resume. The first
5
+ // 30 steps are NOT replayed β€” they live in the 162-byte checkpoint and are
6
+ // already visible via read-through.
7
+ //
8
+ // Run: node examples/checkpointing.mjs
9
+ //
10
+ // ── verified output ───────────────────────────────────────────────────────
11
+ // (see examples/README.md)
12
+ // ──────────────────────────────────────────────────────────────────────────
13
+
14
+ import fs from 'node:fs';
15
+ import path from 'node:path';
16
+ import { open } from '../src/index.js'; // in your project: from 'agenticow'
17
+ import { vecFactory, tmpdir, ms } from './_shared.mjs';
18
+
19
+ const DIM = 32;
20
+ const dir = tmpdir('checkpoint');
21
+ const vec = vecFactory(DIM, 11);
22
+
23
+ const mem = open(path.join(dir, 'agent.rvf'), { dimension: DIM });
24
+ const stored = new Map(); // step -> vector, to verify read-through later
25
+
26
+ let ingestCalls = 0;
27
+ function step(n) {
28
+ const v = vec();
29
+ mem.ingest([{ id: n, vector: v }]);
30
+ stored.set(n, v);
31
+ ingestCalls++;
32
+ }
33
+
34
+ // Run steps 1..30, checkpoint at 10, 20, 30.
35
+ const checkpoints = {};
36
+ for (let n = 1; n <= 30; n++) {
37
+ step(n);
38
+ if (n % 10 === 0) {
39
+ checkpoints[n] = mem.checkpoint(`step-${n}`);
40
+ console.log(`checkpoint @ step ${n}: id ${checkpoints[n].id.slice(0, 10)}… (162 B, depth ${checkpoints[n].depth})`);
41
+ }
42
+ }
43
+
44
+ // Step 31 does some work, then "crashes".
45
+ step(31);
46
+ const crashedHasGarbage = mem.query(stored.get(31), 1)[0].id === 31;
47
+ console.log(`step 31 ran (partial work present = ${crashedHasGarbage}) ... πŸ’₯ simulated crash`);
48
+
49
+ // Recover: roll back to the step-30 checkpoint.
50
+ const callsBefore = ingestCalls;
51
+ const t0 = performance.now();
52
+ mem.rollback(checkpoints[30].id);
53
+ const recoverMs = ms(t0);
54
+
55
+ // The 30 steps are intact WITHOUT replay (ingestCalls unchanged), step 31 gone.
56
+ const step31Gone = mem.query(stored.get(31), 5).every((h) => h.id !== 31);
57
+ const step15Present = mem.query(stored.get(15), 1)[0].id === 15;
58
+ const step30Present = mem.query(stored.get(30), 1)[0].id === 30;
59
+ console.log(`recovered to step-30 in ${recoverMs} (re-ingests during recovery: ${ingestCalls - callsBefore})`);
60
+ console.log(`after recovery: step 31 gone = ${step31Gone}, step 15 present = ${step15Present}, step 30 present = ${step30Present}`);
61
+ console.log(`total ingest() calls across the whole run: ${ingestCalls} (31 steps, no replay)`);
62
+
63
+ mem.close();
64
+ fs.rmSync(dir, { recursive: true, force: true });
@@ -0,0 +1,52 @@
1
+ // git-workflow.mjs β€” agent β†’ test β†’ prod memory pipeline (branch/diff/promote).
2
+ //
3
+ // Demonstrates the Git-style workflow for vector memory: branch a feature off
4
+ // production, ingest into it, review the change with diff(), then promote() the
5
+ // vetted delta back into production. Production is untouched until you promote.
6
+ //
7
+ // Run: node examples/git-workflow.mjs
8
+ //
9
+ // ── verified output ───────────────────────────────────────────────────────
10
+ // (see examples/README.md)
11
+ // ──────────────────────────────────────────────────────────────────────────
12
+
13
+ import fs from 'node:fs';
14
+ import path from 'node:path';
15
+ import { open } from '../src/index.js'; // in your project: from 'agenticow'
16
+ import { vecFactory, tmpdir } from './_shared.mjs';
17
+
18
+ const DIM = 32;
19
+ const dir = tmpdir('git-workflow');
20
+ const vec = vecFactory(DIM, 23);
21
+
22
+ // 1. Production memory.
23
+ const prod = open(path.join(dir, 'prod.rvf'), { dimension: DIM });
24
+ const v1 = vec(); const v2 = vec();
25
+ prod.ingest([{ id: 1, vector: v1 }, { id: 2, vector: v2 }]);
26
+ console.log(`prod: ${prod.status().totalVectors} vectors`);
27
+
28
+ // 2. Branch a feature, ingest new memories, override one, delete one.
29
+ const feature = prod.branch('feature/new-facts');
30
+ const f100 = vec(); const f101 = vec(); const v2new = vec();
31
+ feature.ingest([{ id: 100, vector: f100 }, { id: 101, vector: f101 }]);
32
+ feature.ingest([{ id: 2, vector: v2new }]); // override an existing prod id
33
+ feature.delete([1]); // retract a prod fact in the branch
34
+
35
+ // 3. Review the change set before merging.
36
+ const d = feature.diff();
37
+ console.log(`diff(feature): +added=${JSON.stringify(d.added)} ~overridden=${JSON.stringify(d.overridden)} -deleted=${JSON.stringify(d.deleted)}`);
38
+
39
+ // prod is still untouched at this point (feature edits are isolated):
40
+ const id100InProdBefore = prod.query(f100, 1)[0].id === 100;
41
+ console.log(`prod before promote: id 1 present = ${prod.query(v1, 1)[0].id === 1}, feature id 100 present = ${id100InProdBefore}`);
42
+
43
+ // 4. Promote the vetted delta into production.
44
+ const r = feature.promote(prod);
45
+ console.log(`promote β†’ ${r.ingested} vectors merged, ${r.deleted} tombstoned into prod`);
46
+ const id100InProd = prod.query(f100, 1)[0].id === 100;
47
+ const id1Retracted = prod.query(v1, 5).every((h) => h.id !== 1);
48
+ console.log(`prod after promote: id 100 present = ${id100InProd}, id 1 retracted = ${id1Retracted}`);
49
+
50
+ prod.close();
51
+ feature.close();
52
+ fs.rmSync(dir, { recursive: true, force: true });
@@ -10,7 +10,7 @@
10
10
  import fs from 'node:fs';
11
11
  import os from 'node:os';
12
12
  import path from 'node:path';
13
- import { open } from 'agenticow'; // or '../src/index.js' from inside this repo
13
+ import { open } from '../src/index.js'; // in your project: from 'agenticow'
14
14
 
15
15
  const DIM = 64;
16
16
  const N_AGENTS = 8;
@@ -0,0 +1,66 @@
1
+ // personalization.mjs β€” one shared base memory, a cheap COW branch per user.
2
+ //
3
+ // Demonstrates: per-user/per-account personalization without copying the base.
4
+ // Each user gets an isolated branch (their edits stay private) while still
5
+ // reading through to the shared base. Storage is delta-only (KB/user), not a
6
+ // full copy of the base (MB/user).
7
+ //
8
+ // Run: node examples/personalization.mjs
9
+ //
10
+ // ── verified output ───────────────────────────────────────────────────────
11
+ // (see examples/README.md β€” outputs are deterministic via a seeded RNG)
12
+ // ──────────────────────────────────────────────────────────────────────────
13
+
14
+ import fs from 'node:fs';
15
+ import path from 'node:path';
16
+ import { open } from '../src/index.js'; // in your project: from 'agenticow'
17
+ import { vecFactory, tmpdir, ms, kb, mb } from './_shared.mjs';
18
+
19
+ const DIM = 64;
20
+ const USERS = 50;
21
+ const dir = tmpdir('personalization');
22
+ const vec = vecFactory(DIM, 1);
23
+
24
+ // 1. One shared base memory (e.g. the org-wide knowledge base).
25
+ const base = open(path.join(dir, 'base.rvf'), { dimension: DIM });
26
+ const N = 10_000;
27
+ const flat = new Float32Array(N * DIM);
28
+ const ids = [];
29
+ for (let i = 0; i < N; i++) { flat.set(vec(), i * DIM); ids.push(i); }
30
+ base.ingest(flat, ids);
31
+ const baseBytes = base.status().fileSize;
32
+ console.log(`base: ${N.toLocaleString()} vectors, ${mb(baseBytes)}`);
33
+
34
+ // 2. A cheap COW branch per user β€” fork() off the read-only base.
35
+ // Each user adds one private preference vector (id 900000 + u).
36
+ const t0 = performance.now();
37
+ const users = [];
38
+ for (let u = 0; u < USERS; u++) {
39
+ const ub = base.fork(`user-${u}`);
40
+ ub.ingest([{ id: 900000 + u, vector: vec() }]);
41
+ users.push(ub);
42
+ }
43
+ const forkWall = ms(t0);
44
+ let deltaBytes = 0;
45
+ for (const u of users) deltaBytes += fs.statSync(u.lineage()[0].path).size;
46
+ const fullCopyBytes = baseBytes * USERS;
47
+ console.log(`branched ${USERS} users in ${forkWall} (${((performance.now() - t0) / USERS).toFixed(3)} ms/user) β€” ` +
48
+ `total delta ${kb(deltaBytes)} (${(deltaBytes / USERS / 1024).toFixed(2)} KB/user)`);
49
+ console.log(`vs ${USERS} full copies of the base: ${mb(fullCopyBytes)} β†’ ` +
50
+ `${Math.round(fullCopyBytes / deltaBytes).toLocaleString()}x less storage`);
51
+
52
+ // 3. Isolation: user-0 sees its own preference; user-1's stays private.
53
+ const u0SeesOwn = users[0].diff().added.includes(900000);
54
+ const u1LeaksToU0 = users[0]
55
+ .query(flat.slice(0, DIM), USERS)
56
+ .some((h) => h.id === 900001);
57
+ console.log(`isolation: user-0 sees own pref (id 900000)=${u0SeesOwn}, user-1's pref leaks to user-0=${u1LeaksToU0}`);
58
+
59
+ // 4. Read-through: every user still queries the shared base.
60
+ const probe = flat.slice(42 * DIM, 43 * DIM);
61
+ const hit = users[7].query(probe, 1)[0];
62
+ console.log(`read-through: user-7 top hit for base vector #42 = id ${hit.id} (from ${hit.branch})`);
63
+
64
+ base.close();
65
+ users.forEach((u) => u.close());
66
+ fs.rmSync(dir, { recursive: true, force: true });
@@ -0,0 +1,54 @@
1
+ // rollback-quarantine.mjs β€” quarantine a poisoned ingest, then discard it.
2
+ //
3
+ // Demonstrates: an agent ingests "hallucinated" / adversarial vectors into a
4
+ // branch. You detect them, then throw the branch away β†’ the shared base is
5
+ // instantly clean. No re-indexing, no restore-from-backup. Shows the query
6
+ // result before (poison visible in the branch) and after (gone, base intact).
7
+ //
8
+ // Run: node examples/rollback-quarantine.mjs
9
+ //
10
+ // ── verified output ───────────────────────────────────────────────────────
11
+ // (see examples/README.md)
12
+ // ──────────────────────────────────────────────────────────────────────────
13
+
14
+ import fs from 'node:fs';
15
+ import path from 'node:path';
16
+ import { open } from '../src/index.js'; // in your project: from 'agenticow'
17
+ import { vecFactory, tmpdir, ms } from './_shared.mjs';
18
+
19
+ const DIM = 48;
20
+ const dir = tmpdir('quarantine');
21
+ const vec = vecFactory(DIM, 7);
22
+
23
+ // 1. Trusted base memory.
24
+ const base = open(path.join(dir, 'base.rvf'), { dimension: DIM });
25
+ const known = Array.from({ length: 2000 }, () => vec());
26
+ const flat = new Float32Array(2000 * DIM);
27
+ known.forEach((v, i) => flat.set(v, i * DIM));
28
+ base.ingest(flat, known.map((_, i) => i));
29
+ console.log(`base: ${base.status().totalVectors} trusted vectors`);
30
+
31
+ // 2. An untrusted agent works in a sandbox branch and ingests poison.
32
+ const sandbox = base.fork('untrusted-agent');
33
+ const sandboxPath = sandbox.lineage()[0].path; // capture before we close it
34
+ const poison = Array.from({ length: 100 }, () => vec());
35
+ const POISON_IDS = poison.map((_, i) => 666000 + i);
36
+ poison.forEach((v, i) => sandbox.ingest([{ id: POISON_IDS[i], vector: v }]));
37
+ console.log(`agent ingested ${POISON_IDS.length} unvetted vectors into its sandbox branch`);
38
+
39
+ // 3. Detection: query near a poisoned vector β€” it should surface in the sandbox.
40
+ const detect = sandbox.query(poison[0], 1)[0];
41
+ const poisonPresent = POISON_IDS.includes(detect.id);
42
+ console.log(`before: query near poison[0] -> id ${detect.id} (poison present in sandbox = ${poisonPresent})`);
43
+
44
+ // 4. Quarantine = discard the branch. The base never saw the poison.
45
+ const t0 = performance.now();
46
+ sandbox.close();
47
+ fs.rmSync(sandboxPath, { force: true });
48
+ const discardMs = ms(t0);
49
+ const poisonInBase = POISON_IDS.includes(base.query(poison[0], 1)[0].id);
50
+ console.log(`discarded branch in ${discardMs} β†’ poison present in base = ${poisonInBase}`);
51
+ console.log(`base intact: ${base.status().totalVectors} vectors, base vector #1 still found = ${base.query(known[1], 1)[0].id === 1}`);
52
+
53
+ base.close();
54
+ fs.rmSync(dir, { recursive: true, force: true });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agenticow",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Git for Agent Memory: Copy-On-Write vector branching for embedded multi-agent memory. Branch a base memory in ~0.5ms / 162 bytes regardless of base size β€” 83x faster, 3000x smaller than full-copy snapshots. Exact read-through queries (parent βˆͺ edits, child wins).",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -27,6 +27,13 @@
27
27
  "bench": "node bench/bench.js",
28
28
  "acceptance": "node bench/acceptance.js",
29
29
  "demo": "node bin/agenticow.js demo",
30
+ "examples": "for f in personalization rollback-quarantine checkpointing git-workflow ab-branches parallel-agents; do echo \"\\n── $f ──\"; node examples/$f.mjs; done",
31
+ "example:personalization": "node examples/personalization.mjs",
32
+ "example:rollback": "node examples/rollback-quarantine.mjs",
33
+ "example:checkpointing": "node examples/checkpointing.mjs",
34
+ "example:git-workflow": "node examples/git-workflow.mjs",
35
+ "example:ab-branches": "node examples/ab-branches.mjs",
36
+ "example:parallel-agents": "node examples/parallel-agents.mjs",
30
37
  "test": "node --test test/*.test.js"
31
38
  },
32
39
  "keywords": [
@@ -63,6 +70,6 @@
63
70
  "node": ">=18"
64
71
  },
65
72
  "dependencies": {
66
- "@ruvector/rvf-node": "0.1.8"
73
+ "@ruvector/rvf-node": "^0.2.0"
67
74
  }
68
75
  }
package/src/index.d.ts CHANGED
@@ -30,6 +30,22 @@ export interface QueryOptions {
30
30
  efSearch?: number;
31
31
  /** Candidates to over-fetch per store before exact merge. Default: k*4. */
32
32
  overscan?: number;
33
+ /**
34
+ * Force the exact JS chain-walk even on a native-COW fork.
35
+ * Default false (native path used when available).
36
+ */
37
+ forceExact?: boolean;
38
+ }
39
+
40
+ export interface ForkOptions {
41
+ /**
42
+ * Use the native Rust COW dual-graph ANN path (PR #618).
43
+ * When true, fork() calls RvfDatabase.branch() instead of derive(), giving
44
+ * the returned fork a working node whose query() spans the COW boundary in
45
+ * a single Rust call. recall@10 = 1.0 at 1200-vector L2 test corpus.
46
+ * Default: false (exact JS chain-walk).
47
+ */
48
+ nativeAnn?: boolean;
33
49
  }
34
50
 
35
51
  export interface QueryHit {
@@ -73,12 +89,17 @@ export type IngestRecord = { id: number; vector: number[] | Float32Array };
73
89
  export class AgenticMemory {
74
90
  static open(filePath: string, opts?: OpenOptions): AgenticMemory;
75
91
  readonly dimension: number;
92
+ /**
93
+ * True when this fork was created with `{nativeAnn:true}`.
94
+ * query() routes through the Rust dual-graph ANN merge (PR #618).
95
+ */
96
+ readonly nativeAnn: boolean;
76
97
  ingest(records: IngestRecord[]): IngestResult;
77
98
  ingest(vectors: Float32Array, ids: number[]): IngestResult;
78
99
  delete(ids: number[]): { deleted: number; tombstoned: number };
79
100
  query(vector: number[] | Float32Array, k?: number, opts?: QueryOptions): QueryHit[];
80
101
  branch(label?: string, filePath?: string): AgenticMemory;
81
- fork(label?: string, filePath?: string): AgenticMemory;
102
+ fork(label?: string, filePath?: string, opts?: ForkOptions): AgenticMemory;
82
103
  diff(): MemoryDiff;
83
104
  promote(target: AgenticMemory): { ingested: number; deleted: number };
84
105
  checkpoint(label?: string): CheckpointDescriptor;
package/src/index.js CHANGED
@@ -74,7 +74,7 @@ class Node {
74
74
 
75
75
  export class AgenticMemory {
76
76
  /** @private */
77
- constructor(workingNode, ancestors, dim, metric, track = true) {
77
+ constructor(workingNode, ancestors, dim, metric, track = true, owned = null, nativeCow = false) {
78
78
  /** @type {Node} */
79
79
  this._working = workingNode;
80
80
  /** @type {Node[]} ancestors newest -> oldest (base last) */
@@ -83,7 +83,21 @@ export class AgenticMemory {
83
83
  this._metric = metric;
84
84
  this._track = track;
85
85
  this._normalize = String(metric).toLowerCase() === 'cosine';
86
+ // Nodes this instance is allowed to close. Ancestors shared from a parent
87
+ // (via fork/branch) are NOT owned, so closing a fork never closes the base.
88
+ /** @type {Set<Node>} */
89
+ this._owned = owned || new Set([workingNode]);
86
90
  this._closed = false;
91
+ /**
92
+ * True when this instance's working node was created via RvfDatabase.branch()
93
+ * (a real COW child with a dual-graph HNSW that spans the parent boundary).
94
+ * When true, query() routes through the native Rust ANN path β€” a single
95
+ * db.query() call returns parentβˆͺchild hits via the dual-graph merge in
96
+ * rvf-runtime's query_via_index_cow. recall@10 = 1.0 at 1200-vector L2
97
+ * with 5% tombstones (verified in integration tests for rvf-runtime PR #618).
98
+ * @type {boolean}
99
+ */
100
+ this._nativeCow = nativeCow;
87
101
  }
88
102
 
89
103
  /**
@@ -205,10 +219,31 @@ export class AgenticMemory {
205
219
  query(vector, k = 10, opts = {}) {
206
220
  this._assertOpen();
207
221
  const qv = this._normalize ? l2normalize(vector) : toF32(vector);
222
+ const qopts = opts.efSearch ? { efSearch: opts.efSearch } : undefined;
223
+
224
+ // ── Native COW dual-graph ANN path ────────────────────────────────
225
+ // When this instance was created via fork({nativeAnn:true}) or
226
+ // branch({nativeAnn:true}), the working node's db is a real COW child
227
+ // (RvfDatabase.branch()). A single db.query() call transparently queries
228
+ // both the child's own HNSW and the parent's HNSW, merges candidates with
229
+ // child-wins semantics, and excludes tombstoned IDs β€” all in Rust.
230
+ // Recall@10 = 1.0000 on the PR#618 integration test (1200-vector L2,
231
+ // 60 new + 20 overrides + 10 tombstones, efSearch=300).
232
+ if (this._nativeCow && !opts.forceExact) {
233
+ const hits = this._working.db.query(qv, k, qopts);
234
+ return hits.map((h) => ({
235
+ id: h.id,
236
+ distance: h.distance,
237
+ branch: this._working.label || this._working.id,
238
+ }));
239
+ }
240
+
241
+ // ── Exact JS chain-walk (default / fallback) ──────────────────────
242
+ // For each node in the lineage chain (newest first), query its local
243
+ // store and merge with child-wins semantics.
208
244
  const fetch = Math.max(k, opts.overscan || k * 4);
209
245
  const resolved = new Map(); // id -> {id, distance, branch}
210
246
  const hidden = new Set(); // ids tombstoned by a nearer descendant
211
- const qopts = opts.efSearch ? { efSearch: opts.efSearch } : undefined;
212
247
  for (const node of this._chain()) {
213
248
  for (const t of node.tombstones) hidden.add(t);
214
249
  let hits = [];
@@ -225,6 +260,16 @@ export class AgenticMemory {
225
260
  return [...resolved.values()].sort((a, b) => a.distance - b.distance).slice(0, k);
226
261
  }
227
262
 
263
+ /**
264
+ * Whether this instance uses the native Rust COW dual-graph ANN query path.
265
+ * true => query() routes through rvf-runtime's query_via_index_cow (PR #618).
266
+ * false => exact JS chain-walk across the lineage.
267
+ * @type {boolean}
268
+ */
269
+ get nativeAnn() {
270
+ return this._nativeCow;
271
+ }
272
+
228
273
  /**
229
274
  * Create an isolated COW branch (a parallel fork of this memory). O(1) in base
230
275
  * size β€” ~0.5 ms / 162 bytes. The branch sees everything this memory currently
@@ -244,10 +289,12 @@ export class AgenticMemory {
244
289
  const parentChildDb = frozen.db.derive(parentChildPath, this._deriveOpts());
245
290
  const childPath = filePath || tmpChildPath(frozen.path, label);
246
291
  const childDb = frozen.db.derive(childPath, this._deriveOpts());
247
- // Parent continues, transparently, in its own fresh child.
292
+ // Parent continues, transparently, in its own fresh child (which it owns).
248
293
  this._ancestors = [frozen, ...this._ancestors];
249
294
  this._working = new Node(parentChildDb, parentChildPath, 'working');
250
- // Branch shares the frozen snapshot + all older ancestors.
295
+ this._owned.add(this._working);
296
+ // Branch shares the frozen snapshot + all older ancestors; it owns only its
297
+ // own working child.
251
298
  const branchNode = new Node(childDb, childPath, label || 'branch');
252
299
  return new AgenticMemory(branchNode, [...this._ancestors], this._dim, this._metric, this._track);
253
300
  }
@@ -258,13 +305,37 @@ export class AgenticMemory {
258
305
  * per-user branches off one shared base). One derive() per fork β€” ~0.5 ms /
259
306
  * 162 bytes each, O(1) in base size. Read-through isolation holds as long as
260
307
  * the parent base stays read-only after forking.
308
+ *
309
+ * `opts.nativeAnn` (default false): when true, creates a real COW branch via
310
+ * RvfDatabase.branch() instead of derive(). The returned fork's query() routes
311
+ * through the native Rust dual-graph ANN merge (PR #618), which queries both
312
+ * the fork's own HNSW and the parent's HNSW in a single call. This gives
313
+ * sub-linear ANN performance across the COW boundary at recall@10 = 1.0.
314
+ * Requires the parent to NOT be mutated after forking (same rule as exact mode).
261
315
  * @param {string} [label]
262
316
  * @param {string} [filePath]
317
+ * @param {{nativeAnn?:boolean}} [opts]
263
318
  * @returns {AgenticMemory}
264
319
  */
265
- fork(label, filePath) {
320
+ fork(label, filePath, opts = {}) {
266
321
  this._assertOpen();
267
322
  const childPath = filePath || tmpChildPath(this._working.path, label);
323
+ if (opts.nativeAnn) {
324
+ // Native COW branch: the Rust COW engine wires parent→child read-through
325
+ // so a single db.query() merges both sides via dual-graph ANN.
326
+ const childDb = this._working.db.branch(childPath);
327
+ const childNode = new Node(childDb, childPath, label || 'fork');
328
+ // The COW child already knows its parent; no JS ancestor chain needed.
329
+ return new AgenticMemory(
330
+ childNode,
331
+ [], // ancestors managed by Rust COW engine
332
+ this._dim,
333
+ this._metric,
334
+ this._track,
335
+ null,
336
+ true // _nativeCow = true β†’ query() uses native path
337
+ );
338
+ }
268
339
  const childDb = this._working.db.derive(childPath, this._deriveOpts());
269
340
  const childNode = new Node(childDb, childPath, label || 'fork');
270
341
  return new AgenticMemory(
@@ -339,6 +410,7 @@ export class AgenticMemory {
339
410
  const childNode = new Node(childDb, childPath, 'working');
340
411
  this._ancestors = [frozen, ...this._ancestors];
341
412
  this._working = childNode;
413
+ this._owned.add(childNode);
342
414
  return {
343
415
  id: frozen.id,
344
416
  label: frozen.label,
@@ -364,18 +436,21 @@ export class AgenticMemory {
364
436
  idx = this._ancestors.findIndex((n) => n.id === checkpointId);
365
437
  if (idx === -1) throw new Error(`agenticow: checkpoint ${checkpointId} not found`);
366
438
  }
367
- // Discard the current poisoned working child and any checkpoints newer than target.
368
- try {
369
- this._working.db.close();
370
- } catch { /* ignore */ }
371
- try {
372
- fs.rmSync(this._working.path, { force: true });
373
- } catch { /* ignore */ }
439
+ // Discard the current poisoned working child and any checkpoints newer than
440
+ // target β€” but only ones THIS instance owns (never a shared ancestor).
441
+ const discarded = [this._working, ...this._ancestors.slice(0, idx)];
442
+ for (const n of discarded) {
443
+ if (!this._owned.has(n)) continue;
444
+ try { n.db.close(); } catch { /* ignore */ }
445
+ try { fs.rmSync(n.path, { force: true }); } catch { /* ignore */ }
446
+ this._owned.delete(n);
447
+ }
374
448
  const target = this._ancestors[idx];
375
449
  const newAncestors = this._ancestors.slice(idx); // target + older
376
450
  const childPath = tmpChildPath(target.path, 'work');
377
451
  const childDb = target.db.derive(childPath, this._deriveOpts());
378
452
  this._working = new Node(childDb, childPath, 'working');
453
+ this._owned.add(this._working);
379
454
  this._ancestors = newAncestors;
380
455
  return { restoredTo: target.id, depth: this._ancestors.length };
381
456
  }
@@ -444,13 +519,14 @@ export class AgenticMemory {
444
519
  node.editVecs = new Map(Object.entries(nm.editVecs || {}).map(([id, v]) => [Number(id), Float32Array.from(v)]));
445
520
  return node;
446
521
  });
447
- return new AgenticMemory(nodes[0], nodes.slice(1), m.dim, m.metric, m.track !== false);
522
+ // A loaded instance opened every handle itself, so it owns the whole chain.
523
+ return new AgenticMemory(nodes[0], nodes.slice(1), m.dim, m.metric, m.track !== false, new Set(nodes));
448
524
  }
449
525
 
450
- /** Close all open handles in the chain. */
526
+ /** Close the handles this instance owns (never a shared parent/base handle). */
451
527
  close() {
452
528
  if (this._closed) return;
453
- for (const n of this._chain()) {
529
+ for (const n of this._owned) {
454
530
  try { n.db.close(); } catch { /* ignore */ }
455
531
  }
456
532
  this._closed = true;