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,83 @@
1
+ /**
2
+ * Where the AI layer's non-secret settings live.
3
+ *
4
+ * `<dataDir>/ai/settings.json` holds which provider and model the user chose
5
+ * and where to reach them. It never holds the API key — that is
6
+ * `secrets.ts` — and a test asserts the string does not appear in the file,
7
+ * because "we do not write it there" is the kind of promise that quietly
8
+ * stops being true.
9
+ */
10
+ import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
11
+ import { join } from 'node:path';
12
+
13
+ /** The settings file's shape. `version` exists so a later format can migrate. */
14
+ export interface StoredSettings {
15
+ version: 1;
16
+ provider: string | null;
17
+ modelId: string | null;
18
+ baseUrl: string | null;
19
+ /** False means the key is held in memory only and forgotten on exit. */
20
+ remember: boolean;
21
+ }
22
+
23
+ /** Reads and writes {@link StoredSettings}. */
24
+ export interface SettingsStore {
25
+ read(): StoredSettings;
26
+ write(next: StoredSettings): void;
27
+ }
28
+
29
+ /** What a fresh installation has. No provider, so the layer is off. */
30
+ export function defaultSettings(): StoredSettings {
31
+ return { version: 1, provider: null, modelId: null, baseUrl: null, remember: true };
32
+ }
33
+
34
+ function coerce(value: unknown): StoredSettings | null {
35
+ if (typeof value !== 'object' || value === null) return null;
36
+ const raw = value as Record<string, unknown>;
37
+ const text = (key: string): string | null => (typeof raw[key] === 'string' ? (raw[key] as string) : null);
38
+ return {
39
+ version: 1,
40
+ provider: text('provider'),
41
+ modelId: text('modelId'),
42
+ baseUrl: text('baseUrl'),
43
+ remember: raw['remember'] !== false,
44
+ };
45
+ }
46
+
47
+ /** Open the settings store for one data directory. */
48
+ export function createSettingsStore(dataDir: string): SettingsStore {
49
+ const directory = join(dataDir, 'ai');
50
+ const file = join(directory, 'settings.json');
51
+ const temporary = `${file}.tmp`;
52
+
53
+ return {
54
+ read() {
55
+ let text: string;
56
+ try {
57
+ text = readFileSync(file, 'utf8');
58
+ } catch {
59
+ // No file yet is the ordinary case on a first run, not a failure.
60
+ return defaultSettings();
61
+ }
62
+ try {
63
+ const parsed = coerce(JSON.parse(text) as unknown);
64
+ if (parsed === null) throw new Error('not an object');
65
+ return parsed;
66
+ } catch {
67
+ // A file the user or another tool mangled should not stop the
68
+ // application from starting, and should not be deleted either — they
69
+ // may want to repair it.
70
+ console.warn(`[broapp] ignoring unreadable AI settings at ${file}`);
71
+ return defaultSettings();
72
+ }
73
+ },
74
+
75
+ write(next) {
76
+ mkdirSync(directory, { recursive: true, mode: 0o700 });
77
+ // Written to a sibling and renamed, so a crash mid-write leaves the
78
+ // previous settings intact rather than a truncated file.
79
+ writeFileSync(temporary, `${JSON.stringify(next, null, 2)}\n`, 'utf8');
80
+ renameSync(temporary, file);
81
+ },
82
+ };
83
+ }
@@ -0,0 +1,366 @@
1
+ /**
2
+ * Where conversations live.
3
+ *
4
+ * `<dataDir>/ai/threads.sqlite`, beside the settings and the secrets. A
5
+ * conversation is the user's own writing, so nothing here asks whether a
6
+ * provider is configured: somebody who has just deleted their key still owns
7
+ * what they typed and must still be able to read and delete it.
8
+ *
9
+ * The host stores messages and never interprets them. Parts are the AI SDK's
10
+ * shape, written as JSON and handed back to the browser unread, with one
11
+ * deliberate exception on the way in — see {@link withoutImages}.
12
+ */
13
+ import { Database } from 'bun:sqlite';
14
+ import { mkdirSync } from 'node:fs';
15
+ import { join } from 'node:path';
16
+
17
+ import { publicError } from '../../shared/errors.ts';
18
+ import type { StoredMessage, Thread } from '../shared/types.ts';
19
+
20
+ /**
21
+ * The migrations, in order.
22
+ *
23
+ * Each is applied once and `user_version` is set to its index. Adding a column
24
+ * later means appending here — never editing an entry that has shipped,
25
+ * because a database that already ran the old version will never run the new
26
+ * one.
27
+ */
28
+ const MIGRATIONS: readonly string[] = [
29
+ `CREATE TABLE threads (
30
+ id TEXT PRIMARY KEY,
31
+ title TEXT NOT NULL,
32
+ model_id TEXT,
33
+ created_at INTEGER NOT NULL,
34
+ updated_at INTEGER NOT NULL
35
+ );
36
+ CREATE INDEX threads_updated_at ON threads (updated_at DESC);
37
+ CREATE TABLE messages (
38
+ thread_id TEXT NOT NULL REFERENCES threads (id) ON DELETE CASCADE,
39
+ position INTEGER NOT NULL,
40
+ json TEXT NOT NULL,
41
+ PRIMARY KEY (thread_id, position)
42
+ );`,
43
+ /*
44
+ * A monotonic sequence, because a timestamp is not one.
45
+ *
46
+ * `updated_at` is milliseconds, and two writes inside one millisecond used
47
+ * to be ordered by whichever random id sorted higher — so a list could come
48
+ * back in a different order for the same history (report 07). `seq` is
49
+ * bumped by every write that touches a conversation, so "most recently
50
+ * changed first" means what it says. The backfill puts existing rows in the
51
+ * order they were last shown in, `rowid` breaking the ties the old query
52
+ * could not.
53
+ */
54
+ `ALTER TABLE threads ADD COLUMN seq INTEGER NOT NULL DEFAULT 0;
55
+ UPDATE threads SET seq = (
56
+ SELECT COUNT(*) FROM threads AS earlier
57
+ WHERE earlier.updated_at < threads.updated_at
58
+ OR (earlier.updated_at = threads.updated_at AND earlier.rowid <= threads.rowid)
59
+ );
60
+ CREATE INDEX threads_seq ON threads (seq DESC);`,
61
+ ];
62
+
63
+ /** What a conversation is called until it has been named. */
64
+ export const DEFAULT_THREAD_TITLE = 'New conversation';
65
+
66
+ /** How much of the first message becomes the title. */
67
+ const TITLE_CHARS = 60;
68
+
69
+ /** The most conversations one listing returns, matching the contract's bound. */
70
+ const MAX_THREADS = 500;
71
+
72
+ /**
73
+ * The most JSON one save may write.
74
+ *
75
+ * The contract bounds the *number* of messages and parts but not their size,
76
+ * because a part is `unknown` by design. This is the bound on the amount: four
77
+ * megabytes is far more than a conversation of 200 messages needs and far less
78
+ * than a browser could use to fill somebody's disk.
79
+ */
80
+ const MAX_SAVE_CHARS = 4_000_000;
81
+
82
+ /** A conversation and everything in it. */
83
+ export interface ThreadStore {
84
+ readonly path: string;
85
+ /** Most recently changed first, capped at 500. */
86
+ list(): Thread[];
87
+ create(input: { title?: string | undefined; modelId?: string | null | undefined }): Thread;
88
+ get(id: string): { thread: Thread; messages: StoredMessage[] };
89
+ /** Replaces the messages whole and bumps `updatedAt`. */
90
+ save(input: {
91
+ id: string;
92
+ messages: readonly StoredMessage[];
93
+ title?: string | undefined;
94
+ }): Thread;
95
+ update(input: {
96
+ id: string;
97
+ title?: string | undefined;
98
+ modelId?: string | null | undefined;
99
+ }): Thread;
100
+ remove(id: string): boolean;
101
+ /** Every conversation. Returns how many were deleted. */
102
+ clear(): number;
103
+ close(): void;
104
+ }
105
+
106
+ /** A row of `threads`, joined with its message count. */
107
+ interface ThreadRow {
108
+ id: string;
109
+ title: string;
110
+ model_id: string | null;
111
+ created_at: number;
112
+ updated_at: number;
113
+ message_count: number;
114
+ }
115
+
116
+ function toThread(row: ThreadRow): Thread {
117
+ return {
118
+ id: row.id,
119
+ title: row.title,
120
+ modelId: row.model_id,
121
+ createdAt: row.created_at,
122
+ updatedAt: row.updated_at,
123
+ messageCount: row.message_count,
124
+ };
125
+ }
126
+
127
+ /** An id matching the contract's pattern: 32 hex characters. */
128
+ function newThreadId(): string {
129
+ return crypto.randomUUID().replace(/-/g, '');
130
+ }
131
+
132
+ /** True for a part the AI SDK would render as an attachment. */
133
+ function isFilePart(part: unknown): part is { type: 'file'; filename?: unknown; mediaType?: unknown } {
134
+ return typeof part === 'object' && part !== null && (part as { type?: unknown }).type === 'file';
135
+ }
136
+
137
+ /**
138
+ * A message with its images replaced by the line that names them.
139
+ *
140
+ * A `file` part carries a data URL, so storing one would put a second copy of
141
+ * the picture in SQLite that nobody asked to keep and nothing ever deletes.
142
+ * The placeholder is the same line the browser already puts in `history`, so
143
+ * a reloaded conversation says exactly what the model was told on the turns
144
+ * after the one the image arrived on.
145
+ */
146
+ function withoutImages(message: StoredMessage): StoredMessage {
147
+ if (!message.parts.some(isFilePart)) return message;
148
+ const parts = message.parts.map((part) => {
149
+ if (!isFilePart(part)) return part;
150
+ const name =
151
+ typeof part.filename === 'string'
152
+ ? part.filename
153
+ : typeof part.mediaType === 'string'
154
+ ? part.mediaType
155
+ : 'image';
156
+ return { type: 'text', text: `[image: ${name}]` };
157
+ });
158
+ return message.metadata === undefined
159
+ ? { id: message.id, role: message.role, parts }
160
+ : { id: message.id, role: message.role, parts, metadata: message.metadata };
161
+ }
162
+
163
+ /** The text of a message, for deriving a title. */
164
+ function textOf(message: StoredMessage): string {
165
+ const lines: string[] = [];
166
+ for (const part of message.parts) {
167
+ if (typeof part !== 'object' || part === null) continue;
168
+ const typed = part as { type?: unknown; text?: unknown };
169
+ if (typed.type === 'text' && typeof typed.text === 'string') lines.push(typed.text);
170
+ }
171
+ return lines.join(' ');
172
+ }
173
+
174
+ /** The first user message's opening words, or null when there is nothing to use. */
175
+ function derivedTitle(messages: readonly StoredMessage[]): string | null {
176
+ const first = messages.find((message) => message.role === 'user');
177
+ if (first === undefined) return null;
178
+ const collapsed = textOf(first).replace(/\s+/g, ' ').trim();
179
+ if (collapsed === '') return null;
180
+ return collapsed.slice(0, TITLE_CHARS);
181
+ }
182
+
183
+ /** Open the conversation store for one data directory, migrating it as needed. */
184
+ export function openThreads(dataDir: string): ThreadStore {
185
+ const directory = join(dataDir, 'ai');
186
+ // The same mode the settings store uses: this directory holds what somebody
187
+ // wrote to their assistant, which is nobody else's business.
188
+ mkdirSync(directory, { recursive: true, mode: 0o700 });
189
+ const path = join(directory, 'threads.sqlite');
190
+ const db = new Database(path, { create: true, strict: true });
191
+
192
+ db.exec('PRAGMA journal_mode = WAL');
193
+ // Without this, `ON DELETE CASCADE` is decoration: SQLite does not enforce a
194
+ // foreign key unless it is asked to.
195
+ db.exec('PRAGMA foreign_keys = ON');
196
+ db.exec('PRAGMA busy_timeout = 5000');
197
+
198
+ migrate(db);
199
+
200
+ const THREAD_COLUMNS = `t.id AS id, t.title AS title, t.model_id AS model_id,
201
+ t.created_at AS created_at, t.updated_at AS updated_at,
202
+ (SELECT COUNT(*) FROM messages m WHERE m.thread_id = t.id) AS message_count`;
203
+
204
+ const statements = {
205
+ list: db.query<ThreadRow, [number]>(
206
+ // `seq` only: it is unique and monotonic, so no tie-break is needed and
207
+ // none can disagree with the order the writes actually happened in.
208
+ `SELECT ${THREAD_COLUMNS} FROM threads t ORDER BY t.seq DESC LIMIT ?`,
209
+ ),
210
+ byId: db.query<ThreadRow, [string]>(
211
+ `SELECT ${THREAD_COLUMNS} FROM threads t WHERE t.id = ?`,
212
+ ),
213
+ /*
214
+ * Both writes take the next sequence in the same statement that changes
215
+ * the row, so the number a conversation is ordered by is decided inside
216
+ * whatever transaction is writing it — never by a second round trip that
217
+ * another write could interleave with.
218
+ */
219
+ insert: db.query<unknown, [string, string, string | null, number, number]>(
220
+ `INSERT INTO threads (id, title, model_id, created_at, updated_at, seq)
221
+ VALUES (?, ?, ?, ?, ?, (SELECT COALESCE(MAX(seq), 0) + 1 FROM threads))`,
222
+ ),
223
+ touch: db.query<unknown, [string, string | null, number, string]>(
224
+ `UPDATE threads SET title = ?, model_id = ?, updated_at = ?,
225
+ seq = (SELECT COALESCE(MAX(seq), 0) + 1 FROM threads)
226
+ WHERE id = ?`,
227
+ ),
228
+ messages: db.query<{ json: string }, [string]>(
229
+ 'SELECT json FROM messages WHERE thread_id = ? ORDER BY position ASC',
230
+ ),
231
+ deleteMessages: db.query<unknown, [string]>('DELETE FROM messages WHERE thread_id = ?'),
232
+ insertMessage: db.query<unknown, [string, number, string]>(
233
+ 'INSERT INTO messages (thread_id, position, json) VALUES (?, ?, ?)',
234
+ ),
235
+ remove: db.query<{ id: string }, [string]>('DELETE FROM threads WHERE id = ? RETURNING id'),
236
+ count: db.query<{ n: number }, []>('SELECT COUNT(*) AS n FROM threads'),
237
+ clear: db.query<unknown, []>('DELETE FROM threads'),
238
+ };
239
+
240
+ /** The row, or the sentence a browser shows when a conversation is gone. */
241
+ function mustGet(id: string): ThreadRow {
242
+ const row = statements.byId.get(id);
243
+ if (row === null) throw publicError.notFound('That conversation is gone.');
244
+ return row;
245
+ }
246
+
247
+ const store: ThreadStore = {
248
+ path,
249
+
250
+ list() {
251
+ return statements.list.all(MAX_THREADS).map(toThread);
252
+ },
253
+
254
+ create({ title, modelId }) {
255
+ const now = Date.now();
256
+ const id = newThreadId();
257
+ statements.insert.run(id, title ?? DEFAULT_THREAD_TITLE, modelId ?? null, now, now);
258
+ return toThread(mustGet(id));
259
+ },
260
+
261
+ get(id) {
262
+ const thread = toThread(mustGet(id));
263
+ const messages = statements.messages.all(id).map((row) => {
264
+ // Written by this process, from a value the contract validated. A
265
+ // parse failure would mean the file was edited by hand or corrupted,
266
+ // and the conversation is unreadable either way.
267
+ return JSON.parse(row.json) as StoredMessage;
268
+ });
269
+ return { thread, messages };
270
+ },
271
+
272
+ save({ id, messages, title }) {
273
+ const row = mustGet(id);
274
+ const stored = messages.map(withoutImages);
275
+ const encoded = stored.map((message) => JSON.stringify(message));
276
+ const characters = encoded.reduce((total, json) => total + json.length, 0);
277
+ if (characters > MAX_SAVE_CHARS) {
278
+ throw publicError.invalidInput('That conversation is too large to save.');
279
+ }
280
+ // A title given wins; otherwise a conversation still carrying the
281
+ // default name takes one from what the person actually asked.
282
+ const named =
283
+ title ?? (row.title === DEFAULT_THREAD_TITLE ? (derivedTitle(stored) ?? row.title) : row.title);
284
+ const now = Date.now();
285
+ // One transaction: a save that failed half way would leave a
286
+ // conversation holding the first few messages of the new list and none
287
+ // of the old.
288
+ db.transaction(() => {
289
+ statements.deleteMessages.run(id);
290
+ for (let index = 0; index < encoded.length; index += 1) {
291
+ const json = encoded[index];
292
+ if (json === undefined) continue;
293
+ statements.insertMessage.run(id, index, json);
294
+ }
295
+ statements.touch.run(named, row.model_id, now, id);
296
+ })();
297
+ return toThread(mustGet(id));
298
+ },
299
+
300
+ update({ id, title, modelId }) {
301
+ const row = mustGet(id);
302
+ statements.touch.run(
303
+ title ?? row.title,
304
+ modelId === undefined ? row.model_id : modelId,
305
+ Date.now(),
306
+ id,
307
+ );
308
+ return toThread(mustGet(id));
309
+ },
310
+
311
+ remove(id) {
312
+ return statements.remove.get(id) !== null;
313
+ },
314
+
315
+ clear() {
316
+ const before = statements.count.get()?.n ?? 0;
317
+ statements.clear.run();
318
+ return before;
319
+ },
320
+
321
+ close() {
322
+ // Checkpointing folds the WAL back into the main file, so what is left
323
+ // behind is one complete database rather than one that needs its
324
+ // sidecars to be readable.
325
+ try {
326
+ db.exec('PRAGMA wal_checkpoint(TRUNCATE)');
327
+ } catch {
328
+ // A checkpoint that fails is not a reason to leave the handle open.
329
+ }
330
+ db.close();
331
+ },
332
+ };
333
+
334
+ return store;
335
+ }
336
+
337
+ function currentVersion(db: Database): number {
338
+ const row = db.query<{ user_version: number }, []>('PRAGMA user_version').get();
339
+ return row?.user_version ?? 0;
340
+ }
341
+
342
+ /**
343
+ * Bring the schema up to date.
344
+ *
345
+ * Each migration and its version bump happen in one transaction, so an
346
+ * interrupted upgrade leaves the database at the last version that fully
347
+ * applied — never half-way through one.
348
+ */
349
+ function migrate(db: Database): void {
350
+ const from = currentVersion(db);
351
+ if (from > MIGRATIONS.length) {
352
+ throw new Error(
353
+ `the conversation store's schema version ${String(from)} is newer than this build understands (${String(MIGRATIONS.length)})`,
354
+ );
355
+ }
356
+ for (let version = from; version < MIGRATIONS.length; version += 1) {
357
+ const statement = MIGRATIONS[version];
358
+ if (statement === undefined) continue;
359
+ db.transaction(() => {
360
+ db.exec(statement);
361
+ // PRAGMA does not accept a bound parameter, so the value is interpolated.
362
+ // It is a loop index, not input.
363
+ db.exec(`PRAGMA user_version = ${String(version + 1)}`);
364
+ })();
365
+ }
366
+ }
@@ -0,0 +1,95 @@
1
+ /**
2
+ * What the AI layer can offer a model, and how a confirmation is answered.
3
+ *
4
+ * Kept apart from `create-ai.ts` so that `from-contract.ts` and `run.ts` can
5
+ * share these without either importing the other's module graph.
6
+ */
7
+ import type { Envelope, Gate } from '../../host/gate.ts';
8
+ import type { Effect } from '../../shared/contract.ts';
9
+ import type { JsonSchema } from '../../shared/schema.ts';
10
+
11
+ /** One thing a model may do. */
12
+ export interface AiTool {
13
+ readonly description: string;
14
+ /** JSON Schema for the input. Use `schema.toJsonSchema()` or write it by hand. */
15
+ readonly inputSchema: JsonSchema;
16
+ /** What running it does to the world. The gate decides from this and the channel. */
17
+ readonly effect: Effect;
18
+ /**
19
+ * Run it.
20
+ *
21
+ * The envelope comes from the run loop, which built it from what it knows
22
+ * rather than from what the model said. An implementation passes it on; it
23
+ * does not invent one.
24
+ */
25
+ execute(input: unknown, envelope: Envelope, signal: AbortSignal): Promise<unknown>;
26
+ }
27
+
28
+ /**
29
+ * The brand that says a tool's `execute` reaches the gate.
30
+ *
31
+ * A hand-written tool is ordinary host code: nothing about its type says
32
+ * whether it asked anybody before doing what it does. Rather than trust that
33
+ * every application remembers, `createAi` refuses a tool without this symbol,
34
+ * and the only way to get one is {@link guardedTool}, which does the asking.
35
+ */
36
+ export const GUARDED: unique symbol = Symbol('broapp.guarded');
37
+
38
+ /** An {@link AiTool} whose calls are known to pass the gate. */
39
+ export interface GuardedTool extends AiTool {
40
+ readonly [GUARDED]: true;
41
+ }
42
+
43
+ /** What {@link guardedTool} needs to know about the thing it is wrapping. */
44
+ export interface GuardedToolDefinition {
45
+ /** The tool's name, which is also the route in the gate's records. */
46
+ readonly name: string;
47
+ readonly description: string;
48
+ readonly inputSchema: JsonSchema;
49
+ readonly effect: Effect;
50
+ run(input: unknown, signal: AbortSignal): Promise<unknown>;
51
+ }
52
+
53
+ /**
54
+ * Wrap a hand-written tool so its calls pass the gate.
55
+ *
56
+ * This is the supported way to give a model something an application's
57
+ * contract does not describe — a search over a third-party index, a shell
58
+ * command, a mail send. The wrapping is the whole point: the model's request
59
+ * arrives with the run loop's envelope, the gate decides, and only then does
60
+ * `run` happen.
61
+ */
62
+ export function guardedTool(gate: Gate, tool: GuardedToolDefinition): GuardedTool {
63
+ return {
64
+ [GUARDED]: true,
65
+ description: tool.description,
66
+ inputSchema: tool.inputSchema,
67
+ effect: tool.effect,
68
+ execute: (input, envelope) =>
69
+ gate.guard({ ...envelope, route: tool.name, effect: tool.effect, input }, (signal) =>
70
+ tool.run(input, signal),
71
+ ),
72
+ };
73
+ }
74
+
75
+ /** A record the model may be shown, named but not loaded. */
76
+ export interface ContextRef {
77
+ readonly ref: string;
78
+ readonly title: string;
79
+ readonly snippet?: string;
80
+ }
81
+
82
+ /** A record the model is shown in full. */
83
+ export interface ContextDocument {
84
+ readonly ref: string;
85
+ readonly title: string;
86
+ readonly content: string;
87
+ }
88
+
89
+ /** Where the model's knowledge of the application's data comes from. */
90
+ 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[]>;
93
+ /** Full content for named refs. Unknown refs are skipped, not errors. */
94
+ resolve?(refs: readonly string[], signal: AbortSignal): Promise<ContextDocument[]>;
95
+ }