@zenera/cli 1.1.23 → 1.1.24
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 +35 -2
- package/dist/ask.d.ts +51 -0
- package/dist/ask.js +143 -0
- package/dist/batch.d.ts +77 -0
- package/dist/batch.js +338 -0
- package/dist/command.d.ts +6 -0
- package/dist/commands/inspect.d.ts +11 -0
- package/dist/commands/inspect.js +388 -89
- package/dist/commands/list.js +128 -19
- package/dist/commands/memory.js +342 -12
- package/dist/commands/models.js +14 -2
- package/dist/commands/run.js +139 -34
- package/dist/commands/sandbox.js +72 -1
- package/dist/engine.d.ts +19 -0
- package/dist/engine.js +60 -8
- package/dist/liveness.js +6 -3
- package/dist/main.js +5 -2
- package/dist/podman.d.ts +16 -0
- package/dist/podman.js +115 -2
- package/dist/progress.d.ts +33 -0
- package/dist/progress.js +402 -0
- package/dist/request.d.ts +45 -0
- package/dist/request.js +300 -0
- package/dist/sandbox.js +10 -0
- package/dist/session.d.ts +26 -1
- package/dist/session.js +42 -3
- package/dist/term.d.ts +3 -0
- package/dist/term.js +17 -1
- package/dist/trace.d.ts +84 -0
- package/dist/trace.js +686 -0
- package/dist/validate.js +25 -3
- package/package.json +2 -2
- package/templates/editor/.github/copilot-instructions.md +11 -6
- package/templates/editor/.github/prompts/finetune.prompt.md +251 -0
- package/templates/editor/.github/skills/zen-cli/SKILL.md +9 -3
- package/templates/editor/.github/skills/zen-cli/references/check.md +7 -5
- package/templates/editor/.github/skills/zen-cli/references/inspect.md +262 -21
- package/templates/editor/.github/skills/zen-cli/references/memory.md +89 -19
- package/templates/editor/.github/skills/zen-cli/references/models.md +1 -1
- package/templates/editor/.github/skills/zen-cli/references/projects.md +12 -5
- package/templates/editor/.github/skills/zen-cli/references/run.md +201 -4
- package/templates/editor/.github/skills/zen-finetune/SKILL.md +1000 -0
- package/templates/editor/.github/skills/zen-finetune/scripts/collect.sh +432 -0
- package/templates/editor/.github/skills/zen-finetune/scripts/sample.sh +199 -0
- package/templates/editor/.github/skills/zen-inspect/SKILL.md +396 -0
- package/templates/editor/.github/skills/zen-memory/SKILL.md +74 -29
- package/templates/editor/.github/skills/zen-memory-warmup/SKILL.md +363 -531
- package/templates/editor/.github/skills/zen-sandbox/SKILL.md +19 -0
- package/templates/editor/.github/skills/zen-sandbox-capacity/SKILL.md +334 -0
- package/templates/editor/.github/skills/zen-sandbox-capacity/references/loadtest.md +181 -0
- package/templates/editor/.github/skills/zen-sandbox-capacity/scripts/preflight_sandbox.sh +457 -0
- package/templates/editor/.vscode/agents.schema.json +1 -1
- package/templates/editor/.vscode/settings.json +1 -0
- package/templates/project/agents/files-instructions.md +13 -0
- package/templates/project/agents/fork-instructions.md +27 -0
- package/templates/project/agents/memory-instructions.md +80 -7
- package/templates/project/gitignore +3 -0
package/dist/request.js
ADDED
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
import { media, text } from '@zenera/neo';
|
|
2
|
+
import { readFileSync, statSync } from 'node:fs';
|
|
3
|
+
import { dirname, extname, isAbsolute, resolve } from 'node:path';
|
|
4
|
+
import { readStdin, usageError, warn } from "./term.js";
|
|
5
|
+
/**
|
|
6
|
+
* Local media is read and inlined, so the state a run is resumed from carries
|
|
7
|
+
* the picture rather than a path that meant something on another machine. That
|
|
8
|
+
* also puts the bytes in `state.json`, rewritten at every checkpoint of every
|
|
9
|
+
* later turn — hence a ceiling, and a word of warning well before it.
|
|
10
|
+
*/
|
|
11
|
+
const MAX_MEDIA_BYTES = 20 * 1024 * 1024;
|
|
12
|
+
const WARN_MEDIA_BYTES = 2 * 1024 * 1024;
|
|
13
|
+
/** Enough to name what people actually attach; anything else says `mimeType`. */
|
|
14
|
+
const MIME = {
|
|
15
|
+
'.png': 'image/png',
|
|
16
|
+
'.jpg': 'image/jpeg',
|
|
17
|
+
'.jpeg': 'image/jpeg',
|
|
18
|
+
'.gif': 'image/gif',
|
|
19
|
+
'.webp': 'image/webp',
|
|
20
|
+
'.bmp': 'image/bmp',
|
|
21
|
+
'.svg': 'image/svg+xml',
|
|
22
|
+
'.heic': 'image/heic',
|
|
23
|
+
'.avif': 'image/avif',
|
|
24
|
+
'.mp3': 'audio/mpeg',
|
|
25
|
+
'.wav': 'audio/wav',
|
|
26
|
+
'.ogg': 'audio/ogg',
|
|
27
|
+
'.m4a': 'audio/mp4',
|
|
28
|
+
'.flac': 'audio/flac',
|
|
29
|
+
'.aac': 'audio/aac',
|
|
30
|
+
'.mp4': 'video/mp4',
|
|
31
|
+
'.webm': 'video/webm',
|
|
32
|
+
'.mov': 'video/quicktime',
|
|
33
|
+
'.mkv': 'video/x-matroska',
|
|
34
|
+
'.pdf': 'application/pdf',
|
|
35
|
+
'.txt': 'text/plain',
|
|
36
|
+
'.md': 'text/markdown',
|
|
37
|
+
'.csv': 'text/csv',
|
|
38
|
+
'.json': 'application/json',
|
|
39
|
+
'.xml': 'application/xml',
|
|
40
|
+
'.html': 'text/html',
|
|
41
|
+
'.yaml': 'application/yaml',
|
|
42
|
+
'.yml': 'application/yaml',
|
|
43
|
+
};
|
|
44
|
+
const MEDIA_KEYS = ['image', 'audio', 'video', 'file'];
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
// Reading
|
|
47
|
+
// ---------------------------------------------------------------------------
|
|
48
|
+
/**
|
|
49
|
+
* `-` is stdin, and then there is no file to resolve paths against, so the cwd
|
|
50
|
+
* answers instead — the only place a piped request could have meant.
|
|
51
|
+
*/
|
|
52
|
+
export async function readRequest(file, cwd, piped) {
|
|
53
|
+
if (file === '-') {
|
|
54
|
+
const body = piped ?? (await readStdin());
|
|
55
|
+
if (body === undefined) {
|
|
56
|
+
throw usageError('--input - expects the request on stdin', 'pipe a JSON object in');
|
|
57
|
+
}
|
|
58
|
+
return parseRequest(body, cwd, '<stdin>');
|
|
59
|
+
}
|
|
60
|
+
const at = resolve(cwd, file);
|
|
61
|
+
let body;
|
|
62
|
+
try {
|
|
63
|
+
body = readFileSync(at, 'utf8');
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
throw usageError(`cannot read ${at}`);
|
|
67
|
+
}
|
|
68
|
+
return parseRequest(body, dirname(at), at);
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* `base` is the directory every relative path in the request is resolved
|
|
72
|
+
* against — the file's own, so a case travels with the images beside it.
|
|
73
|
+
*/
|
|
74
|
+
export function parseRequest(body, base, where) {
|
|
75
|
+
let raw;
|
|
76
|
+
try {
|
|
77
|
+
raw = JSON.parse(body);
|
|
78
|
+
}
|
|
79
|
+
catch (err) {
|
|
80
|
+
throw usageError(`${where}: ${err.message}`, 'the request must be JSON');
|
|
81
|
+
}
|
|
82
|
+
if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
|
|
83
|
+
throw usageError(`${where}: expected a JSON object`, 'see: zen run --help');
|
|
84
|
+
}
|
|
85
|
+
if (raw.input === undefined) {
|
|
86
|
+
throw usageError(`${where}: no "input"`, 'a request has to say what to ask');
|
|
87
|
+
}
|
|
88
|
+
return {
|
|
89
|
+
project: project(raw.project, base, where),
|
|
90
|
+
input: toInput(raw.input, base, where),
|
|
91
|
+
workspace: path(raw.workspace, base, where, 'workspace'),
|
|
92
|
+
memory: path(raw.memory, base, where, 'memory'),
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
/** The batch file. `-` is stdin, and then the cwd is what paths mean. */
|
|
96
|
+
export async function readBatch(file, cwd, piped) {
|
|
97
|
+
if (file === '-') {
|
|
98
|
+
const body = piped ?? (await readStdin());
|
|
99
|
+
if (body === undefined) {
|
|
100
|
+
throw usageError('--input - expects the batch on stdin', 'pipe a JSON object in');
|
|
101
|
+
}
|
|
102
|
+
return parseBatch(body, cwd, '<stdin>');
|
|
103
|
+
}
|
|
104
|
+
const at = resolve(cwd, file);
|
|
105
|
+
let body;
|
|
106
|
+
try {
|
|
107
|
+
body = readFileSync(at, 'utf8');
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
throw usageError(`cannot read ${at}`);
|
|
111
|
+
}
|
|
112
|
+
return parseBatch(body, dirname(at), at);
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Everything refusable is refused here, before a single model is called: a
|
|
116
|
+
* batch that fails on item 80 has already spent seventy-nine runs finding out
|
|
117
|
+
* something its file could have been read for.
|
|
118
|
+
*/
|
|
119
|
+
export function parseBatch(body, base, where) {
|
|
120
|
+
let raw;
|
|
121
|
+
try {
|
|
122
|
+
raw = JSON.parse(body);
|
|
123
|
+
}
|
|
124
|
+
catch (err) {
|
|
125
|
+
throw usageError(`${where}: ${err.message}`, 'the batch must be JSON');
|
|
126
|
+
}
|
|
127
|
+
if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
|
|
128
|
+
throw usageError(`${where}: expected a JSON object`, 'see: zen run batch --help');
|
|
129
|
+
}
|
|
130
|
+
for (const key of ['memory', 'workspace', 'project']) {
|
|
131
|
+
if (raw[key] !== undefined) {
|
|
132
|
+
throw usageError(`${where}: "${key}" is not part of a batch file`, `it applies to every item, so it is a flag: --${key}`);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
if (!Array.isArray(raw.batch)) {
|
|
136
|
+
throw usageError(`${where}: no "batch" array`, 'a batch is a list of requests');
|
|
137
|
+
}
|
|
138
|
+
if (raw.batch.length === 0) {
|
|
139
|
+
throw usageError(`${where}: "batch" is empty`, 'there is nothing to run');
|
|
140
|
+
}
|
|
141
|
+
const items = raw.batch.map((item, index) => toItem(item, index, base, `${where}: batch`));
|
|
142
|
+
unique(items.map((i) => i.id), 'id', where);
|
|
143
|
+
unique(items.flatMap((i) => (i.workspace ? [i.workspace] : [])), 'workspace', where);
|
|
144
|
+
return { items };
|
|
145
|
+
}
|
|
146
|
+
function toItem(raw, index, base, where) {
|
|
147
|
+
const at = `${where}[${index}]`;
|
|
148
|
+
if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
|
|
149
|
+
throw usageError(`${at}: expected an object`, 'see: zen run batch --help');
|
|
150
|
+
}
|
|
151
|
+
const obj = raw;
|
|
152
|
+
for (const key of ['memory', 'project']) {
|
|
153
|
+
if (obj[key] !== undefined) {
|
|
154
|
+
throw usageError(`${at}: "${key}" is not part of a batch item`, `every item shares one, so it is a flag: --${key}`);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
if (obj.input === undefined) {
|
|
158
|
+
throw usageError(`${at}: no "input"`, 'an item has to say what to ask');
|
|
159
|
+
}
|
|
160
|
+
return {
|
|
161
|
+
id: id(obj.id, index, at),
|
|
162
|
+
index,
|
|
163
|
+
input: toInput(obj.input, base, at),
|
|
164
|
+
workspace: path(obj.workspace, base, at, 'workspace'),
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
/** An id names a directory, so it has to be one path segment and nothing else. */
|
|
168
|
+
function id(value, index, at) {
|
|
169
|
+
if (value === undefined) {
|
|
170
|
+
return String(index);
|
|
171
|
+
}
|
|
172
|
+
if (typeof value !== 'string' || !value) {
|
|
173
|
+
throw usageError(`${at}: "id" must be a non-empty string`);
|
|
174
|
+
}
|
|
175
|
+
if (!/^[\w.-]+$/.test(value) || value === '.' || value === '..') {
|
|
176
|
+
throw usageError(`${at}: "${value}" cannot be an id`, 'it names a directory: letters, digits, dot, dash and underscore');
|
|
177
|
+
}
|
|
178
|
+
return value;
|
|
179
|
+
}
|
|
180
|
+
function unique(values, what, where) {
|
|
181
|
+
const seen = new Set();
|
|
182
|
+
for (const value of values) {
|
|
183
|
+
if (seen.has(value)) {
|
|
184
|
+
throw usageError(`${where}: two items share the ${what} "${value}"`, 'every item runs at the same time as the others; give each its own');
|
|
185
|
+
}
|
|
186
|
+
seen.add(value);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* A registered name is not a path and must not be resolved into one; a name
|
|
191
|
+
* that looks like a path is one. `Projects.open` takes either.
|
|
192
|
+
*/
|
|
193
|
+
function project(value, base, where) {
|
|
194
|
+
if (value === undefined) {
|
|
195
|
+
return undefined;
|
|
196
|
+
}
|
|
197
|
+
if (typeof value !== 'string' || !value) {
|
|
198
|
+
throw usageError(`${where}: "project" must be a name or a directory`);
|
|
199
|
+
}
|
|
200
|
+
return value.startsWith('.') || isAbsolute(value) ? resolve(base, value) : value;
|
|
201
|
+
}
|
|
202
|
+
function path(value, base, where, field) {
|
|
203
|
+
if (value === undefined) {
|
|
204
|
+
return undefined;
|
|
205
|
+
}
|
|
206
|
+
if (typeof value !== 'string' || !value) {
|
|
207
|
+
throw usageError(`${where}: "${field}" must be a directory`);
|
|
208
|
+
}
|
|
209
|
+
return resolve(base, value);
|
|
210
|
+
}
|
|
211
|
+
// ---------------------------------------------------------------------------
|
|
212
|
+
// Input
|
|
213
|
+
// ---------------------------------------------------------------------------
|
|
214
|
+
/**
|
|
215
|
+
* Canonical `ContentPart[]` out, whatever went in: the shorthands exist for
|
|
216
|
+
* whoever writes the file, and stop existing the moment it is read.
|
|
217
|
+
*/
|
|
218
|
+
export function toInput(raw, base, where) {
|
|
219
|
+
if (typeof raw === 'string') {
|
|
220
|
+
return raw;
|
|
221
|
+
}
|
|
222
|
+
if (!Array.isArray(raw)) {
|
|
223
|
+
throw usageError(`${where}: "input" must be a string or an array of parts`);
|
|
224
|
+
}
|
|
225
|
+
return raw.map((part, i) => toPart(part, base, `${where}: input[${i}]`));
|
|
226
|
+
}
|
|
227
|
+
function toPart(part, base, at) {
|
|
228
|
+
if (typeof part === 'string') {
|
|
229
|
+
return text(part);
|
|
230
|
+
}
|
|
231
|
+
if (part === null || typeof part !== 'object' || Array.isArray(part)) {
|
|
232
|
+
throw usageError(`${at}: expected a string or an object`);
|
|
233
|
+
}
|
|
234
|
+
const obj = part;
|
|
235
|
+
const mimeType = mime(obj.mimeType, at);
|
|
236
|
+
if (typeof obj.type === 'string') {
|
|
237
|
+
if (obj.type === 'text') {
|
|
238
|
+
return text(str(obj.text, at, 'text'));
|
|
239
|
+
}
|
|
240
|
+
if (MEDIA_KEYS.includes(obj.type)) {
|
|
241
|
+
const kind = obj.type;
|
|
242
|
+
return url(kind, str(obj.url, at, 'url'), mimeType, base, at);
|
|
243
|
+
}
|
|
244
|
+
throw usageError(`${at}: unknown part type "${obj.type}"`, `text, ${MEDIA_KEYS.join(', ')}`);
|
|
245
|
+
}
|
|
246
|
+
if (typeof obj.text === 'string') {
|
|
247
|
+
return text(obj.text);
|
|
248
|
+
}
|
|
249
|
+
for (const kind of MEDIA_KEYS) {
|
|
250
|
+
if (obj[kind] !== undefined) {
|
|
251
|
+
return url(kind, str(obj[kind], at, kind), mimeType, base, at);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
throw usageError(`${at}: no text, ${MEDIA_KEYS.join(', ')} or type`, 'see: zen run --help');
|
|
255
|
+
}
|
|
256
|
+
/**
|
|
257
|
+
* A url the model can already fetch is left exactly as it is; anything else is
|
|
258
|
+
* a path on this machine, and a path is only a url once it carries its bytes.
|
|
259
|
+
*/
|
|
260
|
+
function url(kind, value, mimeType, base, at) {
|
|
261
|
+
if (/^(https?|data):/.test(value)) {
|
|
262
|
+
return media(kind, value, mimeType);
|
|
263
|
+
}
|
|
264
|
+
const file = resolve(base, value);
|
|
265
|
+
const type = mimeType ?? MIME[extname(file).toLowerCase()];
|
|
266
|
+
if (!type) {
|
|
267
|
+
throw usageError(`${at}: nothing known about "${extname(file) || value}"`, 'name the type with "mimeType"');
|
|
268
|
+
}
|
|
269
|
+
let size;
|
|
270
|
+
try {
|
|
271
|
+
size = statSync(file).size;
|
|
272
|
+
}
|
|
273
|
+
catch {
|
|
274
|
+
throw usageError(`${at}: cannot read ${file}`);
|
|
275
|
+
}
|
|
276
|
+
if (size > MAX_MEDIA_BYTES) {
|
|
277
|
+
throw usageError(`${at}: ${file} is ${Math.round(size / 1024 / 1024)} MB`, `inlined media lives in the run state — the limit is ${MAX_MEDIA_BYTES / 1024 / 1024} MB`);
|
|
278
|
+
}
|
|
279
|
+
if (size > WARN_MEDIA_BYTES) {
|
|
280
|
+
warn(`${file} is large; it is written into the session state on every later turn`);
|
|
281
|
+
}
|
|
282
|
+
const bytes = readFileSync(file);
|
|
283
|
+
return media(kind, `data:${type};base64,${bytes.toString('base64')}`, type);
|
|
284
|
+
}
|
|
285
|
+
function str(value, at, field) {
|
|
286
|
+
if (typeof value !== 'string' || !value) {
|
|
287
|
+
throw usageError(`${at}: "${field}" must be a non-empty string`);
|
|
288
|
+
}
|
|
289
|
+
return value;
|
|
290
|
+
}
|
|
291
|
+
function mime(value, at) {
|
|
292
|
+
if (value === undefined) {
|
|
293
|
+
return undefined;
|
|
294
|
+
}
|
|
295
|
+
if (typeof value !== 'string' || !value) {
|
|
296
|
+
throw usageError(`${at}: "mimeType" must be a string`);
|
|
297
|
+
}
|
|
298
|
+
return value;
|
|
299
|
+
}
|
|
300
|
+
//# sourceMappingURL=request.js.map
|
package/dist/sandbox.js
CHANGED
|
@@ -178,6 +178,12 @@ function toSpec(config, extra, secrets) {
|
|
|
178
178
|
export function usesSandbox(project) {
|
|
179
179
|
return project.agents.some((a) => a.tools.some((t) => t.group === SANDBOX_GROUP));
|
|
180
180
|
}
|
|
181
|
+
/**
|
|
182
|
+
* Images this process has already settled. A batch opens the same project
|
|
183
|
+
* dozens of times at once, and without this each one shells out to podman and
|
|
184
|
+
* the concurrent copies race each other to pull the very same image.
|
|
185
|
+
*/
|
|
186
|
+
const READY = new Set();
|
|
181
187
|
/**
|
|
182
188
|
* Asked before the first turn rather than at the first tool call, so a missing
|
|
183
189
|
* container engine costs nothing instead of costing a round trip and half a
|
|
@@ -185,6 +191,9 @@ export function usesSandbox(project) {
|
|
|
185
191
|
*/
|
|
186
192
|
export async function preflight(setup, yes) {
|
|
187
193
|
mkdirSync(setup.home, { recursive: true });
|
|
194
|
+
if (READY.has(setup.image)) {
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
188
197
|
await ensurePodmanReady({
|
|
189
198
|
image: setup.image,
|
|
190
199
|
build: setup.build,
|
|
@@ -192,6 +201,7 @@ export async function preflight(setup, yes) {
|
|
|
192
201
|
memory: setup.spec.memory,
|
|
193
202
|
yes,
|
|
194
203
|
});
|
|
204
|
+
READY.add(setup.image);
|
|
195
205
|
}
|
|
196
206
|
/** Best-effort teardown: losing a container must never lose a run. */
|
|
197
207
|
export async function teardown(pool) {
|
package/dist/session.d.ts
CHANGED
|
@@ -6,7 +6,6 @@ export interface SessionPaths {
|
|
|
6
6
|
data: string;
|
|
7
7
|
/** the live, resumable state — rewritten after every run */
|
|
8
8
|
state: string;
|
|
9
|
-
memory: string;
|
|
10
9
|
blobs: string;
|
|
11
10
|
runs: string;
|
|
12
11
|
lock: string;
|
|
@@ -52,10 +51,30 @@ export interface RunPaths {
|
|
|
52
51
|
output: string;
|
|
53
52
|
state: string;
|
|
54
53
|
report: string;
|
|
54
|
+
/** the same run as one Mermaid flowchart, for a model to read */
|
|
55
|
+
graph: string;
|
|
55
56
|
meta: string;
|
|
56
57
|
}
|
|
57
58
|
export declare function runPaths(session: SessionPaths, id: string): RunPaths;
|
|
58
59
|
export declare function createRun(session: SessionPaths): RunPaths;
|
|
60
|
+
/**
|
|
61
|
+
* The same paths, reached from a directory instead of from a pair of ids.
|
|
62
|
+
*
|
|
63
|
+
* `zen run --json` hands back a run directory, and a caller holding one has no
|
|
64
|
+
* reason to take it apart again to ask a second question about the same run.
|
|
65
|
+
* The layout is fixed, so the ids can be read back out of the path — and both
|
|
66
|
+
* still go through `isStamp`, so a path from anywhere cannot name a directory
|
|
67
|
+
* this layout would never have produced.
|
|
68
|
+
*
|
|
69
|
+
* The session comes back too, not only the run: blobs are stored per session,
|
|
70
|
+
* so anything that resolves a payload needs both halves — and the project, so
|
|
71
|
+
* a command reached this way can still read the project's config.
|
|
72
|
+
*/
|
|
73
|
+
export declare function runPathsAt(dir: string): {
|
|
74
|
+
project: string;
|
|
75
|
+
session: SessionPaths;
|
|
76
|
+
run: RunPaths;
|
|
77
|
+
};
|
|
59
78
|
export interface RunMeta {
|
|
60
79
|
version: 1;
|
|
61
80
|
id: string;
|
|
@@ -68,9 +87,15 @@ export interface RunMeta {
|
|
|
68
87
|
turns: number;
|
|
69
88
|
usage: unknown;
|
|
70
89
|
workspace: string;
|
|
90
|
+
memory?: string;
|
|
71
91
|
error?: string;
|
|
72
92
|
}
|
|
73
93
|
export declare function writeRunMeta(p: RunPaths, meta: RunMeta): void;
|
|
94
|
+
/**
|
|
95
|
+
* Partial, because a run killed before it recorded anything still has a
|
|
96
|
+
* directory, and every field here is worth having on its own.
|
|
97
|
+
*/
|
|
98
|
+
export declare function readRunMeta(p: RunPaths): Promise<Partial<RunMeta>>;
|
|
74
99
|
export interface RunSummary {
|
|
75
100
|
id: string;
|
|
76
101
|
startedAt?: string;
|
package/dist/session.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
2
2
|
import { hostname } from 'node:os';
|
|
3
|
-
import { join, relative, resolve } from 'node:path';
|
|
3
|
+
import { basename, dirname, join, relative, resolve } from 'node:path';
|
|
4
4
|
import { readJson, writeJson } from "./home.js";
|
|
5
5
|
import { isStamp, stamp, stampInstant } from "./ids.js";
|
|
6
6
|
import { alive, isBusy, runIds, sessionIds, sessionsDir } from "./projects.js";
|
|
@@ -17,7 +17,6 @@ export function sessionPaths(projectDir, id) {
|
|
|
17
17
|
workspace: join(dir, 'workspace'),
|
|
18
18
|
data,
|
|
19
19
|
state: join(data, 'state.json'),
|
|
20
|
-
memory: join(data, 'memory'),
|
|
21
20
|
blobs: join(data, 'blobs'),
|
|
22
21
|
runs: join(dir, 'runs'),
|
|
23
22
|
lock: join(dir, '.lock'),
|
|
@@ -134,6 +133,7 @@ export function runPaths(session, id) {
|
|
|
134
133
|
output: join(dir, 'output.md'),
|
|
135
134
|
state: join(dir, 'state.json'),
|
|
136
135
|
report: join(dir, 'report.html'),
|
|
136
|
+
graph: join(dir, 'graph.mmd'),
|
|
137
137
|
meta: join(dir, 'meta.json'),
|
|
138
138
|
};
|
|
139
139
|
}
|
|
@@ -142,16 +142,55 @@ export function createRun(session) {
|
|
|
142
142
|
mkdirSync(p.dir, { recursive: true });
|
|
143
143
|
return p;
|
|
144
144
|
}
|
|
145
|
+
/**
|
|
146
|
+
* The same paths, reached from a directory instead of from a pair of ids.
|
|
147
|
+
*
|
|
148
|
+
* `zen run --json` hands back a run directory, and a caller holding one has no
|
|
149
|
+
* reason to take it apart again to ask a second question about the same run.
|
|
150
|
+
* The layout is fixed, so the ids can be read back out of the path — and both
|
|
151
|
+
* still go through `isStamp`, so a path from anywhere cannot name a directory
|
|
152
|
+
* this layout would never have produced.
|
|
153
|
+
*
|
|
154
|
+
* The session comes back too, not only the run: blobs are stored per session,
|
|
155
|
+
* so anything that resolves a payload needs both halves — and the project, so
|
|
156
|
+
* a command reached this way can still read the project's config.
|
|
157
|
+
*/
|
|
158
|
+
export function runPathsAt(dir) {
|
|
159
|
+
const full = resolve(dir);
|
|
160
|
+
const runId = basename(full);
|
|
161
|
+
const sessionDir = dirname(dirname(full));
|
|
162
|
+
const wrong = () => usageError(`${display(full)} is not a run directory`, 'runs live at <project>/sessions/<session>/runs/<run>');
|
|
163
|
+
if (!isStamp(runId) || !isStamp(basename(sessionDir)) || basename(dirname(full)) !== 'runs') {
|
|
164
|
+
throw wrong();
|
|
165
|
+
}
|
|
166
|
+
const project = dirname(dirname(sessionDir));
|
|
167
|
+
const session = sessionPaths(project, basename(sessionDir));
|
|
168
|
+
const run = runPaths(session, runId);
|
|
169
|
+
if (resolve(run.dir) !== full) {
|
|
170
|
+
throw wrong();
|
|
171
|
+
}
|
|
172
|
+
if (!existsSync(run.state)) {
|
|
173
|
+
throw invalidError(`run ${runId} has no state.json`, 'only a run that got far enough to save state can be inspected');
|
|
174
|
+
}
|
|
175
|
+
return { project, session, run };
|
|
176
|
+
}
|
|
145
177
|
export function writeRunMeta(p, meta) {
|
|
146
178
|
writeJson(p.meta, meta, 0o644);
|
|
147
179
|
}
|
|
180
|
+
/**
|
|
181
|
+
* Partial, because a run killed before it recorded anything still has a
|
|
182
|
+
* directory, and every field here is worth having on its own.
|
|
183
|
+
*/
|
|
184
|
+
export async function readRunMeta(p) {
|
|
185
|
+
return await readJson(p.meta, {});
|
|
186
|
+
}
|
|
148
187
|
/** Newest first — the order a picker wants. */
|
|
149
188
|
export async function listRuns(session) {
|
|
150
189
|
const out = [];
|
|
151
190
|
for (const id of runIds(session.dir).reverse()) {
|
|
152
191
|
// A run killed before it recorded anything still has an id, and the id
|
|
153
192
|
// says when it started, so it is listable without its meta.
|
|
154
|
-
const meta = await
|
|
193
|
+
const meta = await readRunMeta(runPaths(session, id));
|
|
155
194
|
out.push({
|
|
156
195
|
id,
|
|
157
196
|
startedAt: meta.startedAt ?? stampInstant(id),
|
package/dist/term.d.ts
CHANGED
|
@@ -56,6 +56,8 @@ export declare function write(line?: string): void;
|
|
|
56
56
|
export declare function writeAll(lines: readonly string[]): void;
|
|
57
57
|
/** Machine-readable output. Pretty-printed: it is read by people too. */
|
|
58
58
|
export declare function json(value: unknown): void;
|
|
59
|
+
/** The same bytes, for a caller sending them somewhere other than stdout. */
|
|
60
|
+
export declare function jsonText(value: unknown): string;
|
|
59
61
|
export declare function note(line?: string): void;
|
|
60
62
|
/**
|
|
61
63
|
* One line of narration that rewrites itself while work is in flight, so a
|
|
@@ -72,6 +74,7 @@ export declare function warn(message: string): void;
|
|
|
72
74
|
export declare function title(text: string): void;
|
|
73
75
|
export declare function fail(message: string, hint?: string): void;
|
|
74
76
|
export declare function isInteractive(): boolean;
|
|
77
|
+
export declare function deferBanner(print: () => void): void;
|
|
75
78
|
export declare function ask(question: string, fallback?: string): Promise<string>;
|
|
76
79
|
/**
|
|
77
80
|
* Reads without echoing. `readline` writes what you type through its `output`
|
package/dist/term.js
CHANGED
|
@@ -154,7 +154,11 @@ export function writeAll(lines) {
|
|
|
154
154
|
}
|
|
155
155
|
/** Machine-readable output. Pretty-printed: it is read by people too. */
|
|
156
156
|
export function json(value) {
|
|
157
|
-
process.stdout.write(
|
|
157
|
+
process.stdout.write(jsonText(value));
|
|
158
|
+
}
|
|
159
|
+
/** The same bytes, for a caller sending them somewhere other than stdout. */
|
|
160
|
+
export function jsonText(value) {
|
|
161
|
+
return `${JSON.stringify(value, null, 2)}\n`;
|
|
158
162
|
}
|
|
159
163
|
export function note(line = '') {
|
|
160
164
|
process.stderr.write(`${line}\n`);
|
|
@@ -229,10 +233,22 @@ export function fail(message, hint) {
|
|
|
229
233
|
export function isInteractive() {
|
|
230
234
|
return Boolean(process.stdin.isTTY && process.stderr.isTTY);
|
|
231
235
|
}
|
|
236
|
+
// A banner is for whoever is sitting there, and the only proof that anyone is
|
|
237
|
+
// sitting there is that we are about to ask them something. Commands whose
|
|
238
|
+
// stdout is the answer hand theirs over here instead of printing it, and it
|
|
239
|
+
// appears if — and only if — the run turns out to need a person. Held as a
|
|
240
|
+
// thunk so this module stays below the banner, which is drawn with its colours.
|
|
241
|
+
let pending;
|
|
242
|
+
export function deferBanner(print) {
|
|
243
|
+
pending = print;
|
|
244
|
+
}
|
|
232
245
|
function requireTty(what, flag) {
|
|
233
246
|
if (!isInteractive()) {
|
|
234
247
|
throw usageError(`cannot ask for ${what} without a terminal`, `pass ${flag} instead`);
|
|
235
248
|
}
|
|
249
|
+
const banner = pending;
|
|
250
|
+
pending = undefined;
|
|
251
|
+
banner?.();
|
|
236
252
|
}
|
|
237
253
|
export async function ask(question, fallback) {
|
|
238
254
|
requireTty(question, '--yes or the matching flag');
|
package/dist/trace.d.ts
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { type AgentState, type PayloadResolver, type TrajectoryNode } from '@zenera/neo';
|
|
2
|
+
export interface TraceEntry {
|
|
3
|
+
/** diagram id — `n1`, `n2`, …, in the order the run appended nodes */
|
|
4
|
+
key: string;
|
|
5
|
+
node: TrajectoryNode;
|
|
6
|
+
depth: number;
|
|
7
|
+
/** the branch this ran in, or null on the trunk */
|
|
8
|
+
branch: string | null;
|
|
9
|
+
/** hidden from the model's context by a later compaction */
|
|
10
|
+
covered: boolean;
|
|
11
|
+
/** the id of the compaction that hid it, when it is on the same level */
|
|
12
|
+
coveredBy?: string;
|
|
13
|
+
children: TraceBranch[];
|
|
14
|
+
turnStart: number | null;
|
|
15
|
+
startMs: number | null;
|
|
16
|
+
endMs: number | null;
|
|
17
|
+
}
|
|
18
|
+
export interface TraceBranch {
|
|
19
|
+
name: string;
|
|
20
|
+
agent: string;
|
|
21
|
+
status: string;
|
|
22
|
+
entries: TraceEntry[];
|
|
23
|
+
startMs: number | null;
|
|
24
|
+
endMs: number | null;
|
|
25
|
+
turnStart: number | null;
|
|
26
|
+
/** the one branch the join actually waited for */
|
|
27
|
+
slowest: boolean;
|
|
28
|
+
}
|
|
29
|
+
export interface Trace {
|
|
30
|
+
root: TraceEntry[];
|
|
31
|
+
/** every entry, branches included, in key order */
|
|
32
|
+
entries: TraceEntry[];
|
|
33
|
+
byKey: Map<string, TraceEntry>;
|
|
34
|
+
}
|
|
35
|
+
export declare function traceOf(state: AgentState): Trace;
|
|
36
|
+
export declare function safe(text: string, max?: number): string;
|
|
37
|
+
export interface TraceOptions {
|
|
38
|
+
/** the run id to name in the header, when it differs from `state.runId` */
|
|
39
|
+
runId?: string;
|
|
40
|
+
/** the run directory, so the header can spell the command that opens a node */
|
|
41
|
+
dir?: string;
|
|
42
|
+
/** the files the run worked on */
|
|
43
|
+
workspace?: string;
|
|
44
|
+
/** the memory graph the run read, when it had one */
|
|
45
|
+
memory?: string;
|
|
46
|
+
timing?: boolean;
|
|
47
|
+
usage?: boolean;
|
|
48
|
+
/** false drops the palette: a model reads the labels, not the colours */
|
|
49
|
+
style?: boolean;
|
|
50
|
+
}
|
|
51
|
+
export declare function traceMermaid(state: AgentState, opts?: TraceOptions): string;
|
|
52
|
+
/** The node list on its own, for `--json`. */
|
|
53
|
+
export declare function traceIndex(trace: Trace): {
|
|
54
|
+
id: string;
|
|
55
|
+
nodeId: string;
|
|
56
|
+
kind: string;
|
|
57
|
+
agent: string;
|
|
58
|
+
branch: string | null;
|
|
59
|
+
ts: string;
|
|
60
|
+
label: string;
|
|
61
|
+
}[];
|
|
62
|
+
export interface NodePart {
|
|
63
|
+
name: string;
|
|
64
|
+
text: string;
|
|
65
|
+
}
|
|
66
|
+
export interface NodeDetail {
|
|
67
|
+
id: string;
|
|
68
|
+
nodeId: string;
|
|
69
|
+
kind: string;
|
|
70
|
+
agent: string;
|
|
71
|
+
branch: string | null;
|
|
72
|
+
ts: string;
|
|
73
|
+
label: string;
|
|
74
|
+
parts: NodePart[];
|
|
75
|
+
/** fields worth reading that are not payloads */
|
|
76
|
+
facts: Record<string, string>;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* `n5`, or `n5..n9`. A range is how a reader asks about a stretch, which is
|
|
80
|
+
* the shape of the question the diagram provokes ("what are n14 to n38 doing").
|
|
81
|
+
*/
|
|
82
|
+
export declare function parseNodeIds(words: readonly string[], known: Map<string, unknown>): string[];
|
|
83
|
+
export declare function nodeDetail(trace: Trace, ids: readonly string[], payloads: PayloadResolver): Promise<NodeDetail[]>;
|
|
84
|
+
//# sourceMappingURL=trace.d.ts.map
|