@zenera/neo 1.1.23 → 1.1.25

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 (45) 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 +4 -0
  4. package/dist/index.js +4 -0
  5. package/dist/inspect/client.d.ts +1 -1
  6. package/dist/inspect/client.js +7 -2
  7. package/dist/lock.d.ts +27 -0
  8. package/dist/lock.js +66 -0
  9. package/dist/match.d.ts +44 -0
  10. package/dist/match.js +100 -0
  11. package/dist/memory/diff.d.ts +39 -0
  12. package/dist/memory/diff.js +95 -0
  13. package/dist/memory/files.d.ts +1 -1
  14. package/dist/memory/grep.d.ts +57 -0
  15. package/dist/memory/grep.js +182 -0
  16. package/dist/memory/index.d.ts +10 -2
  17. package/dist/memory/index.js +15 -4
  18. package/dist/memory/merge.js +1 -31
  19. package/dist/memory/recall.d.ts +1 -1
  20. package/dist/memory/recall.js +1 -1
  21. package/dist/memory/store.d.ts +8 -5
  22. package/dist/memory/store.js +26 -55
  23. package/dist/memory/tools.js +105 -9
  24. package/dist/memory/twin.d.ts +6 -0
  25. package/dist/memory/twin.js +40 -0
  26. package/dist/memory/types.d.ts +7 -2
  27. package/dist/models/factory.d.ts +67 -2
  28. package/dist/models/factory.js +256 -10
  29. package/dist/project/config.js +1 -1
  30. package/dist/project/load.d.ts +7 -0
  31. package/dist/project/load.js +29 -6
  32. package/dist/runner.js +7 -0
  33. package/dist/tools/exa.js +1 -1
  34. package/dist/tools/files.d.ts +81 -0
  35. package/dist/tools/files.js +1472 -0
  36. package/dist/tools/index.d.ts +1 -1
  37. package/dist/tools/index.js +1 -1
  38. package/dist/tools/sandbox.d.ts +44 -0
  39. package/dist/tools/sandbox.js +162 -5
  40. package/dist/tools/workspace.js +81 -3
  41. package/dist/trajectory.d.ts +19 -1
  42. package/dist/trajectory.js +53 -0
  43. package/dist/types.d.ts +1 -0
  44. package/dist/types.js +1 -0
  45. package/package.json +1 -1
@@ -1,4 +1,4 @@
1
1
  export * from './exa.ts';
2
+ export * from './files.ts';
2
3
  export * from './sandbox.ts';
3
- export * from './workspace.ts';
4
4
  //# sourceMappingURL=index.d.ts.map
@@ -1,6 +1,6 @@
1
1
  // Tools a host may hand to a project. Nothing here is wired in by default:
2
2
  // what an agent can reach is a deployment decision, so it is opted into.
3
3
  export * from "./exa.js";
4
+ export * from "./files.js";
4
5
  export * from "./sandbox.js";
5
- export * from "./workspace.js";
6
6
  //# sourceMappingURL=index.js.map
@@ -120,6 +120,15 @@ export interface ProcResult {
120
120
  }
121
121
  export type Runner = (bin: string, args: readonly string[], opts?: ProcOptions) => Promise<ProcResult>;
122
122
  export declare const runProcess: Runner;
123
+ /**
124
+ * `run`, retried while the engine could not be reached at all.
125
+ *
126
+ * Empty stdout is the guard that keeps this from retrying real work: a command
127
+ * that produced output ran, whatever its stderr says afterwards. The wait is
128
+ * jittered because the burst being backed off from is N clients arriving
129
+ * together, and a fixed delay would only reassemble it.
130
+ */
131
+ export declare function resilient(run: Runner, attempts?: number): Runner;
123
132
  interface Resolved extends Required<Omit<SandboxSpec, 'user'>> {
124
133
  user?: string;
125
134
  root: string;
@@ -143,12 +152,30 @@ export interface ExecResult {
143
152
  truncated?: boolean;
144
153
  timed_out?: boolean;
145
154
  duration_ms: number;
155
+ out_of_memory?: OutOfMemory;
156
+ }
157
+ /**
158
+ * Why a command was killed for memory, which is a different question from
159
+ * whether it was. A container that overran its own `--memory` will do it again
160
+ * however long anyone waits; one the kernel sacrificed for a neighbour may
161
+ * simply succeed later. Exit code 137 is all either of them says.
162
+ */
163
+ export interface OutOfMemory {
164
+ /** what this container was allowed */
165
+ limit_mib: number;
166
+ /** high-water mark for the container's whole life, not for this command */
167
+ peak_mib?: number;
168
+ /** false when the limit itself is the problem, so an unchanged rerun ends the same way */
169
+ retryable: boolean;
170
+ hint: string;
146
171
  }
147
172
  interface Job {
148
173
  id: string;
149
174
  command: string;
150
175
  cwd: string;
151
176
  startedAt: number;
177
+ /** worked out once, because a log is read many times and the evidence is consumed */
178
+ oom?: OutOfMemory;
152
179
  }
153
180
  /**
154
181
  * The container, started when it is first needed and not before. A project
@@ -195,6 +222,23 @@ export declare class Sandbox {
195
222
  */
196
223
  inside(path?: string): string;
197
224
  }
225
+ export interface SandboxLimits {
226
+ /** MiB */
227
+ memory: number;
228
+ /** fractional cores */
229
+ cpus: number;
230
+ hardening: SandboxHardening;
231
+ /** whether the numbers above were written down, or are this profile's defaults */
232
+ declared: boolean;
233
+ }
234
+ /**
235
+ * What a spec's ceilings come to, for a report that has no container to ask.
236
+ *
237
+ * It exists so `zen sandbox status` cannot disagree with what a run actually
238
+ * applies: the defaults are stated once, here and in `resolveSpec`, and
239
+ * nothing outside this module repeats them.
240
+ */
241
+ export declare function sandboxLimits(spec?: Pick<SandboxSpec, 'cpus' | 'memory' | 'hardening'>): SandboxLimits;
198
242
  export declare class SandboxPool {
199
243
  #private;
200
244
  constructor(opts: SandboxPoolOptions);
@@ -39,6 +39,8 @@ const MAX_TIMEOUT_MS = 3_600_000;
39
39
  const DEFAULT_PIDS = 1024;
40
40
  /** How long a container gets to stop itself before it is killed. */
41
41
  const STOP_GRACE_S = 2;
42
+ /** SIGKILL, which is how the kernel's out-of-memory killer arrives. */
43
+ const KILLED = 137;
42
44
  // Standard mode's resource ceilings. Even without the strict profile, an
43
45
  // unbounded container can exhaust host cores or memory during runaway builds.
44
46
  const DEFAULT_CPUS = 4;
@@ -138,6 +140,57 @@ export const runProcess = (bin, args, opts = {}) => new Promise((settle, fail) =
138
140
  child.stdin.end();
139
141
  }
140
142
  });
143
+ // ---------------------------------------------------------------------------
144
+ // Reaching the engine at all
145
+ //
146
+ // On macOS and Windows the client is remote: unless it has been pointed at the
147
+ // machine's forwarded endpoint, every invocation opens its own ssh connection
148
+ // into the virtual machine, and sshd refuses new ones past `MaxStartups` — ten
149
+ // by default, with a random drop above that. A wide batch therefore loses
150
+ // calls for a reason that has nothing to do with the command, on a different
151
+ // task each time.
152
+ //
153
+ // A refused connection is the one failure that can be retried without asking
154
+ // what the command was: it happened before authentication, so nothing ran.
155
+ // ---------------------------------------------------------------------------
156
+ const TRANSPORT = /kex_exchange_identification|ssh: handshake failed|ssh: unexpected packet|connection closed by remote host|connection reset by peer|connection refused/i;
157
+ const ATTEMPTS = 4;
158
+ /**
159
+ * `run`, retried while the engine could not be reached at all.
160
+ *
161
+ * Empty stdout is the guard that keeps this from retrying real work: a command
162
+ * that produced output ran, whatever its stderr says afterwards. The wait is
163
+ * jittered because the burst being backed off from is N clients arriving
164
+ * together, and a fixed delay would only reassemble it.
165
+ */
166
+ export function resilient(run, attempts = ATTEMPTS) {
167
+ return async (bin, args, opts = {}) => {
168
+ for (let n = 1;; n++) {
169
+ const res = await run(bin, args, opts);
170
+ if (n >= attempts ||
171
+ res.code === 0 ||
172
+ res.stdout !== '' ||
173
+ opts.signal?.aborted ||
174
+ !TRANSPORT.test(res.stderr)) {
175
+ return res;
176
+ }
177
+ await new Promise((go) => setTimeout(go, 2 ** n * 50 + Math.random() * 100));
178
+ }
179
+ };
180
+ }
181
+ /**
182
+ * Asked of the container after a kill. Builtins only: a slim image may have no
183
+ * `awk`, and strict hardening leaves nowhere writable to install one. `oom_kill`
184
+ * counts kills in this container's cgroup and no other, which is what separates
185
+ * the two cases. Unreadable values print empty, and the caller reads that as
186
+ * "could not tell".
187
+ */
188
+ const CGROUP = [
189
+ `peak=; kills=`,
190
+ `read peak < /sys/fs/cgroup/memory.peak 2>/dev/null`,
191
+ `while read -r k v; do [ "$k" = oom_kill ] && kills=$v; done < /sys/fs/cgroup/memory.events 2>/dev/null`,
192
+ `echo "peak=$peak kills=$kills"`,
193
+ ].join('\n');
141
194
  /**
142
195
  * The container, started when it is first needed and not before. A project
143
196
  * whose agents never shell out never pays for an image pull, and a session
@@ -150,9 +203,11 @@ export class Sandbox {
150
203
  #jobs = new Map();
151
204
  #ready;
152
205
  #created = false;
206
+ /** kills counted in this container's cgroup as of the last probe */
207
+ #kills;
153
208
  constructor(opts) {
154
209
  this.spec = resolveSpec(opts);
155
- this.#run = opts.exec ?? runProcess;
210
+ this.#run = resilient(opts.exec ?? runProcess);
156
211
  this.name = containerName(this.spec);
157
212
  }
158
213
  /** Whether anything was actually started — `dispose` is free when not. */
@@ -188,7 +243,10 @@ export class Sandbox {
188
243
  this.#created = true;
189
244
  return;
190
245
  }
191
- await this.#must(this.#createArgs(), `could not create container ${this.name}`);
246
+ const made = await this.#podman(this.#createArgs());
247
+ if (made.code !== 0) {
248
+ throw new SandboxError(`could not create container ${this.name}: ${message(made)}`, LOCKS.test(made.stderr) ? locksHint(this.spec.engine) : undefined);
249
+ }
192
250
  this.#created = true;
193
251
  }
194
252
  #createArgs() {
@@ -265,7 +323,13 @@ export class Sandbox {
265
323
  const cwd = this.inside(opts.cwd);
266
324
  const timeoutMs = this.#timeout(opts.timeout);
267
325
  const startedAt = Date.now();
268
- const res = await this.#run(this.spec.engine, ['exec', '--interactive', '--workdir', cwd, this.name, '/bin/sh', '-s'], { input: command, timeoutMs, signal: opts.signal });
326
+ const once = () => this.#run(this.spec.engine, ['exec', '--interactive', '--workdir', cwd, this.name, '/bin/sh', '-s'], { input: command, timeoutMs, signal: opts.signal });
327
+ let res = await once();
328
+ if (gone(res)) {
329
+ // podman refused before running anything, so the retry cannot run the command twice.
330
+ await this.#revive(res);
331
+ res = await once();
332
+ }
269
333
  return {
270
334
  exit_code: res.code,
271
335
  stdout: res.stdout,
@@ -273,6 +337,7 @@ export class Sandbox {
273
337
  truncated: res.truncated || undefined,
274
338
  timed_out: res.timedOut || undefined,
275
339
  duration_ms: Date.now() - startedAt,
340
+ out_of_memory: await this.#oom(res.code, res.timedOut, opts.signal),
276
341
  };
277
342
  }
278
343
  /**
@@ -353,17 +418,24 @@ export class Sandbox {
353
418
  const output = rest.join('\n---\n');
354
419
  const lines = Number(meta.lines ?? 0);
355
420
  const shown = output ? output.replace(/\n$/, '').split('\n').length : 0;
421
+ const exit = meta.exit === undefined ? undefined : Number(meta.exit);
422
+ // Kept on the job: a second read would find the kill counter unchanged
423
+ // and report this container's own overrun as the machine's doing.
424
+ if (exit === KILLED && !job.oom) {
425
+ job.oom = await this.#oom(exit, false);
426
+ }
356
427
  return {
357
428
  job_id: id,
358
429
  command: job.command,
359
430
  running: meta.exit === undefined,
360
- exit_code: meta.exit === undefined ? undefined : Number(meta.exit),
431
+ exit_code: exit,
361
432
  duration_ms: Date.now() - job.startedAt,
362
433
  start_line: first,
363
434
  end_line: first + shown - 1,
364
435
  lines,
365
436
  truncated: first + shown - 1 < lines || undefined,
366
437
  output,
438
+ out_of_memory: job.oom,
367
439
  };
368
440
  }
369
441
  /** Signals a job's whole process group, so its children go too. */
@@ -422,9 +494,60 @@ export class Sandbox {
422
494
  const wanted = seconds && seconds > 0 ? seconds * 1000 : this.spec.timeout * 1000;
423
495
  return Math.min(wanted || DEFAULT_TIMEOUT_MS, MAX_TIMEOUT_MS);
424
496
  }
497
+ /**
498
+ * Turns a bare 137 into a reason, by asking the container which cgroup did
499
+ * the killing. Only a kill counted against *this* container is the caller's
500
+ * to fix; the rest is the machine being busy, and telling someone to shrink
501
+ * a command that already fit would send them off after the wrong thing.
502
+ */
503
+ async #oom(code, timedOut, signal) {
504
+ // A timeout or a cancel kills podman on this side, not the container,
505
+ // and an uncapped container has no limit it could have exceeded.
506
+ if (code !== KILLED || timedOut || signal?.aborted || this.spec.memory <= 0) {
507
+ return undefined;
508
+ }
509
+ const res = await this.#run(this.spec.engine, ['exec', '--interactive', this.name, '/bin/sh', '-s'], { input: CGROUP, timeoutMs: 15_000 }).catch(() => undefined);
510
+ const out = res?.stdout ?? '';
511
+ const peak = Number(/peak=(\d+)/.exec(out)?.[1]);
512
+ const kills = Number(/kills=(\d+)/.exec(out)?.[1]);
513
+ const counted = Number.isFinite(kills);
514
+ // Nothing readable is read as our own doing: that costs one wasted
515
+ // retry, where the other guess costs an agent retrying forever a
516
+ // command that was never going to fit.
517
+ const own = !counted || kills > (this.#kills ?? 0);
518
+ if (counted) {
519
+ this.#kills = kills;
520
+ }
521
+ const limit = this.spec.memory;
522
+ return {
523
+ limit_mib: limit,
524
+ peak_mib: Number.isFinite(peak) ? Math.round(peak / (1024 * 1024)) : undefined,
525
+ retryable: !own,
526
+ hint: own
527
+ ? `this container is limited to ${limit} MiB and the command wanted more. ` +
528
+ `Running it again unchanged will end the same way: give the sandbox more ` +
529
+ `memory in the project configuration, or do the work in smaller pieces.`
530
+ : `the machine ran out of memory while this container was inside its ` +
531
+ `${limit} MiB limit, so it was killed for something another container did. ` +
532
+ `The command itself does not need to change, and may work once the machine ` +
533
+ `is less busy.`,
534
+ };
535
+ }
425
536
  #podman(args) {
426
537
  return this.#run(this.spec.engine, args, { timeoutMs: 120_000 });
427
538
  }
539
+ /** The container stopped or vanished under us (machine slept, restarted, or someone removed it). */
540
+ async #revive(res) {
541
+ this.#ready = undefined;
542
+ try {
543
+ await this.start();
544
+ }
545
+ catch (err) {
546
+ throw new SandboxError(`the sandbox container ${this.name} stopped (${message(res)}) and could not be restarted: ` +
547
+ (err instanceof Error ? err.message : String(err)), `the ${this.spec.engine} machine may have stopped or restarted — check \`${this.spec.engine} machine list\`, ` +
548
+ `start it with \`${this.spec.engine} machine start\`, then retry`);
549
+ }
550
+ }
428
551
  async #must(args, what) {
429
552
  const res = await this.#podman(args);
430
553
  if (res.code !== 0) {
@@ -439,6 +562,23 @@ function mount(m) {
439
562
  function message(res) {
440
563
  return (res.stderr.trim() || res.stdout.trim() || `exit ${res.code}`).split('\n')[0];
441
564
  }
565
+ /** podman's words for "every lock is taken": `allocating lock … exceeded num_locks (2048)`. */
566
+ const LOCKS = /exceeded num_locks|allocating lock/i;
567
+ /**
568
+ * A stopped container holds a lock as surely as a running one, and a
569
+ * persisted sandbox is stopped, never removed — so they pile up one per
570
+ * session until the engine can create nothing at all, for anyone.
571
+ */
572
+ function locksHint(engine) {
573
+ return (`the ${engine} engine has no locks left: every container it holds, stopped ones ` +
574
+ `included, takes one. Remove the stopped sandbox containers with ` +
575
+ `\`${engine} container prune --force --filter label=zenera=1\`, then retry`);
576
+ }
577
+ /** podman's own exit code for "could not exec at all", with the two reasons that mean the container is not there to exec in. */
578
+ function gone(res) {
579
+ return (res.code === 125 &&
580
+ /can only create exec sessions on running containers|container state improper|no such container/i.test(res.stderr));
581
+ }
442
582
  /** Only ever applied to paths this module derived, never to a model's text. */
443
583
  function quote(s) {
444
584
  return `'${s.replaceAll("'", `'\\''`)}'`;
@@ -474,6 +614,23 @@ function resolveSpec(opts) {
474
614
  engine: opts.engine ?? 'podman',
475
615
  };
476
616
  }
617
+ /**
618
+ * What a spec's ceilings come to, for a report that has no container to ask.
619
+ *
620
+ * It exists so `zen sandbox status` cannot disagree with what a run actually
621
+ * applies: the defaults are stated once, here and in `resolveSpec`, and
622
+ * nothing outside this module repeats them.
623
+ */
624
+ export function sandboxLimits(spec = {}) {
625
+ const hardening = spec.hardening ?? 'standard';
626
+ const strict = hardening === 'strict';
627
+ return {
628
+ memory: spec.memory ?? (strict ? STRICT_MEMORY : DEFAULT_MEMORY),
629
+ cpus: spec.cpus ?? (strict ? STRICT_CPUS : DEFAULT_CPUS),
630
+ hardening,
631
+ declared: spec.memory !== undefined || spec.cpus !== undefined,
632
+ };
633
+ }
477
634
  /**
478
635
  * The container's name is a function of its configuration. Change the image
479
636
  * and a new container appears rather than an old one quietly persisting with
@@ -585,7 +742,7 @@ export function sandboxTools(opts) {
585
742
  group: SANDBOX_GROUP,
586
743
  description: 'Runs a shell command in a Linux container and waits for it to finish. The ' +
587
744
  `workspace is mounted at ${SANDBOX_MOUNT} and is the working directory, so files you ` +
588
- 'read and write here are the same files the workspace tools see. Everything ' +
745
+ 'read and write here are the same files the file tools see. Everything ' +
589
746
  'else is throwaway. Use this for builds, tests, and package installs; use ' +
590
747
  'run_command_background for anything that does not end on its own, such as a ' +
591
748
  'server or a watcher.',
@@ -1,5 +1,5 @@
1
1
  import { realpathSync, statSync } from 'node:fs';
2
- import { mkdir, open, readFile, readdir, rename, rm, stat, writeFile } from 'node:fs/promises';
2
+ import { cp, mkdir, open, readFile, readdir, rename, rm, stat, writeFile } from 'node:fs/promises';
3
3
  import { dirname, extname, join, relative, resolve, sep } from 'node:path';
4
4
  import { tool } from "../types.js";
5
5
  // ---------------------------------------------------------------------------
@@ -684,7 +684,7 @@ function patchLines(lines, chunks, file) {
684
684
  const at = findHeading(out, heading, cursor);
685
685
  if (at < 0) {
686
686
  // A heading only narrows the search. Models invent them —
687
- // `@@ class DfwRiskScanner` for a file of plain functions — and
687
+ // `@@ class RiskScanner` for a file of plain functions — and
688
688
  // when the context occurs once the heading was never needed, so
689
689
  // refusing spends a turn to learn nothing.
690
690
  if (countMatches(out, chunk.before, cursor) === 1) {
@@ -1064,6 +1064,75 @@ export function workspaceTools(opts) {
1064
1064
  return { from: ws.show(source), to: ws.show(target), moved: true };
1065
1065
  },
1066
1066
  });
1067
+ // The one mutating tool whose source may sit in a read-only tree, and the
1068
+ // reason it earns its place: reaching a mounted file otherwise means
1069
+ // reading it into the context and writing it back out, which costs a turn,
1070
+ // truncates past the read cap and cannot carry a binary at all.
1071
+ const copyPath = tool({
1072
+ name: 'copy_file',
1073
+ group: GROUP,
1074
+ description: 'Copies a file, making the destination parent directories as needed. The source ' +
1075
+ 'may be in a read-only tree, so this is how something mounted for reference is ' +
1076
+ 'brought into the workspace; the destination must be writable. The bytes are ' +
1077
+ 'copied directly and are not read into the conversation, so size is no object ' +
1078
+ 'and a binary copies as faithfully as text. Refuses to clobber an existing ' +
1079
+ 'destination unless overwrite is set, and only copies a directory, with ' +
1080
+ 'everything in it, when recursive is set.',
1081
+ parameters: {
1082
+ type: 'object',
1083
+ properties: {
1084
+ from: { type: 'string', description: `What to copy. ${inside}` },
1085
+ to: { type: 'string', description: `Where the copy should land. ${inside}` },
1086
+ overwrite: {
1087
+ type: 'boolean',
1088
+ description: 'Replace the destination if it exists.',
1089
+ },
1090
+ recursive: {
1091
+ type: 'boolean',
1092
+ description: 'Required to copy a directory and its contents.',
1093
+ },
1094
+ },
1095
+ required: ['from', 'to'],
1096
+ additionalProperties: false,
1097
+ },
1098
+ execute: async ({ from, to, overwrite, recursive }) => {
1099
+ ws.mutable();
1100
+ const source = ws.within(from);
1101
+ const target = ws.within(to, { write: true });
1102
+ if (ws.isRoot(target)) {
1103
+ return { error: 'refusing to overwrite the workspace root' };
1104
+ }
1105
+ if (source === target) {
1106
+ return { error: 'the source and the destination are the same path' };
1107
+ }
1108
+ const info = await stat(source); // ENOENT here is the honest error
1109
+ if (info.isDirectory()) {
1110
+ if (!recursive) {
1111
+ return {
1112
+ error: `${ws.show(source)} is a directory`,
1113
+ hint: 'pass recursive to copy it and everything in it',
1114
+ };
1115
+ }
1116
+ if (target.startsWith(source + sep)) {
1117
+ return { error: `cannot copy ${ws.show(source)} into itself` };
1118
+ }
1119
+ }
1120
+ if (await statOrNull(target)) {
1121
+ if (!overwrite) {
1122
+ return {
1123
+ error: `${ws.show(target)} already exists`,
1124
+ hint: 'pass overwrite to replace it',
1125
+ };
1126
+ }
1127
+ // cp() will not replace a directory with a file, or the reverse.
1128
+ await rm(target, { recursive: true, force: true });
1129
+ }
1130
+ await mkdir(dirname(target), { recursive: true });
1131
+ await cp(source, target, { recursive: recursive === true });
1132
+ const out = { from: ws.show(source), to: ws.show(target), copied: true };
1133
+ return info.isDirectory() ? { ...out, directory: true } : { ...out, bytes: info.size };
1134
+ },
1135
+ });
1067
1136
  const deleteFile = tool({
1068
1137
  name: 'delete_file',
1069
1138
  group: GROUP,
@@ -1154,7 +1223,16 @@ export function workspaceTools(opts) {
1154
1223
  });
1155
1224
  return named(ws, opts.readOnly
1156
1225
  ? [readFileTool, listDir, findFiles]
1157
- : [readFileTool, listDir, findFiles, writeFileTool, applyPatch, movePath, deleteFile]);
1226
+ : [
1227
+ readFileTool,
1228
+ listDir,
1229
+ findFiles,
1230
+ writeFileTool,
1231
+ applyPatch,
1232
+ copyPath,
1233
+ movePath,
1234
+ deleteFile,
1235
+ ]);
1158
1236
  }
1159
1237
  /** What each errno means, said the way the rest of these tools say things. */
1160
1238
  const ERRNO = {
@@ -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' | 'load';
70
+ op: 'commit' | 'forget' | 'load' | 'grep';
71
71
  /** sha256(runId, callId) — deterministic, so a replay is deduplicated */
72
72
  opId: string;
73
73
  nodes: {
@@ -202,6 +202,24 @@ export declare function projected(nodes: TrajectoryNode[]): TrajectoryNode[];
202
202
  * added, since counting it too would double every branch.
203
203
  */
204
204
  export declare function totalUsage(trajectory: TrajectoryNode[]): TokenUsage;
205
+ export interface ModelUsage {
206
+ /** the exact model id the call was served by, as recorded on the node */
207
+ model: string;
208
+ calls: number;
209
+ usage: TokenUsage;
210
+ }
211
+ /** A summarizer's tokens are real spend, but no node records which model burned them. */
212
+ export declare const COMPACTION_MODEL = "(compaction)";
213
+ /**
214
+ * `totalUsage`, split by the model that spent it. The same walk and the same
215
+ * recursion into branches, so the rows always add back up to the total — which
216
+ * is the point: a run that used three models must not report one number that
217
+ * belongs to none of them. Heaviest first, since that is the one being asked
218
+ * about.
219
+ */
220
+ export declare function usageByModel(trajectory: TrajectoryNode[]): ModelUsage[];
221
+ /** Folds splits together: many turns of one run, or many items of a batch. */
222
+ export declare function mergeModelUsage(...lists: readonly (readonly ModelUsage[])[]): ModelUsage[];
205
223
  export declare function lastOfType<T extends TrajectoryNode['type']>(trajectory: TrajectoryNode[], type: T): Extract<TrajectoryNode, {
206
224
  type: T;
207
225
  }> | undefined;
@@ -47,6 +47,59 @@ export function totalUsage(trajectory) {
47
47
  return acc;
48
48
  }, zeroUsage());
49
49
  }
50
+ /** A summarizer's tokens are real spend, but no node records which model burned them. */
51
+ export const COMPACTION_MODEL = '(compaction)';
52
+ /**
53
+ * `totalUsage`, split by the model that spent it. The same walk and the same
54
+ * recursion into branches, so the rows always add back up to the total — which
55
+ * is the point: a run that used three models must not report one number that
56
+ * belongs to none of them. Heaviest first, since that is the one being asked
57
+ * about.
58
+ */
59
+ export function usageByModel(trajectory) {
60
+ const into = new Map();
61
+ collect(trajectory, into);
62
+ return sorted(into);
63
+ }
64
+ /** Folds splits together: many turns of one run, or many items of a batch. */
65
+ export function mergeModelUsage(...lists) {
66
+ const into = new Map();
67
+ for (const list of lists) {
68
+ for (const one of list) {
69
+ fold(into, one);
70
+ }
71
+ }
72
+ return sorted(into);
73
+ }
74
+ function collect(trajectory, into) {
75
+ for (const n of trajectory) {
76
+ if (n.type === 'llm_call') {
77
+ fold(into, { model: n.model, calls: 1, usage: n.usage });
78
+ }
79
+ else if (n.type === 'compaction') {
80
+ fold(into, { model: COMPACTION_MODEL, calls: 1, usage: n.usage });
81
+ }
82
+ else if (n.type === 'join') {
83
+ for (const branch of n.branches) {
84
+ collect(branch.nodes, into);
85
+ }
86
+ }
87
+ }
88
+ }
89
+ function fold(into, one) {
90
+ const had = into.get(one.model);
91
+ into.set(one.model, had
92
+ ? {
93
+ model: had.model,
94
+ calls: had.calls + one.calls,
95
+ usage: addUsage(had.usage, one.usage),
96
+ }
97
+ : { model: one.model, calls: one.calls, usage: one.usage });
98
+ }
99
+ function sorted(into) {
100
+ const spent = (m) => m.usage.inputTokens + m.usage.outputTokens;
101
+ return [...into.values()].sort((a, b) => spent(b) - spent(a) || a.model.localeCompare(b.model));
102
+ }
50
103
  export function lastOfType(trajectory, type) {
51
104
  for (let i = trajectory.length - 1; i >= 0; i--) {
52
105
  if (trajectory[i].type === type) {
package/dist/types.d.ts CHANGED
@@ -184,6 +184,7 @@ export declare const HANDOFF_PREFIX = "transfer_to_";
184
184
  export declare const FORK_TOOL = "fork";
185
185
  export declare const FINAL_OUTPUT_TOOL = "final_output";
186
186
  export declare const MEMORY_SEARCH_TOOL = "memory_search";
187
+ export declare const MEMORY_GREP_TOOL = "memory_grep";
187
188
  export declare const MEMORY_LOAD_TOOL = "memory_load";
188
189
  export declare const MEMORY_COMMIT_TOOL = "memory_commit";
189
190
  export declare const MEMORY_FORGET_TOOL = "memory_forget";
package/dist/types.js CHANGED
@@ -144,6 +144,7 @@ export const HANDOFF_PREFIX = 'transfer_to_';
144
144
  export const FORK_TOOL = 'fork';
145
145
  export const FINAL_OUTPUT_TOOL = 'final_output';
146
146
  export const MEMORY_SEARCH_TOOL = 'memory_search';
147
+ export const MEMORY_GREP_TOOL = 'memory_grep';
147
148
  export const MEMORY_LOAD_TOOL = 'memory_load';
148
149
  export const MEMORY_COMMIT_TOOL = 'memory_commit';
149
150
  export const MEMORY_FORGET_TOOL = 'memory_forget';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zenera/neo",
3
- "version": "1.1.23",
3
+ "version": "1.1.25",
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",