@zenera/gym 1.1.40 → 1.1.42

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.
Files changed (51) hide show
  1. package/README.md +169 -104
  2. package/dist/agents.d.ts +25 -0
  3. package/dist/agents.js +52 -0
  4. package/dist/bindings.d.ts +25 -0
  5. package/dist/bindings.js +81 -0
  6. package/dist/box.d.ts +39 -36
  7. package/dist/box.js +183 -217
  8. package/dist/cache.d.ts +24 -14
  9. package/dist/cache.js +102 -66
  10. package/dist/catalog.d.ts +83 -0
  11. package/dist/catalog.js +293 -0
  12. package/dist/command.d.ts +1 -0
  13. package/dist/command.js +235 -68
  14. package/dist/fake.d.ts +4 -0
  15. package/dist/fake.js +142 -0
  16. package/dist/generate.d.ts +28 -24
  17. package/dist/generate.js +417 -120
  18. package/dist/inputs.d.ts +12 -0
  19. package/dist/inputs.js +132 -0
  20. package/dist/knowledge.d.ts +50 -0
  21. package/dist/knowledge.js +199 -0
  22. package/dist/logger.d.ts +10 -8
  23. package/dist/logger.js +4 -3
  24. package/dist/paging.d.ts +2 -2
  25. package/dist/paging.js +2 -2
  26. package/dist/probe.d.ts +5 -5
  27. package/dist/probe.js +15 -15
  28. package/dist/prompt.d.ts +31 -18
  29. package/dist/prompt.js +160 -146
  30. package/dist/quiet.d.ts +2 -0
  31. package/dist/quiet.js +7 -0
  32. package/dist/router.js +5 -1
  33. package/dist/runtime.d.ts +160 -0
  34. package/dist/runtime.js +229 -0
  35. package/dist/schema.js +1 -2
  36. package/dist/server.js +98 -57
  37. package/dist/setup.d.ts +47 -6
  38. package/dist/setup.js +157 -21
  39. package/dist/spec.d.ts +4 -3
  40. package/dist/spec.js +73 -22
  41. package/dist/store.d.ts +78 -0
  42. package/dist/store.js +253 -0
  43. package/dist/synth.d.ts +42 -0
  44. package/dist/synth.js +589 -0
  45. package/dist/worker.d.ts +41 -0
  46. package/dist/worker.js +99 -0
  47. package/package.json +5 -3
  48. package/dist/envelope.d.ts +0 -13
  49. package/dist/envelope.js +0 -9
  50. package/dist/image.d.ts +0 -25
  51. package/dist/image.js +0 -81
package/dist/inputs.js ADDED
@@ -0,0 +1,132 @@
1
+ import { invalidError } from '@zenera/cli/lib';
2
+ import { isGlob, wildcard } from '@zenera/neo';
3
+ import { readdir, readFile, stat } from 'node:fs/promises';
4
+ import { dirname, extname, isAbsolute, join, resolve, sep } from 'node:path';
5
+ // ---------------------------------------------------------------------------
6
+ // What the user named
7
+ //
8
+ // Three kinds of input, each a list of files, directories or patterns: the API
9
+ // documents (`--schema`, or bare arguments), the story of the world
10
+ // (`--story`), and reference material (`--docs`). Nobody is asked to know how
11
+ // any of them is indexed; that is gym's business.
12
+ //
13
+ // A directory named for documents is walked and only the files that actually
14
+ // declare `swagger:` or `openapi:` are kept, so pointing at a folder of mixed
15
+ // JSON — a project, or an index somebody built — finds the APIs in it. A file
16
+ // named outright is taken at its word.
17
+ // ---------------------------------------------------------------------------
18
+ const SPEC_EXTENSIONS = new Set(['.yaml', '.yml', '.json']);
19
+ const TEXT_EXTENSIONS = new Set(['.md', '.markdown', '.txt', '.text']);
20
+ /** Enough for any API collection; far short of a filesystem. */
21
+ const MAX_FILES = 5000;
22
+ /** `swagger: "2.0"` or `"openapi": "3.1.0"`, at the top of a line. */
23
+ const DECLARES = /^\s*["']?(swagger|openapi)["']?\s*:/m;
24
+ /**
25
+ * Every API document the arguments name, absolute, de-duplicated, in the order
26
+ * named (and sorted within a directory or a pattern, so a start is repeatable).
27
+ */
28
+ export async function expandSchemas(args, cwd) {
29
+ const out = [];
30
+ const seen = new Set();
31
+ const add = (file) => {
32
+ if (!seen.has(file)) {
33
+ seen.add(file);
34
+ out.push(file);
35
+ }
36
+ };
37
+ for (const arg of args) {
38
+ const path = resolve(cwd, arg);
39
+ if (isGlob(arg)) {
40
+ const matched = await specsIn(await matching(path));
41
+ if (matched.length === 0) {
42
+ throw invalidError(`no API document matches ${arg}`, globHint(arg));
43
+ }
44
+ matched.forEach(add);
45
+ continue;
46
+ }
47
+ const info = await stat(path).catch(() => undefined);
48
+ if (!info) {
49
+ throw invalidError(`no such file or directory: ${arg}`);
50
+ }
51
+ if (info.isDirectory()) {
52
+ const found = await specsIn(await walk(path, SPEC_EXTENSIONS));
53
+ if (found.length === 0) {
54
+ throw invalidError(`${arg} holds no API document`, 'a document is a .yaml, .yml or .json file that declares `swagger:` or `openapi:`');
55
+ }
56
+ found.forEach(add);
57
+ continue;
58
+ }
59
+ if (TEXT_EXTENSIONS.has(extname(path).toLowerCase())) {
60
+ throw invalidError(`${arg} is not an API document`, 'pass the story of the world with --story, and reference material with --docs');
61
+ }
62
+ add(path);
63
+ }
64
+ return out;
65
+ }
66
+ /**
67
+ * Checks story and docs arguments before anything reads them. They are read by
68
+ * `@zenera/rag`, which takes a named file at its word — so an API document
69
+ * passed here by mistake would quietly become a very long story.
70
+ */
71
+ export async function checkTexts(args, cwd, flag) {
72
+ for (const arg of args) {
73
+ if (isGlob(arg)) {
74
+ continue;
75
+ }
76
+ const path = resolve(cwd, arg);
77
+ const info = await stat(path).catch(() => undefined);
78
+ if (!info) {
79
+ throw invalidError(`no such file or directory: ${arg}`, `named by ${flag}`);
80
+ }
81
+ if (info.isFile() && SPEC_EXTENSIONS.has(extname(path).toLowerCase())) {
82
+ throw invalidError(`${arg} looks like an API document, not ${flag === '--story' ? 'a story' : 'reference material'}`, 'pass API documents with --schema, or as bare arguments');
83
+ }
84
+ }
85
+ }
86
+ // ---------------------------------------------------------------------------
87
+ /** The files that declare themselves OpenAPI or Swagger; the rest are not ours. */
88
+ async function specsIn(files) {
89
+ const out = [];
90
+ for (const file of files) {
91
+ if (!SPEC_EXTENSIONS.has(extname(file).toLowerCase())) {
92
+ continue;
93
+ }
94
+ const text = await readFile(file, 'utf8').catch(() => '');
95
+ if (DECLARES.test(text)) {
96
+ out.push(file);
97
+ }
98
+ }
99
+ return out;
100
+ }
101
+ async function walk(root, extensions) {
102
+ const out = [];
103
+ const queue = [root];
104
+ while (queue.length > 0 && out.length < MAX_FILES) {
105
+ const dir = queue.shift();
106
+ const entries = await readdir(dir, { withFileTypes: true }).catch(() => []);
107
+ for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
108
+ if (entry.name.startsWith('.') || entry.name === 'node_modules') {
109
+ continue;
110
+ }
111
+ const path = join(dir, entry.name);
112
+ if (entry.isDirectory()) {
113
+ queue.push(path);
114
+ }
115
+ else if (extensions.has(extname(entry.name).toLowerCase())) {
116
+ out.push(path);
117
+ }
118
+ }
119
+ }
120
+ return out.sort();
121
+ }
122
+ /** Files under the deepest directory a pattern names outright, that match it. */
123
+ async function matching(pattern) {
124
+ const parts = pattern.split(sep);
125
+ const at = parts.findIndex((part) => isGlob(part));
126
+ const anchor = (at === -1 ? parts : parts.slice(0, at)).join(sep) || sep;
127
+ const root = isAbsolute(anchor) ? anchor : dirname(pattern);
128
+ const match = wildcard(pattern, { caseSensitive: true });
129
+ return (await walk(root, SPEC_EXTENSIONS)).filter((file) => match(file));
130
+ }
131
+ const globHint = (arg) => `quote the pattern so gym expands it, e.g. --schema '${arg}'`;
132
+ //# sourceMappingURL=inputs.js.map
@@ -0,0 +1,50 @@
1
+ import { type AnyTool, type Embedder } from '@zenera/neo';
2
+ export interface KnowledgeOptions {
3
+ /** files, directories or patterns */
4
+ story: readonly string[];
5
+ docs: readonly string[];
6
+ cwd: string;
7
+ /** gym's workspace; indexes go under `<root>/indexes` */
8
+ root: string;
9
+ /** the model that embeds the docs, or none for full-text search only */
10
+ embedding?: {
11
+ ref: string;
12
+ embedder: Embedder;
13
+ };
14
+ onWarn?: (message: string) => void;
15
+ onProgress?: (phase: 'reading' | 'embedding', done: number, total: number) => void;
16
+ }
17
+ export type SearchMode = 'hybrid' | 'text';
18
+ export interface DocsSummary {
19
+ files: number;
20
+ chunks: number;
21
+ search: SearchMode;
22
+ /** the embedding model, when search is hybrid */
23
+ embedding?: string;
24
+ /** whether the index was there already */
25
+ reused: boolean;
26
+ }
27
+ export interface Knowledge {
28
+ /** what the world agent is told about the world, whole */
29
+ readonly story: string;
30
+ /** a function of the story's content; '' when there is none */
31
+ readonly storyDigest: string;
32
+ readonly storyFiles: number;
33
+ readonly docs?: DocsSummary;
34
+ /** search, list, grep and read over the docs; none without docs */
35
+ readonly tools: AnyTool[];
36
+ /** the passages that best answer a question, quoted; '' when nothing does */
37
+ recall(query: string, limit?: number): Promise<string>;
38
+ close(): void;
39
+ }
40
+ /** No story: agents invent a plausible world, and are told so. */
41
+ export declare const NO_STORY: string;
42
+ export declare function openKnowledge(opts: KnowledgeOptions): Promise<Knowledge>;
43
+ export declare function indexDir(root: string, content: string, ref: string | undefined): string;
44
+ /**
45
+ * The store wants a vector per chunk, so a model-free index gets one: hashed
46
+ * words, which nothing searches. Cheap, deterministic, and shaped well enough
47
+ * for the store to build its vector index on a large corpus.
48
+ */
49
+ export declare const TEXT_ONLY: Embedder;
50
+ //# sourceMappingURL=knowledge.d.ts.map
@@ -0,0 +1,199 @@
1
+ import { embeddingResponse } from '@zenera/neo';
2
+ import { docs } from '@zenera/rag';
3
+ import { randomUUID } from 'node:crypto';
4
+ import { existsSync, mkdirSync, renameSync, rmSync } from 'node:fs';
5
+ import { join } from 'node:path';
6
+ import { digest } from "./store.js";
7
+ // ---------------------------------------------------------------------------
8
+ // What the agents know about the world
9
+ //
10
+ // Two inputs, read two ways. The STORY is the state of this world — who is
11
+ // who, what is happening, what has not happened yet — and it is authoritative,
12
+ // so it is quoted whole in every world-agent prompt. The DOCS are reference
13
+ // material — a knowledge base, runbooks, an admin guide — read when needed,
14
+ // through search.
15
+ //
16
+ // The docs are indexed here, before the server listens, into a directory gym
17
+ // owns and nobody is told about: keyed by what the documents say and by which
18
+ // model embeds them, so a restart is a cache hit and an edit pays only for the
19
+ // chunks that changed (`@zenera/rag` keeps the vectors). With no embedding
20
+ // model the index is still built, for full-text search; the vectors it carries
21
+ // then are hashed words, there only because the store wants a column.
22
+ // ---------------------------------------------------------------------------
23
+ /** Past this the story is still quoted whole, but it is probably reference material. */
24
+ const STORY_WARN_BYTES = 50_000;
25
+ /** No story: agents invent a plausible world, and are told so. */
26
+ export const NO_STORY = 'There is no story for this world. Invent plausible, internally consistent data: a ' +
27
+ 'small cast of people with stable names and email addresses, and content that reads ' +
28
+ 'like real use of the API. Reuse what is already in the world before inventing anything new.';
29
+ export async function openKnowledge(opts) {
30
+ const story = opts.story.length > 0 ? await readStory(opts) : undefined;
31
+ const index = opts.docs.length > 0 ? await openDocs(opts) : undefined;
32
+ const search = index?.tools.find((t) => t.name === 'search_docs');
33
+ return {
34
+ story: story?.text ?? NO_STORY,
35
+ storyDigest: story?.digest ?? '',
36
+ storyFiles: story?.files ?? 0,
37
+ docs: index?.summary,
38
+ tools: index?.tools ?? [],
39
+ async recall(query, limit = 4) {
40
+ if (!search || !query.trim()) {
41
+ return '';
42
+ }
43
+ try {
44
+ const found = (await search.execute({ query, limit, max_lines: 120 }, {}));
45
+ return found.passages ?? '';
46
+ }
47
+ catch {
48
+ // Recall is a head start, not a dependency: the agent can still search.
49
+ return '';
50
+ }
51
+ },
52
+ close: () => index?.close(),
53
+ };
54
+ }
55
+ // ---------------------------------------------------------------------------
56
+ // The story
57
+ // ---------------------------------------------------------------------------
58
+ async function readStory(opts) {
59
+ const corpus = await docs.loadDocuments(opts.story, opts.cwd);
60
+ const bytes = corpus.docs.reduce((n, d) => n + d.bytes, 0);
61
+ if (bytes > STORY_WARN_BYTES) {
62
+ opts.onWarn?.(`the story is ${Math.round(bytes / 1000)} KB and is quoted whole in every prompt — ` +
63
+ 'reference material belongs in --docs, where it is searched instead');
64
+ }
65
+ return {
66
+ text: [
67
+ 'THE STORY OF THIS WORLD. Everything the API returns must agree with it.',
68
+ '',
69
+ ...corpus.docs.map((d) => `=== ${d.name} ===\n${d.text.trim()}`),
70
+ ].join('\n'),
71
+ digest: digest(JSON.stringify(corpus.docs.map((d) => [d.name, d.sha256]))),
72
+ files: corpus.docs.length,
73
+ };
74
+ }
75
+ async function openDocs(opts) {
76
+ // Read first to learn what the index would be called. The parse cache makes
77
+ // the second read, inside the build, free.
78
+ const corpus = await docs.loadDocuments(opts.docs, opts.cwd, {
79
+ onProgress: (done, total) => opts.onProgress?.('reading', done, total),
80
+ });
81
+ const content = digest(JSON.stringify(corpus.docs.map((d) => [d.name, d.sha256])));
82
+ let mode = opts.embedding ? 'hybrid' : 'text';
83
+ let dir = indexDir(opts.root, content, opts.embedding?.ref);
84
+ let reused = existsSync(dir);
85
+ if (!reused) {
86
+ try {
87
+ await build(opts, dir, opts.embedding);
88
+ }
89
+ catch (err) {
90
+ if (!opts.embedding) {
91
+ throw err;
92
+ }
93
+ // The docs are still worth searching by their wording.
94
+ opts.onWarn?.(`could not embed the docs with ${opts.embedding.ref} ` +
95
+ `(${err instanceof Error ? err.message.split('\n')[0] : String(err)}) — ` +
96
+ 'searching them by wording only');
97
+ mode = 'text';
98
+ dir = indexDir(opts.root, content, undefined);
99
+ reused = existsSync(dir);
100
+ if (!reused) {
101
+ await build(opts, dir, undefined);
102
+ }
103
+ }
104
+ }
105
+ const index = await docs.DocsIndex.open(dir, mode === 'hybrid' ? opts.embedding.embedder : undefined);
106
+ const tools = docs.docsTools(index);
107
+ return {
108
+ summary: {
109
+ files: index.manifest.sources.length,
110
+ chunks: index.manifest.counts.chunks,
111
+ search: mode,
112
+ embedding: mode === 'hybrid' ? opts.embedding.ref : undefined,
113
+ reused,
114
+ },
115
+ tools: mode === 'hybrid' ? tools : tools.map(wordingOnly),
116
+ close: () => index.close(),
117
+ };
118
+ }
119
+ /**
120
+ * Built beside where it belongs and renamed into place, so a half-built index
121
+ * is never opened, and a second process building the same one at the same
122
+ * moment loses the rename instead of the index.
123
+ */
124
+ async function build(opts, dir, embedding) {
125
+ mkdirSync(join(dir, '..'), { recursive: true });
126
+ const scratch = `${dir}.tmp-${randomUUID().slice(0, 8)}`;
127
+ try {
128
+ await docs.buildIndex({
129
+ files: opts.docs,
130
+ cwd: opts.cwd,
131
+ out: scratch,
132
+ embedder: embedding?.embedder ?? TEXT_ONLY,
133
+ embeddingRef: embedding?.ref ?? TEXT_ONLY.id,
134
+ indexer: '@zenera/gym',
135
+ onReading: (done, total) => opts.onProgress?.('reading', done, total),
136
+ onProgress: (done, total) => opts.onProgress?.('embedding', done, total),
137
+ });
138
+ try {
139
+ renameSync(scratch, dir);
140
+ }
141
+ catch {
142
+ if (!existsSync(dir)) {
143
+ throw new Error(`could not move the docs index into ${dir}`);
144
+ }
145
+ }
146
+ }
147
+ finally {
148
+ rmSync(scratch, { recursive: true, force: true });
149
+ }
150
+ }
151
+ export function indexDir(root, content, ref) {
152
+ const model = ref ? ref.replace(/[^A-Za-z0-9]+/g, '-').replace(/^-|-$/g, '') : 'text';
153
+ return join(root, 'indexes', `docs-${content}-${model}`);
154
+ }
155
+ /**
156
+ * `search_docs` with the vector half taken away. A question is asked of the
157
+ * full-text index as it stands; a vector wording becomes a text one.
158
+ */
159
+ function wordingOnly(tool) {
160
+ if (tool.name !== 'search_docs') {
161
+ return tool;
162
+ }
163
+ return {
164
+ ...tool,
165
+ description: `${tool.description} Matches wording only: there is no embedding model, so use the words the documents would use.`,
166
+ execute: (raw, tc) => {
167
+ const { vector_query: vector, mode: _mode, ...args } = raw;
168
+ const text = args.text_query ?? (args.query ? undefined : vector);
169
+ return tool.execute(text !== undefined
170
+ ? { ...args, query: undefined, text_query: text }
171
+ : { ...args, mode: 'text' }, tc);
172
+ },
173
+ };
174
+ }
175
+ /**
176
+ * The store wants a vector per chunk, so a model-free index gets one: hashed
177
+ * words, which nothing searches. Cheap, deterministic, and shaped well enough
178
+ * for the store to build its vector index on a large corpus.
179
+ */
180
+ export const TEXT_ONLY = {
181
+ id: 'gym-text-only',
182
+ embed: (req) => Promise.resolve(embeddingResponse(req.input.map(hashed), req)),
183
+ };
184
+ const DIMENSIONS = 32;
185
+ function hashed(text) {
186
+ const v = new Array(DIMENSIONS).fill(0);
187
+ for (const word of text.toLowerCase().match(/[a-z0-9]+/g) ?? []) {
188
+ let h = 2166136261;
189
+ for (let i = 0; i < word.length; i++) {
190
+ h = Math.imul(h ^ word.charCodeAt(i), 16777619) >>> 0;
191
+ }
192
+ v[h % DIMENSIONS] += h & 0x100 ? 1 : -1;
193
+ }
194
+ if (!v.some((x) => x !== 0)) {
195
+ v[0] = 1;
196
+ }
197
+ return v;
198
+ }
199
+ //# sourceMappingURL=knowledge.js.map
package/dist/logger.d.ts CHANGED
@@ -1,5 +1,5 @@
1
- import type { GeneratorInput } from './envelope.ts';
2
- export type ErrorKind = 'CLIENT_ERROR: NOT_FOUND' | 'CLIENT_ERROR: METHOD_NOT_ALLOWED' | 'CLIENT_ERROR: BAD_REQUEST' | 'CLIENT_ERROR: VALIDATION_FAILED' | 'CLIENT_ERROR: PAYLOAD_TOO_LARGE' | 'GENERATOR_FAULT' | 'GENERATOR_TIMEOUT' | 'GENERATOR_SCHEMA_MISMATCH' | 'GENERATOR_BUILD_FAILED' | 'SANDBOX_UNAVAILABLE' | 'SERVER_INTERNAL_ERROR' | 'REGENERATION_LIMIT_HIT' | 'REGENERATION_FAILED' | 'SUCCESS';
1
+ import type { HandlerRequest } from './runtime.ts';
2
+ export type ErrorKind = 'CLIENT_ERROR: NOT_FOUND' | 'CLIENT_ERROR: METHOD_NOT_ALLOWED' | 'CLIENT_ERROR: BAD_REQUEST' | 'CLIENT_ERROR: VALIDATION_FAILED' | 'CLIENT_ERROR: PAYLOAD_TOO_LARGE' | 'HANDLER_FAULT' | 'HANDLER_TIMEOUT' | 'HANDLER_SCHEMA_MISMATCH' | 'HANDLER_BUILD_FAILED' | 'WORKER_UNAVAILABLE' | 'WORLD_AGENT_FAILED' | 'SERVER_INTERNAL_ERROR' | 'REGENERATION_LIMIT_HIT' | 'REGENERATION_FAILED' | 'SUCCESS';
3
3
  export interface RequestDumpData {
4
4
  id: string;
5
5
  timestamp: Date;
@@ -19,10 +19,10 @@ export interface RequestDumpData {
19
19
  responseBody?: unknown;
20
20
  errorKind?: ErrorKind;
21
21
  errorMessage?: string;
22
- /** what to do about it, when the fix is not in the generator */
22
+ /** what to do about it, when the fix is not in the handler */
23
23
  hint?: string;
24
24
  stderr?: string;
25
- /** the build report, when the answer was "there is no generator" */
25
+ /** the build report, when the answer was "there is no handler" */
26
26
  buildDump?: string;
27
27
  cacheStatus?: 'hit' | 'miss' | 'regenerated';
28
28
  regenerated?: boolean;
@@ -55,12 +55,14 @@ export interface BuildDumpData {
55
55
  /** the operation's cache key, which is also its directory in the box */
56
56
  key: string;
57
57
  limit: number;
58
- /** rewriting a generator that already existed rather than writing the first */
58
+ /** rewriting a handler that already existed rather than writing the first */
59
59
  regeneration?: boolean;
60
60
  model?: string;
61
61
  attempts: readonly BuildAttempt[];
62
- /** the attempts ran out — no generator was produced */
62
+ /** the attempts ran out — no handler was produced */
63
63
  gaveUp?: boolean;
64
+ /** the agent's HTML run report */
65
+ report?: string;
64
66
  }
65
67
  export declare function saveBuildDump(dir: string, data: BuildDumpData): Promise<string>;
66
68
  export declare function formatDumpPath(label: string, path: string): string;
@@ -69,13 +71,13 @@ export declare function formatRegenLimitHit(urlPath: string, limit: number, oper
69
71
  export declare function formatRegenerating(urlPath: string, attempt: number, limit: number, operationId?: string, reason?: string, stderr?: string): string;
70
72
  export declare function formatRegenerated(urlPath: string, attempt: number, durationMs: number, operationId?: string): string;
71
73
  export interface ExampleRequest {
72
- input: GeneratorInput;
74
+ input: HandlerRequest;
73
75
  output?: unknown;
74
76
  }
75
77
  export declare class RequestHistory {
76
78
  #private;
77
79
  constructor(maxExamples?: number);
78
- recordSuccess(operationKey: string, input: GeneratorInput, output: unknown): void;
80
+ recordSuccess(operationKey: string, input: HandlerRequest, output: unknown): void;
79
81
  getSuccessful(operationKey: string, count?: number): ExampleRequest[];
80
82
  }
81
83
  export declare class RegenerationLimiter {
package/dist/logger.js CHANGED
@@ -202,13 +202,14 @@ export async function saveBuildDump(dir, data) {
202
202
  `- **Started**: ${data.started.toISOString()}`,
203
203
  `- **Model**: ${data.model ? `\`${data.model}\`` : '_Unknown_'}`,
204
204
  `- **Outcome**: ${outcome}`,
205
+ ...(data.report ? [`- **Agent run**: [${data.report}](file://${data.report})`] : []),
205
206
  '',
206
207
  ];
207
208
  for (const a of data.attempts) {
208
209
  const elapsed = Math.max(0, a.at.getTime() - data.started.getTime());
209
210
  lines.push('---', '', `## Attempt ${a.attempt} of ${data.limit}`, '', `_Rejected ${a.at.toISOString()}, ${elapsed}ms into the build._`, '', '### Why it was rejected', '', ...(a.diagnostics.length > 0
210
211
  ? a.diagnostics.map((d) => (d.trimStart().startsWith('-') ? d : `- ${d}`))
211
- : ['_No diagnostics._']), '', '### Generator', '', '```python', a.source, '```', '');
212
+ : ['_No diagnostics._']), '', '### Handler', '', '```ts', a.source, '```', '');
212
213
  }
213
214
  await writeFile(fullPath, lines.join('\n'), 'utf8');
214
215
  return resolve(fullPath);
@@ -266,14 +267,14 @@ export function formatRegenerating(urlPath, attempt, limit, operationId, reason,
266
267
  const ts = dim(`[${formatTimestamp(new Date())}]`);
267
268
  const op = operationId ? ` (${operationId})` : '';
268
269
  const r = reason ? ` [${reason}]` : '';
269
- const head = `${ts} ${yellow(bold('[REGENERATING]'))} Error in generator for "${urlPath}"${op}${r}. Attempting regeneration (${attempt}/${limit})...`;
270
+ const head = `${ts} ${yellow(bold('[REGENERATING]'))} Error in handler for "${urlPath}"${op}${r}. Attempting regeneration (${attempt}/${limit})...`;
270
271
  const tail = stderr?.trim().split('\n').slice(-3).join('\n ');
271
272
  return tail ? `${head}\n ${dim('stderr:')} ${red(tail)}` : head;
272
273
  }
273
274
  export function formatRegenerated(urlPath, attempt, durationMs, operationId) {
274
275
  const ts = dim(`[${formatTimestamp(new Date())}]`);
275
276
  const op = operationId ? ` (${operationId})` : '';
276
- return `${ts} ${green(bold('[REGENERATED]'))} Successfully regenerated generator for "${urlPath}"${op} in attempt ${attempt} (${durationMs}ms).`;
277
+ return `${ts} ${green(bold('[REGENERATED]'))} Successfully regenerated handler for "${urlPath}"${op} in attempt ${attempt} (${durationMs}ms).`;
277
278
  }
278
279
  export class RequestHistory {
279
280
  #successful = new Map();
package/dist/paging.d.ts CHANGED
@@ -41,10 +41,10 @@ export declare function pagingSeen(params: readonly ParamSpec[], schema: Schema
41
41
  /** The token a body offers for the next page, or nothing when it offers none. */
42
42
  export declare function tokenOf(value: unknown, paging: Paging): string | undefined;
43
43
  /**
44
- * A last line of defence, for the generator that is already on disk: a body
44
+ * A last line of defence, for the handler that is already on disk: a body
45
45
  * offering the very token it was given is cut back to "no more pages".
46
46
  *
47
- * Deliberately timid. Nothing re-validates a generator's output on the way to
47
+ * Deliberately timid. Nothing re-validates a handler's output on the way to
48
48
  * the client, so writing `null` into a required, non-nullable property would
49
49
  * trade a client that hangs for a mock that lies — and a hang is at least
50
50
  * obvious. Where the schema leaves no room, this changes nothing and says so.
package/dist/paging.js CHANGED
@@ -174,10 +174,10 @@ export function tokenOf(value, paging) {
174
174
  return typeof token === 'object' ? JSON.stringify(token) : String(token);
175
175
  }
176
176
  /**
177
- * A last line of defence, for the generator that is already on disk: a body
177
+ * A last line of defence, for the handler that is already on disk: a body
178
178
  * offering the very token it was given is cut back to "no more pages".
179
179
  *
180
- * Deliberately timid. Nothing re-validates a generator's output on the way to
180
+ * Deliberately timid. Nothing re-validates a handler's output on the way to
181
181
  * the client, so writing `null` into a required, non-nullable property would
182
182
  * trade a client that hangs for a mock that lies — and a hang is at least
183
183
  * obvious. Where the schema leaves no room, this changes nothing and says so.
package/dist/probe.d.ts CHANGED
@@ -1,12 +1,12 @@
1
- import type { GeneratorInput } from './envelope.ts';
1
+ import type { HandlerRequest } from './runtime.ts';
2
2
  import type { Paging } from './paging.ts';
3
3
  import { type Schema } from './schema.ts';
4
4
  import type { Operation } from './spec.ts';
5
5
  import type { Issue } from './validate.ts';
6
- export declare function probesFor(operation: Operation): GeneratorInput[];
6
+ export declare function probesFor(operation: Operation): HandlerRequest[];
7
7
  /** The first page: an ordinary probe with the paging control taken back off. */
8
- export declare function walkStart(operation: Operation, paging: Paging): GeneratorInput;
8
+ export declare function walkStart(operation: Operation, paging: Paging): HandlerRequest;
9
9
  /** The same request again, asking for whatever the last answer pointed at. */
10
- export declare function nextPage(previous: GeneratorInput, paging: Paging, token: string): GeneratorInput;
11
- export declare function echoIssues(input: GeneratorInput, value: unknown, schema: Schema | undefined): Issue[];
10
+ export declare function nextPage(previous: HandlerRequest, paging: Paging, token: string): HandlerRequest;
11
+ export declare function echoIssues(input: HandlerRequest, value: unknown, schema: Schema | undefined): Issue[];
12
12
  //# sourceMappingURL=probe.d.ts.map
package/dist/probe.js CHANGED
@@ -2,14 +2,14 @@ import { properties } from "./schema.js";
2
2
  // ---------------------------------------------------------------------------
3
3
  // Probes
4
4
  //
5
- // A generator is judged before it is trusted, and it is judged on inputs made
5
+ // A handler is judged before it is trusted, and it is judged on inputs made
6
6
  // up here rather than on traffic. That is not only about cost: a probe built
7
7
  // from a request body would put whatever a caller sent into the next prompt,
8
8
  // and a mock server is exactly the kind of thing people point at with real
9
9
  // payloads by accident.
10
10
  //
11
11
  // Two probes, deliberately. One ordinary, one at the edges of whatever the
12
- // schema allows — a generator that hard-codes the first probe's id passes once
12
+ // schema allows — a handler that hard-codes the first probe's id passes once
13
13
  // and fails the second, which is the mistake this catches.
14
14
  // ---------------------------------------------------------------------------
15
15
  /**
@@ -17,9 +17,8 @@ import { properties } from "./schema.js";
17
17
  *
18
18
  * Not the schema's maximum. `bounded` below answers an unbounded want with
19
19
  * `maximum - 1`, so a `page_size` declared `maximum: 1000` used to make every
20
- * probe ask for 999 fabricated objects — minutes of Faker inside a container
21
- * with a 30s budget, reported as "the file took longer than 30s" and blamed on
22
- * a generator that was doing exactly what it was told. A page is judged on its
20
+ * probe ask for 999 fabricated objects, and a handler that was doing exactly
21
+ * what it was told was blamed for being slow. A page is judged on its
23
22
  * shape, and three rows show it.
24
23
  */
25
24
  const PROBE_PAGE_SIZE = 3;
@@ -39,28 +38,29 @@ export function probesFor(operation) {
39
38
  }));
40
39
  }
41
40
  function values(params, where, variant, size) {
41
+ // Text, as on the wire: a handler sees what a real request would give it.
42
42
  const out = {};
43
43
  for (const p of params) {
44
44
  if (p.in !== where) {
45
45
  continue;
46
46
  }
47
47
  // An optional query parameter is present in one probe and absent in the
48
- // other, so a generator cannot assume either.
48
+ // other, so a handler cannot assume either.
49
49
  if (!p.required && variant === 1) {
50
50
  continue;
51
51
  }
52
- out[p.name] =
53
- p.name === size
54
- ? bounded(p.schema, PROBE_PAGE_SIZE, true)
55
- : sample(p.schema, variant, 0, p.schema);
52
+ const value = p.name === size
53
+ ? bounded(p.schema, PROBE_PAGE_SIZE, true)
54
+ : sample(p.schema, variant, 0, p.schema);
55
+ out[p.name] = Array.isArray(value) ? value.join(',') : String(value);
56
56
  }
57
57
  return out;
58
58
  }
59
59
  const NAMES = ['ada', 'grace', 'linus'];
60
60
  /**
61
61
  * A value the schema would accept. Not a faker — that job belongs to the
62
- * generator; this only has to be *valid*, so that a probe failure is the
63
- * generator's fault and never the probe's.
62
+ * handler; this only has to be *valid*, so that a probe failure is the
63
+ * handler's fault and never the probe's.
64
64
  */
65
65
  function sample(schema, variant, depth, root) {
66
66
  if (depth > 4) {
@@ -175,7 +175,7 @@ function text(schema, variant) {
175
175
  break;
176
176
  }
177
177
  // A `pattern` cannot be satisfied by guessing, so it is left to the
178
- // parameter check to tell us the probe was wrong rather than the generator.
178
+ // parameter check to tell us the probe was wrong rather than the handler.
179
179
  const base = NAMES[variant % NAMES.length];
180
180
  const min = num(schema.minLength) ?? 0;
181
181
  return base.length >= min ? base : base.padEnd(min, 'x');
@@ -188,7 +188,7 @@ function text(schema, variant) {
188
188
  // means anything in the answer it arrived with, so the pages have to be asked
189
189
  // for in order.
190
190
  //
191
- // The seed is held still across the whole walk on purpose. A generator that
191
+ // The seed is held still across the whole walk on purpose. A handler that
192
192
  // mints its token out of `seed` rather than out of the request is the exact
193
193
  // mistake being looked for, and a still seed makes it a fixed point — visible
194
194
  // on the second page here instead of on somebody's client.
@@ -227,7 +227,7 @@ export function nextPage(previous, paging, token) {
227
227
  // mismatch there is a broken mock. A query parameter is usually a control
228
228
  // rather than content — `?source=realtime`, `?page_size=50`, `?cursor=…` — and
229
229
  // real APIs collide those names with unrelated response properties all the
230
- // time. Enforcing on them rejected working generators for a rule they were
230
+ // time. Enforcing on them rejected working handlers for a rule they were
231
231
  // right to ignore. The prompt still asks for query echo where it is meaningful;
232
232
  // it is simply not a reason to throw the file away.
233
233
  // ---------------------------------------------------------------------------