broapp 0.3.0 → 0.4.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "broapp",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
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",
@@ -61,7 +61,7 @@ export interface ProviderAdapter {
61
61
  models(config: AdapterConfig, signal: AbortSignal): Promise<BroappModel[]>;
62
62
  /** Cheapest possible proof the config works. Must reject with {@link AdapterError}. */
63
63
  test(config: AdapterConfig, signal: AbortSignal): Promise<void>;
64
- /** The AI SDK model. Only `broapp/ai/host` calls this. */
64
+ /** The AI SDK model. Only `broapp/ai/host` calls this; other host code asks `Ai.model()`. */
65
65
  model(config: AdapterConfig, modelId: string): LanguageModel;
66
66
  }
67
67
 
@@ -10,6 +10,7 @@
10
10
  * `mount` has an implementation for every route in the contract, which it
11
11
  * insists on.
12
12
  */
13
+ import type { LanguageModel } from 'ai';
13
14
  import type { Bridge } from 'brobridge';
14
15
 
15
16
  // Imported from the host entry point rather than from `host/app.ts` directly:
@@ -18,7 +19,7 @@ import type { Bridge } from 'brobridge';
18
19
  // browser target polyfills `node:fs`, so the file stores alone would not stop
19
20
  // this code from being bundled into a page.
20
21
  import { createPendingApprovals, createReservedHostApp } from '../../host/index.ts';
21
- import type { HostApp, HostLogger } from '../../host/app.ts';
22
+ import type { HostApp, HostLogger, StreamSink } from '../../host/app.ts';
22
23
  import { publicError } from '../../shared/errors.ts';
23
24
  import { aiContract, type AiContract } from '../shared/contract.ts';
24
25
  import type { ProviderInfo } from '../shared/types.ts';
@@ -26,10 +27,66 @@ import type { ProviderInfo } from '../shared/types.ts';
26
27
  import { AdapterError, toPublicError, type AdapterConfig, type ProviderAdapter } from './adapter.ts';
27
28
  import { createRegistry, type Registry } from './registry.ts';
28
29
  import { runChat, type RunDeps } from './run.ts';
30
+ import type { ChatEvent } from './run-types.ts';
29
31
  import { createFileSecretStore, createMemorySecretStore } from './secrets.ts';
30
32
  import { createSettingsStore } from './settings.ts';
31
33
  import { openThreads, type ThreadStore } from './threads.ts';
32
- import { GUARDED, type AiContextProviders, type AiTool } from './tool.ts';
34
+ import { GUARDED, type AiContextProviders, type AiTool, type ContextDocument } from './tool.ts';
35
+
36
+ /** How a turn went, beyond whether it ended well. */
37
+ export interface RunEndDetail {
38
+ readonly usage?: { readonly inputTokens: number; readonly outputTokens: number };
39
+ /** Tool round trips the turn made. */
40
+ readonly steps: number;
41
+ readonly ms: number;
42
+ }
43
+
44
+ /**
45
+ * What one turn was given, after the budget. Documents are exactly what the
46
+ * model saw.
47
+ *
48
+ * Reported before the model is called, so a listener can write down the turn's
49
+ * inputs with the identity they had then rather than reconstruct them later
50
+ * from settings that may since have changed.
51
+ */
52
+ export interface DeliveredContext {
53
+ readonly system: string;
54
+ readonly documents: readonly ContextDocument[];
55
+ /** The person's message for this turn, which the system prompt does not carry. */
56
+ readonly message: string;
57
+ /** The provider and model the turn was sent to. */
58
+ readonly model: { readonly provider: string; readonly id: string };
59
+ }
60
+
61
+ /** One turn run in-process by {@link Ai.turn}. */
62
+ export interface InProcessTurn {
63
+ readonly runId: string;
64
+ readonly message: string;
65
+ /** The model for this turn, within the configured provider. */
66
+ readonly modelId?: string;
67
+ }
68
+
69
+ /** How {@link Ai.turn} is answered and stopped. */
70
+ export interface InProcessTurnOptions {
71
+ /**
72
+ * The answer to each question the gate asks during the turn.
73
+ *
74
+ * The caller is the person's stand-in, so it decides exactly as the person
75
+ * would have: the gate still asks, and still records the answer.
76
+ */
77
+ readonly answer: (question: { readonly tool: string; readonly input: unknown }) => boolean;
78
+ /** Aborting it cancels the turn, as a browser's cancel would. */
79
+ readonly signal?: AbortSignal;
80
+ readonly onEvent?: (event: ChatEvent) => void;
81
+ }
82
+
83
+ /** How an in-process turn ended, and everything it emitted. */
84
+ export interface InProcessTurnResult {
85
+ readonly status: 'succeeded' | 'failed' | 'cancelled';
86
+ readonly events: readonly ChatEvent[];
87
+ /** The reason a turn that could not start gave, such as no provider being set up. */
88
+ readonly error?: string;
89
+ }
33
90
 
34
91
  /** What the application is, in the words a model is given. */
35
92
  export interface AiAppDescription {
@@ -75,7 +132,16 @@ export interface CreateAiOptions {
75
132
  runId: string,
76
133
  status: 'succeeded' | 'failed' | 'cancelled',
77
134
  summary: string,
135
+ detail?: RunEndDetail,
78
136
  ) => void;
137
+ /**
138
+ * Called once per turn, after the context budget and before the model, with
139
+ * what the model is about to be given.
140
+ *
141
+ * A hook that throws is logged and ignored: recording a turn is never a
142
+ * reason to fail it.
143
+ */
144
+ readonly onContext?: (runId: string, delivered: DeliveredContext) => void;
79
145
  }
80
146
 
81
147
  /**
@@ -104,8 +170,29 @@ export interface Ai {
104
170
  readonly activeStreams: number;
105
171
  /** For tests, and for applications that read settings on the host. */
106
172
  readonly registry: Registry;
173
+ /**
174
+ * The configured model, for host code that needs one outside a chat turn.
175
+ *
176
+ * Resolved the same way a turn resolves it, so a caller is told "AI is not
177
+ * set up yet" in the same words, and always a model instance — never a
178
+ * string the AI SDK would send to its gateway.
179
+ */
180
+ model(override?: { readonly modelId?: string }): Promise<LanguageModel>;
181
+ /**
182
+ * Run one chat turn in this process, without a bridge or a browser.
183
+ *
184
+ * For scripts that drive the engineer themselves, such as a replay. It is
185
+ * the `ai.chat` route's own loop — the same tools, gate, context providers
186
+ * and hooks — with a sink that collects the events instead of writing them to
187
+ * a socket, and `answer` standing where the person's click would.
188
+ */
189
+ turn(turn: InProcessTurn, options: InProcessTurnOptions): Promise<InProcessTurnResult>;
107
190
  }
108
191
 
192
+ /** How long an in-process answer waits for its question to be registered. */
193
+ const ANSWER_ATTEMPTS = 200;
194
+ const ANSWER_INTERVAL_MS = 5;
195
+
109
196
  /** How long a provider is given to answer a listing or a connection test. */
110
197
  const PROVIDER_TIMEOUT_MS = 20_000;
111
198
 
@@ -215,6 +302,7 @@ export function createAi(options: CreateAiOptions): Ai {
215
302
  confirmTimeoutMs: options.confirmTimeoutMs ?? DEFAULT_CONFIRM_TIMEOUT_MS,
216
303
  approvals,
217
304
  ...(options.onRunEnd === undefined ? {} : { onRunEnd: options.onRunEnd }),
305
+ ...(options.onContext === undefined ? {} : { onContext: options.onContext }),
218
306
  logger: options.logger ?? console,
219
307
  };
220
308
 
@@ -262,5 +350,79 @@ export function createAi(options: CreateAiOptions): Ai {
262
350
  return host.activeStreams;
263
351
  },
264
352
  registry,
353
+ model: async (override) => {
354
+ const { adapter, config, modelId } = await registry.resolve(override);
355
+ return adapter.model(config, modelId);
356
+ },
357
+ turn: async (turn, turnOptions) => {
358
+ const controller = new AbortController();
359
+ const relay = (): void => controller.abort(turnOptions.signal?.reason);
360
+ turnOptions.signal?.addEventListener('abort', relay, { once: true });
361
+ if (turnOptions.signal?.aborted === true) relay();
362
+
363
+ const events: ChatEvent[] = [];
364
+ /**
365
+ * Answer a question once the approval table holds it.
366
+ *
367
+ * The `confirm` event is emitted, and awaited, before the run's approver
368
+ * registers the question, so an answer given inside `emit` would find
369
+ * nobody waiting. This waits for it, briefly, the way a person's click
370
+ * necessarily arrives later.
371
+ */
372
+ const settle = async (requestId: string, approved: boolean): Promise<void> => {
373
+ for (let attempt = 0; attempt < ANSWER_ATTEMPTS; attempt += 1) {
374
+ if (approvals.answer({ requestId, approved }) !== 'unknown') return;
375
+ await Bun.sleep(ANSWER_INTERVAL_MS);
376
+ }
377
+ };
378
+ const sink: StreamSink<ChatEvent> = {
379
+ signal: controller.signal,
380
+ sessionId: 'in-process',
381
+ emit(event) {
382
+ if (controller.signal.aborted) return Promise.reject(new Error('stream is no longer open'));
383
+ events.push(event);
384
+ turnOptions.onEvent?.(event);
385
+ if (event.type === 'confirm') {
386
+ // The gate's request id is `<runId>:<callId>`, which is what an
387
+ // event without one would have named.
388
+ const requestId = event.requestId ?? `${turn.runId}:${event.callId}`;
389
+ void settle(requestId, turnOptions.answer({ tool: event.tool ?? '', input: event.input }));
390
+ }
391
+ return Promise.resolve();
392
+ },
393
+ };
394
+
395
+ let status: InProcessTurnResult['status'] | null = null;
396
+ const onRunEnd = runDeps.onRunEnd;
397
+ try {
398
+ await runChat(
399
+ {
400
+ runId: turn.runId,
401
+ message: turn.message,
402
+ refs: [],
403
+ history: [],
404
+ ...(turn.modelId === undefined ? {} : { modelId: turn.modelId }),
405
+ },
406
+ sink,
407
+ {
408
+ ...runDeps,
409
+ // The turn's own ending is what this returns; whoever the layer
410
+ // was built to tell is still told.
411
+ onRunEnd: (runId, ended, summary, detail) => {
412
+ status = ended;
413
+ onRunEnd?.(runId, ended, summary, detail);
414
+ },
415
+ },
416
+ );
417
+ return { status: status ?? 'succeeded', events };
418
+ } catch (cause) {
419
+ // A turn that could not start — no provider set up, most often — ends
420
+ // failed with the sentence the browser would have been shown.
421
+ const error = String(cause instanceof Error ? cause.message : cause);
422
+ return { status: status ?? 'failed', events, error };
423
+ } finally {
424
+ turnOptions.signal?.removeEventListener('abort', relay);
425
+ }
426
+ },
265
427
  };
266
428
  }
@@ -7,7 +7,17 @@
7
7
  * follows this import fails loudly instead.
8
8
  */
9
9
  export { createAi } from './create-ai.ts';
10
- export type { Ai, AiAppDescription, CreateAiOptions } from './create-ai.ts';
10
+ export type {
11
+ Ai,
12
+ AiAppDescription,
13
+ CreateAiOptions,
14
+ DeliveredContext,
15
+ InProcessTurn,
16
+ InProcessTurnOptions,
17
+ InProcessTurnResult,
18
+ RunEndDetail,
19
+ } from './create-ai.ts';
20
+ export type { ChatEvent } from './run-types.ts';
11
21
 
12
22
  export { AdapterError, isLoopbackUrl, toPublicError } from './adapter.ts';
13
23
  export type { AdapterConfig, AdapterErrorCode, ProviderAdapter } from './adapter.ts';
@@ -28,6 +28,7 @@ import { AdapterError } from './adapter.ts';
28
28
  import type { AdapterConfig, ProviderAdapter } from './adapter.ts';
29
29
  import type { Registry } from './registry.ts';
30
30
  import type { AiContextProviders, AiTool, ContextDocument } from './tool.ts';
31
+ import type { DeliveredContext, RunEndDetail } from './create-ai.ts';
31
32
 
32
33
  /** What the run loop needs from the `Ai` that owns it. */
33
34
  export interface RunDeps {
@@ -53,7 +54,37 @@ export interface RunDeps {
53
54
  * outside the AI layer — Autoapp's run store — close the record the gate has
54
55
  * been writing steps into.
55
56
  */
56
- readonly onRunEnd?: (runId: string, status: 'succeeded' | 'failed' | 'cancelled', summary: string) => void;
57
+ readonly onRunEnd?: (
58
+ runId: string,
59
+ status: 'succeeded' | 'failed' | 'cancelled',
60
+ summary: string,
61
+ detail?: RunEndDetail,
62
+ ) => void;
63
+ /** Called once per turn with what the model is about to be given. */
64
+ readonly onContext?: (runId: string, delivered: DeliveredContext) => void;
65
+ }
66
+
67
+ /** What a turn counts as it goes, for {@link RunEndDetail}. */
68
+ interface TurnTally {
69
+ steps: number;
70
+ usage?: { inputTokens: number; outputTokens: number };
71
+ }
72
+
73
+ /**
74
+ * Call a listener's hook without letting it touch the turn.
75
+ *
76
+ * Both hooks exist so something outside the AI layer can write down what
77
+ * happened. A recorder that fails has failed at recording, not at the turn, so
78
+ * the failure is logged and the turn carries on exactly as it would have.
79
+ */
80
+ function safely(logger: HostLogger, hook: string, call: () => void): void {
81
+ try {
82
+ call();
83
+ } catch (cause) {
84
+ logger.error(
85
+ `[broapp] ai ${hook} hook failed: ${String(cause instanceof Error ? cause.message : cause)}`,
86
+ );
87
+ }
57
88
  }
58
89
 
59
90
  /**
@@ -177,7 +208,10 @@ async function assembleContext(
177
208
 
178
209
  await load(params.refs);
179
210
  if (searcher !== undefined) {
180
- const found = await searcher({ text: params.message, limit: SEARCH_LIMIT }, signal);
211
+ const found = await searcher(
212
+ { text: params.message, limit: SEARCH_LIMIT, runId: params.runId },
213
+ signal,
214
+ );
181
215
  await load(found.map((entry) => entry.ref));
182
216
  }
183
217
  return fitToBudget(documents, deps.contextBudgetChars);
@@ -421,13 +455,24 @@ export async function runChat(
421
455
  // browser cancelled and a turn that finished all have to close their record,
422
456
  // or a run store is left with something that looks like it is still running.
423
457
  let ended = false;
458
+ const started = Date.now();
459
+ const tally: TurnTally = { steps: 0 };
424
460
  const end = (status: 'succeeded' | 'failed' | 'cancelled'): void => {
425
461
  if (ended) return;
426
462
  ended = true;
427
- deps.onRunEnd?.(params.runId, status, params.message.slice(0, SUMMARY_CHARS));
463
+ const onRunEnd = deps.onRunEnd;
464
+ if (onRunEnd === undefined) return;
465
+ const detail: RunEndDetail = {
466
+ steps: tally.steps,
467
+ ms: Date.now() - started,
468
+ ...(tally.usage === undefined ? {} : { usage: tally.usage }),
469
+ };
470
+ safely(deps.logger, 'onRunEnd', () =>
471
+ onRunEnd(params.runId, status, params.message.slice(0, SUMMARY_CHARS), detail),
472
+ );
428
473
  };
429
474
  try {
430
- await runTurn(params, sink, deps, end);
475
+ await runTurn(params, sink, deps, end, tally);
431
476
  end(sink.signal.aborted ? 'cancelled' : 'succeeded');
432
477
  } catch (cause) {
433
478
  end(sink.signal.aborted ? 'cancelled' : 'failed');
@@ -441,6 +486,7 @@ async function runTurn(
441
486
  sink: StreamSink<ChatEvent>,
442
487
  deps: RunDeps,
443
488
  end: (status: 'succeeded' | 'failed' | 'cancelled') => void,
489
+ tally: TurnTally,
444
490
  ): Promise<void> {
445
491
  // Throws a PublicError when nothing is configured. `runStream` in host/app.ts
446
492
  // turns that into the right thing on the wire, so it is not caught here.
@@ -473,12 +519,28 @@ async function runTurn(
473
519
  requestId.startsWith(`${params.runId}:`) ? requestId.slice(params.runId.length + 1) : requestId,
474
520
  );
475
521
 
522
+ // Built once and handed to both the listener and the model, so what is
523
+ // written down is the string that was sent rather than a second rendering of
524
+ // it that might differ.
525
+ const system = buildSystemPrompt(deps, documents);
526
+ const onContext = deps.onContext;
527
+ if (onContext !== undefined) {
528
+ safely(deps.logger, 'onContext', () =>
529
+ onContext(params.runId, {
530
+ system,
531
+ documents,
532
+ message: params.message,
533
+ model: { provider: resolved.adapter.id, id: resolved.modelId },
534
+ }),
535
+ );
536
+ }
537
+
476
538
  const result = streamText({
477
539
  // Always a model *instance*. A string here would be resolved by the AI
478
540
  // SDK's gateway, over the global fetch, to a Vercel host — see
479
541
  // reports/01-spike.md. Nothing in this layer may pass one.
480
542
  model: resolved.adapter.model(resolved.config, resolved.modelId),
481
- system: buildSystemPrompt(deps, documents),
543
+ system,
482
544
  messages: toModelMessages(params),
483
545
  tools: buildTools(params, deps, sink, approver),
484
546
  stopWhen: stepCountIs(deps.maxSteps),
@@ -494,16 +556,23 @@ async function runTurn(
494
556
  case 'text-delta':
495
557
  await sink.emit({ type: 'text', text: part.text });
496
558
  break;
497
- case 'finish':
498
- await sink.emit({
499
- type: 'usage',
500
- // `ai` flattens the provider's nested usage object into plain
501
- // numbers, either of which a provider may omit.
559
+ case 'tool-call':
560
+ // Counted here rather than in `execute`: a call the SDK rejected before
561
+ // it ran was still a round trip the model spent.
562
+ tally.steps += 1;
563
+ break;
564
+ case 'finish': {
565
+ // `ai` flattens the provider's nested usage object into plain
566
+ // numbers, either of which a provider may omit.
567
+ const usage = {
502
568
  inputTokens: part.totalUsage.inputTokens ?? 0,
503
569
  outputTokens: part.totalUsage.outputTokens ?? 0,
504
- });
570
+ };
571
+ tally.usage = usage;
572
+ await sink.emit({ type: 'usage', ...usage });
505
573
  await sink.emit({ type: 'done' });
506
574
  break;
575
+ }
507
576
  case 'error':
508
577
  await sink.emit({
509
578
  type: 'error',
@@ -531,8 +600,8 @@ async function runTurn(
531
600
  end('cancelled');
532
601
  return;
533
602
  default:
534
- // tool-call, tool-result, text-start, finish-step, reasoning, source,
535
- // raw: either already emitted from `execute`, or not something the
603
+ // tool-result, text-start, finish-step, reasoning, source, raw:
604
+ // either already emitted from `execute`, or not something the
536
605
  // browser has a use for.
537
606
  break;
538
607
  }
@@ -47,7 +47,8 @@ export interface GuardedToolDefinition {
47
47
  readonly description: string;
48
48
  readonly inputSchema: JsonSchema;
49
49
  readonly effect: Effect;
50
- run(input: unknown, signal: AbortSignal): Promise<unknown>;
50
+ /** The envelope is the run loop's, never the model's; a tool may record it and must not act on its channel. */
51
+ run(input: unknown, signal: AbortSignal, envelope?: Envelope): Promise<unknown>;
51
52
  }
52
53
 
53
54
  /**
@@ -67,7 +68,7 @@ export function guardedTool(gate: Gate, tool: GuardedToolDefinition): GuardedToo
67
68
  effect: tool.effect,
68
69
  execute: (input, envelope) =>
69
70
  gate.guard({ ...envelope, route: tool.name, effect: tool.effect, input }, (signal) =>
70
- tool.run(input, signal),
71
+ tool.run(input, signal, envelope),
71
72
  ),
72
73
  };
73
74
  }
@@ -88,8 +89,13 @@ export interface ContextDocument {
88
89
 
89
90
  /** Where the model's knowledge of the application's data comes from. */
90
91
  export interface AiContextProviders {
91
- /** Records relevant to a query. Return refs and short snippets, not full content. */
92
- search?(query: { text: string; limit: number }, signal: AbortSignal): Promise<ContextRef[]>;
92
+ /**
93
+ * Records relevant to a query. Return refs and short snippets, not full content.
94
+ *
95
+ * `runId` is the turn asking, so a provider can write down what it offered
96
+ * to which run. It identifies; it grants nothing.
97
+ */
98
+ search?(query: { text: string; limit: number; runId?: string }, signal: AbortSignal): Promise<ContextRef[]>;
93
99
  /** Full content for named refs. Unknown refs are skipped, not errors. */
94
100
  resolve?(refs: readonly string[], signal: AbortSignal): Promise<ContextDocument[]>;
95
101
  }
package/src/cli/main.ts CHANGED
@@ -21,7 +21,7 @@ Usage:
21
21
  broapp build --page Build the UI document only
22
22
 
23
23
  Build options:
24
- --target <id> Compile for one target. Repeatable. Default: this machine.
24
+ --target <id> Compile for one target, named <name>-<id>. Repeatable. Default: this machine, named <name>.
25
25
  --all-targets Compile every supported target.
26
26
  --out-dir <path> Where executables go. Default: release
27
27
  --no-minify Keep the bundle readable.
@@ -158,6 +158,12 @@ async function main(): Promise<number> {
158
158
  name: config.binaryName,
159
159
  outDir: flags.outDir ?? config.outDir,
160
160
  targets,
161
+ // A binary built for a named target carries the target in its name,
162
+ // even when there is only one: a release workflow that asks for
163
+ // `--target linux-x64` is about to archive `<name>-linux-x64`, and a
164
+ // bare `<name>` there was the fault that emptied two releases. Only the
165
+ // default — this machine, nothing named — stays bare.
166
+ suffixTarget: flags.allTargets || flags.targets.length > 0,
161
167
  minify: flags.minify,
162
168
  bytecode: flags.bytecode && config.bytecode,
163
169
  });