@czap/astro 0.1.0

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 (159) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +19 -0
  3. package/dist/Satellite.d.ts +42 -0
  4. package/dist/Satellite.d.ts.map +1 -0
  5. package/dist/Satellite.js +55 -0
  6. package/dist/Satellite.js.map +1 -0
  7. package/dist/client-directives/gpu.d.ts +3 -0
  8. package/dist/client-directives/gpu.d.ts.map +1 -0
  9. package/dist/client-directives/gpu.js +5 -0
  10. package/dist/client-directives/gpu.js.map +1 -0
  11. package/dist/client-directives/llm.d.ts +3 -0
  12. package/dist/client-directives/llm.d.ts.map +1 -0
  13. package/dist/client-directives/llm.js +5 -0
  14. package/dist/client-directives/llm.js.map +1 -0
  15. package/dist/client-directives/satellite.d.ts +3 -0
  16. package/dist/client-directives/satellite.d.ts.map +1 -0
  17. package/dist/client-directives/satellite.js +5 -0
  18. package/dist/client-directives/satellite.js.map +1 -0
  19. package/dist/client-directives/stream.d.ts +3 -0
  20. package/dist/client-directives/stream.d.ts.map +1 -0
  21. package/dist/client-directives/stream.js +5 -0
  22. package/dist/client-directives/stream.js.map +1 -0
  23. package/dist/client-directives/wasm.d.ts +3 -0
  24. package/dist/client-directives/wasm.d.ts.map +1 -0
  25. package/dist/client-directives/wasm.js +6 -0
  26. package/dist/client-directives/wasm.js.map +1 -0
  27. package/dist/client-directives/worker.d.ts +3 -0
  28. package/dist/client-directives/worker.d.ts.map +1 -0
  29. package/dist/client-directives/worker.js +5 -0
  30. package/dist/client-directives/worker.js.map +1 -0
  31. package/dist/detect-upgrade.d.ts +16 -0
  32. package/dist/detect-upgrade.d.ts.map +1 -0
  33. package/dist/detect-upgrade.js +105 -0
  34. package/dist/detect-upgrade.js.map +1 -0
  35. package/dist/headers.d.ts +45 -0
  36. package/dist/headers.d.ts.map +1 -0
  37. package/dist/headers.js +64 -0
  38. package/dist/headers.js.map +1 -0
  39. package/dist/index.d.ts +30 -0
  40. package/dist/index.d.ts.map +1 -0
  41. package/dist/index.js +26 -0
  42. package/dist/index.js.map +1 -0
  43. package/dist/integration.d.ts +76 -0
  44. package/dist/integration.d.ts.map +1 -0
  45. package/dist/integration.js +240 -0
  46. package/dist/integration.js.map +1 -0
  47. package/dist/middleware.d.ts +69 -0
  48. package/dist/middleware.d.ts.map +1 -0
  49. package/dist/middleware.js +75 -0
  50. package/dist/middleware.js.map +1 -0
  51. package/dist/quantize.d.ts +50 -0
  52. package/dist/quantize.d.ts.map +1 -0
  53. package/dist/quantize.js +122 -0
  54. package/dist/quantize.js.map +1 -0
  55. package/dist/runtime/boundary.d.ts +123 -0
  56. package/dist/runtime/boundary.d.ts.map +1 -0
  57. package/dist/runtime/boundary.js +164 -0
  58. package/dist/runtime/boundary.js.map +1 -0
  59. package/dist/runtime/globals.d.ts +32 -0
  60. package/dist/runtime/globals.d.ts.map +1 -0
  61. package/dist/runtime/globals.js +45 -0
  62. package/dist/runtime/globals.js.map +1 -0
  63. package/dist/runtime/gpu.d.ts +15 -0
  64. package/dist/runtime/gpu.d.ts.map +1 -0
  65. package/dist/runtime/gpu.js +266 -0
  66. package/dist/runtime/gpu.js.map +1 -0
  67. package/dist/runtime/index.d.ts +7 -0
  68. package/dist/runtime/index.d.ts.map +1 -0
  69. package/dist/runtime/index.js +5 -0
  70. package/dist/runtime/index.js.map +1 -0
  71. package/dist/runtime/llm-receipt-tracker.d.ts +21 -0
  72. package/dist/runtime/llm-receipt-tracker.d.ts.map +1 -0
  73. package/dist/runtime/llm-receipt-tracker.js +60 -0
  74. package/dist/runtime/llm-receipt-tracker.js.map +1 -0
  75. package/dist/runtime/llm-render-pipeline.d.ts +89 -0
  76. package/dist/runtime/llm-render-pipeline.d.ts.map +1 -0
  77. package/dist/runtime/llm-render-pipeline.js +241 -0
  78. package/dist/runtime/llm-render-pipeline.js.map +1 -0
  79. package/dist/runtime/llm-session.d.ts +126 -0
  80. package/dist/runtime/llm-session.d.ts.map +1 -0
  81. package/dist/runtime/llm-session.js +385 -0
  82. package/dist/runtime/llm-session.js.map +1 -0
  83. package/dist/runtime/llm.d.ts +16 -0
  84. package/dist/runtime/llm.d.ts.map +1 -0
  85. package/dist/runtime/llm.js +273 -0
  86. package/dist/runtime/llm.js.map +1 -0
  87. package/dist/runtime/policy.d.ts +100 -0
  88. package/dist/runtime/policy.d.ts.map +1 -0
  89. package/dist/runtime/policy.js +147 -0
  90. package/dist/runtime/policy.js.map +1 -0
  91. package/dist/runtime/receipt-chain.d.ts +22 -0
  92. package/dist/runtime/receipt-chain.d.ts.map +1 -0
  93. package/dist/runtime/receipt-chain.js +80 -0
  94. package/dist/runtime/receipt-chain.js.map +1 -0
  95. package/dist/runtime/runtime-session.d.ts +34 -0
  96. package/dist/runtime/runtime-session.d.ts.map +1 -0
  97. package/dist/runtime/runtime-session.js +102 -0
  98. package/dist/runtime/runtime-session.js.map +1 -0
  99. package/dist/runtime/satellite.d.ts +13 -0
  100. package/dist/runtime/satellite.d.ts.map +1 -0
  101. package/dist/runtime/satellite.js +59 -0
  102. package/dist/runtime/satellite.js.map +1 -0
  103. package/dist/runtime/slots.d.ts +34 -0
  104. package/dist/runtime/slots.d.ts.map +1 -0
  105. package/dist/runtime/slots.js +108 -0
  106. package/dist/runtime/slots.js.map +1 -0
  107. package/dist/runtime/stream-session.d.ts +47 -0
  108. package/dist/runtime/stream-session.d.ts.map +1 -0
  109. package/dist/runtime/stream-session.js +82 -0
  110. package/dist/runtime/stream-session.js.map +1 -0
  111. package/dist/runtime/stream.d.ts +9 -0
  112. package/dist/runtime/stream.d.ts.map +1 -0
  113. package/dist/runtime/stream.js +308 -0
  114. package/dist/runtime/stream.js.map +1 -0
  115. package/dist/runtime/url-policy.d.ts +28 -0
  116. package/dist/runtime/url-policy.d.ts.map +1 -0
  117. package/dist/runtime/url-policy.js +87 -0
  118. package/dist/runtime/url-policy.js.map +1 -0
  119. package/dist/runtime/wasm.d.ts +20 -0
  120. package/dist/runtime/wasm.d.ts.map +1 -0
  121. package/dist/runtime/wasm.js +70 -0
  122. package/dist/runtime/wasm.js.map +1 -0
  123. package/dist/runtime/worker.d.ts +11 -0
  124. package/dist/runtime/worker.d.ts.map +1 -0
  125. package/dist/runtime/worker.js +249 -0
  126. package/dist/runtime/worker.js.map +1 -0
  127. package/package.json +106 -0
  128. package/src/Satellite.astro +39 -0
  129. package/src/Satellite.ts +84 -0
  130. package/src/client-directives/gpu.ts +5 -0
  131. package/src/client-directives/llm.ts +5 -0
  132. package/src/client-directives/satellite.ts +5 -0
  133. package/src/client-directives/stream.ts +5 -0
  134. package/src/client-directives/wasm.ts +6 -0
  135. package/src/client-directives/worker.ts +5 -0
  136. package/src/detect-upgrade.ts +105 -0
  137. package/src/headers.ts +84 -0
  138. package/src/index.ts +30 -0
  139. package/src/integration.ts +309 -0
  140. package/src/middleware.ts +133 -0
  141. package/src/quantize.ts +173 -0
  142. package/src/runtime/boundary.ts +263 -0
  143. package/src/runtime/globals.ts +57 -0
  144. package/src/runtime/gpu.ts +291 -0
  145. package/src/runtime/index.ts +12 -0
  146. package/src/runtime/llm-receipt-tracker.ts +88 -0
  147. package/src/runtime/llm-render-pipeline.ts +366 -0
  148. package/src/runtime/llm-session.ts +548 -0
  149. package/src/runtime/llm.ts +344 -0
  150. package/src/runtime/policy.ts +229 -0
  151. package/src/runtime/receipt-chain.ts +106 -0
  152. package/src/runtime/runtime-session.ts +139 -0
  153. package/src/runtime/satellite.ts +80 -0
  154. package/src/runtime/slots.ts +136 -0
  155. package/src/runtime/stream-session.ts +125 -0
  156. package/src/runtime/stream.ts +407 -0
  157. package/src/runtime/url-policy.ts +107 -0
  158. package/src/runtime/wasm.ts +85 -0
  159. package/src/runtime/worker.ts +307 -0
@@ -0,0 +1,548 @@
1
+ import type { Receipt, UIFrame } from '@czap/core';
2
+ import {
3
+ LLMChunkNormalization,
4
+ createHtmlFragment,
5
+ type HtmlPolicy,
6
+ type LLMChunk,
7
+ type ToolCallAccumulator,
8
+ } from '@czap/web';
9
+ import { createLLMRenderPipeline, type LLMRenderPipeline } from './llm-render-pipeline.js';
10
+ import { createLLMReceiptTracker, type LLMReceiptTracker } from './llm-receipt-tracker.js';
11
+ import type { RuntimeSessionState } from './runtime-session.js';
12
+
13
+ type DeviceTier = 'none' | 'transitions' | 'animations' | 'physics' | 'compute';
14
+
15
+ /**
16
+ * Config accepted by {@link createLLMSession}. Drives a DOM-bound LLM
17
+ * session: `element` is the root the `client:llm` directive attaches
18
+ * to, `target` is where text is appended, and `mode` selects render
19
+ * strategy.
20
+ */
21
+ export interface LLMSessionConfig {
22
+ /** Host element (directive root). Receives `czap:llm-*` events. */
23
+ readonly element: HTMLElement;
24
+ /** Text-sink element (typically a child of `element`). */
25
+ readonly target: HTMLElement;
26
+ /** Render mode label forwarded to the pipeline. */
27
+ readonly mode: string;
28
+ /** Device-tier getter used by the quality controller. */
29
+ readonly getDeviceTier: () => DeviceTier;
30
+ /** HTML trust policy governing text-sink writes. Defaults to `text`. */
31
+ readonly htmlPolicy?: HtmlPolicy;
32
+ /** Opt-in to `trusted-html` (pairs with `htmlPolicy`). */
33
+ readonly allowTrustedHtml?: boolean;
34
+ }
35
+
36
+ /**
37
+ * Controller surface of an LLM session. Tracks runtime state, ingests
38
+ * chunks from a stream adapter, and releases resources on
39
+ * {@link LLMSessionShape.dispose}.
40
+ */
41
+ export interface LLMSessionShape {
42
+ /** Current session state (`idle` / `active` / `reconnecting` / `disposed`). */
43
+ readonly state: RuntimeSessionState;
44
+ /** Transition from idle to active. */
45
+ activate(): void;
46
+ /** Enter the reconnecting state so incoming chunks are gated. */
47
+ beginReconnect(): void;
48
+ /** Consume one chunk; returns `done` on stream end. */
49
+ ingest(chunk: LLMChunk): 'continue' | 'done';
50
+ /** Replay receipts after a gap; returns the chosen strategy type. */
51
+ replayGap(): { readonly type: string };
52
+ /** Remember a server-emitted receipt envelope for later replay. */
53
+ rememberEnvelope(envelope: Receipt.Envelope): void;
54
+ /** Reset accumulated state; optionally re-bind the target element. */
55
+ reset(target?: HTMLElement): void;
56
+ /** Terminate the session and release pooled runtimes. */
57
+ dispose(): void;
58
+ }
59
+
60
+ /**
61
+ * Pluggable callbacks a session uses to render text/frames and emit
62
+ * observability events. Implementations either write to the DOM
63
+ * ({@link createDOMLLMSessionHost}) or funnel data into external
64
+ * observers ({@link createSupportLLMSessionHost}).
65
+ */
66
+ export interface LLMSessionHost {
67
+ setTarget(target?: HTMLElement): void;
68
+ renderText(text: string, accumulated: string, mode: string): boolean;
69
+ renderFrame(frame: UIFrame, accumulated: string, mode: string): boolean;
70
+ emitToken(text: string, accumulated: string): void;
71
+ emitFrame(frame: UIFrame): void;
72
+ emitToolStart(name: string): void;
73
+ emitToolEnd(name: string, args: unknown): void;
74
+ emitDone(accumulated: string): void;
75
+ }
76
+
77
+ interface SupportLLMSessionHostHandlers {
78
+ readonly onToken?: (detail: { readonly text: string; readonly accumulated: string }) => void;
79
+ readonly onTokenValue?: (text: string, accumulated: string) => void;
80
+ readonly onFrame?: (frame: UIFrame) => void;
81
+ readonly onToolStart?: (detail: { readonly name: string }) => void;
82
+ readonly onToolStartValue?: (name: string) => void;
83
+ readonly onToolEnd?: (detail: { readonly name: string; readonly args: unknown }) => void;
84
+ readonly onToolEndValue?: (name: string, args: unknown) => void;
85
+ readonly onDone?: (detail: { readonly accumulated: string }) => void;
86
+ readonly onDoneValue?: (accumulated: string) => void;
87
+ }
88
+
89
+ const noopSetTarget = (): void => {};
90
+ const noopEmitToken = (): void => {};
91
+ const noopEmitFrame = (): void => {};
92
+ const noopEmitToolStart = (): void => {};
93
+ const noopEmitToolEnd = (): void => {};
94
+ const noopEmitDone = (): void => {};
95
+ const alwaysRenderText = (): boolean => true;
96
+ const renderFrameTokensOnly = (frame: UIFrame): boolean => frame.tokens.length > 0;
97
+
98
+ function appendText(target: HTMLElement, text: string): void {
99
+ if (typeof target.append === 'function') {
100
+ target.append(text);
101
+ return;
102
+ }
103
+
104
+ target.appendChild(document.createTextNode(text));
105
+ }
106
+
107
+ function replaceWithText(target: HTMLElement, text: string): void {
108
+ target.textContent = text;
109
+ }
110
+
111
+ function writeHtml(target: HTMLElement, html: string, htmlPolicy: HtmlPolicy, allowTrustedHtml: boolean): void {
112
+ // Route through createHtmlFragment so the assignment goes via the
113
+ // shared trust pipeline's Trusted-Types-aware `assignInnerHTML` helper
114
+ // rather than a raw `target.innerHTML = ...` (which throws under
115
+ // `require-trusted-types-for 'script'` enforcement). See SECURITY.md
116
+ // "CSP and Trusted Types" for the recipe.
117
+ const fragment = createHtmlFragment(html, {
118
+ policy: htmlPolicy,
119
+ allowTrustedHtml,
120
+ });
121
+ target.replaceChildren(fragment);
122
+ }
123
+
124
+ /**
125
+ * Build an {@link LLMSessionHost} that writes text/frames directly to
126
+ * the DOM and dispatches `czap:llm-*` custom events on `element`.
127
+ * Default host used by {@link createLLMSession}.
128
+ */
129
+ export function createDOMLLMSessionHost(
130
+ element: HTMLElement,
131
+ initialTarget: HTMLElement,
132
+ options?: { readonly htmlPolicy?: HtmlPolicy; readonly allowTrustedHtml?: boolean },
133
+ ): LLMSessionHost {
134
+ let currentTarget = initialTarget;
135
+ const htmlPolicy = options?.htmlPolicy ?? 'text';
136
+ const allowTrustedHtml = options?.allowTrustedHtml ?? false;
137
+
138
+ return {
139
+ setTarget(target) {
140
+ if (target) {
141
+ currentTarget = target;
142
+ }
143
+ },
144
+
145
+ renderText(text, accumulated, mode) {
146
+ if (htmlPolicy !== 'text') {
147
+ writeHtml(currentTarget, accumulated, htmlPolicy, allowTrustedHtml);
148
+ return true;
149
+ }
150
+
151
+ if (mode === 'append') {
152
+ appendText(currentTarget, text);
153
+ } else {
154
+ replaceWithText(currentTarget, accumulated);
155
+ }
156
+
157
+ return true;
158
+ },
159
+
160
+ renderFrame(frame, accumulated, mode) {
161
+ const text = frame.tokens.join('');
162
+ if (!text) {
163
+ return false;
164
+ }
165
+
166
+ if (htmlPolicy !== 'text') {
167
+ writeHtml(currentTarget, accumulated, htmlPolicy, allowTrustedHtml);
168
+ return true;
169
+ }
170
+
171
+ if (mode === 'append') {
172
+ appendText(currentTarget, text);
173
+ } else {
174
+ replaceWithText(currentTarget, accumulated);
175
+ }
176
+
177
+ return true;
178
+ },
179
+
180
+ emitToken(text, accumulated) {
181
+ element.dispatchEvent(
182
+ new CustomEvent('czap:llm-token', {
183
+ detail: { text, accumulated },
184
+ bubbles: true,
185
+ }),
186
+ );
187
+ },
188
+
189
+ emitFrame(frame) {
190
+ element.dispatchEvent(
191
+ new CustomEvent('czap:llm-frame', {
192
+ detail: frame,
193
+ bubbles: true,
194
+ }),
195
+ );
196
+ },
197
+
198
+ emitToolStart(name) {
199
+ element.dispatchEvent(
200
+ new CustomEvent('czap:llm-tool-start', {
201
+ detail: { name },
202
+ bubbles: true,
203
+ }),
204
+ );
205
+ },
206
+
207
+ emitToolEnd(name, args) {
208
+ element.dispatchEvent(
209
+ new CustomEvent('czap:llm-tool-end', {
210
+ detail: { name, args },
211
+ bubbles: true,
212
+ }),
213
+ );
214
+ },
215
+
216
+ emitDone(accumulated) {
217
+ element.dispatchEvent(
218
+ new CustomEvent('czap:llm-done', {
219
+ detail: { accumulated },
220
+ bubbles: true,
221
+ }),
222
+ );
223
+ },
224
+ };
225
+ }
226
+
227
+ /**
228
+ * Build an {@link LLMSessionHost} that forwards events to the given
229
+ * handler callbacks instead of touching the DOM. Useful for tests,
230
+ * benchmarks, and any caller that wants to observe LLM output
231
+ * programmatically.
232
+ */
233
+ export function createSupportLLMSessionHost(handlers?: SupportLLMSessionHostHandlers): LLMSessionHost {
234
+ const onToken = handlers?.onToken;
235
+ const onTokenValue = handlers?.onTokenValue;
236
+ const onFrame = handlers?.onFrame;
237
+ const onToolStart = handlers?.onToolStart;
238
+ const onToolStartValue = handlers?.onToolStartValue;
239
+ const onToolEnd = handlers?.onToolEnd;
240
+ const onToolEndValue = handlers?.onToolEndValue;
241
+ const onDone = handlers?.onDone;
242
+ const onDoneValue = handlers?.onDoneValue;
243
+
244
+ // The startup bench only needs the token boundary, so skip the generic
245
+ // support-host composition work when no other callbacks are present.
246
+ if (
247
+ onTokenValue &&
248
+ !onToken &&
249
+ !onFrame &&
250
+ !onToolStart &&
251
+ !onToolStartValue &&
252
+ !onToolEnd &&
253
+ !onToolEndValue &&
254
+ !onDone &&
255
+ !onDoneValue
256
+ ) {
257
+ return {
258
+ setTarget: noopSetTarget,
259
+ renderText: alwaysRenderText,
260
+ renderFrame: renderFrameTokensOnly,
261
+ emitToken: onTokenValue,
262
+ emitFrame: noopEmitFrame,
263
+ emitToolStart: noopEmitToolStart,
264
+ emitToolEnd: noopEmitToolEnd,
265
+ emitDone: noopEmitDone,
266
+ };
267
+ }
268
+
269
+ const emitToken =
270
+ onTokenValue && onToken
271
+ ? (text: string, accumulated: string): void => {
272
+ onTokenValue(text, accumulated);
273
+ onToken({ text, accumulated });
274
+ }
275
+ : onTokenValue
276
+ ? onTokenValue
277
+ : onToken
278
+ ? (text: string, accumulated: string): void => {
279
+ onToken({ text, accumulated });
280
+ }
281
+ : undefined;
282
+ const emitFrame = onFrame ?? noopEmitFrame;
283
+ const emitToolStart =
284
+ onToolStartValue && onToolStart
285
+ ? (name: string): void => {
286
+ onToolStartValue(name);
287
+ onToolStart({ name });
288
+ }
289
+ : onToolStartValue
290
+ ? onToolStartValue
291
+ : onToolStart
292
+ ? (name: string): void => {
293
+ onToolStart({ name });
294
+ }
295
+ : noopEmitToolStart;
296
+ const emitToolEnd =
297
+ onToolEndValue && onToolEnd
298
+ ? (name: string, args: unknown): void => {
299
+ onToolEndValue(name, args);
300
+ onToolEnd({ name, args });
301
+ }
302
+ : onToolEndValue
303
+ ? onToolEndValue
304
+ : onToolEnd
305
+ ? (name: string, args: unknown): void => {
306
+ onToolEnd({ name, args });
307
+ }
308
+ : noopEmitToolEnd;
309
+ const emitDone =
310
+ onDoneValue && onDone
311
+ ? (accumulated: string): void => {
312
+ onDoneValue(accumulated);
313
+ onDone({ accumulated });
314
+ }
315
+ : onDoneValue
316
+ ? onDoneValue
317
+ : onDone
318
+ ? (accumulated: string): void => {
319
+ onDone({ accumulated });
320
+ }
321
+ : noopEmitDone;
322
+
323
+ return {
324
+ setTarget: noopSetTarget,
325
+ renderText: alwaysRenderText,
326
+ renderFrame: renderFrameTokensOnly,
327
+ emitToken: emitToken ?? noopEmitToken,
328
+ emitFrame,
329
+ emitToolStart,
330
+ emitToolEnd,
331
+ emitDone,
332
+ };
333
+ }
334
+
335
+ /**
336
+ * Minimal {@link LLMSessionHost} that only surfaces the token
337
+ * boundary, skipping the branching composition in
338
+ * {@link createSupportLLMSessionHost}. Exposed for startup benchmarks
339
+ * that need the cheapest possible host.
340
+ */
341
+ export function createSupportLLMTokenBoundaryHost(
342
+ onTokenValue: (text: string, accumulated: string) => void,
343
+ ): LLMSessionHost {
344
+ return {
345
+ setTarget: noopSetTarget,
346
+ renderText: alwaysRenderText,
347
+ renderFrame: alwaysRenderText,
348
+ emitToken: onTokenValue,
349
+ emitFrame: noopEmitFrame,
350
+ emitToolStart: noopEmitToolStart,
351
+ emitToolEnd: noopEmitToolEnd,
352
+ emitDone: noopEmitDone,
353
+ };
354
+ }
355
+
356
+ class LLMSessionController implements LLMSessionShape {
357
+ private runtimeState: RuntimeSessionState = 'idle';
358
+ private currentTarget: HTMLElement | undefined;
359
+ private toolCallBuffer: ToolCallAccumulator = null;
360
+ private readonly pipeline: LLMRenderPipeline;
361
+ private readonly receiptTracker: LLMReceiptTracker;
362
+
363
+ constructor(
364
+ private readonly config: Pick<LLMSessionConfig, 'mode' | 'getDeviceTier'> & { readonly target?: HTMLElement },
365
+ private readonly host: LLMSessionHost,
366
+ ) {
367
+ this.currentTarget = config.target;
368
+ this.pipeline = createLLMRenderPipeline({ mode: config.mode, getDeviceTier: config.getDeviceTier });
369
+ this.receiptTracker = createLLMReceiptTracker();
370
+ }
371
+
372
+ get state(): RuntimeSessionState {
373
+ return this.runtimeState;
374
+ }
375
+
376
+ activate(): void {
377
+ if (!this.isDisposed()) {
378
+ this.runtimeState = 'active';
379
+ }
380
+ }
381
+
382
+ beginReconnect(): void {
383
+ if (!this.isDisposed()) {
384
+ this.runtimeState = 'reconnecting';
385
+ }
386
+ }
387
+
388
+ ingest(chunk: LLMChunk): 'continue' | 'done' {
389
+ if (this.isDisposed()) {
390
+ return 'done';
391
+ }
392
+
393
+ const recordFrame = (frame: UIFrame): void => this.receiptTracker.recordFrame(frame);
394
+
395
+ switch (chunk.type) {
396
+ case 'text': {
397
+ if (!chunk.content) {
398
+ return 'continue';
399
+ }
400
+
401
+ if (
402
+ this.pipeline.shouldUseFastLane(
403
+ this.toolCallBuffer === null,
404
+ this.receiptTracker.receiptChain === null,
405
+ this.runtimeState,
406
+ ) &&
407
+ this.pipeline.renderImmediateText(chunk.content, this.host)
408
+ ) {
409
+ this.pipeline.setFastLanePrimed(true);
410
+ this.host.emitToken(chunk.content, this.pipeline.accumulated);
411
+ return 'continue';
412
+ }
413
+
414
+ this.pipeline.pushText(chunk.content);
415
+ if (this.isDisposed()) {
416
+ this.pipeline.clearQueuedText();
417
+ return 'done';
418
+ }
419
+ if (!this.pipeline.canQueueRenderBurst()) {
420
+ this.pipeline.clearQueuedText();
421
+ return 'continue';
422
+ }
423
+
424
+ if (this.pipeline.fastLanePrimed && !this.pipeline.llmRuntime) {
425
+ this.pipeline.promoteFastLane();
426
+ }
427
+ this.pipeline.enqueueFlush(this.host, recordFrame);
428
+ return 'continue';
429
+ }
430
+
431
+ case 'tool-call-start': {
432
+ if (this.pipeline.flushQueued || this.pipeline.queuedTextFragments.length > 0) {
433
+ this.pipeline.flushPendingText(this.host, recordFrame);
434
+ }
435
+ const normalized = LLMChunkNormalization.normalize(chunk, this.toolCallBuffer);
436
+ this.toolCallBuffer = normalized.toolCallBuffer;
437
+ this.host.emitToolStart(normalized.chunk?.toolName ?? '');
438
+ return 'continue';
439
+ }
440
+
441
+ case 'tool-call-delta': {
442
+ if (this.pipeline.flushQueued || this.pipeline.queuedTextFragments.length > 0) {
443
+ this.pipeline.flushPendingText(this.host, recordFrame);
444
+ }
445
+ const normalized = LLMChunkNormalization.normalize(chunk, this.toolCallBuffer);
446
+ this.toolCallBuffer = normalized.toolCallBuffer;
447
+ return 'continue';
448
+ }
449
+
450
+ case 'tool-call-end': {
451
+ if (this.pipeline.flushQueued || this.pipeline.queuedTextFragments.length > 0) {
452
+ this.pipeline.flushPendingText(this.host, recordFrame);
453
+ }
454
+ if (!this.pipeline.llmRuntime) {
455
+ this.pipeline.promoteFastLane();
456
+ }
457
+ const normalized = LLMChunkNormalization.normalize(chunk, this.toolCallBuffer);
458
+ this.toolCallBuffer = normalized.toolCallBuffer;
459
+ const name = normalized.chunk?.toolName ?? '';
460
+ const args = normalized.chunk?.toolArgs;
461
+ this.host.emitToolEnd(name, args);
462
+ return 'continue';
463
+ }
464
+
465
+ case 'done':
466
+ this.pipeline.flushPendingText(this.host, recordFrame);
467
+ this.host.emitDone(this.pipeline.accumulated);
468
+ return 'done';
469
+ }
470
+ }
471
+
472
+ replayGap(): { readonly type: string } {
473
+ return this.receiptTracker.replayGap(this.pipeline, this.host);
474
+ }
475
+
476
+ rememberEnvelope(envelope: Receipt.Envelope): void {
477
+ if (this.isDisposed()) {
478
+ return;
479
+ }
480
+
481
+ this.receiptTracker.rememberEnvelope(envelope);
482
+ }
483
+
484
+ reset(target?: HTMLElement): void {
485
+ if (this.isDisposed()) {
486
+ return;
487
+ }
488
+
489
+ // Preserve 'reconnecting' state so that chunks arriving between reset() and
490
+ // activate() cannot re-engage the fast lane prematurely. The caller must
491
+ // explicitly call activate() to advance past the reconnecting gate.
492
+ if (this.runtimeState !== 'reconnecting') {
493
+ this.runtimeState = 'idle';
494
+ }
495
+ this.resetSession(target);
496
+ }
497
+
498
+ dispose(): void {
499
+ this.resetSession(this.currentTarget);
500
+ this.pipeline.releaseRuntime();
501
+ this.runtimeState = 'disposed';
502
+ }
503
+
504
+ private isDisposed(): boolean {
505
+ return this.runtimeState === 'disposed';
506
+ }
507
+
508
+ private resetSession(target = this.currentTarget): void {
509
+ this.currentTarget = target;
510
+ this.host.setTarget(target);
511
+ this.toolCallBuffer = null;
512
+ this.pipeline.resetPipelineState();
513
+ this.receiptTracker.reset();
514
+ }
515
+ }
516
+
517
+ /**
518
+ * Build an {@link LLMSessionShape} backed by a caller-supplied
519
+ * {@link LLMSessionHost}. Tests and bench harnesses prefer this
520
+ * variant over {@link createLLMSession} so they can observe output
521
+ * without a DOM.
522
+ */
523
+ export function createLLMSessionWithHost(
524
+ config: Pick<LLMSessionConfig, 'mode' | 'getDeviceTier'> & { readonly target?: HTMLElement },
525
+ host: LLMSessionHost,
526
+ ): LLMSessionShape {
527
+ return new LLMSessionController(config, host);
528
+ }
529
+
530
+ /**
531
+ * Default `client:llm` factory: builds a session wired to the DOM.
532
+ * Equivalent to composing {@link createDOMLLMSessionHost} with
533
+ * {@link createLLMSessionWithHost}.
534
+ */
535
+ export function createLLMSession(config: LLMSessionConfig): LLMSessionShape {
536
+ const domHost = createDOMLLMSessionHost(config.element, config.target, {
537
+ htmlPolicy: config.htmlPolicy,
538
+ allowTrustedHtml: config.allowTrustedHtml,
539
+ });
540
+ return createLLMSessionWithHost(
541
+ {
542
+ mode: config.mode,
543
+ getDeviceTier: config.getDeviceTier,
544
+ target: config.target,
545
+ },
546
+ domHost,
547
+ );
548
+ }