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 +150 -0
- package/examples/README.md +111 -0
- package/examples/_shared.mjs +34 -0
- package/examples/ab-branches.mjs +60 -0
- package/examples/checkpointing.mjs +64 -0
- package/examples/git-workflow.mjs +52 -0
- package/examples/parallel-agents.mjs +1 -1
- package/examples/personalization.mjs +66 -0
- package/examples/rollback-quarantine.mjs +54 -0
- package/package.json +9 -2
- package/src/index.d.ts +22 -1
- package/src/index.js +91 -15
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
|

|
|
@@ -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 '
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
529
|
+
for (const n of this._owned) {
|
|
454
530
|
try { n.db.close(); } catch { /* ignore */ }
|
|
455
531
|
}
|
|
456
532
|
this._closed = true;
|