@frockbot/plugin-search 0.0.0 → 0.1.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/src/shared.ts ADDED
@@ -0,0 +1,569 @@
1
+ // The Search Package's narrow, versioned DTOs and their decoders.
2
+ //
3
+ // Every value here crosses a runtime boundary — a Bot Durable Object to the
4
+ // User Durable Object, the User object to the gateway, the gateway to a
5
+ // browser — so each is decoded at its seam with exact keys, the discipline
6
+ // `packages/plugin-shell/src/run-protocol.ts` established.
7
+ //
8
+ // The index these rows feed is a *projection*. `AGENTS.md` § Memory: "Indexes,
9
+ // embeddings, and summaries are derived … and are always rebuildable". Nothing
10
+ // in this Package is authority: a row is reconstructable from the Bot Durable
11
+ // Object's stored runs, and `rebuild` proves it.
12
+
13
+ /** Most rows one User's index holds before the oldest are evicted. */
14
+ export const SEARCH_MAX_ROWS_V1 = 2_000_000;
15
+ /** Most bytes of body text one row carries; longer bodies are truncated. */
16
+ export const SEARCH_MAX_BODY_BYTES_V1 = 8 * 1024;
17
+ /** Longest accepted query string. */
18
+ export const SEARCH_MAX_QUERY_LENGTH_V1 = 256;
19
+ /** Most hits one page returns. */
20
+ export const SEARCH_MAX_RESULTS_V1 = 50;
21
+ /** Longest snippet one hit carries. */
22
+ export const SEARCH_MAX_SNIPPET_LENGTH_V1 = 300;
23
+ /** Longest accepted paging cursor. */
24
+ export const SEARCH_MAX_CURSOR_LENGTH_V1 = 64;
25
+ /** Most Bots one page of results groups. */
26
+ export const SEARCH_MAX_GROUPS_V1 = 200;
27
+ /** Most rows one Bot may offer the index in a single rebuild page. */
28
+ export const SEARCH_MAX_ROW_PAGE_V1 = 2_048;
29
+
30
+ const MAX_ID_LENGTH = 128;
31
+ const MAX_TIMESTAMP_LENGTH = 64;
32
+ const MAX_NAME_LENGTH = 256;
33
+ const MAX_DEEP_LINK_LENGTH = 512;
34
+
35
+ export class SearchDecodeError extends Error {
36
+ constructor(message: string) {
37
+ super(message);
38
+ this.name = "SearchDecodeError";
39
+ }
40
+ }
41
+
42
+ /**
43
+ * What produced one indexed row.
44
+ *
45
+ * `media` exists because the parity register's `search-index.db` carries a
46
+ * `media` table beside `messages` (`docs/research/grokbot-computer.md:169`).
47
+ * FrockBot has no attachment concept yet, so the kind is declared and never
48
+ * written: the schema does not change when attachments arrive.
49
+ */
50
+ export type SearchRowKindV1 = "user" | "assistant" | "tool" | "media";
51
+
52
+ export const SEARCH_ROW_KINDS_V1: readonly SearchRowKindV1[] = [
53
+ "user",
54
+ "assistant",
55
+ "tool",
56
+ "media",
57
+ ];
58
+
59
+ /**
60
+ * Kinds a query returns when it names none.
61
+ *
62
+ * `tool` is indexed and excluded by default: a tool result can carry
63
+ * credentials-adjacent text, so seeing it is an explicit opt-in.
64
+ */
65
+ export const SEARCH_DEFAULT_ROW_KINDS_V1: readonly SearchRowKindV1[] = [
66
+ "user",
67
+ "assistant",
68
+ ];
69
+
70
+ /** One indexed row. Idempotent on `(botId, runId, seq)`. */
71
+ export interface SearchRowV1 {
72
+ botId: string;
73
+ runId: string;
74
+ /** Position within the run's projection; stable across rebuilds. */
75
+ seq: number;
76
+ kind: SearchRowKindV1;
77
+ /** ISO-8601, the run's admission time. */
78
+ at: string;
79
+ body: string;
80
+ }
81
+
82
+ /** A page of rows one Bot offers the index during a rebuild. */
83
+ export interface SearchRowPageV1 {
84
+ schemaVersion: 1;
85
+ botId: string;
86
+ rows: SearchRowV1[];
87
+ /** Absent when the Bot has no further runs to project. */
88
+ nextCursor?: string;
89
+ }
90
+
91
+ export type SearchIndexStateV1 = "ready" | "rebuilding" | "truncated";
92
+
93
+ export interface SearchQueryV1 {
94
+ schemaVersion: 1;
95
+ query: string;
96
+ /** Opaque page cursor from a previous result's `page.nextCursor`. */
97
+ before?: string;
98
+ kinds?: SearchRowKindV1[];
99
+ botId?: string;
100
+ includeArchived?: boolean;
101
+ }
102
+
103
+ export interface SearchHitV1 {
104
+ botId: string;
105
+ runId: string;
106
+ kind: SearchRowKindV1;
107
+ at: string;
108
+ snippet: string;
109
+ }
110
+
111
+ /** What the index itself answers, before Bot identity is joined on. */
112
+ export interface SearchIndexResultsV1 {
113
+ schemaVersion: 1;
114
+ query: string;
115
+ hits: SearchHitV1[];
116
+ truncated: boolean;
117
+ nextCursor?: string;
118
+ indexState: SearchIndexStateV1;
119
+ }
120
+
121
+ export interface ClientSearchHitV1 {
122
+ runId: string;
123
+ kind: SearchRowKindV1;
124
+ at: string;
125
+ snippet: string;
126
+ /** `/?bot=<botId>#turn-<runId>`; the client never builds this itself. */
127
+ deepLink: string;
128
+ }
129
+
130
+ export interface ClientSearchBotGroupV1 {
131
+ botId: string;
132
+ botName: string;
133
+ archived: boolean;
134
+ /** A Bot the sidebar hides is still searchable, and is labelled. */
135
+ hidden: boolean;
136
+ hits: ClientSearchHitV1[];
137
+ totalHits: number;
138
+ }
139
+
140
+ export interface ClientSearchPageV1 {
141
+ truncated: boolean;
142
+ nextCursor?: string;
143
+ }
144
+
145
+ export interface ClientSearchResultsV1 {
146
+ schemaVersion: 1;
147
+ query: string;
148
+ groups: ClientSearchBotGroupV1[];
149
+ page: ClientSearchPageV1;
150
+ indexState: SearchIndexStateV1;
151
+ }
152
+
153
+ export interface ClientSearchRebuildReceiptV1 {
154
+ schemaVersion: 1;
155
+ status: "rebuilt";
156
+ indexedRows: number;
157
+ bots: number;
158
+ indexState: SearchIndexStateV1;
159
+ }
160
+
161
+ /** The deep link one hit resolves to. The shell already reads `?bot=`. */
162
+ export function searchDeepLinkV1(botId: string, runId: string): string {
163
+ return `/?bot=${encodeURIComponent(botId)}#${searchTurnAnchorV1(runId)}`;
164
+ }
165
+
166
+ /** The anchor id the conversation's turn renderer carries. */
167
+ export function searchTurnAnchorV1(runId: string): string {
168
+ return `turn-${runId}`;
169
+ }
170
+
171
+ // ---------------------------------------------------------------------------
172
+ // Decoders.
173
+ // ---------------------------------------------------------------------------
174
+
175
+ function record(value: unknown, label: string): Record<string, unknown> {
176
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
177
+ throw new SearchDecodeError(`${label} must be an object`);
178
+ }
179
+ return value as Record<string, unknown>;
180
+ }
181
+
182
+ function exactKeys(
183
+ value: Record<string, unknown>,
184
+ allowed: readonly string[],
185
+ label: string,
186
+ ): void {
187
+ const allowedKeys = new Set(allowed);
188
+ const unexpected = Reflect.ownKeys(value).find(
189
+ (key) =>
190
+ typeof key !== "string" ||
191
+ !allowedKeys.has(key) ||
192
+ !Object.prototype.propertyIsEnumerable.call(value, key),
193
+ );
194
+ if (unexpected !== undefined) {
195
+ const field =
196
+ typeof unexpected === "symbol" ? unexpected.toString() : unexpected;
197
+ throw new SearchDecodeError(`${label}.${field} is not allowed`);
198
+ }
199
+ }
200
+
201
+ function schemaVersion(value: Record<string, unknown>, label: string): void {
202
+ if (value.schemaVersion !== 1) {
203
+ throw new SearchDecodeError(`${label}.schemaVersion must be 1`);
204
+ }
205
+ }
206
+
207
+ function text(
208
+ value: Record<string, unknown>,
209
+ key: string,
210
+ maximum: number,
211
+ label: string,
212
+ ): string {
213
+ const field = value[key];
214
+ if (typeof field !== "string" || field.length > maximum) {
215
+ throw new SearchDecodeError(`${label}.${key} must be a bounded string`);
216
+ }
217
+ return field;
218
+ }
219
+
220
+ function identifier(
221
+ value: Record<string, unknown>,
222
+ key: string,
223
+ label: string,
224
+ ): string {
225
+ const field = text(value, key, MAX_ID_LENGTH, label);
226
+ if (field.length === 0) {
227
+ throw new SearchDecodeError(`${label}.${key} must not be empty`);
228
+ }
229
+ return field;
230
+ }
231
+
232
+ function timestamp(
233
+ value: Record<string, unknown>,
234
+ key: string,
235
+ label: string,
236
+ ): string {
237
+ const field = text(value, key, MAX_TIMESTAMP_LENGTH, label);
238
+ if (!Number.isFinite(Date.parse(field))) {
239
+ throw new SearchDecodeError(`${label}.${key} must be a timestamp`);
240
+ }
241
+ return field;
242
+ }
243
+
244
+ function rowKind(value: unknown, label: string): SearchRowKindV1 {
245
+ if (
246
+ typeof value !== "string" ||
247
+ !SEARCH_ROW_KINDS_V1.includes(value as SearchRowKindV1)
248
+ ) {
249
+ throw new SearchDecodeError(`${label}.kind is invalid`);
250
+ }
251
+ return value as SearchRowKindV1;
252
+ }
253
+
254
+ function boolean(
255
+ value: Record<string, unknown>,
256
+ key: string,
257
+ label: string,
258
+ ): boolean {
259
+ if (typeof value[key] !== "boolean") {
260
+ throw new SearchDecodeError(`${label}.${key} must be a boolean`);
261
+ }
262
+ return value[key] as boolean;
263
+ }
264
+
265
+ function list(value: unknown, label: string): unknown[] {
266
+ if (!Array.isArray(value)) {
267
+ throw new SearchDecodeError(`${label} must be an array`);
268
+ }
269
+ return value;
270
+ }
271
+
272
+ /** Truncates a body to the durable per-row byte bound, on a code-point edge. */
273
+ export function boundSearchBodyV1(value: string): string {
274
+ const encoder = new TextEncoder();
275
+ if (encoder.encode(value).byteLength <= SEARCH_MAX_BODY_BYTES_V1)
276
+ return value;
277
+ let low = 0;
278
+ let high = value.length;
279
+ while (low < high) {
280
+ const middle = Math.ceil((low + high) / 2);
281
+ if (
282
+ encoder.encode(value.slice(0, middle)).byteLength <=
283
+ SEARCH_MAX_BODY_BYTES_V1
284
+ ) {
285
+ low = middle;
286
+ } else {
287
+ high = middle - 1;
288
+ }
289
+ }
290
+ const bounded = value.slice(0, low);
291
+ return /[\uD800-\uDBFF]$/.test(bounded) ? bounded.slice(0, -1) : bounded;
292
+ }
293
+
294
+ export function decodeSearchRowV1(input: unknown): SearchRowV1 {
295
+ const row = record(input, "search row");
296
+ exactKeys(row, ["botId", "runId", "seq", "kind", "at", "body"], "search row");
297
+ if (
298
+ !Number.isSafeInteger(row.seq) ||
299
+ (row.seq as number) < 0 ||
300
+ (row.seq as number) > 1_000_000
301
+ ) {
302
+ throw new SearchDecodeError("search row.seq must be a bounded integer");
303
+ }
304
+ return {
305
+ botId: identifier(row, "botId", "search row"),
306
+ runId: identifier(row, "runId", "search row"),
307
+ seq: row.seq as number,
308
+ kind: rowKind(row.kind, "search row"),
309
+ at: timestamp(row, "at", "search row"),
310
+ body: boundSearchBodyV1(
311
+ text(row, "body", SEARCH_MAX_BODY_BYTES_V1 * 4, "search row"),
312
+ ),
313
+ };
314
+ }
315
+
316
+ export function decodeSearchRowPageV1(input: unknown): SearchRowPageV1 {
317
+ const page = record(input, "search row page");
318
+ exactKeys(
319
+ page,
320
+ ["schemaVersion", "botId", "rows", "nextCursor"],
321
+ "search row page",
322
+ );
323
+ schemaVersion(page, "search row page");
324
+ const rows = list(page.rows, "search row page.rows");
325
+ if (rows.length > SEARCH_MAX_ROW_PAGE_V1) {
326
+ throw new SearchDecodeError("search row page.rows exceeds its bound");
327
+ }
328
+ const botId = identifier(page, "botId", "search row page");
329
+ const decoded = rows.map(decodeSearchRowV1);
330
+ const foreign = decoded.find((row) => row.botId !== botId);
331
+ if (foreign) {
332
+ throw new SearchDecodeError("search row page.rows names another Bot");
333
+ }
334
+ return {
335
+ schemaVersion: 1,
336
+ botId,
337
+ rows: decoded,
338
+ ...(page.nextCursor === undefined
339
+ ? {}
340
+ : {
341
+ nextCursor: text(
342
+ page,
343
+ "nextCursor",
344
+ SEARCH_MAX_CURSOR_LENGTH_V1 * 8,
345
+ "search row page",
346
+ ),
347
+ }),
348
+ };
349
+ }
350
+
351
+ export function decodeSearchQueryV1(input: unknown): SearchQueryV1 {
352
+ const query = record(input, "search query");
353
+ exactKeys(
354
+ query,
355
+ ["schemaVersion", "query", "before", "kinds", "botId", "includeArchived"],
356
+ "search query",
357
+ );
358
+ schemaVersion(query, "search query");
359
+ const kinds =
360
+ query.kinds === undefined
361
+ ? undefined
362
+ : list(query.kinds, "search query.kinds").map((kind) =>
363
+ rowKind(kind, "search query"),
364
+ );
365
+ if (
366
+ kinds &&
367
+ (kinds.length === 0 || kinds.length > SEARCH_ROW_KINDS_V1.length)
368
+ ) {
369
+ throw new SearchDecodeError("search query.kinds is invalid");
370
+ }
371
+ return {
372
+ schemaVersion: 1,
373
+ query: text(query, "query", SEARCH_MAX_QUERY_LENGTH_V1, "search query"),
374
+ ...(query.before === undefined
375
+ ? {}
376
+ : {
377
+ before: text(
378
+ query,
379
+ "before",
380
+ SEARCH_MAX_CURSOR_LENGTH_V1,
381
+ "search query",
382
+ ),
383
+ }),
384
+ ...(kinds ? { kinds: [...new Set(kinds)] } : {}),
385
+ ...(query.botId === undefined
386
+ ? {}
387
+ : { botId: identifier(query, "botId", "search query") }),
388
+ ...(query.includeArchived === undefined
389
+ ? {}
390
+ : { includeArchived: boolean(query, "includeArchived", "search query") }),
391
+ };
392
+ }
393
+
394
+ function indexState(value: unknown, label: string): SearchIndexStateV1 {
395
+ if (value !== "ready" && value !== "rebuilding" && value !== "truncated") {
396
+ throw new SearchDecodeError(`${label}.indexState is invalid`);
397
+ }
398
+ return value;
399
+ }
400
+
401
+ export function decodeSearchHitV1(input: unknown): SearchHitV1 {
402
+ const hit = record(input, "search hit");
403
+ exactKeys(hit, ["botId", "runId", "kind", "at", "snippet"], "search hit");
404
+ return {
405
+ botId: identifier(hit, "botId", "search hit"),
406
+ runId: identifier(hit, "runId", "search hit"),
407
+ kind: rowKind(hit.kind, "search hit"),
408
+ at: timestamp(hit, "at", "search hit"),
409
+ snippet: text(hit, "snippet", SEARCH_MAX_SNIPPET_LENGTH_V1, "search hit"),
410
+ };
411
+ }
412
+
413
+ export function decodeSearchIndexResultsV1(
414
+ input: unknown,
415
+ ): SearchIndexResultsV1 {
416
+ const results = record(input, "search results");
417
+ exactKeys(
418
+ results,
419
+ ["schemaVersion", "query", "hits", "truncated", "nextCursor", "indexState"],
420
+ "search results",
421
+ );
422
+ schemaVersion(results, "search results");
423
+ const hits = list(results.hits, "search results.hits");
424
+ if (hits.length > SEARCH_MAX_RESULTS_V1) {
425
+ throw new SearchDecodeError("search results.hits exceeds its bound");
426
+ }
427
+ return {
428
+ schemaVersion: 1,
429
+ query: text(results, "query", SEARCH_MAX_QUERY_LENGTH_V1, "search results"),
430
+ hits: hits.map(decodeSearchHitV1),
431
+ truncated: boolean(results, "truncated", "search results"),
432
+ ...(results.nextCursor === undefined
433
+ ? {}
434
+ : {
435
+ nextCursor: text(
436
+ results,
437
+ "nextCursor",
438
+ SEARCH_MAX_CURSOR_LENGTH_V1,
439
+ "search results",
440
+ ),
441
+ }),
442
+ indexState: indexState(results.indexState, "search results"),
443
+ };
444
+ }
445
+
446
+ export function decodeClientSearchHitV1(input: unknown): ClientSearchHitV1 {
447
+ const hit = record(input, "client search hit");
448
+ exactKeys(
449
+ hit,
450
+ ["runId", "kind", "at", "snippet", "deepLink"],
451
+ "client search hit",
452
+ );
453
+ return {
454
+ runId: identifier(hit, "runId", "client search hit"),
455
+ kind: rowKind(hit.kind, "client search hit"),
456
+ at: timestamp(hit, "at", "client search hit"),
457
+ snippet: text(
458
+ hit,
459
+ "snippet",
460
+ SEARCH_MAX_SNIPPET_LENGTH_V1,
461
+ "client search hit",
462
+ ),
463
+ deepLink: text(hit, "deepLink", MAX_DEEP_LINK_LENGTH, "client search hit"),
464
+ };
465
+ }
466
+
467
+ export function decodeClientSearchBotGroupV1(
468
+ input: unknown,
469
+ ): ClientSearchBotGroupV1 {
470
+ const group = record(input, "client search group");
471
+ exactKeys(
472
+ group,
473
+ ["botId", "botName", "archived", "hidden", "hits", "totalHits"],
474
+ "client search group",
475
+ );
476
+ const hits = list(group.hits, "client search group.hits");
477
+ if (hits.length > SEARCH_MAX_RESULTS_V1) {
478
+ throw new SearchDecodeError("client search group.hits exceeds its bound");
479
+ }
480
+ if (
481
+ !Number.isSafeInteger(group.totalHits) ||
482
+ (group.totalHits as number) < hits.length
483
+ ) {
484
+ throw new SearchDecodeError("client search group.totalHits is invalid");
485
+ }
486
+ return {
487
+ botId: identifier(group, "botId", "client search group"),
488
+ botName: text(group, "botName", MAX_NAME_LENGTH, "client search group"),
489
+ archived: boolean(group, "archived", "client search group"),
490
+ hidden: boolean(group, "hidden", "client search group"),
491
+ hits: hits.map(decodeClientSearchHitV1),
492
+ totalHits: group.totalHits as number,
493
+ };
494
+ }
495
+
496
+ export function decodeClientSearchResultsV1(
497
+ input: unknown,
498
+ ): ClientSearchResultsV1 {
499
+ const results = record(input, "client search results");
500
+ exactKeys(
501
+ results,
502
+ ["schemaVersion", "query", "groups", "page", "indexState"],
503
+ "client search results",
504
+ );
505
+ schemaVersion(results, "client search results");
506
+ const groups = list(results.groups, "client search results.groups");
507
+ if (groups.length > SEARCH_MAX_GROUPS_V1) {
508
+ throw new SearchDecodeError(
509
+ "client search results.groups exceeds its bound",
510
+ );
511
+ }
512
+ const page = record(results.page, "client search results.page");
513
+ exactKeys(page, ["truncated", "nextCursor"], "client search results.page");
514
+ return {
515
+ schemaVersion: 1,
516
+ query: text(
517
+ results,
518
+ "query",
519
+ SEARCH_MAX_QUERY_LENGTH_V1,
520
+ "client search results",
521
+ ),
522
+ groups: groups.map(decodeClientSearchBotGroupV1),
523
+ page: {
524
+ truncated: boolean(page, "truncated", "client search results.page"),
525
+ ...(page.nextCursor === undefined
526
+ ? {}
527
+ : {
528
+ nextCursor: text(
529
+ page,
530
+ "nextCursor",
531
+ SEARCH_MAX_CURSOR_LENGTH_V1,
532
+ "client search results.page",
533
+ ),
534
+ }),
535
+ },
536
+ indexState: indexState(results.indexState, "client search results"),
537
+ };
538
+ }
539
+
540
+ export function decodeClientSearchRebuildReceiptV1(
541
+ input: unknown,
542
+ ): ClientSearchRebuildReceiptV1 {
543
+ const receipt = record(input, "client search rebuild receipt");
544
+ exactKeys(
545
+ receipt,
546
+ ["schemaVersion", "status", "indexedRows", "bots", "indexState"],
547
+ "client search rebuild receipt",
548
+ );
549
+ schemaVersion(receipt, "client search rebuild receipt");
550
+ if (receipt.status !== "rebuilt") {
551
+ throw new SearchDecodeError(
552
+ "client search rebuild receipt.status is invalid",
553
+ );
554
+ }
555
+ for (const key of ["indexedRows", "bots"] as const) {
556
+ if (!Number.isSafeInteger(receipt[key]) || (receipt[key] as number) < 0) {
557
+ throw new SearchDecodeError(
558
+ `client search rebuild receipt.${key} must be a non-negative integer`,
559
+ );
560
+ }
561
+ }
562
+ return {
563
+ schemaVersion: 1,
564
+ status: "rebuilt",
565
+ indexedRows: receipt.indexedRows as number,
566
+ bots: receipt.bots as number,
567
+ indexState: indexState(receipt.indexState, "client search rebuild receipt"),
568
+ };
569
+ }