@zenera/engine 0.0.0-stage → 1.1.37

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Andrey Ryabov
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,43 @@
1
- # Temporary Holding Version
1
+ # @zenera/engine
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ **Runs a [`@zenera/neo`](../neo/README.md) project the way `zen run` does - for any host, not only the terminal.**
4
+
5
+ > Part of [ZeneraNeo](https://github.com/andreyryabov/ZeneraNeo). `zen` is built on it; a server
6
+ > embedding agent projects uses the same code.
7
+
8
+ `@zenera/neo` is the runtime. This package is the wiring around it that makes a project folder
9
+ runnable on a machine: the keyring and the project's `.env`, the sandbox (image, mounts, forwarded
10
+ credentials), a session directory that persists the conversation, and the record every turn leaves
11
+ behind (`state.json`, `report.html`, run metadata).
12
+
13
+ It never prints and never prompts. Warnings go to the `Logger` you pass, errors are `EngineError`s
14
+ with a code, and installing a container engine is a `preflight` the host supplies.
15
+
16
+ ## Use
17
+
18
+ ```ts
19
+ import { createSessionAt, openEngine, runEngine } from '@zenera/engine';
20
+
21
+ const session = createSessionAt(
22
+ '/data/users/u1/sessions/t1',
23
+ '/data/users/u1/sessions/t1/workspace',
24
+ );
25
+ const engine = await openEngine({
26
+ project: { dir: '/project', name: 'acme' },
27
+ session,
28
+ memoryDir: '/data/users/u1/memory',
29
+ logger: { warn: (m) => console.warn(m) },
30
+ });
31
+ try {
32
+ const outcome = await runEngine(engine, 'What changed?', (event) => {
33
+ if (event.type === 'text_delta') process.stdout.write(event.delta);
34
+ });
35
+ console.log(outcome.text);
36
+ } finally {
37
+ await engine.close();
38
+ }
39
+ ```
40
+
41
+ ## License
42
+
43
+ [MIT](LICENSE).
@@ -0,0 +1,63 @@
1
+ import { type ModelRequirement } from '@zenera/neo';
2
+ import { type KeyOwner, type KeyStore } from './keys.ts';
3
+ /** How a host emphasises a name and dims a fix; plain text by default. */
4
+ export interface IssueStyle {
5
+ bold: (s: string) => string;
6
+ dim: (s: string) => string;
7
+ }
8
+ /** Which key of the config named a reference: `model:`/`models:` or `embedding:`/`embeddings:`. */
9
+ export type DeclaredRole = 'model' | 'embedding';
10
+ export interface ModelIssue {
11
+ /** the model as the config names it: an alias, or the ref itself */
12
+ name: string;
13
+ /** what the config declared it for */
14
+ role: DeclaredRole;
15
+ /** the provider it resolves to */
16
+ provider: string;
17
+ /** the variable that would carry the credential */
18
+ env: string;
19
+ /** `missing` — nothing to authenticate with. `dead` — rejected when checked.
20
+ * `blocked` — accepted, and then refused by the account behind it. */
21
+ reason: 'missing' | 'dead' | 'blocked';
22
+ /** the provider's own words, when it was the one to say no */
23
+ detail?: string;
24
+ /** what to do about a refusal that a new key would not fix */
25
+ fix?: string;
26
+ /** the keyring provider the fix names, when the kind is one of them */
27
+ add?: KeyOwner;
28
+ }
29
+ /**
30
+ * Whether a requirement is actually met, and under which variable.
31
+ *
32
+ * `satisfied` is the library's answer and it is about api keys, which is the
33
+ * wrong question for Vertex: that kind is `keyOptional` because it can
34
+ * authenticate from a service-account file instead. So both of its variables
35
+ * are looked for, along with the file `gcloud` leaves behind.
36
+ *
37
+ * The variable named back is the one that is actually set, because a report
38
+ * that named `VERTEX_API_KEY` while the audit silently checked
39
+ * `GOOGLE_APPLICATION_CREDENTIALS` would be two answers to one question, and
40
+ * the wrong one is the one people would act on. With nothing set there is no
41
+ * such answer, so it names the form the provider is usually reached by.
42
+ */
43
+ export declare function credentialFor(need: ModelRequirement): {
44
+ env: string;
45
+ present: boolean;
46
+ };
47
+ /**
48
+ * Reports the models a run could not reach. Best effort by design: a config
49
+ * that will not parse, or that names a provider that does not exist, is the
50
+ * loader's to report — precisely, and with the offending key named. Guessing
51
+ * at it here would only produce a worse version of the same message.
52
+ *
53
+ * Call *after* `KeyStore.materialize()`, so the keyring's keys are as visible
54
+ * here as they will be to the library.
55
+ */
56
+ export declare function auditModels(projectDir: string, store: KeyStore): ModelIssue[];
57
+ /**
58
+ * One line, and the command that fixes it. A warning nobody can act on is
59
+ * noise, so the fix is part of the sentence rather than something to go and
60
+ * look up.
61
+ */
62
+ export declare function describeIssue(issue: ModelIssue, { bold, dim }?: IssueStyle): string;
63
+ //# sourceMappingURL=audit.d.ts.map
package/dist/audit.js ADDED
@@ -0,0 +1,148 @@
1
+ import { projectRegistry, readProjectConfig, } from '@zenera/neo';
2
+ import { envNames, form, gcloudAdc, isOwner, isProvider, SHAPES, } from "./keys.js";
3
+ const PLAIN = { bold: (s) => s, dim: (s) => s };
4
+ /**
5
+ * Every model the project names, keyed by how one would refer to it.
6
+ *
7
+ * The `models:` table comes first so a declared alias keeps its own name in the
8
+ * report; a `model:` that names one collapses onto it rather than appearing
9
+ * twice, and a `model:` that names no alias is a shorthand standing for itself.
10
+ *
11
+ * Embeddings are walked the same way and kept in their own list: an alias may
12
+ * legitimately appear under both keys meaning two different things, and they
13
+ * are two credentials to check either way.
14
+ */
15
+ function declared(config) {
16
+ // The schema widens `reasoningEffort` to `string` on purpose, which is the
17
+ // one thing keeping a config's model spec from being a `ModelSpec`. The
18
+ // loader hands the same cast to `models.model()` for the same reason.
19
+ const models = new Map(Object.entries(config.models ?? {}));
20
+ const add = (into, ref) => {
21
+ if (ref && !into.has(ref)) {
22
+ into.set(ref, ref);
23
+ }
24
+ };
25
+ add(models, config.model);
26
+ for (const agent of config.agents) {
27
+ add(models, agent.model);
28
+ }
29
+ const embeddings = new Map(Object.entries(config.embeddings ?? {}));
30
+ add(embeddings, config.embedding);
31
+ return new Map([
32
+ ['model', models],
33
+ ['embedding', embeddings],
34
+ ]);
35
+ }
36
+ /**
37
+ * Whether a requirement is actually met, and under which variable.
38
+ *
39
+ * `satisfied` is the library's answer and it is about api keys, which is the
40
+ * wrong question for Vertex: that kind is `keyOptional` because it can
41
+ * authenticate from a service-account file instead. So both of its variables
42
+ * are looked for, along with the file `gcloud` leaves behind.
43
+ *
44
+ * The variable named back is the one that is actually set, because a report
45
+ * that named `VERTEX_API_KEY` while the audit silently checked
46
+ * `GOOGLE_APPLICATION_CREDENTIALS` would be two answers to one question, and
47
+ * the wrong one is the one people would act on. With nothing set there is no
48
+ * such answer, so it names the form the provider is usually reached by.
49
+ */
50
+ export function credentialFor(need) {
51
+ if (need.gateway) {
52
+ return { env: need.apiKeyEnv, present: need.satisfied };
53
+ }
54
+ const provider = isProvider(need.kind) ? need.kind : undefined;
55
+ // A provider with a file form can authenticate without an api key, which is
56
+ // the only thing `satisfied` knows how to look for.
57
+ if (provider && SHAPES[provider].forms.some((f) => f.holds === 'file')) {
58
+ const set = envNames(provider).find((name) => process.env[name]);
59
+ return { env: set ?? form(provider).env, present: Boolean(set) || Boolean(gcloudAdc()) };
60
+ }
61
+ return { env: need.apiKeyEnv, present: need.satisfied };
62
+ }
63
+ /**
64
+ * Reports the models a run could not reach. Best effort by design: a config
65
+ * that will not parse, or that names a provider that does not exist, is the
66
+ * loader's to report — precisely, and with the offending key named. Guessing
67
+ * at it here would only produce a worse version of the same message.
68
+ *
69
+ * Call *after* `KeyStore.materialize()`, so the keyring's keys are as visible
70
+ * here as they will be to the library.
71
+ */
72
+ export function auditModels(projectDir, store) {
73
+ let config;
74
+ try {
75
+ config = readProjectConfig(projectDir).config;
76
+ }
77
+ catch {
78
+ return [];
79
+ }
80
+ const registry = projectRegistry(config);
81
+ const issues = [];
82
+ for (const [role, refs] of declared(config)) {
83
+ for (const [name, ref] of refs) {
84
+ let need;
85
+ try {
86
+ need = registry.requirement(ref);
87
+ }
88
+ catch {
89
+ continue;
90
+ }
91
+ const { env, present } = credentialFor(need);
92
+ const provider = need.gateway && isOwner(need.gateway)
93
+ ? need.gateway
94
+ : isProvider(need.kind)
95
+ ? need.kind
96
+ : undefined;
97
+ if (!present) {
98
+ issues.push({
99
+ name,
100
+ role,
101
+ provider: need.provider,
102
+ env,
103
+ reason: 'missing',
104
+ add: provider,
105
+ });
106
+ continue;
107
+ }
108
+ // A key that the provider itself rejected last time it was asked.
109
+ // Said as a warning rather than an error because a key can be
110
+ // reinstated between the check and the run, and a stale verdict must
111
+ // not be the thing that stops a run from being attempted.
112
+ const check = provider ? store.active(provider)?.check : undefined;
113
+ if (check?.state === 'dead' || check?.state === 'blocked') {
114
+ issues.push({
115
+ name,
116
+ role,
117
+ provider: need.provider,
118
+ env,
119
+ reason: check.state,
120
+ detail: check.detail,
121
+ fix: check.fix,
122
+ add: provider,
123
+ });
124
+ }
125
+ }
126
+ }
127
+ return issues;
128
+ }
129
+ /**
130
+ * One line, and the command that fixes it. A warning nobody can act on is
131
+ * noise, so the fix is part of the sentence rather than something to go and
132
+ * look up.
133
+ */
134
+ export function describeIssue(issue, { bold, dim } = PLAIN) {
135
+ const what = `${bold(issue.name)} (${issue.provider}${issue.role === 'embedding' ? ', embedding' : ''})`;
136
+ if (issue.reason === 'missing') {
137
+ const fix = issue.add ? `zen key add ${issue.add}` : `set ${issue.env}`;
138
+ return `${what} has no credential — ${issue.env} is not set; ${dim(fix)}`;
139
+ }
140
+ const why = issue.detail ? `: ${issue.detail}` : '';
141
+ if (issue.reason === 'blocked') {
142
+ const list = `zen models ls --${issue.role === 'embedding' ? 'embeddings' : 'chat'}`;
143
+ return `${what} authenticated and was then refused${why} — ${dim(issue.fix ?? list)}`;
144
+ }
145
+ const fix = `zen key check ${issue.add ?? ''}`.trim();
146
+ return `${what} was rejected when last checked${why} — ${dim(fix)}`;
147
+ }
148
+ //# sourceMappingURL=audit.js.map
@@ -0,0 +1,117 @@
1
+ import { AgentRunner, Workspace, type AgentEvent, type AgentProject, type AgentState, type Input, type MemoryIndex, type RunResult } from '@zenera/neo';
2
+ import { type ModelIssue } from './audit.ts';
3
+ import { type Logger } from './errors.ts';
4
+ import { type Project } from './projects.ts';
5
+ import { type Preflight, type SandboxSetup } from './sandbox.ts';
6
+ import { type Held, type RunPaths, type SessionPaths } from './session.ts';
7
+ /** A run drawn for a model to read, written beside it as `graph.mmd`. */
8
+ export type GraphRenderer = (state: AgentState, run: {
9
+ runId: string;
10
+ dir: string;
11
+ workspace: string;
12
+ memory?: string;
13
+ }) => string;
14
+ export interface EngineOptions {
15
+ project: Project;
16
+ session: SessionPaths;
17
+ readOnly?: boolean;
18
+ /** default model override — `--model` */
19
+ model?: string;
20
+ /** sandbox image override — `--image` */
21
+ image?: string;
22
+ /** another directory to remember into — `--memory` */
23
+ memoryDir?: string;
24
+ /** recall only: nothing is written and several runs may share one graph */
25
+ memoryReadOnly?: boolean;
26
+ /**
27
+ * An already-open graph to remember into instead of opening `memoryDir`. A
28
+ * memory directory is locked by whoever opens it, so a host running several
29
+ * sessions over one graph opens it once and hands the same index to each.
30
+ * Name its directory in `memoryDir` too: that is what is mounted at `/memory`.
31
+ */
32
+ memory?: MemoryIndex;
33
+ /** whether credentials reach the sandbox — `--no-keys` sets this false */
34
+ keys?: boolean;
35
+ /** answer the sandbox's install question without asking — `--yes` */
36
+ yes?: boolean;
37
+ /**
38
+ * Hold back the warnings about the project rather than the run. A batch
39
+ * opens one project a hundred times, and a hundred copies of the same
40
+ * complaint is a worse report than one.
41
+ */
42
+ quiet?: boolean;
43
+ /**
44
+ * The session will never be resumed, so its containers are removed on close
45
+ * whatever `persist:` says. A stopped one would only hold an engine lock.
46
+ */
47
+ disposable?: boolean;
48
+ /** where warnings go; stderr when unset */
49
+ logger?: Logger;
50
+ /** a model the run cannot reach; `logger.warn` with a plain sentence when unset */
51
+ onIssue?: (issue: ModelIssue) => void;
52
+ /** readies the container engine when the project can shell out; nothing when unset */
53
+ preflight?: Preflight;
54
+ /** draws each finished run as `graph.mmd`; no graph when unset */
55
+ graph?: GraphRenderer;
56
+ }
57
+ export interface Engine {
58
+ project: AgentProject;
59
+ runner: AgentRunner;
60
+ name: string;
61
+ workspace: string;
62
+ /** where the workspace appears inside the container */
63
+ workspaceMount: string;
64
+ /** every tree the file tools reach, and the names they reach them by */
65
+ files: Workspace;
66
+ /** whether the agent was denied every writing tool */
67
+ readOnly: boolean;
68
+ session: SessionPaths;
69
+ /** the graph this session remembers into, when any agent has one */
70
+ memory?: string;
71
+ /** the session's accumulated state, when it has one */
72
+ state?: AgentState;
73
+ /** the containers this session may start; present whether or not it does */
74
+ sandbox: SandboxSetup;
75
+ lock: Held;
76
+ graph?: GraphRenderer;
77
+ close(): Promise<void>;
78
+ }
79
+ /**
80
+ * Wires the library to a session directory. Every store is file-backed and
81
+ * rooted inside `.data/`, so a session is self-contained: copy the directory
82
+ * and the whole conversation, its memory and its blobs travel with it.
83
+ */
84
+ export declare function openEngine(opts: EngineOptions): Promise<Engine>;
85
+ export interface Mount {
86
+ /** the directory on this machine */
87
+ host: string;
88
+ /** where it appears to the agent */
89
+ at: string;
90
+ readOnly: boolean;
91
+ }
92
+ /** Every tree the agent can see, the workspace first. */
93
+ export declare function mounts(engine: Engine): Mount[];
94
+ export interface RunOutcome {
95
+ run: RunPaths;
96
+ result: RunResult<string>;
97
+ text: string;
98
+ durationMs: number;
99
+ /** where the report landed, when one could be rendered */
100
+ report?: string;
101
+ /** where the Mermaid graph landed, when one could be emitted */
102
+ graph?: string;
103
+ }
104
+ /**
105
+ * What one finished run looks like to a program: `zen run --json` prints it,
106
+ * and a batch writes one beside every item. The same function on purpose — a
107
+ * batch whose items were shaped differently would make every consumer of a
108
+ * single run a special case.
109
+ */
110
+ export declare function envelope(engine: Engine, outcome: RunOutcome): Record<string, unknown>;
111
+ /**
112
+ * One turn. The session's state decides whether this starts a run or continues
113
+ * one — which is the whole of what `resume` used to be, and why it is not a
114
+ * command.
115
+ */
116
+ export declare function runEngine(engine: Engine, input: Input, onEvent?: (event: AgentEvent) => void, signal?: AbortSignal): Promise<RunOutcome>;
117
+ //# sourceMappingURL=engine.d.ts.map