agenticow 0.1.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/LICENSE +21 -0
- package/README.md +227 -0
- package/bench/acceptance.js +268 -0
- package/bench/bench.js +238 -0
- package/bin/agenticow.js +284 -0
- package/examples/parallel-agents.mjs +64 -0
- package/package.json +68 -0
- package/src/index.d.ts +99 -0
- package/src/index.js +465 -0
package/src/index.d.ts
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// Type declarations for agenticow — Git for Agent Memory.
|
|
2
|
+
|
|
3
|
+
export interface OpenOptions {
|
|
4
|
+
/** Vector dimension. Required when creating a new memory file. */
|
|
5
|
+
dimension?: number;
|
|
6
|
+
/** Distance metric. Default: "cosine". */
|
|
7
|
+
metric?: string;
|
|
8
|
+
/** HNSW M parameter (optional, create only). */
|
|
9
|
+
m?: number;
|
|
10
|
+
/** HNSW efConstruction (optional, create only). */
|
|
11
|
+
efConstruction?: number;
|
|
12
|
+
/** Keep an in-memory edit log enabling diff()/promote(). Default: true. */
|
|
13
|
+
track?: boolean;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface MemoryDiff {
|
|
17
|
+
added: number[];
|
|
18
|
+
overridden: number[];
|
|
19
|
+
deleted: number[];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface IngestResult {
|
|
23
|
+
accepted: number;
|
|
24
|
+
rejected: number;
|
|
25
|
+
epoch: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface QueryOptions {
|
|
29
|
+
/** HNSW efSearch passed to each store in the lineage chain. */
|
|
30
|
+
efSearch?: number;
|
|
31
|
+
/** Candidates to over-fetch per store before exact merge. Default: k*4. */
|
|
32
|
+
overscan?: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface QueryHit {
|
|
36
|
+
id: number;
|
|
37
|
+
distance: number;
|
|
38
|
+
/** label/id of the lineage node the hit came from (which "wins"). */
|
|
39
|
+
branch: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface CheckpointDescriptor {
|
|
43
|
+
id: string;
|
|
44
|
+
label: string;
|
|
45
|
+
path: string;
|
|
46
|
+
depth: number;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface LineageNode {
|
|
50
|
+
role: 'working' | 'checkpoint' | 'base';
|
|
51
|
+
id: string;
|
|
52
|
+
label: string | null;
|
|
53
|
+
path: string;
|
|
54
|
+
tombstones: number;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface MemoryStatus {
|
|
58
|
+
totalVectors: number;
|
|
59
|
+
totalSegments: number;
|
|
60
|
+
fileSize: number;
|
|
61
|
+
currentEpoch: number;
|
|
62
|
+
profileId: number;
|
|
63
|
+
compactionState: string;
|
|
64
|
+
deadSpaceRatio: number;
|
|
65
|
+
readOnly: boolean;
|
|
66
|
+
chainDepth: number;
|
|
67
|
+
dimension: number;
|
|
68
|
+
metric: string;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export type IngestRecord = { id: number; vector: number[] | Float32Array };
|
|
72
|
+
|
|
73
|
+
export class AgenticMemory {
|
|
74
|
+
static open(filePath: string, opts?: OpenOptions): AgenticMemory;
|
|
75
|
+
readonly dimension: number;
|
|
76
|
+
ingest(records: IngestRecord[]): IngestResult;
|
|
77
|
+
ingest(vectors: Float32Array, ids: number[]): IngestResult;
|
|
78
|
+
delete(ids: number[]): { deleted: number; tombstoned: number };
|
|
79
|
+
query(vector: number[] | Float32Array, k?: number, opts?: QueryOptions): QueryHit[];
|
|
80
|
+
branch(label?: string, filePath?: string): AgenticMemory;
|
|
81
|
+
fork(label?: string, filePath?: string): AgenticMemory;
|
|
82
|
+
diff(): MemoryDiff;
|
|
83
|
+
promote(target: AgenticMemory): { ingested: number; deleted: number };
|
|
84
|
+
checkpoint(label?: string): CheckpointDescriptor;
|
|
85
|
+
rollback(checkpointId?: string): { restoredTo: string; depth: number };
|
|
86
|
+
lineage(): LineageNode[];
|
|
87
|
+
status(): MemoryStatus;
|
|
88
|
+
save(manifestPath: string): string;
|
|
89
|
+
static load(manifestPath: string): AgenticMemory;
|
|
90
|
+
close(): void;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function open(filePath: string, opts?: OpenOptions): AgenticMemory;
|
|
94
|
+
|
|
95
|
+
declare const _default: {
|
|
96
|
+
open: typeof open;
|
|
97
|
+
AgenticMemory: typeof AgenticMemory;
|
|
98
|
+
};
|
|
99
|
+
export default _default;
|
package/src/index.js
ADDED
|
@@ -0,0 +1,465 @@
|
|
|
1
|
+
// agenticow — Git for Agent Memory
|
|
2
|
+
// Copy-On-Write vector branching for embedded multi-agent memory.
|
|
3
|
+
//
|
|
4
|
+
// Built on ruvector's RVF format via @ruvector/rvf-node. The headline capability
|
|
5
|
+
// is base-size-independent COW branch creation: deriving a branch off a base
|
|
6
|
+
// memory costs ~0.5 ms and ~162 bytes regardless of how big the base is —
|
|
7
|
+
// proven 83x faster and ~3000x smaller than full-copy snapshots at 1M vectors.
|
|
8
|
+
//
|
|
9
|
+
// Honest scope:
|
|
10
|
+
// - Branch/checkpoint CREATE is O(edits), O(1) in base size. PROVEN.
|
|
11
|
+
// - query() is an EXACT read-through: parent ∪ child-edits, child wins on an
|
|
12
|
+
// id collision, deletes are honored. It works by merging each store in the
|
|
13
|
+
// lineage chain (child -> ... -> base). Each store answers with its own
|
|
14
|
+
// native index; agenticow merges + re-ranks exactly across the boundary.
|
|
15
|
+
// - A single ANN/HNSW index that SPANS the COW boundary is NOT shipped — that
|
|
16
|
+
// is roadmap. Native cluster-level read-through (branch()) landed in
|
|
17
|
+
// ruvnet/RuVector PR #617; until it is published, agenticow implements the
|
|
18
|
+
// read-through in this wrapper over the shipped derive() primitive.
|
|
19
|
+
|
|
20
|
+
import fs from 'node:fs';
|
|
21
|
+
import os from 'node:os';
|
|
22
|
+
import path from 'node:path';
|
|
23
|
+
import crypto from 'node:crypto';
|
|
24
|
+
import pkg from '@ruvector/rvf-node';
|
|
25
|
+
|
|
26
|
+
const { RvfDatabase } = pkg;
|
|
27
|
+
|
|
28
|
+
const DEFAULT_METRIC = 'cosine';
|
|
29
|
+
|
|
30
|
+
/** @param {number[]|Float32Array} v */
|
|
31
|
+
function toF32(v) {
|
|
32
|
+
return v instanceof Float32Array ? v : Float32Array.from(v);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// L2-normalize a copy of v. Used when metric is cosine so that ranking is
|
|
36
|
+
// identical whether the engine scores with cosine or L2 — important because the
|
|
37
|
+
// shipped rvf-node binding reopens files with the l2 metric (the cosine setting
|
|
38
|
+
// is not persisted). On unit vectors, L2 distance is monotonic with cosine
|
|
39
|
+
// distance, so top-K is preserved either way.
|
|
40
|
+
function l2normalize(src) {
|
|
41
|
+
const v = src instanceof Float32Array ? Float32Array.from(src) : Float32Array.from(src);
|
|
42
|
+
let n = 0;
|
|
43
|
+
for (let i = 0; i < v.length; i++) n += v[i] * v[i];
|
|
44
|
+
n = Math.sqrt(n);
|
|
45
|
+
if (n > 0) for (let i = 0; i < v.length; i++) v[i] /= n;
|
|
46
|
+
return v;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function tmpChildPath(base, label) {
|
|
50
|
+
const slug = (label || 'branch').replace(/[^a-zA-Z0-9_-]/g, '').slice(0, 24) || 'branch';
|
|
51
|
+
const rand = crypto.randomBytes(4).toString('hex');
|
|
52
|
+
const dir = path.dirname(base);
|
|
53
|
+
const stem = path.basename(base).replace(/\.rvf$/i, '');
|
|
54
|
+
return path.join(dir, `${stem}.${slug}-${rand}.rvf`);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* One node in the COW lineage chain. `db` is an open RvfDatabase handle.
|
|
59
|
+
* `tombstones` are ids deleted at this node (hide the same id in ancestors).
|
|
60
|
+
*/
|
|
61
|
+
class Node {
|
|
62
|
+
constructor(db, nodePath, label) {
|
|
63
|
+
this.db = db;
|
|
64
|
+
this.path = nodePath;
|
|
65
|
+
this.label = label || null;
|
|
66
|
+
this.id = db.fileId();
|
|
67
|
+
this.tombstones = new Set();
|
|
68
|
+
// Edit log for diff()/promote(). Cheap on small branches; disabled on huge
|
|
69
|
+
// bases via { track:false } in open().
|
|
70
|
+
this.editIds = new Set();
|
|
71
|
+
this.editVecs = new Map();
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export class AgenticMemory {
|
|
76
|
+
/** @private */
|
|
77
|
+
constructor(workingNode, ancestors, dim, metric, track = true) {
|
|
78
|
+
/** @type {Node} */
|
|
79
|
+
this._working = workingNode;
|
|
80
|
+
/** @type {Node[]} ancestors newest -> oldest (base last) */
|
|
81
|
+
this._ancestors = ancestors;
|
|
82
|
+
this._dim = dim;
|
|
83
|
+
this._metric = metric;
|
|
84
|
+
this._track = track;
|
|
85
|
+
this._normalize = String(metric).toLowerCase() === 'cosine';
|
|
86
|
+
this._closed = false;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Open an existing memory file, or create one if it does not exist.
|
|
91
|
+
* @param {string} filePath
|
|
92
|
+
* @param {{dimension?:number, metric?:string, m?:number, efConstruction?:number, track?:boolean}} [opts]
|
|
93
|
+
* track (default true): keep an in-memory edit log enabling diff()/promote().
|
|
94
|
+
* Set false on very large bases to avoid caching their vectors.
|
|
95
|
+
* @returns {AgenticMemory}
|
|
96
|
+
*/
|
|
97
|
+
static open(filePath, opts = {}) {
|
|
98
|
+
let db, dim, metric;
|
|
99
|
+
if (fs.existsSync(filePath)) {
|
|
100
|
+
db = RvfDatabase.open(filePath);
|
|
101
|
+
dim = db.dimension();
|
|
102
|
+
metric = db.metric ? db.metric() : (opts.metric || DEFAULT_METRIC);
|
|
103
|
+
} else {
|
|
104
|
+
if (!opts.dimension) {
|
|
105
|
+
throw new Error('agenticow: dimension is required when creating a new memory file');
|
|
106
|
+
}
|
|
107
|
+
dim = opts.dimension;
|
|
108
|
+
metric = opts.metric || DEFAULT_METRIC;
|
|
109
|
+
db = RvfDatabase.create(filePath, {
|
|
110
|
+
dimension: dim,
|
|
111
|
+
metric,
|
|
112
|
+
...(opts.m ? { m: opts.m } : {}),
|
|
113
|
+
...(opts.efConstruction ? { efConstruction: opts.efConstruction } : {}),
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
return new AgenticMemory(new Node(db, filePath, 'base'), [], dim, metric, opts.track !== false);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Full lineage chain, working node first. @returns {Node[]} */
|
|
120
|
+
_chain() {
|
|
121
|
+
return [this._working, ...this._ancestors];
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
_deriveOpts() {
|
|
125
|
+
return { dimension: this._dim, metric: this._metric };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
_assertOpen() {
|
|
129
|
+
if (this._closed) throw new Error('agenticow: memory is closed');
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Ingest vectors into the current working node.
|
|
134
|
+
* Forms:
|
|
135
|
+
* ingest([{ id, vector }, ...])
|
|
136
|
+
* ingest(Float32Array, number[]) // flat batch, fastest
|
|
137
|
+
* @returns {{accepted:number, rejected:number, epoch:number}}
|
|
138
|
+
*/
|
|
139
|
+
ingest(records, ids) {
|
|
140
|
+
this._assertOpen();
|
|
141
|
+
let flat, idArr;
|
|
142
|
+
if (records instanceof Float32Array) {
|
|
143
|
+
flat = this._normalize ? Float32Array.from(records) : records;
|
|
144
|
+
idArr = ids;
|
|
145
|
+
} else if (Array.isArray(records)) {
|
|
146
|
+
idArr = records.map((r) => r.id);
|
|
147
|
+
flat = new Float32Array(records.length * this._dim);
|
|
148
|
+
for (let i = 0; i < records.length; i++) flat.set(toF32(records[i].vector), i * this._dim);
|
|
149
|
+
} else {
|
|
150
|
+
throw new Error('agenticow: ingest expects an array of {id,vector} or (Float32Array, ids)');
|
|
151
|
+
}
|
|
152
|
+
if (this._normalize) {
|
|
153
|
+
// normalize each row in place (flat is already a private copy here)
|
|
154
|
+
for (let i = 0; i < idArr.length; i++) {
|
|
155
|
+
const o = i * this._dim;
|
|
156
|
+
let n = 0;
|
|
157
|
+
for (let j = 0; j < this._dim; j++) n += flat[o + j] * flat[o + j];
|
|
158
|
+
n = Math.sqrt(n);
|
|
159
|
+
if (n > 0) for (let j = 0; j < this._dim; j++) flat[o + j] /= n;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
// A re-ingested id is no longer a delete in this node.
|
|
163
|
+
for (const id of idArr) this._working.tombstones.delete(id);
|
|
164
|
+
const res = this._working.db.ingestBatch(flat, idArr);
|
|
165
|
+
if (this._track) {
|
|
166
|
+
for (let i = 0; i < idArr.length; i++) {
|
|
167
|
+
const id = idArr[i];
|
|
168
|
+
this._working.editIds.add(id);
|
|
169
|
+
this._working.editVecs.set(id, flat.slice(i * this._dim, (i + 1) * this._dim));
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
return res;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Delete ids from the current branch's view (COW tombstone). The id stays in
|
|
177
|
+
* the ancestor on disk, but is hidden from this branch's reads.
|
|
178
|
+
* @param {number[]} ids
|
|
179
|
+
*/
|
|
180
|
+
delete(ids) {
|
|
181
|
+
this._assertOpen();
|
|
182
|
+
let deleted = 0;
|
|
183
|
+
try {
|
|
184
|
+
const res = this._working.db.delete(ids);
|
|
185
|
+
deleted = res?.deleted ?? 0;
|
|
186
|
+
} catch {
|
|
187
|
+
/* id may live only in an ancestor; fall through to tombstone */
|
|
188
|
+
}
|
|
189
|
+
for (const id of ids) {
|
|
190
|
+
this._working.tombstones.add(id);
|
|
191
|
+
this._working.editIds.delete(id);
|
|
192
|
+
this._working.editVecs.delete(id);
|
|
193
|
+
}
|
|
194
|
+
return { deleted, tombstoned: ids.length };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* EXACT read-through k-NN: parent ∪ child-edits, child wins on id collision,
|
|
199
|
+
* deletes honored. Merges every node in the lineage chain and re-ranks.
|
|
200
|
+
* @param {number[]|Float32Array} vector
|
|
201
|
+
* @param {number} [k=10]
|
|
202
|
+
* @param {{efSearch?:number, overscan?:number}} [opts]
|
|
203
|
+
* @returns {{id:number, distance:number, branch:string}[]}
|
|
204
|
+
*/
|
|
205
|
+
query(vector, k = 10, opts = {}) {
|
|
206
|
+
this._assertOpen();
|
|
207
|
+
const qv = this._normalize ? l2normalize(vector) : toF32(vector);
|
|
208
|
+
const fetch = Math.max(k, opts.overscan || k * 4);
|
|
209
|
+
const resolved = new Map(); // id -> {id, distance, branch}
|
|
210
|
+
const hidden = new Set(); // ids tombstoned by a nearer descendant
|
|
211
|
+
const qopts = opts.efSearch ? { efSearch: opts.efSearch } : undefined;
|
|
212
|
+
for (const node of this._chain()) {
|
|
213
|
+
for (const t of node.tombstones) hidden.add(t);
|
|
214
|
+
let hits = [];
|
|
215
|
+
try {
|
|
216
|
+
hits = node.db.query(qv, fetch, qopts);
|
|
217
|
+
} catch {
|
|
218
|
+
hits = [];
|
|
219
|
+
}
|
|
220
|
+
for (const h of hits) {
|
|
221
|
+
if (resolved.has(h.id) || hidden.has(h.id)) continue; // nearer node wins
|
|
222
|
+
resolved.set(h.id, { id: h.id, distance: h.distance, branch: node.label || node.id });
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
return [...resolved.values()].sort((a, b) => a.distance - b.distance).slice(0, k);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Create an isolated COW branch (a parallel fork of this memory). O(1) in base
|
|
230
|
+
* size — ~0.5 ms / 162 bytes. The branch sees everything this memory currently
|
|
231
|
+
* has via read-through, plus its own future edits. Mutations are isolated:
|
|
232
|
+
* neither side sees the other's later writes.
|
|
233
|
+
* @param {string} [label]
|
|
234
|
+
* @param {string} [filePath]
|
|
235
|
+
* @returns {AgenticMemory}
|
|
236
|
+
*/
|
|
237
|
+
branch(label, filePath) {
|
|
238
|
+
this._assertOpen();
|
|
239
|
+
// Freeze the current working node so BOTH sides derive off the same
|
|
240
|
+
// immutable snapshot — this is what guarantees mutation isolation (neither
|
|
241
|
+
// the parent nor the branch sees the other's later writes).
|
|
242
|
+
const frozen = this._working;
|
|
243
|
+
const parentChildPath = tmpChildPath(frozen.path, 'work');
|
|
244
|
+
const parentChildDb = frozen.db.derive(parentChildPath, this._deriveOpts());
|
|
245
|
+
const childPath = filePath || tmpChildPath(frozen.path, label);
|
|
246
|
+
const childDb = frozen.db.derive(childPath, this._deriveOpts());
|
|
247
|
+
// Parent continues, transparently, in its own fresh child.
|
|
248
|
+
this._ancestors = [frozen, ...this._ancestors];
|
|
249
|
+
this._working = new Node(parentChildDb, parentChildPath, 'working');
|
|
250
|
+
// Branch shares the frozen snapshot + all older ancestors.
|
|
251
|
+
const branchNode = new Node(childDb, childPath, label || 'branch');
|
|
252
|
+
return new AgenticMemory(branchNode, [...this._ancestors], this._dim, this._metric, this._track);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Lightweight fork: derive a child WITHOUT re-pointing this memory. Use this to
|
|
257
|
+
* fan out many branches off a base you will not mutate again (e.g. spawn 1,000
|
|
258
|
+
* per-user branches off one shared base). One derive() per fork — ~0.5 ms /
|
|
259
|
+
* 162 bytes each, O(1) in base size. Read-through isolation holds as long as
|
|
260
|
+
* the parent base stays read-only after forking.
|
|
261
|
+
* @param {string} [label]
|
|
262
|
+
* @param {string} [filePath]
|
|
263
|
+
* @returns {AgenticMemory}
|
|
264
|
+
*/
|
|
265
|
+
fork(label, filePath) {
|
|
266
|
+
this._assertOpen();
|
|
267
|
+
const childPath = filePath || tmpChildPath(this._working.path, label);
|
|
268
|
+
const childDb = this._working.db.derive(childPath, this._deriveOpts());
|
|
269
|
+
const childNode = new Node(childDb, childPath, label || 'fork');
|
|
270
|
+
return new AgenticMemory(
|
|
271
|
+
childNode,
|
|
272
|
+
[this._working, ...this._ancestors],
|
|
273
|
+
this._dim,
|
|
274
|
+
this._metric,
|
|
275
|
+
this._track
|
|
276
|
+
);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Git-style diff of this branch's working node against its nearest ancestor:
|
|
281
|
+
* which ids were added, overridden, or tombstoned. Requires edit tracking
|
|
282
|
+
* (open with track !== false).
|
|
283
|
+
* @returns {{added:number[], overridden:number[], deleted:number[]}}
|
|
284
|
+
*/
|
|
285
|
+
diff() {
|
|
286
|
+
this._assertOpen();
|
|
287
|
+
const ancestorIds = new Set();
|
|
288
|
+
for (const a of this._ancestors) for (const id of a.editIds) ancestorIds.add(id);
|
|
289
|
+
const added = [];
|
|
290
|
+
const overridden = [];
|
|
291
|
+
for (const id of this._working.editIds) {
|
|
292
|
+
(ancestorIds.has(id) ? overridden : added).push(id);
|
|
293
|
+
}
|
|
294
|
+
return {
|
|
295
|
+
added: added.sort((a, b) => a - b),
|
|
296
|
+
overridden: overridden.sort((a, b) => a - b),
|
|
297
|
+
deleted: [...this._working.tombstones].sort((a, b) => a - b),
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Promote (merge) this branch's recorded edits onto a target memory — the
|
|
303
|
+
* Git-style "branch -> reviewed -> production" workflow. Replays the working
|
|
304
|
+
* node's ingested vectors and tombstones into `target`. Requires edit tracking.
|
|
305
|
+
* @param {AgenticMemory} target
|
|
306
|
+
* @returns {{ingested:number, deleted:number}}
|
|
307
|
+
*/
|
|
308
|
+
promote(target) {
|
|
309
|
+
this._assertOpen();
|
|
310
|
+
if (!(target instanceof AgenticMemory)) {
|
|
311
|
+
throw new Error('agenticow: promote target must be an AgenticMemory');
|
|
312
|
+
}
|
|
313
|
+
const ids = [...this._working.editIds];
|
|
314
|
+
if (ids.length && this._working.editVecs.size === 0) {
|
|
315
|
+
throw new Error('agenticow: promote needs tracked edit vectors (open with track:true)');
|
|
316
|
+
}
|
|
317
|
+
if (ids.length) {
|
|
318
|
+
const flat = new Float32Array(ids.length * this._dim);
|
|
319
|
+
for (let i = 0; i < ids.length; i++) flat.set(this._working.editVecs.get(ids[i]), i * this._dim);
|
|
320
|
+
target.ingest(flat, ids);
|
|
321
|
+
}
|
|
322
|
+
const tomb = [...this._working.tombstones];
|
|
323
|
+
if (tomb.length) target.delete(tomb);
|
|
324
|
+
return { ingested: ids.length, deleted: tomb.length };
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Freeze the current state as an immutable restore point and keep working in a
|
|
329
|
+
* fresh COW child. O(1) in base size. Returns the checkpoint descriptor.
|
|
330
|
+
* @param {string} [label]
|
|
331
|
+
* @returns {{id:string, label:string, path:string, depth:number}}
|
|
332
|
+
*/
|
|
333
|
+
checkpoint(label) {
|
|
334
|
+
this._assertOpen();
|
|
335
|
+
const frozen = this._working;
|
|
336
|
+
frozen.label = label || frozen.label || `ckpt-${this._ancestors.length + 1}`;
|
|
337
|
+
const childPath = tmpChildPath(frozen.path, 'work');
|
|
338
|
+
const childDb = frozen.db.derive(childPath, this._deriveOpts());
|
|
339
|
+
const childNode = new Node(childDb, childPath, 'working');
|
|
340
|
+
this._ancestors = [frozen, ...this._ancestors];
|
|
341
|
+
this._working = childNode;
|
|
342
|
+
return {
|
|
343
|
+
id: frozen.id,
|
|
344
|
+
label: frozen.label,
|
|
345
|
+
path: frozen.path,
|
|
346
|
+
depth: this._ancestors.length,
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* Discard all edits since a checkpoint and resume from it. With no argument,
|
|
352
|
+
* rolls back to the most recent checkpoint. Abandons the poisoned working
|
|
353
|
+
* child and derives a fresh writable child off the chosen checkpoint.
|
|
354
|
+
* @param {string} [checkpointId] fileId of the checkpoint to return to
|
|
355
|
+
* @returns {{restoredTo:string, depth:number}}
|
|
356
|
+
*/
|
|
357
|
+
rollback(checkpointId) {
|
|
358
|
+
this._assertOpen();
|
|
359
|
+
if (this._ancestors.length === 0) {
|
|
360
|
+
throw new Error('agenticow: nothing to roll back to (no checkpoints)');
|
|
361
|
+
}
|
|
362
|
+
let idx = 0; // default: most recent checkpoint
|
|
363
|
+
if (checkpointId) {
|
|
364
|
+
idx = this._ancestors.findIndex((n) => n.id === checkpointId);
|
|
365
|
+
if (idx === -1) throw new Error(`agenticow: checkpoint ${checkpointId} not found`);
|
|
366
|
+
}
|
|
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 */ }
|
|
374
|
+
const target = this._ancestors[idx];
|
|
375
|
+
const newAncestors = this._ancestors.slice(idx); // target + older
|
|
376
|
+
const childPath = tmpChildPath(target.path, 'work');
|
|
377
|
+
const childDb = target.db.derive(childPath, this._deriveOpts());
|
|
378
|
+
this._working = new Node(childDb, childPath, 'working');
|
|
379
|
+
this._ancestors = newAncestors;
|
|
380
|
+
return { restoredTo: target.id, depth: this._ancestors.length };
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/** Lineage chain metadata, working node first. */
|
|
384
|
+
lineage() {
|
|
385
|
+
return this._chain().map((n, i) => ({
|
|
386
|
+
role: i === 0 ? 'working' : (i === this._chain().length - 1 ? 'base' : 'checkpoint'),
|
|
387
|
+
id: n.id,
|
|
388
|
+
label: n.label,
|
|
389
|
+
path: n.path,
|
|
390
|
+
tombstones: n.tombstones.size,
|
|
391
|
+
}));
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/** Status of the working node plus chain depth. */
|
|
395
|
+
status() {
|
|
396
|
+
this._assertOpen();
|
|
397
|
+
const s = this._working.db.status();
|
|
398
|
+
return {
|
|
399
|
+
...s,
|
|
400
|
+
chainDepth: this._chain().length,
|
|
401
|
+
dimension: this._dim,
|
|
402
|
+
metric: this._metric,
|
|
403
|
+
};
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/** dimension of stored vectors */
|
|
407
|
+
get dimension() {
|
|
408
|
+
return this._dim;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* Persist the lineage to a small JSON manifest so the CLI (or another process)
|
|
413
|
+
* can reopen the exact chain. Vector data stays in the .rvf files; the manifest
|
|
414
|
+
* holds the chain order, labels, tombstones and (for diff/promote) the working
|
|
415
|
+
* node's recorded edits.
|
|
416
|
+
* @param {string} manifestPath
|
|
417
|
+
*/
|
|
418
|
+
save(manifestPath) {
|
|
419
|
+
this._assertOpen();
|
|
420
|
+
const nodes = this._chain().map((n, i) => ({
|
|
421
|
+
path: path.resolve(n.path),
|
|
422
|
+
label: n.label,
|
|
423
|
+
tombstones: [...n.tombstones],
|
|
424
|
+
// only the working node (i===0) needs its edit vectors for promote()
|
|
425
|
+
editIds: i === 0 ? [...n.editIds] : [],
|
|
426
|
+
editVecs: i === 0 ? Object.fromEntries([...n.editVecs].map(([id, v]) => [id, Array.from(v)])) : {},
|
|
427
|
+
}));
|
|
428
|
+
fs.writeFileSync(manifestPath, JSON.stringify({ v: 1, dim: this._dim, metric: this._metric, track: this._track, nodes }, null, 2));
|
|
429
|
+
return manifestPath;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/**
|
|
433
|
+
* Reconstruct an AgenticMemory from a manifest written by save().
|
|
434
|
+
* @param {string} manifestPath
|
|
435
|
+
* @returns {AgenticMemory}
|
|
436
|
+
*/
|
|
437
|
+
static load(manifestPath) {
|
|
438
|
+
const m = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
|
|
439
|
+
const nodes = m.nodes.map((nm, i) => {
|
|
440
|
+
const db = i === 0 ? RvfDatabase.open(nm.path) : RvfDatabase.openReadonly(nm.path);
|
|
441
|
+
const node = new Node(db, nm.path, nm.label);
|
|
442
|
+
node.tombstones = new Set(nm.tombstones || []);
|
|
443
|
+
node.editIds = new Set(nm.editIds || []);
|
|
444
|
+
node.editVecs = new Map(Object.entries(nm.editVecs || {}).map(([id, v]) => [Number(id), Float32Array.from(v)]));
|
|
445
|
+
return node;
|
|
446
|
+
});
|
|
447
|
+
return new AgenticMemory(nodes[0], nodes.slice(1), m.dim, m.metric, m.track !== false);
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/** Close all open handles in the chain. */
|
|
451
|
+
close() {
|
|
452
|
+
if (this._closed) return;
|
|
453
|
+
for (const n of this._chain()) {
|
|
454
|
+
try { n.db.close(); } catch { /* ignore */ }
|
|
455
|
+
}
|
|
456
|
+
this._closed = true;
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/** Convenience: open or create a memory. @see AgenticMemory.open */
|
|
461
|
+
export function open(filePath, opts) {
|
|
462
|
+
return AgenticMemory.open(filePath, opts);
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
export default { open, AgenticMemory };
|