@zenera/neo 1.1.2 → 1.1.4
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 +7 -6
- package/dist/models/factory.d.ts +2 -1
- package/dist/models/factory.js +10 -2
- package/dist/project/config.d.ts +3 -0
- package/dist/project/config.js +28 -13
- package/dist/tools/sandbox.d.ts +11 -0
- package/dist/tools/sandbox.js +15 -0
- package/dist/tools/workspace.js +55 -4
- package/dist/types.d.ts +4 -3
- package/dist/types.js +21 -3
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -124,8 +124,9 @@ of surfacing three turns into a run as a confused model.
|
|
|
124
124
|
|
|
125
125
|
## What it gives you
|
|
126
126
|
|
|
127
|
-
- **Agents** — an instruction, a model, tools, skills,
|
|
128
|
-
There is no hidden orchestration
|
|
127
|
+
- **Agents** — an instruction, a model, tools, skills, who it may hand the work
|
|
128
|
+
to, and how it splits into parallel branches. There is no hidden orchestration
|
|
129
|
+
layer.
|
|
129
130
|
- **Models** — OpenAI, Anthropic, Google/Vertex and OpenRouter behind one
|
|
130
131
|
interface, each through its own SDK, plus any OpenAI-compatible endpoint.
|
|
131
132
|
Named providers and model aliases resolve from config or from the host.
|
|
@@ -135,9 +136,10 @@ of surfacing three turns into a run as a confused model.
|
|
|
135
136
|
permanently occupying the prompt, optionally owning their own tools.
|
|
136
137
|
- **Memory** — pluggable `MemoryStore`s, with scopes deciding what is private to
|
|
137
138
|
an agent and what is shared.
|
|
138
|
-
- **Trajectory** — an append-only log of everything that happened.
|
|
139
|
-
|
|
140
|
-
deleting them, so the
|
|
139
|
+
- **Trajectory** — an append-only log of everything that happened. What is sent
|
|
140
|
+
to a provider is derived from it, and compaction _covers_ nodes rather than
|
|
141
|
+
deleting them, so the full record survives even when older turns have to be
|
|
142
|
+
pushed out of the context window.
|
|
141
143
|
- **Inspection** — `renderReportHtml` turns any run into one self-contained HTML
|
|
142
144
|
file: the graph, every request, every token.
|
|
143
145
|
|
|
@@ -153,7 +155,6 @@ of surfacing three turns into a run as a confused model.
|
|
|
153
155
|
|
|
154
156
|
## Documentation
|
|
155
157
|
|
|
156
|
-
[DESIGN.md](https://github.com/andreyryabov/ZeneraNeo/blob/main/DESIGN.md) ·
|
|
157
158
|
[docs/projects.md](https://github.com/andreyryabov/ZeneraNeo/blob/main/docs/projects.md) ·
|
|
158
159
|
[docs/agents-yaml.md](https://github.com/andreyryabov/ZeneraNeo/blob/main/docs/agents-yaml.md)
|
|
159
160
|
· and
|
package/dist/models/factory.d.ts
CHANGED
|
@@ -46,7 +46,8 @@ export interface ProviderSpec extends Credentials {
|
|
|
46
46
|
/**
|
|
47
47
|
* vertex only: GCP project id. Falls back to `GOOGLE_CLOUD_PROJECT`, then
|
|
48
48
|
* to the `project_id` inside the service-account key file named by
|
|
49
|
-
* `GOOGLE_APPLICATION_CREDENTIALS`.
|
|
49
|
+
* `GOOGLE_APPLICATION_CREDENTIALS`. Ignored when an `apiKey` is given —
|
|
50
|
+
* express mode addresses no project.
|
|
50
51
|
*/
|
|
51
52
|
project?: string;
|
|
52
53
|
/** vertex only: a region, or `global` (env: `GOOGLE_CLOUD_LOCATION`, default `global`) */
|
package/dist/models/factory.js
CHANGED
|
@@ -429,6 +429,12 @@ function buildOpenRouter(kind, apiKey, baseURL, headers, opts) {
|
|
|
429
429
|
* application-default login` — and refreshes them itself. That is why `vertex`
|
|
430
430
|
* is `keyOptional`, and why it needs none of the bearer-token machinery the
|
|
431
431
|
* OpenAI protocol uses for the same job.
|
|
432
|
+
*
|
|
433
|
+
* A key and a project are alternatives, not a pair. An express-mode key
|
|
434
|
+
* authenticates against `aiplatform.googleapis.com` with no project in the
|
|
435
|
+
* path; naming a project as well makes the SDK build a project-scoped url and
|
|
436
|
+
* then authenticate it with the key header — a combination the service refuses,
|
|
437
|
+
* and one nothing in the resulting 403 attributes to having set both.
|
|
432
438
|
*/
|
|
433
439
|
function buildGenAI(kind, apiKey, baseURL, headers, opts, where) {
|
|
434
440
|
const { GoogleGenAI } = sdk('@google/genai', kind);
|
|
@@ -436,16 +442,18 @@ function buildGenAI(kind, apiKey, baseURL, headers, opts, where) {
|
|
|
436
442
|
if (kind !== 'vertex') {
|
|
437
443
|
return new GoogleGenAI({ apiKey, httpOptions });
|
|
438
444
|
}
|
|
445
|
+
if (apiKey) {
|
|
446
|
+
return new GoogleGenAI({ vertexai: true, apiKey, httpOptions });
|
|
447
|
+
}
|
|
439
448
|
const project = expand(opts.project, `${where}: project`) ??
|
|
440
449
|
fromEnv('GOOGLE_CLOUD_PROJECT') ??
|
|
441
450
|
projectFromKeyFile();
|
|
442
|
-
if (!project
|
|
451
|
+
if (!project) {
|
|
443
452
|
throw new Error(`${where}: vertex needs \`project\`, or GOOGLE_CLOUD_PROJECT ` +
|
|
444
453
|
`(or \`apiKey\` for express mode)`);
|
|
445
454
|
}
|
|
446
455
|
return new GoogleGenAI({
|
|
447
456
|
vertexai: true,
|
|
448
|
-
apiKey,
|
|
449
457
|
project,
|
|
450
458
|
location: expand(opts.location, `${where}: location`) ??
|
|
451
459
|
fromEnv('GOOGLE_CLOUD_LOCATION') ??
|
package/dist/project/config.d.ts
CHANGED
|
@@ -142,6 +142,7 @@ declare const sandbox: z.ZodObject<{
|
|
|
142
142
|
user: z.ZodOptional<z.ZodString>;
|
|
143
143
|
persist: z.ZodOptional<z.ZodBoolean>;
|
|
144
144
|
env: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
145
|
+
keys: z.ZodOptional<z.ZodBoolean>;
|
|
145
146
|
}, z.core.$strict>;
|
|
146
147
|
export declare const projectSchema: z.ZodObject<{
|
|
147
148
|
version: z.ZodDefault<z.ZodLiteral<1>>;
|
|
@@ -265,6 +266,7 @@ export declare const projectSchema: z.ZodObject<{
|
|
|
265
266
|
user: z.ZodOptional<z.ZodString>;
|
|
266
267
|
persist: z.ZodOptional<z.ZodBoolean>;
|
|
267
268
|
env: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
269
|
+
keys: z.ZodOptional<z.ZodBoolean>;
|
|
268
270
|
}, z.core.$strict>>;
|
|
269
271
|
agents: z.ZodArray<z.ZodObject<{
|
|
270
272
|
name: z.ZodString;
|
|
@@ -306,6 +308,7 @@ export declare const projectSchema: z.ZodObject<{
|
|
|
306
308
|
user: z.ZodOptional<z.ZodString>;
|
|
307
309
|
persist: z.ZodOptional<z.ZodBoolean>;
|
|
308
310
|
env: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
311
|
+
keys: z.ZodOptional<z.ZodBoolean>;
|
|
309
312
|
}, z.core.$strict>>;
|
|
310
313
|
default: z.ZodOptional<z.ZodBoolean>;
|
|
311
314
|
}, z.core.$strict>>;
|
package/dist/project/config.js
CHANGED
|
@@ -165,9 +165,11 @@ const forkBinding = z
|
|
|
165
165
|
*
|
|
166
166
|
* `env` names host variables to forward, and only names: values never appear
|
|
167
167
|
* in the config, so a repository never carries one. Names that read like a
|
|
168
|
-
* credential are refused outright
|
|
169
|
-
*
|
|
170
|
-
*
|
|
168
|
+
* credential are refused outright — not because credentials never belong in
|
|
169
|
+
* the sandbox, but because `env` is the wrong door for them. Model keys go
|
|
170
|
+
* through `keys`, which forwards a known set by name and can be turned off in
|
|
171
|
+
* one place; a hand-written `env: [OPENAI_API_KEY]` would be a second, silent
|
|
172
|
+
* way in that no single switch could close.
|
|
171
173
|
*/
|
|
172
174
|
const SECRETISH = /(KEY|TOKEN|SECRET|PASSWORD|PASSWD|CREDENTIAL)/i;
|
|
173
175
|
/**
|
|
@@ -207,6 +209,17 @@ const sandbox = z
|
|
|
207
209
|
persist: z.boolean().optional(),
|
|
208
210
|
/** host variables to forward, by name */
|
|
209
211
|
env: z.array(z.string().min(1)).optional(),
|
|
212
|
+
/**
|
|
213
|
+
* Whether the model credentials reach the sandbox. On by default,
|
|
214
|
+
* because the scaffolded image installs `zen` itself and an agent that
|
|
215
|
+
* cannot run `zen check` inside its own sandbox is a surprise.
|
|
216
|
+
*
|
|
217
|
+
* Turning it off is the honest answer for a project whose agents write
|
|
218
|
+
* code they then execute: the container runs whatever the model wrote,
|
|
219
|
+
* so a forwarded key is a key that code can read and, on a `bridge`
|
|
220
|
+
* network, send somewhere.
|
|
221
|
+
*/
|
|
222
|
+
keys: z.boolean().optional(),
|
|
210
223
|
})
|
|
211
224
|
.strict()
|
|
212
225
|
.superRefine((value, ctx) => {
|
|
@@ -218,16 +231,18 @@ const sandbox = z
|
|
|
218
231
|
'in its FROM line, so one of the two would be silently ignored',
|
|
219
232
|
});
|
|
220
233
|
}
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
}
|
|
234
|
+
// Disable key blacklisting for now.
|
|
235
|
+
// for (const [i, name] of (value.env ?? []).entries()) {
|
|
236
|
+
// if (SECRETISH.test(name)) {
|
|
237
|
+
// ctx.addIssue({
|
|
238
|
+
// code: 'custom',
|
|
239
|
+
// path: ['env', i],
|
|
240
|
+
// message:
|
|
241
|
+
// `refusing to forward ${name} into the sandbox — it reads like a ` +
|
|
242
|
+
// 'credential, and the sandbox runs whatever the model writes',
|
|
243
|
+
// });
|
|
244
|
+
// }
|
|
245
|
+
// }
|
|
231
246
|
});
|
|
232
247
|
const agent = z
|
|
233
248
|
.object({
|
package/dist/tools/sandbox.d.ts
CHANGED
|
@@ -29,6 +29,17 @@ export interface SandboxSpec {
|
|
|
29
29
|
persist?: boolean;
|
|
30
30
|
/** environment inside the container — resolved values, never a passthrough */
|
|
31
31
|
env?: Record<string, string>;
|
|
32
|
+
/**
|
|
33
|
+
* Variables forwarded from this process by *name*, for credentials.
|
|
34
|
+
*
|
|
35
|
+
* Separate from `env` for two reasons, both about the value never being
|
|
36
|
+
* written down. `--env NAME` without an `=` tells podman to read the value
|
|
37
|
+
* out of its own environment, so the secret never appears on a command
|
|
38
|
+
* line, in `ps`, or in `podman inspect`. And the container's identity is
|
|
39
|
+
* computed from the names alone, so rotating a key does not abandon a
|
|
40
|
+
* persisted container's filesystem.
|
|
41
|
+
*/
|
|
42
|
+
secrets?: readonly string[];
|
|
32
43
|
}
|
|
33
44
|
export type SandboxNetwork = 'bridge' | 'none' | 'host';
|
|
34
45
|
export interface SandboxMount {
|
package/dist/tools/sandbox.js
CHANGED
|
@@ -212,6 +212,14 @@ export class Sandbox {
|
|
|
212
212
|
for (const [k, v] of Object.entries(s.env)) {
|
|
213
213
|
args.push('--env', `${k}=${v}`);
|
|
214
214
|
}
|
|
215
|
+
// No `=`, and so no value: podman reads each one from its own
|
|
216
|
+
// environment. A key passed this way is not in the argv of a process
|
|
217
|
+
// any user on the machine can list.
|
|
218
|
+
for (const name of s.secrets) {
|
|
219
|
+
if (process.env[name]) {
|
|
220
|
+
args.push('--env', name);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
215
223
|
args.push(s.image, 'sleep', 'infinity');
|
|
216
224
|
return args;
|
|
217
225
|
}
|
|
@@ -422,6 +430,7 @@ function resolveSpec(opts) {
|
|
|
422
430
|
user: opts.user,
|
|
423
431
|
persist: opts.persist ?? false,
|
|
424
432
|
env: opts.env ?? {},
|
|
433
|
+
secrets: opts.secrets ?? [],
|
|
425
434
|
readOnly: opts.readOnly ?? false,
|
|
426
435
|
mounts: opts.mounts ?? [],
|
|
427
436
|
engine: opts.engine ?? 'podman',
|
|
@@ -431,6 +440,11 @@ function resolveSpec(opts) {
|
|
|
431
440
|
* The container's name is a function of its configuration. Change the image
|
|
432
441
|
* and a new container appears rather than an old one quietly persisting with
|
|
433
442
|
* the wrong rootfs — which is the failure mode `persist` would otherwise have.
|
|
443
|
+
*
|
|
444
|
+
* Secrets contribute their names and not their values: what a container is
|
|
445
|
+
* depends on which credentials reach it, not on what they happen to say today,
|
|
446
|
+
* and hashing the values would throw away a persisted container's filesystem
|
|
447
|
+
* every time a key was rotated.
|
|
434
448
|
*/
|
|
435
449
|
function containerName(spec) {
|
|
436
450
|
const digest = createHash('sha256')
|
|
@@ -445,6 +459,7 @@ function containerName(spec) {
|
|
|
445
459
|
spec.readOnly,
|
|
446
460
|
spec.mounts,
|
|
447
461
|
Object.entries(spec.env).sort(),
|
|
462
|
+
[...spec.secrets].sort(),
|
|
448
463
|
]))
|
|
449
464
|
.digest('hex')
|
|
450
465
|
.slice(0, 10);
|
package/dist/tools/workspace.js
CHANGED
|
@@ -638,9 +638,15 @@ export function workspaceTools(opts) {
|
|
|
638
638
|
: 'Paths are relative to the workspace root.') + bounds;
|
|
639
639
|
const inside = (ws.mount
|
|
640
640
|
? `Path in the workspace: ${ws.mount}/src/a.ts, or src/a.ts relative to the root — ` +
|
|
641
|
-
`the same file either way. The root itself is "${ws.mount}"
|
|
641
|
+
`the same file either way. The root itself is "${ws.mount}" or ".".`
|
|
642
642
|
: 'Path in the workspace, relative to its root: src/a.ts. The root itself is "." ' +
|
|
643
643
|
'or "/".') + (extras.length === 0 ? '' : ` Read-only: ${nameList(extras)}.`);
|
|
644
|
+
// With more than one tree in reach, "/" is not another name for the
|
|
645
|
+
// workspace root: it is where the trees hang, and listing it names them.
|
|
646
|
+
// One tree has nothing above it, so there "/" stays the root.
|
|
647
|
+
const trees = ws.mount && ws.mounts.length > 0
|
|
648
|
+
? [ws.mount, ...extras].map((name) => ({ name, kind: 'dir' }))
|
|
649
|
+
: undefined;
|
|
644
650
|
/**
|
|
645
651
|
* `.`, `/`, the mount and nothing at all are four spellings of the root, and
|
|
646
652
|
* a tool whose path is optional takes all four. A model that means "the top"
|
|
@@ -723,13 +729,20 @@ export function workspaceTools(opts) {
|
|
|
723
729
|
properties: {
|
|
724
730
|
path: {
|
|
725
731
|
type: 'string',
|
|
726
|
-
description:
|
|
732
|
+
description: trees
|
|
733
|
+
? `${inside} Omit it, or give "/", to list the directories that can be ` +
|
|
734
|
+
`reached: ${nameList(trees.map((t) => t.name))}.`
|
|
735
|
+
: `${inside} Omit it to list the root.`,
|
|
727
736
|
},
|
|
728
737
|
},
|
|
729
738
|
required: [],
|
|
730
739
|
additionalProperties: false,
|
|
731
740
|
},
|
|
732
741
|
execute: async ({ path }) => {
|
|
742
|
+
const asked = path?.trim() ?? '';
|
|
743
|
+
if (trees && (asked === '' || /^[\\/]+$/.test(asked))) {
|
|
744
|
+
return { path: '/', entries: trees };
|
|
745
|
+
}
|
|
733
746
|
const at = root(path);
|
|
734
747
|
const entries = await readdir(at, { withFileTypes: true });
|
|
735
748
|
const listed = [];
|
|
@@ -986,9 +999,47 @@ export function workspaceTools(opts) {
|
|
|
986
999
|
};
|
|
987
1000
|
},
|
|
988
1001
|
});
|
|
989
|
-
return opts.readOnly
|
|
1002
|
+
return named(ws, opts.readOnly
|
|
990
1003
|
? [readFileTool, listDir, findFiles]
|
|
991
|
-
: [readFileTool, listDir, findFiles, writeFileTool, applyPatch, movePath, deleteFile];
|
|
1004
|
+
: [readFileTool, listDir, findFiles, writeFileTool, applyPatch, movePath, deleteFile]);
|
|
1005
|
+
}
|
|
1006
|
+
/** What each errno means, said the way the rest of these tools say things. */
|
|
1007
|
+
const ERRNO = {
|
|
1008
|
+
ENOENT: 'does not exist',
|
|
1009
|
+
ENOTDIR: 'is not a directory',
|
|
1010
|
+
EISDIR: 'is a directory',
|
|
1011
|
+
EEXIST: 'already exists',
|
|
1012
|
+
ENOTEMPTY: 'is not empty',
|
|
1013
|
+
EACCES: 'cannot be reached',
|
|
1014
|
+
EPERM: 'cannot be reached',
|
|
1015
|
+
ELOOP: 'is a loop of symlinks',
|
|
1016
|
+
};
|
|
1017
|
+
/**
|
|
1018
|
+
* Every path these tools are given comes back under the name the model wrote
|
|
1019
|
+
* it — except when the filesystem itself fails, because Node's errors name the
|
|
1020
|
+
* host path: `ENOENT: no such file or directory, scandir '/Users/me/proj/src'`.
|
|
1021
|
+
* That is a name the model has never been shown, cannot pass to another tool,
|
|
1022
|
+
* and will read as evidence about where it is running. So a failure is
|
|
1023
|
+
* rewritten into the same vocabulary as a success before it leaves.
|
|
1024
|
+
*/
|
|
1025
|
+
function named(ws, tools) {
|
|
1026
|
+
return tools.map((t) => ({
|
|
1027
|
+
...t,
|
|
1028
|
+
execute: async (args, tc) => {
|
|
1029
|
+
try {
|
|
1030
|
+
return await t.execute(args, tc);
|
|
1031
|
+
}
|
|
1032
|
+
catch (err) {
|
|
1033
|
+
const e = err;
|
|
1034
|
+
if (typeof e?.code !== 'string' || typeof e.path !== 'string') {
|
|
1035
|
+
throw err;
|
|
1036
|
+
}
|
|
1037
|
+
throw new Error(`${ws.show(e.path)} ${ERRNO[e.code] ?? `failed: ${e.code}`}`, {
|
|
1038
|
+
cause: err,
|
|
1039
|
+
});
|
|
1040
|
+
}
|
|
1041
|
+
},
|
|
1042
|
+
}));
|
|
992
1043
|
}
|
|
993
1044
|
async function runPatch(ws, patch) {
|
|
994
1045
|
const ops = parsePatch(patch);
|
package/dist/types.d.ts
CHANGED
|
@@ -174,9 +174,10 @@ export interface ToolSelection {
|
|
|
174
174
|
}
|
|
175
175
|
/**
|
|
176
176
|
* Resolves selectors against the tools a host provided, in the order written
|
|
177
|
-
* and without duplicates. A selector is a tool name, `<group
|
|
178
|
-
* in
|
|
179
|
-
* what it matches from
|
|
177
|
+
* and without duplicates. A selector is a tool name, `<group>:<name>` for the
|
|
178
|
+
* same tool written out in full, `<group>:*` for every tool in a group, or `*`
|
|
179
|
+
* for everything; any of them prefixed with `-` removes what it matches from
|
|
180
|
+
* the selection so far.
|
|
180
181
|
*/
|
|
181
182
|
export declare function selectTools<TCtx>(available: AnyTool<TCtx>[], selectors: string[], opts: ToolSelection): AnyTool<TCtx>[];
|
|
182
183
|
export declare const HANDOFF_PREFIX = "transfer_to_";
|
package/dist/types.js
CHANGED
|
@@ -59,9 +59,10 @@ export function tool(def) {
|
|
|
59
59
|
}
|
|
60
60
|
/**
|
|
61
61
|
* Resolves selectors against the tools a host provided, in the order written
|
|
62
|
-
* and without duplicates. A selector is a tool name, `<group
|
|
63
|
-
* in
|
|
64
|
-
* what it matches from
|
|
62
|
+
* and without duplicates. A selector is a tool name, `<group>:<name>` for the
|
|
63
|
+
* same tool written out in full, `<group>:*` for every tool in a group, or `*`
|
|
64
|
+
* for everything; any of them prefixed with `-` removes what it matches from
|
|
65
|
+
* the selection so far.
|
|
65
66
|
*/
|
|
66
67
|
export function selectTools(available, selectors, opts) {
|
|
67
68
|
const chosen = new Map();
|
|
@@ -100,6 +101,23 @@ function matchTools(available, selector, opts) {
|
|
|
100
101
|
if (exact) {
|
|
101
102
|
return [exact];
|
|
102
103
|
}
|
|
104
|
+
// `workspace:read_file` is the same thing as `read_file`: an author who
|
|
105
|
+
// writes `workspace:*` on one line should not be told the qualified form
|
|
106
|
+
// is a typo on the next.
|
|
107
|
+
const colon = selector.lastIndexOf(':');
|
|
108
|
+
if (colon > 0) {
|
|
109
|
+
const group = selector.slice(0, colon);
|
|
110
|
+
const name = selector.slice(colon + 1);
|
|
111
|
+
const qualified = available.find((t) => t.group === group && t.name === name);
|
|
112
|
+
if (qualified) {
|
|
113
|
+
return [qualified];
|
|
114
|
+
}
|
|
115
|
+
const elsewhere = available.find((t) => t.name === name);
|
|
116
|
+
if (elsewhere) {
|
|
117
|
+
throw new Error(`${opts.where}: unknown tool "${selector}" ` +
|
|
118
|
+
`("${name}" is in group "${elsewhere.group ?? 'none'}", not "${group}")`);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
103
121
|
const known = available.map((t) => t.name).join(', ') || 'none';
|
|
104
122
|
throw new Error(`${opts.where}: unknown tool "${selector}" ` +
|
|
105
123
|
(opts.hint ? `(${opts.hint}; known: ${known})` : `(known: ${known})`));
|