broapp 0.4.1 → 0.4.3

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "broapp",
3
- "version": "0.4.1",
3
+ "version": "0.4.3",
4
4
  "type": "module",
5
5
  "description": "Build tooling and runtime for local applications made of a Bun host, a browser UI, and a Brobridge connection between them",
6
6
  "license": "MIT",
@@ -49,9 +49,9 @@
49
49
  "./package.json": "./package.json"
50
50
  },
51
51
  "dependencies": {
52
- "@brobridgejs/client": "^0.2.1",
53
- "@brobridgejs/core": "^0.2.1",
54
- "brobridge": "^0.2.1"
52
+ "@brobridgejs/client": "^0.2.2",
53
+ "@brobridgejs/core": "^0.2.2",
54
+ "brobridge": "^0.2.2"
55
55
  },
56
56
  "peerDependencies": {
57
57
  "react": ">=18",
@@ -22,7 +22,7 @@ import { createPendingApprovals, createReservedHostApp } from '../../host/index.
22
22
  import type { HostApp, HostLogger, StreamSink } from '../../host/app.ts';
23
23
  import { publicError } from '../../shared/errors.ts';
24
24
  import { aiContract, type AiContract } from '../shared/contract.ts';
25
- import type { ProviderInfo } from '../shared/types.ts';
25
+ import type { ChatTurn, ProviderInfo } from '../shared/types.ts';
26
26
 
27
27
  import { AdapterError, toPublicError, type AdapterConfig, type ProviderAdapter } from './adapter.ts';
28
28
  import { createRegistry, type Registry } from './registry.ts';
@@ -64,6 +64,23 @@ export interface InProcessTurn {
64
64
  readonly message: string;
65
65
  /** The model for this turn, within the configured provider. */
66
66
  readonly modelId?: string;
67
+ /**
68
+ * Earlier turns, as a browser would send them. An assistant turn naming a
69
+ * run this layer kept a transcript for is expanded exactly as on `ai.chat`.
70
+ */
71
+ readonly history?: readonly ChatTurn[];
72
+ }
73
+
74
+ /** One question put to an in-process turn's stand-in. */
75
+ export interface InProcessQuestion {
76
+ readonly tool: string;
77
+ readonly input: unknown;
78
+ /** The approval table's key, `<runId>:<callId>`. */
79
+ readonly requestId: string;
80
+ /** The call the question is about, as `ai.chatConfirm` names it with the run id. */
81
+ readonly callId: string;
82
+ /** When the question stops waiting, when the gate said. */
83
+ readonly expiresAt?: number;
67
84
  }
68
85
 
69
86
  /** How {@link Ai.turn} is answered and stopped. */
@@ -73,8 +90,14 @@ export interface InProcessTurnOptions {
73
90
  *
74
91
  * The caller is the person's stand-in, so it decides exactly as the person
75
92
  * would have: the gate still asks, and still records the answer.
93
+ *
94
+ * `'defer'` answers nothing. The question stays in the approval table for
95
+ * somebody else to answer by its request id, over `ai.chatConfirm` as a
96
+ * person in a chat would, and the gate's own window still ends it. That is
97
+ * for a stand-in that answers some questions itself and brings the rest to a
98
+ * person: whoever answers, each question is answered once.
76
99
  */
77
- readonly answer: (question: { readonly tool: string; readonly input: unknown }) => boolean;
100
+ readonly answer: (question: InProcessQuestion) => boolean | 'defer';
78
101
  /** Aborting it cancels the turn, as a browser's cancel would. */
79
102
  readonly signal?: AbortSignal;
80
103
  readonly onEvent?: (event: ChatEvent) => void;
@@ -291,6 +314,14 @@ export function createAi(options: CreateAiOptions): Ai {
291
314
  }
292
315
  });
293
316
 
317
+ // Opened on the first conversation route and not before: an application
318
+ // whose user never opens the panel should not find a database in its data
319
+ // directory, and `createAi` is built unconditionally by every application
320
+ // that offers AI at all. A turn opens it too, to keep its transcript.
321
+ let threads: ThreadStore | null = null;
322
+ const threadStore = (): ThreadStore =>
323
+ (threads ??= openThreads(options.dataDir, options.logger === undefined ? {} : { logger: options.logger }));
324
+
294
325
  const approvals = createPendingApprovals(options.logger);
295
326
  const runDeps: RunDeps = {
296
327
  registry,
@@ -303,6 +334,12 @@ export function createAi(options: CreateAiOptions): Ai {
303
334
  approvals,
304
335
  ...(options.onRunEnd === undefined ? {} : { onRunEnd: options.onRunEnd }),
305
336
  ...(options.onContext === undefined ? {} : { onContext: options.onContext }),
337
+ transcripts: {
338
+ save: (runId, messages) => {
339
+ threadStore().saveTranscript(runId, messages);
340
+ },
341
+ read: (runId) => threadStore().transcript(runId),
342
+ },
306
343
  logger: options.logger ?? console,
307
344
  };
308
345
 
@@ -315,13 +352,6 @@ export function createAi(options: CreateAiOptions): Ai {
315
352
  approvals.answer({ requestId: `${runId}:${callId}`, approved: approve }) === 'accepted',
316
353
  }));
317
354
 
318
- // Opened on the first conversation route and not before: an application
319
- // whose user never opens the panel should not find a database in its data
320
- // directory, and `createAi` is built unconditionally by every application
321
- // that offers AI at all.
322
- let threads: ThreadStore | null = null;
323
- const threadStore = (): ThreadStore => (threads ??= openThreads(options.dataDir));
324
-
325
355
  host.operation('ai.threadsList', () => ({ threads: threadStore().list() }));
326
356
  host.operation('ai.threadsCreate', (input) => threadStore().create(input));
327
357
  host.operation('ai.threadsGet', ({ id }) => threadStore().get(id));
@@ -386,7 +416,14 @@ export function createAi(options: CreateAiOptions): Ai {
386
416
  // The gate's request id is `<runId>:<callId>`, which is what an
387
417
  // event without one would have named.
388
418
  const requestId = event.requestId ?? `${turn.runId}:${event.callId}`;
389
- void settle(requestId, turnOptions.answer({ tool: event.tool ?? '', input: event.input }));
419
+ const answer = turnOptions.answer({
420
+ tool: event.tool ?? '',
421
+ input: event.input,
422
+ requestId,
423
+ callId: event.callId ?? '',
424
+ ...(event.expiresAt === undefined ? {} : { expiresAt: event.expiresAt }),
425
+ });
426
+ if (answer !== 'defer') void settle(requestId, answer);
390
427
  }
391
428
  return Promise.resolve();
392
429
  },
@@ -400,7 +437,7 @@ export function createAi(options: CreateAiOptions): Ai {
400
437
  runId: turn.runId,
401
438
  message: turn.message,
402
439
  refs: [],
403
- history: [],
440
+ history: turn.history === undefined ? [] : [...turn.history],
404
441
  ...(turn.modelId === undefined ? {} : { modelId: turn.modelId }),
405
442
  },
406
443
  sink,
@@ -12,6 +12,7 @@ export type {
12
12
  AiAppDescription,
13
13
  CreateAiOptions,
14
14
  DeliveredContext,
15
+ InProcessQuestion,
15
16
  InProcessTurn,
16
17
  InProcessTurnOptions,
17
18
  InProcessTurnResult,
@@ -22,6 +22,7 @@ import type { ApprovalQuestion, Approver } from '../../host/gate.ts';
22
22
  import type { Effect } from '../../shared/contract.ts';
23
23
  import { fromTransportError, isPublicError, publicError } from '../../shared/errors.ts';
24
24
  import type { ToolPermission } from '../shared/types.ts';
25
+ import type { ChatTurn } from '../shared/types.ts';
25
26
  import type { ChatEvent, StreamChatParams } from './run-types.ts';
26
27
 
27
28
  import { AdapterError } from './adapter.ts';
@@ -29,6 +30,18 @@ import type { AdapterConfig, ProviderAdapter } from './adapter.ts';
29
30
  import type { Registry } from './registry.ts';
30
31
  import type { AiContextProviders, AiTool, ContextDocument } from './tool.ts';
31
32
  import type { DeliveredContext, RunEndDetail } from './create-ai.ts';
33
+ import type { ResponseMessage } from './threads.ts';
34
+
35
+ /**
36
+ * Where a turn's own transcript is kept and read back.
37
+ *
38
+ * Both sides are the host's: `save` is handed what the AI SDK produced, and
39
+ * `read` returns only what `save` wrote. A browser never supplies either.
40
+ */
41
+ export interface RunTranscripts {
42
+ save(runId: string, messages: readonly ResponseMessage[]): void;
43
+ read(runId: string): readonly ResponseMessage[] | null;
44
+ }
32
45
 
33
46
  /** What the run loop needs from the `Ai` that owns it. */
34
47
  export interface RunDeps {
@@ -62,6 +75,11 @@ export interface RunDeps {
62
75
  ) => void;
63
76
  /** Called once per turn with what the model is about to be given. */
64
77
  readonly onContext?: (runId: string, delivered: DeliveredContext) => void;
78
+ /**
79
+ * The turn transcripts. Absent, every history turn is text and nothing is
80
+ * written, which is exactly the layer before transcripts existed.
81
+ */
82
+ readonly transcripts?: RunTranscripts;
65
83
  }
66
84
 
67
85
  /** What a turn counts as it goes, for {@link RunEndDetail}. */
@@ -217,6 +235,128 @@ async function assembleContext(
217
235
  return fitToBudget(documents, deps.contextBudgetChars);
218
236
  }
219
237
 
238
+ /** The bounds on history expanded from transcripts. */
239
+ export interface HistoryLimits {
240
+ /** Assistant turns expanded, newest first. */
241
+ readonly turns: number;
242
+ /** Characters of one tool call's input, as JSON. */
243
+ readonly inputChars: number;
244
+ /** Characters of one tool result's output, as JSON. */
245
+ readonly outputChars: number;
246
+ /** Characters of every expanded message together, as JSON. */
247
+ readonly totalChars: number;
248
+ }
249
+
250
+ /** The bounds a turn uses. */
251
+ export const HISTORY_LIMITS: HistoryLimits = {
252
+ turns: 6,
253
+ inputChars: 1_000,
254
+ outputChars: 2_000,
255
+ totalChars: 60_000,
256
+ };
257
+
258
+ /** The head of a long string, and how much was left out. Never a summary. */
259
+ function cut(text: string, max: number): string {
260
+ return text.length <= max ? text : `${text.slice(0, max)}<omitted ${String(text.length - max)} chars>`;
261
+ }
262
+
263
+ function isRecord(value: unknown): value is Record<string, unknown> {
264
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
265
+ }
266
+
267
+ /**
268
+ * A tool result's output, bounded.
269
+ *
270
+ * An `error` is kept whole wherever it is — an error-typed output, or the
271
+ * `{ error }` a failed tool returns — because it is the one thing a model
272
+ * needs verbatim to avoid repeating the call that caused it.
273
+ */
274
+ function boundOutput(output: unknown, max: number): unknown {
275
+ if (!isRecord(output)) return output;
276
+ const type = output['type'];
277
+ if (type === 'error-text' || type === 'error-json' || type === 'execution-denied') return output;
278
+ if (type === 'text' && typeof output['value'] === 'string') {
279
+ return { type: 'text', value: cut(output['value'], max) };
280
+ }
281
+ const json = JSON.stringify(type === 'json' ? output['value'] : output) ?? '';
282
+ if (json.length <= max) return output;
283
+ const value = type === 'json' ? output['value'] : undefined;
284
+ if (isRecord(value) && 'error' in value) {
285
+ const { error, ...rest } = value;
286
+ return { type: 'json', value: { error, rest: cut(JSON.stringify(rest), max) } };
287
+ }
288
+ return { type: 'text', value: cut(json, max) };
289
+ }
290
+
291
+ /**
292
+ * One transcript message with every tool input and output bounded.
293
+ *
294
+ * A cut input stays an object, `{ truncated: "<head><omitted N chars>" }`,
295
+ * rather than becoming the string itself: an OpenAI-compatible provider sends
296
+ * a call's input as its `arguments`, and Ollama refuses a whole request whose
297
+ * arguments are not a JSON object ("invalid tool call arguments"). The first
298
+ * 12j evaluation lost a turn to exactly that.
299
+ */
300
+ function boundMessage(message: ResponseMessage, limits: HistoryLimits): ModelMessage {
301
+ if (!Array.isArray(message.content)) return message;
302
+ const content = (message.content as readonly unknown[]).map((part) => {
303
+ if (!isRecord(part)) return part;
304
+ if (part['type'] === 'tool-call') {
305
+ const json = JSON.stringify(part['input']) ?? '';
306
+ return json.length <= limits.inputChars ? part : { ...part, input: { truncated: cut(json, limits.inputChars) } };
307
+ }
308
+ if (part['type'] === 'tool-result') return { ...part, output: boundOutput(part['output'], limits.outputChars) };
309
+ return part;
310
+ });
311
+ return { ...message, content } as ModelMessage;
312
+ }
313
+
314
+ /**
315
+ * History as the model is given it.
316
+ *
317
+ * Walking from the newest turn back, an assistant turn whose `runId` names a
318
+ * transcript the host holds is replaced by that transcript — its own tool calls
319
+ * and results, bounded — while fewer than `limits.turns` have been and the total
320
+ * stays under `limits.totalChars`. The first turn that would cross the total
321
+ * stays text, and so does every turn older than it. Every other turn is its text,
322
+ * exactly as before. User turns keep their place.
323
+ */
324
+ export function expandHistory(
325
+ history: readonly ChatTurn[],
326
+ read: (runId: string) => readonly ResponseMessage[] | null,
327
+ limits: HistoryLimits = HISTORY_LIMITS,
328
+ ): ModelMessage[] {
329
+ const segments: ModelMessage[][] = [];
330
+ let expanded = 0;
331
+ let total = 0;
332
+ let full = false;
333
+ for (let index = history.length - 1; index >= 0; index -= 1) {
334
+ const turn = history[index];
335
+ if (turn === undefined) continue;
336
+ const text: ModelMessage[] = [{ role: turn.role, content: turn.content }];
337
+ if (turn.role !== 'assistant' || turn.runId === undefined || full || expanded >= limits.turns) {
338
+ segments.push(text);
339
+ continue;
340
+ }
341
+ const transcript = read(turn.runId);
342
+ if (transcript === null || transcript.length === 0) {
343
+ segments.push(text);
344
+ continue;
345
+ }
346
+ const bounded = transcript.map((message) => boundMessage(message, limits));
347
+ const chars = JSON.stringify(bounded).length;
348
+ if (total + chars > limits.totalChars) {
349
+ full = true;
350
+ segments.push(text);
351
+ continue;
352
+ }
353
+ total += chars;
354
+ expanded += 1;
355
+ segments.push(bounded);
356
+ }
357
+ return segments.reverse().flat();
358
+ }
359
+
220
360
  /**
221
361
  * A message the model may see, without whatever a caller invented.
222
362
  *
@@ -224,13 +364,21 @@ async function assembleContext(
224
364
  * are strings by contract, so an earlier turn's picture is already a
225
365
  * `[image: name]` line the browser put there — the alternative, resending
226
366
  * every image on every turn, would cost the user the same upload again on each
227
- * question.
367
+ * question. An assistant turn that names its run is expanded from the host's
368
+ * own transcript by {@link expandHistory}.
228
369
  */
229
- function toModelMessages(params: StreamChatParams): ModelMessage[] {
230
- const messages: ModelMessage[] = params.history.map((turn) => ({
231
- role: turn.role,
232
- content: turn.content,
233
- }));
370
+ function toModelMessages(params: StreamChatParams, transcripts: RunTranscripts | undefined, logger: HostLogger): ModelMessage[] {
371
+ const read = (runId: string): readonly ResponseMessage[] | null => {
372
+ if (transcripts === undefined) return null;
373
+ try {
374
+ return transcripts.read(runId);
375
+ } catch (cause) {
376
+ // A store that cannot be read has no transcript to give; the turn is text.
377
+ logger.error(`[broapp] ai could not read the transcript of run ${runId}: ${String(cause instanceof Error ? cause.message : cause)}`);
378
+ return null;
379
+ }
380
+ };
381
+ const messages: ModelMessage[] = expandHistory(params.history, read);
234
382
  const files = params.files ?? [];
235
383
  if (files.length === 0) {
236
384
  messages.push({ role: 'user', content: params.message });
@@ -366,6 +514,7 @@ function buildTools(
366
514
  deps: RunDeps,
367
515
  sink: StreamSink<ChatEvent>,
368
516
  approver: Approver,
517
+ recorder: TranscriptRecorder,
369
518
  ): ToolSet {
370
519
  const tools: ToolSet = {};
371
520
  for (const [name, definition] of Object.entries(deps.tools)) {
@@ -374,6 +523,7 @@ function buildTools(
374
523
  inputSchema: jsonSchema(definition.inputSchema),
375
524
  execute: async (input: unknown, options: { toolCallId: string }): Promise<unknown> => {
376
525
  const callId = options.toolCallId;
526
+ recorder.called(callId, name, input);
377
527
  await sink.emit({
378
528
  type: 'tool-call',
379
529
  callId,
@@ -409,12 +559,14 @@ function buildTools(
409
559
  output: DECLINED,
410
560
  denied: true,
411
561
  });
562
+ recorder.answered(callId, DECLINED);
412
563
  return DECLINED;
413
564
  }
414
565
  // One tool failing is not the turn failing. The model gets the
415
566
  // reason and can carry on or explain.
416
567
  output = { error: safeToolMessage(cause, name, deps.logger) };
417
568
  }
569
+ recorder.answered(callId, output);
418
570
  await sink.emit({ type: 'tool-result', callId, tool: name, output });
419
571
  return output;
420
572
  },
@@ -442,6 +594,59 @@ function safeToolMessage(cause: unknown, name: string, logger: HostLogger): stri
442
594
  return 'The tool failed.';
443
595
  }
444
596
 
597
+ /**
598
+ * What a turn has produced so far, for a turn that does not reach `finish`.
599
+ *
600
+ * A finished turn's transcript is the AI SDK's own `responseMessages`. A turn
601
+ * that is stopped never gets one, and a stopped turn is exactly the one whose
602
+ * "continue" needs it most; so the steps the SDK finished are kept as it gave
603
+ * them, and the step in flight is kept from its text and from the calls this
604
+ * layer ran. Storage removes a call that never got its result.
605
+ */
606
+ class TranscriptRecorder {
607
+ /** Set once the model has been asked; before that there is nothing to keep. */
608
+ started = false;
609
+ private readonly finished: ResponseMessage[] = [];
610
+ private text = '';
611
+ private calls = new Map<string, { name: string; input: unknown; output?: { value: unknown } }>();
612
+
613
+ stepEnded(messages: readonly ResponseMessage[]): void {
614
+ this.finished.push(...messages);
615
+ this.text = '';
616
+ this.calls = new Map();
617
+ }
618
+
619
+ wrote(text: string): void {
620
+ this.text += text;
621
+ }
622
+
623
+ called(callId: string, name: string, input: unknown): void {
624
+ this.calls.set(callId, { name, input });
625
+ }
626
+
627
+ answered(callId: string, output: unknown): void {
628
+ const call = this.calls.get(callId);
629
+ if (call !== undefined) call.output = { value: output };
630
+ }
631
+
632
+ /** Every finished step, then the step in flight. */
633
+ sofar(): ResponseMessage[] {
634
+ const assistant: Array<{ type: 'text'; text: string } | { type: 'tool-call'; toolCallId: string; toolName: string; input: unknown }> = [];
635
+ if (this.text !== '') assistant.push({ type: 'text', text: this.text });
636
+ const results: Array<{ type: 'tool-result'; toolCallId: string; toolName: string; output: { type: 'json'; value: never } }> = [];
637
+ for (const [toolCallId, call] of this.calls) {
638
+ assistant.push({ type: 'tool-call', toolCallId, toolName: call.name, input: call.input });
639
+ if (call.output !== undefined) {
640
+ results.push({ type: 'tool-result', toolCallId, toolName: call.name, output: { type: 'json', value: call.output.value as never } });
641
+ }
642
+ }
643
+ const out = [...this.finished];
644
+ if (assistant.length > 0) out.push({ role: 'assistant', content: assistant });
645
+ if (results.length > 0) out.push({ role: 'tool', content: results });
646
+ return out;
647
+ }
648
+ }
649
+
445
650
  /** How much of the person's message stands in for the whole turn. */
446
651
  const SUMMARY_CHARS = 200;
447
652
 
@@ -457,6 +662,18 @@ export async function runChat(
457
662
  let ended = false;
458
663
  const started = Date.now();
459
664
  const tally: TurnTally = { steps: 0 };
665
+ const recorder = new TranscriptRecorder();
666
+ const transcript = new TranscriptWriter(params.runId, deps);
667
+ // A turn that never reaches `finish` — stopped, failed, or cut off by the
668
+ // provider — still leaves what it did. A stop is written the moment it
669
+ // happens rather than when the loop notices: the AI SDK waits for a running
670
+ // tool before it ends the stream, and what the turn did is what had come back
671
+ // when the person stopped it, which is also what the browser was shown.
672
+ // Written once: a finished turn has already written the SDK's own messages.
673
+ const keepSoFar = (): void => {
674
+ if (recorder.started) transcript.write(recorder.sofar());
675
+ };
676
+ sink.signal.addEventListener('abort', keepSoFar, { once: true });
460
677
  const end = (status: 'succeeded' | 'failed' | 'cancelled'): void => {
461
678
  if (ended) return;
462
679
  ended = true;
@@ -472,11 +689,43 @@ export async function runChat(
472
689
  );
473
690
  };
474
691
  try {
475
- await runTurn(params, sink, deps, end, tally);
692
+ await runTurn(params, sink, deps, end, tally, recorder, transcript);
476
693
  end(sink.signal.aborted ? 'cancelled' : 'succeeded');
477
694
  } catch (cause) {
478
695
  end(sink.signal.aborted ? 'cancelled' : 'failed');
479
696
  throw cause;
697
+ } finally {
698
+ sink.signal.removeEventListener('abort', keepSoFar);
699
+ keepSoFar();
700
+ }
701
+ }
702
+
703
+ /**
704
+ * Writes one turn's transcript, once, without ever failing the turn.
705
+ *
706
+ * A store that cannot write loses the transcript, not the turn: the error is
707
+ * logged, `done` still goes out, and the next turn's history for this run is
708
+ * its text.
709
+ */
710
+ class TranscriptWriter {
711
+ private written = false;
712
+
713
+ constructor(
714
+ private readonly runId: string,
715
+ private readonly deps: RunDeps,
716
+ ) {}
717
+
718
+ write(messages: readonly ResponseMessage[]): void {
719
+ const transcripts = this.deps.transcripts;
720
+ if (this.written || transcripts === undefined) return;
721
+ this.written = true;
722
+ try {
723
+ transcripts.save(this.runId, messages);
724
+ } catch (cause) {
725
+ this.deps.logger.error(
726
+ `[broapp] ai could not keep the transcript of run ${this.runId}: ${String(cause instanceof Error ? cause.message : cause)}`,
727
+ );
728
+ }
480
729
  }
481
730
  }
482
731
 
@@ -487,6 +736,8 @@ async function runTurn(
487
736
  deps: RunDeps,
488
737
  end: (status: 'succeeded' | 'failed' | 'cancelled') => void,
489
738
  tally: TurnTally,
739
+ recorder: TranscriptRecorder,
740
+ transcript: TranscriptWriter,
490
741
  ): Promise<void> {
491
742
  // Throws a PublicError when nothing is configured. `runStream` in host/app.ts
492
743
  // turns that into the right thing on the wire, so it is not caught here.
@@ -535,19 +786,29 @@ async function runTurn(
535
786
  );
536
787
  }
537
788
 
789
+ // From here the model has been asked, so there is a transcript to keep
790
+ // however the turn ends. A turn refused before this point has none.
791
+ recorder.started = true;
538
792
  const result = streamText({
539
793
  // Always a model *instance*. A string here would be resolved by the AI
540
794
  // SDK's gateway, over the global fetch, to a Vercel host — see
541
795
  // reports/01-spike.md. Nothing in this layer may pass one.
542
796
  model: resolved.adapter.model(resolved.config, resolved.modelId),
543
797
  system,
544
- messages: toModelMessages(params),
545
- tools: buildTools(params, deps, sink, approver),
798
+ messages: toModelMessages(params, deps.transcripts, deps.logger),
799
+ tools: buildTools(params, deps, sink, approver, recorder),
546
800
  stopWhen: stepCountIs(deps.maxSteps),
547
801
  abortSignal: sink.signal,
548
802
  // The default handler prints the error; this layer reports it as an event
549
803
  // and decides for itself what is safe to say.
550
804
  onError: () => undefined,
805
+ // Both from the SDK's own pipeline rather than from the loop below, so a
806
+ // step's text and the step's end are seen in the order they happened even
807
+ // when the loop is behind, waiting on a slow socket.
808
+ onChunk: ({ chunk }) => {
809
+ if (chunk.type === 'text-delta') recorder.wrote(chunk.text);
810
+ },
811
+ onStepEnd: (step) => recorder.stepEnded(step.response.messages),
551
812
  });
552
813
 
553
814
  for await (const part of result.fullStream) {
@@ -569,6 +830,9 @@ async function runTurn(
569
830
  outputTokens: part.totalUsage.outputTokens ?? 0,
570
831
  };
571
832
  tally.usage = usage;
833
+ // Before `done`: a client that saves the conversation on `done` and
834
+ // sends it back at once must find the run's transcript already there.
835
+ transcript.write(await result.responseMessages);
572
836
  await sink.emit({ type: 'usage', ...usage });
573
837
  await sink.emit({ type: 'done' });
574
838
  break;
@@ -14,6 +14,10 @@ import { Database } from 'bun:sqlite';
14
14
  import { mkdirSync } from 'node:fs';
15
15
  import { join } from 'node:path';
16
16
 
17
+ import type { AssistantModelMessage, ToolModelMessage } from 'ai';
18
+
19
+ import type { HostLogger } from '../../host/app.ts';
20
+ import { canonicalJson } from '../../host/gate.ts';
17
21
  import { publicError } from '../../shared/errors.ts';
18
22
  import type { StoredMessage, Thread } from '../shared/types.ts';
19
23
 
@@ -58,8 +62,40 @@ const MIGRATIONS: readonly string[] = [
58
62
  OR (earlier.updated_at = threads.updated_at AND earlier.rowid <= threads.rowid)
59
63
  );
60
64
  CREATE INDEX threads_seq ON threads (seq DESC);`,
65
+ /*
66
+ * What the model itself saw and did on each turn, by run id.
67
+ *
68
+ * Written by the host from the AI SDK's own response messages, never from
69
+ * anything a browser saved: a history turn that names a run gets these back
70
+ * in place of its text. Not tied to a thread, because the host does not know
71
+ * which conversation a run belongs to; the age and count caps are the bound.
72
+ */
73
+ `CREATE TABLE transcripts (
74
+ run_id TEXT PRIMARY KEY,
75
+ messages TEXT NOT NULL,
76
+ chars INTEGER NOT NULL,
77
+ created_at INTEGER NOT NULL
78
+ );
79
+ CREATE INDEX transcripts_created_at ON transcripts (created_at);`,
61
80
  ];
62
81
 
82
+ /**
83
+ * One message a turn produced: what `StreamTextResult.responseMessages` holds.
84
+ *
85
+ * `ai` declares `ResponseMessage` as exactly this union but does not export the
86
+ * name, so it is spelled out here from the two types it does export.
87
+ */
88
+ export type ResponseMessage = AssistantModelMessage | ToolModelMessage;
89
+
90
+ /** The most characters one transcript may be; a longer turn stays text. */
91
+ export const MAX_TRANSCRIPT_CHARS = 200_000;
92
+
93
+ /** How long a transcript is kept. */
94
+ const TRANSCRIPT_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000;
95
+
96
+ /** How many transcripts are kept, newest first. */
97
+ const TRANSCRIPT_MAX_COUNT = 2_000;
98
+
63
99
  /** What a conversation is called until it has been named. */
64
100
  export const DEFAULT_THREAD_TITLE = 'New conversation';
65
101
 
@@ -100,9 +136,104 @@ export interface ThreadStore {
100
136
  remove(id: string): boolean;
101
137
  /** Every conversation. Returns how many were deleted. */
102
138
  clear(): number;
139
+ /**
140
+ * Keep one turn's response messages under its run id.
141
+ *
142
+ * Unpaired tool calls are removed first, provider metadata is dropped, and a
143
+ * transcript over {@link MAX_TRANSCRIPT_CHARS} is not written. Returns whether
144
+ * a row was written. Throws when the store cannot write.
145
+ */
146
+ saveTranscript(runId: string, messages: readonly ResponseMessage[]): boolean;
147
+ /** A transcript this store wrote, or null when it holds none that reads back whole. */
148
+ transcript(runId: string): readonly ResponseMessage[] | null;
149
+ /** Delete transcripts older than 30 days or beyond the newest 2,000. Returns how many went. */
150
+ retainTranscripts(): number;
103
151
  close(): void;
104
152
  }
105
153
 
154
+ /** Options for {@link openThreads}. */
155
+ export interface OpenThreadsOptions {
156
+ /** Where an oversized or unreadable transcript is reported. Default `console`. */
157
+ readonly logger?: HostLogger;
158
+ }
159
+
160
+ /** A plain object, for reading a stored or SDK-given value without trusting its type. */
161
+ function isRecord(value: unknown): value is Record<string, unknown> {
162
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
163
+ }
164
+
165
+ /** A copy of an object without the provider's own fields, which no later prompt needs. */
166
+ function withoutProviderFields(value: Record<string, unknown>): Record<string, unknown> {
167
+ const out: Record<string, unknown> = {};
168
+ for (const [key, member] of Object.entries(value)) {
169
+ if (key === 'providerOptions' || key === 'providerMetadata' || key === 'providerExecuted') continue;
170
+ out[key] = member;
171
+ }
172
+ return out;
173
+ }
174
+
175
+ /**
176
+ * A turn's response messages as they are kept.
177
+ *
178
+ * Two edits and nothing else. A tool call with no result — a turn stopped while
179
+ * the tool ran — is removed, because a provider refuses a prompt that has a call
180
+ * without its answer; a message that edit leaves empty goes with it. Provider
181
+ * metadata is dropped at the message, part and output level: it belongs to the
182
+ * request it came from, and tool inputs and outputs are left exactly as given.
183
+ */
184
+ export function transcriptForStorage(messages: readonly ResponseMessage[]): ResponseMessage[] {
185
+ const answered = new Set<string>();
186
+ for (const message of messages) {
187
+ if (!Array.isArray(message.content)) continue;
188
+ for (const part of message.content as readonly unknown[]) {
189
+ if (isRecord(part) && part['type'] === 'tool-result' && typeof part['toolCallId'] === 'string') {
190
+ answered.add(part['toolCallId']);
191
+ }
192
+ }
193
+ }
194
+ const out: ResponseMessage[] = [];
195
+ for (const message of messages) {
196
+ if (!Array.isArray(message.content)) {
197
+ out.push(withoutProviderFields(message as unknown as Record<string, unknown>) as unknown as ResponseMessage);
198
+ continue;
199
+ }
200
+ const content: unknown[] = [];
201
+ for (const part of message.content as readonly unknown[]) {
202
+ if (!isRecord(part)) continue;
203
+ if (part['type'] === 'tool-call' && !answered.has(String(part['toolCallId']))) continue;
204
+ const kept = withoutProviderFields(part);
205
+ if (isRecord(kept['output'])) kept['output'] = withoutProviderFields(kept['output']);
206
+ content.push(kept);
207
+ }
208
+ if (content.length === 0) continue;
209
+ out.push({ ...withoutProviderFields(message as unknown as Record<string, unknown>), content } as unknown as ResponseMessage);
210
+ }
211
+ return out;
212
+ }
213
+
214
+ /**
215
+ * Whether a stored value reads back as response messages.
216
+ *
217
+ * The row was written by this process, but a file can be edited or damaged, and
218
+ * what comes back goes into a prompt. Only an assistant or tool message with a
219
+ * string or an array of typed parts passes; a `system` role never does.
220
+ */
221
+ function isTranscript(value: unknown): value is ResponseMessage[] {
222
+ if (!Array.isArray(value)) return false;
223
+ return value.every((message) => {
224
+ if (!isRecord(message)) return false;
225
+ const role = message['role'];
226
+ const content = message['content'];
227
+ if (role === 'assistant') {
228
+ return typeof content === 'string' || (Array.isArray(content) && content.every((part) => isRecord(part) && typeof part['type'] === 'string'));
229
+ }
230
+ if (role === 'tool') {
231
+ return Array.isArray(content) && content.every((part) => isRecord(part) && typeof part['type'] === 'string');
232
+ }
233
+ return false;
234
+ });
235
+ }
236
+
106
237
  /** A row of `threads`, joined with its message count. */
107
238
  interface ThreadRow {
108
239
  id: string;
@@ -181,7 +312,8 @@ function derivedTitle(messages: readonly StoredMessage[]): string | null {
181
312
  }
182
313
 
183
314
  /** Open the conversation store for one data directory, migrating it as needed. */
184
- export function openThreads(dataDir: string): ThreadStore {
315
+ export function openThreads(dataDir: string, options: OpenThreadsOptions = {}): ThreadStore {
316
+ const logger = options.logger ?? console;
185
317
  const directory = join(dataDir, 'ai');
186
318
  // The same mode the settings store uses: this directory holds what somebody
187
319
  // wrote to their assistant, which is nobody else's business.
@@ -235,8 +367,32 @@ export function openThreads(dataDir: string): ThreadStore {
235
367
  remove: db.query<{ id: string }, [string]>('DELETE FROM threads WHERE id = ? RETURNING id'),
236
368
  count: db.query<{ n: number }, []>('SELECT COUNT(*) AS n FROM threads'),
237
369
  clear: db.query<unknown, []>('DELETE FROM threads'),
370
+ saveTranscript: db.query<unknown, [string, string, number, number]>(
371
+ `INSERT INTO transcripts (run_id, messages, chars, created_at) VALUES (?, ?, ?, ?)
372
+ ON CONFLICT (run_id) DO UPDATE SET messages = excluded.messages, chars = excluded.chars,
373
+ created_at = excluded.created_at`,
374
+ ),
375
+ transcript: db.query<{ messages: string }, [string]>('SELECT messages FROM transcripts WHERE run_id = ?'),
376
+ expireTranscripts: db.query<{ run_id: string }, [number]>(
377
+ 'DELETE FROM transcripts WHERE created_at < ? RETURNING run_id',
378
+ ),
379
+ capTranscripts: db.query<{ run_id: string }, [number]>(
380
+ `DELETE FROM transcripts WHERE rowid NOT IN (
381
+ SELECT rowid FROM transcripts ORDER BY created_at DESC, rowid DESC LIMIT ?
382
+ ) RETURNING run_id`,
383
+ ),
238
384
  };
239
385
 
386
+ /** One unreadable transcript is reported once, not on every turn that names it. */
387
+ const reported = new Set<string>();
388
+
389
+ /** Both caps, in one transaction, so a crash never leaves one applied without the other. */
390
+ const retain = (): number =>
391
+ db.transaction(() => {
392
+ const expired = statements.expireTranscripts.all(Date.now() - TRANSCRIPT_MAX_AGE_MS).length;
393
+ return expired + statements.capTranscripts.all(TRANSCRIPT_MAX_COUNT).length;
394
+ })();
395
+
240
396
  /** The row, or the sentence a browser shows when a conversation is gone. */
241
397
  function mustGet(id: string): ThreadRow {
242
398
  const row = statements.byId.get(id);
@@ -244,6 +400,10 @@ export function openThreads(dataDir: string): ThreadStore {
244
400
  return row;
245
401
  }
246
402
 
403
+ // Retention runs on open as well as on every save, so a store that is only
404
+ // read still sheds what has aged out.
405
+ retain();
406
+
247
407
  const store: ThreadStore = {
248
408
  path,
249
409
 
@@ -318,6 +478,43 @@ export function openThreads(dataDir: string): ThreadStore {
318
478
  return before;
319
479
  },
320
480
 
481
+ saveTranscript(runId, messages) {
482
+ const kept = transcriptForStorage(messages);
483
+ const json = canonicalJson(kept);
484
+ if (json.length > MAX_TRANSCRIPT_CHARS) {
485
+ logger.warn(
486
+ `[broapp] ai transcript for run ${runId} is ${String(json.length)} characters, over ${String(MAX_TRANSCRIPT_CHARS)}; the turn stays text`,
487
+ );
488
+ return false;
489
+ }
490
+ db.transaction(() => {
491
+ statements.saveTranscript.run(runId, json, json.length, Date.now());
492
+ retain();
493
+ })();
494
+ return true;
495
+ },
496
+
497
+ transcript(runId) {
498
+ const row = statements.transcript.get(runId);
499
+ if (row === null) return null;
500
+ let parsed: unknown;
501
+ try {
502
+ parsed = JSON.parse(row.messages);
503
+ } catch {
504
+ parsed = undefined;
505
+ }
506
+ if (!isTranscript(parsed) || row.messages.length > MAX_TRANSCRIPT_CHARS) {
507
+ if (!reported.has(runId)) {
508
+ reported.add(runId);
509
+ logger.warn(`[broapp] ai transcript for run ${runId} does not read back as messages; the turn stays text`);
510
+ }
511
+ return null;
512
+ }
513
+ return parsed;
514
+ },
515
+
516
+ retainTranscripts: retain,
517
+
321
518
  close() {
322
519
  // Checkpointing folds the WAL back into the main file, so what is left
323
520
  // behind is one complete database rather than one that needs its
@@ -85,6 +85,8 @@ export type ChatMessage =
85
85
  readonly content: string;
86
86
  readonly toolCalls: ToolCallState[];
87
87
  readonly pending: boolean;
88
+ /** The run that wrote it, sent back in `history` so the host can use its transcript. */
89
+ readonly runId: string;
88
90
  };
89
91
 
90
92
  /** What {@link useAiChat} returns. */
@@ -107,12 +109,16 @@ function newRunId(): string {
107
109
  return crypto.randomUUID().replace(/-/g, '');
108
110
  }
109
111
 
110
- /** Every completed turn, as the model should see it. */
111
- function toHistory(messages: readonly ChatMessage[]): ChatTurn[] {
112
+ /** Every completed turn, as the model should see it. Exported for its test; not part of `broapp/ai/react`. */
113
+ export function toHistory(messages: readonly ChatMessage[]): ChatTurn[] {
112
114
  const turns: ChatTurn[] = [];
113
115
  for (const message of messages) {
114
116
  if (message.role === 'assistant' && (message.pending || message.content === '')) continue;
115
- turns.push({ role: message.role, content: message.content });
117
+ turns.push(
118
+ message.role === 'assistant'
119
+ ? { role: 'assistant', content: message.content, runId: message.runId }
120
+ : { role: 'user', content: message.content },
121
+ );
116
122
  }
117
123
  return turns.slice(-MAX_HISTORY);
118
124
  }
@@ -267,7 +273,7 @@ export function useAiChat(options: AiChatOptions = {}): AiChatHook {
267
273
  setMessages((current) => [
268
274
  ...current,
269
275
  { id: `${id}-user`, role: 'user', content: trimmed },
270
- { id: `${id}-assistant`, role: 'assistant', content: '', toolCalls: [], pending: true },
276
+ { id: `${id}-assistant`, role: 'assistant', content: '', toolCalls: [], pending: true, runId: id },
271
277
  ]);
272
278
 
273
279
  try {
@@ -53,6 +53,9 @@ const settings = s.object({
53
53
  const chatTurn = s.object({
54
54
  role: s.enum(['user', 'assistant']),
55
55
  content: s.string({ max: 20_000 }),
56
+ // Names a run whose transcript the host wrote. It only ever selects
57
+ // something the host already holds; an id it does not hold is ignored.
58
+ runId: s.optional(runId),
56
59
  });
57
60
 
58
61
  /**
@@ -59,6 +59,12 @@ export type ToolPermission = 'read' | 'confirm';
59
59
  export interface ChatTurn {
60
60
  role: 'user' | 'assistant';
61
61
  content: string;
62
+ /**
63
+ * On an assistant turn: the run that produced it. The host expands a turn
64
+ * whose run it holds a transcript for into that turn's own tool calls and
65
+ * results; any other turn, and any run it does not hold, is `content`.
66
+ */
67
+ runId?: string;
62
68
  }
63
69
 
64
70
  /**
@@ -76,6 +76,15 @@ export interface RunningApp {
76
76
  * looking attached for that whole minute.
77
77
  */
78
78
  readonly attached: boolean;
79
+ /**
80
+ * A fresh address for this application, carrying a new one-time launch token.
81
+ *
82
+ * Brobridge mints the token; each address works once and expires on the
83
+ * bridge's launch-token lifetime. Call it on the host's own decision — a
84
+ * person's click in an authenticated tab, a local process that already holds
85
+ * a credential — never because a browser asked. Do not log the result.
86
+ */
87
+ launchUrl(): string;
79
88
  }
80
89
 
81
90
  const POLL_INTERVAL_MS = 1_000;
@@ -215,5 +224,6 @@ export async function startApp(options: StartAppOptions): Promise<RunningApp> {
215
224
  get attached() {
216
225
  return isAttached();
217
226
  },
227
+ launchUrl: () => bridge.launchUrl(),
218
228
  };
219
229
  }