@volter/world-core 3.0.165 → 3.0.167

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 (47) hide show
  1. package/attach.cjs +7 -3
  2. package/dist/attach.cjs +7 -3
  3. package/dist/src/changeset.d.ts +9 -0
  4. package/dist/src/changeset.js +5 -2
  5. package/dist/src/checkout.d.ts +4 -0
  6. package/dist/src/checkout.js +13 -0
  7. package/dist/src/completeness.d.ts +36 -0
  8. package/dist/src/completeness.js +46 -0
  9. package/dist/src/derived-core.d.ts +27 -0
  10. package/dist/src/derived-core.js +31 -2
  11. package/dist/src/events.d.ts +6 -1
  12. package/dist/src/events.js +58 -13
  13. package/dist/src/index.d.ts +4 -2
  14. package/dist/src/index.js +3 -2
  15. package/dist/src/openai-wire.js +0 -1
  16. package/dist/src/pack-fetch.d.ts +1 -3
  17. package/dist/src/pack-fetch.js +103 -43
  18. package/dist/src/runtime.d.ts +5 -2
  19. package/dist/src/runtime.js +3 -1
  20. package/dist/src/scenario-consume.d.ts +43 -0
  21. package/dist/src/scenario-consume.js +111 -0
  22. package/dist/src/scenario-serve.d.ts +16 -0
  23. package/dist/src/scenario-serve.js +47 -0
  24. package/dist/src/scenario-timeline.d.ts +131 -0
  25. package/dist/src/scenario-timeline.js +242 -0
  26. package/dist/src/scenario.d.ts +15 -93
  27. package/dist/src/scenario.js +34 -323
  28. package/dist/src/sockets.d.ts +8 -4
  29. package/dist/src/sockets.js +1 -1
  30. package/dist/src/world-clock.d.ts +3 -0
  31. package/dist/src/world-clock.js +14 -9
  32. package/package.json +5 -1
  33. package/src/changeset.ts +13 -3
  34. package/src/checkout.ts +12 -0
  35. package/src/completeness.ts +57 -0
  36. package/src/derived-core.ts +41 -2
  37. package/src/events.ts +58 -11
  38. package/src/index.ts +5 -3
  39. package/src/openai-wire.ts +0 -1
  40. package/src/pack-fetch.ts +81 -39
  41. package/src/runtime.ts +5 -2
  42. package/src/scenario-consume.ts +123 -0
  43. package/src/scenario-serve.ts +47 -0
  44. package/src/scenario-timeline.ts +269 -0
  45. package/src/scenario.ts +43 -341
  46. package/src/sockets.ts +5 -5
  47. package/src/world-clock.ts +16 -9
package/src/scenario.ts CHANGED
@@ -1,24 +1,12 @@
1
- import { exactInteger, compareExactNumbers, isExactIntegerValue } from './exact-json.ts';
2
- // THE scenario engine — System 2 of the twin programming model (one grammar, per-pack
3
- // vocabulary). See company-repo BRIEFS/TWIN-PROGRAMMING-MODEL.md (LOCKED, 2026-08-27).
1
+ // A model vendor's turn, matched with the pack's own vocabulary (architecture, "Scenarios"; ADR 0036). The World's
2
+ // scenario places turn decisions and faults on a timeline (scenario-timeline.ts); scenario-serve.ts hands the ones not
3
+ // used up to this engine as ordered rules `{ on, respond | fault }`, and the first whose `on` conditions ALL hold, and
4
+ // whose answer the request admits, decides the turn. No match is the miss: the pack's labelled deterministic stub.
4
5
  //
5
- // A HANDLER is an MSW-shaped data rule: { on, respond, once?, scope?, phase?, advancePhase? }.
6
- // Handlers are evaluated IN ORDER; the FIRST handler whose `on` conditions ALL hold fires.
7
- // No match → the caller serves its labeled deterministic stub and records the MISS (with the
8
- // request's extracted features — the authoring signal). The handler FILE in the world dir is
9
- // the only write surface; `engine.use(...)` exists for in-process tests only (LIFO over the
10
- // baseline, removable). There are NO runtime write doors — a running world is never mutated.
11
- //
12
- // The GRAMMAR (structure, ordering, once/scope/phase, strict validation, extractors,
13
- // placeholders, miss records) is this module's and identical for every vendor. The
14
- // VOCABULARY (which `on` keys exist and how each matches; what `respond` may contain; which
15
- // routes are stateful and therefore refuse success-shaped handlers) is the pack's, declared
16
- // through a PackScenarioAdapter. Determinism: the engine is a pure state machine — same
17
- // handler list + same request sequence → same decisions, byte for byte.
18
- //
19
- // STRICT EVERYWHERE (the gemini discipline): unknown top-level keys, unknown `on` keys,
20
- // unknown placeholder names, malformed extractors — all THROW with the valid vocabulary in
21
- // the message. A typo must fail loudly at load, never silently mis-match at serve.
6
+ // The GRAMMAR (strict validation, ordering, the fault vocabulary) is this module's and the same for every vendor; the
7
+ // VOCABULARY (which `on` keys exist and how each matches, what `respond` may contain, which routes are stateful and so
8
+ // refuse success-shaped answers) is the pack's, declared through a PackScenarioAdapter. A typo fails loudly at load,
9
+ // naming the valid vocabulary, never silently mis-matches at serve.
22
10
 
23
11
  /** A vendor-agnostic bag of facts about one request, produced by the pack's adapter. Powers
24
12
  * matching context, miss records (the authoring signal), and the self-teaching stub text. */
@@ -36,8 +24,8 @@ export type PackScenarioAdapter<Req> = {
36
24
  /** The legal `on` keys and their per-request semantics. Pure. */
37
25
  matchers: Record<string, ScenarioMatcher<Req>>;
38
26
  /** Validate `on` CONDITION VALUES at load (key membership is the kernel's; VALUE typing is
39
- * the pack's — "a string nthCall silently never matches" is exactly the misfire strict
40
- * loading exists to prevent). Return an error string to refuse. */
27
+ * the pack's — a mistyped value silently never matching is the misfire strict loading exists
28
+ * to prevent). Return an error string to refuse. */
41
29
  validateOn?: (on: Record<string, unknown>) => string | null;
42
30
  /** Validate a handler's `respond` payload at load; return an error string to refuse.
43
31
  * This is ALSO where a pack refuses success-shaped handlers on stateful routes
@@ -46,22 +34,10 @@ export type PackScenarioAdapter<Req> = {
46
34
  /** Whether the request admits a handler's answer (OpenAI's `tool_choice: 'none'` admits no tool call): a handler whose
47
35
  * answer it does not admit is passed over, and the next that matches answers, else the stub. Absent, every answer. */
48
36
  admits?: (req: Req, respond: unknown) => boolean;
49
- /** The request's text corpus for `textPattern` extractors (packs with text requests). */
50
- text?: (req: Req) => string;
51
- /** Pack-defined extractor KINDS beyond the builtins (feature, textPattern). A pack kind with
52
- * a builtin's name OVERRIDES the builtin (e.g. a richer textPattern with flags/group).
53
- * validate returns an error string to refuse the spec at load; extract runs at serve time
54
- * and throws ScenarioError when the request cannot supply the value. */
55
- extractorKinds?: Record<string, {
56
- validate: (spec: Record<string, unknown>, name: string) => string | null;
57
- extract: (spec: Record<string, unknown>, req: Req, name: string) => string | number;
58
- }>;
59
- /** The per-session scope discriminator; omitted → all requests share one "world" scope. */
60
- scopeKey?: (req: Req) => string;
61
- /** The VENDOR'S error envelope for a `status` fault — a 429 from this vendor looks like this
62
- * vendor's 429. Omitted → the kernel's envelope `{ error: { message, type: "twin_fault" } }`. */
63
37
  /** Opt-in scope check for post-admission model generation failures; absent rejects them. */
64
38
  validateGenerationFault?: (fault: ScenarioGenerationFault, handler: ScenarioHandler) => string | null;
39
+ /** The VENDOR'S error envelope for a `status` fault — a 429 from this vendor looks like this
40
+ * vendor's 429. Omitted → the kernel's envelope `{ error: { message, type: "twin_fault" } }`. */
65
41
  renderFault?: (fault: ScenarioStatusFault, req: Req) => { body: unknown; headers?: Record<string, string> };
66
42
  };
67
43
 
@@ -88,344 +64,72 @@ export type ScenarioStatusFault = Extract<ScenarioFault, { kind: "status" }>;
88
64
  export type ScenarioFaultResult = { status: number; headers: Record<string, string>; body: unknown };
89
65
 
90
66
  export type ScenarioHandler = {
91
- /** Stable id for status/telemetry; defaults to `handler-<1-based index>`. */
67
+ /** The rule's id: `entry-<n>`, the timeline entry it came from. */
92
68
  id?: string;
93
- /** Conditions — ALL must hold. `{}` matches every request (an ordered catch-all). */
69
+ /** Conditions — ALL must hold. `{}` matches every request. */
94
70
  on: Record<string, unknown>;
95
- /** Pack-realized response content (the pack's realizer builds the faithful envelope).
96
- * Optional only on a handler that carries a `fault` (a `slow` may still carry one; a
97
- * `status` or `drop` serves no content, so it must not). */
71
+ /** The turn's answer, which the pack realizes into the vendor's envelope; absent on a fault. */
98
72
  respond?: unknown;
99
- /** A fault to inject when this handler fires (ScenarioFault). Same once/phase/scope
100
- * semantics as any handler — a mid-story outage is `{on, fault, once}` or `{phase}`. */
73
+ /** The fault that answers the turn instead (ScenarioFault). */
101
74
  fault?: ScenarioFault;
102
- /** Fire at most once per scope. */
103
- once?: boolean;
104
- /** Reserved: "world" (default) | "session" — with "session", once/phase state is per
105
- * scopeKey instead of shared. */
106
- scope?: "world" | "session";
107
- /** Fires only while the scope's phase equals this. Handlers without `phase` fire in any. */
108
- phase?: string;
109
- /** On fire, move the scope's phase — the tiny sequencing primitive that replaces
110
- * linear scripts and nthCall arithmetic. */
111
- advancePhase?: string;
112
75
  };
113
76
 
114
- /** Request-derived values a `respond` payload may reference as "{{name}}" placeholders —
115
- * builtin kinds, or any kind the pack's adapter declares. Payload placeholders support the
116
- * numeric transforms `{{name|min:N}}` / `{{name|max:N}}`; a whole-string placeholder yields
117
- * the TYPED value (numbers stay numbers). */
118
- export type ScenarioExtractorSpec =
119
- | { kind: "feature"; feature: string }
120
- | { kind: "textPattern"; pattern: string; as?: "string" | "number" }
121
- | ({ kind: string } & Record<string, unknown>);
122
-
123
- export type ScenarioDocument = {
124
- extractors?: Record<string, ScenarioExtractorSpec>;
125
- handlers: ScenarioHandler[];
126
- };
77
+ /** The ordered rules of one turn. */
78
+ export type ScenarioDocument = { handlers: ScenarioHandler[] };
127
79
 
128
- export type ScenarioMissRecord = { features: ScenarioFeatures; phase: string | undefined };
80
+ export type ScenarioMissRecord = { features: ScenarioFeatures };
129
81
 
130
82
  export type ScenarioDecision =
131
83
  | { kind: "handler"; handler: ScenarioHandler; respond: unknown; ruleId: string; fault?: ScenarioFault }
132
84
  | { kind: "miss"; miss: ScenarioMissRecord };
133
85
 
134
- export type ScenarioStatus = {
135
- vendor: string;
136
- handlers: Array<{ id: string; phase?: string; once?: boolean; scope?: string; matches: number; source: "file" | "use" }>;
137
- misses: number;
138
- recentMisses: ScenarioMissRecord[];
139
- };
140
-
141
86
  export class ScenarioError extends Error {}
142
87
 
143
- // `$comment` is allowed (and ignored) at document and handler level — JSON has no comments
144
- // and scenario files are hand-authored story documents.
145
- const HANDLER_KEYS = new Set(["id", "on", "respond", "fault", "once", "scope", "phase", "advancePhase", "$comment"]);
146
- const DOCUMENT_KEYS = new Set(["extractors", "handlers", "$comment"]);
88
+ const HANDLER_KEYS = new Set(["id", "on", "respond", "fault"]);
147
89
 
148
- /** Strict-loud parse of a scenario DOCUMENT (the per-vendor handlers/<vendor>.json content).
149
- * The caller does file IO; this validates. Every refusal names the valid vocabulary. */
150
- export function parseScenarioDocument<Req>(raw: unknown, adapter: PackScenarioAdapter<Req>): ScenarioDocument {
151
- if (typeof raw !== "object" || raw === null || Array.isArray(raw)) throw new ScenarioError(`${adapter.vendor} scenario: the document is an object { extractors?, handlers }`);
152
- const doc = raw as Record<string, unknown>;
153
- for (const key of Object.keys(doc)) {
154
- if (!DOCUMENT_KEYS.has(key)) throw new ScenarioError(`${adapter.vendor} scenario: unknown key "${key}" (valid: ${[...DOCUMENT_KEYS].join(", ")})`);
155
- }
156
- const extractors: Record<string, ScenarioExtractorSpec> = {};
157
- if (doc.extractors !== undefined) {
158
- if (typeof doc.extractors !== "object" || doc.extractors === null || Array.isArray(doc.extractors)) throw new ScenarioError(`${adapter.vendor} scenario: extractors is an object of named specs`);
159
- for (const [name, spec] of Object.entries(doc.extractors as Record<string, unknown>)) {
160
- extractors[name] = parseExtractor(name, spec, adapter);
161
- }
162
- }
163
- if (!Array.isArray(doc.handlers)) throw new ScenarioError(`${adapter.vendor} scenario: handlers is an array`);
164
- const handlers = (doc.handlers as unknown[]).map((h, i) => parseHandler(h, i, adapter, extractors));
165
- return { ...(doc.extractors !== undefined ? { extractors } : {}), handlers };
90
+ /** Strict-loud parse of a turn's rules. Every refusal names the valid vocabulary. */
91
+ export function parseScenarioDocument<Req>(raw: ScenarioDocument, adapter: PackScenarioAdapter<Req>): ScenarioDocument {
92
+ return { handlers: raw.handlers.map((h, i) => parseHandler(h, i, adapter)) };
166
93
  }
167
94
 
168
- function parseExtractor<Req>(name: string, raw: unknown, adapter: PackScenarioAdapter<Req>): ScenarioExtractorSpec {
169
- if (typeof raw !== "object" || raw === null) throw new ScenarioError(`${adapter.vendor} scenario: extractor "${name}" is an object`);
170
- const spec = raw as Record<string, unknown>;
171
- const packKind = typeof spec.kind === "string" ? adapter.extractorKinds?.[spec.kind] : undefined;
172
- if (packKind !== undefined) {
173
- const refusal = packKind.validate(spec, name);
174
- if (refusal) throw new ScenarioError(`${adapter.vendor} scenario: extractor "${name}": ${refusal}`);
175
- return spec as ScenarioExtractorSpec;
176
- }
177
- if (spec.kind === "feature") {
178
- if (typeof spec.feature !== "string" || spec.feature.length === 0) throw new ScenarioError(`${adapter.vendor} scenario: extractor "${name}" (feature) needs a feature name`);
179
- for (const key of Object.keys(spec)) if (key !== "kind" && key !== "feature") throw new ScenarioError(`${adapter.vendor} scenario: extractor "${name}": unknown key "${key}"`);
180
- return { kind: "feature", feature: spec.feature };
181
- }
182
- if (spec.kind === "textPattern") {
183
- if (adapter.text === undefined) throw new ScenarioError(`${adapter.vendor} scenario: extractor "${name}" uses textPattern but this pack exposes no request text`);
184
- if (typeof spec.pattern !== "string") throw new ScenarioError(`${adapter.vendor} scenario: extractor "${name}" (textPattern) needs a pattern`);
185
- try { new RegExp(spec.pattern); } catch (e) { throw new ScenarioError(`${adapter.vendor} scenario: extractor "${name}": invalid pattern: ${e instanceof Error ? e.message : String(e)}`); }
186
- if (spec.as !== undefined && spec.as !== "string" && spec.as !== "number") throw new ScenarioError(`${adapter.vendor} scenario: extractor "${name}": as is "string" or "number"`);
187
- for (const key of Object.keys(spec)) if (!["kind", "pattern", "as"].includes(key)) throw new ScenarioError(`${adapter.vendor} scenario: extractor "${name}": unknown key "${key}"`);
188
- return { kind: "textPattern", pattern: spec.pattern, ...(spec.as !== undefined ? { as: spec.as as "string" | "number" } : {}) };
189
- }
190
- throw new ScenarioError(`${adapter.vendor} scenario: extractor "${name}": kind is one of ${[...new Set(["feature", "textPattern", ...Object.keys(adapter.extractorKinds ?? {})])].join(", ")}`);
191
- }
192
-
193
- function parseHandler<Req>(raw: unknown, index: number, adapter: PackScenarioAdapter<Req>, extractors: Record<string, ScenarioExtractorSpec>): ScenarioHandler {
194
- const at = `handler ${index + 1}`;
195
- if (typeof raw !== "object" || raw === null || Array.isArray(raw)) throw new ScenarioError(`${adapter.vendor} scenario: ${at} is an object { on, respond, ... }`);
95
+ function parseHandler<Req>(raw: unknown, index: number, adapter: PackScenarioAdapter<Req>): ScenarioHandler {
196
96
  const h = raw as Record<string, unknown>;
97
+ const at = typeof h.id === "string" ? h.id : `rule ${index + 1}`;
197
98
  for (const key of Object.keys(h)) {
198
99
  if (!HANDLER_KEYS.has(key)) throw new ScenarioError(`${adapter.vendor} scenario: ${at}: unknown key "${key}" (valid: ${[...HANDLER_KEYS].join(", ")})`);
199
100
  }
200
101
  if (typeof h.on !== "object" || h.on === null || Array.isArray(h.on)) throw new ScenarioError(`${adapter.vendor} scenario: ${at}: \`on\` is an object of conditions ({} matches all)`);
201
- // `nthCall` is the one kernel-builtin condition (1-based per-scope call index); every other
202
- // `on` key is the pack's declared vocabulary.
203
- const validOn = ["nthCall", ...Object.keys(adapter.matchers)];
204
- for (const [key, value] of Object.entries(h.on as Record<string, unknown>)) {
102
+ const validOn = Object.keys(adapter.matchers);
103
+ for (const key of Object.keys(h.on as Record<string, unknown>)) {
205
104
  if (!validOn.includes(key)) throw new ScenarioError(`${adapter.vendor} scenario: ${at}: unknown \`on\` key "${key}" (valid here: ${validOn.join(", ")})`);
206
- if (key === "nthCall" && (!Number.isInteger(value) || (value as number) < 1)) throw new ScenarioError(`${adapter.vendor} scenario: ${at}: nthCall is a positive integer`);
207
105
  }
208
106
  const onRefusal = adapter.validateOn?.(h.on as Record<string, unknown>);
209
107
  if (onRefusal) throw new ScenarioError(`${adapter.vendor} scenario: ${at}: ${onRefusal}`);
210
- if (h.fault !== undefined) {
211
- const f = h.fault as Record<string, unknown>;
212
- if (typeof f !== "object" || f === null || Array.isArray(f)) throw new ScenarioError(`${adapter.vendor} scenario: ${at}: fault is an object { kind: "slow" | "status" | "drop" | "generation", ... }`);
213
- const kinds = ["slow", "status", "drop", "generation"];
214
- if (!kinds.includes(f.kind as string)) throw new ScenarioError(`${adapter.vendor} scenario: ${at}: fault.kind is one of ${kinds.join(", ")}, got ${JSON.stringify(f.kind)}`);
215
- const keysFor: Record<string, string[]> = { slow: ["kind", "ms"], status: ["kind", "status", "retryAfterSeconds", "message", "metadata", "errorType"], generation: ["kind", "status", "message", "errorType", "metadata", "partialText", "event"], drop: ["kind", "holdMs"] };
216
- for (const key of Object.keys(f)) if (!keysFor[f.kind as string]!.includes(key)) throw new ScenarioError(`${adapter.vendor} scenario: ${at}: fault.${key} is not a key of a ${String(f.kind)} fault (valid: ${keysFor[f.kind as string]!.join(", ")})`);
217
- if (f.kind === "slow" && !(Number.isInteger(f.ms) && (f.ms as number) >= 0 && (f.ms as number) <= 600_000)) throw new ScenarioError(`${adapter.vendor} scenario: ${at}: fault.ms is an integer 0..600000 (milliseconds)`);
218
- if ((f.kind === "status" || f.kind === "generation") && !(Number.isInteger(f.status) && (f.status as number) >= 400 && (f.status as number) <= 599)) throw new ScenarioError(`${adapter.vendor} scenario: ${at}: fault.status is an integer 400..599 — a fault is a refusal or a failure, never a success`);
219
- if (f.kind === "status" && f.retryAfterSeconds !== undefined && !(Number.isInteger(f.retryAfterSeconds) && (f.retryAfterSeconds as number) >= 0)) throw new ScenarioError(`${adapter.vendor} scenario: ${at}: fault.retryAfterSeconds is a non-negative integer`);
220
- if (f.kind === "status" && f.message !== undefined && typeof f.message !== "string") throw new ScenarioError(`${adapter.vendor} scenario: ${at}: fault.message is a string`);
221
- if ((f.kind === "status" || f.kind === "generation") && f.metadata !== undefined && (typeof f.metadata !== "object" || f.metadata === null || Array.isArray(f.metadata))) throw new ScenarioError(`${adapter.vendor} scenario: ${at}: fault.metadata is an object`);
222
- if (f.kind === "status" && f.errorType !== undefined && typeof f.errorType !== "string") throw new ScenarioError(`${adapter.vendor} scenario: ${at}: fault.errorType is a string`);
223
- if (f.kind === "generation") {
224
- if (typeof f.message !== "string" || typeof f.errorType !== "string" || !f.errorType) throw new ScenarioError(`${adapter.vendor} scenario: ${at}: generation fault requires message and nonempty errorType`);
225
- for (const key of ["partialText", "event"]) if (f[key] !== undefined && typeof f[key] !== "string") throw new ScenarioError(`${adapter.vendor} scenario: ${at}: fault.${key} is a string`);
226
- if (!adapter.validateGenerationFault) throw new ScenarioError(`${adapter.vendor} scenario: ${at}: generation faults require a compute-only adapter`);
227
- const refusal = adapter.validateGenerationFault(f as ScenarioGenerationFault, h as unknown as ScenarioHandler);
228
- if (refusal) throw new ScenarioError(`${adapter.vendor} scenario: ${at}: ${refusal}`);
229
- }
230
- if (f.kind === "drop" && f.holdMs !== undefined && !(Number.isInteger(f.holdMs) && (f.holdMs as number) >= 0)) throw new ScenarioError(`${adapter.vendor} scenario: ${at}: fault.holdMs is a non-negative integer`);
231
- if (f.kind !== "slow" && "respond" in h) throw new ScenarioError(`${adapter.vendor} scenario: ${at}: a ${String(f.kind)} fault serves no content — drop \`respond\` (only a slow fault may carry one)`);
232
- }
233
- if (!("respond" in h) && h.fault === undefined) throw new ScenarioError(`${adapter.vendor} scenario: ${at}: \`respond\` is required (or a \`fault\`)`);
234
- if (h.id !== undefined && (typeof h.id !== "string" || h.id.length === 0)) throw new ScenarioError(`${adapter.vendor} scenario: ${at}: id is a non-empty string`);
235
- if (h.once !== undefined && typeof h.once !== "boolean") throw new ScenarioError(`${adapter.vendor} scenario: ${at}: once is boolean`);
236
- if (h.scope !== undefined && h.scope !== "world" && h.scope !== "session") throw new ScenarioError(`${adapter.vendor} scenario: ${at}: scope is "world" or "session"`);
237
- if (h.scope === "session" && adapter.scopeKey === undefined) throw new ScenarioError(`${adapter.vendor} scenario: ${at}: scope "session" but this pack derives no session key`);
238
- for (const key of ["phase", "advancePhase"] as const) {
239
- if (h[key] !== undefined && (typeof h[key] !== "string" || (h[key] as string).length === 0)) throw new ScenarioError(`${adapter.vendor} scenario: ${at}: ${key} is a non-empty string`);
240
- }
241
- for (const name of "respond" in h ? placeholderNames(h.respond) : []) {
242
- if (extractors[name] === undefined) throw new ScenarioError(`${adapter.vendor} scenario: ${at}: respond references "{{${name}}}" but no extractor "${name}" is declared`);
108
+ if (h.fault !== undefined && (h.fault as ScenarioFault).kind === "generation") {
109
+ if (!adapter.validateGenerationFault) throw new ScenarioError(`${adapter.vendor} scenario: ${at}: generation faults require a compute-only adapter`);
110
+ const refusal = adapter.validateGenerationFault(h.fault as ScenarioGenerationFault, h as unknown as ScenarioHandler);
111
+ if (refusal) throw new ScenarioError(`${adapter.vendor} scenario: ${at}: ${refusal}`);
243
112
  }
244
113
  const refusal = "respond" in h ? adapter.validateRespond?.(h.respond, h as ScenarioHandler) : null;
245
114
  if (refusal) throw new ScenarioError(`${adapter.vendor} scenario: ${at}: ${refusal}`);
246
115
  return h as ScenarioHandler;
247
116
  }
248
117
 
249
- const PLACEHOLDER_SRC = "\\{\\{([a-zA-Z0-9_.-]+)(\\|(?:min|max):-?[\\d.]+)?\\}\\}";
250
-
251
- function placeholderNames(value: unknown, out: Set<string> = new Set()): Set<string> {
252
- if (typeof value === "string") {
253
- for (const m of value.matchAll(new RegExp(PLACEHOLDER_SRC, "g"))) out.add(m[1]!);
254
- // A lone unparseable {{...}} is almost certainly a typo'd placeholder — fail loudly.
255
- const braces = /\{\{[^}]*\}\}/g.exec(value);
256
- if (braces && !new RegExp(`^${PLACEHOLDER_SRC}$`).test(braces[0])) {
257
- throw new ScenarioError(`malformed placeholder ${JSON.stringify(braces[0])} (expected {{name}} or {{name|min:N}}/{{name|max:N}})`);
258
- }
259
- } else if (Array.isArray(value)) {
260
- for (const v of value) placeholderNames(v, out);
261
- } else if (typeof value === "object" && value !== null) {
262
- for (const v of Object.values(value)) placeholderNames(v, out);
263
- }
264
- return out;
265
- }
266
-
267
- function adapterKind<Req>(adapter: PackScenarioAdapter<Req>, kind: string): { validate: (spec: Record<string, unknown>, name: string) => string | null; extract: (spec: Record<string, unknown>, req: Req, name: string) => string | number } | undefined {
268
- return adapter.extractorKinds?.[kind];
269
- }
270
-
271
- /** `{{name|min:N}}` / `{{name|max:N}}` — numeric clamps on an extracted value. */
272
- function applyTransform(value: string | number, transform: string | undefined, vendor: string, where: string): string | number {
273
- if (!transform) return value;
274
- const [op, rawN] = transform.slice(1).split(":") as [string, string];
275
- if (typeof value !== "number" && !(typeof value === "string" && isExactIntegerValue(value))) throw new ScenarioError(`${vendor} scenario: ${where} applies |${op}:${rawN} to a non-numeric extractor value ${JSON.stringify(value)}`);
276
- const n = /^-?\d+$/.test(rawN) ? exactInteger(rawN) : Number(rawN);
277
- const cmp = compareExactNumbers(value, n);
278
- return op === "min" ? (cmp <= 0 ? value : n) : (cmp >= 0 ? value : n);
279
- }
280
-
281
- type ScopeState = { calls: number; phase: string | undefined; onceFired: Set<string> };
282
- type Registered = { handler: ScenarioHandler; id: string; source: "file" | "use"; matches: number };
283
-
284
- const MISS_KEEP = 20;
285
-
286
- /** The engine: pure state machine over registered handlers. One instance per twin server. */
118
+ /** The engine over one turn's rules: pure, the same rules and request giving the same decision. */
287
119
  export class ScenarioEngine<Req> {
288
- private readonly adapter: PackScenarioAdapter<Req>;
289
- private readonly extractors: Record<string, ScenarioExtractorSpec>;
290
- private baseline: Registered[] = [];
291
- private overrides: Registered[] = [];
292
- private scopes = new Map<string, ScopeState>();
293
- private missCount = 0;
294
- private recentMisses: ScenarioMissRecord[] = [];
295
-
296
- constructor(adapter: PackScenarioAdapter<Req>, document?: ScenarioDocument) {
297
- this.adapter = adapter;
298
- this.extractors = document?.extractors ?? {};
299
- if (document) {
300
- this.baseline = document.handlers.map((handler, i) => ({ handler, id: handler.id ?? `handler-${i + 1}`, source: "file", matches: 0 }));
301
- }
302
- }
120
+ constructor(private readonly adapter: PackScenarioAdapter<Req>, private readonly document: ScenarioDocument) {}
303
121
 
304
- /** In-process test overrides: LIFO over the baseline; returns a remover. NOT a runtime
305
- * door — nothing outside this process can reach it, by design. */
306
- use(...handlers: ScenarioHandler[]): () => void {
307
- const parsed = handlers.map((h, i) => parseHandler(h, i, this.adapter, this.extractors));
308
- const registered: Registered[] = parsed.map((handler, i) => ({ handler, id: handler.id ?? `use-${this.overrides.length + i + 1}`, source: "use", matches: 0 }));
309
- this.overrides = [...registered, ...this.overrides]; // LIFO: newest first
310
- return () => { this.overrides = this.overrides.filter((r) => !registered.includes(r)); };
311
- }
312
-
313
- /** Decide one request. Mutates scope state (calls, once, phase) exactly like serving. */
122
+ /** Decide one request: the first rule that matches and whose answer the request admits, else the miss. */
314
123
  next(req: Req): ScenarioDecision {
315
- const scopeOf = (h: ScenarioHandler): string => (h.scope === "session" ? `session:${this.adapter.scopeKey!(req)}` : "world");
316
- // The call counter ticks once per request on the WORLD scope (and the session scope when
317
- // one exists) — before matching, so nthCall-style matchers see 1-based "this call".
318
- this.scope("world").calls += 1;
319
- if (this.adapter.scopeKey) this.scope(`session:${this.adapter.scopeKey(req)}`).calls += 1;
320
- for (const r of [...this.overrides, ...this.baseline]) {
321
- const scope = this.scope(scopeOf(r.handler));
322
- if (r.handler.phase !== undefined && scope.phase !== r.handler.phase) continue;
323
- if (r.handler.once && scope.onceFired.has(r.id)) continue;
324
- if (!this.matches(r.handler, req, scope)) continue;
325
- if ("respond" in r.handler && this.adapter.admits && !this.adapter.admits(req, r.handler.respond)) continue;
326
- if (r.handler.once) scope.onceFired.add(r.id);
327
- if (r.handler.advancePhase !== undefined) scope.phase = r.handler.advancePhase;
328
- r.matches += 1;
329
- // `ruleId` is the row's STABLE id — the authored `id` or the parser's `handler-<n>`
330
- // default — the same name the status door reports, so a pack stamping the fired rule
331
- // (deepgram's `scenario_rule`) and the operator reading /twin/scenario see one vocabulary.
332
- try {
333
- return { kind: "handler", handler: r.handler, respond: "respond" in r.handler ? this.substitute(r.handler.respond, req) : undefined, ruleId: r.id, ...(r.handler.fault !== undefined ? { fault: r.handler.fault } : {}) };
334
- } catch (e) {
335
- // The handler that demanded the value is named: an authoring fault is fixed by finding it.
336
- if (e instanceof ScenarioError) throw new ScenarioError(`${e.message} [${r.id}${typeof (r.handler as { $comment?: unknown }).$comment === "string" ? `: ${String((r.handler as { $comment?: string }).$comment).slice(0, 80)}` : ""}]`);
337
- throw e;
338
- }
339
- }
340
- const miss: ScenarioMissRecord = { features: this.adapter.features(req), phase: this.scope("world").phase };
341
- this.missCount += 1;
342
- this.recentMisses = [...this.recentMisses.slice(-(MISS_KEEP - 1)), miss];
343
- return { kind: "miss", miss };
344
- }
345
-
346
- /** Decide one request AND honor its fault: a `slow` is awaited here and the decision is
347
- * returned for the pack to serve; a `status` returns the rendered refusal to serve as-is; a
348
- * `drop` never resolves within its hold. The one call a realizer makes so every vendor's
349
- * outage reads the same. */
350
- async serve(req: Req, signal?: AbortSignal): Promise<ScenarioDecision | { kind: "fault"; result: ScenarioFaultResult; ruleId: string }> {
351
- throwIfScenarioAborted(signal);
352
- const decision = this.next(req);
353
- throwIfScenarioAborted(signal);
354
- if (decision.kind !== "handler" || decision.fault === undefined) return decision;
355
- const result = await scenarioFaultResult(decision.fault, () => this.adapter.renderFault?.(decision.fault as ScenarioStatusFault, req), signal);
356
- throwIfScenarioAborted(signal);
357
- return result === null ? decision : { kind: "fault", result, ruleId: decision.ruleId };
358
- }
359
-
360
- /** For GET /twin/scenario — active handlers with match counts, and the recent misses. */
361
- status(): ScenarioStatus {
362
- const row = (r: Registered) => ({ id: r.id, ...(r.handler.phase !== undefined ? { phase: r.handler.phase } : {}), ...(r.handler.once !== undefined ? { once: r.handler.once } : {}), ...(r.handler.scope !== undefined ? { scope: r.handler.scope } : {}), matches: r.matches, source: r.source });
363
- return { vendor: this.adapter.vendor, handlers: [...this.overrides, ...this.baseline].map(row), misses: this.missCount, recentMisses: [...this.recentMisses] };
364
- }
365
-
366
- private scope(key: string): ScopeState {
367
- let s = this.scopes.get(key);
368
- if (!s) { s = { calls: 0, phase: undefined, onceFired: new Set() }; this.scopes.set(key, s); }
369
- return s;
370
- }
371
-
372
- private matches(handler: ScenarioHandler, req: Req, scope: ScopeState): boolean {
373
- for (const [key, condition] of Object.entries(handler.on)) {
374
- if (key === "nthCall") { if (scope.calls !== condition) return false; continue; }
375
- const matcher = this.adapter.matchers[key];
376
- if (!matcher) return false; // unreachable post-parse; belt over braces
377
- if (!matcher(req, condition)) return false;
378
- }
379
- return true;
380
- }
381
-
382
- private extractValue(name: string, req: Req, cache: Map<string, string | number>): string | number {
383
- const cached = cache.get(name);
384
- if (cached !== undefined) return cached;
385
- const spec = this.extractors[name]!;
386
- const packKind = adapterKind(this.adapter, spec.kind);
387
- let value: string | number;
388
- if (packKind !== undefined) {
389
- value = packKind.extract(spec as Record<string, unknown>, req, name);
390
- } else if (spec.kind === "feature") {
391
- const v = this.adapter.features(req)[(spec as { feature: string }).feature];
392
- if (v === undefined) throw new ScenarioError(`${this.adapter.vendor} scenario: placeholder "{{${name}}}": the request has no feature "${(spec as { feature: string }).feature}"`);
393
- value = typeof v === "number" || typeof v === "string" ? v : String(v);
394
- } else {
395
- const text = this.adapter.text!(req);
396
- const tp = spec as { pattern: string; as?: string };
397
- const m = new RegExp(tp.pattern).exec(text);
398
- if (!m) throw new ScenarioError(`${this.adapter.vendor} scenario: placeholder "{{${name}}}": pattern did not match the request (the handler demanded a value the request never stated)`);
399
- const captured = m[1] ?? m[0]!;
400
- if (tp.as === "number") {
401
- const n = /^-?\d+$/.test(captured) ? exactInteger(captured) : Number(captured);
402
- if (Number.isNaN(n)) throw new ScenarioError(`${this.adapter.vendor} scenario: placeholder "{{${name}}}": captured "${captured}" is not a number`);
403
- value = n;
404
- } else value = captured;
405
- }
406
- cache.set(name, value);
407
- return value;
408
- }
409
-
410
- private substitute(value: unknown, req: Req, cache: Map<string, string | number> = new Map()): unknown {
411
- if (typeof value === "string") {
412
- // A WHOLE-string placeholder yields the typed value (numbers stay numbers).
413
- const whole = new RegExp(`^${PLACEHOLDER_SRC}$`).exec(value);
414
- if (whole) return applyTransform(this.extractValue(whole[1]!, req, cache), whole[2], this.adapter.vendor, value);
415
- return value.replace(new RegExp(PLACEHOLDER_SRC, "g"), (all, name: string, transform: string | undefined) =>
416
- String(applyTransform(this.extractValue(name, req, cache), transform, this.adapter.vendor, all)));
417
- }
418
- if (Array.isArray(value)) return value.map((v) => this.substitute(v, req, cache));
419
- if (typeof value === "object" && value !== null) {
420
- return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, this.substitute(v, req, cache)]));
124
+ for (const [i, handler] of this.document.handlers.entries()) {
125
+ if (!Object.entries(handler.on).every(([key, condition]) => this.adapter.matchers[key]!(req, condition))) continue;
126
+ if ("respond" in handler && this.adapter.admits && !this.adapter.admits(req, handler.respond)) continue;
127
+ return { kind: "handler", handler, respond: handler.respond, ruleId: handler.id ?? `rule-${i + 1}`, ...(handler.fault !== undefined ? { fault: handler.fault } : {}) };
421
128
  }
422
- return value;
129
+ return { kind: "miss", miss: { features: this.adapter.features(req) } };
423
130
  }
424
131
  }
425
132
 
426
- /** The GET /twin manifest for a STATEFUL twin (no scenario engine): what it stores, how
427
- * state and identity work, where behavior scripting actually lives. Education ships
428
- * INSIDE the twin — in-world agents have no repos or skills, only HTTP. */
429
133
  /** Honor a fault outside the engine (hand-written serve paths): `slow` waits and returns null
430
134
  * (serve normally now); `status` returns what to serve; `drop` holds the request for `holdMs`
431
135
  * (default 300 000) and then throws, so the socket is released and nothing is ever answered. */
@@ -486,8 +190,7 @@ export function statefulTwinManifest(input: { vendor: string; twinOf: string; st
486
190
 
487
191
  /** The GET /twin manifest — the discovery door's body. Education ships INSIDE the twin:
488
192
  * in-world agents have no repos or skills, only HTTP. */
489
- export function twinManifest(input: { vendor: string; twinOf: string; stateSentence: string; behaviorSentence: string; exampleHandler: ScenarioHandler | null; engine?: ScenarioEngine<never> }): Record<string, unknown> {
490
- const status = input.engine?.status();
193
+ export function twinManifest(input: { vendor: string; twinOf: string; stateSentence: string; behaviorSentence: string; exampleHandler: ScenarioHandler | null }): Record<string, unknown> {
491
194
  return {
492
195
  twin: true,
493
196
  vendor: input.vendor,
@@ -495,10 +198,9 @@ export function twinManifest(input: { vendor: string; twinOf: string; stateSente
495
198
  program: {
496
199
  state: input.stateSentence,
497
200
  behavior: input.behaviorSentence,
498
- invariant: "A handler never fakes a SUCCESS on a route whose data this twin stores — seed that through the vendor's own API instead. Serving is deterministic.",
499
- ...(input.exampleHandler ? { exampleHandler: input.exampleHandler } : {}),
201
+ invariant: "Nothing answers in place of this twin: a World's scenario places turn decisions (point \"turn\") on its timeline, and the twin answers and records them; stored data is seeded through the vendor's own API. Serving is deterministic.",
202
+ ...(input.exampleHandler ? { exampleDecision: { at: "0s", decision: { vendor: input.vendor, point: "turn", on: input.exampleHandler.on, outcome: input.exampleHandler.respond } } } : {}),
500
203
  },
501
- doors: { manifest: "GET /twin", scenario: "GET /twin/scenario (read-only: active handlers + match/miss counts)" },
502
- ...(status ? { activeHandlers: status.handlers.length, misses: status.misses } : {}),
204
+ doors: { manifest: "GET /twin", scenario: "GET /twin/scenario (read-only: what the World's scenario placed on this twin, and what it consumed)" },
503
205
  };
504
206
  }
package/src/sockets.ts CHANGED
@@ -9,7 +9,7 @@ import { runWithStorageTenant, storageTenant } from './request-scope.ts';
9
9
  // Nothing here runs at module scope beyond the empty registry.
10
10
  import { worldNow } from './world-clock.ts';
11
11
  import type { EventWrite } from './events.ts';
12
- import type { ScenarioEngine } from './scenario.ts';
12
+ import type { ScenarioDecision, ScenarioFaultResult } from './scenario.ts';
13
13
  import type { WebSocketPeer, WebSocketUpgrade } from './serve-http.ts';
14
14
 
15
15
  /** A socket the vendor serves: its path (and host, when the vendor serves it on a host of its own), as the manifest
@@ -36,8 +36,8 @@ export type SocketSession = {
36
36
  decide(asked: unknown): Promise<ScenarioServed | undefined>;
37
37
  };
38
38
 
39
- /** What the World's scenario serves a turn: a handler's decision, a miss, or a fault (scenario.ts `serve`). */
40
- export type ScenarioServed = Awaited<ReturnType<ScenarioEngine<unknown>['serve']>>;
39
+ /** What the World's scenario serves a turn (scenario-serve.ts): its decision, the miss, or a fault's answer. */
40
+ export type ScenarioServed = ScenarioDecision | { kind: 'fault'; result: ScenarioFaultResult; ruleId: string };
41
41
 
42
42
  /** A socket's protocol, over the handler contract's context (a read of the World as the request that opened it). */
43
43
  export type SocketEngine<C> = {
@@ -108,7 +108,7 @@ export function socketUpgrade<C>(
108
108
  context: (request: Request, decl: SocketDecl, params: Record<string, string>) => Promise<C>,
109
109
  hostOf: (request: Request) => string,
110
110
  pathOf: (request: Request) => string,
111
- decide: (asked: unknown) => Promise<ScenarioServed | undefined>,
111
+ decide: (asked: unknown, context: () => Promise<C>) => Promise<ScenarioServed | undefined>,
112
112
  ): WebSocketUpgrade {
113
113
  const matchFor = (request: Request): { decl: SocketDecl; params: Record<string, string> } | undefined => {
114
114
  const path = pathOf(request).replace(/\/+$/, '') || '/';
@@ -149,7 +149,7 @@ export function socketUpgrade<C>(
149
149
  clearTimeout(p.timer); pending.delete(key); p.resolve(value);
150
150
  return true;
151
151
  },
152
- decide,
152
+ decide: (asked) => decide(asked, () => o.context() as Promise<C>),
153
153
  };
154
154
  const tenant = storageTenant(service), run = <T>(fn: () => T): T => runWithStorageTenant(service, tenant, fn);
155
155
  const o: Open = { tenant, run, session, engine, context: () => context(request.clone(), decl, params), queue: Promise.resolve(), pending };
@@ -25,16 +25,23 @@ const machineNow = (): number => {
25
25
  /** The world's "now": the clock file's time when one is configured (its frozen instant, or a running clock's time
26
26
  * now); real wall-clock time otherwise. */
27
27
  export function worldNow(): string {
28
+ const clock = heldClock();
29
+ return new Date(clock ? clockForm.clockNowMs(clock, machineNow()) : machineNow()).toISOString();
30
+ }
31
+
32
+ /** The World's clock as its file holds it, or null where it has none (the machine's time). One read answers both
33
+ * whether the clock file is there and what it holds, kept while the file's own facts stand (world-store.ts readKept):
34
+ * every operation asks the time. A configured-but-corrupt clock is a World defect: parseClock fails loudly, never
35
+ * drifting back to the wall clock (two services disagreeing about now breaks the World). */
36
+ function heldClock(): ReturnType<typeof clockForm.parseClock> | null {
28
37
  const file = process.env[WORLD_CLOCK_ENV];
29
- // One read answers both whether the clock file is there and what it holds.
30
- // Kept while the clock file's own facts stand (world-store.ts readKept): every operation asks the time.
31
38
  const store = getActiveWorldStore();
32
39
  const held = file ? (store.readKept ? store.readKept(file) : store.read(file)) : null;
33
- if (held !== null) {
34
- // A configured-but-corrupt clock is a WORLD defect — parseClock fails loudly, never silently drifting back to
35
- // wall-clock (two services disagreeing about now breaks the world).
36
- const clock = clockForm.parseClock(held);
37
- return new Date(clockForm.clockNowMs(clock, machineNow())).toISOString();
38
- }
39
- return new Date(machineNow()).toISOString();
40
+ return held === null ? null : clockForm.parseClock(held);
41
+ }
42
+
43
+ /** Whether the World's time runs on by itself (a running clock, or no clock: the machine's), rather than standing frozen
44
+ * until it is moved. */
45
+ export function worldClockRunning(): boolean {
46
+ return heldClock()?.kind !== 'frozen';
40
47
  }