@zenera/neo 1.1.12 → 1.1.15

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.
@@ -1,3 +1,4 @@
1
+ import { buildMemorySlice } from "../memory/report.js";
1
2
  import { collectPayloads } from "../payload.js";
2
3
  // ---------------------------------------------------------------------------
3
4
  // Run inspector — a single HTML file that explains a run
@@ -12,6 +13,9 @@ import { collectPayloads } from "../payload.js";
12
13
  * a report into a file no editor will open. */
13
14
  export const MAX_BLOB_BYTES = 512 * 1024;
14
15
  export const MERMAID_URL = 'https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs';
16
+ /** Deliberately meaner than the memory page's own cap: this file already holds
17
+ * a whole run, and one remembered artifact should not decide whether it opens. */
18
+ export const MAX_MEMORY_FILE_BYTES = 64 * 1024;
15
19
  /** Base64 data uris only: the charset ends at the closing JSON quote. */
16
20
  const DATA_URL = /data:[\w.+-]+\/[\w.+-]+;base64,[A-Za-z0-9+/=]+/g;
17
21
  /** Below this a uri is cheaper to leave in place than to indirect through. */
@@ -57,6 +61,47 @@ export async function buildRunReport(state, payloads, opts = {}) {
57
61
  truncated,
58
62
  media,
59
63
  architecture: opts.architecture,
64
+ memory: opts.memory ? await remembered(opts.memory, state, media, opts) : undefined,
65
+ };
66
+ }
67
+ /** Every memory node the run touched, branches included. */
68
+ function memoryIds(nodes, into) {
69
+ for (const node of nodes) {
70
+ if (node.type === 'memory_recall' || node.type === 'memory_op') {
71
+ for (const n of node.nodes) {
72
+ into.add(n.id);
73
+ }
74
+ }
75
+ else if (node.type === 'join') {
76
+ for (const branch of node.branches) {
77
+ memoryIds(branch.nodes, into);
78
+ }
79
+ }
80
+ }
81
+ return into;
82
+ }
83
+ async function remembered(store, state, media, opts) {
84
+ const ids = memoryIds(state.trajectory, new Set());
85
+ if (!ids.size) {
86
+ return undefined;
87
+ }
88
+ let slice;
89
+ try {
90
+ slice = await buildMemorySlice(store, [...ids], {
91
+ maxContentBytes: opts.maxMemoryFileBytes ?? MAX_MEMORY_FILE_BYTES,
92
+ });
93
+ }
94
+ catch {
95
+ // A memory that cannot be read is a view the page does without; it is
96
+ // not a reason to lose the record of the run.
97
+ return undefined;
98
+ }
99
+ return {
100
+ dir: store.dir,
101
+ // Remembered images go through the same table as the run's own, so a
102
+ // picture the agent both saw and filed is carried once.
103
+ nodes: slice.nodes.map((n) => (n.image ? { ...n, image: hoistMedia(n.image, media) } : n)),
104
+ edges: slice.edges,
60
105
  };
61
106
  }
62
107
  //# sourceMappingURL=report.js.map
@@ -53,5 +53,17 @@ export interface MemoryReportOptions {
53
53
  /** override the clock, for tests */
54
54
  now?: () => string;
55
55
  }
56
+ /** Part of a memory, for a caller that already knows which part it wants. */
57
+ export interface MemorySlice {
58
+ nodes: ReportNode[];
59
+ /** only the edges with both ends inside the slice */
60
+ edges: MemoryEdge[];
61
+ }
62
+ /**
63
+ * The nodes named, built exactly as the whole graph is — same inlining, same
64
+ * excuses, same degree, which is counted over every edge rather than over the
65
+ * slice: a node's degree is a fact about the memory, not about the cut.
66
+ */
67
+ export declare function buildMemorySlice(store: MemoryStore, ids: readonly string[], opts?: Pick<MemoryReportOptions, 'maxContentBytes'>): Promise<MemorySlice>;
56
68
  export declare function buildMemoryReport(store: MemoryStore, opts?: MemoryReportOptions): Promise<MemoryReport>;
57
69
  //# sourceMappingURL=report.d.ts.map
@@ -25,25 +25,26 @@ const MIME = {
25
25
  webp: 'image/webp',
26
26
  svg: 'image/svg+xml',
27
27
  };
28
+ /**
29
+ * The nodes named, built exactly as the whole graph is — same inlining, same
30
+ * excuses, same degree, which is counted over every edge rather than over the
31
+ * slice: a node's degree is a fact about the memory, not about the cut.
32
+ */
33
+ export async function buildMemorySlice(store, ids, opts = {}) {
34
+ const graph = store.graph;
35
+ const all = graph.edges();
36
+ const wanted = new Set(ids);
37
+ return {
38
+ nodes: await inline(store, graph.nodes().filter((n) => wanted.has(n.id)), degrees(all), graph.superseded(), opts.maxContentBytes ?? MAX_CONTENT_BYTES),
39
+ edges: all.filter((e) => wanted.has(e.source) && wanted.has(e.target)),
40
+ };
41
+ }
28
42
  export async function buildMemoryReport(store, opts = {}) {
29
43
  const graph = store.graph;
30
44
  const nodes = graph.nodes();
31
45
  const edges = graph.edges();
32
- const stale = graph.superseded();
33
46
  const limit = opts.maxContentBytes ?? MAX_CONTENT_BYTES;
34
- const degree = new Map();
35
- for (const e of edges) {
36
- degree.set(e.source, (degree.get(e.source) ?? 0) + 1);
37
- degree.set(e.target, (degree.get(e.target) ?? 0) + 1);
38
- }
39
- // Files are read in parallel: one memory can hold hundreds, and they are
40
- // independent.
41
- const built = await Promise.all(nodes.map(async (n) => ({
42
- ...plain(n),
43
- stale: stale.has(n.id),
44
- degree: degree.get(n.id) ?? 0,
45
- ...(n.file ? await body(store.dir, n.file, limit) : {}),
46
- })));
47
+ const built = await inline(store, nodes, degrees(edges), graph.superseded(), limit);
47
48
  const vectors = store.vectors;
48
49
  return {
49
50
  title: opts.title ?? 'Memory',
@@ -59,6 +60,23 @@ export async function buildMemoryReport(store, opts = {}) {
59
60
  audiences: [...new Set(nodes.flatMap((n) => n.audience))].sort(),
60
61
  };
61
62
  }
63
+ function degrees(edges) {
64
+ const degree = new Map();
65
+ for (const e of edges) {
66
+ degree.set(e.source, (degree.get(e.source) ?? 0) + 1);
67
+ degree.set(e.target, (degree.get(e.target) ?? 0) + 1);
68
+ }
69
+ return degree;
70
+ }
71
+ /** Files are read in parallel: one memory can hold hundreds, independently. */
72
+ async function inline(store, nodes, degree, stale, limit) {
73
+ return Promise.all(nodes.map(async (n) => ({
74
+ ...plain(n),
75
+ stale: stale.has(n.id),
76
+ degree: degree.get(n.id) ?? 0,
77
+ ...(n.file ? await body(store.dir, n.file, limit) : {}),
78
+ })));
79
+ }
62
80
  /** The node's own fields, without the graph attributes graphology adds. */
63
81
  function plain(n) {
64
82
  return {
@@ -101,8 +101,23 @@ export function memoryTools(opts) {
101
101
  additionalProperties: false,
102
102
  },
103
103
  execute: async (args, tc) => {
104
+ const opId = memoryOpId(tc.state.runId, tc.callId);
104
105
  const loaded = await index.load(args.ids, binding.sees, tc.services.clock);
105
- return loaded.map((l) => ({
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) => ({
106
121
  id: l.node.id,
107
122
  kind: l.node.kind,
108
123
  text: l.node.text,
@@ -113,7 +128,7 @@ export function memoryTools(opts) {
113
128
  ? { path: l.node.file.path, bytes: l.node.file.bytes }
114
129
  : undefined,
115
130
  content: l.content,
116
- }));
131
+ })), { kind: 'memory_op', spec });
117
132
  },
118
133
  }),
119
134
  ];
@@ -271,6 +286,11 @@ export function memoryTools(opts) {
271
286
  const node = index.graph.get(id);
272
287
  return node ? [{ id, kind: node.kind, revision: node.revision }] : [];
273
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]));
274
294
  try {
275
295
  await index.forget(args.ids, binding.sees);
276
296
  }
@@ -279,7 +299,14 @@ export function memoryTools(opts) {
279
299
  }
280
300
  return withEffects(`${MEMORY_FORGET_TOOL} ${before.length} nodes`, {
281
301
  kind: 'memory_op',
282
- spec: { kind: 'op', op: 'forget', opId, nodes: before, edges: [], files: 0 },
302
+ spec: {
303
+ kind: 'op',
304
+ op: 'forget',
305
+ opId,
306
+ nodes: before,
307
+ edges: [...links.values()],
308
+ files: 0,
309
+ },
283
310
  });
284
311
  },
285
312
  }));
@@ -169,10 +169,11 @@ export interface MemoryRecallSpec {
169
169
  }
170
170
  export interface MemoryOpSpec {
171
171
  kind: 'op';
172
- op: 'commit' | 'forget';
172
+ /** `load` is here rather than under recall: it hands the model whole bodies, and it writes. */
173
+ op: 'commit' | 'forget' | 'load';
173
174
  /** sha256(runId, callId) — deterministic, so a replay is deduplicated */
174
175
  opId: string;
175
- /** nodes this op created, changed or removed */
176
+ /** nodes this op created, changed, removed or read in full */
176
177
  nodes: {
177
178
  id: string;
178
179
  kind: string;
@@ -67,7 +67,7 @@ export interface MemoryRecallNode extends NodeBase {
67
67
  }
68
68
  export interface MemoryOpNode extends NodeBase {
69
69
  type: 'memory_op';
70
- op: 'commit' | 'forget';
70
+ op: 'commit' | 'forget' | 'load';
71
71
  /** sha256(runId, callId) — deterministic, so a replay is deduplicated */
72
72
  opId: string;
73
73
  nodes: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zenera/neo",
3
- "version": "1.1.12",
3
+ "version": "1.1.15",
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",