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.
- package/README.md +13 -4
- package/package.json +10 -2
- package/src/ai/host/adapter.ts +109 -0
- package/src/ai/host/create-ai.ts +266 -0
- package/src/ai/host/fake.ts +230 -0
- package/src/ai/host/from-contract.ts +105 -0
- package/src/ai/host/index.ts +37 -0
- package/src/ai/host/registry.ts +232 -0
- package/src/ai/host/run-types.ts +14 -0
- package/src/ai/host/run.ts +540 -0
- package/src/ai/host/secrets.ts +118 -0
- package/src/ai/host/settings.ts +83 -0
- package/src/ai/host/threads.ts +366 -0
- package/src/ai/host/tool.ts +95 -0
- package/src/ai/react/AiChat.tsx +242 -0
- package/src/ai/react/AiSettings.tsx +228 -0
- package/src/ai/react/ai.css +166 -0
- package/src/ai/react/index.tsx +38 -0
- package/src/ai/react/provider.tsx +97 -0
- package/src/ai/react/use-ai-chat.ts +317 -0
- package/src/ai/react/use-ai-models.ts +70 -0
- package/src/ai/react/use-ai-settings.ts +105 -0
- package/src/ai/shared/contract.ts +247 -0
- package/src/ai/shared/index.ts +19 -0
- package/src/ai/shared/types.check.ts +71 -0
- package/src/ai/shared/types.ts +147 -0
- package/src/host/app.ts +183 -36
- package/src/host/approvals.ts +115 -0
- package/src/host/gate.ts +380 -0
- package/src/host/index.ts +25 -0
- package/src/host/paths.ts +6 -2
- package/src/host/runtime.ts +23 -2
- package/src/react/hooks.tsx +37 -3
- package/src/react/index.ts +1 -0
- package/src/shared/contract.ts +99 -2
- package/src/shared/countdown.ts +36 -0
- package/src/shared/errors.ts +66 -2
- package/src/shared/index.ts +14 -3
- 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
|
+
}
|