@zenera/engine 0.0.0-stage → 1.1.39
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 +21 -0
- package/README.md +42 -2
- package/dist/audit.d.ts +63 -0
- package/dist/audit.js +148 -0
- package/dist/engine.d.ts +117 -0
- package/dist/engine.js +315 -0
- package/dist/env.d.ts +20 -0
- package/dist/env.js +65 -0
- package/dist/errors.d.ts +28 -0
- package/dist/errors.js +41 -0
- package/dist/home.d.ts +35 -0
- package/dist/home.js +112 -0
- package/dist/ids.d.ts +12 -0
- package/dist/ids.js +44 -0
- package/dist/image.d.ts +16 -0
- package/dist/image.js +85 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.js +12 -0
- package/dist/keys.d.ts +269 -0
- package/dist/keys.js +624 -0
- package/dist/projects.d.ts +91 -0
- package/dist/projects.js +303 -0
- package/dist/sandbox.d.ts +66 -0
- package/dist/sandbox.js +199 -0
- package/dist/session.d.ts +118 -0
- package/dist/session.js +197 -0
- package/package.json +48 -5
package/dist/engine.js
ADDED
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
import { AgentRunner, FilePayloadStore, SANDBOX_MOUNT, SKILLS_MOUNT, Workspace, assertState, buildRunReport, exaTools, fileTools, lastText, loadProject, memoryDir, readProjectConfig, renderReportHtml, sandboxTools, turns, } from '@zenera/neo';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { writeFile } from 'node:fs/promises';
|
|
4
|
+
import { resolve } from 'node:path';
|
|
5
|
+
import { pathToFileURL } from 'node:url';
|
|
6
|
+
import { auditModels, describeIssue } from "./audit.js";
|
|
7
|
+
import { loadProjectEnv } from "./env.js";
|
|
8
|
+
import { EXIT, EngineError, invalidError, stderrLogger } from "./errors.js";
|
|
9
|
+
import { readJson, writeJson } from "./home.js";
|
|
10
|
+
import { KeyStore, assertUsable } from "./keys.js";
|
|
11
|
+
import { projectMounts } from "./projects.js";
|
|
12
|
+
import { buildSandbox, prepareHome, teardown, usesSandbox, } from "./sandbox.js";
|
|
13
|
+
import { acquire, createRun, readSessionMeta, writeRunMeta, writeSessionMeta, } from "./session.js";
|
|
14
|
+
/**
|
|
15
|
+
* Wires the library to a session directory. Every store is file-backed and
|
|
16
|
+
* rooted inside `.data/`, so a session is self-contained: copy the directory
|
|
17
|
+
* and the whole conversation, its memory and its blobs travel with it.
|
|
18
|
+
*/
|
|
19
|
+
export async function openEngine(opts) {
|
|
20
|
+
const logger = opts.logger ?? stderrLogger;
|
|
21
|
+
const onIssue = opts.onIssue ?? ((issue) => logger.warn(describeIssue(issue)));
|
|
22
|
+
// Before the keyring, so the project's own file wins over the machine's
|
|
23
|
+
// keys, and before the load, so `${VAR}` in agents.yaml can name anything
|
|
24
|
+
// in it. The real environment still beats both.
|
|
25
|
+
const env = loadProjectEnv(opts.project.dir);
|
|
26
|
+
const keys = await KeyStore.open();
|
|
27
|
+
keys.materialize();
|
|
28
|
+
assertUsable(keys);
|
|
29
|
+
// Said before the load, because the load stops at the first model it cannot
|
|
30
|
+
// build: one SDK's words about one model, when the useful answer is which
|
|
31
|
+
// of the project's models are reachable and which are not.
|
|
32
|
+
for (const issue of auditModels(opts.project.dir, keys)) {
|
|
33
|
+
if (!opts.quiet) {
|
|
34
|
+
onIssue(issue);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
const meta = await readSessionMeta(opts.session);
|
|
38
|
+
const workspace = resolve(meta.workspace);
|
|
39
|
+
const payloads = new FilePayloadStore({ dir: opts.session.blobs, id: 'file' });
|
|
40
|
+
// The config is read before the project is loaded because the sandbox is
|
|
41
|
+
// configured by it and the tools have to exist before selectors can be
|
|
42
|
+
// resolved against them. They are always registered, whether or not anyone
|
|
43
|
+
// selects them: a project that names `sandbox:*` against an empty tool list
|
|
44
|
+
// fails to load with "unknown tool", which would be a lie.
|
|
45
|
+
let sandbox;
|
|
46
|
+
let project;
|
|
47
|
+
let files;
|
|
48
|
+
let memory;
|
|
49
|
+
try {
|
|
50
|
+
const { root, config } = readProjectConfig(opts.project.dir);
|
|
51
|
+
// Naming a directory declares the graph, but it cannot bind anyone to
|
|
52
|
+
// it — so say when the run is pointed at a memory no agent will touch.
|
|
53
|
+
if (opts.memoryDir && !opts.quiet && !config.agents.some((a) => a.memory)) {
|
|
54
|
+
logger.warn('--memory names a directory no agent uses — none has `memory: true`');
|
|
55
|
+
}
|
|
56
|
+
memory = memoryDir(root, config, opts.memoryDir);
|
|
57
|
+
// Assets and the skill catalog are mounted for both, under one name.
|
|
58
|
+
const mounts = projectMounts(root, config, opts.memoryDir);
|
|
59
|
+
sandbox = buildSandbox({
|
|
60
|
+
config,
|
|
61
|
+
root,
|
|
62
|
+
session: opts.session,
|
|
63
|
+
workspace,
|
|
64
|
+
readOnly: opts.readOnly,
|
|
65
|
+
image: opts.image,
|
|
66
|
+
keys: opts.keys,
|
|
67
|
+
env: env?.names,
|
|
68
|
+
mounts,
|
|
69
|
+
...(opts.disposable ? { persist: false } : {}),
|
|
70
|
+
});
|
|
71
|
+
const workspaceOptions = {
|
|
72
|
+
root: workspace,
|
|
73
|
+
readOnly: opts.readOnly,
|
|
74
|
+
mount: sandbox.spec.workdir ?? SANDBOX_MOUNT,
|
|
75
|
+
mounts,
|
|
76
|
+
};
|
|
77
|
+
// The same view the file tools have, so a path the agent just wrote
|
|
78
|
+
// resolves the same way when it asks memory to keep a copy.
|
|
79
|
+
files = new Workspace(workspaceOptions);
|
|
80
|
+
project = await loadProject(opts.project.dir, {
|
|
81
|
+
tools: [
|
|
82
|
+
// Both groups are pointed at one directory, so both are told the
|
|
83
|
+
// one name for it: whatever `run_command` prints a path as,
|
|
84
|
+
// `read_file` accepts.
|
|
85
|
+
...fileTools(workspaceOptions),
|
|
86
|
+
...sandboxTools(sandbox.pool),
|
|
87
|
+
// Registered whether or not a key exists: the credential is
|
|
88
|
+
// read when a tool is called, so a project that names `exa:*`
|
|
89
|
+
// loads on a machine that cannot yet search, and says so on the
|
|
90
|
+
// turn that tried.
|
|
91
|
+
...exaTools(),
|
|
92
|
+
],
|
|
93
|
+
skillsAt: SKILLS_MOUNT,
|
|
94
|
+
memory: opts.memory,
|
|
95
|
+
memoryDir: opts.memoryDir,
|
|
96
|
+
memoryReadOnly: opts.memoryReadOnly,
|
|
97
|
+
payloads,
|
|
98
|
+
resolveFile: (path) => files.within(path),
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
catch (err) {
|
|
102
|
+
throw invalidError(err instanceof Error ? err.message : String(err), `while loading ${opts.project.dir}`);
|
|
103
|
+
}
|
|
104
|
+
// Asked here rather than at the first `run_command`, so a host with no
|
|
105
|
+
// container engine costs an error instead of a round trip. Nothing is
|
|
106
|
+
// started: the container itself waits until something actually runs.
|
|
107
|
+
if (usesSandbox(project)) {
|
|
108
|
+
prepareHome(sandbox);
|
|
109
|
+
await opts.preflight?.(sandbox, opts.yes);
|
|
110
|
+
}
|
|
111
|
+
// `recordRequests` is what makes the report show the exact bytes sent
|
|
112
|
+
// rather than a reconstruction. It costs state size, and a CLI run is
|
|
113
|
+
// written to disk once and inspected by a human — exactly the case the
|
|
114
|
+
// library leaves it off for by default.
|
|
115
|
+
//
|
|
116
|
+
// `--model` becomes the runner's fallback rather than an alias, so it
|
|
117
|
+
// overrides the config's `model:` without touching agents that pinned one.
|
|
118
|
+
const runner = project.runner({
|
|
119
|
+
recordRequests: true,
|
|
120
|
+
model: opts.model ? project.models.model(opts.model) : undefined,
|
|
121
|
+
});
|
|
122
|
+
const state = await loadState(opts.session);
|
|
123
|
+
const lock = acquire(opts.session);
|
|
124
|
+
return {
|
|
125
|
+
project,
|
|
126
|
+
runner,
|
|
127
|
+
name: opts.project.name,
|
|
128
|
+
workspace,
|
|
129
|
+
workspaceMount: sandbox.spec.workdir ?? SANDBOX_MOUNT,
|
|
130
|
+
files,
|
|
131
|
+
readOnly: Boolean(opts.readOnly),
|
|
132
|
+
session: opts.session,
|
|
133
|
+
memory,
|
|
134
|
+
state,
|
|
135
|
+
sandbox,
|
|
136
|
+
lock,
|
|
137
|
+
graph: opts.graph,
|
|
138
|
+
close: async () => {
|
|
139
|
+
await teardown(sandbox.pool, logger);
|
|
140
|
+
// A host-supplied graph is the host's to release.
|
|
141
|
+
if (!opts.memory) {
|
|
142
|
+
project.close();
|
|
143
|
+
}
|
|
144
|
+
lock.release();
|
|
145
|
+
},
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
async function loadState(session) {
|
|
149
|
+
if (!existsSync(session.state)) {
|
|
150
|
+
return undefined;
|
|
151
|
+
}
|
|
152
|
+
const json = await readJson(session.state, undefined);
|
|
153
|
+
try {
|
|
154
|
+
return assertState(json);
|
|
155
|
+
}
|
|
156
|
+
catch {
|
|
157
|
+
throw invalidError(`${session.state} is not a usable run state`, 'start a fresh session with --new');
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
/** Every tree the agent can see, the workspace first. */
|
|
161
|
+
export function mounts(engine) {
|
|
162
|
+
return [
|
|
163
|
+
{ host: engine.workspace, at: engine.workspaceMount, readOnly: engine.readOnly },
|
|
164
|
+
...engine.sandbox.mounts.map((m) => ({
|
|
165
|
+
host: m.host,
|
|
166
|
+
at: m.at,
|
|
167
|
+
readOnly: Boolean(m.readOnly),
|
|
168
|
+
})),
|
|
169
|
+
];
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* What one finished run looks like to a program: `zen run --json` prints it,
|
|
173
|
+
* and a batch writes one beside every item. The same function on purpose — a
|
|
174
|
+
* batch whose items were shaped differently would make every consumer of a
|
|
175
|
+
* single run a special case.
|
|
176
|
+
*/
|
|
177
|
+
export function envelope(engine, outcome) {
|
|
178
|
+
return {
|
|
179
|
+
session: { id: engine.session.id, dir: engine.session.dir },
|
|
180
|
+
run: {
|
|
181
|
+
id: outcome.run.id,
|
|
182
|
+
dir: outcome.run.dir,
|
|
183
|
+
input: outcome.run.input,
|
|
184
|
+
output: outcome.run.output,
|
|
185
|
+
state: outcome.run.state,
|
|
186
|
+
meta: outcome.run.meta,
|
|
187
|
+
...(outcome.report ? { report: outcome.report } : {}),
|
|
188
|
+
...(outcome.graph ? { graph: outcome.graph } : {}),
|
|
189
|
+
},
|
|
190
|
+
mounts: mounts(engine),
|
|
191
|
+
agent: outcome.result.agent,
|
|
192
|
+
stopReason: outcome.result.stopReason,
|
|
193
|
+
durationMs: outcome.durationMs,
|
|
194
|
+
usage: outcome.result.usage,
|
|
195
|
+
output: outcome.text,
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* One turn. The session's state decides whether this starts a run or continues
|
|
200
|
+
* one — which is the whole of what `resume` used to be, and why it is not a
|
|
201
|
+
* command.
|
|
202
|
+
*/
|
|
203
|
+
export async function runEngine(engine, input, onEvent, signal) {
|
|
204
|
+
const startedAt = new Date();
|
|
205
|
+
const stream = engine.state
|
|
206
|
+
? engine.runner.send(engine.state, input, { signal })
|
|
207
|
+
: engine.runner.run(engine.project.entry, input, { signal });
|
|
208
|
+
if (onEvent) {
|
|
209
|
+
for await (const event of stream) {
|
|
210
|
+
onEvent(event);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
const result = await stream.final();
|
|
214
|
+
const durationMs = Date.now() - startedAt.getTime();
|
|
215
|
+
engine.state = result.state;
|
|
216
|
+
const outcome = await record(engine, input, result, startedAt, durationMs);
|
|
217
|
+
if (result.stopReason === 'failed') {
|
|
218
|
+
throw new EngineError(result.state.error ?? 'the run failed', EXIT.failed, `report: ${outcome.run.report}`);
|
|
219
|
+
}
|
|
220
|
+
return outcome;
|
|
221
|
+
}
|
|
222
|
+
/**
|
|
223
|
+
* Everything a finished turn leaves behind. The session state is written first:
|
|
224
|
+
* if the report fails to render, the conversation is still resumable, which is
|
|
225
|
+
* the ordering that loses least.
|
|
226
|
+
*/
|
|
227
|
+
async function record(engine, input, result, startedAt, durationMs) {
|
|
228
|
+
const { session } = engine;
|
|
229
|
+
writeJson(session.state, result.state, 0o644);
|
|
230
|
+
const meta = await readSessionMeta(session);
|
|
231
|
+
meta.lastRunAt = new Date().toISOString();
|
|
232
|
+
meta.title ??= title(input);
|
|
233
|
+
writeSessionMeta(session, meta);
|
|
234
|
+
const run = createRun(session);
|
|
235
|
+
const text = await lastText(result.state, engine.runner.services.payloads);
|
|
236
|
+
await writeFile(run.input, `${asText(input)}\n`, 'utf8');
|
|
237
|
+
await writeFile(run.output, `${text}\n`, 'utf8');
|
|
238
|
+
writeJson(run.state, result.state, 0o644);
|
|
239
|
+
let report;
|
|
240
|
+
try {
|
|
241
|
+
const built = await buildRunReport(result.state, engine.runner.services.payloads, {
|
|
242
|
+
title: `${engine.name} · ${run.id}`,
|
|
243
|
+
architecture: await engine.runner.describe(),
|
|
244
|
+
memory: engine.runner.services.memory?.store,
|
|
245
|
+
mounts: [
|
|
246
|
+
{ at: engine.workspaceMount, href: pathToFileURL(engine.files.root).href },
|
|
247
|
+
...engine.files.mounts.map((m) => ({ at: m.at, href: pathToFileURL(m.host).href })),
|
|
248
|
+
],
|
|
249
|
+
});
|
|
250
|
+
await writeFile(run.report, renderReportHtml(built), 'utf8');
|
|
251
|
+
report = run.report;
|
|
252
|
+
}
|
|
253
|
+
catch {
|
|
254
|
+
// A report is a convenience. Losing it must not lose the run.
|
|
255
|
+
}
|
|
256
|
+
// The same trajectory for the other reader. `report.html` is for a person;
|
|
257
|
+
// this is what `zen inspect graph` prints, written down while the state is
|
|
258
|
+
// already in hand — a batch of a hundred items is otherwise a hundred more
|
|
259
|
+
// commands to run before anything can be read.
|
|
260
|
+
let graph;
|
|
261
|
+
try {
|
|
262
|
+
if (engine.graph) {
|
|
263
|
+
await writeFile(run.graph, engine.graph(result.state, {
|
|
264
|
+
runId: run.id,
|
|
265
|
+
dir: run.dir,
|
|
266
|
+
workspace: engine.workspace,
|
|
267
|
+
memory: engine.memory,
|
|
268
|
+
}), 'utf8');
|
|
269
|
+
graph = run.graph;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
catch {
|
|
273
|
+
// Same rule as the report: a view of the run is not the run.
|
|
274
|
+
}
|
|
275
|
+
writeRunMeta(run, {
|
|
276
|
+
version: 1,
|
|
277
|
+
id: run.id,
|
|
278
|
+
session: session.id,
|
|
279
|
+
startedAt: startedAt.toISOString(),
|
|
280
|
+
finishedAt: new Date().toISOString(),
|
|
281
|
+
durationMs,
|
|
282
|
+
agent: result.agent,
|
|
283
|
+
stopReason: result.stopReason,
|
|
284
|
+
turns: turns(result.state),
|
|
285
|
+
usage: result.usage,
|
|
286
|
+
workspace: engine.workspace,
|
|
287
|
+
memory: engine.memory,
|
|
288
|
+
error: result.state.error,
|
|
289
|
+
});
|
|
290
|
+
return { run, result, text, durationMs, report, graph };
|
|
291
|
+
}
|
|
292
|
+
function asText(input) {
|
|
293
|
+
if (typeof input === 'string') {
|
|
294
|
+
return input;
|
|
295
|
+
}
|
|
296
|
+
return input
|
|
297
|
+
.map((part) => {
|
|
298
|
+
if (typeof part === 'string') {
|
|
299
|
+
return part;
|
|
300
|
+
}
|
|
301
|
+
if ('text' in part && typeof part.text === 'string') {
|
|
302
|
+
return part.text;
|
|
303
|
+
}
|
|
304
|
+
// A media shorthand is a single key; naming it is more useful in a
|
|
305
|
+
// transcript than printing the url or the base64 behind it.
|
|
306
|
+
return `[${Object.keys(part)[0] ?? 'part'}]`;
|
|
307
|
+
})
|
|
308
|
+
.join('\n');
|
|
309
|
+
}
|
|
310
|
+
/** A session's label in a picker: the first thing that was ever asked of it. */
|
|
311
|
+
function title(input) {
|
|
312
|
+
const text = asText(input).replace(/\s+/g, ' ').trim();
|
|
313
|
+
return text.length > 60 ? `${text.slice(0, 57)}…` : text;
|
|
314
|
+
}
|
|
315
|
+
//# sourceMappingURL=engine.js.map
|
package/dist/env.d.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/** The file, relative to the project directory. */
|
|
2
|
+
export declare const ENV_FILE = ".env";
|
|
3
|
+
export interface ProjectEnv {
|
|
4
|
+
/** the file it was read from */
|
|
5
|
+
path: string;
|
|
6
|
+
/** every name the file declares, whether or not its value won */
|
|
7
|
+
names: string[];
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Reads `<dir>/.env` into `process.env`, without overwriting anything already
|
|
11
|
+
* set, and returns the names it found. A project with no such file is not a
|
|
12
|
+
* problem to report — most have none — so the answer is simply `undefined`.
|
|
13
|
+
*
|
|
14
|
+
* The names are returned in full, including the ones the environment already
|
|
15
|
+
* answered: the variable is in play either way, and the sandbox forwards by
|
|
16
|
+
* name, so what it needs to know is which names matter and not which of the
|
|
17
|
+
* two sources supplied them.
|
|
18
|
+
*/
|
|
19
|
+
export declare function loadProjectEnv(dir: string): ProjectEnv | undefined;
|
|
20
|
+
//# sourceMappingURL=env.d.ts.map
|
package/dist/env.js
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { isAbsolute, join, resolve } from 'node:path';
|
|
3
|
+
import { parseEnv } from 'node:util';
|
|
4
|
+
import { FILE_ENVS } from "./keys.js";
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
// The project's `.env`
|
|
7
|
+
//
|
|
8
|
+
// A project is a directory, and some of what it needs to run is particular to
|
|
9
|
+
// it: the token for the service it talks to, the base url of the environment
|
|
10
|
+
// it talks to it in. The keyring answers "what can this machine pay for"; this
|
|
11
|
+
// answers "what does this project need", and the two are different questions
|
|
12
|
+
// often enough to be worth two places.
|
|
13
|
+
//
|
|
14
|
+
// Precedence is the same one everything else here uses — the real environment
|
|
15
|
+
// always wins. What is already set in the shell is left alone, this file fills
|
|
16
|
+
// the gaps, and the keyring fills whatever is still missing after that. So a
|
|
17
|
+
// variable exported for one command still overrides the file, which is the
|
|
18
|
+
// behaviour anybody typing `FOO=bar zen run` is expecting.
|
|
19
|
+
//
|
|
20
|
+
// The names, not the values, are what leaves this module: they are what the
|
|
21
|
+
// sandbox forwards, and forwarding by name is what keeps a secret out of an
|
|
22
|
+
// argv. See `forwarded()` in sandbox.ts for the rest of that story.
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
/** The file, relative to the project directory. */
|
|
25
|
+
export const ENV_FILE = '.env';
|
|
26
|
+
/**
|
|
27
|
+
* Reads `<dir>/.env` into `process.env`, without overwriting anything already
|
|
28
|
+
* set, and returns the names it found. A project with no such file is not a
|
|
29
|
+
* problem to report — most have none — so the answer is simply `undefined`.
|
|
30
|
+
*
|
|
31
|
+
* The names are returned in full, including the ones the environment already
|
|
32
|
+
* answered: the variable is in play either way, and the sandbox forwards by
|
|
33
|
+
* name, so what it needs to know is which names matter and not which of the
|
|
34
|
+
* two sources supplied them.
|
|
35
|
+
*/
|
|
36
|
+
export function loadProjectEnv(dir) {
|
|
37
|
+
const path = join(dir, ENV_FILE);
|
|
38
|
+
if (!existsSync(path)) {
|
|
39
|
+
return undefined;
|
|
40
|
+
}
|
|
41
|
+
const names = [];
|
|
42
|
+
for (const [name, value] of Object.entries(parseEnv(readFileSync(path, 'utf8')))) {
|
|
43
|
+
if (value === undefined) {
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
names.push(name);
|
|
47
|
+
// An empty string is not an answer: tools test for absence, and a
|
|
48
|
+
// commented-out line someone half-filled in should not shadow a key
|
|
49
|
+
// the keyring has.
|
|
50
|
+
if (!process.env[name]) {
|
|
51
|
+
process.env[name] = located(dir, name, value);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return { path, names };
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* A service-account file is normally kept beside the `.env` that names it, so
|
|
58
|
+
* a relative path there means relative to the *project* — not to wherever the
|
|
59
|
+
* command happened to be run, which is what both the Google SDK and podman's
|
|
60
|
+
* bind mount would otherwise make of it.
|
|
61
|
+
*/
|
|
62
|
+
function located(dir, name, value) {
|
|
63
|
+
return FILE_ENVS.includes(name) && !isAbsolute(value) ? resolve(dir, value) : value;
|
|
64
|
+
}
|
|
65
|
+
//# sourceMappingURL=env.js.map
|
package/dist/errors.d.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export declare const EXIT: {
|
|
2
|
+
readonly ok: 0;
|
|
3
|
+
readonly failed: 1;
|
|
4
|
+
readonly usage: 2;
|
|
5
|
+
readonly invalid: 3;
|
|
6
|
+
readonly credentials: 4;
|
|
7
|
+
readonly sandbox: 5;
|
|
8
|
+
};
|
|
9
|
+
export type ExitCode = (typeof EXIT)[keyof typeof EXIT];
|
|
10
|
+
/**
|
|
11
|
+
* An error that already knows what it means. Anything else reaching the top is
|
|
12
|
+
* a bug, which is the distinction a caller needs: a wrong request and a wrong
|
|
13
|
+
* answer are not the same failure.
|
|
14
|
+
*/
|
|
15
|
+
export declare class EngineError extends Error {
|
|
16
|
+
readonly code: ExitCode;
|
|
17
|
+
readonly hint?: string;
|
|
18
|
+
constructor(message: string, code?: ExitCode, hint?: string);
|
|
19
|
+
}
|
|
20
|
+
export declare const usageError: (m: string, hint?: string) => EngineError;
|
|
21
|
+
export declare const invalidError: (m: string, hint?: string) => EngineError;
|
|
22
|
+
export declare const credentialError: (m: string, hint?: string) => EngineError;
|
|
23
|
+
export interface Logger {
|
|
24
|
+
warn(message: string): void;
|
|
25
|
+
}
|
|
26
|
+
/** Plain lines on stderr, for a host that did not say where warnings go. */
|
|
27
|
+
export declare const stderrLogger: Logger;
|
|
28
|
+
//# sourceMappingURL=errors.d.ts.map
|
package/dist/errors.js
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// Errors and warnings
|
|
3
|
+
//
|
|
4
|
+
// The engine runs under a terminal and under a server, so it never prints and
|
|
5
|
+
// never exits. An error carries a code a CLI turns into an exit status and a
|
|
6
|
+
// server turns into an HTTP status; a warning goes to whatever `Logger` the
|
|
7
|
+
// host handed in.
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
export const EXIT = {
|
|
10
|
+
ok: 0,
|
|
11
|
+
failed: 1,
|
|
12
|
+
usage: 2,
|
|
13
|
+
invalid: 3,
|
|
14
|
+
credentials: 4,
|
|
15
|
+
sandbox: 5,
|
|
16
|
+
};
|
|
17
|
+
/**
|
|
18
|
+
* An error that already knows what it means. Anything else reaching the top is
|
|
19
|
+
* a bug, which is the distinction a caller needs: a wrong request and a wrong
|
|
20
|
+
* answer are not the same failure.
|
|
21
|
+
*/
|
|
22
|
+
export class EngineError extends Error {
|
|
23
|
+
code;
|
|
24
|
+
hint;
|
|
25
|
+
constructor(message, code = EXIT.failed, hint) {
|
|
26
|
+
super(message);
|
|
27
|
+
this.name = 'EngineError';
|
|
28
|
+
this.code = code;
|
|
29
|
+
this.hint = hint;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
export const usageError = (m, hint) => new EngineError(m, EXIT.usage, hint);
|
|
33
|
+
export const invalidError = (m, hint) => new EngineError(m, EXIT.invalid, hint);
|
|
34
|
+
export const credentialError = (m, hint) => new EngineError(m, EXIT.credentials, hint);
|
|
35
|
+
/** Plain lines on stderr, for a host that did not say where warnings go. */
|
|
36
|
+
export const stderrLogger = {
|
|
37
|
+
warn: (message) => {
|
|
38
|
+
process.stderr.write(`warning ${message}\n`);
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
//# sourceMappingURL=errors.js.map
|
package/dist/home.d.ts
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
export declare function home(): string;
|
|
2
|
+
export declare const paths: {
|
|
3
|
+
home: typeof home;
|
|
4
|
+
projects: () => string;
|
|
5
|
+
keys: () => string;
|
|
6
|
+
keyDir: () => string;
|
|
7
|
+
gym: () => string;
|
|
8
|
+
/** what the meta agent runs on, which is a choice about a tool, not a project */
|
|
9
|
+
meta: () => string;
|
|
10
|
+
/** work already done and worth keeping: vectors, parses, model listings */
|
|
11
|
+
cache: () => string;
|
|
12
|
+
};
|
|
13
|
+
/** Creates a directory owner-only, and leaves an existing one's mode alone. */
|
|
14
|
+
export declare function ensureDir(dir: string, mode?: number): string;
|
|
15
|
+
export declare function ensureHome(): string;
|
|
16
|
+
/**
|
|
17
|
+
* Refuses to read a credential file that anyone else can read, the way `ssh`
|
|
18
|
+
* does. A world-readable key is not a warning-level event: warnings are
|
|
19
|
+
* ignored, and the whole point of the store is that the secret is not lying
|
|
20
|
+
* around in the open.
|
|
21
|
+
*
|
|
22
|
+
* Windows reports a mode that means nothing here, so the check is POSIX-only.
|
|
23
|
+
*/
|
|
24
|
+
export declare function assertPrivate(path: string): void;
|
|
25
|
+
export declare function readJson<T>(path: string, fallback: T): Promise<T>;
|
|
26
|
+
/**
|
|
27
|
+
* Write to a sibling and rename. A crash then leaves either the old file or the
|
|
28
|
+
* new one, never a half-written registry — which matters more here than it
|
|
29
|
+
* looks, because the alternative is a corrupt `keys.json` locking someone out
|
|
30
|
+
* of every provider at once.
|
|
31
|
+
*
|
|
32
|
+
* Synchronous on purpose: this is also called from exit paths.
|
|
33
|
+
*/
|
|
34
|
+
export declare function writeJson(path: string, value: unknown, mode?: number): void;
|
|
35
|
+
//# sourceMappingURL=home.d.ts.map
|
package/dist/home.js
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { chmodSync, mkdirSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { readFile } from 'node:fs/promises';
|
|
4
|
+
import { homedir } from 'node:os';
|
|
5
|
+
import { dirname, join } from 'node:path';
|
|
6
|
+
import { EngineError, EXIT } from "./errors.js";
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
// The home directory
|
|
9
|
+
//
|
|
10
|
+
// ~/.zenera/neo holds exactly two kinds of thing: credentials, which belong to
|
|
11
|
+
// the machine and never to a project, and an index of where projects are, which
|
|
12
|
+
// is a convenience and is allowed to be wrong.
|
|
13
|
+
//
|
|
14
|
+
// `ZENERA_HOME` moves the whole tree. That is what makes any of this testable
|
|
15
|
+
// and what lets CI start from an empty one.
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
const DIR_MODE = 0o700;
|
|
18
|
+
const FILE_MODE = 0o600;
|
|
19
|
+
export function home() {
|
|
20
|
+
const override = process.env.ZENERA_HOME?.trim();
|
|
21
|
+
return override ? override : join(homedir(), '.zenera', 'neo');
|
|
22
|
+
}
|
|
23
|
+
export const paths = {
|
|
24
|
+
home,
|
|
25
|
+
projects: () => join(home(), 'projects.json'),
|
|
26
|
+
keys: () => join(home(), 'keys.json'),
|
|
27
|
+
keyDir: () => join(home(), 'keys'),
|
|
28
|
+
gym: () => join(home(), 'gym'),
|
|
29
|
+
/** what the meta agent runs on, which is a choice about a tool, not a project */
|
|
30
|
+
meta: () => join(home(), 'meta.json'),
|
|
31
|
+
/** work already done and worth keeping: vectors, parses, model listings */
|
|
32
|
+
cache: () => join(home(), 'cache'),
|
|
33
|
+
};
|
|
34
|
+
/** Creates a directory owner-only, and leaves an existing one's mode alone. */
|
|
35
|
+
export function ensureDir(dir, mode = DIR_MODE) {
|
|
36
|
+
mkdirSync(dir, { recursive: true, mode });
|
|
37
|
+
return dir;
|
|
38
|
+
}
|
|
39
|
+
export function ensureHome() {
|
|
40
|
+
return ensureDir(home());
|
|
41
|
+
}
|
|
42
|
+
// ---------------------------------------------------------------------------
|
|
43
|
+
// Permissions
|
|
44
|
+
// ---------------------------------------------------------------------------
|
|
45
|
+
/**
|
|
46
|
+
* Refuses to read a credential file that anyone else can read, the way `ssh`
|
|
47
|
+
* does. A world-readable key is not a warning-level event: warnings are
|
|
48
|
+
* ignored, and the whole point of the store is that the secret is not lying
|
|
49
|
+
* around in the open.
|
|
50
|
+
*
|
|
51
|
+
* Windows reports a mode that means nothing here, so the check is POSIX-only.
|
|
52
|
+
*/
|
|
53
|
+
export function assertPrivate(path) {
|
|
54
|
+
if (process.platform === 'win32') {
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
let mode;
|
|
58
|
+
try {
|
|
59
|
+
mode = statSync(path).mode;
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
return; // absent is fine; it will be created with the right mode
|
|
63
|
+
}
|
|
64
|
+
const open = mode & 0o077;
|
|
65
|
+
if (open !== 0) {
|
|
66
|
+
const octal = (mode & 0o777).toString(8).padStart(3, '0');
|
|
67
|
+
throw new EngineError(`permissions ${octal} on ${path} are too open`, EXIT.credentials, `run: chmod 600 ${path}`);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
// ---------------------------------------------------------------------------
|
|
71
|
+
// JSON files
|
|
72
|
+
// ---------------------------------------------------------------------------
|
|
73
|
+
export async function readJson(path, fallback) {
|
|
74
|
+
let text;
|
|
75
|
+
try {
|
|
76
|
+
text = await readFile(path, 'utf8');
|
|
77
|
+
}
|
|
78
|
+
catch (err) {
|
|
79
|
+
if (err.code === 'ENOENT') {
|
|
80
|
+
return fallback;
|
|
81
|
+
}
|
|
82
|
+
throw err;
|
|
83
|
+
}
|
|
84
|
+
try {
|
|
85
|
+
return JSON.parse(text);
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
throw new EngineError(`${path} is not valid JSON`, EXIT.invalid, 'fix or delete the file');
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Write to a sibling and rename. A crash then leaves either the old file or the
|
|
93
|
+
* new one, never a half-written registry — which matters more here than it
|
|
94
|
+
* looks, because the alternative is a corrupt `keys.json` locking someone out
|
|
95
|
+
* of every provider at once.
|
|
96
|
+
*
|
|
97
|
+
* Synchronous on purpose: this is also called from exit paths.
|
|
98
|
+
*/
|
|
99
|
+
export function writeJson(path, value, mode = FILE_MODE) {
|
|
100
|
+
ensureDir(dirname(path));
|
|
101
|
+
const tmp = `${path}.${process.pid}.${randomUUID()}.tmp`;
|
|
102
|
+
try {
|
|
103
|
+
writeFileSync(tmp, `${JSON.stringify(value, null, 2)}\n`, { mode });
|
|
104
|
+
chmodSync(tmp, mode);
|
|
105
|
+
renameSync(tmp, path);
|
|
106
|
+
}
|
|
107
|
+
catch (err) {
|
|
108
|
+
rmSync(tmp, { force: true });
|
|
109
|
+
throw err;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
//# sourceMappingURL=home.js.map
|
package/dist/ids.d.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export declare function stamp(now?: Date): string;
|
|
2
|
+
/**
|
|
3
|
+
* Ids arrive from `--session` and `--run` and become path segments, so they are
|
|
4
|
+
* checked rather than trusted. The shape has no `.` and no separator, which
|
|
5
|
+
* makes traversal unrepresentable rather than merely rejected.
|
|
6
|
+
*/
|
|
7
|
+
export declare function isStamp(value: string): boolean;
|
|
8
|
+
/** Human-readable form of a stamp: `2026-08-25 14:30:12`. */
|
|
9
|
+
export declare function stampDate(id: string): string;
|
|
10
|
+
/** ISO instant a stamp names, for `ago()`. Local time in, local time out. */
|
|
11
|
+
export declare function stampInstant(id: string): string | undefined;
|
|
12
|
+
//# sourceMappingURL=ids.d.ts.map
|
package/dist/ids.js
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto';
|
|
2
|
+
// ---------------------------------------------------------------------------
|
|
3
|
+
// Identifiers
|
|
4
|
+
//
|
|
5
|
+
// `YYYYMMDD-HHMMSS-xxxx`, local time, four hex characters of entropy.
|
|
6
|
+
//
|
|
7
|
+
// Sortable as a plain string, readable without a decoder, and collision-free
|
|
8
|
+
// when two runs start inside the same second. An epoch integer gives up the
|
|
9
|
+
// first two properties and a uuid gives up all three.
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
const STAMP = /^\d{8}-\d{6}-[0-9a-f]{4}$/;
|
|
12
|
+
export function stamp(now = new Date()) {
|
|
13
|
+
const p = (n, w = 2) => String(n).padStart(w, '0');
|
|
14
|
+
const date = `${now.getFullYear()}${p(now.getMonth() + 1)}${p(now.getDate())}`;
|
|
15
|
+
const time = `${p(now.getHours())}${p(now.getMinutes())}${p(now.getSeconds())}`;
|
|
16
|
+
return `${date}-${time}-${randomBytes(2).toString('hex')}`;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Ids arrive from `--session` and `--run` and become path segments, so they are
|
|
20
|
+
* checked rather than trusted. The shape has no `.` and no separator, which
|
|
21
|
+
* makes traversal unrepresentable rather than merely rejected.
|
|
22
|
+
*/
|
|
23
|
+
export function isStamp(value) {
|
|
24
|
+
return STAMP.test(value);
|
|
25
|
+
}
|
|
26
|
+
/** Human-readable form of a stamp: `2026-08-25 14:30:12`. */
|
|
27
|
+
export function stampDate(id) {
|
|
28
|
+
if (!isStamp(id)) {
|
|
29
|
+
return id;
|
|
30
|
+
}
|
|
31
|
+
const [d, t] = id.split('-');
|
|
32
|
+
return (`${d.slice(0, 4)}-${d.slice(4, 6)}-${d.slice(6, 8)} ` +
|
|
33
|
+
`${t.slice(0, 2)}:${t.slice(2, 4)}:${t.slice(4, 6)}`);
|
|
34
|
+
}
|
|
35
|
+
/** ISO instant a stamp names, for `ago()`. Local time in, local time out. */
|
|
36
|
+
export function stampInstant(id) {
|
|
37
|
+
if (!isStamp(id)) {
|
|
38
|
+
return undefined;
|
|
39
|
+
}
|
|
40
|
+
const [d, t] = id.split('-');
|
|
41
|
+
const at = new Date(Number(d.slice(0, 4)), Number(d.slice(4, 6)) - 1, Number(d.slice(6, 8)), Number(t.slice(0, 2)), Number(t.slice(2, 4)), Number(t.slice(4, 6)));
|
|
42
|
+
return Number.isNaN(at.getTime()) ? undefined : at.toISOString();
|
|
43
|
+
}
|
|
44
|
+
//# sourceMappingURL=ids.js.map
|