@zenera/neo 1.1.16 → 1.1.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -166,7 +166,7 @@ of surfacing three turns into a run as a confused model.
166
166
  [docs/projects.md](https://github.com/andreyryabov/ZeneraNeo/blob/main/docs/projects.md) ·
167
167
  [docs/agents-yaml.md](https://github.com/andreyryabov/ZeneraNeo/blob/main/docs/agents-yaml.md)
168
168
  · and
169
- [`examples/`](https://github.com/andreyryabov/ZeneraNeo/tree/main/examples) for
169
+ [`examples/sdk/`](https://github.com/andreyryabov/ZeneraNeo/tree/main/examples/sdk) for
170
170
  eight worked demos, from a single agent to a project loaded entirely from a
171
171
  folder.
172
172
 
@@ -99,10 +99,12 @@ export class RateLimiter {
99
99
  if (at >= 0) {
100
100
  this.#waiting.splice(at, 1);
101
101
  }
102
+ this.#hold();
102
103
  reject(signal?.reason);
103
104
  };
104
105
  signal?.addEventListener('abort', abort, { once: true });
105
106
  this.#waiting.push(waiter);
107
+ this.#hold();
106
108
  });
107
109
  }
108
110
  #free() {
@@ -121,6 +123,7 @@ export class RateLimiter {
121
123
  this.#active++;
122
124
  this.#waiting.shift().grant(this.#epoch);
123
125
  }
126
+ this.#hold();
124
127
  }
125
128
  #succeeded() {
126
129
  this.#penalty = this.#initial;
@@ -168,7 +171,23 @@ export class RateLimiter {
168
171
  return;
169
172
  }
170
173
  this.#timer = setTimeout(() => this.#arm(), remaining);
171
- this.#timer.unref?.();
174
+ this.#hold();
175
+ }
176
+ /**
177
+ * A parked task is a bare promise, which keeps nothing alive, and while the
178
+ * pause is in force this timer is the only thing that can ever wake it — so
179
+ * it has to hold the event loop open, or the last in-flight request settling
180
+ * empties the loop and Node exits 13 with the whole queue unsettled. With
181
+ * nobody waiting it must NOT hold it, or a run that ended on a refusal sits
182
+ * out the rest of the backoff before the process can exit.
183
+ */
184
+ #hold() {
185
+ if (this.#waiting.length > 0) {
186
+ this.#timer?.ref?.();
187
+ }
188
+ else {
189
+ this.#timer?.unref?.();
190
+ }
172
191
  }
173
192
  }
174
193
  const clamp = (n, low, high) => Math.min(high, Math.max(low, n));
package/dist/fork.d.ts ADDED
@@ -0,0 +1,24 @@
1
+ import type { JsonSchema } from './types.ts';
2
+ /**
3
+ * Deliberately not a function of the agent list or the branch cap: both are
4
+ * already on the tool schema, where the provider enforces them during decoding,
5
+ * and naming them twice buys nothing but two places to drift.
6
+ */
7
+ export declare function forkInstructions(): string;
8
+ /**
9
+ * Mechanics only — the policy above is in the system prompt of every agent that
10
+ * can call this, so repeating it here would cost tokens in two places at once.
11
+ */
12
+ export declare const FORK_DESCRIPTION: string;
13
+ /**
14
+ * Derived from the registry rather than a constant, because the one thing a
15
+ * weak model reliably gets wrong here is inventing an agent name. An `enum` of
16
+ * the agents that actually exist is a constraint the provider enforces during
17
+ * decoding, which no amount of prose in the description can match.
18
+ *
19
+ * `minItems`/`maxItems` are stated too, but they are advisory — most providers
20
+ * ignore them under strict decoding — so `parseForkArgs` and `forkProblem`
21
+ * still check both, and their messages are written to be read by the model.
22
+ */
23
+ export declare function forkParameters(agents: string[], self: string, maxBranches?: number): JsonSchema;
24
+ //# sourceMappingURL=fork.d.ts.map
package/dist/fork.js ADDED
@@ -0,0 +1,137 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Teaching the model to fan out and to delegate
3
+ //
4
+ // The tool schema says what the arguments are; none of it says what survives a
5
+ // join, that a branch cannot be corrected once it starts, or which `context`
6
+ // mode fits which shape of work. That is policy, it is the same for every
7
+ // agent, and it belongs in the system prompt — the only place it can be stated
8
+ // once per run instead of once per call.
9
+ //
10
+ // It goes in through `composePrompt`'s `derived` array, so it sits in the
11
+ // cacheable prefix and is recorded on the `system_prompt` node like every other
12
+ // part of what the model read. The text is gated on the agent holding a fork
13
+ // binding *and* being below the depth cap — the same condition that decides
14
+ // whether the tool is offered at all, because a rule about a tool the model
15
+ // does not have is a rule it can only be confused by.
16
+ // ---------------------------------------------------------------------------
17
+ /**
18
+ * Deliberately not a function of the agent list or the branch cap: both are
19
+ * already on the tool schema, where the provider enforces them during decoding,
20
+ * and naming them twice buys nothing but two places to drift.
21
+ */
22
+ export function forkInstructions() {
23
+ return FORKING;
24
+ }
25
+ const FORKING = `Forking runs work in separate conversations and brings back only the answers.
26
+
27
+ A branch is a run of its own: its own conversation, its own tool calls, its own
28
+ reasoning. None of that returns. What returns is each branch's final answer, as
29
+ the result of your \`fork\` call — so whatever you will need afterwards has to be
30
+ *in* that answer, and the branch only knows it if your instructions say so. Work
31
+ whose value is the trace rather than the conclusion should not be forked at all.
32
+
33
+ The instructions you write are the entire assignment. A branch cannot ask you a
34
+ question, cannot see what its siblings are doing, and cannot be corrected once
35
+ it starts. Say what it must do, what it must leave alone, and the exact shape of
36
+ what it must hand back.
37
+
38
+ **One branch is delegation**: the work happens elsewhere and you get the
39
+ conclusion instead of the transcript. Use it when another agent is better suited
40
+ to the job, or when the job would otherwise fill this conversation with material
41
+ you have no use for afterwards — a long file survey, a noisy build loop, an
42
+ exploration down a path that may go nowhere. **Several branches are a fan-out**:
43
+ independent parts of one task, worked at the same time, merged by you. It is the
44
+ same call either way; the number of branches is the only difference.
45
+
46
+ Do not fork a sequence. Branches run at once and can exchange nothing, so a step
47
+ that needs the step before it has to stay in this conversation.
48
+
49
+ \`context\` decides what each branch starts from, and you choose it per call:
50
+ - \`inherit\` — everything said here so far. For work that only makes sense
51
+ against the case as it stands.
52
+ - \`compact\` — the same, without the tool traffic: what was decided, not how it
53
+ was found out. The usual choice for a wide fan-out.
54
+ - \`none\` — nothing but its own instructions. The cheapest, and the honest one
55
+ when the assignment is self-contained.
56
+
57
+ A branch can fail, or answer badly. Its row says which, and what the final
58
+ answer is remains your decision, not the join's.`;
59
+ /**
60
+ * Mechanics only — the policy above is in the system prompt of every agent that
61
+ * can call this, so repeating it here would cost tokens in two places at once.
62
+ */
63
+ export const FORK_DESCRIPTION = [
64
+ 'Run work in separate conversations and get back only the answers.',
65
+ '',
66
+ 'Each branch needs a unique name and complete, self-contained instructions: it runs on ' +
67
+ 'its own, cannot ask questions, and cannot see the other branches. One branch delegates ' +
68
+ 'a job; several run in parallel and rejoin together.',
69
+ '',
70
+ 'The branches\u2019 answers come back as the result of this call, and what the final answer ' +
71
+ 'is remains yours to decide.',
72
+ ].join('\n');
73
+ /**
74
+ * Derived from the registry rather than a constant, because the one thing a
75
+ * weak model reliably gets wrong here is inventing an agent name. An `enum` of
76
+ * the agents that actually exist is a constraint the provider enforces during
77
+ * decoding, which no amount of prose in the description can match.
78
+ *
79
+ * `minItems`/`maxItems` are stated too, but they are advisory — most providers
80
+ * ignore them under strict decoding — so `parseForkArgs` and `forkProblem`
81
+ * still check both, and their messages are written to be read by the model.
82
+ */
83
+ export function forkParameters(agents, self, maxBranches) {
84
+ const max = maxBranches ?? 0;
85
+ // When the author's list excludes the current agent there is no sane
86
+ // default, so the model has to name one.
87
+ const selfAllowed = agents.includes(self);
88
+ return {
89
+ type: 'object',
90
+ properties: {
91
+ branches: {
92
+ type: 'array',
93
+ minItems: 1,
94
+ ...(max >= 1 ? { maxItems: max } : {}),
95
+ description: `One branch per job that can be carried out on its own${max >= 1 ? `, at most ${max}` : ''}. ` +
96
+ 'One delegates that job elsewhere; several work independent parts of the ' +
97
+ 'task at the same time.',
98
+ items: {
99
+ type: 'object',
100
+ properties: {
101
+ name: {
102
+ type: 'string',
103
+ description: 'Short, unique label for this branch, e.g. "eu-tariffs".',
104
+ },
105
+ instructions: {
106
+ type: 'string',
107
+ description: 'The complete assignment for this branch. It runs in a separate ' +
108
+ 'conversation and cannot ask questions, so state everything it ' +
109
+ 'needs and exactly what it must return.',
110
+ },
111
+ agent: {
112
+ type: 'string',
113
+ enum: agents,
114
+ description: 'Which agent runs this branch. Must be one of: ' +
115
+ agents.join(', ') +
116
+ (selfAllowed ? `. Omit to use "${self}".` : '.'),
117
+ },
118
+ },
119
+ required: selfAllowed
120
+ ? ['name', 'instructions']
121
+ : ['name', 'instructions', 'agent'],
122
+ additionalProperties: false,
123
+ },
124
+ },
125
+ context: {
126
+ type: 'string',
127
+ enum: ['inherit', 'compact', 'none'],
128
+ description: 'How much of this conversation each branch starts with: "inherit" (all of ' +
129
+ 'it, the default), "compact" (messages only, no tool traffic), "none" ' +
130
+ '(nothing but its own instructions).',
131
+ },
132
+ },
133
+ required: ['branches'],
134
+ additionalProperties: false,
135
+ };
136
+ }
137
+ //# sourceMappingURL=fork.js.map
package/dist/index.d.ts CHANGED
@@ -4,6 +4,7 @@ export * from './embedding.ts';
4
4
  export * from './embeddings/index.ts';
5
5
  export * from './events.ts';
6
6
  export * from './failure.ts';
7
+ export * from './fork.ts';
7
8
  export * from './ids.ts';
8
9
  export * from './inspect/index.ts';
9
10
  export * as Kernel from './kernel.ts';
package/dist/index.js CHANGED
@@ -4,6 +4,7 @@ export * from "./embedding.js";
4
4
  export * from "./embeddings/index.js";
5
5
  export * from "./events.js";
6
6
  export * from "./failure.js";
7
+ export * from "./fork.js";
7
8
  export * from "./ids.js";
8
9
  export * from "./inspect/index.js";
9
10
  export * as Kernel from "./kernel.js";
package/dist/kernel.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { z } from 'zod';
2
2
  import { Agent, AgentRegistry, handoffTarget, handoffTool } from "./agent.js";
3
+ import { FORK_DESCRIPTION, forkInstructions, forkParameters } from "./fork.js";
3
4
  import { systemClock } from "./ids.js";
4
5
  import { memoryInstructions, renderPreferences } from "./memory/instructions.js";
5
6
  import { memoryTools } from "./memory/tools.js";
@@ -193,82 +194,10 @@ function forkAgents(state, reg) {
193
194
  const known = allowed.filter((n) => reg.find(n));
194
195
  return known.length ? known : [state.agentName];
195
196
  }
196
- /**
197
- * Derived from the state rather than a constant, because the one thing a weak
198
- * model reliably gets wrong here is inventing an agent name. An `enum` of the
199
- * agents that actually exist is a constraint the provider enforces during
200
- * decoding, which no amount of prose in the description can match.
201
- *
202
- * `minItems`/`maxItems` are stated too, but they are advisory — most providers
203
- * ignore them under strict decoding — so `parseForkArgs` and `forkProblem`
204
- * still check both, and their messages are written to be read by the model.
205
- */
206
- function forkParameters(agents, self, maxBranches) {
207
- const max = maxBranches ?? 0;
208
- // When the author's list excludes the current agent there is no sane
209
- // default, so the model has to name one.
210
- const selfAllowed = agents.includes(self);
211
- return {
212
- type: 'object',
213
- properties: {
214
- branches: {
215
- type: 'array',
216
- minItems: 2,
217
- ...(max >= 2 ? { maxItems: max } : {}),
218
- description: `At least two branches${max >= 2 ? `, at most ${max}` : ''}. ` +
219
- 'One branch is never valid: if the work does not split, do it yourself ' +
220
- 'instead of calling this tool.',
221
- items: {
222
- type: 'object',
223
- properties: {
224
- name: {
225
- type: 'string',
226
- description: 'Short, unique label for this branch, e.g. "eu-tariffs".',
227
- },
228
- instructions: {
229
- type: 'string',
230
- description: 'The complete assignment for this branch. It runs in a separate ' +
231
- 'conversation and cannot ask questions, so state everything it ' +
232
- 'needs and exactly what it must return.',
233
- },
234
- agent: {
235
- type: 'string',
236
- enum: agents,
237
- description: 'Which agent runs this branch. Must be one of: ' +
238
- agents.join(', ') +
239
- (selfAllowed ? `. Omit to use "${self}".` : '.'),
240
- },
241
- },
242
- required: selfAllowed
243
- ? ['name', 'instructions']
244
- : ['name', 'instructions', 'agent'],
245
- additionalProperties: false,
246
- },
247
- },
248
- context: {
249
- type: 'string',
250
- enum: ['inherit', 'compact', 'none'],
251
- description: 'How much of this conversation each branch starts with: "inherit" (all of ' +
252
- 'it, the default), "compact" (messages only, no tool traffic), "none" ' +
253
- '(nothing but its own instructions).',
254
- },
255
- },
256
- required: ['branches'],
257
- additionalProperties: false,
258
- };
197
+ /** One condition for the tool and for the prompt block that explains it. */
198
+ function canFork(agent, state) {
199
+ return Boolean(agent.fork) && state.spec.forkDepth < state.spec.maxForkDepth;
259
200
  }
260
- const FORK_DESCRIPTION = [
261
- 'Split the work into independent branches that run in parallel and rejoin into one result.',
262
- '',
263
- 'Call it when two or more parts of the task can be worked on without seeing each ' +
264
- "other's findings — separate documents, regions, candidates, hypotheses. Do not call it " +
265
- 'to break one line of reasoning into steps: branches cannot talk to each other, and a ' +
266
- "step that needs the previous step's answer must stay in this conversation.",
267
- '',
268
- 'Every call needs at least two branches, each with a unique name and self-contained ' +
269
- 'instructions. Their answers come back to you as the result of this call, and you ' +
270
- 'decide what the final answer is.',
271
- ].join('\n');
272
201
  /**
273
202
  * tools(state) = agent tools + handoffs + memory + skills + fork +
274
203
  * final_output. Computed from the state so a rehydrated run offers exactly the
@@ -298,11 +227,11 @@ export async function resolveTools(state, reg, env) {
298
227
  const own = new Set(agent.tools.map((t) => t.name));
299
228
  tools.push(...(await lockedSkillTools(binding, provider, own)));
300
229
  }
301
- if (agent.fork && state.spec.forkDepth < state.spec.maxForkDepth) {
230
+ if (canFork(agent, state)) {
302
231
  tools.push({
303
232
  name: FORK_TOOL,
304
233
  description: FORK_DESCRIPTION,
305
- parameters: forkParameters(forkAgents(state, reg), state.agentName, agent.fork.maxBranches),
234
+ parameters: forkParameters(forkAgents(state, reg), state.agentName, agent.fork?.maxBranches),
306
235
  // Never executed as a tool: the runner drives branches and the join
307
236
  // becomes the tool result.
308
237
  execute: () => 'fork scheduled',
@@ -355,6 +284,9 @@ async function derivedPrompt(agent, state, env) {
355
284
  out.push(rendered);
356
285
  }
357
286
  }
287
+ if (canFork(agent, state)) {
288
+ out.push(forkInstructions());
289
+ }
358
290
  if (state.spec.outputSchema) {
359
291
  out.push(FINAL_OUTPUT_INSTRUCTIONS);
360
292
  }
@@ -444,12 +376,7 @@ function parseForkArgs(raw) {
444
376
  // the model as the tool result, and it is the only chance it gets to work
445
377
  // out what to do differently.
446
378
  if (!Array.isArray(branches) || branches.length === 0) {
447
- return 'the "branches" argument is missing or empty; it must be an array of at least two branches';
448
- }
449
- if (branches.length < 2) {
450
- return ('a fork needs at least two branches, and this call has one. Either add the other ' +
451
- 'independent branches, or do not fork at all and carry out the work in this ' +
452
- 'conversation.');
379
+ return 'the "branches" argument is missing or empty; it must be an array of at least one branch';
453
380
  }
454
381
  const parsed = [];
455
382
  for (const b of branches) {
@@ -847,6 +774,9 @@ export async function createChildState(state, forkId, branch, env) {
847
774
  * what happens to what it produces, exist nowhere else. Without them a branch
848
775
  * re-derives work a sibling already owns and writes an answer shaped for a user
849
776
  * rather than for a merge.
777
+ *
778
+ * A lone branch has no siblings to be told about, and the thing it does need to
779
+ * know is the opposite one: nobody else is working on any of this.
850
780
  */
851
781
  function branchBrief(fork, plan, instructions) {
852
782
  const siblings = fork.branches.filter((b) => b.name !== plan.name).map((b) => `"${b.name}"`);
@@ -855,11 +785,16 @@ function branchBrief(fork, plan, instructions) {
855
785
  '',
856
786
  instructions,
857
787
  '',
858
- `Running in parallel, on the assignments the call above gave them: ${siblings.join(', ')}. ` +
859
- 'You cannot see their work and they cannot see yours, so anything you do on their ' +
860
- 'part is duplicated effort, and anything you leave to them will be there. This branch ' +
861
- 'ends with your final answer; that answer alone rejoins the conversation above, ' +
862
- 'merged with theirs.',
788
+ siblings.length
789
+ ? `Running in parallel, on the assignments the call above gave them: ${siblings.join(', ')}. ` +
790
+ 'You cannot see their work and they cannot see yours, so anything you do on their ' +
791
+ 'part is duplicated effort, and anything you leave to them will be there. This branch ' +
792
+ 'ends with your final answer; that answer alone rejoins the conversation above, ' +
793
+ 'merged with theirs.'
794
+ : 'No other branch was opened, so the whole assignment is yours and nothing you ' +
795
+ 'leave undone will be picked up elsewhere. This branch ends with your final ' +
796
+ 'answer; that answer alone rejoins the conversation above, and none of the steps ' +
797
+ 'you take to reach it go with it.',
863
798
  ].join('\n');
864
799
  }
865
800
  /** The parent history a branch starts from, as decided by `contextMode`. */
@@ -51,7 +51,7 @@ export class MemoryStore {
51
51
  const b = opts.embedding;
52
52
  if (a.model !== b.model || a.dimensions !== b.dimensions) {
53
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');
54
+ `but ${b.model} (${b.dimensions}d) is configured`, 'restore the original model, or start a new memory');
55
55
  }
56
56
  }
57
57
  const graph = manifest
@@ -79,7 +79,7 @@ export class MemoryStore {
79
79
  if (held) {
80
80
  if (held.model !== embedding.model || held.dimensions !== embedding.dimensions) {
81
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');
82
+ `but ${embedding.model} (${embedding.dimensions}d) is configured`, 'restore the original model, or start a new memory');
83
83
  }
84
84
  return;
85
85
  }
@@ -157,15 +157,17 @@ const skillsBinding = z
157
157
  * `fork:` is never offered the tool.
158
158
  *
159
159
  * Both limits are stated as errors rather than as values that get clamped
160
- * later: `maxBranches: 1` would make every call the model could write fail, and
160
+ * later: `maxBranches: 0` would make every call the model could write fail, and
161
161
  * `agents: []` reads as "no agents" while actually meaning "only itself".
162
+ * `maxBranches: 1` is not a contradiction — it says this agent may delegate a
163
+ * job but may not fan out.
162
164
  */
163
165
  const forkBinding = z
164
166
  .object({
165
167
  /** agents a branch may run; absent means any registered agent */
166
168
  agents: z.array(name).min(1).optional(),
167
- /** a fork is never valid with one branch, so a cap below two is a contradiction */
168
- maxBranches: z.int().min(2).optional(),
169
+ /** a cap of 1 allows delegation only; 0 would refuse every possible call */
170
+ maxBranches: z.int().min(1).optional(),
169
171
  })
170
172
  .strict();
171
173
  /**
@@ -4,7 +4,7 @@ import { RunStream } from '../events.ts';
4
4
  import { MemoryIndex } from '../memory/index.ts';
5
5
  import { ModelRegistry, type EmbeddingRef, type ModelRef, type ProviderSpec } from '../models/factory.ts';
6
6
  import type { PayloadStore } from '../payload.ts';
7
- import { AgentRunner, type RunOptions, type RunnerOptions } from '../runner.ts';
7
+ import { AgentRunner, type RunnerOptions, type RunOptions } from '../runner.ts';
8
8
  import { type SkillDir } from '../skill-providers/file.ts';
9
9
  import type { SkillProvider } from '../skills.ts';
10
10
  import { type AnyTool, type Input } from '../types.ts';
@@ -13,8 +13,9 @@ import { type ProjectConfig } from './config.ts';
13
13
  * A folder is an agentic system:
14
14
  *
15
15
  * ```
16
- * INSTRUCTIONS.md house rules, prepended to every agent
17
16
  * agents.yaml who exists, what they may reach for
17
+ * agents/instructions.md house rules, prepended to every agent
18
+ * agents/<topic>-instructions.md more of them, added as the project grows
18
19
  * agents/prompts/<role>.md one agent's own instructions
19
20
  * agents/skills/<name>/SKILL.md
20
21
  * assets/ reference material, mounted read-only at /assets
@@ -55,6 +56,13 @@ export interface ProjectOptions<TCtx = unknown> {
55
56
  */
56
57
  registry?: ModelRegistry;
57
58
  memory?: MemoryIndex;
59
+ /**
60
+ * Another directory to keep the memory graph in, relative to the project
61
+ * root when it is not absolute. Whoever sets it must mount that directory
62
+ * as `/memory` too, or the agent reads its remembered files from one graph
63
+ * and writes to another.
64
+ */
65
+ memoryDir?: string;
58
66
  /** turns an agent-visible path into a host path, so files can be remembered */
59
67
  resolveFile?: (path: string) => string;
60
68
  payloads?: PayloadStore;
@@ -165,8 +173,13 @@ export declare function projectRegistry<TCtx>(config: ProjectConfig, opts?: Proj
165
173
  * with the front end, which has to mount the same directory into the sandbox:
166
174
  * two answers to this question would mean `/memory` pointing somewhere the
167
175
  * graph is not.
176
+ *
177
+ * `override` is the host pointing the run at another graph — `zen run
178
+ * --memory`. It answers for a project that declares no memory at all, because
179
+ * naming a directory is the declaration; the agents bound to it are still the
180
+ * ones that said `memory: true`.
168
181
  */
169
- export declare function memoryDir(root: string, config: ProjectConfig): string | undefined;
182
+ export declare function memoryDir(root: string, config: ProjectConfig, override?: string): string | undefined;
170
183
  /**
171
184
  * Where each skill directory appears in the agent's namespace.
172
185
  *
@@ -1,5 +1,5 @@
1
- import { existsSync, readFileSync, statSync } from 'node:fs';
2
- import { basename, join } from 'node:path';
1
+ import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
2
+ import { basename, join, relative, resolve, sep } from 'node:path';
3
3
  import { Agent, AgentRegistry } from "../agent.js";
4
4
  import { RunStream } from "../events.js";
5
5
  import { MemoryIndex } from "../memory/index.js";
@@ -11,6 +11,7 @@ import { FileSkillProvider } from "../skill-providers/file.js";
11
11
  import { selectTools } from "../types.js";
12
12
  import { parseConfig } from "./config.js";
13
13
  import { projectDir, projectFile, projectRoot } from "./refs.js";
14
+ const AGENTS_DIR = 'agents';
14
15
  const PROMPTS_DIR = 'agents/prompts';
15
16
  const SKILLS_DIR = 'agents/skills';
16
17
  /** Reference material, by convention, when `assets:` says nothing. */
@@ -19,7 +20,11 @@ const CONFIG_NAMES = ['agents.yaml', 'agents.yml', 'agents/agents.yaml', 'agents
19
20
  // Deliberately not `AGENTS.md`: every coding assistant now reads that name out
20
21
  // of an open folder, and `zen open` opens exactly this directory. The house
21
22
  // rules here address *this project's* agents, not the editor's.
22
- const HOUSE_RULES = 'INSTRUCTIONS.md';
23
+ const HOUSE_RULES = 'agents/instructions.md';
24
+ /** Every other `agents/<topic>-instructions.md`, in filename order. */
25
+ const INSTRUCTIONS_SUFFIX = '-instructions.md';
26
+ /** Where the house rules used to live. Still read, so an old project still runs. */
27
+ const LEGACY_HOUSE_RULES = 'INSTRUCTIONS.md';
23
28
  /**
24
29
  * Finds and validates `agents.yaml`, and stops there. Loading a project builds
25
30
  * its model clients, which needs credentials — so anything that wants to *look*
@@ -39,8 +44,8 @@ export function readProjectConfig(dir) {
39
44
  */
40
45
  export async function loadProject(dir, opts = {}) {
41
46
  const { root, source, config } = readProjectConfig(dir);
42
- // Read once and share the object: every agent's prompt then reports the
43
- // same path and the same content hash, so the report says "one document,
47
+ // Read once and share the objects: every agent's prompt then reports the
48
+ // same paths and the same content hashes, so the report says "one document,
44
49
  // five prompts" instead of showing five identical blobs.
45
50
  const houseRules = readHouseRules(root);
46
51
  const providers = [...skillProviders(root, config, opts), ...(opts.skills ?? [])];
@@ -69,7 +74,7 @@ export async function loadProject(dir, opts = {}) {
69
74
  // The default is resolved here rather than on first use, so a broken
70
75
  // `embedding:` fails at load like a broken `model:` does.
71
76
  embedders(config.embedding, 'embedding');
72
- const memory = opts.memory ?? (await openMemory(root, config, embedders));
77
+ const memory = opts.memory ?? (await openMemory(root, config, embedders, opts.memoryDir));
73
78
  return new AgentProject({
74
79
  root,
75
80
  source,
@@ -184,30 +189,63 @@ function findConfig(root) {
184
189
  }
185
190
  throw new Error(`no project configuration in ${root} (looked for ${CONFIG_NAMES.join(', ')})`);
186
191
  }
192
+ /** The project-relative name of a file, which is the name the model is given. */
193
+ function within(root, path) {
194
+ return relative(root, path).split(sep).join('/');
195
+ }
196
+ /**
197
+ * Every house-rules document, in the order they are prepended.
198
+ *
199
+ * The set is a glob rather than a list in `agents.yaml` because these are read
200
+ * by every agent without exception — there is nothing to decide, so there is
201
+ * nothing to declare. Order is the filename's, which is the only ordering that
202
+ * is the same on every machine and is visible without opening another file.
203
+ *
204
+ * All of it is optional: a one-agent project whose whole prompt is its role
205
+ * file should not need a second, empty document.
206
+ */
187
207
  function readHouseRules(root) {
188
- const path = join(root, HOUSE_RULES);
189
- // Optional on purpose: a one-agent project whose whole prompt is its role
190
- // file should not need a second, empty document.
191
- return existsSync(path) ? promptFile(path, 'house_rules') : undefined;
208
+ const rules = [];
209
+ const take = (rel) => {
210
+ const path = join(root, rel);
211
+ if (existsSync(path)) {
212
+ rules.push(promptFile(path, 'house_rules', rel));
213
+ }
214
+ };
215
+ // First, because it is the oldest and most general thing the project says:
216
+ // anything added under `agents/` during a migration layers on top of it.
217
+ take(LEGACY_HOUSE_RULES);
218
+ take(HOUSE_RULES);
219
+ for (const name of topics(root)) {
220
+ take(`${AGENTS_DIR}/${name}`);
221
+ }
222
+ return rules;
223
+ }
224
+ /** `agents/<topic>-instructions.md`, sorted. Subdirectories are not searched. */
225
+ function topics(root) {
226
+ const dir = join(root, AGENTS_DIR);
227
+ if (!existsSync(dir)) {
228
+ return [];
229
+ }
230
+ return readdirSync(dir, { withFileTypes: true })
231
+ .filter((entry) => entry.isFile() && entry.name.endsWith(INSTRUCTIONS_SUFFIX))
232
+ .map((entry) => entry.name)
233
+ .sort((a, b) => a.localeCompare(b));
192
234
  }
193
235
  /**
194
- * `INSTRUCTIONS.md` first, the agent's own file second — shared context before
236
+ * The house rules first, the agent's own file second — shared context before
195
237
  * the specific job, and the stable half of the prompt in front where a cache
196
238
  * can reuse it.
197
239
  */
198
240
  function instructionsFor(root, spec, houseRules) {
199
- const parts = [];
200
- if (houseRules) {
201
- parts.push(houseRules);
202
- }
203
- if (spec.system) {
204
- parts.push(promptFile(projectFile(root, spec.system, `agents.${spec.name}.system`), 'role'));
205
- }
206
- else {
207
- const conventional = join(root, PROMPTS_DIR, `${spec.name}.md`);
208
- if (existsSync(conventional)) {
209
- parts.push(promptFile(conventional, 'role'));
210
- }
241
+ const parts = [...houseRules];
242
+ const role = spec.system
243
+ ? projectFile(root, spec.system, `agents.${spec.name}.system`)
244
+ : join(root, PROMPTS_DIR, `${spec.name}.md`);
245
+ // A named prompt that is missing is `projectFile`'s to refuse; a
246
+ // conventional one that is missing is simply an agent without a role file.
247
+ if (spec.system || existsSync(role)) {
248
+ parts.push(promptFile(role, 'role', within(root, role)));
211
249
  }
212
250
  return parts;
213
251
  }
@@ -354,8 +392,16 @@ function memoryFor(spec) {
354
392
  * with the front end, which has to mount the same directory into the sandbox:
355
393
  * two answers to this question would mean `/memory` pointing somewhere the
356
394
  * graph is not.
395
+ *
396
+ * `override` is the host pointing the run at another graph — `zen run
397
+ * --memory`. It answers for a project that declares no memory at all, because
398
+ * naming a directory is the declaration; the agents bound to it are still the
399
+ * ones that said `memory: true`.
357
400
  */
358
- export function memoryDir(root, config) {
401
+ export function memoryDir(root, config, override) {
402
+ if (override) {
403
+ return resolve(root, override);
404
+ }
359
405
  if (!config.memory && !config.agents.some((a) => a.memory)) {
360
406
  return undefined;
361
407
  }
@@ -367,8 +413,8 @@ export function memoryDir(root, config) {
367
413
  * to it still counts: it is how a project declares the graph `zen memory`
368
414
  * inspects between runs.
369
415
  */
370
- async function openMemory(root, config, embedders) {
371
- const dir = memoryDir(root, config);
416
+ async function openMemory(root, config, embedders, override) {
417
+ const dir = memoryDir(root, config, override);
372
418
  if (!dir) {
373
419
  return undefined;
374
420
  }
package/dist/prompt.d.ts CHANGED
@@ -13,6 +13,13 @@ export interface PromptText {
13
13
  path?: string;
14
14
  /** opt-in wrapper tag, e.g. `intent` → `<intent>…</intent>` */
15
15
  section?: string;
16
+ /**
17
+ * The name of that document *as the model is told it*, written onto the
18
+ * wrapper tag as `src`. Deliberately not `path`: that one is absolute, so
19
+ * it would put a home directory in the prompt and give two machines two
20
+ * different cache prefixes for the same project.
21
+ */
22
+ src?: string;
16
23
  }
17
24
  /**
18
25
  * One contributor to a system prompt: literal text, text computed from the run
@@ -39,7 +46,7 @@ export type Instructions<TCtx = unknown> = string | ((ctx: TCtx, spec: RunSpec)
39
46
  * A prompt that must change without a restart is a different feature: rebuild
40
47
  * the agent, or use the function form.
41
48
  */
42
- export declare function promptFile(path: string, section?: string): PromptText;
49
+ export declare function promptFile(path: string, section?: string, src?: string): PromptText;
43
50
  /** A document that fed a prompt, with the exact bytes it contributed. */
44
51
  export interface PromptSource {
45
52
  path: string;
@@ -51,6 +58,8 @@ export interface ComposedPrompt {
51
58
  /** the editable contributors, in the order they were rendered */
52
59
  sources: PromptSource[];
53
60
  }
61
+ /** `&`, `<` and `"` are the three that would end the attribute or the tag. */
62
+ export declare function attribute(value: string): string;
54
63
  /**
55
64
  * Renders the authored parts, then the kernel's derived text, into one string —
56
65
  * and reports which documents went into it.
package/dist/prompt.js CHANGED
@@ -11,17 +11,34 @@ import { readFileSync } from 'node:fs';
11
11
  * A prompt that must change without a restart is a different feature: rebuild
12
12
  * the agent, or use the function form.
13
13
  */
14
- export function promptFile(path, section) {
14
+ export function promptFile(path, section, src) {
15
15
  const text = readFileSync(path, 'utf8');
16
- return section === undefined ? { text, path } : { text, path, section };
16
+ return {
17
+ text,
18
+ path,
19
+ ...(section === undefined ? {} : { section }),
20
+ ...(src === undefined ? {} : { src }),
21
+ };
17
22
  }
18
23
  const SEPARATOR = '\n\n';
24
+ /** `&`, `<` and `"` are the three that would end the attribute or the tag. */
25
+ export function attribute(value) {
26
+ return value.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/"/g, '&quot;');
27
+ }
19
28
  /**
20
29
  * Markers are opt-in because they are tokens the model reads: a wrapper that
21
30
  * "does not influence the content" does not exist for an LLM.
31
+ *
32
+ * `src` rides on the marker rather than being a line of prose above it for the
33
+ * same reason the marker exists at all — it is structure, and a model that can
34
+ * see where a block ends can see where it came from.
22
35
  */
23
36
  function wrap(part) {
24
- return part.section ? `<${part.section}>\n${part.text}\n</${part.section}>` : part.text;
37
+ if (!part.section) {
38
+ return part.text;
39
+ }
40
+ const src = part.src ? ` src="${attribute(part.src)}"` : '';
41
+ return `<${part.section}${src}>\n${part.text}\n</${part.section}>`;
25
42
  }
26
43
  /**
27
44
  * Renders the authored parts, then the kernel's derived text, into one string —
@@ -1,5 +1,5 @@
1
1
  import { readFile, readdir } from 'node:fs/promises';
2
- import { basename, join, resolve } from 'node:path';
2
+ import { basename, join, relative, resolve } from 'node:path';
3
3
  import { selectTools } from "../types.js";
4
4
  // ---------------------------------------------------------------------------
5
5
  // Filesystem-backed skill provider
@@ -83,6 +83,7 @@ export class FileSkillProvider {
83
83
  ...entry.summary,
84
84
  content: entry.content,
85
85
  file: entry.file,
86
+ src: entry.src,
86
87
  ...(tools.length ? { tools } : {}),
87
88
  ...(entry.at ? { path: entry.at } : {}),
88
89
  };
@@ -122,19 +123,20 @@ export class FileSkillProvider {
122
123
  // Only a folder skill has files of its own to point at, and
123
124
  // only when the host said where the directory can be reached.
124
125
  const at = folder && dir.at ? `${dir.at}/${basename(folder)}` : undefined;
125
- const entry = parse(raw, base, file, folder, at);
126
+ const entry = parse(raw, base, file, relative(dir.path, file), folder, at);
126
127
  index.set(entry.summary.name, entry);
127
128
  }
128
129
  }
129
130
  return index;
130
131
  }
131
132
  }
132
- function parse(raw, base, file, folder, at) {
133
+ function parse(raw, base, file, src, folder, at) {
133
134
  const { data, body } = frontmatter(raw);
134
135
  const tags = toList(data.tags);
135
136
  const tools = toList(data.tools);
136
137
  return {
137
138
  file,
139
+ src,
138
140
  folder,
139
141
  ...(at ? { at } : {}),
140
142
  content: body,
package/dist/skills.d.ts CHANGED
@@ -33,6 +33,13 @@ export interface Skill extends SkillSummary {
33
33
  path?: string;
34
34
  /** absolute path of the source file, if known (set by file-backed providers) */
35
35
  file?: string;
36
+ /**
37
+ * The source document *as the model is told it*, written onto the block as
38
+ * `src`. Deliberately not `file`: that one is absolute, so it would put a
39
+ * home directory in the prompt and give two machines two different cache
40
+ * prefixes for the same catalog.
41
+ */
42
+ src?: string;
36
43
  }
37
44
  export interface SkillProvider {
38
45
  readonly id: string;
package/dist/skills.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { hash } from "./payload.js";
2
+ import { attribute } from "./prompt.js";
2
3
  import { projected } from "./trajectory.js";
3
4
  import { SKILL_LOAD_TOOL, SKILL_SEARCH_TOOL, tool, withEffects, } from "./types.js";
4
5
  export function skillContentHash(s) {
@@ -7,15 +8,21 @@ export function skillContentHash(s) {
7
8
  export function renderSkills(skills) {
8
9
  return skills
9
10
  .map((s) => {
10
- const head = `## Skill: ${s.name}${s.version ? ` (${s.version})` : ''}`;
11
+ const attrs = [
12
+ ` name="${attribute(s.name)}"`,
13
+ s.version ? ` version="${attribute(s.version)}"` : '',
14
+ // Same contract as a prompt block's `src`: the document to edit,
15
+ // under a name that is the same on every machine.
16
+ s.src ? ` src="${attribute(s.src)}"` : '',
17
+ ].join('');
11
18
  // One line, and only when the files are reachable. Without it the
12
19
  // model has the instructions and no way to act on the half of them
13
20
  // that names a file.
14
21
  const where = s.path
15
- ? `\nThis skill's files are at ${s.path}, read-only. Paths written in it are ` +
16
- 'relative to that directory.\n'
22
+ ? `This skill's files are at ${s.path}, read-only. Paths written in it are ` +
23
+ 'relative to that directory.\n\n'
17
24
  : '';
18
- return `${head}${where}\n${s.content}`;
25
+ return `<skill${attrs}>\n${where}${s.content}\n</skill>`;
19
26
  })
20
27
  .join('\n\n');
21
28
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zenera/neo",
3
- "version": "1.1.16",
3
+ "version": "1.1.18",
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",