@zenera/neo 1.1.22 → 1.1.24

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.
Files changed (42) hide show
  1. package/dist/embeddings/rate-limit.d.ts +7 -0
  2. package/dist/embeddings/rate-limit.js +33 -0
  3. package/dist/index.d.ts +2 -0
  4. package/dist/index.js +2 -0
  5. package/dist/inspect/client.d.ts +1 -1
  6. package/dist/inspect/client.js +7 -2
  7. package/dist/memory/graph.d.ts +14 -0
  8. package/dist/memory/graph.js +28 -0
  9. package/dist/memory/grep.d.ts +57 -0
  10. package/dist/memory/grep.js +182 -0
  11. package/dist/memory/index.d.ts +12 -2
  12. package/dist/memory/index.js +16 -5
  13. package/dist/memory/match.d.ts +18 -0
  14. package/dist/memory/match.js +61 -0
  15. package/dist/memory/merge.d.ts +65 -0
  16. package/dist/memory/merge.js +379 -0
  17. package/dist/memory/recall.d.ts +1 -1
  18. package/dist/memory/recall.js +1 -1
  19. package/dist/memory/store.d.ts +18 -0
  20. package/dist/memory/store.js +31 -4
  21. package/dist/memory/tools.js +105 -9
  22. package/dist/memory/types.d.ts +7 -2
  23. package/dist/memory/vectors.d.ts +2 -0
  24. package/dist/memory/vectors.js +7 -0
  25. package/dist/models/factory.d.ts +43 -2
  26. package/dist/models/factory.js +108 -1
  27. package/dist/project/config.js +1 -1
  28. package/dist/project/load.d.ts +7 -0
  29. package/dist/project/load.js +29 -6
  30. package/dist/tools/exa.js +1 -1
  31. package/dist/tools/files.d.ts +81 -0
  32. package/dist/tools/files.js +1472 -0
  33. package/dist/tools/index.d.ts +1 -1
  34. package/dist/tools/index.js +1 -1
  35. package/dist/tools/sandbox.d.ts +44 -0
  36. package/dist/tools/sandbox.js +122 -3
  37. package/dist/tools/workspace.js +81 -3
  38. package/dist/trajectory.d.ts +19 -1
  39. package/dist/trajectory.js +53 -0
  40. package/dist/types.d.ts +1 -0
  41. package/dist/types.js +1 -0
  42. package/package.json +1 -1
@@ -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
@@ -1,7 +1,7 @@
1
1
  import type { MemoryGraph } from './graph.ts';
2
2
  import { type MemoryQuery, type Recollection, type ScoredNode } from './types.ts';
3
3
  import { type VectorBlock } from './vectors.ts';
4
- export declare const DEFAULT_SEEDS = 5;
4
+ export declare const DEFAULT_SEEDS = 10;
5
5
  export declare const DEFAULT_MAX_HOPS = 2;
6
6
  /** Past roughly this many nodes a rendered graph reads as soup, not structure. */
7
7
  export declare const DEFAULT_MAX_NODES = 25;
@@ -17,7 +17,7 @@ import { overlap, recencyDecay, tokenize } from "./vectors.js";
17
17
  // it replaced from every recall, while the history stays on disk for anyone
18
18
  // asking how the correction came about.
19
19
  // ---------------------------------------------------------------------------
20
- export const DEFAULT_SEEDS = 5;
20
+ export const DEFAULT_SEEDS = 10;
21
21
  export const DEFAULT_MAX_HOPS = 2;
22
22
  /** Past roughly this many nodes a rendered graph reads as soup, not structure. */
23
23
  export const DEFAULT_MAX_NODES = 25;
@@ -19,16 +19,28 @@ 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;
25
30
  /** take the directory lock; off for read-only inspection (`zen memory ls`) */
26
31
  lock?: boolean;
32
+ /**
33
+ * Recall, and nothing else. Writing is refused rather than dropped, the
34
+ * directory is never created, and no lock is taken — so any number of runs
35
+ * may share one graph at once, which is the whole point of it.
36
+ */
37
+ readOnly?: boolean;
27
38
  }
28
39
  export declare class MemoryStore {
29
40
  #private;
30
41
  readonly dir: string;
31
42
  readonly graph: MemoryGraph;
43
+ readonly readOnly: boolean;
32
44
  private constructor();
33
45
  static open(dir: string, opts?: OpenOptions): Promise<MemoryStore>;
34
46
  get embedding(): MemoryEmbedding | undefined;
@@ -46,4 +58,10 @@ export declare class MemoryStore {
46
58
  commit(): Promise<void>;
47
59
  release(): void;
48
60
  }
61
+ /**
62
+ * Who is writing a memory right now, if anyone. Read-only on purpose: it never
63
+ * claims the lock and never clears a stale one, so it is safe to ask about a
64
+ * directory this process has no intention of opening for writing.
65
+ */
66
+ export declare function lockHolder(dir: string): Lock | undefined;
49
67
  //# sourceMappingURL=store.d.ts.map
@@ -34,18 +34,29 @@ export const LOCK_FILE = '.lock';
34
34
  export class MemoryStore {
35
35
  dir;
36
36
  graph;
37
+ readOnly;
37
38
  #embedding;
38
39
  #vectors;
39
40
  #locked = false;
40
- constructor(dir, graph, vectors, embedding) {
41
+ constructor(dir, graph, vectors, embedding, readOnly) {
41
42
  this.dir = dir;
42
43
  this.graph = graph;
43
44
  this.#vectors = vectors;
44
45
  this.#embedding = embedding;
46
+ this.readOnly = readOnly;
45
47
  }
46
48
  static async open(dir, opts = {}) {
47
- await mkdir(join(dir, FILES_DIR), { recursive: true });
49
+ const readOnly = opts.readOnly === true;
50
+ if (!readOnly) {
51
+ await mkdir(join(dir, FILES_DIR), { recursive: true });
52
+ }
48
53
  const manifest = await readManifest(dir);
54
+ // Opening for writing brings a memory into existence; opening one to
55
+ // read from has to find it, or a mistyped path is an empty graph that
56
+ // answers every question with silence and reports no error.
57
+ if (readOnly && !manifest) {
58
+ throw new MemoryError(`${dir} is not a memory`, `no ${MANIFEST_FILE} in it, and a read-only open cannot create one`);
59
+ }
49
60
  if (manifest?.embedding && opts.embedding) {
50
61
  const a = manifest.embedding;
51
62
  const b = opts.embedding;
@@ -59,8 +70,10 @@ export class MemoryStore {
59
70
  : new MemoryGraph();
60
71
  const embedding = opts.embedding ?? manifest?.embedding;
61
72
  const vectors = manifest && embedding ? await readVectors(dir, embedding) : undefined;
62
- const store = new MemoryStore(dir, graph, vectors, embedding);
63
- if (opts.lock !== false) {
73
+ const store = new MemoryStore(dir, graph, vectors, embedding, readOnly);
74
+ // A reader that claimed the lock would only be refusing itself, and the
75
+ // readers of one graph are meant to be able to refuse each other least.
76
+ if (!readOnly && opts.lock !== false) {
64
77
  store.#claim();
65
78
  }
66
79
  return store;
@@ -97,6 +110,11 @@ export class MemoryStore {
97
110
  }
98
111
  /** Manifest last: a torn commit reads as no memory, never as a partial one. */
99
112
  async commit() {
113
+ // Loudly, not quietly. A write that is silently dropped is a run that
114
+ // believes it remembered something and did not.
115
+ if (this.readOnly) {
116
+ throw new MemoryError('this memory is open read-only', 'nothing this run learns can be kept — point it at a memory of its own');
117
+ }
100
118
  await atomic(join(this.dir, GRAPH_FILE), JSON.stringify(this.graph.export()));
101
119
  const vectors = this.#vectors;
102
120
  if (vectors) {
@@ -202,6 +220,15 @@ async function atomic(path, body) {
202
220
  throw err;
203
221
  }
204
222
  }
223
+ /**
224
+ * Who is writing a memory right now, if anyone. Read-only on purpose: it never
225
+ * claims the lock and never clears a stale one, so it is safe to ask about a
226
+ * directory this process has no intention of opening for writing.
227
+ */
228
+ export function lockHolder(dir) {
229
+ const held = readLock(join(dir, LOCK_FILE));
230
+ return held && held.host === hostname() && alive(held.pid) ? held : undefined;
231
+ }
205
232
  function readLock(path) {
206
233
  try {
207
234
  return JSON.parse(readFileSync(path, 'utf8'));
@@ -1,25 +1,30 @@
1
1
  /**
2
- * The four tools an agent gets. Deliberately four and not seven: `search` and
3
- * `load` are split because finding is not reading — search returns stitched
4
- * context the model never asked for, and counting that as use would poison
5
- * recency. `commit` is one transaction because a remembered thing is a
6
- * subgraph, and building it with three calls leaves the graph half-formed if
7
- * the model stops early. `forget` is separate because deleting rarely happens
8
- * in the same breath as creating, and a `forget` buried inside a truncated
9
- * commit preview would be both irreversible and invisible.
2
+ * The five tools an agent gets. `search` and `load` are split because finding
3
+ * is not reading — search returns stitched context the model never asked for,
4
+ * and counting that as use would poison recency. `grep` is separate from
5
+ * `search` because exactness is not a tuning of nearness: a ranking returns the
6
+ * top of a list, so it can say what is closest but never that nothing is there.
7
+ * `commit` is one transaction because a remembered thing is a subgraph, and
8
+ * building it with three calls leaves the graph half-formed if the model stops
9
+ * early. `forget` is separate because deleting rarely happens in the same
10
+ * breath as creating, and a `forget` buried inside a truncated commit preview
11
+ * would be both irreversible and invisible.
10
12
  *
11
13
  * `audience` reaches the schema only when the binding grants more than one
12
14
  * label, and `sees` never does: an agent that could name its own audience
13
15
  * could read another agent's slice just by asking for it.
14
16
  */
15
17
  import { hash } from "../payload.js";
16
- import { MEMORY_COMMIT_TOOL, MEMORY_FORGET_TOOL, MEMORY_LOAD_TOOL, MEMORY_SEARCH_TOOL, tool, withEffects, } from "../types.js";
18
+ import { MEMORY_COMMIT_TOOL, MEMORY_FORGET_TOOL, MEMORY_GREP_TOOL, MEMORY_LOAD_TOOL, MEMORY_SEARCH_TOOL, tool, withEffects, } from "../types.js";
19
+ import { GREP_FIELDS, grepMemory } from "./grep.js";
17
20
  import { renderRecollection } from "./render.js";
18
21
  import { canForget, canWrite, KIND_HELP, MemoryError, RELATION_HELP, } from "./types.js";
19
22
  /** Deterministic, so a replayed call is the same op and not a second one. */
20
23
  export function memoryOpId(runId, callId) {
21
24
  return hash(`${runId}\u0000${callId}`);
22
25
  }
26
+ /** Enough nodes to settle a question, few enough not to be a context dump. */
27
+ const GREPPED = 20;
23
28
  export function memoryTools(opts) {
24
29
  const { index, binding } = opts;
25
30
  const kinds = [...index.kinds];
@@ -131,6 +136,97 @@ export function memoryTools(opts) {
131
136
  })), { kind: 'memory_op', spec });
132
137
  },
133
138
  }),
139
+ tool({
140
+ name: MEMORY_GREP_TOOL,
141
+ description: 'Find an exact string in memory, the way grep does: every node whose text, ' +
142
+ 'metadata or remembered file contains it, with the matching lines. Use this ' +
143
+ `instead of ${MEMORY_SEARCH_TOOL} when the answer has to be complete rather ` +
144
+ 'than close — whether something was already recorded, or where a name, path, ' +
145
+ 'id or command appears. This is the only correct way to search the memory ' +
146
+ 'directly; never run a shell grep over /memory. Read a whole node with ' +
147
+ `${MEMORY_LOAD_TOOL}.`,
148
+ parameters: {
149
+ type: 'object',
150
+ properties: {
151
+ pattern: {
152
+ type: 'string',
153
+ description: 'the exact text to look for; matched case-insensitively',
154
+ },
155
+ regex: {
156
+ type: 'boolean',
157
+ description: 'read the pattern as a regular expression, matched per line',
158
+ },
159
+ case_sensitive: { type: 'boolean', description: 'match case exactly' },
160
+ kinds: {
161
+ type: 'array',
162
+ items: { type: 'string', enum: kinds },
163
+ description: 'restrict to these kinds',
164
+ },
165
+ in: {
166
+ type: 'array',
167
+ items: { type: 'string', enum: [...GREP_FIELDS] },
168
+ description: 'which parts to look in; all of them by default',
169
+ },
170
+ include_superseded: {
171
+ type: 'boolean',
172
+ description: 'also search nodes that have been corrected by a newer one; they come back marked stale',
173
+ },
174
+ limit: { type: 'integer', description: `nodes to return; default ${GREPPED}` },
175
+ },
176
+ required: ['pattern'],
177
+ additionalProperties: false,
178
+ },
179
+ execute: async (args, tc) => {
180
+ const opId = memoryOpId(tc.state.runId, tc.callId);
181
+ let res;
182
+ try {
183
+ res = await grepMemory(index.store, args.pattern, {
184
+ sees: binding.sees,
185
+ regex: args.regex,
186
+ caseSensitive: args.case_sensitive,
187
+ kinds: args.kinds,
188
+ in: args.in,
189
+ stale: args.include_superseded ? 'include' : 'exclude',
190
+ limit: args.limit ?? GREPPED,
191
+ });
192
+ }
193
+ catch (err) {
194
+ return refusal(err);
195
+ }
196
+ // A complete answer, so nothing found is information rather than
197
+ // a failed lookup — that is the whole reason this tool exists.
198
+ if (!res.found && !res.skipped.length) {
199
+ return `nothing in memory contains ${JSON.stringify(args.pattern)}`;
200
+ }
201
+ const spec = {
202
+ kind: 'op',
203
+ op: 'grep',
204
+ opId,
205
+ nodes: res.matches.map((m) => ({
206
+ id: m.node.id,
207
+ kind: m.node.kind,
208
+ revision: m.node.revision,
209
+ })),
210
+ edges: [],
211
+ files: 0,
212
+ };
213
+ return withEffects({
214
+ found: res.found,
215
+ truncated: res.truncated || undefined,
216
+ matches: res.matches.map((m) => ({
217
+ id: m.node.id,
218
+ kind: m.node.kind,
219
+ stale: m.stale || undefined,
220
+ file: m.node.file?.path,
221
+ hits: m.hits.map((h) => ({ in: h.where, line: h.line, text: h.text })),
222
+ more: m.more || undefined,
223
+ })),
224
+ // Named, because a file that was not read means a hit in
225
+ // it would not have been reported either.
226
+ unsearched: res.skipped.length ? res.skipped : undefined,
227
+ }, { kind: 'memory_op', spec });
228
+ },
229
+ }),
134
230
  ];
135
231
  if (!canWrite(binding)) {
136
232
  return tools;
@@ -169,8 +169,13 @@ export interface MemoryRecallSpec {
169
169
  }
170
170
  export interface MemoryOpSpec {
171
171
  kind: 'op';
172
- /** `load` is here rather than under recall: it hands the model whole bodies, and it writes. */
173
- op: 'commit' | 'forget' | 'load';
172
+ /**
173
+ * `load` is here rather than under recall: it hands the model whole bodies,
174
+ * and it writes. `grep` is here because it is not a recall either — there is
175
+ * no query vector, no subgraph and no ranking to record, only the nodes a
176
+ * pattern happened to hit.
177
+ */
178
+ op: 'commit' | 'forget' | 'load' | 'grep';
174
179
  /** sha256(runId, callId) — deterministic, so a replay is deduplicated */
175
180
  opId: string;
176
181
  /** nodes this op created, changed, removed or read in full */
@@ -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. */