@zenera/neo 1.1.22 → 1.1.23

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/dist/index.d.ts CHANGED
@@ -12,6 +12,7 @@ export * as Kernel from './kernel.ts';
12
12
  export * from './memory/files.ts';
13
13
  export * from './memory/graph.ts';
14
14
  export * from './memory/index.ts';
15
+ export * from './memory/merge.ts';
15
16
  export * from './memory/page.ts';
16
17
  export * from './memory/recall.ts';
17
18
  export * from './memory/render.ts';
package/dist/index.js CHANGED
@@ -12,6 +12,7 @@ export * as Kernel from "./kernel.js";
12
12
  export * from "./memory/files.js";
13
13
  export * from "./memory/graph.js";
14
14
  export * from "./memory/index.js";
15
+ export * from "./memory/merge.js";
15
16
  export * from "./memory/page.js";
16
17
  export * from "./memory/recall.js";
17
18
  export * from "./memory/render.js";
@@ -29,14 +29,28 @@ export declare class MemoryGraph {
29
29
  has(id: string): boolean;
30
30
  get(id: string): MemoryNode | undefined;
31
31
  add(draft: NodeDraft, at: string): MemoryNode;
32
+ /**
33
+ * Put a node in exactly as it stands — id, timestamps, revision and counters
34
+ * included. `add` mints a new memory; this carries one across from another
35
+ * graph, and replaces the node already under that id if there is one.
36
+ */
37
+ adopt(node: MemoryNode): void;
32
38
  update(id: string, patch: NodePatch, at: string): MemoryNode;
33
39
  /** Reading is what counts as use — see `memory_load`, not `memory_search`. */
34
40
  touch(id: string, at: string): MemoryNode | undefined;
35
41
  link(source: string, target: string, relation: Relation, at: string): void;
42
+ /** `link` for an edge that already exists elsewhere: it keeps the stamp it had. */
43
+ adoptEdge(source: string, target: string, attrs: MemoryEdgeAttrs): void;
36
44
  /** Drops the node and every edge touching it; the file it names is the store's to unlink. */
37
45
  forget(id: string): MemoryNode | undefined;
38
46
  nodes(sees?: readonly string[]): MemoryNode[];
39
47
  edges(sees?: readonly string[]): MemoryEdge[];
48
+ /** Edges with their attributes and no mask — `edges` is the view, this is the record. */
49
+ links(): {
50
+ source: string;
51
+ target: string;
52
+ attrs: MemoryEdgeAttrs;
53
+ }[];
40
54
  /** Both directions: a memory is as often reached from its artifact as from its request. */
41
55
  neighbors(id: string, sees?: readonly string[]): MemoryEdge[];
42
56
  /** Edges between nodes already chosen — what makes a materialized subgraph closed. */
@@ -41,6 +41,20 @@ export class MemoryGraph {
41
41
  this.#graph.addNode(node.id, node);
42
42
  return node;
43
43
  }
44
+ /**
45
+ * Put a node in exactly as it stands — id, timestamps, revision and counters
46
+ * included. `add` mints a new memory; this carries one across from another
47
+ * graph, and replaces the node already under that id if there is one.
48
+ */
49
+ adopt(node) {
50
+ const next = { ...node };
51
+ if (this.#graph.hasNode(node.id)) {
52
+ this.#graph.replaceNodeAttributes(node.id, next);
53
+ }
54
+ else {
55
+ this.#graph.addNode(node.id, next);
56
+ }
57
+ }
44
58
  update(id, patch, at) {
45
59
  const current = this.#graph.getNodeAttributes(id);
46
60
  if (patch.expectedRevision !== undefined && patch.expectedRevision !== current.revision) {
@@ -75,6 +89,12 @@ export class MemoryGraph {
75
89
  createdAt: at,
76
90
  });
77
91
  }
92
+ /** `link` for an edge that already exists elsewhere: it keeps the stamp it had. */
93
+ adoptEdge(source, target, attrs) {
94
+ this.#graph.mergeEdgeWithKey(edgeKey(source, target, attrs.relation), source, target, {
95
+ ...attrs,
96
+ });
97
+ }
78
98
  /** Drops the node and every edge touching it; the file it names is the store's to unlink. */
79
99
  forget(id) {
80
100
  const node = this.get(id);
@@ -102,6 +122,14 @@ export class MemoryGraph {
102
122
  });
103
123
  return out;
104
124
  }
125
+ /** Edges with their attributes and no mask — `edges` is the view, this is the record. */
126
+ links() {
127
+ const out = [];
128
+ this.#graph.forEachEdge((_key, attrs, source, target) => {
129
+ out.push({ source, target, attrs });
130
+ });
131
+ return out;
132
+ }
105
133
  /** Both directions: a memory is as often reached from its artifact as from its request. */
106
134
  neighbors(id, sees) {
107
135
  if (!this.#graph.hasNode(id)) {
@@ -91,4 +91,6 @@ export declare class MemoryIndex {
91
91
  commit(tx: MemoryTransaction, opts: CommitOptions): Promise<CommitResult>;
92
92
  forget(ids: readonly string[], sees: readonly string[]): Promise<MemoryNode[]>;
93
93
  }
94
+ /** Whitespace and case are not what makes two memories different. */
95
+ export declare function flatten(text: string): string;
94
96
  //# sourceMappingURL=index.d.ts.map
@@ -383,7 +383,7 @@ export class MemoryIndex {
383
383
  }
384
384
  }
385
385
  /** Whitespace and case are not what makes two memories different. */
386
- function flatten(text) {
386
+ export function flatten(text) {
387
387
  return text.replace(/\s+/g, ' ').trim().toLowerCase();
388
388
  }
389
389
  //# sourceMappingURL=index.js.map
@@ -0,0 +1,65 @@
1
+ import { MemoryStore, type MemoryEmbedding } from './store.ts';
2
+ import { MemoryError } from './types.ts';
3
+ export interface MergeOptions {
4
+ /** fold near-identical memories onto the one already there; on by default */
5
+ dedupe?: boolean;
6
+ /** resolve divergence by taking the highest revision instead of refusing */
7
+ force?: boolean;
8
+ /** plan and report, write nothing */
9
+ dryRun?: boolean;
10
+ }
11
+ export interface MergeSource {
12
+ dir: string;
13
+ /** what the source holds */
14
+ nodes: number;
15
+ edges: number;
16
+ /** what it contributed */
17
+ added: number;
18
+ /** ids the target already had, reconciled rather than duplicated */
19
+ shared: number;
20
+ /** new ids that turned out to be a memory the target already held */
21
+ twins: number;
22
+ files: number;
23
+ }
24
+ export interface MergeConflict {
25
+ id: string;
26
+ /** the source that disagreed */
27
+ dir: string;
28
+ /** revision already in the merge, and the one the source brought */
29
+ mine: number;
30
+ theirs: number;
31
+ text: string;
32
+ }
33
+ export interface MergeReport {
34
+ /** the memory everything was folded into */
35
+ dir: string;
36
+ sources: MergeSource[];
37
+ added: number;
38
+ shared: number;
39
+ twins: number;
40
+ /** edges the merge added, after re-pointing folded ends */
41
+ edges: number;
42
+ files: number;
43
+ embedding?: MemoryEmbedding;
44
+ dryRun: boolean;
45
+ }
46
+ /**
47
+ * Refusal carrying what to look at. Thrown before anything is written, so a
48
+ * conflicted merge leaves the target exactly as it was.
49
+ */
50
+ export declare class MergeConflicts extends MemoryError {
51
+ readonly conflicts: readonly MergeConflict[];
52
+ constructor(conflicts: readonly MergeConflict[]);
53
+ }
54
+ /**
55
+ * Fold `sources` into `target`, left to right.
56
+ *
57
+ * Order is visible: a memory folded onto a twin keeps the id of whichever copy
58
+ * arrived first, so `merge a b` and `merge b a` can differ in which id
59
+ * survives. They never differ in what is remembered.
60
+ *
61
+ * On `dryRun` the target is left with the merge applied in memory and nothing
62
+ * on disk. Report the result and discard the store; do not commit it.
63
+ */
64
+ export declare function mergeMemories(target: MemoryStore, sources: readonly string[], opts?: MergeOptions): Promise<MergeReport>;
65
+ //# sourceMappingURL=merge.d.ts.map
@@ -0,0 +1,379 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { readFile, rm, stat, writeFile } from 'node:fs/promises';
3
+ import { join, resolve } from 'node:path';
4
+ import { hostPath } from "./files.js";
5
+ import { DUPLICATE_SCORE, flatten } from "./index.js";
6
+ import { MANIFEST_FILE, MemoryStore, lockHolder } from "./store.js";
7
+ import { MemoryError } from "./types.js";
8
+ // ---------------------------------------------------------------------------
9
+ // Folding several memories into one
10
+ //
11
+ // Warming a memory is embarrassingly parallel — N runs, N questions, N answers
12
+ // — but the lock is per directory, so N runs cannot share one. They each write
13
+ // their own, and this puts the results back together.
14
+ //
15
+ // Ids are ULIDs and are preserved, which makes the interesting case the one
16
+ // where the same id appears twice: the runs started from a copy of the same
17
+ // memory, so most of what they hold is a shared ancestor rather than a
18
+ // collision. That case reconciles to nothing more than counters, and the
19
+ // counters take the MAX rather than the sum, because merging the same source
20
+ // twice has to be a no-op. Anything that is not a shared ancestor is a real
21
+ // divergence and refuses the whole merge; `force` is the escape hatch, not the
22
+ // default, because picking a winner silently loses a memory.
23
+ //
24
+ // Nothing here embeds. A merge is offline and fast, and the price of that is
25
+ // that every side must already agree on the embedder.
26
+ // ---------------------------------------------------------------------------
27
+ /** How much of a diverged memory's text a conflict report carries. */
28
+ const CLIP = 80;
29
+ /**
30
+ * Refusal carrying what to look at. Thrown before anything is written, so a
31
+ * conflicted merge leaves the target exactly as it was.
32
+ */
33
+ export class MergeConflicts extends MemoryError {
34
+ conflicts;
35
+ constructor(conflicts) {
36
+ super(`${conflicts.length} ${conflicts.length === 1 ? 'memory' : 'memories'} changed in more than one place`, 'compare them with `zen memory show <id>`, or take the newest with --force');
37
+ this.name = 'MergeConflicts';
38
+ this.conflicts = conflicts;
39
+ }
40
+ }
41
+ /**
42
+ * Fold `sources` into `target`, left to right.
43
+ *
44
+ * Order is visible: a memory folded onto a twin keeps the id of whichever copy
45
+ * arrived first, so `merge a b` and `merge b a` can differ in which id
46
+ * survives. They never differ in what is remembered.
47
+ *
48
+ * On `dryRun` the target is left with the merge applied in memory and nothing
49
+ * on disk. Report the result and discard the store; do not commit it.
50
+ */
51
+ export async function mergeMemories(target, sources, opts = {}) {
52
+ const dedupe = opts.dedupe !== false;
53
+ const opened = await openSources(target, sources, opts.force === true);
54
+ const reports = new Map(opened.map((src) => [
55
+ src,
56
+ {
57
+ dir: src.dir,
58
+ nodes: src.graph.order,
59
+ edges: src.graph.size,
60
+ added: 0,
61
+ shared: 0,
62
+ twins: 0,
63
+ files: 0,
64
+ },
65
+ ]));
66
+ const embedding = agree(target, opened);
67
+ if (embedding) {
68
+ target.adopt(embedding);
69
+ }
70
+ // --- plan: everything refusable is refused here, before a single write ---
71
+ const plan = new Map();
72
+ for (const node of target.graph.nodes()) {
73
+ plan.set(node.id, { node, fresh: false });
74
+ }
75
+ const conflicts = [];
76
+ for (const src of opened) {
77
+ const report = reports.get(src);
78
+ for (const node of src.graph.nodes()) {
79
+ const held = plan.get(node.id);
80
+ if (!held) {
81
+ plan.set(node.id, { node, from: src, fresh: true });
82
+ continue;
83
+ }
84
+ if (held.node.revision === node.revision && sameContent(held.node, node)) {
85
+ plan.set(node.id, { ...held, node: reconcile(held.node, node) });
86
+ report.shared++;
87
+ continue;
88
+ }
89
+ if (!opts.force) {
90
+ conflicts.push({
91
+ id: node.id,
92
+ dir: src.dir,
93
+ mine: held.node.revision,
94
+ theirs: node.revision,
95
+ text: clip(node.text),
96
+ });
97
+ continue;
98
+ }
99
+ report.shared++;
100
+ plan.set(node.id, newer(node, held.node)
101
+ ? { node: reconcile(node, held.node), from: src, fresh: held.fresh }
102
+ : { ...held, node: reconcile(held.node, node) });
103
+ }
104
+ }
105
+ if (conflicts.length) {
106
+ throw new MergeConflicts(conflicts);
107
+ }
108
+ await checkFiles(plan);
109
+ // --- fold, in memory ---
110
+ const before = target.graph.size;
111
+ const moved = new Map();
112
+ for (const planned of plan.values()) {
113
+ if (planned.fresh) {
114
+ continue;
115
+ }
116
+ target.graph.adopt(planned.node);
117
+ if (planned.from) {
118
+ carryVector(planned.from, target, planned.node.id);
119
+ }
120
+ }
121
+ const lexical = new Map();
122
+ for (const node of target.graph.nodes()) {
123
+ lexical.set(twinKey(node), node.id);
124
+ }
125
+ for (const src of opened) {
126
+ const report = reports.get(src);
127
+ const stale = target.graph.superseded();
128
+ for (const node of src.graph.nodes()) {
129
+ const planned = plan.get(node.id);
130
+ if (!planned.fresh || planned.from !== src || moved.has(node.id)) {
131
+ continue;
132
+ }
133
+ const twin = dedupe ? twinOf(target, src, planned.node, lexical, stale) : undefined;
134
+ if (twin) {
135
+ moved.set(node.id, twin);
136
+ report.twins++;
137
+ continue;
138
+ }
139
+ target.graph.adopt(planned.node);
140
+ carryVector(src, target, node.id);
141
+ lexical.set(twinKey(planned.node), node.id);
142
+ moved.set(node.id, node.id);
143
+ report.added++;
144
+ }
145
+ for (const link of src.graph.links()) {
146
+ const from = moved.get(link.source) ?? link.source;
147
+ const to = moved.get(link.target) ?? link.target;
148
+ // A self-edge is what a fold leaves behind when both ends collapsed
149
+ // onto the same memory; it says nothing and is dropped.
150
+ if (from === to || !target.graph.has(from) || !target.graph.has(to)) {
151
+ continue;
152
+ }
153
+ target.graph.adoptEdge(from, to, link.attrs);
154
+ }
155
+ }
156
+ const report = {
157
+ dir: target.dir,
158
+ sources: opened.map((src) => reports.get(src)),
159
+ added: 0,
160
+ shared: 0,
161
+ twins: 0,
162
+ edges: target.graph.size - before,
163
+ files: 0,
164
+ ...(target.embedding ? { embedding: target.embedding } : {}),
165
+ dryRun: opts.dryRun === true,
166
+ };
167
+ if (opts.dryRun) {
168
+ for (const [id, planned] of plan) {
169
+ if (copies(id, planned, moved)) {
170
+ reports.get(planned.from).files++;
171
+ }
172
+ }
173
+ return total(report);
174
+ }
175
+ await copyFiles(target, plan, moved, reports);
176
+ await target.commit();
177
+ return total(report);
178
+ }
179
+ async function openSources(target, sources, force) {
180
+ if (!sources.length) {
181
+ throw new MemoryError('no memories to merge', 'name at least one memory directory');
182
+ }
183
+ const here = resolve(target.dir);
184
+ const seen = new Set();
185
+ const out = [];
186
+ for (const dir of sources) {
187
+ const path = resolve(dir);
188
+ if (path === here) {
189
+ throw new MemoryError(`${dir} is the memory being merged into`, 'name only the memories to fold in');
190
+ }
191
+ if (seen.has(path)) {
192
+ throw new MemoryError(`${dir} was named twice`, 'name each memory once');
193
+ }
194
+ seen.add(path);
195
+ // `MemoryStore.open` creates what is missing, so a mistyped path would
196
+ // otherwise merge an empty memory it had just brought into existence.
197
+ try {
198
+ await stat(join(path, MANIFEST_FILE));
199
+ }
200
+ catch {
201
+ throw new MemoryError(`${dir} is not a memory`, `no ${MANIFEST_FILE} in it`);
202
+ }
203
+ const held = lockHolder(path);
204
+ if (held && !force) {
205
+ throw new MemoryError(`${dir} is in use (pid ${held.pid}, since ${held.startedAt})`, 'wait for that run to finish, or merge with --force');
206
+ }
207
+ out.push(await MemoryStore.open(path, { lock: false }));
208
+ }
209
+ return out;
210
+ }
211
+ /**
212
+ * The one embedder everything must already share. Sources that hold nothing are
213
+ * not asked: a warmup run that found nothing worth keeping never adopted one.
214
+ */
215
+ function agree(target, sources) {
216
+ const holding = sources.filter((src) => src.graph.order > 0);
217
+ const chosen = target.embedding ?? holding.find((src) => src.embedding)?.embedding;
218
+ for (const src of holding) {
219
+ const theirs = src.embedding;
220
+ if (chosen && theirs) {
221
+ if (theirs.model !== chosen.model || theirs.dimensions !== chosen.dimensions) {
222
+ throw new MemoryError(`${src.dir} was embedded with ${theirs.model} (${theirs.dimensions}d), ` +
223
+ `but this merge is ${chosen.model} (${chosen.dimensions}d)`, 'merge memories warmed with the same embedder');
224
+ }
225
+ }
226
+ else if (chosen) {
227
+ throw new MemoryError(`${src.dir} has no embedder, but this merge is ${chosen.model} (${chosen.dimensions}d)`, 'its memories would be invisible to search; re-warm it with that embedder');
228
+ }
229
+ }
230
+ return chosen;
231
+ }
232
+ /**
233
+ * The existing twin rule, applied across memories: same kind, same audience, no
234
+ * file, not superseded, and either near-identical by cosine or identical once
235
+ * whitespace and case stop counting.
236
+ */
237
+ function twinOf(target, src, node, lexical, stale) {
238
+ if (node.file) {
239
+ return undefined;
240
+ }
241
+ const vector = src.vectors?.get(node.id);
242
+ const block = target.vectors;
243
+ if (vector && block && block.dims === vector.length && block.rows) {
244
+ const allow = (id) => {
245
+ const other = target.graph.get(id);
246
+ return !stale.has(id) && !!other && comparable(other, node);
247
+ };
248
+ const hit = block.topK(vector, 1, allow)[0];
249
+ return hit && hit.score >= DUPLICATE_SCORE ? hit.id : undefined;
250
+ }
251
+ const id = lexical.get(twinKey(node));
252
+ return id && !stale.has(id) ? id : undefined;
253
+ }
254
+ function comparable(a, b) {
255
+ return a.id !== b.id && a.kind === b.kind && !a.file && audienceKey(a) === audienceKey(b);
256
+ }
257
+ function twinKey(node) {
258
+ return [node.kind, audienceKey(node), flatten(node.text)].join('\u0000');
259
+ }
260
+ function audienceKey(node) {
261
+ return [...node.audience].sort().join('|');
262
+ }
263
+ function carryVector(from, to, id) {
264
+ const vector = from.vectors?.get(id);
265
+ const block = to.vectors;
266
+ if (vector && block && block.dims === vector.length) {
267
+ block.set(id, vector);
268
+ }
269
+ }
270
+ /**
271
+ * Use is not change: `touch` moves these and deliberately leaves `revision`
272
+ * alone, which is what lets two runs of the same memory reconcile instead of
273
+ * colliding. MAX and not sum, so merging a source twice changes nothing.
274
+ */
275
+ function reconcile(keep, other) {
276
+ return {
277
+ ...keep,
278
+ // ISO-8601 in UTC is fixed width, so string order is time order.
279
+ createdAt: keep.createdAt <= other.createdAt ? keep.createdAt : other.createdAt,
280
+ lastUsedAt: keep.lastUsedAt >= other.lastUsedAt ? keep.lastUsedAt : other.lastUsedAt,
281
+ useCount: Math.max(keep.useCount, other.useCount),
282
+ };
283
+ }
284
+ function newer(a, b) {
285
+ return a.revision === b.revision ? a.updatedAt > b.updatedAt : a.revision > b.revision;
286
+ }
287
+ function sameContent(a, b) {
288
+ return (a.kind === b.kind &&
289
+ a.text === b.text &&
290
+ audienceKey(a) === audienceKey(b) &&
291
+ a.file?.sha256 === b.file?.sha256 &&
292
+ deep(a.metadata, b.metadata));
293
+ }
294
+ function deep(a, b) {
295
+ if (a === b) {
296
+ return true;
297
+ }
298
+ if (typeof a !== 'object' || typeof b !== 'object' || a === null || b === null) {
299
+ return false;
300
+ }
301
+ if (Array.isArray(a) !== Array.isArray(b)) {
302
+ return false;
303
+ }
304
+ const left = a;
305
+ const right = b;
306
+ const keys = Object.keys(left);
307
+ return keys.length === Object.keys(right).length && keys.every((k) => deep(left[k], right[k]));
308
+ }
309
+ /** Stat every file the merge would copy before it copies any of them. */
310
+ async function checkFiles(plan) {
311
+ for (const planned of plan.values()) {
312
+ const file = planned.node.file;
313
+ if (!planned.from || !file) {
314
+ continue;
315
+ }
316
+ const source = hostPath(planned.from.dir, file);
317
+ const info = await stat(source).catch(() => undefined);
318
+ if (!info?.isFile()) {
319
+ throw new MemoryError(`${file.path} is missing from ${planned.from.dir}`, 'that memory is incomplete; re-run it, or forget the node that names the file');
320
+ }
321
+ }
322
+ }
323
+ /**
324
+ * Bytes last, and all or nothing: the digest is checked against the one the
325
+ * node records, and anything written before a failure is taken back, so a
326
+ * refused merge cannot leave files no graph mentions.
327
+ */
328
+ async function copyFiles(target, plan, moved, reports) {
329
+ const written = [];
330
+ try {
331
+ for (const [id, planned] of plan) {
332
+ const file = planned.node.file;
333
+ if (!copies(id, planned, moved)) {
334
+ continue;
335
+ }
336
+ const source = hostPath(planned.from.dir, file);
337
+ const destination = hostPath(target.dir, file);
338
+ if (source === destination) {
339
+ continue;
340
+ }
341
+ const bytes = await readFile(source);
342
+ const sha = createHash('sha256').update(bytes).digest('hex');
343
+ if (sha !== file.sha256) {
344
+ throw new MemoryError(`${file.path} in ${planned.from.dir} does not match the digest its memory records`, 'that memory was modified outside zen; merge it after re-running it');
345
+ }
346
+ const existed = await stat(destination).then(() => true, () => false);
347
+ await writeFile(destination, bytes);
348
+ if (!existed) {
349
+ written.push(destination);
350
+ }
351
+ reports.get(planned.from).files++;
352
+ }
353
+ }
354
+ catch (err) {
355
+ await Promise.all(written.map((path) => rm(path, { force: true })));
356
+ throw err;
357
+ }
358
+ }
359
+ /** A file comes across when its node came from a source and survived the fold. */
360
+ function copies(id, planned, moved) {
361
+ if (!planned.from || !planned.node.file) {
362
+ return false;
363
+ }
364
+ return !planned.fresh || moved.get(id) === id;
365
+ }
366
+ function total(report) {
367
+ for (const src of report.sources) {
368
+ report.added += src.added;
369
+ report.shared += src.shared;
370
+ report.twins += src.twins;
371
+ report.files += src.files;
372
+ }
373
+ return report;
374
+ }
375
+ function clip(text) {
376
+ const flat = flatten(text);
377
+ return flat.length > CLIP ? `${flat.slice(0, CLIP - 1)}…` : flat;
378
+ }
379
+ //# sourceMappingURL=merge.js.map
@@ -19,6 +19,11 @@ export interface MemoryManifest {
19
19
  edges: number;
20
20
  updatedAt: string;
21
21
  }
22
+ export interface Lock {
23
+ pid: number;
24
+ host: string;
25
+ startedAt: string;
26
+ }
22
27
  export interface OpenOptions {
23
28
  /** the embedder the caller intends to use; a mismatch with the manifest is fatal */
24
29
  embedding?: MemoryEmbedding;
@@ -46,4 +51,10 @@ export declare class MemoryStore {
46
51
  commit(): Promise<void>;
47
52
  release(): void;
48
53
  }
54
+ /**
55
+ * Who is writing a memory right now, if anyone. Read-only on purpose: it never
56
+ * claims the lock and never clears a stale one, so it is safe to ask about a
57
+ * directory this process has no intention of opening for writing.
58
+ */
59
+ export declare function lockHolder(dir: string): Lock | undefined;
49
60
  //# sourceMappingURL=store.d.ts.map
@@ -202,6 +202,15 @@ async function atomic(path, body) {
202
202
  throw err;
203
203
  }
204
204
  }
205
+ /**
206
+ * Who is writing a memory right now, if anyone. Read-only on purpose: it never
207
+ * claims the lock and never clears a stale one, so it is safe to ask about a
208
+ * directory this process has no intention of opening for writing.
209
+ */
210
+ export function lockHolder(dir) {
211
+ const held = readLock(join(dir, LOCK_FILE));
212
+ return held && held.host === hostname() && alive(held.pid) ? held : undefined;
213
+ }
205
214
  function readLock(path) {
206
215
  try {
207
216
  return JSON.parse(readFileSync(path, 'utf8'));
@@ -9,6 +9,8 @@ export declare class VectorBlock {
9
9
  get rows(): number;
10
10
  get ids(): readonly string[];
11
11
  has(id: string): boolean;
12
+ /** A copy, not a view: the row it came from moves the next time something is deleted. */
13
+ get(id: string): Float32Array | undefined;
12
14
  set(id: string, vector: ArrayLike<number>): void;
13
15
  remove(id: string): boolean;
14
16
  /** Exact kNN over the rows `allow` accepts. Ties break toward the earlier row. */
@@ -39,6 +39,13 @@ export class VectorBlock {
39
39
  has(id) {
40
40
  return this.#index.has(id);
41
41
  }
42
+ /** A copy, not a view: the row it came from moves the next time something is deleted. */
43
+ get(id) {
44
+ const row = this.#index.get(id);
45
+ return row === undefined
46
+ ? undefined
47
+ : this.#data.slice(row * this.#dims, (row + 1) * this.#dims);
48
+ }
42
49
  set(id, vector) {
43
50
  if (vector.length !== this.#dims) {
44
51
  throw new Error(`expected a ${this.#dims}-dimension vector, got ${vector.length}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zenera/neo",
3
- "version": "1.1.22",
3
+ "version": "1.1.23",
4
4
  "description": "A multi-agent runtime for Node.js: agents, models, tools, skills, memory and an append-only trajectory.",
5
5
  "keywords": [
6
6
  "agents",