broapp 0.1.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.
Files changed (39) hide show
  1. package/README.md +13 -4
  2. package/package.json +10 -2
  3. package/src/ai/host/adapter.ts +109 -0
  4. package/src/ai/host/create-ai.ts +266 -0
  5. package/src/ai/host/fake.ts +230 -0
  6. package/src/ai/host/from-contract.ts +105 -0
  7. package/src/ai/host/index.ts +37 -0
  8. package/src/ai/host/registry.ts +232 -0
  9. package/src/ai/host/run-types.ts +14 -0
  10. package/src/ai/host/run.ts +540 -0
  11. package/src/ai/host/secrets.ts +118 -0
  12. package/src/ai/host/settings.ts +83 -0
  13. package/src/ai/host/threads.ts +366 -0
  14. package/src/ai/host/tool.ts +95 -0
  15. package/src/ai/react/AiChat.tsx +242 -0
  16. package/src/ai/react/AiSettings.tsx +228 -0
  17. package/src/ai/react/ai.css +166 -0
  18. package/src/ai/react/index.tsx +38 -0
  19. package/src/ai/react/provider.tsx +97 -0
  20. package/src/ai/react/use-ai-chat.ts +317 -0
  21. package/src/ai/react/use-ai-models.ts +70 -0
  22. package/src/ai/react/use-ai-settings.ts +105 -0
  23. package/src/ai/shared/contract.ts +247 -0
  24. package/src/ai/shared/index.ts +19 -0
  25. package/src/ai/shared/types.check.ts +71 -0
  26. package/src/ai/shared/types.ts +147 -0
  27. package/src/host/app.ts +183 -36
  28. package/src/host/approvals.ts +115 -0
  29. package/src/host/gate.ts +380 -0
  30. package/src/host/index.ts +25 -0
  31. package/src/host/paths.ts +6 -2
  32. package/src/host/runtime.ts +23 -2
  33. package/src/react/hooks.tsx +37 -3
  34. package/src/react/index.ts +1 -0
  35. package/src/shared/contract.ts +99 -2
  36. package/src/shared/countdown.ts +36 -0
  37. package/src/shared/errors.ts +66 -2
  38. package/src/shared/index.ts +14 -3
  39. package/src/shared/schema.ts +141 -28
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Turning an application's own operations into tools a model may call.
3
+ *
4
+ * The contract already says what each operation takes, what it returns and,
5
+ * in its `summary`, what it is for — which is exactly what a tool definition
6
+ * needs. Deriving tools from it means a model cannot be offered an operation
7
+ * that does not exist, and a change to an operation's input reaches the tool
8
+ * description without anybody remembering to update it.
9
+ *
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.
17
+ */
18
+ import type { HostApp } from '../../host/app.ts';
19
+ import type { Effect } from '../../shared/contract.ts';
20
+ import type { AnyContract, OperationName } from '../../shared/contract.ts';
21
+ import type { JsonSchema } from '../../shared/schema.ts';
22
+
23
+ import { GUARDED, type GuardedTool } from './tool.ts';
24
+
25
+ /** Which operations a model may call, and how much ceremony each needs. */
26
+ export interface ContractToolAllowList<C extends AnyContract> {
27
+ readonly read?: readonly OperationName<C>[];
28
+ readonly confirm?: readonly OperationName<C>[];
29
+ }
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
+
34
+ /** Build tools from operations the contract already describes. */
35
+ export function fromContract<C extends AnyContract>(
36
+ contract: C,
37
+ app: HostApp<C>,
38
+ allow: ContractToolAllowList<C>,
39
+ ): Record<string, GuardedTool> {
40
+ const read = allow.read ?? [];
41
+ const confirm = allow.confirm ?? [];
42
+
43
+ const both = read.filter((route) => (confirm as readonly string[]).includes(route));
44
+ if (both.length > 0) {
45
+ throw new TypeError(
46
+ `operation ${JSON.stringify(both[0])} is listed as both a read tool and a confirm tool`,
47
+ );
48
+ }
49
+
50
+ const tools: Record<string, GuardedTool> = {};
51
+ const groups: readonly (readonly [readonly OperationName<C>[], 'read' | 'confirm'])[] = [
52
+ [read, 'read'],
53
+ [confirm, 'confirm'],
54
+ ];
55
+ for (const [routes, list] of groups) {
56
+ for (const route of routes) {
57
+ const spec = contract.operations[route];
58
+ if (spec === undefined) {
59
+ throw new TypeError(`operation ${JSON.stringify(route)} is not declared in the contract`);
60
+ }
61
+ if (spec.summary === undefined || spec.summary === '') {
62
+ // Without a summary the model is told a name and nothing else, and it
63
+ // will guess. Better to refuse at startup than to guess in production.
64
+ throw new TypeError(
65
+ `operation ${JSON.stringify(route)} needs a summary before it can be offered to a model`,
66
+ );
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];
79
+ const describe = (spec.input as { toJsonSchema?: () => JsonSchema }).toJsonSchema;
80
+ if (typeof describe !== 'function') {
81
+ throw new TypeError(
82
+ `operation ${JSON.stringify(route)} uses a validator with no toJsonSchema(); pass a hand-written tool for it instead`,
83
+ );
84
+ }
85
+ // An operation taking `s.void()` is described to the model as an object
86
+ // with no properties, because that is what a tool's arguments have to
87
+ // be. The model then sends `{}`, which `s.void()` refuses. Nothing is
88
+ // lost by turning it back into "no argument" here.
89
+ const takesNothing = spec.input.kind === 'void';
90
+ tools[route] = {
91
+ [GUARDED]: true,
92
+ description: spec.summary,
93
+ inputSchema: describe.call(spec.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 }),
101
+ };
102
+ }
103
+ }
104
+ return tools;
105
+ }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * `broapp/ai/host` — the AI layer's host runtime.
3
+ *
4
+ * This is the only entry point that reaches the AI SDK. Never import it from
5
+ * browser code: the page's CSP allows `connect-src 'self'` and loopback only,
6
+ * so a browser that could reach a provider would be a bug, and a bundler that
7
+ * follows this import fails loudly instead.
8
+ */
9
+ export { createAi } from './create-ai.ts';
10
+ export type { Ai, AiAppDescription, CreateAiOptions } from './create-ai.ts';
11
+
12
+ export { AdapterError, isLoopbackUrl, toPublicError } from './adapter.ts';
13
+ export type { AdapterConfig, AdapterErrorCode, ProviderAdapter } from './adapter.ts';
14
+
15
+ export { createFakeAdapter } from './fake.ts';
16
+ export type { FakeAdapter, FakeAdapterOptions, FakeStep } from './fake.ts';
17
+
18
+ export { fromContract } from './from-contract.ts';
19
+ export type { ContractToolAllowList } from './from-contract.ts';
20
+
21
+ export { GUARDED, guardedTool } from './tool.ts';
22
+ export type {
23
+ AiContextProviders,
24
+ AiTool,
25
+ ContextDocument,
26
+ ContextRef,
27
+ GuardedTool,
28
+ GuardedToolDefinition,
29
+ } from './tool.ts';
30
+
31
+ export { apiKeySecretName, createFileSecretStore, createMemorySecretStore } from './secrets.ts';
32
+ export type { SecretStore } from './secrets.ts';
33
+
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';
@@ -0,0 +1,232 @@
1
+ /**
2
+ * Settings plus adapters, resolved into "the model to use right now".
3
+ *
4
+ * Every route that needs a provider goes through here, so the rules about what
5
+ * counts as configured are written once. `resolve()` is the single source of
6
+ * that truth: `configured` in the settings the browser sees is literally
7
+ * "would `resolve()` succeed", rather than a second copy of the same
8
+ * conditions that can drift from the first.
9
+ */
10
+ import { isPublicError, publicError } from '../../shared/errors.ts';
11
+ import type { AiSettings } from '../shared/types.ts';
12
+
13
+ import type { AdapterConfig, ProviderAdapter } from './adapter.ts';
14
+ import { apiKeySecretName, type SecretStore } from './secrets.ts';
15
+ import type { SettingsStore, StoredSettings } from './settings.ts';
16
+
17
+ /** Everything needed to run one chat turn. */
18
+ export interface ResolvedModel {
19
+ readonly adapter: ProviderAdapter;
20
+ readonly config: AdapterConfig;
21
+ readonly modelId: string;
22
+ }
23
+
24
+ /** The fields `ai.settings.update` may change. */
25
+ export interface UpdatePatch {
26
+ readonly provider?: string | undefined;
27
+ readonly modelId?: string | undefined;
28
+ readonly baseUrl?: string | null | undefined;
29
+ readonly apiKey?: string | null | undefined;
30
+ readonly remember?: boolean | undefined;
31
+ }
32
+
33
+ /** The adapters this build has, and the settings pointing at one of them. */
34
+ export interface Registry {
35
+ readonly adapters: readonly ProviderAdapter[];
36
+ adapter(id: string): ProviderAdapter | null;
37
+ /** Current settings plus the key, for adapter calls. */
38
+ currentConfig(): Promise<{ adapter: ProviderAdapter; config: AdapterConfig } | null>;
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>;
48
+ /** The public view: settings without the key. */
49
+ settings(): Promise<AiSettings>;
50
+ update(patch: UpdatePatch): Promise<AiSettings>;
51
+ /** The config an adapter would get today, ignoring which one is selected. */
52
+ configFor(adapter: ProviderAdapter): AdapterConfig;
53
+ }
54
+
55
+ /** What {@link createRegistry} needs from its surroundings. */
56
+ export interface RegistryOptions {
57
+ readonly adapters: readonly ProviderAdapter[];
58
+ readonly settingsStore: SettingsStore;
59
+ readonly fileSecrets: SecretStore;
60
+ readonly memorySecrets: SecretStore;
61
+ readonly fetch: typeof fetch;
62
+ }
63
+
64
+ const NOT_SET_UP = 'AI is not set up yet. Open Settings to choose a provider.';
65
+
66
+ /**
67
+ * A key is hinted by its last four characters, and only when it is long
68
+ * enough that four characters are a small fraction of it. A short key would
69
+ * be half-published by its own hint.
70
+ */
71
+ function hint(key: string | null): string | null {
72
+ if (key === null || key.length < 8) return null;
73
+ return key.slice(-4);
74
+ }
75
+
76
+ export function createRegistry(options: RegistryOptions): Registry {
77
+ const byId = new Map(options.adapters.map((adapter) => [adapter.id, adapter]));
78
+
79
+ /** The store the key currently lives in, which `remember` decides. */
80
+ function store(settings: StoredSettings): SecretStore {
81
+ return settings.remember ? options.fileSecrets : options.memorySecrets;
82
+ }
83
+
84
+ async function keyFor(settings: StoredSettings, providerId: string): Promise<string | null> {
85
+ return store(settings).get(apiKeySecretName(providerId));
86
+ }
87
+
88
+ function configFrom(settings: StoredSettings, adapter: ProviderAdapter, apiKey: string | null): AdapterConfig {
89
+ return {
90
+ apiKey,
91
+ baseUrl: settings.baseUrl ?? adapter.defaultBaseUrl,
92
+ fetch: options.fetch,
93
+ };
94
+ }
95
+
96
+ const registry: Registry = {
97
+ adapters: options.adapters,
98
+
99
+ adapter: (id) => byId.get(id) ?? null,
100
+
101
+ configFor(adapter) {
102
+ const settings = options.settingsStore.read();
103
+ // The stored base URL belongs to the *selected* provider. Applying it to
104
+ // the others would have told the user that Anthropic runs on their
105
+ // computer, simply because they had Ollama selected a moment ago.
106
+ const scoped: StoredSettings =
107
+ settings.provider === adapter.id ? settings : { ...settings, baseUrl: null };
108
+ // No key either: whether a provider stays on this machine is a property
109
+ // of the address, and the answer must not depend on what is stored.
110
+ return configFrom(scoped, adapter, null);
111
+ },
112
+
113
+ async currentConfig() {
114
+ const settings = options.settingsStore.read();
115
+ if (settings.provider === null) return null;
116
+ const adapter = byId.get(settings.provider);
117
+ if (adapter === undefined) return null;
118
+ const apiKey = await keyFor(settings, adapter.id);
119
+ return { adapter, config: configFrom(settings, adapter, apiKey) };
120
+ },
121
+
122
+ async resolve(override) {
123
+ const settings = options.settingsStore.read();
124
+ if (settings.provider === null) throw publicError.unavailable(NOT_SET_UP);
125
+ const adapter = byId.get(settings.provider);
126
+ if (adapter === undefined) {
127
+ throw publicError.unavailable('The configured AI provider is not available in this build.');
128
+ }
129
+ // The key and the address come before the model on purpose: the list of
130
+ // models is fetched *from* the provider, so telling a user to choose one
131
+ // before they can see any is an instruction they cannot follow.
132
+ const apiKey = await keyFor(settings, adapter.id);
133
+ if (adapter.needs.apiKey === 'required' && (apiKey === null || apiKey === '')) {
134
+ throw publicError.unavailable(`An API key is required for ${adapter.label}.`);
135
+ }
136
+ const config = configFrom(settings, adapter, apiKey);
137
+ if (adapter.needs.baseUrl === 'required' && (config.baseUrl === null || config.baseUrl === '')) {
138
+ throw publicError.unavailable(`A server address is required for ${adapter.label}.`);
139
+ }
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) {
145
+ // Distinct from "not set up": the user is looking at the settings panel
146
+ // with a provider selected, and being told to choose a provider is an
147
+ // instruction they have already followed.
148
+ throw publicError.unavailable(`Choose a model for ${adapter.label}.`);
149
+ }
150
+ return { adapter, config, modelId };
151
+ },
152
+
153
+ async settings() {
154
+ const settings = options.settingsStore.read();
155
+ const apiKey =
156
+ settings.provider === null ? null : await keyFor(settings, settings.provider);
157
+ let configured = true;
158
+ try {
159
+ await registry.resolve();
160
+ } catch (cause) {
161
+ // Anything that is not a deliberate "not configured" is a real fault
162
+ // and must not be reported as merely unconfigured.
163
+ if (!isPublicError(cause)) throw cause;
164
+ configured = false;
165
+ }
166
+ return {
167
+ provider: settings.provider,
168
+ modelId: settings.modelId,
169
+ baseUrl: settings.baseUrl,
170
+ hasKey: apiKey !== null && apiKey !== '',
171
+ keyHint: hint(apiKey),
172
+ remember: settings.remember,
173
+ configured,
174
+ };
175
+ },
176
+
177
+ async update(patch) {
178
+ const before = options.settingsStore.read();
179
+ const next: StoredSettings = { ...before };
180
+
181
+ if (patch.provider !== undefined) {
182
+ if (!byId.has(patch.provider)) throw publicError.invalidInput('Unknown provider.');
183
+ if (patch.provider !== before.provider) {
184
+ next.provider = patch.provider;
185
+ // A model id belongs to the provider that offers it, and a base URL
186
+ // points at that provider's server. Carrying either across a change
187
+ // would leave the settings describing something that does not exist.
188
+ next.modelId = null;
189
+ next.baseUrl = byId.get(patch.provider)?.defaultBaseUrl ?? null;
190
+ }
191
+ }
192
+ if (patch.modelId !== undefined) next.modelId = patch.modelId;
193
+ if (patch.baseUrl !== undefined) next.baseUrl = patch.baseUrl;
194
+
195
+ if (patch.remember !== undefined && patch.remember !== before.remember) {
196
+ next.remember = patch.remember;
197
+ await moveKeys(before, next);
198
+ }
199
+
200
+ if (patch.apiKey !== undefined && next.provider !== null) {
201
+ const name = apiKeySecretName(next.provider);
202
+ const value = patch.apiKey === null || patch.apiKey === '' ? null : patch.apiKey;
203
+ if (value === null) await store(next).delete(name);
204
+ else await store(next).set(name, value);
205
+ }
206
+
207
+ options.settingsStore.write(next);
208
+ return registry.settings();
209
+ },
210
+ };
211
+
212
+ /**
213
+ * Move every stored key to the store `after` selects.
214
+ *
215
+ * Turning `remember` off must not merely stop future writes: the key already
216
+ * on disk has to leave the disk, or the setting would be a promise the
217
+ * layer does not keep.
218
+ */
219
+ async function moveKeys(before: StoredSettings, after: StoredSettings): Promise<void> {
220
+ const from = store(before);
221
+ const to = store(after);
222
+ for (const adapter of options.adapters) {
223
+ const name = apiKeySecretName(adapter.id);
224
+ const value = await from.get(name);
225
+ if (value === null) continue;
226
+ await to.set(name, value);
227
+ await from.delete(name);
228
+ }
229
+ }
230
+
231
+ return registry;
232
+ }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * The two shapes the run loop shares with `create-ai.ts`.
3
+ *
4
+ * They are derived from the contract rather than restated, so a change to the
5
+ * contract is a type error here instead of a mismatch at runtime.
6
+ */
7
+ import type { StreamEvent, StreamParams } from '../../shared/contract.ts';
8
+ import type { AiContract } from '../shared/contract.ts';
9
+
10
+ /** What the browser sends to start a turn. */
11
+ export type StreamChatParams = StreamParams<AiContract, 'ai.chat'>;
12
+
13
+ /** One event on the way back. */
14
+ export type ChatEvent = StreamEvent<AiContract, 'ai.chat'>;