broapp 0.2.0 → 0.3.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.2.0",
3
+ "version": "0.3.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",
@@ -50,7 +50,10 @@ export interface ProviderAdapter {
50
50
  /** Stable id, stored in settings: `'anthropic'`, `'ollama'`, `'fake'`. */
51
51
  readonly id: string;
52
52
  readonly label: string;
53
- readonly needs: { readonly apiKey: boolean; readonly baseUrl: 'required' | 'optional' | 'none' };
53
+ readonly needs: {
54
+ readonly apiKey: 'required' | 'optional' | 'none';
55
+ readonly baseUrl: 'required' | 'optional' | 'none';
56
+ };
54
57
  readonly defaultBaseUrl: string | null;
55
58
  /** Whether requests stay on this machine under this config. */
56
59
  local(config: AdapterConfig): boolean;
@@ -17,7 +17,7 @@ import type { Bridge } from 'brobridge';
17
17
  // is what makes a browser bundle of `broapp/ai/host` fail to build. Bun's
18
18
  // browser target polyfills `node:fs`, so the file stores alone would not stop
19
19
  // this code from being bundled into a page.
20
- import { createReservedHostApp } from '../../host/index.ts';
20
+ import { createPendingApprovals, createReservedHostApp } from '../../host/index.ts';
21
21
  import type { HostApp, HostLogger } from '../../host/app.ts';
22
22
  import { publicError } from '../../shared/errors.ts';
23
23
  import { aiContract, type AiContract } from '../shared/contract.ts';
@@ -28,13 +28,23 @@ import { createRegistry, type Registry } from './registry.ts';
28
28
  import { runChat, type RunDeps } from './run.ts';
29
29
  import { createFileSecretStore, createMemorySecretStore } from './secrets.ts';
30
30
  import { createSettingsStore } from './settings.ts';
31
- import { createConfirmations, type AiContextProviders, type AiTool } from './tool.ts';
31
+ import { openThreads, type ThreadStore } from './threads.ts';
32
+ import { GUARDED, type AiContextProviders, type AiTool } from './tool.ts';
32
33
 
33
34
  /** What the application is, in the words a model is given. */
34
35
  export interface AiAppDescription {
35
36
  readonly name: string;
36
37
  readonly purpose: string;
37
38
  readonly terminology?: readonly string[];
39
+ /**
40
+ * Extra standing instructions, appended verbatim after the purpose.
41
+ *
42
+ * For an assistant whose job needs more than a sentence to describe — the
43
+ * shape of a workspace it edits, a sequence it has to follow, things it may
44
+ * not do. It is host-authored text, not anything a browser or a model
45
+ * supplied, and it goes in front of the documents rather than among them.
46
+ */
47
+ readonly instructions?: string;
38
48
  }
39
49
 
40
50
  /** Options for {@link createAi}. */
@@ -53,6 +63,19 @@ export interface CreateAiOptions {
53
63
  readonly maxSteps?: number;
54
64
  /** How long a `confirm` tool waits for the user. Default 300_000 ms. */
55
65
  readonly confirmTimeoutMs?: number;
66
+ /**
67
+ * Called once when a chat turn ends, however it ends.
68
+ *
69
+ * Autoapp's run store uses it to close the record the gate has been writing
70
+ * steps into: the browser's run identifier is the prefix of every request
71
+ * identifier the turn produced, so this is the one signal that ties the two
72
+ * together. An application that does not record runs leaves it unset.
73
+ */
74
+ readonly onRunEnd?: (
75
+ runId: string,
76
+ status: 'succeeded' | 'failed' | 'cancelled',
77
+ summary: string,
78
+ ) => void;
56
79
  }
57
80
 
58
81
  /**
@@ -68,6 +91,16 @@ const TOOL_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_.]*$/;
68
91
  export interface Ai {
69
92
  mount(bridge: Bridge): void;
70
93
  abortAll(reason: string): void;
94
+ /**
95
+ * Release what the layer holds open. Call it from the application's
96
+ * shutdown, beside `abortAll`.
97
+ *
98
+ * Today that is the conversation store: closing it checkpoints the WAL, so
99
+ * what is left on disk is one complete database rather than one that needs
100
+ * its sidecars. Calling it twice is harmless, and an application that never
101
+ * opened a conversation has nothing to close.
102
+ */
103
+ close(): void;
71
104
  readonly activeStreams: number;
72
105
  /** For tests, and for applications that read settings on the host. */
73
106
  readonly registry: Registry;
@@ -93,10 +126,19 @@ export function createAi(options: CreateAiOptions): Ai {
93
126
  }
94
127
  seen.add(adapter.id);
95
128
  }
96
- for (const name of Object.keys(options.tools ?? {})) {
129
+ for (const [name, definition] of Object.entries(options.tools ?? {})) {
97
130
  if (!TOOL_NAME_PATTERN.test(name)) {
98
131
  throw new TypeError(`tool name ${JSON.stringify(name)} must be letters, digits, "_" or "."`);
99
132
  }
133
+ // A tool is host code that a model gets to trigger. Whether it asked
134
+ // anybody first is not visible in its type, so the brand is required
135
+ // rather than hoped for: an application cannot hand a model an ungated
136
+ // capability by forgetting one wrapper.
137
+ if ((definition as { [GUARDED]?: true })[GUARDED] !== true) {
138
+ throw new TypeError(
139
+ `tool ${JSON.stringify(name)} does not pass the gate; build it with guardedTool()`,
140
+ );
141
+ }
100
142
  }
101
143
 
102
144
  // Both stores are built once and kept. `remember` chooses between them, and
@@ -162,7 +204,7 @@ export function createAi(options: CreateAiOptions): Ai {
162
204
  }
163
205
  });
164
206
 
165
- const confirmations = createConfirmations();
207
+ const approvals = createPendingApprovals(options.logger);
166
208
  const runDeps: RunDeps = {
167
209
  registry,
168
210
  app: options.app,
@@ -171,15 +213,35 @@ export function createAi(options: CreateAiOptions): Ai {
171
213
  contextBudgetChars: options.contextBudgetChars ?? DEFAULT_CONTEXT_BUDGET_CHARS,
172
214
  maxSteps: options.maxSteps ?? DEFAULT_MAX_STEPS,
173
215
  confirmTimeoutMs: options.confirmTimeoutMs ?? DEFAULT_CONFIRM_TIMEOUT_MS,
174
- confirmations,
216
+ approvals,
217
+ ...(options.onRunEnd === undefined ? {} : { onRunEnd: options.onRunEnd }),
175
218
  logger: options.logger ?? console,
176
219
  };
177
220
 
178
221
  host.stream('ai.chat', (params, sink) => runChat(params, sink, runDeps));
222
+ // The wire shape is unchanged: a run and a call name the question, and
223
+ // `accepted` says whether anybody was waiting on it. What changed is where
224
+ // the answer goes — into the same approval table the gate asks.
179
225
  host.operation('ai.chatConfirm', ({ runId, callId, approve }) => ({
180
- accepted: confirmations.answer(runId, callId, approve),
226
+ accepted:
227
+ approvals.answer({ requestId: `${runId}:${callId}`, approved: approve }) === 'accepted',
181
228
  }));
182
229
 
230
+ // Opened on the first conversation route and not before: an application
231
+ // whose user never opens the panel should not find a database in its data
232
+ // directory, and `createAi` is built unconditionally by every application
233
+ // that offers AI at all.
234
+ let threads: ThreadStore | null = null;
235
+ const threadStore = (): ThreadStore => (threads ??= openThreads(options.dataDir));
236
+
237
+ host.operation('ai.threadsList', () => ({ threads: threadStore().list() }));
238
+ host.operation('ai.threadsCreate', (input) => threadStore().create(input));
239
+ host.operation('ai.threadsGet', ({ id }) => threadStore().get(id));
240
+ host.operation('ai.threadsSave', (input) => threadStore().save(input));
241
+ host.operation('ai.threadsUpdate', (input) => threadStore().update(input));
242
+ host.operation('ai.threadsDelete', ({ id }) => ({ deleted: threadStore().remove(id) }));
243
+ host.operation('ai.threadsClear', () => ({ deleted: threadStore().clear() }));
244
+
183
245
  /** The current provider config, or the "not set up" error. */
184
246
  async function requireConfig(): Promise<{ adapter: ProviderAdapter; config: AdapterConfig }> {
185
247
  const current = await registry.currentConfig();
@@ -192,6 +254,10 @@ export function createAi(options: CreateAiOptions): Ai {
192
254
  return {
193
255
  mount: (bridge: Bridge) => host.mount(bridge),
194
256
  abortAll: (reason: string) => host.abortAll(reason),
257
+ close: () => {
258
+ threads?.close();
259
+ threads = null;
260
+ },
195
261
  get activeStreams() {
196
262
  return host.activeStreams;
197
263
  },
@@ -48,6 +48,13 @@ export interface FakeAdapterOptions {
48
48
  readonly script?: readonly FakeStep[];
49
49
  /** Delay between chunks, so a cancel test can catch a stream mid-flight. */
50
50
  readonly chunkDelayMs?: number;
51
+ /**
52
+ * Whether the default model says it can read images. Default `false`.
53
+ *
54
+ * The default stays `false` so a test that does not mention images keeps
55
+ * proving that a turn with images is refused by a model that cannot see.
56
+ */
57
+ readonly vision?: boolean;
51
58
  }
52
59
 
53
60
  /** A fake adapter, plus what the test wants to know about it afterwards. */
@@ -60,12 +67,12 @@ export interface FakeAdapter extends ProviderAdapter {
60
67
  readonly aborted: number;
61
68
  }
62
69
 
63
- function defaultModel(providerId: string): BroappModel {
70
+ function defaultModel(providerId: string, vision: boolean): BroappModel {
64
71
  return {
65
72
  provider: providerId,
66
73
  modelId: 'fake-1',
67
74
  label: 'Fake 1',
68
- capabilities: { tools: true, vision: false, structuredOutput: true },
75
+ capabilities: { tools: true, vision, structuredOutput: true },
69
76
  };
70
77
  }
71
78
 
@@ -127,7 +134,7 @@ function chunksFor(step: FakeStep, callIndex: number): StreamPart[] {
127
134
  /** Build an adapter that needs no provider. */
128
135
  export function createFakeAdapter(options: FakeAdapterOptions = {}): FakeAdapter {
129
136
  const id = options.id ?? 'fake';
130
- const models = options.models ?? [defaultModel(id)];
137
+ const models = options.models ?? [defaultModel(id, options.vision === true)];
131
138
  const script = options.script ?? [{ kind: 'text', chunks: ['fake reply'] } as const];
132
139
  const steps = flatten(script);
133
140
  const delay = options.chunkDelayMs ?? 0;
@@ -140,7 +147,7 @@ export function createFakeAdapter(options: FakeAdapterOptions = {}): FakeAdapter
140
147
  const adapter: FakeAdapter = {
141
148
  id,
142
149
  label: 'Fake provider',
143
- needs: { apiKey: options.needsKey === true, baseUrl: 'none' },
150
+ needs: { apiKey: options.needsKey === true ? 'required' : 'none', baseUrl: 'none' },
144
151
  defaultBaseUrl: null,
145
152
  // Nothing leaves the process, so this is true whatever the configuration.
146
153
  local: () => true,
@@ -7,17 +7,20 @@
7
7
  * that does not exist, and a change to an operation's input reaches the tool
8
8
  * description without anybody remembering to update it.
9
9
  *
10
- * The permission split is the important part. `read` operations run as soon as
11
- * the model asks; `confirm` operations wait for the user. Nothing is a tool
12
- * unless it is named here, so the default for an application's surface is that
13
- * the model cannot reach it.
10
+ * The lists here *select*; they no longer decide. What a call is allowed to do
11
+ * is the route's own `effect`, and the gate reads it from the contract. The
12
+ * list a route sits in has to agree with what it declares, and a route that
13
+ * declares nothing takes the list's word for it — which is how an application
14
+ * written before effects existed still says what it meant. Nothing is a tool
15
+ * unless it is named here, so the default for an application's surface is
16
+ * still that the model cannot reach it.
14
17
  */
15
18
  import type { HostApp } from '../../host/app.ts';
19
+ import type { Effect } from '../../shared/contract.ts';
16
20
  import type { AnyContract, OperationName } from '../../shared/contract.ts';
17
21
  import type { JsonSchema } from '../../shared/schema.ts';
18
- import type { ToolPermission } from '../shared/types.ts';
19
22
 
20
- import type { AiTool } from './tool.ts';
23
+ import { GUARDED, type GuardedTool } from './tool.ts';
21
24
 
22
25
  /** Which operations a model may call, and how much ceremony each needs. */
23
26
  export interface ContractToolAllowList<C extends AnyContract> {
@@ -25,12 +28,15 @@ export interface ContractToolAllowList<C extends AnyContract> {
25
28
  readonly confirm?: readonly OperationName<C>[];
26
29
  }
27
30
 
31
+ /** What each list means about a route that does not declare an effect. */
32
+ const IMPLIED: Record<'read' | 'confirm', Effect> = { read: 'read', confirm: 'write' };
33
+
28
34
  /** Build tools from operations the contract already describes. */
29
35
  export function fromContract<C extends AnyContract>(
30
36
  contract: C,
31
37
  app: HostApp<C>,
32
38
  allow: ContractToolAllowList<C>,
33
- ): Record<string, AiTool> {
39
+ ): Record<string, GuardedTool> {
34
40
  const read = allow.read ?? [];
35
41
  const confirm = allow.confirm ?? [];
36
42
 
@@ -41,12 +47,12 @@ export function fromContract<C extends AnyContract>(
41
47
  );
42
48
  }
43
49
 
44
- const tools: Record<string, AiTool> = {};
45
- const groups: readonly (readonly [readonly OperationName<C>[], ToolPermission])[] = [
50
+ const tools: Record<string, GuardedTool> = {};
51
+ const groups: readonly (readonly [readonly OperationName<C>[], 'read' | 'confirm'])[] = [
46
52
  [read, 'read'],
47
53
  [confirm, 'confirm'],
48
54
  ];
49
- for (const [routes, permission] of groups) {
55
+ for (const [routes, list] of groups) {
50
56
  for (const route of routes) {
51
57
  const spec = contract.operations[route];
52
58
  if (spec === undefined) {
@@ -59,6 +65,17 @@ export function fromContract<C extends AnyContract>(
59
65
  `operation ${JSON.stringify(route)} needs a summary before it can be offered to a model`,
60
66
  );
61
67
  }
68
+ const declared = spec.effect;
69
+ // A list that disagrees with the contract is a misunderstanding about
70
+ // what an operation does, and the two readings differ in exactly the way
71
+ // that matters: one asks the user and the other does not. Neither is
72
+ // safe to guess at, so it is refused where a developer can see it.
73
+ if (declared !== undefined && declared !== IMPLIED[list] && !(list === 'confirm' && declared === 'external')) {
74
+ throw new TypeError(
75
+ `operation ${JSON.stringify(route)} is listed as a ${list} tool but declares effect ${JSON.stringify(declared)}`,
76
+ );
77
+ }
78
+ const effect: Effect = declared ?? IMPLIED[list];
62
79
  const describe = (spec.input as { toJsonSchema?: () => JsonSchema }).toJsonSchema;
63
80
  if (typeof describe !== 'function') {
64
81
  throw new TypeError(
@@ -71,13 +88,16 @@ export function fromContract<C extends AnyContract>(
71
88
  // lost by turning it back into "no argument" here.
72
89
  const takesNothing = spec.input.kind === 'void';
73
90
  tools[route] = {
91
+ [GUARDED]: true,
74
92
  description: spec.summary,
75
93
  inputSchema: describe.call(spec.input),
76
- permission,
77
- // `invoke` validates the input and applies the same error boundary a
78
- // call from the browser gets, so a model's arguments are no more
79
- // trusted than a tab's.
80
- execute: (input) => app.invoke(route, takesNothing ? undefined : input),
94
+ effect,
95
+ // `invoke` validates the input, guards it and applies the same error
96
+ // boundary a call from the browser gets, so a model's arguments are no
97
+ // more trusted than a tab's. The hint is what the list decided, and it
98
+ // only applies where the contract itself is silent.
99
+ execute: (input, envelope) =>
100
+ app.invoke(route, takesNothing ? undefined : input, { ...envelope, effectHint: effect }),
81
101
  };
82
102
  }
83
103
  }
@@ -18,15 +18,20 @@ export type { FakeAdapter, FakeAdapterOptions, FakeStep } from './fake.ts';
18
18
  export { fromContract } from './from-contract.ts';
19
19
  export type { ContractToolAllowList } from './from-contract.ts';
20
20
 
21
+ export { GUARDED, guardedTool } from './tool.ts';
21
22
  export type {
22
23
  AiContextProviders,
23
24
  AiTool,
24
- Confirmations,
25
25
  ContextDocument,
26
26
  ContextRef,
27
+ GuardedTool,
28
+ GuardedToolDefinition,
27
29
  } from './tool.ts';
28
30
 
29
31
  export { apiKeySecretName, createFileSecretStore, createMemorySecretStore } from './secrets.ts';
30
32
  export type { SecretStore } from './secrets.ts';
31
33
 
32
34
  export type { Registry, ResolvedModel, UpdatePatch } from './registry.ts';
35
+
36
+ export { DEFAULT_THREAD_TITLE, openThreads } from './threads.ts';
37
+ export type { ThreadStore } from './threads.ts';
@@ -7,7 +7,7 @@
7
7
  * "would `resolve()` succeed", rather than a second copy of the same
8
8
  * conditions that can drift from the first.
9
9
  */
10
- import { PublicError, publicError } from '../../shared/errors.ts';
10
+ import { isPublicError, publicError } from '../../shared/errors.ts';
11
11
  import type { AiSettings } from '../shared/types.ts';
12
12
 
13
13
  import type { AdapterConfig, ProviderAdapter } from './adapter.ts';
@@ -36,8 +36,15 @@ export interface Registry {
36
36
  adapter(id: string): ProviderAdapter | null;
37
37
  /** Current settings plus the key, for adapter calls. */
38
38
  currentConfig(): Promise<{ adapter: ProviderAdapter; config: AdapterConfig } | null>;
39
- /** Everything needed to run a chat, or a `PublicError` explaining what is missing. */
40
- resolve(): Promise<ResolvedModel>;
39
+ /**
40
+ * Everything needed to run a chat, or a `PublicError` explaining what is
41
+ * missing.
42
+ *
43
+ * `override.modelId` replaces the model Settings names, for this call only
44
+ * and only within the configured provider — a conversation may pin a model,
45
+ * never a vendor.
46
+ */
47
+ resolve(override?: { readonly modelId?: string | undefined }): Promise<ResolvedModel>;
41
48
  /** The public view: settings without the key. */
42
49
  settings(): Promise<AiSettings>;
43
50
  update(patch: UpdatePatch): Promise<AiSettings>;
@@ -112,7 +119,7 @@ export function createRegistry(options: RegistryOptions): Registry {
112
119
  return { adapter, config: configFrom(settings, adapter, apiKey) };
113
120
  },
114
121
 
115
- async resolve() {
122
+ async resolve(override) {
116
123
  const settings = options.settingsStore.read();
117
124
  if (settings.provider === null) throw publicError.unavailable(NOT_SET_UP);
118
125
  const adapter = byId.get(settings.provider);
@@ -123,20 +130,24 @@ export function createRegistry(options: RegistryOptions): Registry {
123
130
  // models is fetched *from* the provider, so telling a user to choose one
124
131
  // before they can see any is an instruction they cannot follow.
125
132
  const apiKey = await keyFor(settings, adapter.id);
126
- if (adapter.needs.apiKey && (apiKey === null || apiKey === '')) {
133
+ if (adapter.needs.apiKey === 'required' && (apiKey === null || apiKey === '')) {
127
134
  throw publicError.unavailable(`An API key is required for ${adapter.label}.`);
128
135
  }
129
136
  const config = configFrom(settings, adapter, apiKey);
130
137
  if (adapter.needs.baseUrl === 'required' && (config.baseUrl === null || config.baseUrl === '')) {
131
138
  throw publicError.unavailable(`A server address is required for ${adapter.label}.`);
132
139
  }
133
- if (settings.modelId === null) {
140
+ // Read after the provider, the key and the address, so a conversation
141
+ // carrying its own model still hears "AI is not set up yet" first: the
142
+ // model is the last thing missing, never the first.
143
+ const modelId = override?.modelId ?? settings.modelId;
144
+ if (modelId === null) {
134
145
  // Distinct from "not set up": the user is looking at the settings panel
135
146
  // with a provider selected, and being told to choose a provider is an
136
147
  // instruction they have already followed.
137
148
  throw publicError.unavailable(`Choose a model for ${adapter.label}.`);
138
149
  }
139
- return { adapter, config, modelId: settings.modelId };
150
+ return { adapter, config, modelId };
140
151
  },
141
152
 
142
153
  async settings() {
@@ -149,7 +160,7 @@ export function createRegistry(options: RegistryOptions): Registry {
149
160
  } catch (cause) {
150
161
  // Anything that is not a deliberate "not configured" is a real fault
151
162
  // and must not be reported as merely unconfigured.
152
- if (!(cause instanceof PublicError)) throw cause;
163
+ if (!isPublicError(cause)) throw cause;
153
164
  configured = false;
154
165
  }
155
166
  return {