broapp 0.4.0 → 0.4.2

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.0",
3
+ "version": "0.4.2",
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",
@@ -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,11 @@ 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[];
67
72
  }
68
73
 
69
74
  /** How {@link Ai.turn} is answered and stopped. */
@@ -291,6 +296,14 @@ export function createAi(options: CreateAiOptions): Ai {
291
296
  }
292
297
  });
293
298
 
299
+ // Opened on the first conversation route and not before: an application
300
+ // whose user never opens the panel should not find a database in its data
301
+ // directory, and `createAi` is built unconditionally by every application
302
+ // that offers AI at all. A turn opens it too, to keep its transcript.
303
+ let threads: ThreadStore | null = null;
304
+ const threadStore = (): ThreadStore =>
305
+ (threads ??= openThreads(options.dataDir, options.logger === undefined ? {} : { logger: options.logger }));
306
+
294
307
  const approvals = createPendingApprovals(options.logger);
295
308
  const runDeps: RunDeps = {
296
309
  registry,
@@ -303,6 +316,12 @@ export function createAi(options: CreateAiOptions): Ai {
303
316
  approvals,
304
317
  ...(options.onRunEnd === undefined ? {} : { onRunEnd: options.onRunEnd }),
305
318
  ...(options.onContext === undefined ? {} : { onContext: options.onContext }),
319
+ transcripts: {
320
+ save: (runId, messages) => {
321
+ threadStore().saveTranscript(runId, messages);
322
+ },
323
+ read: (runId) => threadStore().transcript(runId),
324
+ },
306
325
  logger: options.logger ?? console,
307
326
  };
308
327
 
@@ -315,13 +334,6 @@ export function createAi(options: CreateAiOptions): Ai {
315
334
  approvals.answer({ requestId: `${runId}:${callId}`, approved: approve }) === 'accepted',
316
335
  }));
317
336
 
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
337
  host.operation('ai.threadsList', () => ({ threads: threadStore().list() }));
326
338
  host.operation('ai.threadsCreate', (input) => threadStore().create(input));
327
339
  host.operation('ai.threadsGet', ({ id }) => threadStore().get(id));
@@ -400,7 +412,7 @@ export function createAi(options: CreateAiOptions): Ai {
400
412
  runId: turn.runId,
401
413
  message: turn.message,
402
414
  refs: [],
403
- history: [],
415
+ history: turn.history === undefined ? [] : [...turn.history],
404
416
  ...(turn.modelId === undefined ? {} : { modelId: turn.modelId }),
405
417
  },
406
418
  sink,
@@ -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,120 @@ 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
+ /** One transcript message with every tool input and output bounded. */
292
+ function boundMessage(message: ResponseMessage, limits: HistoryLimits): ModelMessage {
293
+ if (!Array.isArray(message.content)) return message;
294
+ const content = (message.content as readonly unknown[]).map((part) => {
295
+ if (!isRecord(part)) return part;
296
+ if (part['type'] === 'tool-call') {
297
+ const json = JSON.stringify(part['input']) ?? '';
298
+ return json.length <= limits.inputChars ? part : { ...part, input: cut(json, limits.inputChars) };
299
+ }
300
+ if (part['type'] === 'tool-result') return { ...part, output: boundOutput(part['output'], limits.outputChars) };
301
+ return part;
302
+ });
303
+ return { ...message, content } as ModelMessage;
304
+ }
305
+
306
+ /**
307
+ * History as the model is given it.
308
+ *
309
+ * Walking from the newest turn back, an assistant turn whose `runId` names a
310
+ * transcript the host holds is replaced by that transcript — its own tool calls
311
+ * and results, bounded — while fewer than `limits.turns` have been and the total
312
+ * stays under `limits.totalChars`. The first turn that would cross the total
313
+ * stays text, and so does every turn older than it. Every other turn is its text,
314
+ * exactly as before. User turns keep their place.
315
+ */
316
+ export function expandHistory(
317
+ history: readonly ChatTurn[],
318
+ read: (runId: string) => readonly ResponseMessage[] | null,
319
+ limits: HistoryLimits = HISTORY_LIMITS,
320
+ ): ModelMessage[] {
321
+ const segments: ModelMessage[][] = [];
322
+ let expanded = 0;
323
+ let total = 0;
324
+ let full = false;
325
+ for (let index = history.length - 1; index >= 0; index -= 1) {
326
+ const turn = history[index];
327
+ if (turn === undefined) continue;
328
+ const text: ModelMessage[] = [{ role: turn.role, content: turn.content }];
329
+ if (turn.role !== 'assistant' || turn.runId === undefined || full || expanded >= limits.turns) {
330
+ segments.push(text);
331
+ continue;
332
+ }
333
+ const transcript = read(turn.runId);
334
+ if (transcript === null || transcript.length === 0) {
335
+ segments.push(text);
336
+ continue;
337
+ }
338
+ const bounded = transcript.map((message) => boundMessage(message, limits));
339
+ const chars = JSON.stringify(bounded).length;
340
+ if (total + chars > limits.totalChars) {
341
+ full = true;
342
+ segments.push(text);
343
+ continue;
344
+ }
345
+ total += chars;
346
+ expanded += 1;
347
+ segments.push(bounded);
348
+ }
349
+ return segments.reverse().flat();
350
+ }
351
+
220
352
  /**
221
353
  * A message the model may see, without whatever a caller invented.
222
354
  *
@@ -224,13 +356,21 @@ async function assembleContext(
224
356
  * are strings by contract, so an earlier turn's picture is already a
225
357
  * `[image: name]` line the browser put there — the alternative, resending
226
358
  * every image on every turn, would cost the user the same upload again on each
227
- * question.
359
+ * question. An assistant turn that names its run is expanded from the host's
360
+ * own transcript by {@link expandHistory}.
228
361
  */
229
- function toModelMessages(params: StreamChatParams): ModelMessage[] {
230
- const messages: ModelMessage[] = params.history.map((turn) => ({
231
- role: turn.role,
232
- content: turn.content,
233
- }));
362
+ function toModelMessages(params: StreamChatParams, transcripts: RunTranscripts | undefined, logger: HostLogger): ModelMessage[] {
363
+ const read = (runId: string): readonly ResponseMessage[] | null => {
364
+ if (transcripts === undefined) return null;
365
+ try {
366
+ return transcripts.read(runId);
367
+ } catch (cause) {
368
+ // A store that cannot be read has no transcript to give; the turn is text.
369
+ logger.error(`[broapp] ai could not read the transcript of run ${runId}: ${String(cause instanceof Error ? cause.message : cause)}`);
370
+ return null;
371
+ }
372
+ };
373
+ const messages: ModelMessage[] = expandHistory(params.history, read);
234
374
  const files = params.files ?? [];
235
375
  if (files.length === 0) {
236
376
  messages.push({ role: 'user', content: params.message });
@@ -366,6 +506,7 @@ function buildTools(
366
506
  deps: RunDeps,
367
507
  sink: StreamSink<ChatEvent>,
368
508
  approver: Approver,
509
+ recorder: TranscriptRecorder,
369
510
  ): ToolSet {
370
511
  const tools: ToolSet = {};
371
512
  for (const [name, definition] of Object.entries(deps.tools)) {
@@ -374,6 +515,7 @@ function buildTools(
374
515
  inputSchema: jsonSchema(definition.inputSchema),
375
516
  execute: async (input: unknown, options: { toolCallId: string }): Promise<unknown> => {
376
517
  const callId = options.toolCallId;
518
+ recorder.called(callId, name, input);
377
519
  await sink.emit({
378
520
  type: 'tool-call',
379
521
  callId,
@@ -409,12 +551,14 @@ function buildTools(
409
551
  output: DECLINED,
410
552
  denied: true,
411
553
  });
554
+ recorder.answered(callId, DECLINED);
412
555
  return DECLINED;
413
556
  }
414
557
  // One tool failing is not the turn failing. The model gets the
415
558
  // reason and can carry on or explain.
416
559
  output = { error: safeToolMessage(cause, name, deps.logger) };
417
560
  }
561
+ recorder.answered(callId, output);
418
562
  await sink.emit({ type: 'tool-result', callId, tool: name, output });
419
563
  return output;
420
564
  },
@@ -442,6 +586,59 @@ function safeToolMessage(cause: unknown, name: string, logger: HostLogger): stri
442
586
  return 'The tool failed.';
443
587
  }
444
588
 
589
+ /**
590
+ * What a turn has produced so far, for a turn that does not reach `finish`.
591
+ *
592
+ * A finished turn's transcript is the AI SDK's own `responseMessages`. A turn
593
+ * that is stopped never gets one, and a stopped turn is exactly the one whose
594
+ * "continue" needs it most; so the steps the SDK finished are kept as it gave
595
+ * them, and the step in flight is kept from its text and from the calls this
596
+ * layer ran. Storage removes a call that never got its result.
597
+ */
598
+ class TranscriptRecorder {
599
+ /** Set once the model has been asked; before that there is nothing to keep. */
600
+ started = false;
601
+ private readonly finished: ResponseMessage[] = [];
602
+ private text = '';
603
+ private calls = new Map<string, { name: string; input: unknown; output?: { value: unknown } }>();
604
+
605
+ stepEnded(messages: readonly ResponseMessage[]): void {
606
+ this.finished.push(...messages);
607
+ this.text = '';
608
+ this.calls = new Map();
609
+ }
610
+
611
+ wrote(text: string): void {
612
+ this.text += text;
613
+ }
614
+
615
+ called(callId: string, name: string, input: unknown): void {
616
+ this.calls.set(callId, { name, input });
617
+ }
618
+
619
+ answered(callId: string, output: unknown): void {
620
+ const call = this.calls.get(callId);
621
+ if (call !== undefined) call.output = { value: output };
622
+ }
623
+
624
+ /** Every finished step, then the step in flight. */
625
+ sofar(): ResponseMessage[] {
626
+ const assistant: Array<{ type: 'text'; text: string } | { type: 'tool-call'; toolCallId: string; toolName: string; input: unknown }> = [];
627
+ if (this.text !== '') assistant.push({ type: 'text', text: this.text });
628
+ const results: Array<{ type: 'tool-result'; toolCallId: string; toolName: string; output: { type: 'json'; value: never } }> = [];
629
+ for (const [toolCallId, call] of this.calls) {
630
+ assistant.push({ type: 'tool-call', toolCallId, toolName: call.name, input: call.input });
631
+ if (call.output !== undefined) {
632
+ results.push({ type: 'tool-result', toolCallId, toolName: call.name, output: { type: 'json', value: call.output.value as never } });
633
+ }
634
+ }
635
+ const out = [...this.finished];
636
+ if (assistant.length > 0) out.push({ role: 'assistant', content: assistant });
637
+ if (results.length > 0) out.push({ role: 'tool', content: results });
638
+ return out;
639
+ }
640
+ }
641
+
445
642
  /** How much of the person's message stands in for the whole turn. */
446
643
  const SUMMARY_CHARS = 200;
447
644
 
@@ -457,6 +654,18 @@ export async function runChat(
457
654
  let ended = false;
458
655
  const started = Date.now();
459
656
  const tally: TurnTally = { steps: 0 };
657
+ const recorder = new TranscriptRecorder();
658
+ const transcript = new TranscriptWriter(params.runId, deps);
659
+ // A turn that never reaches `finish` — stopped, failed, or cut off by the
660
+ // provider — still leaves what it did. A stop is written the moment it
661
+ // happens rather than when the loop notices: the AI SDK waits for a running
662
+ // tool before it ends the stream, and what the turn did is what had come back
663
+ // when the person stopped it, which is also what the browser was shown.
664
+ // Written once: a finished turn has already written the SDK's own messages.
665
+ const keepSoFar = (): void => {
666
+ if (recorder.started) transcript.write(recorder.sofar());
667
+ };
668
+ sink.signal.addEventListener('abort', keepSoFar, { once: true });
460
669
  const end = (status: 'succeeded' | 'failed' | 'cancelled'): void => {
461
670
  if (ended) return;
462
671
  ended = true;
@@ -472,11 +681,43 @@ export async function runChat(
472
681
  );
473
682
  };
474
683
  try {
475
- await runTurn(params, sink, deps, end, tally);
684
+ await runTurn(params, sink, deps, end, tally, recorder, transcript);
476
685
  end(sink.signal.aborted ? 'cancelled' : 'succeeded');
477
686
  } catch (cause) {
478
687
  end(sink.signal.aborted ? 'cancelled' : 'failed');
479
688
  throw cause;
689
+ } finally {
690
+ sink.signal.removeEventListener('abort', keepSoFar);
691
+ keepSoFar();
692
+ }
693
+ }
694
+
695
+ /**
696
+ * Writes one turn's transcript, once, without ever failing the turn.
697
+ *
698
+ * A store that cannot write loses the transcript, not the turn: the error is
699
+ * logged, `done` still goes out, and the next turn's history for this run is
700
+ * its text.
701
+ */
702
+ class TranscriptWriter {
703
+ private written = false;
704
+
705
+ constructor(
706
+ private readonly runId: string,
707
+ private readonly deps: RunDeps,
708
+ ) {}
709
+
710
+ write(messages: readonly ResponseMessage[]): void {
711
+ const transcripts = this.deps.transcripts;
712
+ if (this.written || transcripts === undefined) return;
713
+ this.written = true;
714
+ try {
715
+ transcripts.save(this.runId, messages);
716
+ } catch (cause) {
717
+ this.deps.logger.error(
718
+ `[broapp] ai could not keep the transcript of run ${this.runId}: ${String(cause instanceof Error ? cause.message : cause)}`,
719
+ );
720
+ }
480
721
  }
481
722
  }
482
723
 
@@ -487,6 +728,8 @@ async function runTurn(
487
728
  deps: RunDeps,
488
729
  end: (status: 'succeeded' | 'failed' | 'cancelled') => void,
489
730
  tally: TurnTally,
731
+ recorder: TranscriptRecorder,
732
+ transcript: TranscriptWriter,
490
733
  ): Promise<void> {
491
734
  // Throws a PublicError when nothing is configured. `runStream` in host/app.ts
492
735
  // turns that into the right thing on the wire, so it is not caught here.
@@ -535,19 +778,29 @@ async function runTurn(
535
778
  );
536
779
  }
537
780
 
781
+ // From here the model has been asked, so there is a transcript to keep
782
+ // however the turn ends. A turn refused before this point has none.
783
+ recorder.started = true;
538
784
  const result = streamText({
539
785
  // Always a model *instance*. A string here would be resolved by the AI
540
786
  // SDK's gateway, over the global fetch, to a Vercel host — see
541
787
  // reports/01-spike.md. Nothing in this layer may pass one.
542
788
  model: resolved.adapter.model(resolved.config, resolved.modelId),
543
789
  system,
544
- messages: toModelMessages(params),
545
- tools: buildTools(params, deps, sink, approver),
790
+ messages: toModelMessages(params, deps.transcripts, deps.logger),
791
+ tools: buildTools(params, deps, sink, approver, recorder),
546
792
  stopWhen: stepCountIs(deps.maxSteps),
547
793
  abortSignal: sink.signal,
548
794
  // The default handler prints the error; this layer reports it as an event
549
795
  // and decides for itself what is safe to say.
550
796
  onError: () => undefined,
797
+ // Both from the SDK's own pipeline rather than from the loop below, so a
798
+ // step's text and the step's end are seen in the order they happened even
799
+ // when the loop is behind, waiting on a slow socket.
800
+ onChunk: ({ chunk }) => {
801
+ if (chunk.type === 'text-delta') recorder.wrote(chunk.text);
802
+ },
803
+ onStepEnd: (step) => recorder.stepEnded(step.response.messages),
551
804
  });
552
805
 
553
806
  for await (const part of result.fullStream) {
@@ -569,6 +822,9 @@ async function runTurn(
569
822
  outputTokens: part.totalUsage.outputTokens ?? 0,
570
823
  };
571
824
  tally.usage = usage;
825
+ // Before `done`: a client that saves the conversation on `done` and
826
+ // sends it back at once must find the run's transcript already there.
827
+ transcript.write(await result.responseMessages);
572
828
  await sink.emit({ type: 'usage', ...usage });
573
829
  await sink.emit({ type: 'done' });
574
830
  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
@@ -71,20 +71,24 @@ function ToolCall({
71
71
  <div
72
72
  className={`ai-chat__confirm${urgent ? ' ai-chat__confirm--urgent' : ''}`}
73
73
  role="group"
74
- aria-label={`Allow ${call.tool}?`}
74
+ aria-label={`Allow ${call.asks ?? call.tool}?`}
75
75
  >
76
- <span>Allow this?</span>
76
+ {/* A question about one of this call's own steps says which step. */}
77
+ <span>{call.asks === undefined ? 'Allow this?' : `Allow ${call.asks}?`}</span>
78
+ {call.asks === undefined || call.asksInput === undefined ? null : (
79
+ <pre className="ai-chat__json">{JSON.stringify(call.asksInput, null, 2)}</pre>
80
+ )}
77
81
  {call.expiresAt === undefined ? null : (
78
82
  <span className="ai-chat__expires">expires in {countdown(call.expiresAt, now)}</span>
79
83
  )}
80
84
  <button
81
85
  className="button button--primary"
82
86
  type="button"
83
- onClick={() => onConfirm(call.callId, true)}
87
+ onClick={() => onConfirm(call.confirmId ?? call.callId, true)}
84
88
  >
85
89
  Allow
86
90
  </button>
87
- <button className="button" type="button" onClick={() => onConfirm(call.callId, false)}>
91
+ <button className="button" type="button" onClick={() => onConfirm(call.confirmId ?? call.callId, false)}>
88
92
  Decline
89
93
  </button>
90
94
  </div>
@@ -24,6 +24,56 @@ export interface ToolCallState {
24
24
  readonly output?: unknown;
25
25
  /** While awaiting confirmation: when the question stops waiting. */
26
26
  readonly expiresAt?: number;
27
+ /**
28
+ * While awaiting confirmation for one of this call's own steps: the id the
29
+ * answer goes to (`<callId>.<step>`), what that step runs and its input.
30
+ * Absent when the question is about the call itself.
31
+ */
32
+ readonly confirmId?: string;
33
+ readonly asks?: string;
34
+ readonly asksInput?: unknown;
35
+ }
36
+
37
+ /**
38
+ * Put a question on the card it belongs to.
39
+ *
40
+ * A question about the call itself names the call's own id. A tool that runs
41
+ * several gated steps — the launcher's `candidate.cycle` builds and previews
42
+ * after the patch it was called with — asks once per step, as `<callId>.<step>`,
43
+ * so each answer is to exactly one action. Those questions go on the parent's
44
+ * card, which then says what it is asking about.
45
+ */
46
+ export function attachConfirm(
47
+ calls: readonly ToolCallState[],
48
+ event: { readonly callId?: string; readonly tool?: string; readonly input?: unknown; readonly expiresAt?: number },
49
+ ): ToolCallState[] {
50
+ const id = event.callId ?? '';
51
+ const exact = calls.some((call) => call.callId === id);
52
+ return calls.map((call) => {
53
+ const own = call.callId === id;
54
+ const step = !exact && id.startsWith(`${call.callId}.`);
55
+ if (!own && !step) return call;
56
+ const { confirmId: _confirmId, asks: _asks, asksInput: _asksInput, ...rest } = call;
57
+ return {
58
+ ...rest,
59
+ status: 'awaiting-confirmation',
60
+ ...(event.expiresAt === undefined ? {} : { expiresAt: event.expiresAt }),
61
+ ...(step ? { confirmId: id, asks: event.tool ?? '', asksInput: event.input } : {}),
62
+ };
63
+ });
64
+ }
65
+
66
+ /**
67
+ * An answer was accepted: the card goes back to running until its result or
68
+ * its next question arrives. Without this a card whose tool asks again later
69
+ * would keep offering buttons for a question already answered.
70
+ */
71
+ export function settleAnswer(calls: readonly ToolCallState[], answeredId: string): ToolCallState[] {
72
+ return calls.map((call) => {
73
+ if (call.status !== 'awaiting-confirmation' || (call.callId !== answeredId && call.confirmId !== answeredId)) return call;
74
+ const { confirmId: _confirmId, asks: _asks, asksInput: _asksInput, expiresAt: _expiresAt, ...rest } = call;
75
+ return { ...rest, status: 'running' };
76
+ });
27
77
  }
28
78
 
29
79
  /** One message in the transcript. */
@@ -35,6 +85,8 @@ export type ChatMessage =
35
85
  readonly content: string;
36
86
  readonly toolCalls: ToolCallState[];
37
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;
38
90
  };
39
91
 
40
92
  /** What {@link useAiChat} returns. */
@@ -57,12 +109,16 @@ function newRunId(): string {
57
109
  return crypto.randomUUID().replace(/-/g, '');
58
110
  }
59
111
 
60
- /** Every completed turn, as the model should see it. */
61
- 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[] {
62
114
  const turns: ChatTurn[] = [];
63
115
  for (const message of messages) {
64
116
  if (message.role === 'assistant' && (message.pending || message.content === '')) continue;
65
- 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
+ );
66
122
  }
67
123
  return turns.slice(-MAX_HISTORY);
68
124
  }
@@ -152,18 +208,7 @@ export function useAiChat(options: AiChatOptions = {}): AiChatHook {
152
208
  break;
153
209
  case 'confirm':
154
210
  setStatus('awaiting-confirmation');
155
- patchPending((message) => ({
156
- ...message,
157
- toolCalls: message.toolCalls.map((call) =>
158
- call.callId === event.callId
159
- ? {
160
- ...call,
161
- status: 'awaiting-confirmation',
162
- ...(event.expiresAt === undefined ? {} : { expiresAt: event.expiresAt }),
163
- }
164
- : call,
165
- ),
166
- }));
211
+ patchPending((message) => ({ ...message, toolCalls: attachConfirm(message.toolCalls, event) }));
167
212
  break;
168
213
  case 'tool-result': {
169
214
  setStatus((current) => (current === 'awaiting-confirmation' ? 'streaming' : current));
@@ -228,7 +273,7 @@ export function useAiChat(options: AiChatOptions = {}): AiChatHook {
228
273
  setMessages((current) => [
229
274
  ...current,
230
275
  { id: `${id}-user`, role: 'user', content: trimmed },
231
- { id: `${id}-assistant`, role: 'assistant', content: '', toolCalls: [], pending: true },
276
+ { id: `${id}-assistant`, role: 'assistant', content: '', toolCalls: [], pending: true, runId: id },
232
277
  ]);
233
278
 
234
279
  try {
@@ -296,12 +341,15 @@ export function useAiChat(options: AiChatOptions = {}): AiChatHook {
296
341
  // Nobody was waiting: the turn timed out or was cancelled while the
297
342
  // question was on screen.
298
343
  setError('That request has expired.');
344
+ return;
299
345
  }
346
+ patchPending((message) => ({ ...message, toolCalls: settleAnswer(message.toolCalls, callId) }));
347
+ setStatus((current) => (current === 'awaiting-confirmation' ? 'streaming' : current));
300
348
  } catch (cause) {
301
349
  setError(cause instanceof BroappError ? cause.message : 'That answer could not be sent.');
302
350
  }
303
351
  },
304
- [shared],
352
+ [shared, patchPending],
305
353
  );
306
354
 
307
355
  const clear = React.useCallback((): void => {
@@ -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
  /**