@zenera/neo 1.1.11 → 1.1.14

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 (68) hide show
  1. package/README.md +3 -3
  2. package/dist/agent.d.ts +8 -7
  3. package/dist/agent.js +15 -8
  4. package/dist/architecture.d.ts +7 -5
  5. package/dist/architecture.js +20 -12
  6. package/dist/embeddings/limiter.js +14 -3
  7. package/dist/index.d.ts +12 -2
  8. package/dist/index.js +12 -2
  9. package/dist/inspect/client.d.ts +1 -1
  10. package/dist/inspect/client.js +440 -26
  11. package/dist/inspect/css.d.ts +1 -1
  12. package/dist/inspect/css.js +49 -0
  13. package/dist/inspect/index.d.ts +3 -3
  14. package/dist/inspect/index.js +1 -1
  15. package/dist/inspect/page.js +16 -0
  16. package/dist/inspect/report.d.ts +25 -0
  17. package/dist/inspect/report.js +45 -0
  18. package/dist/kernel.d.ts +1 -1
  19. package/dist/kernel.js +40 -23
  20. package/dist/memory/files.d.ts +24 -0
  21. package/dist/memory/files.js +85 -0
  22. package/dist/memory/graph.d.ts +56 -0
  23. package/dist/memory/graph.js +159 -0
  24. package/dist/memory/index.d.ts +93 -0
  25. package/dist/memory/index.js +357 -0
  26. package/dist/memory/instructions.d.ts +15 -0
  27. package/dist/memory/instructions.js +103 -0
  28. package/dist/memory/page.d.ts +8 -0
  29. package/dist/memory/page.js +797 -0
  30. package/dist/memory/recall.d.ts +35 -0
  31. package/dist/memory/recall.js +161 -0
  32. package/dist/memory/render.d.ts +10 -0
  33. package/dist/memory/render.js +228 -0
  34. package/dist/memory/report.d.ts +69 -0
  35. package/dist/memory/report.js +131 -0
  36. package/dist/memory/store.d.ts +49 -0
  37. package/dist/memory/store.js +226 -0
  38. package/dist/memory/tools.d.ts +11 -0
  39. package/dist/memory/tools.js +337 -0
  40. package/dist/memory/types.d.ts +193 -0
  41. package/dist/memory/types.js +105 -0
  42. package/dist/memory/vectors.d.ts +29 -0
  43. package/dist/memory/vectors.js +152 -0
  44. package/dist/project/config.d.ts +49 -1
  45. package/dist/project/config.js +63 -0
  46. package/dist/project/index.d.ts +2 -2
  47. package/dist/project/index.js +1 -1
  48. package/dist/project/load.d.ts +16 -2
  49. package/dist/project/load.js +67 -1
  50. package/dist/runner.d.ts +4 -2
  51. package/dist/runner.js +29 -30
  52. package/dist/services.d.ts +23 -6
  53. package/dist/services.js +25 -15
  54. package/dist/tools/workspace.js +195 -17
  55. package/dist/trajectory.d.ts +14 -12
  56. package/dist/types.d.ts +5 -5
  57. package/dist/types.js +3 -3
  58. package/package.json +2 -5
  59. package/dist/memory-stores/factory.d.ts +0 -15
  60. package/dist/memory-stores/factory.js +0 -29
  61. package/dist/memory-stores/file.d.ts +0 -37
  62. package/dist/memory-stores/file.js +0 -174
  63. package/dist/memory-stores/in-memory.d.ts +0 -17
  64. package/dist/memory-stores/in-memory.js +0 -80
  65. package/dist/memory-stores/index.d.ts +0 -4
  66. package/dist/memory-stores/index.js +0 -5
  67. package/dist/memory.d.ts +0 -115
  68. package/dist/memory.js +0 -213
@@ -0,0 +1,226 @@
1
+ import { readFileSync, unlinkSync, writeFileSync } from 'node:fs';
2
+ import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
3
+ import { hostname } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import { MemoryGraph } from "./graph.js";
6
+ import { MemoryError } from "./types.js";
7
+ import { VectorBlock } from "./vectors.js";
8
+ // ---------------------------------------------------------------------------
9
+ // What a memory is, on disk
10
+ //
11
+ // A directory, and the order it is written in is the whole crash story: the
12
+ // manifest goes last, so a half-written memory has no manifest and reads as
13
+ // empty rather than as a graph that quietly lost its vectors. Every file lands
14
+ // through a temporary name and a rename, which on a local filesystem is atomic,
15
+ // so a reader never sees half a graph.
16
+ //
17
+ // The lock is advisory and per directory. Memory is project-scoped, and two
18
+ // runs of the same project writing the same graph would interleave commits and
19
+ // lose edges — but a lock whose process is gone is stale by definition and gets
20
+ // taken, because a crashed run must not make a project permanently unusable.
21
+ //
22
+ // Vectors live beside the graph rather than inside it. `graph.json` is parsed
23
+ // whole on open and a few thousand embeddings inflate to megabytes of JSON
24
+ // numbers; the same data as raw little-endian float32 is a quarter the size and
25
+ // needs no parsing at all.
26
+ // ---------------------------------------------------------------------------
27
+ export const MEMORY_VERSION = 1;
28
+ export const GRAPH_FILE = 'graph.json';
29
+ export const VECTORS_FILE = 'vectors.f32';
30
+ export const VECTOR_IDS_FILE = 'vectors.json';
31
+ export const MANIFEST_FILE = 'manifest.json';
32
+ export const FILES_DIR = 'files';
33
+ export const LOCK_FILE = '.lock';
34
+ export class MemoryStore {
35
+ dir;
36
+ graph;
37
+ #embedding;
38
+ #vectors;
39
+ #locked = false;
40
+ constructor(dir, graph, vectors, embedding) {
41
+ this.dir = dir;
42
+ this.graph = graph;
43
+ this.#vectors = vectors;
44
+ this.#embedding = embedding;
45
+ }
46
+ static async open(dir, opts = {}) {
47
+ await mkdir(join(dir, FILES_DIR), { recursive: true });
48
+ const manifest = await readManifest(dir);
49
+ if (manifest?.embedding && opts.embedding) {
50
+ const a = manifest.embedding;
51
+ const b = opts.embedding;
52
+ if (a.model !== b.model || a.dimensions !== b.dimensions) {
53
+ throw new MemoryError(`this memory was embedded with ${a.model} (${a.dimensions}d), ` +
54
+ `but ${b.model} (${b.dimensions}d) is configured`, 're-embed it with `zen memory reembed`, or restore the original model');
55
+ }
56
+ }
57
+ const graph = manifest
58
+ ? MemoryGraph.from(JSON.parse(await readFile(join(dir, GRAPH_FILE), 'utf8')))
59
+ : new MemoryGraph();
60
+ const embedding = opts.embedding ?? manifest?.embedding;
61
+ const vectors = manifest && embedding ? await readVectors(dir, embedding) : undefined;
62
+ const store = new MemoryStore(dir, graph, vectors, embedding);
63
+ if (opts.lock !== false) {
64
+ store.#claim();
65
+ }
66
+ return store;
67
+ }
68
+ get embedding() {
69
+ return this.#embedding;
70
+ }
71
+ /**
72
+ * Records the vectoriser actually in use. A width is only knowable from a
73
+ * real response, so a memory embedded for the first time learns it here
74
+ * rather than making the caller declare a number it would have to keep in
75
+ * step with the model.
76
+ */
77
+ adopt(embedding) {
78
+ const held = this.#embedding;
79
+ if (held) {
80
+ if (held.model !== embedding.model || held.dimensions !== embedding.dimensions) {
81
+ throw new MemoryError(`this memory was embedded with ${held.model} (${held.dimensions}d), ` +
82
+ `but ${embedding.model} (${embedding.dimensions}d) is configured`, 're-embed it with `zen memory reembed`, or restore the original model');
83
+ }
84
+ return;
85
+ }
86
+ this.#embedding = embedding;
87
+ }
88
+ /** Absent when no embedder is configured; recall then falls back to term overlap. */
89
+ get vectors() {
90
+ if (!this.#vectors && this.#embedding) {
91
+ this.#vectors = new VectorBlock(this.#embedding.dimensions);
92
+ }
93
+ return this.#vectors;
94
+ }
95
+ filePath(name) {
96
+ return join(this.dir, FILES_DIR, name);
97
+ }
98
+ /** Manifest last: a torn commit reads as no memory, never as a partial one. */
99
+ async commit() {
100
+ await atomic(join(this.dir, GRAPH_FILE), JSON.stringify(this.graph.export()));
101
+ const vectors = this.#vectors;
102
+ if (vectors) {
103
+ const raw = vectors.bytes();
104
+ await atomic(join(this.dir, VECTORS_FILE), new Uint8Array(raw.buffer, raw.byteOffset, raw.byteLength));
105
+ await atomic(join(this.dir, VECTOR_IDS_FILE), JSON.stringify({ dims: vectors.dims, ids: vectors.ids }));
106
+ }
107
+ const manifest = {
108
+ version: MEMORY_VERSION,
109
+ ...(this.#embedding ? { embedding: this.#embedding } : {}),
110
+ nodes: this.graph.order,
111
+ edges: this.graph.size,
112
+ updatedAt: new Date().toISOString(),
113
+ };
114
+ await atomic(join(this.dir, MANIFEST_FILE), `${JSON.stringify(manifest, null, 4)}\n`);
115
+ }
116
+ release() {
117
+ if (!this.#locked) {
118
+ return;
119
+ }
120
+ this.#locked = false;
121
+ try {
122
+ unlinkSync(join(this.dir, LOCK_FILE));
123
+ }
124
+ catch {
125
+ // Already gone, or taken over as stale: neither is worth failing a run for.
126
+ }
127
+ }
128
+ /**
129
+ * `wx` makes the create and the check one operation, so two runs racing for
130
+ * the same project cannot both win.
131
+ */
132
+ #claim() {
133
+ const path = join(this.dir, LOCK_FILE);
134
+ const lock = {
135
+ pid: process.pid,
136
+ host: hostname(),
137
+ startedAt: new Date().toISOString(),
138
+ };
139
+ const body = `${JSON.stringify(lock, null, 4)}\n`;
140
+ try {
141
+ writeFileSync(path, body, { flag: 'wx' });
142
+ this.#locked = true;
143
+ return;
144
+ }
145
+ catch (err) {
146
+ if (err.code !== 'EEXIST') {
147
+ throw err;
148
+ }
149
+ }
150
+ const held = readLock(path);
151
+ if (held && held.host === hostname() && alive(held.pid)) {
152
+ throw new MemoryError(`this memory is in use (pid ${held.pid}, since ${held.startedAt})`, 'wait for that run to finish, or remove .lock if it crashed');
153
+ }
154
+ writeFileSync(path, body);
155
+ this.#locked = true;
156
+ }
157
+ }
158
+ async function readManifest(dir) {
159
+ try {
160
+ const manifest = JSON.parse(await readFile(join(dir, MANIFEST_FILE), 'utf8'));
161
+ if (manifest.version !== MEMORY_VERSION) {
162
+ throw new MemoryError(`this memory is version ${manifest.version}, but this build reads version ${MEMORY_VERSION}`, 'export it with the older build, or start a new memory');
163
+ }
164
+ return manifest;
165
+ }
166
+ catch (err) {
167
+ if (err instanceof MemoryError) {
168
+ throw err;
169
+ }
170
+ return undefined;
171
+ }
172
+ }
173
+ async function readVectors(dir, embedding) {
174
+ let ids;
175
+ let dims;
176
+ try {
177
+ const meta = JSON.parse(await readFile(join(dir, VECTOR_IDS_FILE), 'utf8'));
178
+ ids = meta.ids;
179
+ dims = meta.dims;
180
+ }
181
+ catch {
182
+ return new VectorBlock(embedding.dimensions);
183
+ }
184
+ if (dims !== embedding.dimensions) {
185
+ return new VectorBlock(embedding.dimensions);
186
+ }
187
+ const raw = await readFile(join(dir, VECTORS_FILE));
188
+ // Copied rather than viewed: a Buffer from the pool is not guaranteed to sit
189
+ // on a 4-byte boundary, and Float32Array over an unaligned buffer throws.
190
+ const data = new Float32Array(Math.floor(raw.byteLength / 4));
191
+ Buffer.from(data.buffer, data.byteOffset, data.byteLength).set(raw);
192
+ return VectorBlock.load(dims, ids, data);
193
+ }
194
+ async function atomic(path, body) {
195
+ const tmp = `${path}.tmp`;
196
+ await writeFile(tmp, body);
197
+ try {
198
+ await rename(tmp, path);
199
+ }
200
+ catch (err) {
201
+ await rm(tmp, { force: true });
202
+ throw err;
203
+ }
204
+ }
205
+ function readLock(path) {
206
+ try {
207
+ return JSON.parse(readFileSync(path, 'utf8'));
208
+ }
209
+ catch {
210
+ return undefined;
211
+ }
212
+ }
213
+ /**
214
+ * `kill(pid, 0)` sends no signal and only asks whether the process exists.
215
+ * EPERM means it exists and belongs to someone else, which still counts.
216
+ */
217
+ function alive(pid) {
218
+ try {
219
+ process.kill(pid, 0);
220
+ return true;
221
+ }
222
+ catch (err) {
223
+ return err.code === 'EPERM';
224
+ }
225
+ }
226
+ //# sourceMappingURL=store.js.map
@@ -0,0 +1,11 @@
1
+ import { type AnyTool } from '../types.ts';
2
+ import type { MemoryIndex } from './index.ts';
3
+ import { type ResolvedMemoryBinding } from './types.ts';
4
+ /** Deterministic, so a replayed call is the same op and not a second one. */
5
+ export declare function memoryOpId(runId: string, callId: string): string;
6
+ export interface MemoryToolsOptions {
7
+ index: MemoryIndex;
8
+ binding: ResolvedMemoryBinding;
9
+ }
10
+ export declare function memoryTools<TCtx>(opts: MemoryToolsOptions): AnyTool<TCtx>[];
11
+ //# sourceMappingURL=tools.d.ts.map
@@ -0,0 +1,337 @@
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.
10
+ *
11
+ * `audience` reaches the schema only when the binding grants more than one
12
+ * label, and `sees` never does: an agent that could name its own audience
13
+ * could read another agent's slice just by asking for it.
14
+ */
15
+ import { hash } from "../payload.js";
16
+ import { MEMORY_COMMIT_TOOL, MEMORY_FORGET_TOOL, MEMORY_LOAD_TOOL, MEMORY_SEARCH_TOOL, tool, withEffects, } from "../types.js";
17
+ import { renderRecollection } from "./render.js";
18
+ import { canForget, canWrite, KIND_HELP, MemoryError, RELATION_HELP, } from "./types.js";
19
+ /** Deterministic, so a replayed call is the same op and not a second one. */
20
+ export function memoryOpId(runId, callId) {
21
+ return hash(`${runId}\u0000${callId}`);
22
+ }
23
+ export function memoryTools(opts) {
24
+ const { index, binding } = opts;
25
+ const kinds = [...index.kinds];
26
+ const relations = [...index.relations];
27
+ const kindHelp = kinds
28
+ .map((k) => `${k}: ${KIND_HELP[k] ?? ''}`)
29
+ .join('; ');
30
+ const relationHelp = relations
31
+ .map((r) => `${r}: ${RELATION_HELP[r] ?? ''}`)
32
+ .join('; ');
33
+ const tools = [
34
+ tool({
35
+ name: MEMORY_SEARCH_TOOL,
36
+ description: 'Search memory for what was learned on earlier runs. Returns a diagram of ' +
37
+ 'the matching subgraph plus a legend of ids; read a node with ' +
38
+ `${MEMORY_LOAD_TOOL}.`,
39
+ parameters: {
40
+ type: 'object',
41
+ properties: {
42
+ query: { type: 'string', description: 'what you are trying to recall' },
43
+ kinds: {
44
+ type: 'array',
45
+ items: { type: 'string', enum: kinds },
46
+ description: 'restrict to these kinds',
47
+ },
48
+ limit: { type: 'integer', description: 'how many matches to seed from' },
49
+ max_hops: { type: 'integer', description: 'how far to follow links' },
50
+ max_nodes: { type: 'integer', description: 'cap on the returned subgraph' },
51
+ newer_than: {
52
+ type: 'string',
53
+ description: 'ISO timestamp; ignore anything older',
54
+ },
55
+ },
56
+ required: ['query'],
57
+ additionalProperties: false,
58
+ },
59
+ execute: async (args, tc) => {
60
+ const query = {
61
+ text: args.query,
62
+ kinds: args.kinds,
63
+ limit: args.limit,
64
+ maxHops: args.max_hops,
65
+ maxNodes: args.max_nodes,
66
+ newerThan: args.newer_than,
67
+ };
68
+ const rec = await index.search(query, binding.sees);
69
+ const content = renderRecollection(rec);
70
+ if (!content) {
71
+ return 'no memory of this';
72
+ }
73
+ return withEffects(content, {
74
+ kind: 'memory_op',
75
+ spec: {
76
+ kind: 'recall',
77
+ query,
78
+ seeds: [...rec.seeds],
79
+ nodes: rec.nodes.map((n) => ({
80
+ id: n.node.id,
81
+ kind: n.node.kind,
82
+ score: n.score,
83
+ })),
84
+ edges: [...rec.edges],
85
+ content,
86
+ },
87
+ });
88
+ },
89
+ }),
90
+ tool({
91
+ name: MEMORY_LOAD_TOOL,
92
+ description: 'Read memories in full by id, using the ids from a ' +
93
+ `${MEMORY_SEARCH_TOOL} legend. A remembered file comes back inline when it ` +
94
+ 'is small, otherwise as a path under /memory you can open or run.',
95
+ parameters: {
96
+ type: 'object',
97
+ properties: {
98
+ ids: { type: 'array', items: { type: 'string' }, minItems: 1 },
99
+ },
100
+ required: ['ids'],
101
+ additionalProperties: false,
102
+ },
103
+ execute: async (args, tc) => {
104
+ const opId = memoryOpId(tc.state.runId, tc.callId);
105
+ const loaded = await index.load(args.ids, binding.sees, tc.services.clock);
106
+ // Recorded because this is the only path that puts a whole body
107
+ // in the context: a recall block carries a clipped line.
108
+ const spec = {
109
+ kind: 'op',
110
+ op: 'load',
111
+ opId,
112
+ nodes: loaded.map((l) => ({
113
+ id: l.node.id,
114
+ kind: l.node.kind,
115
+ revision: l.node.revision,
116
+ })),
117
+ edges: [],
118
+ files: loaded.filter((l) => l.content !== undefined).length,
119
+ };
120
+ return withEffects(loaded.map((l) => ({
121
+ id: l.node.id,
122
+ kind: l.node.kind,
123
+ text: l.node.text,
124
+ created: l.node.createdAt,
125
+ revision: l.node.revision,
126
+ metadata: l.node.metadata,
127
+ file: l.node.file
128
+ ? { path: l.node.file.path, bytes: l.node.file.bytes }
129
+ : undefined,
130
+ content: l.content,
131
+ })), { kind: 'memory_op', spec });
132
+ },
133
+ }),
134
+ ];
135
+ if (!canWrite(binding)) {
136
+ return tools;
137
+ }
138
+ const labels = binding.writes;
139
+ const audience = labels.length > 1
140
+ ? {
141
+ audience: {
142
+ type: 'array',
143
+ items: { type: 'string', enum: labels },
144
+ description: 'who may later see this node; defaults to everyone',
145
+ },
146
+ }
147
+ : {};
148
+ tools.push(tool({
149
+ name: MEMORY_COMMIT_TOOL,
150
+ description: 'Remember something as a small graph, in one call: the nodes and the links ' +
151
+ 'between them. Link a new node to an existing one by using its id. A node ' +
152
+ 'that already exists is folded into it, and its id comes back under your ref.',
153
+ parameters: {
154
+ type: 'object',
155
+ properties: {
156
+ nodes: {
157
+ type: 'array',
158
+ minItems: 1,
159
+ items: {
160
+ type: 'object',
161
+ properties: {
162
+ ref: {
163
+ type: 'string',
164
+ description: 'a short name for a new node, used by edges in this call',
165
+ },
166
+ id: {
167
+ type: 'string',
168
+ description: 'to change an existing node instead of adding one',
169
+ },
170
+ kind: { type: 'string', enum: kinds, description: kindHelp },
171
+ text: {
172
+ type: 'string',
173
+ description: 'what to remember, in your own words',
174
+ },
175
+ file: {
176
+ type: 'string',
177
+ description: 'path of a file to keep a copy of, for kind "file"',
178
+ },
179
+ ...audience,
180
+ expected_revision: {
181
+ type: 'integer',
182
+ description: 'refuse the change if the node moved on',
183
+ },
184
+ },
185
+ additionalProperties: false,
186
+ },
187
+ },
188
+ edges: {
189
+ type: 'array',
190
+ items: {
191
+ type: 'object',
192
+ properties: {
193
+ from: {
194
+ type: 'string',
195
+ description: 'a ref from this call, or an id',
196
+ },
197
+ to: {
198
+ type: 'string',
199
+ description: 'a ref from this call, or an id',
200
+ },
201
+ relation: {
202
+ type: 'string',
203
+ enum: relations,
204
+ description: relationHelp,
205
+ },
206
+ },
207
+ required: ['from', 'to', 'relation'],
208
+ additionalProperties: false,
209
+ },
210
+ },
211
+ },
212
+ required: ['nodes'],
213
+ additionalProperties: false,
214
+ },
215
+ execute: async (args, tc) => {
216
+ const opId = memoryOpId(tc.state.runId, tc.callId);
217
+ let nodes;
218
+ try {
219
+ nodes = args.nodes.map((n) => ({
220
+ ref: n.ref,
221
+ id: n.id,
222
+ kind: n.kind,
223
+ text: n.text,
224
+ audience: n.audience,
225
+ expectedRevision: n.expected_revision,
226
+ file: n.file === undefined ? undefined : { source: source(tc, n.file) },
227
+ }));
228
+ }
229
+ catch (err) {
230
+ return refusal(err);
231
+ }
232
+ const edges = (args.edges ?? []).map((e) => ({
233
+ from: e.from,
234
+ to: e.to,
235
+ relation: e.relation,
236
+ }));
237
+ let res;
238
+ try {
239
+ res = await index.commit({ nodes, edges }, { writes: labels, sees: binding.sees, clock: tc.services.clock });
240
+ }
241
+ catch (err) {
242
+ return refusal(err);
243
+ }
244
+ // The TUI shows a truncated preview, so the counts go first.
245
+ const parts = [`${MEMORY_COMMIT_TOOL} ${res.created + res.updated} nodes`];
246
+ if (res.edges) {
247
+ parts.push(`${res.edges} edges`);
248
+ }
249
+ if (res.files) {
250
+ parts.push(`${res.files} files`);
251
+ }
252
+ if (res.merged) {
253
+ parts.push(`${res.merged} already known`);
254
+ }
255
+ const spec = {
256
+ kind: 'op',
257
+ op: 'commit',
258
+ opId,
259
+ nodes: changed(index, res.ids),
260
+ edges: [],
261
+ files: res.files,
262
+ };
263
+ return withEffects({ summary: parts.join(' '), ids: res.ids }, { kind: 'memory_op', spec });
264
+ },
265
+ }));
266
+ if (!canForget(binding)) {
267
+ return tools;
268
+ }
269
+ tools.push(tool({
270
+ name: MEMORY_FORGET_TOOL,
271
+ description: 'Delete memories by id, with the files they hold. Use this only for what is ' +
272
+ 'wrong or no longer allowed to be kept; to correct something, prefer a new ' +
273
+ `node linked with SUPERSEDES in ${MEMORY_COMMIT_TOOL}.`,
274
+ parameters: {
275
+ type: 'object',
276
+ properties: {
277
+ ids: { type: 'array', items: { type: 'string' }, minItems: 1 },
278
+ reason: { type: 'string', description: 'why this is being removed' },
279
+ },
280
+ required: ['ids'],
281
+ additionalProperties: false,
282
+ },
283
+ execute: async (args, tc) => {
284
+ const opId = memoryOpId(tc.state.runId, tc.callId);
285
+ const before = args.ids.flatMap((id) => {
286
+ const node = index.graph.get(id);
287
+ return node ? [{ id, kind: node.kind, revision: node.revision }] : [];
288
+ });
289
+ // Taken before the delete: afterwards nothing can say what this
290
+ // node hung from, and a report would draw it floating.
291
+ const links = new Map(args.ids
292
+ .flatMap((id) => index.graph.neighbors(id, binding.sees))
293
+ .map((e) => [`${e.source}|${e.relation}|${e.target}`, e]));
294
+ try {
295
+ await index.forget(args.ids, binding.sees);
296
+ }
297
+ catch (err) {
298
+ return refusal(err);
299
+ }
300
+ return withEffects(`${MEMORY_FORGET_TOOL} ${before.length} nodes`, {
301
+ kind: 'memory_op',
302
+ spec: {
303
+ kind: 'op',
304
+ op: 'forget',
305
+ opId,
306
+ nodes: before,
307
+ edges: [...links.values()],
308
+ files: 0,
309
+ },
310
+ });
311
+ },
312
+ }));
313
+ return tools;
314
+ }
315
+ /**
316
+ * A refusal is a result, not a throw: the model can read it and fix the call,
317
+ * whereas an exception ends the turn.
318
+ */
319
+ function refusal(err) {
320
+ if (err instanceof MemoryError) {
321
+ return { error: err.message, hint: err.hint };
322
+ }
323
+ throw err;
324
+ }
325
+ function source(tc, path) {
326
+ if (!tc.services.resolveFile) {
327
+ throw new MemoryError('this agent cannot remember files', 'commit the node without `file`, or give the agent a workspace');
328
+ }
329
+ return tc.services.resolveFile(path);
330
+ }
331
+ function changed(index, ids) {
332
+ return Object.values(ids).flatMap((id) => {
333
+ const node = index.graph.get(id);
334
+ return node ? [{ id, kind: node.kind, revision: node.revision }] : [];
335
+ });
336
+ }
337
+ //# sourceMappingURL=tools.js.map