@volter/twin 0.1.0 → 0.1.1

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 (84) hide show
  1. package/README.md +16 -2
  2. package/inject.cjs +453 -59
  3. package/package.json +12 -22
  4. package/src/actions.ts +234 -49
  5. package/src/blob-store.ts +136 -0
  6. package/src/changeset.ts +807 -0
  7. package/src/cli.ts +60 -10
  8. package/src/connector.ts +30 -7
  9. package/src/control-plane.ts +17 -1
  10. package/src/emit.ts +242 -0
  11. package/src/fork.ts +19 -7
  12. package/src/index.ts +139 -6
  13. package/src/lease.ts +4 -6
  14. package/src/lifecycle.ts +8 -0
  15. package/src/packRegistry.ts +248 -2
  16. package/src/plan.ts +131 -23
  17. package/src/proxy.ts +5 -2
  18. package/src/pushLedger.ts +116 -11
  19. package/src/queueLifecycle.ts +3 -4
  20. package/src/rateBudget.ts +1115 -0
  21. package/src/refs.ts +9 -10
  22. package/src/remote-execute.ts +16 -0
  23. package/src/scenario.ts +387 -0
  24. package/src/serve.ts +397 -15
  25. package/src/shadow.ts +86 -7
  26. package/src/storage.ts +76 -147
  27. package/src/sync.ts +63 -17
  28. package/src/twin-fetch.ts +115 -0
  29. package/src/validate.ts +6 -5
  30. package/src/world-clock.ts +33 -0
  31. package/src/world-store.ts +482 -0
  32. package/src/worldConfig.ts +4 -3
  33. package/dist/src/actions.d.ts +0 -138
  34. package/dist/src/actions.js +0 -201
  35. package/dist/src/args.d.ts +0 -3
  36. package/dist/src/args.js +0 -12
  37. package/dist/src/cli.d.ts +0 -2
  38. package/dist/src/cli.js +0 -425
  39. package/dist/src/connector.d.ts +0 -106
  40. package/dist/src/connector.js +0 -129
  41. package/dist/src/control-plane.d.ts +0 -21
  42. package/dist/src/control-plane.js +0 -40
  43. package/dist/src/egress.d.ts +0 -93
  44. package/dist/src/egress.js +0 -264
  45. package/dist/src/fork.d.ts +0 -126
  46. package/dist/src/fork.js +0 -206
  47. package/dist/src/index.d.ts +0 -42
  48. package/dist/src/index.js +0 -52
  49. package/dist/src/lease.d.ts +0 -50
  50. package/dist/src/lease.js +0 -80
  51. package/dist/src/packRegistry.d.ts +0 -34
  52. package/dist/src/packRegistry.js +0 -22
  53. package/dist/src/plan.d.ts +0 -97
  54. package/dist/src/plan.js +0 -151
  55. package/dist/src/proxy.d.ts +0 -25
  56. package/dist/src/proxy.js +0 -152
  57. package/dist/src/pushLedger.d.ts +0 -81
  58. package/dist/src/pushLedger.js +0 -130
  59. package/dist/src/queueLifecycle.d.ts +0 -62
  60. package/dist/src/queueLifecycle.js +0 -95
  61. package/dist/src/reconcile.d.ts +0 -58
  62. package/dist/src/reconcile.js +0 -137
  63. package/dist/src/refs.d.ts +0 -29
  64. package/dist/src/refs.js +0 -68
  65. package/dist/src/schemas.d.ts +0 -78
  66. package/dist/src/schemas.js +0 -50
  67. package/dist/src/serve.d.ts +0 -44
  68. package/dist/src/serve.js +0 -93
  69. package/dist/src/shadow.d.ts +0 -77
  70. package/dist/src/shadow.js +0 -138
  71. package/dist/src/status.d.ts +0 -31
  72. package/dist/src/status.js +0 -42
  73. package/dist/src/storage.d.ts +0 -119
  74. package/dist/src/storage.js +0 -535
  75. package/dist/src/sync.d.ts +0 -91
  76. package/dist/src/sync.js +0 -121
  77. package/dist/src/types.d.ts +0 -40
  78. package/dist/src/types.js +0 -1
  79. package/dist/src/validate.d.ts +0 -27
  80. package/dist/src/validate.js +0 -68
  81. package/dist/src/visualizer.d.ts +0 -13
  82. package/dist/src/visualizer.js +0 -133
  83. package/dist/src/worldConfig.d.ts +0 -9
  84. package/dist/src/worldConfig.js +0 -16
package/src/refs.ts CHANGED
@@ -4,9 +4,9 @@
4
4
  // ref records which remote ref a fork was based on, so a stale-base push can be
5
5
  // rejected before any provider call. Pure file I/O over the world dir; deterministic
6
6
  // (caller supplies observedAt).
7
- import { existsSync, mkdirSync, readdirSync, writeFileSync } from 'node:fs';
8
7
  import { join } from 'node:path';
9
8
  import { readJsonFile, worldPaths } from './storage.ts';
9
+ import { getActiveWorldStore } from './world-store.ts';
10
10
 
11
11
  export type WorldRemoteRef = {
12
12
  service: string;
@@ -45,23 +45,23 @@ function safe(part: string): string {
45
45
  /** Record/advance a provider checkpoint (e.g. after a confirmed pull). */
46
46
  export function writeRemoteRef(ref: WorldRemoteRef, root?: string): WorldRemoteRef {
47
47
  const path = remoteRefPath(ref.service, safe(ref.provider), safe(ref.name), root);
48
- mkdirSync(join(path, '..'), { recursive: true });
49
- writeFileSync(path, `${JSON.stringify(ref, null, 2)}\n`);
48
+ getActiveWorldStore().write(path, `${JSON.stringify(ref, null, 2)}\n`);
50
49
  return ref;
51
50
  }
52
51
 
53
52
  export function readRemoteRef(service: string, provider: string, name = 'main', root?: string): WorldRemoteRef | null {
54
53
  const path = remoteRefPath(service, safe(provider), safe(name), root);
55
- return existsSync(path) ? readJsonFile<WorldRemoteRef>(path) : null;
54
+ return getActiveWorldStore().exists(path) ? readJsonFile<WorldRemoteRef>(path) : null;
56
55
  }
57
56
 
58
57
  export function listRemoteRefs(service: string, root?: string): WorldRemoteRef[] {
58
+ const store = getActiveWorldStore();
59
59
  const base = join(refsDir(service, root), 'remote');
60
- if (!existsSync(base)) return [];
60
+ if (!store.exists(base)) return [];
61
61
  const out: WorldRemoteRef[] = [];
62
- for (const provider of readdirSync(base)) {
62
+ for (const provider of store.list(base)) {
63
63
  const providerDir = join(base, provider);
64
- for (const file of readdirSync(providerDir)) {
64
+ for (const file of store.list(providerDir)) {
65
65
  if (file.endsWith('.json')) out.push(readJsonFile<WorldRemoteRef>(join(providerDir, file)));
66
66
  }
67
67
  }
@@ -70,14 +70,13 @@ export function listRemoteRefs(service: string, root?: string): WorldRemoteRef[]
70
70
 
71
71
  export function writeLocalRef(ref: WorldLocalRef, root?: string): WorldLocalRef {
72
72
  const path = localRefPath(ref.service, safe(ref.forkId), root);
73
- mkdirSync(join(path, '..'), { recursive: true });
74
- writeFileSync(path, `${JSON.stringify(ref, null, 2)}\n`);
73
+ getActiveWorldStore().write(path, `${JSON.stringify(ref, null, 2)}\n`);
75
74
  return ref;
76
75
  }
77
76
 
78
77
  export function readLocalRef(service: string, forkId: string, root?: string): WorldLocalRef | null {
79
78
  const path = localRefPath(service, safe(forkId), root);
80
- return existsSync(path) ? readJsonFile<WorldLocalRef>(path) : null;
79
+ return getActiveWorldStore().exists(path) ? readJsonFile<WorldLocalRef>(path) : null;
81
80
  }
82
81
 
83
82
  /**
@@ -0,0 +1,16 @@
1
+ // THE ONE PULL EXECUTOR SHAPE (runtime contract R14, scheduled pull): what every pack's
2
+ // `sync<Name>FromRemote` adapter receives. TYPES ONLY — the kernel defines the seam so
3
+ // packs can adapt their vendor-specific executors to it; BUILDING one (origin +
4
+ // sealed-credential egress) is the twins service's job, never a pack's.
5
+ export type RemoteExecuteRequest = {
6
+ method: string;
7
+ path: string;
8
+ headers?: Record<string, string>;
9
+ body?: string;
10
+ };
11
+ export type RemoteExecuteResponse = {
12
+ status: number;
13
+ headers: Record<string, string>;
14
+ body: string;
15
+ };
16
+ export type RemoteExecute = (request: RemoteExecuteRequest) => Promise<RemoteExecuteResponse>;
@@ -0,0 +1,387 @@
1
+ // THE scenario engine — System 2 of the twin programming model (one grammar, per-pack
2
+ // vocabulary). See company-repo BRIEFS/TWIN-PROGRAMMING-MODEL.md (LOCKED, 2026-08-27).
3
+ //
4
+ // A HANDLER is an MSW-shaped data rule: { on, respond, once?, scope?, phase?, advancePhase? }.
5
+ // Handlers are evaluated IN ORDER; the FIRST handler whose `on` conditions ALL hold fires.
6
+ // No match → the caller serves its labeled deterministic stub and records the MISS (with the
7
+ // request's extracted features — the authoring signal). The handler FILE in the world dir is
8
+ // the only write surface; `engine.use(...)` exists for in-process tests only (LIFO over the
9
+ // baseline, removable). There are NO runtime write doors — a running world is never mutated.
10
+ //
11
+ // The GRAMMAR (structure, ordering, once/scope/phase, strict validation, extractors,
12
+ // placeholders, miss records) is this module's and identical for every vendor. The
13
+ // VOCABULARY (which `on` keys exist and how each matches; what `respond` may contain; which
14
+ // routes are stateful and therefore refuse success-shaped handlers) is the pack's, declared
15
+ // through a PackScenarioAdapter. Determinism: the engine is a pure state machine — same
16
+ // handler list + same request sequence → same decisions, byte for byte.
17
+ //
18
+ // STRICT EVERYWHERE (the gemini discipline): unknown top-level keys, unknown `on` keys,
19
+ // unknown placeholder names, malformed extractors — all THROW with the valid vocabulary in
20
+ // the message. A typo must fail loudly at load, never silently mis-match at serve.
21
+
22
+ /** A vendor-agnostic bag of facts about one request, produced by the pack's adapter. Powers
23
+ * matching context, miss records (the authoring signal), and the self-teaching stub text. */
24
+ export type ScenarioFeatures = Record<string, string | number | boolean | readonly string[]>;
25
+
26
+ /** One matcher: does THIS request satisfy `condition`? Pure — no state, no IO. */
27
+ export type ScenarioMatcher<Req> = (req: Req, condition: unknown) => boolean;
28
+
29
+ /** The pack's declaration of its vocabulary — the ONLY vendor-specific surface. */
30
+ export type PackScenarioAdapter<Req> = {
31
+ /** Vendor key, e.g. "anthropic" — used in errors and the manifest. */
32
+ vendor: string;
33
+ /** Extract the feature bag for miss records / stub teaching. Pure. */
34
+ features: (req: Req) => ScenarioFeatures;
35
+ /** The legal `on` keys and their per-request semantics. Pure. */
36
+ matchers: Record<string, ScenarioMatcher<Req>>;
37
+ /** Validate `on` CONDITION VALUES at load (key membership is the kernel's; VALUE typing is
38
+ * the pack's — "a string nthCall silently never matches" is exactly the misfire strict
39
+ * loading exists to prevent). Return an error string to refuse. */
40
+ validateOn?: (on: Record<string, unknown>) => string | null;
41
+ /** Validate a handler's `respond` payload at load; return an error string to refuse.
42
+ * This is ALSO where a pack refuses success-shaped handlers on stateful routes
43
+ * ("seed that through the vendor's API instead"). */
44
+ validateRespond?: (respond: unknown, handler: ScenarioHandler) => string | null;
45
+ /** The request's text corpus for `textPattern` extractors (packs with text requests). */
46
+ text?: (req: Req) => string;
47
+ /** Pack-defined extractor KINDS beyond the builtins (feature, textPattern). A pack kind with
48
+ * a builtin's name OVERRIDES the builtin (e.g. a richer textPattern with flags/group).
49
+ * validate returns an error string to refuse the spec at load; extract runs at serve time
50
+ * and throws ScenarioError when the request cannot supply the value. */
51
+ extractorKinds?: Record<string, {
52
+ validate: (spec: Record<string, unknown>, name: string) => string | null;
53
+ extract: (spec: Record<string, unknown>, req: Req, name: string) => string | number;
54
+ }>;
55
+ /** The per-session scope discriminator; omitted → all requests share one "world" scope. */
56
+ scopeKey?: (req: Req) => string;
57
+ };
58
+
59
+ export type ScenarioHandler = {
60
+ /** Stable id for status/telemetry; defaults to `handler-<1-based index>`. */
61
+ id?: string;
62
+ /** Conditions — ALL must hold. `{}` matches every request (an ordered catch-all). */
63
+ on: Record<string, unknown>;
64
+ /** Pack-realized response content (the pack's realizer builds the faithful envelope). */
65
+ respond: unknown;
66
+ /** Fire at most once per scope. */
67
+ once?: boolean;
68
+ /** Reserved: "world" (default) | "session" — with "session", once/phase state is per
69
+ * scopeKey instead of shared. */
70
+ scope?: "world" | "session";
71
+ /** Fires only while the scope's phase equals this. Handlers without `phase` fire in any. */
72
+ phase?: string;
73
+ /** On fire, move the scope's phase — the tiny sequencing primitive that replaces
74
+ * linear scripts and nthCall arithmetic. */
75
+ advancePhase?: string;
76
+ };
77
+
78
+ /** Request-derived values a `respond` payload may reference as "{{name}}" placeholders —
79
+ * builtin kinds, or any kind the pack's adapter declares. Payload placeholders support the
80
+ * numeric transforms `{{name|min:N}}` / `{{name|max:N}}`; a whole-string placeholder yields
81
+ * the TYPED value (numbers stay numbers). */
82
+ export type ScenarioExtractorSpec =
83
+ | { kind: "feature"; feature: string }
84
+ | { kind: "textPattern"; pattern: string; as?: "string" | "number" }
85
+ | ({ kind: string } & Record<string, unknown>);
86
+
87
+ export type ScenarioDocument = {
88
+ extractors?: Record<string, ScenarioExtractorSpec>;
89
+ handlers: ScenarioHandler[];
90
+ };
91
+
92
+ export type ScenarioMissRecord = { features: ScenarioFeatures; phase: string | undefined };
93
+
94
+ export type ScenarioDecision =
95
+ | { kind: "handler"; handler: ScenarioHandler; respond: unknown; ruleId: string }
96
+ | { kind: "miss"; miss: ScenarioMissRecord };
97
+
98
+ export type ScenarioStatus = {
99
+ vendor: string;
100
+ handlers: Array<{ id: string; phase?: string; once?: boolean; scope?: string; matches: number; source: "file" | "use" }>;
101
+ misses: number;
102
+ recentMisses: ScenarioMissRecord[];
103
+ };
104
+
105
+ export class ScenarioError extends Error {}
106
+
107
+ // `$comment` is allowed (and ignored) at document and handler level — JSON has no comments
108
+ // and scenario files are hand-authored story documents.
109
+ const HANDLER_KEYS = new Set(["id", "on", "respond", "once", "scope", "phase", "advancePhase", "$comment"]);
110
+ const DOCUMENT_KEYS = new Set(["extractors", "handlers", "$comment"]);
111
+
112
+ /** Strict-loud parse of a scenario DOCUMENT (the per-vendor handlers/<vendor>.json content).
113
+ * The caller does file IO; this validates. Every refusal names the valid vocabulary. */
114
+ export function parseScenarioDocument<Req>(raw: unknown, adapter: PackScenarioAdapter<Req>): ScenarioDocument {
115
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) throw new ScenarioError(`${adapter.vendor} scenario: the document is an object { extractors?, handlers }`);
116
+ const doc = raw as Record<string, unknown>;
117
+ for (const key of Object.keys(doc)) {
118
+ if (!DOCUMENT_KEYS.has(key)) throw new ScenarioError(`${adapter.vendor} scenario: unknown key "${key}" (valid: ${[...DOCUMENT_KEYS].join(", ")})`);
119
+ }
120
+ const extractors: Record<string, ScenarioExtractorSpec> = {};
121
+ if (doc.extractors !== undefined) {
122
+ 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`);
123
+ for (const [name, spec] of Object.entries(doc.extractors as Record<string, unknown>)) {
124
+ extractors[name] = parseExtractor(name, spec, adapter);
125
+ }
126
+ }
127
+ if (!Array.isArray(doc.handlers)) throw new ScenarioError(`${adapter.vendor} scenario: handlers is an array`);
128
+ const handlers = (doc.handlers as unknown[]).map((h, i) => parseHandler(h, i, adapter, extractors));
129
+ return { ...(doc.extractors !== undefined ? { extractors } : {}), handlers };
130
+ }
131
+
132
+ function parseExtractor<Req>(name: string, raw: unknown, adapter: PackScenarioAdapter<Req>): ScenarioExtractorSpec {
133
+ if (typeof raw !== "object" || raw === null) throw new ScenarioError(`${adapter.vendor} scenario: extractor "${name}" is an object`);
134
+ const spec = raw as Record<string, unknown>;
135
+ const packKind = typeof spec.kind === "string" ? adapter.extractorKinds?.[spec.kind] : undefined;
136
+ if (packKind !== undefined) {
137
+ const refusal = packKind.validate(spec, name);
138
+ if (refusal) throw new ScenarioError(`${adapter.vendor} scenario: extractor "${name}": ${refusal}`);
139
+ return spec as ScenarioExtractorSpec;
140
+ }
141
+ if (spec.kind === "feature") {
142
+ if (typeof spec.feature !== "string" || spec.feature.length === 0) throw new ScenarioError(`${adapter.vendor} scenario: extractor "${name}" (feature) needs a feature name`);
143
+ for (const key of Object.keys(spec)) if (key !== "kind" && key !== "feature") throw new ScenarioError(`${adapter.vendor} scenario: extractor "${name}": unknown key "${key}"`);
144
+ return { kind: "feature", feature: spec.feature };
145
+ }
146
+ if (spec.kind === "textPattern") {
147
+ if (adapter.text === undefined) throw new ScenarioError(`${adapter.vendor} scenario: extractor "${name}" uses textPattern but this pack exposes no request text`);
148
+ if (typeof spec.pattern !== "string") throw new ScenarioError(`${adapter.vendor} scenario: extractor "${name}" (textPattern) needs a pattern`);
149
+ try { new RegExp(spec.pattern); } catch (e) { throw new ScenarioError(`${adapter.vendor} scenario: extractor "${name}": invalid pattern: ${e instanceof Error ? e.message : String(e)}`); }
150
+ if (spec.as !== undefined && spec.as !== "string" && spec.as !== "number") throw new ScenarioError(`${adapter.vendor} scenario: extractor "${name}": as is "string" or "number"`);
151
+ for (const key of Object.keys(spec)) if (!["kind", "pattern", "as"].includes(key)) throw new ScenarioError(`${adapter.vendor} scenario: extractor "${name}": unknown key "${key}"`);
152
+ return { kind: "textPattern", pattern: spec.pattern, ...(spec.as !== undefined ? { as: spec.as as "string" | "number" } : {}) };
153
+ }
154
+ throw new ScenarioError(`${adapter.vendor} scenario: extractor "${name}": kind is one of ${[...new Set(["feature", "textPattern", ...Object.keys(adapter.extractorKinds ?? {})])].join(", ")}`);
155
+ }
156
+
157
+ function parseHandler<Req>(raw: unknown, index: number, adapter: PackScenarioAdapter<Req>, extractors: Record<string, ScenarioExtractorSpec>): ScenarioHandler {
158
+ const at = `handler ${index + 1}`;
159
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) throw new ScenarioError(`${adapter.vendor} scenario: ${at} is an object { on, respond, ... }`);
160
+ const h = raw as Record<string, unknown>;
161
+ for (const key of Object.keys(h)) {
162
+ if (!HANDLER_KEYS.has(key)) throw new ScenarioError(`${adapter.vendor} scenario: ${at}: unknown key "${key}" (valid: ${[...HANDLER_KEYS].join(", ")})`);
163
+ }
164
+ 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)`);
165
+ // `nthCall` is the one kernel-builtin condition (1-based per-scope call index); every other
166
+ // `on` key is the pack's declared vocabulary.
167
+ const validOn = ["nthCall", ...Object.keys(adapter.matchers)];
168
+ for (const [key, value] of Object.entries(h.on as Record<string, unknown>)) {
169
+ if (!validOn.includes(key)) throw new ScenarioError(`${adapter.vendor} scenario: ${at}: unknown \`on\` key "${key}" (valid here: ${validOn.join(", ")})`);
170
+ if (key === "nthCall" && (!Number.isInteger(value) || (value as number) < 1)) throw new ScenarioError(`${adapter.vendor} scenario: ${at}: nthCall is a positive integer`);
171
+ }
172
+ const onRefusal = adapter.validateOn?.(h.on as Record<string, unknown>);
173
+ if (onRefusal) throw new ScenarioError(`${adapter.vendor} scenario: ${at}: ${onRefusal}`);
174
+ if (!("respond" in h)) throw new ScenarioError(`${adapter.vendor} scenario: ${at}: \`respond\` is required`);
175
+ 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`);
176
+ if (h.once !== undefined && typeof h.once !== "boolean") throw new ScenarioError(`${adapter.vendor} scenario: ${at}: once is boolean`);
177
+ if (h.scope !== undefined && h.scope !== "world" && h.scope !== "session") throw new ScenarioError(`${adapter.vendor} scenario: ${at}: scope is "world" or "session"`);
178
+ if (h.scope === "session" && adapter.scopeKey === undefined) throw new ScenarioError(`${adapter.vendor} scenario: ${at}: scope "session" but this pack derives no session key`);
179
+ for (const key of ["phase", "advancePhase"] as const) {
180
+ 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`);
181
+ }
182
+ for (const name of placeholderNames(h.respond)) {
183
+ if (extractors[name] === undefined) throw new ScenarioError(`${adapter.vendor} scenario: ${at}: respond references "{{${name}}}" but no extractor "${name}" is declared`);
184
+ }
185
+ const refusal = adapter.validateRespond?.(h.respond, h as ScenarioHandler);
186
+ if (refusal) throw new ScenarioError(`${adapter.vendor} scenario: ${at}: ${refusal}`);
187
+ return h as ScenarioHandler;
188
+ }
189
+
190
+ const PLACEHOLDER_SRC = "\\{\\{([a-zA-Z0-9_.-]+)(\\|(?:min|max):-?[\\d.]+)?\\}\\}";
191
+
192
+ function placeholderNames(value: unknown, out: Set<string> = new Set()): Set<string> {
193
+ if (typeof value === "string") {
194
+ for (const m of value.matchAll(new RegExp(PLACEHOLDER_SRC, "g"))) out.add(m[1]!);
195
+ // A lone unparseable {{...}} is almost certainly a typo'd placeholder — fail loudly.
196
+ const braces = /\{\{[^}]*\}\}/g.exec(value);
197
+ if (braces && !new RegExp(`^${PLACEHOLDER_SRC}$`).test(braces[0])) {
198
+ throw new ScenarioError(`malformed placeholder ${JSON.stringify(braces[0])} (expected {{name}} or {{name|min:N}}/{{name|max:N}})`);
199
+ }
200
+ } else if (Array.isArray(value)) {
201
+ for (const v of value) placeholderNames(v, out);
202
+ } else if (typeof value === "object" && value !== null) {
203
+ for (const v of Object.values(value)) placeholderNames(v, out);
204
+ }
205
+ return out;
206
+ }
207
+
208
+ 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 {
209
+ return adapter.extractorKinds?.[kind];
210
+ }
211
+
212
+ /** `{{name|min:N}}` / `{{name|max:N}}` — numeric clamps on an extracted value. */
213
+ function applyTransform(value: string | number, transform: string | undefined, vendor: string, where: string): string | number {
214
+ if (!transform) return value;
215
+ const [op, rawN] = transform.slice(1).split(":") as [string, string];
216
+ if (typeof value !== "number") throw new ScenarioError(`${vendor} scenario: ${where} applies |${op}:${rawN} to a non-numeric extractor value ${JSON.stringify(value)}`);
217
+ const n = Number(rawN);
218
+ return op === "min" ? Math.min(value, n) : Math.max(value, n);
219
+ }
220
+
221
+ type ScopeState = { calls: number; phase: string | undefined; onceFired: Set<string> };
222
+ type Registered = { handler: ScenarioHandler; id: string; source: "file" | "use"; matches: number };
223
+
224
+ const MISS_KEEP = 20;
225
+
226
+ /** The engine: pure state machine over registered handlers. One instance per twin server. */
227
+ export class ScenarioEngine<Req> {
228
+ private readonly adapter: PackScenarioAdapter<Req>;
229
+ private readonly extractors: Record<string, ScenarioExtractorSpec>;
230
+ private baseline: Registered[] = [];
231
+ private overrides: Registered[] = [];
232
+ private scopes = new Map<string, ScopeState>();
233
+ private missCount = 0;
234
+ private recentMisses: ScenarioMissRecord[] = [];
235
+
236
+ constructor(adapter: PackScenarioAdapter<Req>, document?: ScenarioDocument) {
237
+ this.adapter = adapter;
238
+ this.extractors = document?.extractors ?? {};
239
+ if (document) {
240
+ this.baseline = document.handlers.map((handler, i) => ({ handler, id: handler.id ?? `handler-${i + 1}`, source: "file", matches: 0 }));
241
+ }
242
+ }
243
+
244
+ /** In-process test overrides: LIFO over the baseline; returns a remover. NOT a runtime
245
+ * door — nothing outside this process can reach it, by design. */
246
+ use(...handlers: ScenarioHandler[]): () => void {
247
+ const parsed = handlers.map((h, i) => parseHandler(h, i, this.adapter, this.extractors));
248
+ const registered: Registered[] = parsed.map((handler, i) => ({ handler, id: handler.id ?? `use-${this.overrides.length + i + 1}`, source: "use", matches: 0 }));
249
+ this.overrides = [...registered, ...this.overrides]; // LIFO: newest first
250
+ return () => { this.overrides = this.overrides.filter((r) => !registered.includes(r)); };
251
+ }
252
+
253
+ /** Decide one request. Mutates scope state (calls, once, phase) exactly like serving. */
254
+ next(req: Req): ScenarioDecision {
255
+ const scopeOf = (h: ScenarioHandler): string => (h.scope === "session" ? `session:${this.adapter.scopeKey!(req)}` : "world");
256
+ // The call counter ticks once per request on the WORLD scope (and the session scope when
257
+ // one exists) — before matching, so nthCall-style matchers see 1-based "this call".
258
+ this.scope("world").calls += 1;
259
+ if (this.adapter.scopeKey) this.scope(`session:${this.adapter.scopeKey(req)}`).calls += 1;
260
+ for (const r of [...this.overrides, ...this.baseline]) {
261
+ const scope = this.scope(scopeOf(r.handler));
262
+ if (r.handler.phase !== undefined && scope.phase !== r.handler.phase) continue;
263
+ if (r.handler.once && scope.onceFired.has(r.id)) continue;
264
+ if (!this.matches(r.handler, req, scope)) continue;
265
+ if (r.handler.once) scope.onceFired.add(r.id);
266
+ if (r.handler.advancePhase !== undefined) scope.phase = r.handler.advancePhase;
267
+ r.matches += 1;
268
+ // `ruleId` is the row's STABLE id — the authored `id` or the parser's `handler-<n>`
269
+ // default — the same name the status door reports, so a pack stamping the fired rule
270
+ // (deepgram's `scenario_rule`) and the operator reading /twin/scenario see one vocabulary.
271
+ try {
272
+ return { kind: "handler", handler: r.handler, respond: this.substitute(r.handler.respond, req), ruleId: r.id };
273
+ } catch (e) {
274
+ // The handler that demanded the value is named: an authoring fault is fixed by finding it.
275
+ 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)}` : ""}]`);
276
+ throw e;
277
+ }
278
+ }
279
+ const miss: ScenarioMissRecord = { features: this.adapter.features(req), phase: this.scope("world").phase };
280
+ this.missCount += 1;
281
+ this.recentMisses = [...this.recentMisses.slice(-(MISS_KEEP - 1)), miss];
282
+ return { kind: "miss", miss };
283
+ }
284
+
285
+ /** For GET /twin/scenario — active handlers with match counts, and the recent misses. */
286
+ status(): ScenarioStatus {
287
+ 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 });
288
+ return { vendor: this.adapter.vendor, handlers: [...this.overrides, ...this.baseline].map(row), misses: this.missCount, recentMisses: [...this.recentMisses] };
289
+ }
290
+
291
+ private scope(key: string): ScopeState {
292
+ let s = this.scopes.get(key);
293
+ if (!s) { s = { calls: 0, phase: undefined, onceFired: new Set() }; this.scopes.set(key, s); }
294
+ return s;
295
+ }
296
+
297
+ private matches(handler: ScenarioHandler, req: Req, scope: ScopeState): boolean {
298
+ for (const [key, condition] of Object.entries(handler.on)) {
299
+ if (key === "nthCall") { if (scope.calls !== condition) return false; continue; }
300
+ const matcher = this.adapter.matchers[key];
301
+ if (!matcher) return false; // unreachable post-parse; belt over braces
302
+ if (!matcher(req, condition)) return false;
303
+ }
304
+ return true;
305
+ }
306
+
307
+ private extractValue(name: string, req: Req, cache: Map<string, string | number>): string | number {
308
+ const cached = cache.get(name);
309
+ if (cached !== undefined) return cached;
310
+ const spec = this.extractors[name]!;
311
+ const packKind = adapterKind(this.adapter, spec.kind);
312
+ let value: string | number;
313
+ if (packKind !== undefined) {
314
+ value = packKind.extract(spec as Record<string, unknown>, req, name);
315
+ } else if (spec.kind === "feature") {
316
+ const v = this.adapter.features(req)[(spec as { feature: string }).feature];
317
+ if (v === undefined) throw new ScenarioError(`${this.adapter.vendor} scenario: placeholder "{{${name}}}": the request has no feature "${(spec as { feature: string }).feature}"`);
318
+ value = typeof v === "number" || typeof v === "string" ? v : String(v);
319
+ } else {
320
+ const text = this.adapter.text!(req);
321
+ const tp = spec as { pattern: string; as?: string };
322
+ const m = new RegExp(tp.pattern).exec(text);
323
+ 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)`);
324
+ const captured = m[1] ?? m[0]!;
325
+ if (tp.as === "number") {
326
+ const n = Number(captured);
327
+ if (Number.isNaN(n)) throw new ScenarioError(`${this.adapter.vendor} scenario: placeholder "{{${name}}}": captured "${captured}" is not a number`);
328
+ value = n;
329
+ } else value = captured;
330
+ }
331
+ cache.set(name, value);
332
+ return value;
333
+ }
334
+
335
+ private substitute(value: unknown, req: Req, cache: Map<string, string | number> = new Map()): unknown {
336
+ if (typeof value === "string") {
337
+ // A WHOLE-string placeholder yields the typed value (numbers stay numbers).
338
+ const whole = new RegExp(`^${PLACEHOLDER_SRC}$`).exec(value);
339
+ if (whole) return applyTransform(this.extractValue(whole[1]!, req, cache), whole[2], this.adapter.vendor, value);
340
+ return value.replace(new RegExp(PLACEHOLDER_SRC, "g"), (all, name: string, transform: string | undefined) =>
341
+ String(applyTransform(this.extractValue(name, req, cache), transform, this.adapter.vendor, all)));
342
+ }
343
+ if (Array.isArray(value)) return value.map((v) => this.substitute(v, req, cache));
344
+ if (typeof value === "object" && value !== null) {
345
+ return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, this.substitute(v, req, cache)]));
346
+ }
347
+ return value;
348
+ }
349
+ }
350
+
351
+ /** The GET /twin manifest for a STATEFUL twin (no scenario engine): what it stores, how
352
+ * state and identity work, where behavior scripting actually lives. Education ships
353
+ * INSIDE the twin — in-world agents have no repos or skills, only HTTP. */
354
+ export function statefulTwinManifest(input: { vendor: string; twinOf: string; stores: string; identity?: string; notes?: string }): Record<string, unknown> {
355
+ return {
356
+ twin: true,
357
+ vendor: input.vendor,
358
+ twinOf: input.twinOf,
359
+ program: {
360
+ state: `Stateful: it stores ${input.stores}. Create state through the vendor's OWN API with the real SDK or plain fetch pointed here — there is no fixture language and no write door besides the vendor's.`,
361
+ identity: input.identity ?? 'Authenticate as the vendor does; the twin accepts any non-sentinel credential.',
362
+ time: 'Writes are stamped from the WORLD CLOCK (volter-world clock <world> set/advance) — deterministic history is clock-set, seed, clock-advance.',
363
+ behavior: 'This twin is state, not scripting — answers are functions of what you seeded. Judgment/fault scripting lives on the scripted vendor twins (their GET /twin explains).',
364
+ ...(input.notes ? { notes: input.notes } : {}),
365
+ },
366
+ doors: { manifest: 'GET /twin' },
367
+ };
368
+ }
369
+
370
+ /** The GET /twin manifest — the discovery door's body. Education ships INSIDE the twin:
371
+ * in-world agents have no repos or skills, only HTTP. */
372
+ export function twinManifest(input: { vendor: string; twinOf: string; stateSentence: string; behaviorSentence: string; exampleHandler: ScenarioHandler | null; engine?: ScenarioEngine<never> }): Record<string, unknown> {
373
+ const status = input.engine?.status();
374
+ return {
375
+ twin: true,
376
+ vendor: input.vendor,
377
+ twinOf: input.twinOf,
378
+ program: {
379
+ state: input.stateSentence,
380
+ behavior: input.behaviorSentence,
381
+ 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.",
382
+ ...(input.exampleHandler ? { exampleHandler: input.exampleHandler } : {}),
383
+ },
384
+ doors: { manifest: "GET /twin", scenario: "GET /twin/scenario (read-only: active handlers + match/miss counts)" },
385
+ ...(status ? { activeHandlers: status.handlers.length, misses: status.misses } : {}),
386
+ };
387
+ }