jev-devtools 0.4.1 → 0.5.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.
package/README.md CHANGED
@@ -1,12 +1,14 @@
1
1
  # Jev DevTools
2
2
 
3
- [![npm version 0.4.1](https://img.shields.io/badge/npm-v0.4.1-E551BA?labelColor=0D0A0F&logo=npm)](https://www.npmjs.com/package/jev-devtools/v/0.4.1)
3
+ [![npm version 0.5.1](https://img.shields.io/badge/npm-v0.5.1-E551BA?labelColor=0D0A0F&logo=npm)](https://www.npmjs.com/package/jev-devtools/v/0.5.1)
4
4
 
5
5
  A React drawer and Node adapter for inspecting TypeSafe Jev requests with animated diagrams. Includes request history, context, questions, typed answers, transport attempts, and raw snapshots.
6
6
 
7
- **Version 0.4.1 is prepared for release; publication is pending.** Check the release history for verified publication status. **Experimental (pre-1.0).** React 18/19 and Node 22.12+ are supported. APIs may change before 1.0. Verification uses sample responses through the real TypeSafe SDK, not live inference.
7
+ **Source version: 0.5.1.** Check the [release history](https://github.com/diegoag64/jev-devtools/blob/main/CHANGELOG.md) for npm availability. If trying this source before publication, install the local archive below. **Experimental (pre-1.0).** React 18/19 and Node 22.12+ are supported. APIs may change before 1.0. Verification uses sample responses through the real TypeSafe SDK, not live inference.
8
8
 
9
- [Changelog and release history](https://github.com/diegoag64/jev-devtools/blob/main/CHANGELOG.md) — published versions and unreleased work.
9
+ [Website](https://jevtools.dev/) · [Documentation](https://docs.jevtools.dev/) · [Quick start](https://docs.jevtools.dev/getting-started/) · [API reference](https://docs.jevtools.dev/api/reference/) · [Release history](https://docs.jevtools.dev/changelog/)
10
+
11
+ Hosted docs update separately; use the installed declarations and bundled skill for your exact version, and the [repository changelog](https://github.com/diegoag64/jev-devtools/blob/main/CHANGELOG.md) for release-candidate status.
10
12
 
11
13
  ## Demo
12
14
 
@@ -31,12 +33,14 @@ The [setup skill](https://github.com/diegoag64/jev-devtools/blob/main/packages/j
31
33
 
32
34
  ## Install
33
35
 
34
- The npm command installs the latest published release (0.4.0 at preparation time):
36
+ Install from npm:
35
37
 
36
38
  ```sh
37
- npm install jev-devtools @typesafe-ai/sdk
39
+ pnpm add jev-devtools @typesafe-ai/sdk
38
40
  ```
39
41
 
42
+ Use ESM imports, matching React/React DOM **18.x or 19.x**, Node **22.12+**, and TypeSafe SDK **0.7.x**. TypeScript consumers need **5.4+** and compatible React/Node types. React and React DOM are required package peers even for server-only installations. See [compatibility and all public imports](https://github.com/diegoag64/jev-devtools/blob/main/docs/integration.md#compatibility-and-public-imports); `/server` and `/flow` run only on Node.
43
+
40
44
  To try local changes before a future release:
41
45
 
42
46
  ```sh
@@ -44,7 +48,7 @@ To try local changes before a future release:
44
48
  npm ci
45
49
  npm run pack:devtools
46
50
  # From the consuming app:
47
- npm install /absolute/path/to/jev-devtools-0.4.1.tgz
51
+ npm install /absolute/path/to/jev-devtools-0.5.1.tgz
48
52
  ```
49
53
 
50
54
  Mount once. The drawer loads its own styles; no CSS import is needed:
@@ -70,19 +74,23 @@ app.use(inspector.middleware); // Mount at root, before fallback handlers.
70
74
  // Call inspector.dispose() on shutdown.
71
75
  ```
72
76
 
73
- Only calls through that client are captured. The React component subscribes via same-origin SSE, receiving an initial snapshot followed by changed traces. Older client/server combinations remain compatible through full snapshots. Both sides default to disabled. API keys remain server-side. Browser code is SSR-safe. The drawer uses a Shadow DOM boundary and its own stylesheet so host page selectors cannot alter its internal layout. Existing stylesheet imports remain supported; no Tailwind or shadcn installation is required. Drag the Flow canvas to pan, scroll/pinch to zoom, or use Fit.
77
+ Only calls through that client are captured. The React component subscribes via same-origin SSE, receiving an initial snapshot followed by changed traces. Older client/server combinations remain compatible through full snapshots. Both sides default to disabled. API keys remain server-side. Browser code is SSR-safe. The drawer uses a Shadow DOM boundary and its own stylesheet so host page selectors cannot alter its internal layout. No Tailwind or shadcn installation is required. Drag the Flow canvas to pan, scroll/pinch to zoom, or use Fit.
74
78
 
75
79
  History is bounded to 50 calls in memory by default. API routes and inspector endpoints must share an adapter instance; distributed processes and edge runtimes are not supported. Without `authorize`, the middleware accepts only loopback connections; Web Request handlers can't see the connection's address, so run those servers on `127.0.0.1`. Captures include bounded/redacted context and answers, not hidden model reasoning.
76
80
 
77
- For Next.js App Router, use `getJevDevtools(appKey, optionsFactory)` from `/server` to share an adapter across server reloads, expose `handleNodeRoute` through an optional catch-all Route Handler, and mount the root component in a client boundary. See the [Next.js guide](https://github.com/diegoag64/jev-devtools/blob/main/docs/nextjs.md) and runnable example. No Next dependency is required by this package.
81
+ Known credential fields are always redacted from DevTools captures; SDK and runtime data remain unchanged. The optional `<JevDevtools redactAdditionalSensitiveData={false} />` prop controls **display-only** masking of supported email, phone, postal address, SSN and card-number fields. It defaults to `true` and updates existing views immediately. These additional values still exist in capture/stream/browser memory while masked. Custom server redaction remains active, and neither credentials nor custom-redacted values can be revealed. Headers/options are not captured. See the [exact policy and limitations](https://github.com/diegoag64/jev-devtools/blob/main/docs/integration.md#redaction-policy); arbitrary secrets in prose and unusual field names still need application-specific redaction.
82
+
83
+ For Next.js App Router, use `getJevDevtools(appKey, optionsFactory)` from `/server` to share an adapter across server reloads, expose `handleNodeRoute` through an optional catch-all Route Handler, and mount the root component in a client boundary. See the [Next.js guide](https://docs.jevtools.dev/guides/nextjs/) and runnable example. No Next dependency is required by this package.
78
84
 
79
- [Full integration guide](https://github.com/diegoag64/jev-devtools/blob/main/docs/integration.md) · [Repository and playground](https://github.com/diegoag64/jev-devtools)
85
+ [Quick start](https://docs.jevtools.dev/getting-started/) · [Detailed integration and access controls](https://github.com/diegoag64/jev-devtools/blob/main/docs/integration.md) · [Repository and playground](https://github.com/diegoag64/jev-devtools)
86
+
87
+ Jev DevTools is an independent, open-source community project. It is not affiliated with TypeSafe AI.
80
88
 
81
89
  MIT for project code. See bundled LICENSE and THIRD_PARTY_NOTICES.txt for original design attribution and TypeSafe branding.
82
90
 
83
91
  ## Connected Jev flows with `jev-devtools/flow`
84
92
 
85
- Define calls, conditions, parallel checks, and named outcomes with `jev-devtools/flow`. **0.4.0 adds** server function steps (`task`), runtime request factories, bounded collections (`map`), returning conditionals (`if`), stateful iteration (`loop`), and strict numeric `.gt()` comparisons. The drawer connects request diagrams, function results, selected paths, collection items, and loop iterations on one canvas. Existing single-request integrations remain supported.
93
+ Define calls, conditions, parallel checks, and named outcomes with `jev-devtools/flow`. The builder supports server function steps (`task`), runtime request factories, bounded collections (`map`), returning conditionals (`if`), stateful iteration (`loop`), and strict numeric `.gt()` comparisons. The drawer connects request diagrams, function results, selected paths, collection items, and loop iterations on one canvas. Existing single-request integrations remain supported.
86
94
 
87
95
  On the server, reuse the instrumented `jev` client and `inspector` from the setup above:
88
96
 
@@ -111,9 +119,11 @@ const result = await review.run({ message: 'Please review my ticket.' }, {
111
119
  // Handle result.outcome in your app. The flow does not execute business actions.
112
120
  ```
113
121
 
114
- Define once in a Node module and run per request. Use `flow.parallel` for concurrent named lanes; downstream calls wait for every lane to succeed. `flow.branch` is terminal in its block; use `flow.if` when selected values feed shared downstream work. Tasks execute application code on Node with JSON-compatible inputs/results; maps and loops have explicit bounds. Runs have SDK retries and cooperative cancellation, with bounded in-memory inspector history. They do not provide durable or distributed execution. Keep the 0.4.0 server and drawer together to inspect its new step kinds.
122
+ Define once in a Node module and run per request. Use `flow.parallel` for concurrent named lanes; downstream calls wait for every lane to succeed. `flow.branch` is terminal in its block; use `flow.if` when selected values feed shared downstream work. Tasks execute application code on Node with JSON-compatible inputs/results; maps and loops have explicit bounds. Runs have SDK retries and cooperative cancellation, with bounded in-memory inspector history. They do not provide durable or distributed execution. Keep the server and drawer on the same package version to inspect every supported step kind.
123
+
124
+ **Added in 0.5.0.** **Copy fixture** in the drawer saves a run as JSON, and `replayClient(fixture)` from `jev-devtools/flow` replays its Jev calls in tests with no network access or API key; tasks run again. `compareWithFixture` from `jev-devtools/flow` compares a live run's answers, routing and item steps with a saved fixture.
115
125
 
116
- [Full flow guide](https://github.com/diegoag64/jev-devtools/blob/main/docs/flows.md) · [AI flow recipe](https://github.com/diegoag64/jev-devtools/blob/main/packages/jev-devtools/skills/setup-jev-devtools/references/flows.md)
126
+ [Beginner flow guide](https://docs.jevtools.dev/guides/flow-basics/) · [Flow API reference](https://docs.jevtools.dev/api/flow/) · [Detailed flow guide](https://github.com/diegoag64/jev-devtools/blob/main/docs/flows.md) · [AI flow recipe](https://github.com/diegoag64/jev-devtools/blob/main/packages/jev-devtools/skills/setup-jev-devtools/references/flows.md)
117
127
 
118
128
  To have your coding agent add a flow after installation:
119
129
 
@@ -0,0 +1,61 @@
1
+ import type { FlowDefinition } from "../src/flow-protocol.js";
2
+ import type { FlowFixture } from "../src/flow-fixture.js";
3
+ import type { FlowOutcome, FlowRunOptions } from "./index.js";
4
+ type AnswerValue = string | number | boolean | null;
5
+ /** One scalar answer field of a jev step both sides ran; delta is set when both sides and their difference are finite. Answer drift never sets FixtureComparison.changed on its own. */
6
+ export type AnswerChange = {
7
+ stepId: string;
8
+ question: string;
9
+ field: string;
10
+ recorded?: AnswerValue;
11
+ live?: AnswerValue;
12
+ delta?: number;
13
+ changed: boolean;
14
+ };
15
+ export type FixtureComparison = {
16
+ /** Outcome, value or a decision differs, or a jev step ran on only one side. Answer drift alone does not set it. */
17
+ changed: boolean;
18
+ outcome: {
19
+ recorded: string;
20
+ live: string;
21
+ changed: boolean;
22
+ };
23
+ value: {
24
+ recorded?: unknown;
25
+ live?: unknown;
26
+ changed: boolean;
27
+ };
28
+ decisions: {
29
+ stepId: string;
30
+ recorded?: boolean;
31
+ live?: boolean;
32
+ changed: boolean;
33
+ }[];
34
+ answers: AnswerChange[];
35
+ /** Jev step ids; map and loop items use `${parentId}/${index}/${innerId}`. */
36
+ steps: {
37
+ onlyRecorded: string[];
38
+ onlyLive: string[];
39
+ };
40
+ /** The live run's response bodies, unredacted and untruncated. */
41
+ liveFixture: FlowFixture;
42
+ };
43
+ /** Any flow from defineJevFlow, typed by its input. */
44
+ export type ComparableFlow<T> = {
45
+ definition: FlowDefinition;
46
+ run(input: T, options: FlowRunOptions): Promise<FlowOutcome & {
47
+ runId: string;
48
+ }>;
49
+ };
50
+ /**
51
+ * Replays `fixture` offline and runs the flow with `options.client` on the same input, then
52
+ * reports what changed. Only the live run reaches `options.devtools`. Tasks run on both
53
+ * sides, the recorded side on the replayed answers. Rejects when the
54
+ * fixture cannot replay this input, or with the live run's own error.
55
+ */
56
+ export declare function compareWithFixture<T>(flow: ComparableFlow<T>, input: T, fixture: FlowFixture, options: FlowRunOptions & {
57
+ tolerance?: number;
58
+ }): Promise<FixtureComparison>;
59
+ /** One plain line per change, or "no changes from fixture". */
60
+ export declare function formatComparison(comparison: FixtureComparison): string;
61
+ export {};
@@ -0,0 +1,336 @@
1
+ import { isDeepStrictEqual } from "node:util";
2
+ import { replayClient } from "./replay.js";
3
+ import { observeRequest } from "./runtime.js";
4
+ const record = (v) => !!v && typeof v === "object" && !Array.isArray(v);
5
+ async function observe(flow, input, options) {
6
+ const bodies = new Map();
7
+ const captures = [];
8
+ let latest;
9
+ const user = options.devtools;
10
+ const devtools = {
11
+ startFlow(definition, run) {
12
+ latest = run;
13
+ let inner;
14
+ try {
15
+ inner = user?.startFlow(structuredClone(definition), structuredClone(run));
16
+ }
17
+ catch {
18
+ /* Recording cannot stop execution. */
19
+ }
20
+ return {
21
+ update(next) {
22
+ latest = next;
23
+ try {
24
+ // Recording consumers cannot alter the comparison's private snapshot.
25
+ inner?.update(structuredClone(next));
26
+ }
27
+ catch {
28
+ /* Best effort capture. */
29
+ }
30
+ },
31
+ request(link, onTrace, callback) {
32
+ const stepId = link.stepId;
33
+ // Capture the runner's result even if recording skips, throws or substitutes it.
34
+ const result = observeRequest(callback, inner
35
+ ? (callOnce) => inner.request({ ...link }, onTrace, callOnce)
36
+ : undefined);
37
+ // A separate subscriber; the runner awaits the original result unchanged.
38
+ const capture = Promise.resolve(result).then(
39
+ // Observers may retain the result and mutate it on later run updates.
40
+ (body) => bodies.set(stepId, structuredClone(body)), () => undefined);
41
+ // Handle snapshot failures now, then surface them after successful execution.
42
+ void capture.catch(() => { });
43
+ captures.push(capture);
44
+ return result;
45
+ },
46
+ };
47
+ },
48
+ };
49
+ const result = await flow.run(input, { ...options, devtools });
50
+ await Promise.all(captures);
51
+ return {
52
+ outcome: result.outcome,
53
+ ...(result.value !== undefined ? { value: result.value } : {}),
54
+ ...(latest ? { run: latest } : {}),
55
+ bodies,
56
+ };
57
+ }
58
+ /**
59
+ * Visits both runs' nodes in flowFixture's order: definition order, and at a map or loop
60
+ * its items by ascending index, each item walked whole before the next.
61
+ */
62
+ function walk(definition, recorded, live, visit, prefix = "") {
63
+ const items = (step) => new Map((step?.collection?.items ?? []).flatMap((item) => item.run ? [[item.index, item.run]] : []));
64
+ for (const node of definition.nodes) {
65
+ const r = recorded?.steps[node.id], l = live?.steps[node.id];
66
+ if (node.itemDefinition) {
67
+ const before = items(r), after = items(l);
68
+ // Items complete in any order, so walk them by index.
69
+ for (const index of union(before.keys(), after.keys()).sort((a, b) => a - b))
70
+ walk(node.itemDefinition, before.get(index), after.get(index), visit, `${prefix}${node.id}/${index}/`);
71
+ }
72
+ visit(node, prefix + node.id, r, l);
73
+ }
74
+ }
75
+ /** Scalar leaves of an answer, by path segments, without the type discriminator. */
76
+ function leaves(value, path = [], out = []) {
77
+ if (!value || typeof value !== "object")
78
+ return out;
79
+ for (const [key, child] of Object.entries(value)) {
80
+ if (!path.length && key === "type")
81
+ continue;
82
+ if (child && typeof child === "object")
83
+ leaves(child, [...path, key], out);
84
+ else if (child === null ||
85
+ typeof child === "string" ||
86
+ typeof child === "number" ||
87
+ typeof child === "boolean")
88
+ out.push({ path: [...path, key], value: child });
89
+ }
90
+ return out;
91
+ }
92
+ /** Dotted for plain keys; keys with dots, brackets or quotes are bracketed so paths stay distinct. */
93
+ const fieldName = (path) => path.reduce((name, key) => key && !/[.[\]"]/.test(key)
94
+ ? name + (name ? "." : "") + key
95
+ : `${name}[${JSON.stringify(key)}]`, "");
96
+ const byPath = (leaves) => new Map(leaves.map((leaf) => [JSON.stringify(leaf.path), leaf]));
97
+ const answersOf = (body) => record(body) && record(body.answers) ? body.answers : {};
98
+ /** Recorded keys first, then keys only the live side has. */
99
+ const union = (recorded, live) => [
100
+ ...new Set([...recorded, ...live]),
101
+ ];
102
+ function compareAnswers(stepId, recordedBody, liveBody, tolerance) {
103
+ const recorded = answersOf(recordedBody), live = answersOf(liveBody), changes = [];
104
+ for (const question of union(Object.keys(recorded), Object.keys(live))) {
105
+ const before = byPath(leaves(recorded[question])), after = byPath(leaves(live[question]));
106
+ for (const key of union(before.keys(), after.keys())) {
107
+ const r = before.get(key)?.value, l = after.get(key)?.value;
108
+ const leaf = before.get(key) ?? after.get(key);
109
+ if (!leaf)
110
+ continue; // Every key comes from one of the two maps.
111
+ const change = {
112
+ stepId,
113
+ question,
114
+ field: fieldName(leaf.path),
115
+ changed: false,
116
+ };
117
+ if (before.has(key))
118
+ change.recorded = r;
119
+ if (after.has(key))
120
+ change.live = l;
121
+ if (typeof r === "number" && typeof l === "number") {
122
+ const delta = l - r;
123
+ if (!Number.isFinite(r) || !Number.isFinite(l))
124
+ change.changed = !Object.is(r, l);
125
+ else if (!Number.isFinite(delta))
126
+ change.changed = true; // Overflow: never read as unchanged.
127
+ else {
128
+ change.delta = delta;
129
+ // Allow float noise at the boundary (0.4 - 0.3 is 0.10000000000000003).
130
+ change.changed =
131
+ Math.abs(delta) > tolerance + tolerance * 8 * Number.EPSILON;
132
+ }
133
+ }
134
+ else
135
+ change.changed = before.has(key) !== after.has(key) || r !== l;
136
+ changes.push(change);
137
+ }
138
+ }
139
+ return changes;
140
+ }
141
+ /**
142
+ * Replays `fixture` offline and runs the flow with `options.client` on the same input, then
143
+ * reports what changed. Only the live run reaches `options.devtools`. Tasks run on both
144
+ * sides, the recorded side on the replayed answers. Rejects when the
145
+ * fixture cannot replay this input, or with the live run's own error.
146
+ */
147
+ export async function compareWithFixture(flow, input, fixture, options) {
148
+ const { tolerance = 0, ...runOptions } = options;
149
+ if (!Number.isFinite(tolerance) || tolerance < 0)
150
+ throw new TypeError("tolerance must be a non-negative finite number");
151
+ // Outside the try, so an invalid fixture rejects with replayClient's own TypeError.
152
+ const client = replayClient(fixture);
153
+ let recorded;
154
+ try {
155
+ recorded = await observe(flow, input, {
156
+ client,
157
+ ...(runOptions.signal ? { signal: runOptions.signal } : {}),
158
+ });
159
+ }
160
+ catch (error) {
161
+ if (error instanceof Error &&
162
+ ["AbortError", "APIUserAbortError"].includes(error.name))
163
+ throw error;
164
+ throw new Error(`Fixture replay failed: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
165
+ }
166
+ const live = await observe(flow, input, runOptions);
167
+ const decisions = [], answers = [], onlyRecorded = [], onlyLive = [], steps = {};
168
+ walk(flow.definition, recorded.run, live.run, (node, id, rs, ls) => {
169
+ // Merges copy their if()'s decision and loops keep their last while(); neither counts.
170
+ if (node.kind === "branch" || node.kind === "if") {
171
+ const r = rs?.decision, l = ls?.decision;
172
+ if (r !== undefined || l !== undefined)
173
+ decisions.push({
174
+ stepId: id,
175
+ ...(r !== undefined ? { recorded: r } : {}),
176
+ ...(l !== undefined ? { live: l } : {}),
177
+ changed: r !== l,
178
+ });
179
+ return;
180
+ }
181
+ if (node.kind !== "jev")
182
+ return;
183
+ // Item status is under the bare id; bodies arrive under the composed id.
184
+ const inRecorded = rs?.status === "completed" && recorded.bodies.has(id), inLive = ls?.status === "completed" && live.bodies.has(id);
185
+ if (inLive)
186
+ steps[id] = structuredClone(live.bodies.get(id));
187
+ if (inRecorded && inLive)
188
+ answers.push(...compareAnswers(id, recorded.bodies.get(id), live.bodies.get(id), tolerance));
189
+ else if (inRecorded)
190
+ onlyRecorded.push(id);
191
+ else if (inLive)
192
+ onlyLive.push(id);
193
+ });
194
+ const outcome = {
195
+ recorded: recorded.outcome,
196
+ live: live.outcome,
197
+ changed: recorded.outcome !== live.outcome,
198
+ };
199
+ const value = {
200
+ ...(recorded.value !== undefined ? { recorded: recorded.value } : {}),
201
+ ...(live.value !== undefined ? { live: live.value } : {}),
202
+ changed: !isDeepStrictEqual(recorded.value, live.value),
203
+ };
204
+ return {
205
+ changed: outcome.changed ||
206
+ value.changed ||
207
+ decisions.some((d) => d.changed) ||
208
+ onlyRecorded.length > 0 ||
209
+ onlyLive.length > 0,
210
+ outcome,
211
+ value,
212
+ decisions,
213
+ answers,
214
+ steps: { onlyRecorded, onlyLive },
215
+ liveFixture: { version: 1, flow: flow.definition.name, steps },
216
+ };
217
+ }
218
+ /** JSON for ordinary values; never throws on BigInt, cycles, functions or symbols. */
219
+ function json(v) {
220
+ if (v === undefined)
221
+ return "(none)";
222
+ try {
223
+ const text = JSON.stringify(v);
224
+ if (text !== undefined)
225
+ return text;
226
+ }
227
+ catch {
228
+ /* Not plain JSON; render it below. */
229
+ }
230
+ try {
231
+ return loose(v, new Set()) ?? `[${typeof v}]`;
232
+ }
233
+ catch {
234
+ return "[unserializable]";
235
+ }
236
+ }
237
+ /** JSON.stringify's layout, with BigInt as 1n, cycles as [cycle] and other values by type. */
238
+ function loose(v, ancestors) {
239
+ if (typeof v === "bigint")
240
+ return `${v}n`;
241
+ if (typeof v === "function" || typeof v === "symbol")
242
+ return `[${typeof v}]`;
243
+ if (v === undefined)
244
+ return undefined;
245
+ if (v === null || typeof v !== "object")
246
+ return JSON.stringify(v);
247
+ if (ancestors.has(v))
248
+ return "[cycle]";
249
+ const toJSON = v.toJSON;
250
+ if (typeof toJSON === "function")
251
+ return loose(toJSON.call(v), ancestors);
252
+ ancestors.add(v);
253
+ const text = Array.isArray(v)
254
+ ? `[${v.map((item) => loose(item, ancestors) ?? "null").join(",")}]`
255
+ : `{${Object.entries(v)
256
+ .flatMap(([key, item]) => {
257
+ const shown = loose(item, ancestors);
258
+ return shown === undefined ? [] : [`${JSON.stringify(key)}:${shown}`];
259
+ })
260
+ .join(",")}}`;
261
+ ancestors.delete(v);
262
+ return text;
263
+ }
264
+ /** Report text beyond this many characters is cut; the comparison keeps values in full. */
265
+ const LIMIT = 160;
266
+ /** Up to LIMIT characters from start, marking cut ends; never splits a surrogate pair. */
267
+ function cut(text, start) {
268
+ if (start > 0 && /[\uDC00-\uDFFF]/.test(text[start]))
269
+ start--;
270
+ if (start === 0 && text.length <= LIMIT)
271
+ return text;
272
+ let end = Math.min(start + LIMIT, text.length);
273
+ if (end < text.length && /[\uD800-\uDBFF]/.test(text[end - 1]))
274
+ end--;
275
+ return `${start ? "..." : ""}${text.slice(start, end)}${end < text.length ? "..." : ""} (${text.length} chars)`;
276
+ }
277
+ /** Cuts both sides alike; when the first difference is past the cut, both show a window around it. */
278
+ function clipPair(a, b) {
279
+ let at = 0;
280
+ while (at < a.length && at < b.length && a[at] === b[at])
281
+ at++;
282
+ const start = a !== b && a.length > LIMIT && b.length > LIMIT && at >= LIMIT - 1
283
+ ? at - 40
284
+ : 0;
285
+ return [cut(a, start), cut(b, start)];
286
+ }
287
+ const route = (v) => v === undefined ? "(not run)" : v ? "then" : "else";
288
+ const scalar = (v, digits) => v === undefined
289
+ ? "(none)"
290
+ : typeof v === "number" && digits !== undefined
291
+ ? v.toFixed(digits)
292
+ : String(v);
293
+ /** Two decimals, more when two different numbers would print the same, else exact. */
294
+ function precision(a, b) {
295
+ if (typeof a !== "number" || typeof b !== "number" || a === b)
296
+ return 2;
297
+ for (let digits = 2; digits <= 20; digits++)
298
+ if (a.toFixed(digits) !== b.toFixed(digits))
299
+ return digits;
300
+ return undefined;
301
+ }
302
+ function signed(delta, digits) {
303
+ const sign = delta < 0 ? "-" : "+";
304
+ if (digits !== undefined)
305
+ for (; digits <= 20; digits++)
306
+ if (delta === 0 || Number(delta.toFixed(digits)) !== 0)
307
+ return sign + Math.abs(delta).toFixed(digits);
308
+ return sign + String(Math.abs(delta));
309
+ }
310
+ /** One plain line per change, or "no changes from fixture". */
311
+ export function formatComparison(comparison) {
312
+ const lines = [];
313
+ const { outcome, value, decisions, steps, answers } = comparison;
314
+ if (outcome.changed)
315
+ lines.push(`outcome: ${outcome.recorded} -> ${outcome.live}`);
316
+ if (value.changed) {
317
+ const [recorded, live] = clipPair(json(value.recorded), json(value.live));
318
+ lines.push(`value: ${recorded} -> ${live}`);
319
+ }
320
+ for (const d of decisions)
321
+ if (d.changed)
322
+ lines.push(`decision ${d.stepId}: ${route(d.recorded)} -> ${route(d.live)}`);
323
+ for (const id of steps.onlyRecorded)
324
+ lines.push(`step ${id}: recorded only`);
325
+ for (const id of steps.onlyLive)
326
+ lines.push(`step ${id}: live only`);
327
+ for (const a of answers) {
328
+ if (!a.changed)
329
+ continue;
330
+ const digits = precision(a.recorded, a.live);
331
+ const [recorded, live] = clipPair(scalar(a.recorded, digits), scalar(a.live, digits));
332
+ lines.push(`${fieldName([a.stepId, a.question])}${a.field.startsWith("[") ? "" : "."}${a.field}: ${recorded} -> ${live}` +
333
+ (a.delta !== undefined ? ` (${signed(a.delta, digits)})` : ""));
334
+ }
335
+ return lines.length ? lines.join("\n") : "no changes from fixture";
336
+ }
@@ -1,6 +1,9 @@
1
1
  import type { Questions, ResultFor, SystemOneRequest, TypeSafeClient } from "@typesafe-ai/sdk";
2
2
  import type { FlowDefinition, FlowDisplay, FlowRun } from "../src/flow-protocol.js";
3
3
  export type { FlowDisplay, FlowDefinition, FlowRun, FlowStep, } from "../src/flow-protocol.js";
4
+ export { replayClient, type FlowFixture } from "./replay.js";
5
+ export { flowFixture, type BuiltFlowFixture } from "../src/flow-fixture.js";
6
+ export { compareWithFixture, formatComparison, type AnswerChange, type ComparableFlow, type FixtureComparison, } from "./compare.js";
4
7
  type Ast = {
5
8
  op: "input";
6
9
  } | {
@@ -23,7 +26,8 @@ export declare class FlowExpression<T> {
23
26
  readonly ast: Ast;
24
27
  protected readonly [valueTypeBrand]: T;
25
28
  constructor(ast: Ast, owner?: object);
26
- get<K extends keyof T & (string | number)>(key: K): FlowExpression<T[K]>;
29
+ /** Select an own field from an object or array; primitive properties are unsupported. */
30
+ get<K extends keyof T & (string | number)>(this: FlowExpression<object>, key: K): FlowExpression<T[K]>;
27
31
  eq(value: T | FlowExpression<T>): FlowExpression<boolean>;
28
32
  lt(this: FlowExpression<number>, value: number | FlowExpression<number>): FlowExpression<boolean>;
29
33
  gt(this: FlowExpression<number>, value: number | FlowExpression<number>): FlowExpression<boolean>;
@@ -74,6 +78,7 @@ export type FlowRequestFactory<I, Q extends Questions> = {
74
78
  export type FlowObserver = {
75
79
  startFlow(definition: FlowDefinition, run: FlowRun): {
76
80
  update(run: FlowRun): void;
81
+ /** Call through synchronously to establish recording context. The runner owns the result. */
77
82
  request<T>(link: {
78
83
  runId: string;
79
84
  stepId: string;