@theoremjs/studio 0.3.4-next.403.g5de27f3 → 0.3.4-next.407.gd93ec2c

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 (50) hide show
  1. package/browser-engine.d.ts +1 -1
  2. package/browser-engine.js +1 -1
  3. package/browser-transport.d.ts +12 -0
  4. package/browser-transport.js +26 -7
  5. package/browser.d.ts +1 -1
  6. package/browser.js +1 -1
  7. package/mod.d.ts +2 -1
  8. package/mod.js +2 -1
  9. package/package.json +4 -4
  10. package/page/assets/{Banner-CLcFr8r9.js → Banner-B1Vat7i_.js} +2 -2
  11. package/page/assets/{ContextMenu-CArbXxnu.js → ContextMenu-BreVLY9J.js} +2 -2
  12. package/page/assets/{ToolResultCharts-DG0GfEe-.js → ToolResultCharts-BV2tbzow.js} +1 -1
  13. package/page/assets/{TraceInspector-DlcrrHNa.js → TraceInspector-B8NufbVX.js} +1 -1
  14. package/page/assets/{TraceOverview-BRgK49q4.js → TraceOverview-DoQOfiAe.js} +2 -2
  15. package/page/assets/{browser-engine-yDBh7tWN.js → browser-engine-BvsLwtiv.js} +6 -6
  16. package/page/assets/browser-transport-DxpDmXGb.js +47 -0
  17. package/page/assets/{export-agent-DuPZHGrj.js → export-agent-D2Vo-OSL.js} +3 -3
  18. package/page/assets/{guardrail-tester-xtRSrTXh.js → guardrail-tester-CuqamClw.js} +2 -2
  19. package/page/assets/{index-BVTCzZhh.js → index-C3mDKoGG.js} +69 -69
  20. package/page/assets/{live-messages-CaI0Oqem.js → live-messages-BZGmMo_v.js} +1 -1
  21. package/page/assets/{reader-CaGeZoFz.js → reader-paEmSrWc.js} +4 -4
  22. package/page/assets/{studio-code-pane-_XzNjN6P.js → studio-code-pane-B9bG0xzf.js} +1 -1
  23. package/page/assets/{studio-console-Da8ZVuKx.js → studio-console-1qIWsDtV.js} +3 -3
  24. package/page/assets/studio-decision-BdJR3HSN.js +1 -0
  25. package/page/assets/{studio-embedding-BaWl7liT.js → studio-embedding-CFzitRLj.js} +1 -1
  26. package/page/assets/studio-host-console-BzpDE2TK.js +2 -0
  27. package/page/assets/{studio-live-ON74y8sM.js → studio-live-BucphGSr.js} +2 -2
  28. package/page/assets/{studio-map-W8XbAypV.js → studio-map-BfUMbnZK.js} +1 -1
  29. package/page/assets/studio-trace-replay-C5cBxYG_.js +2 -0
  30. package/page/assets/turn-client-BZUECwPn.js +2 -0
  31. package/page/assets/{use-one-call-BD3tlw64.js → use-one-call-BVmuHLSm.js} +1 -1
  32. package/page/index.html +6 -6
  33. package/replay.d.ts +97 -0
  34. package/replay.js +387 -0
  35. package/server/draft-handler.js +2 -1
  36. package/transport.d.ts +5 -1
  37. package/transport.js +7 -2
  38. package/ui/inspector-context.d.ts +1 -1
  39. package/ui/lib/send-when-ready.d.ts +7 -0
  40. package/ui/lib/send-when-ready.js +14 -0
  41. package/ui/studio-connection.d.ts +1 -1
  42. package/ui/studio-runner.d.ts +4 -1
  43. package/ui/studio-runner.js +3 -2
  44. package/ui/studio-screen.js +111 -11
  45. package/ui/studio-trace-replay.d.ts +23 -0
  46. package/ui/studio-trace-replay.js +216 -0
  47. package/page/assets/browser-transport-B0GPqGzb.js +0 -47
  48. package/page/assets/studio-decision-CHaFsz_1.js +0 -1
  49. package/page/assets/studio-host-console-BuPmNkLs.js +0 -2
  50. package/page/assets/turn-client-BBi2anJM.js +0 -2
package/replay.d.ts ADDED
@@ -0,0 +1,97 @@
1
+ /**
2
+ * A recorded conversation, run again through the profile as it is now. Every model call is
3
+ * answered from the recording, in order, and by default every tool's result too; the profile's
4
+ * guardrails, stages, gates and transcript run for real. When the profile asks for something the
5
+ * recording does not hold, the replay stops there and says why (a divergence).
6
+ *
7
+ * The recording is a run's `TraceRecord`s: one per turn, with its model calls (`chat` spans) and
8
+ * tool calls (`execute_tool` spans). A replay needs no keys: it reaches no model.
9
+ *
10
+ * @module
11
+ */
12
+ import { type Profile } from '@theoremjs/agents';
13
+ import { type TranscriptBlock } from '@theoremjs/agents/interface';
14
+ import type { ModelEvent, TurnModel } from '@theoremjs/agents/kernel';
15
+ import { type TraceRecord } from '@theoremjs/agents/observability';
16
+ import type { ToolRegistration } from './registrations.ts';
17
+ import type { StudioRunPayload } from './run-payload.ts';
18
+ import type { StudioToolHandlers } from './tools.ts';
19
+ /** One model call as recorded: what it answered, and whether the recording holds all of it. */
20
+ export interface ReplayCall {
21
+ /** The agent that made the call. */
22
+ agent: string;
23
+ events: ModelEvent[];
24
+ /** The tools its reply calls, by name. */
25
+ calls: string[];
26
+ /** Some of the reply was not recorded (media a model made), so it cannot be answered again. */
27
+ missing: boolean;
28
+ }
29
+ /** One tool call's recorded result. */
30
+ export interface ReplayResult {
31
+ name: string;
32
+ result: string;
33
+ failed: boolean;
34
+ }
35
+ /** One recorded turn: what the person sent, the model calls and tool results it took, and what they saw. */
36
+ export interface ReplayTurn {
37
+ text: string;
38
+ /** Files sent with the message. Their bytes are never recorded, so such a turn cannot be sent again. */
39
+ attachments: number;
40
+ calls: ReplayCall[];
41
+ results: ReplayResult[];
42
+ /** The turn as the person saw it. */
43
+ blocks: TranscriptBlock[];
44
+ }
45
+ /** A recording as a replay reads it. */
46
+ export interface ReplayScript {
47
+ /** The agent the conversation was with. */
48
+ agent: string;
49
+ turns: ReplayTurn[];
50
+ /** When the first turn started (epoch ms). */
51
+ recordedAt?: number;
52
+ }
53
+ /** Why a replay stopped where it did. */
54
+ export interface ReplayDivergence {
55
+ /** The turn, from 0. */
56
+ turn: number;
57
+ /** The model call within the turn, from 1. */
58
+ call: number;
59
+ reason: string;
60
+ }
61
+ /**
62
+ * The replay a run's records stand for. Each record whose root is a turn of the conversation's
63
+ * agent is a turn; a record of another agent's turn (an agent tool's) adds its calls to the turn
64
+ * before it. Records of anything else (a host's tool call, a decision) hold no turn.
65
+ */
66
+ export declare function replayScript(records: readonly TraceRecord[]): ReplayScript;
67
+ export interface Replay {
68
+ /** Answers each model call from the recording: pass as the runtime's `provider`. */
69
+ provider: (profile: Profile) => TurnModel;
70
+ /** Answers each tool from the recording, by name, in order: pass as the runtime's `toolHandlers`. */
71
+ toolHandlers: StudioToolHandlers;
72
+ /**
73
+ * Sets the turn the next calls belong to. A turn set again goes on where it stopped: a reply
74
+ * that waited on a gate resumes with the calls it had left.
75
+ */
76
+ startTurn(turn: number): void;
77
+ }
78
+ /**
79
+ * A replay of `script` for the agent `agent` (the conversation's agent, as the profile now names
80
+ * it). `onDiverge` hears where it stopped; the call that diverged then fails its turn.
81
+ */
82
+ export declare function createReplay(script: ReplayScript, agent: string, onDiverge: (divergence: ReplayDivergence) => void): Replay;
83
+ /**
84
+ * `tools` with every tool that reaches past the page answered by a handler instead: http, MCP,
85
+ * the page's own, and those that answer with a sample. An agent tool still runs its agent, whose
86
+ * model calls the replay answers too.
87
+ */
88
+ export declare function recordedTools(tools: readonly ToolRegistration[]): ToolRegistration[];
89
+ /** `payload` with its tools, and those of the agents it names, answered from the recording. */
90
+ export declare function recordedPayload(payload: StudioRunPayload): StudioRunPayload;
91
+ /** The conversation as the person saw it, every turn. */
92
+ export declare function recordedTranscript(script: ReplayScript): TranscriptBlock[];
93
+ /**
94
+ * The records a trace file holds: a JSON array of `TraceRecord`s, as the studio downloads them,
95
+ * or one record. Throws, saying why, for anything else.
96
+ */
97
+ export declare function parseTraceFile(text: string): TraceRecord[];
package/replay.js ADDED
@@ -0,0 +1,387 @@
1
+ /**
2
+ * A recorded conversation, run again through the profile as it is now. Every model call is
3
+ * answered from the recording, in order, and by default every tool's result too; the profile's
4
+ * guardrails, stages, gates and transcript run for real. When the profile asks for something the
5
+ * recording does not hold, the replay stops there and says why (a divergence).
6
+ *
7
+ * The recording is a run's `TraceRecord`s: one per turn, with its model calls (`chat` spans) and
8
+ * tool calls (`execute_tool` spans). A replay needs no keys: it reaches no model.
9
+ *
10
+ * @module
11
+ */
12
+ import { TheoremError, traceRecordSchema } from '@theoremjs/agents';
13
+ import { foldConversationTurn } from '@theoremjs/agents/interface';
14
+ import { inlineContent, } from '@theoremjs/agents/observability';
15
+ const isRecord = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
16
+ const attribute = (span, name) => span.attributes[name];
17
+ const stringAttribute = (span, name) => {
18
+ const value = attribute(span, name);
19
+ return typeof value === 'string' ? value : undefined;
20
+ };
21
+ /** A stored attribute, its content put back. */
22
+ function content(record, span, name) {
23
+ const value = attribute(span, name);
24
+ return value === undefined ? undefined : inlineContent(record, value);
25
+ }
26
+ /** A stored attribute as text: its content, or its JSON. */
27
+ function contentText(record, span, name) {
28
+ const value = content(record, span, name);
29
+ if (value === undefined)
30
+ return '';
31
+ return typeof value === 'string' ? value : JSON.stringify(value);
32
+ }
33
+ function messagesOf(record, span, name) {
34
+ const value = content(record, span, name);
35
+ return Array.isArray(value) ? value.filter(isRecord) : [];
36
+ }
37
+ const partsOf = (message) => Array.isArray(message.parts) ? message.parts.filter(isRecord) : [];
38
+ const startOf = (span) => BigInt(String(span.startTimeUnixNano));
39
+ const byStart = (a, b) => {
40
+ const order = startOf(a) - startOf(b);
41
+ return order === 0n ? 0 : order < 0n ? -1 : 1;
42
+ };
43
+ /** A part whose content the record does not hold keeps its hash. */
44
+ const unrecorded = (part) => 'content_sha256' in part || 'json_sha256' in part;
45
+ /** A tool call's arguments, as the model sent them. */
46
+ function argumentsOf(part) {
47
+ const raw = part.arguments;
48
+ if (isRecord(raw))
49
+ return raw;
50
+ if (typeof raw !== 'string')
51
+ return {};
52
+ try {
53
+ const parsed = JSON.parse(raw);
54
+ return isRecord(parsed) ? parsed : {};
55
+ }
56
+ catch {
57
+ return {};
58
+ }
59
+ }
60
+ /** The text or visible thought a recorded part says, if it says any. */
61
+ function saidOf(part) {
62
+ if (typeof part.content !== 'string')
63
+ return undefined;
64
+ if (part.type === 'reasoning')
65
+ return { type: 'thought', text: part.content };
66
+ if (part.type === 'text')
67
+ return { type: 'text', text: part.content };
68
+ return undefined;
69
+ }
70
+ /** The events one recorded reply stands for, in the order the model wrote them. */
71
+ function replyEvents(messages) {
72
+ const events = [];
73
+ const calls = [];
74
+ let missing = false;
75
+ for (const message of messages) {
76
+ const parts = partsOf(message);
77
+ const hasText = parts.some((part) => part.type === 'text');
78
+ for (const part of parts) {
79
+ const said = saidOf(part);
80
+ if (unrecorded(part))
81
+ missing = true;
82
+ else if (said)
83
+ events.push(said);
84
+ else if (part.type === 'structured' && !hasText)
85
+ events.push({ type: 'text', text: JSON.stringify(part.content) });
86
+ else if (part.type === 'tool_call' && typeof part.name === 'string') {
87
+ calls.push(part.name);
88
+ events.push({
89
+ type: 'tool',
90
+ tool: {
91
+ name: part.name,
92
+ callId: typeof part.id === 'string' ? part.id : `replay-${String(calls.length)}`,
93
+ arguments: argumentsOf(part),
94
+ },
95
+ });
96
+ }
97
+ else if (part.type === 'blob' || part.type === 'uri')
98
+ missing = true;
99
+ }
100
+ }
101
+ return { events, calls, missing };
102
+ }
103
+ /** The agent whose turn `span` is part of: the nearest `invoke_agent` above it. */
104
+ function agentOf(span, spans) {
105
+ for (let at = span; at; at = spans.get(at.parentSpanId ?? '')) {
106
+ const name = stringAttribute(at, 'gen_ai.agent.name');
107
+ if (stringAttribute(at, 'gen_ai.operation.name') === 'invoke_agent' && name)
108
+ return name;
109
+ }
110
+ return '';
111
+ }
112
+ const operation = (span) => stringAttribute(span, 'gen_ai.operation.name');
113
+ /** The model calls a record holds, in order, each with its agent and ending as it was recorded to. */
114
+ function recordCalls(record) {
115
+ const spans = new Map(record.spans.map((span) => [span.spanId, span]));
116
+ return record.spans
117
+ .filter((span) => operation(span) === 'chat')
118
+ .toSorted(byStart)
119
+ .map((span) => {
120
+ const reply = replyEvents(messagesOf(record, span, 'gen_ai.output.messages'));
121
+ const kind = stringAttribute(span, 'theorem.stop.kind') ?? 'completed';
122
+ return {
123
+ agent: agentOf(span, spans),
124
+ ...reply,
125
+ events: [...reply.events, { type: 'done', stop: { kind } }],
126
+ };
127
+ });
128
+ }
129
+ /** The tool results a record holds, in order. */
130
+ function recordResults(record) {
131
+ return record.spans
132
+ .filter((span) => operation(span) === 'execute_tool')
133
+ .toSorted(byStart)
134
+ .map((span) => ({
135
+ name: stringAttribute(span, 'gen_ai.tool.name') ?? '',
136
+ result: contentText(record, span, 'gen_ai.tool.call.result'),
137
+ failed: stringAttribute(span, 'theorem.tool.outcome') === 'error',
138
+ }));
139
+ }
140
+ /** What the person sent: the turn's last user message. */
141
+ function userMessage(record, root) {
142
+ const user = messagesOf(record, root, 'gen_ai.input.messages').findLast((message) => message.role === 'user');
143
+ const parts = user ? partsOf(user) : [];
144
+ return {
145
+ text: parts
146
+ .flatMap((part) => part.type === 'text' && typeof part.content === 'string' ? [part.content] : [])
147
+ .join('\n'),
148
+ attachments: parts.filter((part) => part.type === 'blob' || part.type === 'uri').length,
149
+ };
150
+ }
151
+ /** The events the person was shown in a turn, from what it delivered and the tools it ran. */
152
+ function shownEvents(record, root) {
153
+ const tools = record.spans.filter((span) => operation(span) === 'execute_tool').toSorted(byStart);
154
+ const used = new Set();
155
+ /** The tool span for a call: by its id, else the next one by its name. */
156
+ const toolSpan = (callId, name) => {
157
+ const span = tools.find((each) => stringAttribute(each, 'gen_ai.tool.call.id') === callId) ??
158
+ tools.find((each) => !used.has(each) && stringAttribute(each, 'gen_ai.tool.name') === name);
159
+ if (span)
160
+ used.add(span);
161
+ return span;
162
+ };
163
+ const events = [];
164
+ for (const message of messagesOf(record, root, 'gen_ai.output.messages')) {
165
+ for (const part of partsOf(message)) {
166
+ const said = saidOf(part);
167
+ if (said)
168
+ events.push(said);
169
+ else if (part.type === 'tool_call' && typeof part.name === 'string') {
170
+ const callId = typeof part.id === 'string' ? part.id : `call-${String(events.length)}`;
171
+ events.push(...toolEvents(record, part.name, callId, argumentsOf(part), toolSpan));
172
+ }
173
+ }
174
+ }
175
+ const failure = contentText(record, root, 'theorem.error.public');
176
+ if (root.status.code === 'ERROR')
177
+ events.push({ type: 'error', errorKind: 'internal', error: failure || root.status.message });
178
+ const kind = stringAttribute(root, 'theorem.stop.kind') ?? 'completed';
179
+ events.push({ type: 'done', stop: { kind } });
180
+ return events;
181
+ }
182
+ /** A tool call and how it ended, as the transcript shows it. */
183
+ function toolEvents(record, name, callId, args, toolSpan) {
184
+ const span = toolSpan(callId, name);
185
+ const made = { type: 'tool', tool: { name, callId, arguments: args } };
186
+ if (!span)
187
+ return [made];
188
+ const at = Number(startOf(span) / 1000000n);
189
+ const result = contentText(record, span, 'gen_ai.tool.call.result');
190
+ const failed = stringAttribute(span, 'theorem.tool.outcome') === 'error';
191
+ const settled = failed
192
+ ? {
193
+ phase: 'error',
194
+ failure: {
195
+ code: stringAttribute(span, 'theorem.tool.failure.code') ?? 'failed',
196
+ kind: 'internal',
197
+ message: result,
198
+ },
199
+ }
200
+ : { phase: 'complete', output: parsed(result) };
201
+ return [made, { type: 'tool', tool: { name, callId, at, ...settled } }];
202
+ }
203
+ function parsed(text) {
204
+ try {
205
+ return JSON.parse(text);
206
+ }
207
+ catch {
208
+ return text;
209
+ }
210
+ }
211
+ const rootOf = (record) => record.spans.find((span) => !span.parentSpanId);
212
+ /**
213
+ * The replay a run's records stand for. Each record whose root is a turn of the conversation's
214
+ * agent is a turn; a record of another agent's turn (an agent tool's) adds its calls to the turn
215
+ * before it. Records of anything else (a host's tool call, a decision) hold no turn.
216
+ */
217
+ export function replayScript(records) {
218
+ let agent = '';
219
+ let recordedAt;
220
+ const turns = [];
221
+ records.forEach((record, index) => {
222
+ const root = rootOf(record);
223
+ if (!root || operation(root) !== 'invoke_agent')
224
+ return;
225
+ const name = stringAttribute(root, 'gen_ai.agent.name') ?? '';
226
+ agent ||= name;
227
+ const calls = recordCalls(record);
228
+ const results = recordResults(record);
229
+ const last = turns.at(-1);
230
+ if (name !== agent && last) {
231
+ last.calls.push(...calls);
232
+ last.results.push(...results);
233
+ return;
234
+ }
235
+ recordedAt ??= Number(startOf(root) / 1000000n);
236
+ const sent = userMessage(record, root);
237
+ turns.push({
238
+ ...sent,
239
+ calls,
240
+ results,
241
+ blocks: foldConversationTurn({ text: sent.text }, shownEvents(record, root), {
242
+ idPrefix: `recorded-${String(index)}`,
243
+ }),
244
+ });
245
+ });
246
+ return { agent, turns, ...(recordedAt === undefined ? {} : { recordedAt }) };
247
+ }
248
+ /** A recorded result as a function tool returns it: its JSON object, else its text. */
249
+ function resultOutput(result) {
250
+ if (result.failed)
251
+ throw new Error(result.result);
252
+ const value = parsed(result.result);
253
+ return isRecord(value) ? value : { result: value };
254
+ }
255
+ /** Ordinal wording for a call's place in its turn. */
256
+ const nth = (n) => {
257
+ const tens = n % 100;
258
+ const suffix = tens >= 11 && tens <= 13 ? 'th' : ({ 1: 'st', 2: 'nd', 3: 'rd' }[n % 10] ?? 'th');
259
+ return `${String(n)}${suffix}`;
260
+ };
261
+ /** The divergence, if `call` cannot answer `request`. */
262
+ function divergence(call, recorded, request) {
263
+ if (!call)
264
+ return `The profile asked the model a ${nth(recorded + 1)} time this turn; the recording answered ${String(recorded)}.`;
265
+ if (call.missing)
266
+ return 'The recording does not hold all of this reply.';
267
+ const offered = new Set((request.wireTools ?? []).map((tool) => tool.name));
268
+ const gone = call.calls.find((name) => !offered.has(name));
269
+ return gone && `The recording calls ${gone}, which the profile no longer offers.`;
270
+ }
271
+ /**
272
+ * A replay of `script` for the agent `agent` (the conversation's agent, as the profile now names
273
+ * it). `onDiverge` hears where it stopped; the call that diverged then fails its turn.
274
+ */
275
+ export function createReplay(script, agent, onDiverge) {
276
+ let turn = 0;
277
+ /** How often each agent has asked in each turn, by `turn agent`. */
278
+ const asked = new Map();
279
+ /** Each turn's results not yet given, by tool name. */
280
+ const pending = new Map();
281
+ const resultsOf = (index) => {
282
+ let results = pending.get(index);
283
+ if (!results) {
284
+ results = new Map();
285
+ for (const result of script.turns[index]?.results ?? [])
286
+ results.set(result.name, [...(results.get(result.name) ?? []), result]);
287
+ pending.set(index, results);
288
+ }
289
+ return results;
290
+ };
291
+ const model = (name) => ({
292
+ // why: a generator, so a divergence fails the call the engine is reading, not the one before.
293
+ async *complete(request) {
294
+ const calls = script.turns[turn]?.calls.filter((call) => call.agent === name) ?? [];
295
+ const key = `${String(turn)} ${name}`;
296
+ const index = asked.get(key) ?? 0;
297
+ asked.set(key, index + 1);
298
+ const reason = divergence(calls[index], calls.length, request);
299
+ if (reason) {
300
+ onDiverge({ turn, call: index + 1, reason });
301
+ throw new TheoremError('request', `Replay stopped: ${reason}`); // labels-exempt: builder diagnostic
302
+ }
303
+ yield* calls[index]?.events ?? [];
304
+ },
305
+ });
306
+ const handler = (name) => () => {
307
+ const next = resultsOf(turn).get(name)?.shift();
308
+ if (!next)
309
+ throw new Error(`The recording has no further result for ${name}.`);
310
+ return resultOutput(next);
311
+ };
312
+ const names = new Set(script.turns.flatMap((each) => each.results.map((result) => result.name)));
313
+ const toolHandlers = Object.fromEntries([...names].map((name) => [name, handler(name)]));
314
+ return {
315
+ provider: (profile) => model(profile.id === agent ? script.agent : profile.id),
316
+ toolHandlers,
317
+ startTurn: (index) => {
318
+ turn = index;
319
+ },
320
+ };
321
+ }
322
+ /**
323
+ * `tools` with every tool that reaches past the page answered by a handler instead: http, MCP,
324
+ * the page's own, and those that answer with a sample. An agent tool still runs its agent, whose
325
+ * model calls the replay answers too.
326
+ */
327
+ export function recordedTools(tools) {
328
+ return tools.map((tool) => {
329
+ if (tool.type === 'agent')
330
+ return tool;
331
+ const { name, description, category, access, permission, loadTier, paths } = tool;
332
+ return {
333
+ type: 'function',
334
+ name,
335
+ description,
336
+ category,
337
+ access,
338
+ permission,
339
+ loadTier,
340
+ paths,
341
+ inputSchema: tool.inputSchema,
342
+ outputSchema: tool.outputSchema,
343
+ ...(tool.labels ? { labels: tool.labels } : {}),
344
+ };
345
+ });
346
+ }
347
+ /** `payload` with its tools, and those of the agents it names, answered from the recording. */
348
+ export function recordedPayload(payload) {
349
+ return {
350
+ ...payload,
351
+ customTools: recordedTools(payload.customTools),
352
+ ...(payload.dependencies
353
+ ? {
354
+ dependencies: payload.dependencies.map((dependency) => ({
355
+ ...dependency,
356
+ customTools: recordedTools(dependency.customTools),
357
+ })),
358
+ }
359
+ : {}),
360
+ };
361
+ }
362
+ /** The conversation as the person saw it, every turn. */
363
+ export function recordedTranscript(script) {
364
+ return script.turns.flatMap((turn) => turn.blocks);
365
+ }
366
+ /**
367
+ * The records a trace file holds: a JSON array of `TraceRecord`s, as the studio downloads them,
368
+ * or one record. Throws, saying why, for anything else.
369
+ */
370
+ export function parseTraceFile(text) {
371
+ let value;
372
+ try {
373
+ value = JSON.parse(text);
374
+ }
375
+ catch {
376
+ throw new TheoremError('request', 'This file is not JSON.'); // labels-exempt: builder diagnostic
377
+ }
378
+ const records = Array.isArray(value) ? value : [value];
379
+ return records.map((record, index) => {
380
+ const parsed = traceRecordSchema.safeParse(record);
381
+ if (parsed.success)
382
+ return parsed.data;
383
+ const where = records.length > 1 ? ` (record ${String(index + 1)})` : '';
384
+ // labels-exempt: builder diagnostic
385
+ throw new TheoremError('request', `This file is not a Theorem trace${where}.`);
386
+ });
387
+ }
@@ -13,7 +13,7 @@
13
13
  */
14
14
  import { TheoremError, z } from '@theoremjs/agents';
15
15
  import { clientError } from '@theoremjs/agents/host';
16
- import { checkRequest, createMemoryPageToolBroker, createMemorySessionStore, createMemorySteerInbox, createSealedConversationStore, createTheoremSessionBoundary, pageToolResultSchema, readBody, steerUnitOf, theoremConversationRequestSchema, theoremHostCallRequestSchema, theoremInvokeRequestSchema, theoremRewindRequestSchema, theoremSteerRequestSchema, theoremTurnRequestSchema, } from '@theoremjs/web/server';
16
+ import { checkRequest, createMemoryPageToolBroker, createMemorySessionStore, createMemorySteerInbox, createSealedConversationStore, createTheoremSessionBoundary, pageToolResultSchema, readBody, steerUnitOf, theoremConversationRequestSchema, theoremHostCallRequestSchema, theoremInvokeRequestSchema, theoremRewindRequestSchema, theoremStartRequestSchema, theoremSteerRequestSchema, theoremTurnRequestSchema, } from '@theoremjs/web/server';
17
17
  import { newConversationKey } from "../conversation-key.js";
18
18
  import { rewindStudio, startStudioConversation, streamStudioCall, streamStudioHostInvoke, streamStudioInvoke, streamStudioTurn, studioConversation, } from "../runtime.js";
19
19
  import { draftDecide } from "./draft-decide.js";
@@ -130,6 +130,7 @@ export function createStudioDraftHandler(options = {}) {
130
130
  }));
131
131
  }),
132
132
  conversation: (request) => draftRequest(request, async (draft, boundary) => Response.json(await studioConversation(draftRun(lent, draft, boundary, request), conversationsOf(boundary), checkRequest(theoremConversationRequestSchema, draft, 'request body')))),
133
+ 'conversation/start': (request) => draftRequest(request, async (draft, boundary) => Response.json(await startStudioConversation(draftRun(lent, draft, boundary, request), conversationsOf(boundary), checkRequest(theoremStartRequestSchema, draft, 'request body').blocks))),
133
134
  rewind: (request) => draftRequest(request, async (draft, boundary) => Response.json(await rewindStudio(draftRun(lent, draft, boundary, request), conversationsOf(boundary), checkRequest(theoremRewindRequestSchema, draft, 'request body'), request.signal))),
134
135
  call: (request) => draftRequest(request, async (draft, boundary) => {
135
136
  const call = checkRequest(theoremHostCallRequestSchema, draft, 'request body');
package/transport.d.ts CHANGED
@@ -5,7 +5,7 @@
5
5
  * `createTheoremHandler`).
6
6
  */
7
7
  import { type TurnEvent } from '@theoremjs/agents';
8
- import { type ProfileInterface } from '@theoremjs/agents/interface';
8
+ import { type ProfileInterface, type TranscriptBlock } from '@theoremjs/agents/interface';
9
9
  import { type ChatLineSink, type ConversationLine, type DecisionTransport, type EmbeddingTransport, type HostTransport, type HttpOptions, type MalformedEvent, type TheoremTransport, type TraceFeed, type TurnEventSink, type UnsupportedEvent } from '@theoremjs/web/client';
10
10
  import { z } from 'zod';
11
11
  import { type StudioRunPayload } from './run-payload.ts';
@@ -43,6 +43,10 @@ export type StudioTransportOptions = HttpOptions & {
43
43
  };
44
44
  export declare function createStudioEmbeddingTransport(payload: StudioRunPayload, options?: StudioTransportOptions): EmbeddingTransport;
45
45
  export declare function createStudioTransport(payload: StudioRunPayload, options?: StudioTransportOptions): TheoremTransport;
46
+ /** A conversation with the draft that opens on `blocks`, kept in this visitor's session: its ref and view. */
47
+ export declare function startStudioConversation(payload: StudioRunPayload, blocks: readonly TranscriptBlock[], options?: HttpOptions & {
48
+ signal?: AbortSignal;
49
+ }): Promise<import("@theoremjs/web/client").TheoremRewound>;
46
50
  /**
47
51
  * A decision draft's transport: the page describes the profile itself, and every decision posts
48
52
  * the draft's profile and questions beside the state, since the studio user authors both.
package/transport.js CHANGED
@@ -5,9 +5,9 @@
5
5
  * `createTheoremHandler`).
6
6
  */
7
7
  import { defineProfile, TheoremError, TURN_EVENT_SCHEMAS, traceRecordSchema, } from '@theoremjs/agents';
8
- import { interfaceFromProfile } from '@theoremjs/agents/interface';
8
+ import { interfaceFromProfile, } from '@theoremjs/agents/interface';
9
9
  import { createToolRegistry } from '@theoremjs/agents/kernel';
10
- import { CHAT_LINES, createTraceFeed, decisionAssets, decisionInterface, embeddingInterface, fetchConversation, fetchRewound, hostInterface, postDecision, postEmbedding, postJson, postNdjson, } from '@theoremjs/web/client';
10
+ import { CHAT_LINES, createTraceFeed, decisionAssets, decisionInterface, embeddingInterface, fetchConversation, fetchRewound, fetchStarted, hostInterface, postDecision, postEmbedding, postJson, postNdjson, } from '@theoremjs/web/client';
11
11
  import { z } from 'zod';
12
12
  import { uncompiled } from "./compile.js";
13
13
  import { clearStudioRunPayload, keptStudioRunIds, loadStudioRunPayload, } from "./run-payload.js";
@@ -143,6 +143,11 @@ export function createStudioTransport(payload, options = {}) {
143
143
  traces,
144
144
  };
145
145
  }
146
+ /** A conversation with the draft that opens on `blocks`, kept in this visitor's session: its ref and view. */
147
+ export function startStudioConversation(payload, blocks, options = {}) {
148
+ const { profile, customTools, structured, dependencies } = payload;
149
+ return fetchStarted('/api/studio/conversation/start', { profile, customTools, structured, dependencies, blocks }, options);
150
+ }
146
151
  /**
147
152
  * A decision draft's transport: the page describes the profile itself, and every decision posts
148
153
  * the draft's profile and questions beside the state, since the studio user authors both.
@@ -72,7 +72,7 @@ export declare const LocalConnection: import("react").Context<Pick<{
72
72
  ids: string[];
73
73
  error?: string;
74
74
  };
75
- mode: "local" | "demo" | "byok";
75
+ mode: "local" | "byok" | "demo";
76
76
  on: StudioRunsOn;
77
77
  runtime: import("../browser-transport.ts").StudioBrowserRuntime | null;
78
78
  vault: Readonly<Record<string, string | undefined>>;
@@ -0,0 +1,7 @@
1
+ import type { TheoremChatHandle } from '@theoremjs/react/ui';
2
+ import type { RefObject } from 'react';
3
+ /**
4
+ * Sends `text` once the chat takes it: a chat just mounted takes nothing until it has described
5
+ * its agent. Null when it took nothing in `ms`, or `signal` aborted.
6
+ */
7
+ export declare function sendWhenReady(chat: RefObject<TheoremChatHandle | null>, text: string, signal: AbortSignal, ms?: number): Promise<import("@theoremjs/web/client").SentTurn | null>;
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Sends `text` once the chat takes it: a chat just mounted takes nothing until it has described
3
+ * its agent. Null when it took nothing in `ms`, or `signal` aborted.
4
+ */
5
+ export async function sendWhenReady(chat, text, signal, ms = 10_000) {
6
+ const until = Date.now() + ms;
7
+ while (!signal.aborted && Date.now() < until) {
8
+ const sent = await chat.current?.send(text);
9
+ if (sent)
10
+ return sent;
11
+ await new Promise((resolve) => requestAnimationFrame(resolve));
12
+ }
13
+ return null;
14
+ }
@@ -20,7 +20,7 @@ export declare function useStudioConnection(models: readonly (ModelRoute & {
20
20
  entries: KeyEntry[];
21
21
  setEntries: Dispatch<SetStateAction<KeyEntry[]>>;
22
22
  localModels: LocalModels;
23
- mode: "local" | "demo" | "byok";
23
+ mode: "local" | "byok" | "demo";
24
24
  on: StudioRunsOn;
25
25
  runtime: StudioBrowserRuntime | null;
26
26
  vault: Readonly<Record<string, string | undefined>>;
@@ -1,4 +1,5 @@
1
1
  import { TheoremChat } from '@theoremjs/react/ui';
2
+ import { type TraceFeed } from '@theoremjs/web/client';
2
3
  import { type ComponentProps } from 'react';
3
4
  import { type StudioBrowserRuntime } from '../browser.ts';
4
5
  import { type StudioConnectionMode, type StudioRunPayload, type StudioWorkspace } from '../mod.ts';
@@ -34,6 +35,8 @@ export interface StudioRunnerProps {
34
35
  * answers twice while it holds edits the files do not: as the files, and as the edits.
35
36
  */
36
37
  tested?: StudioWorkspace;
38
+ /** The feed its trace records land in, when the page reads them too; else its own. */
39
+ traces?: TraceFeed;
37
40
  }
38
41
  /**
39
42
  * The builder preview and existing run page select transports under the same runner UI.
@@ -41,5 +44,5 @@ export interface StudioRunnerProps {
41
44
  * A runner is one conversation. The chat keeps its transcript when an edit recompiles the draft,
42
45
  * so its trace feed outlives each transport too; remount the runner (a new `key`) to start over.
43
46
  */
44
- export declare function StudioRunner({ payload, mode, runtime, trace, className, flush, columns, onActivity, conversation, conversationView, initialText, onConversationChange, chatRef, slots, context, tested, }: StudioRunnerProps): import("react").JSX.Element;
47
+ export declare function StudioRunner({ payload, mode, runtime, trace, className, flush, columns, onActivity, conversation, conversationView, initialText, onConversationChange, chatRef, slots, context, tested, traces: given, }: StudioRunnerProps): import("react").JSX.Element;
45
48
  export type ChatProps = ComponentProps<typeof TheoremChat>;
@@ -43,8 +43,9 @@ function RunLoading() {
43
43
  * A runner is one conversation. The chat keeps its transcript when an edit recompiles the draft,
44
44
  * so its trace feed outlives each transport too; remount the runner (a new `key`) to start over.
45
45
  */
46
- export function StudioRunner({ payload, mode, runtime, trace, className, flush, columns, onActivity, conversation, conversationView, initialText, onConversationChange, chatRef, slots, context, tested, }) {
47
- const [traces] = useState(createTraceFeed);
46
+ export function StudioRunner({ payload, mode, runtime, trace, className, flush, columns, onActivity, conversation, conversationView, initialText, onConversationChange, chatRef, slots, context, tested, traces: given, }) {
47
+ const [own] = useState(createTraceFeed);
48
+ const traces = given ?? own;
48
49
  const activity = useRef(onActivity);
49
50
  activity.current = onActivity;
50
51
  const [note] = useState(() => () => activity.current?.());