@zenera/gym 1.1.40 → 1.1.41
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 +169 -104
- package/dist/agents.d.ts +25 -0
- package/dist/agents.js +52 -0
- package/dist/bindings.d.ts +25 -0
- package/dist/bindings.js +81 -0
- package/dist/box.d.ts +39 -36
- package/dist/box.js +183 -217
- package/dist/cache.d.ts +24 -14
- package/dist/cache.js +102 -66
- package/dist/catalog.d.ts +83 -0
- package/dist/catalog.js +293 -0
- package/dist/command.d.ts +1 -0
- package/dist/command.js +235 -68
- package/dist/fake.d.ts +4 -0
- package/dist/fake.js +142 -0
- package/dist/generate.d.ts +28 -24
- package/dist/generate.js +417 -120
- package/dist/inputs.d.ts +12 -0
- package/dist/inputs.js +132 -0
- package/dist/knowledge.d.ts +50 -0
- package/dist/knowledge.js +199 -0
- package/dist/logger.d.ts +10 -8
- package/dist/logger.js +4 -3
- package/dist/paging.d.ts +2 -2
- package/dist/paging.js +2 -2
- package/dist/probe.d.ts +5 -5
- package/dist/probe.js +15 -15
- package/dist/prompt.d.ts +31 -18
- package/dist/prompt.js +160 -146
- package/dist/quiet.d.ts +2 -0
- package/dist/quiet.js +7 -0
- package/dist/router.js +5 -1
- package/dist/runtime.d.ts +160 -0
- package/dist/runtime.js +229 -0
- package/dist/schema.js +1 -2
- package/dist/server.js +98 -57
- package/dist/setup.d.ts +47 -6
- package/dist/setup.js +157 -21
- package/dist/spec.d.ts +4 -3
- package/dist/spec.js +73 -22
- package/dist/store.d.ts +78 -0
- package/dist/store.js +253 -0
- package/dist/synth.d.ts +42 -0
- package/dist/synth.js +589 -0
- package/dist/worker.d.ts +41 -0
- package/dist/worker.js +99 -0
- package/package.json +5 -3
- package/dist/envelope.d.ts +0 -13
- package/dist/envelope.js +0 -9
- package/dist/image.d.ts +0 -25
- 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 {
|
|
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' | '
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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:
|
|
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:
|
|
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._']), '', '###
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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 {
|
|
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):
|
|
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):
|
|
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:
|
|
11
|
-
export declare function echoIssues(input:
|
|
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
|
|
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
|
|
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
|
|
21
|
-
*
|
|
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
|
|
48
|
+
// other, so a handler cannot assume either.
|
|
49
49
|
if (!p.required && variant === 1) {
|
|
50
50
|
continue;
|
|
51
51
|
}
|
|
52
|
-
|
|
53
|
-
p.
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
*
|
|
63
|
-
*
|
|
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
|
|
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
|
|
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
|
|
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
|
// ---------------------------------------------------------------------------
|