@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.
- 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/store.js
ADDED
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { mkdirSync, readdirSync, rmSync, statSync } from 'node:fs';
|
|
3
|
+
import { dirname, join, resolve } from 'node:path';
|
|
4
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
5
|
+
const UPSERT = `
|
|
6
|
+
INSERT INTO records (collection, id, data, updated_at) VALUES (?, ?, ?, ?)
|
|
7
|
+
ON CONFLICT(collection, id) DO UPDATE SET data = excluded.data, updated_at = excluded.updated_at
|
|
8
|
+
`;
|
|
9
|
+
export class WorldStore {
|
|
10
|
+
path;
|
|
11
|
+
#db;
|
|
12
|
+
constructor(path) {
|
|
13
|
+
this.path = resolve(path);
|
|
14
|
+
mkdirSync(dirname(this.path), { recursive: true });
|
|
15
|
+
this.#db = new DatabaseSync(this.path);
|
|
16
|
+
this.#db.exec(`
|
|
17
|
+
PRAGMA busy_timeout = 10000;
|
|
18
|
+
PRAGMA journal_mode = WAL;
|
|
19
|
+
PRAGMA synchronous = NORMAL;
|
|
20
|
+
CREATE TABLE IF NOT EXISTS records (
|
|
21
|
+
collection TEXT NOT NULL,
|
|
22
|
+
id TEXT NOT NULL,
|
|
23
|
+
data TEXT NOT NULL,
|
|
24
|
+
updated_at TEXT NOT NULL,
|
|
25
|
+
PRIMARY KEY (collection, id)
|
|
26
|
+
);
|
|
27
|
+
CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT NOT NULL);
|
|
28
|
+
`);
|
|
29
|
+
}
|
|
30
|
+
collection(name) {
|
|
31
|
+
const db = this.#db;
|
|
32
|
+
const col = name.trim();
|
|
33
|
+
const parse = (text) => {
|
|
34
|
+
try {
|
|
35
|
+
return JSON.parse(text);
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return undefined;
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
const all = () => db
|
|
42
|
+
.prepare('SELECT data FROM records WHERE collection = ? ORDER BY rowid')
|
|
43
|
+
.all(col)
|
|
44
|
+
.map((row) => parse(row.data))
|
|
45
|
+
.filter((v) => v !== undefined);
|
|
46
|
+
return {
|
|
47
|
+
name: col,
|
|
48
|
+
count: () => Number(db
|
|
49
|
+
.prepare('SELECT COUNT(*) AS n FROM records WHERE collection = ?')
|
|
50
|
+
.get(col).n),
|
|
51
|
+
all,
|
|
52
|
+
get: (id) => {
|
|
53
|
+
const row = db
|
|
54
|
+
.prepare('SELECT data FROM records WHERE collection = ? AND id = ?')
|
|
55
|
+
.get(col, String(id));
|
|
56
|
+
return row ? parse(row.data) : undefined;
|
|
57
|
+
},
|
|
58
|
+
has: (id) => db
|
|
59
|
+
.prepare('SELECT 1 FROM records WHERE collection = ? AND id = ?')
|
|
60
|
+
.get(col, String(id)) !== undefined,
|
|
61
|
+
find: (predicate) => all().filter(predicate),
|
|
62
|
+
put: (id, value) => {
|
|
63
|
+
db.prepare(UPSERT).run(col, String(id), JSON.stringify(value), new Date().toISOString());
|
|
64
|
+
return value;
|
|
65
|
+
},
|
|
66
|
+
putMany: (items) => {
|
|
67
|
+
const stmt = db.prepare(UPSERT);
|
|
68
|
+
const now = new Date().toISOString();
|
|
69
|
+
db.exec('BEGIN IMMEDIATE');
|
|
70
|
+
try {
|
|
71
|
+
for (const item of items) {
|
|
72
|
+
stmt.run(col, String(item.id), JSON.stringify(item.value), now);
|
|
73
|
+
}
|
|
74
|
+
db.exec('COMMIT');
|
|
75
|
+
}
|
|
76
|
+
catch (err) {
|
|
77
|
+
db.exec('ROLLBACK');
|
|
78
|
+
throw err;
|
|
79
|
+
}
|
|
80
|
+
return items.length;
|
|
81
|
+
},
|
|
82
|
+
delete: (id) => Number(db
|
|
83
|
+
.prepare('DELETE FROM records WHERE collection = ? AND id = ?')
|
|
84
|
+
.run(col, String(id)).changes) > 0,
|
|
85
|
+
clear: () => {
|
|
86
|
+
db.prepare('DELETE FROM records WHERE collection = ?').run(col);
|
|
87
|
+
},
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
collections() {
|
|
91
|
+
const rows = db(this).prepare('SELECT collection, COUNT(*) AS n FROM records GROUP BY collection ORDER BY collection');
|
|
92
|
+
return rows.all().map((row) => ({
|
|
93
|
+
name: row.collection,
|
|
94
|
+
count: Number(row.n),
|
|
95
|
+
sampleIds: db(this)
|
|
96
|
+
.prepare('SELECT id FROM records WHERE collection = ? ORDER BY rowid LIMIT 5')
|
|
97
|
+
.all(row.collection).map((r) => r.id),
|
|
98
|
+
}));
|
|
99
|
+
}
|
|
100
|
+
getMeta(key) {
|
|
101
|
+
const row = this.#db.prepare('SELECT value FROM meta WHERE key = ?').get(key);
|
|
102
|
+
return row ? JSON.parse(row.value) : undefined;
|
|
103
|
+
}
|
|
104
|
+
setMeta(key, value) {
|
|
105
|
+
this.#db
|
|
106
|
+
.prepare('INSERT INTO meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value')
|
|
107
|
+
.run(key, JSON.stringify(value));
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* A consistent copy of the whole world, for judging a handler without
|
|
111
|
+
* letting its test traffic leak into what real requests will see.
|
|
112
|
+
*/
|
|
113
|
+
snapshot(to) {
|
|
114
|
+
const target = resolve(to);
|
|
115
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
116
|
+
this.#db.prepare('VACUUM INTO ?').run(target);
|
|
117
|
+
return target;
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Takes a lease, or says someone else holds it. A lease is a row in `meta`
|
|
121
|
+
* claimed inside one immediate transaction, so two processes on the same
|
|
122
|
+
* file cannot both win; it lapses when its time is up or its process is
|
|
123
|
+
* gone, so a crash costs nobody a wait.
|
|
124
|
+
*/
|
|
125
|
+
claim(key, owner, ttlMs) {
|
|
126
|
+
const name = `lease:${key}`;
|
|
127
|
+
const now = Date.now();
|
|
128
|
+
this.#db.exec('BEGIN IMMEDIATE');
|
|
129
|
+
try {
|
|
130
|
+
const row = this.#db.prepare('SELECT value FROM meta WHERE key = ?').get(name);
|
|
131
|
+
const held = row ? JSON.parse(row.value) : undefined;
|
|
132
|
+
if (held && held.owner !== owner && held.until > now && alive(held.owner)) {
|
|
133
|
+
this.#db.exec('COMMIT');
|
|
134
|
+
return false;
|
|
135
|
+
}
|
|
136
|
+
this.#db
|
|
137
|
+
.prepare('INSERT INTO meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value')
|
|
138
|
+
.run(name, JSON.stringify({ owner, until: now + ttlMs }));
|
|
139
|
+
this.#db.exec('COMMIT');
|
|
140
|
+
return true;
|
|
141
|
+
}
|
|
142
|
+
catch (err) {
|
|
143
|
+
this.#db.exec('ROLLBACK');
|
|
144
|
+
throw err;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
/** Gives a lease back, if it is still this owner's. */
|
|
148
|
+
release(key, owner) {
|
|
149
|
+
const name = `lease:${key}`;
|
|
150
|
+
const row = this.#db.prepare('SELECT value FROM meta WHERE key = ?').get(name);
|
|
151
|
+
if (row && JSON.parse(row.value).owner === owner) {
|
|
152
|
+
this.#db.prepare('DELETE FROM meta WHERE key = ?').run(name);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
/** Throws away every entity, keeping the file and its schema. */
|
|
156
|
+
reset() {
|
|
157
|
+
this.#db.exec('DELETE FROM records; DELETE FROM meta;');
|
|
158
|
+
}
|
|
159
|
+
close() {
|
|
160
|
+
try {
|
|
161
|
+
this.#db.close();
|
|
162
|
+
}
|
|
163
|
+
catch {
|
|
164
|
+
// already closed
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
/** @internal */
|
|
168
|
+
get raw() {
|
|
169
|
+
return this.#db;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
const db = (store) => store.raw;
|
|
173
|
+
/** A lease owner `<pid>:…` whose process still runs; anything else is taken as alive. */
|
|
174
|
+
function alive(owner) {
|
|
175
|
+
const pid = Number(owner.split(':')[0]);
|
|
176
|
+
if (!Number.isInteger(pid) || pid <= 0) {
|
|
177
|
+
return true;
|
|
178
|
+
}
|
|
179
|
+
try {
|
|
180
|
+
process.kill(pid, 0);
|
|
181
|
+
return true;
|
|
182
|
+
}
|
|
183
|
+
catch (err) {
|
|
184
|
+
return err.code === 'EPERM';
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
/** A lease owner for this process. */
|
|
188
|
+
export const leaseOwner = () => `${process.pid}:${Math.random().toString(36).slice(2, 10)}`;
|
|
189
|
+
/** A world named rather than given as a path: letters, digits, `.`, `_`, `-`. */
|
|
190
|
+
const WORLD_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
191
|
+
/**
|
|
192
|
+
* Where the world lives. `--world` wins: a name is a file under
|
|
193
|
+
* `<root>/worlds`, anything that looks like a path is that path. Otherwise the
|
|
194
|
+
* story names its own world, keyed by what the story says, so two processes
|
|
195
|
+
* handed the same story share one world without being told to; with neither,
|
|
196
|
+
* there is one default world.
|
|
197
|
+
*/
|
|
198
|
+
export function worldPath(opts) {
|
|
199
|
+
const named = opts.world?.trim();
|
|
200
|
+
if (named) {
|
|
201
|
+
if (/[\\/]/.test(named) || /\.(sqlite|db)$/i.test(named)) {
|
|
202
|
+
return resolve(opts.cwd, named);
|
|
203
|
+
}
|
|
204
|
+
if (!WORLD_NAME.test(named)) {
|
|
205
|
+
throw new Error(`"${named}" is not a world name: use letters, digits, ".", "_" and "-", or a path`);
|
|
206
|
+
}
|
|
207
|
+
return join(worldsDir(opts.root), `${named}.sqlite`);
|
|
208
|
+
}
|
|
209
|
+
const name = opts.storyDigest ? `story-${opts.storyDigest.slice(0, 16)}` : 'default';
|
|
210
|
+
return join(worldsDir(opts.root), `${name}.sqlite`);
|
|
211
|
+
}
|
|
212
|
+
export const worldsDir = (root) => join(root, 'worlds');
|
|
213
|
+
/** Every world under `<root>/worlds`, most recently used first. */
|
|
214
|
+
export function listWorlds(root) {
|
|
215
|
+
const dir = worldsDir(root);
|
|
216
|
+
let names;
|
|
217
|
+
try {
|
|
218
|
+
names = readdirSync(dir).filter((n) => n.endsWith('.sqlite'));
|
|
219
|
+
}
|
|
220
|
+
catch {
|
|
221
|
+
return [];
|
|
222
|
+
}
|
|
223
|
+
return names
|
|
224
|
+
.map((file) => {
|
|
225
|
+
const path = join(dir, file);
|
|
226
|
+
const info = statSync(path);
|
|
227
|
+
let collections = [];
|
|
228
|
+
try {
|
|
229
|
+
const store = new WorldStore(path);
|
|
230
|
+
collections = store.collections();
|
|
231
|
+
store.close();
|
|
232
|
+
}
|
|
233
|
+
catch {
|
|
234
|
+
// unreadable: listed, with nothing in it
|
|
235
|
+
}
|
|
236
|
+
return {
|
|
237
|
+
name: file.replace(/\.sqlite$/, ''),
|
|
238
|
+
path,
|
|
239
|
+
bytes: info.size,
|
|
240
|
+
usedAt: info.mtime,
|
|
241
|
+
collections,
|
|
242
|
+
};
|
|
243
|
+
})
|
|
244
|
+
.sort((a, b) => b.usedAt.getTime() - a.usedAt.getTime());
|
|
245
|
+
}
|
|
246
|
+
/** Deletes a world and the journal files SQLite keeps beside it. */
|
|
247
|
+
export function removeWorld(path) {
|
|
248
|
+
for (const suffix of ['', '-wal', '-shm']) {
|
|
249
|
+
rmSync(`${path}${suffix}`, { force: true });
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
export const digest = (text) => createHash('sha256').update(text).digest('hex').slice(0, 16);
|
|
253
|
+
//# sourceMappingURL=store.js.map
|
package/dist/synth.d.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { type Model } from '@zenera/neo';
|
|
2
|
+
import type { Bindings } from './bindings.ts';
|
|
3
|
+
import type { SynthesizeHook } from './box.ts';
|
|
4
|
+
import type { Catalog } from './catalog.ts';
|
|
5
|
+
import { WorldStore } from './store.ts';
|
|
6
|
+
import type { Knowledge } from './knowledge.ts';
|
|
7
|
+
export interface WorldEvent {
|
|
8
|
+
collection: string;
|
|
9
|
+
made: number;
|
|
10
|
+
/** the agent did not deliver and sampled records stood in */
|
|
11
|
+
fallback?: string;
|
|
12
|
+
/** records of other types made along the way, because these refer to them */
|
|
13
|
+
dependents?: number;
|
|
14
|
+
/** references in what was made that point at nothing in the world */
|
|
15
|
+
dangling?: string[];
|
|
16
|
+
report?: string;
|
|
17
|
+
durationMs: number;
|
|
18
|
+
}
|
|
19
|
+
export interface WorldOptions {
|
|
20
|
+
model: Model;
|
|
21
|
+
knowledge: Knowledge;
|
|
22
|
+
catalog: Catalog;
|
|
23
|
+
/** where each type lives; the world agent may place a type nobody has used yet */
|
|
24
|
+
bindings: Bindings;
|
|
25
|
+
/** the live world */
|
|
26
|
+
store: WorldStore;
|
|
27
|
+
reportDir?: string;
|
|
28
|
+
onMade?: (event: WorldEvent) => void;
|
|
29
|
+
}
|
|
30
|
+
export declare class World {
|
|
31
|
+
#private;
|
|
32
|
+
readonly store: WorldStore;
|
|
33
|
+
/** what the agents know: the story, and the docs with the tools that read them */
|
|
34
|
+
readonly knowledge: Knowledge;
|
|
35
|
+
constructor(opts: WorldOptions);
|
|
36
|
+
/** Given to the box: every request a handler makes for content lands here. */
|
|
37
|
+
readonly synthesize: SynthesizeHook;
|
|
38
|
+
/** Forgets a world that was only borrowed, such as a judging snapshot. */
|
|
39
|
+
release(db: string): void;
|
|
40
|
+
close(): void;
|
|
41
|
+
}
|
|
42
|
+
//# sourceMappingURL=synth.d.ts.map
|