@frockbot/plugin-shell 0.0.0 → 0.1.1

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 (73) hide show
  1. package/frockbot.json +68 -0
  2. package/package.json +87 -6
  3. package/src/agent.test.ts +372 -0
  4. package/src/agent.ts +335 -0
  5. package/src/approvals.test.ts +224 -0
  6. package/src/approvals.ts +530 -0
  7. package/src/backend-assignment.test.ts +161 -0
  8. package/src/backend-assignment.ts +274 -0
  9. package/src/backend-authoring.test.ts +518 -0
  10. package/src/backend-authoring.ts +531 -0
  11. package/src/backend-bot-identity.test.ts +215 -0
  12. package/src/backend-completion.test.ts +289 -0
  13. package/src/backend-completion.ts +95 -0
  14. package/src/backend-composition.ts +242 -0
  15. package/src/backend-computer.ts +76 -0
  16. package/src/backend-configuration.test.ts +1757 -0
  17. package/src/backend-contracts.test.ts +189 -0
  18. package/src/backend-contracts.ts +44 -0
  19. package/src/backend-debug.test.ts +202 -0
  20. package/src/backend-execution.ts +55 -0
  21. package/src/backend-flock.ts +96 -0
  22. package/src/backend-image.test.ts +115 -0
  23. package/src/backend-image.ts +180 -0
  24. package/src/backend-isolate.test.ts +238 -0
  25. package/src/backend-isolate.ts +409 -0
  26. package/src/backend-machine.ts +144 -0
  27. package/src/backend-memory.ts +89 -0
  28. package/src/backend-recovery-integration.test.ts +1575 -0
  29. package/src/backend-recovery.ts +106 -0
  30. package/src/backend-routines.ts +375 -0
  31. package/src/backend-runner.ts +251 -0
  32. package/src/backend-skills.test.ts +126 -0
  33. package/src/backend-skills.ts +198 -0
  34. package/src/backend-stop.test.ts +356 -0
  35. package/src/backend-subagents.ts +459 -0
  36. package/src/backend.ts +6035 -0
  37. package/src/client/FrockBotApp.vue +1026 -0
  38. package/src/client/SendPayloadView.vue +337 -0
  39. package/src/client/composer-draft.test.ts +31 -0
  40. package/src/client/composer-draft.ts +35 -0
  41. package/src/client/cordis-client-shim.d.ts +15 -0
  42. package/src/client/index.test.ts +2548 -0
  43. package/src/client/index.ts +2346 -0
  44. package/src/client/model-presentation.test.ts +35 -0
  45. package/src/client/model-presentation.ts +19 -0
  46. package/src/client/notify.test.ts +89 -0
  47. package/src/client/notify.ts +101 -0
  48. package/src/client/skill-invocation.test.ts +143 -0
  49. package/src/client/skill-invocation.ts +175 -0
  50. package/src/client/styles.css +1043 -0
  51. package/src/composition-views.ts +118 -0
  52. package/src/debug-protocol.test.ts +80 -0
  53. package/src/debug-protocol.ts +165 -0
  54. package/src/env.d.ts +10 -0
  55. package/src/history.test.ts +163 -0
  56. package/src/history.ts +108 -0
  57. package/src/host.ts +20 -0
  58. package/src/index.ts +2 -0
  59. package/src/manifest.ts +3 -0
  60. package/src/run-cursor.ts +28 -0
  61. package/src/run-protocol.test.ts +1281 -0
  62. package/src/run-protocol.ts +1417 -0
  63. package/src/settings-links.test.ts +106 -0
  64. package/src/settings-links.ts +289 -0
  65. package/src/shared.ts +338 -0
  66. package/src/skill-protocol.ts +117 -0
  67. package/src/terminal-records.test.ts +217 -0
  68. package/src/terminal-records.ts +150 -0
  69. package/src/unread.test.ts +362 -0
  70. package/src/unread.ts +675 -0
  71. package/tsconfig.json +18 -0
  72. package/vite.config.ts +32 -0
  73. package/README.md +0 -3
package/src/unread.ts ADDED
@@ -0,0 +1,675 @@
1
+ /**
2
+ * Per-Bot unread state (parity register row 56).
3
+ *
4
+ * The Bot Durable Object is the authority: unread is derived from the Bot's own
5
+ * admission index and its terminal settlements, which only that object can see.
6
+ * The durable record is a pair of cursors into that index rather than a
7
+ * counter, because a counter increments non-idempotently and double-counts on
8
+ * recovery or replay, while `max()` over a cursor is idempotent — "Recovery
9
+ * never silently duplicates" applied to a badge.
10
+ *
11
+ * The count is derived, never stored: it is the number of admission-index
12
+ * entries strictly after `lastSeenCursor` and at or before `lastActivityCursor`.
13
+ * Only a settled chat Turn advances `lastActivityCursor`, so an in-flight Turn
14
+ * and an automation Turn (slice E, whose outcome reaches the User through its
15
+ * own inbox entry) contribute nothing to the badge.
16
+ */
17
+ import {
18
+ canonicalCommandFingerprintV1,
19
+ isPublicIdentifier,
20
+ } from "@frockbot/configuration-core";
21
+ import { decodeRunCursorV1 } from "./run-cursor.js";
22
+
23
+ /** The single durable key the whole record lives under. */
24
+ export const UNREAD_STATE_KEY = "shell:unread";
25
+ /** The newest visible chat line, written beside unread activity at settlement. */
26
+ export const SIDEBAR_PREVIEW_KEY = "shell:preview";
27
+ /** Idempotency receipts for `bot/mark-read` and `bot/mark-unread`. */
28
+ export const UNREAD_RECEIPT_PREFIX = "shell:unread-receipt:";
29
+ /** Everything past this is reported as "99+"; the sidebar never renders more. */
30
+ export const UNREAD_COUNT_CAP = 99;
31
+ /** A sidebar row never carries more transcript text than it can render. */
32
+ export const SIDEBAR_PREVIEW_TEXT_LIMIT = 120;
33
+
34
+ const MAX_CURSOR_LENGTH = 320;
35
+ const MAX_TIMESTAMP_LENGTH = 64;
36
+ const MAX_COMMAND_ID_LENGTH = 128;
37
+
38
+ export class UnreadDecodeError extends Error {
39
+ constructor(message: string) {
40
+ super(message);
41
+ this.name = "UnreadDecodeError";
42
+ }
43
+ }
44
+
45
+ /** The durable record. One key, one schema version, no derived fields. */
46
+ export interface UnreadStateV1 {
47
+ schemaVersion: 1;
48
+ /** Newest settled chat Turn, as an admission-index cursor. */
49
+ lastActivityCursor?: string;
50
+ /** When that Turn settled. */
51
+ lastActivityAt?: string;
52
+ /** How far the User has read, as an admission-index cursor. */
53
+ lastSeenCursor?: string;
54
+ /** When the User last read it. */
55
+ lastViewedAt?: string;
56
+ /** User intent that is not derivable from any cursor. */
57
+ manuallyUnread: boolean;
58
+ }
59
+
60
+ /**
61
+ * The small durable projection a sidebar row needs from the latest settled
62
+ * chat Turn. It is not transcript authority: the stored run and Session events
63
+ * remain that. This only avoids opening every run log during the bounded
64
+ * sidebar fan-out.
65
+ */
66
+ export interface SidebarMessagePreviewV1 {
67
+ schemaVersion: 1;
68
+ text: string;
69
+ at: string;
70
+ role: "assistant" | "user";
71
+ }
72
+
73
+ export function emptyUnreadStateV1(): UnreadStateV1 {
74
+ return { schemaVersion: 1, manuallyUnread: false };
75
+ }
76
+
77
+ function record(input: unknown, label: string): Record<string, unknown> {
78
+ if (typeof input !== "object" || input === null || Array.isArray(input)) {
79
+ throw new UnreadDecodeError(`${label} must be an object`);
80
+ }
81
+ return input as Record<string, unknown>;
82
+ }
83
+
84
+ function exactKeys(
85
+ value: Record<string, unknown>,
86
+ required: readonly string[],
87
+ optional: readonly string[],
88
+ label: string,
89
+ ): void {
90
+ const keys = Reflect.ownKeys(value);
91
+ if (
92
+ keys.some(
93
+ (key) =>
94
+ typeof key !== "string" ||
95
+ (!required.includes(key) && !optional.includes(key)) ||
96
+ !Object.prototype.propertyIsEnumerable.call(value, key),
97
+ ) ||
98
+ required.some((key) => !Object.hasOwn(value, key))
99
+ ) {
100
+ throw new UnreadDecodeError(`${label} has unknown or missing fields`);
101
+ }
102
+ }
103
+
104
+ function optionalCursor(
105
+ value: Record<string, unknown>,
106
+ key: string,
107
+ label: string,
108
+ ): string | undefined {
109
+ const candidate = value[key];
110
+ if (candidate === undefined) return undefined;
111
+ if (typeof candidate !== "string" || candidate.length > MAX_CURSOR_LENGTH) {
112
+ throw new UnreadDecodeError(`${label} ${key} is invalid`);
113
+ }
114
+ try {
115
+ return decodeRunCursorV1(candidate);
116
+ } catch {
117
+ throw new UnreadDecodeError(`${label} ${key} is invalid`);
118
+ }
119
+ }
120
+
121
+ function optionalTimestamp(
122
+ value: Record<string, unknown>,
123
+ key: string,
124
+ label: string,
125
+ ): string | undefined {
126
+ const candidate = value[key];
127
+ if (candidate === undefined) return undefined;
128
+ if (
129
+ typeof candidate !== "string" ||
130
+ candidate.length > MAX_TIMESTAMP_LENGTH ||
131
+ !Number.isFinite(Date.parse(candidate)) ||
132
+ new Date(candidate).toISOString() !== candidate
133
+ ) {
134
+ throw new UnreadDecodeError(`${label} ${key} is invalid`);
135
+ }
136
+ return candidate;
137
+ }
138
+
139
+ /** Strict codec: exact keys, real cursors, real ISO timestamps. */
140
+ export function decodeUnreadStateV1(input: unknown): UnreadStateV1 {
141
+ const value = record(input, "unread state");
142
+ exactKeys(
143
+ value,
144
+ ["schemaVersion", "manuallyUnread"],
145
+ ["lastActivityCursor", "lastActivityAt", "lastSeenCursor", "lastViewedAt"],
146
+ "unread state",
147
+ );
148
+ if (value.schemaVersion !== 1) {
149
+ throw new UnreadDecodeError("unread state schemaVersion is invalid");
150
+ }
151
+ if (typeof value.manuallyUnread !== "boolean") {
152
+ throw new UnreadDecodeError("unread state manuallyUnread is invalid");
153
+ }
154
+ const lastActivityCursor = optionalCursor(
155
+ value,
156
+ "lastActivityCursor",
157
+ "unread state",
158
+ );
159
+ const lastActivityAt = optionalTimestamp(
160
+ value,
161
+ "lastActivityAt",
162
+ "unread state",
163
+ );
164
+ const lastSeenCursor = optionalCursor(
165
+ value,
166
+ "lastSeenCursor",
167
+ "unread state",
168
+ );
169
+ const lastViewedAt = optionalTimestamp(value, "lastViewedAt", "unread state");
170
+ // An activity cursor with no time it settled — or the reverse — is a record
171
+ // no writer here produces, so it is a corrupt record, not an old one.
172
+ if ((lastActivityCursor === undefined) !== (lastActivityAt === undefined)) {
173
+ throw new UnreadDecodeError("unread state activity is incomplete");
174
+ }
175
+ if ((lastSeenCursor === undefined) !== (lastViewedAt === undefined)) {
176
+ throw new UnreadDecodeError("unread state view is incomplete");
177
+ }
178
+ return {
179
+ schemaVersion: 1,
180
+ manuallyUnread: value.manuallyUnread,
181
+ ...(lastActivityCursor === undefined ? {} : { lastActivityCursor }),
182
+ ...(lastActivityAt === undefined ? {} : { lastActivityAt }),
183
+ ...(lastSeenCursor === undefined ? {} : { lastSeenCursor }),
184
+ ...(lastViewedAt === undefined ? {} : { lastViewedAt }),
185
+ };
186
+ }
187
+
188
+ /** An absent record reads as the empty one; a corrupt record still throws. */
189
+ export function optionalUnreadStateV1(input: unknown): UnreadStateV1 {
190
+ return input === undefined
191
+ ? emptyUnreadStateV1()
192
+ : decodeUnreadStateV1(input);
193
+ }
194
+
195
+ /** Exact durable/client codec for one projected visible chat line. */
196
+ export function decodeSidebarMessagePreviewV1(
197
+ input: unknown,
198
+ ): SidebarMessagePreviewV1 {
199
+ const value = record(input, "sidebar message preview");
200
+ exactKeys(
201
+ value,
202
+ ["schemaVersion", "text", "at", "role"],
203
+ [],
204
+ "sidebar message preview",
205
+ );
206
+ if (value.schemaVersion !== 1) {
207
+ throw new UnreadDecodeError(
208
+ "sidebar message preview schemaVersion is invalid",
209
+ );
210
+ }
211
+ if (
212
+ typeof value.text !== "string" ||
213
+ value.text.length === 0 ||
214
+ value.text.length > SIDEBAR_PREVIEW_TEXT_LIMIT
215
+ ) {
216
+ throw new UnreadDecodeError("sidebar message preview text is invalid");
217
+ }
218
+ const at = optionalTimestamp(value, "at", "sidebar message preview");
219
+ if (at === undefined) {
220
+ throw new UnreadDecodeError("sidebar message preview at is invalid");
221
+ }
222
+ if (value.role !== "assistant" && value.role !== "user") {
223
+ throw new UnreadDecodeError("sidebar message preview role is invalid");
224
+ }
225
+ return {
226
+ schemaVersion: 1,
227
+ text: value.text,
228
+ at,
229
+ role: value.role,
230
+ };
231
+ }
232
+
233
+ /** An absent preview means this Bot has no settled visible chat line yet. */
234
+ export function optionalSidebarMessagePreviewV1(
235
+ input: unknown,
236
+ ): SidebarMessagePreviewV1 | undefined {
237
+ return input === undefined ? undefined : decodeSidebarMessagePreviewV1(input);
238
+ }
239
+
240
+ /**
241
+ * Selects the last visible line of a completed chat Turn. The assistant reply
242
+ * is later when it has text; an empty reply leaves the User's admitted input as
243
+ * the preview. Both are bounded before they enter the durable projection.
244
+ */
245
+ export function sidebarMessagePreviewForTurnV1(
246
+ turn: { acceptedAt: string; input: string; responseText?: string },
247
+ settledAt: string,
248
+ ): SidebarMessagePreviewV1 | undefined {
249
+ const role = turn.responseText ? "assistant" : "user";
250
+ const text = (turn.responseText || turn.input).slice(
251
+ 0,
252
+ SIDEBAR_PREVIEW_TEXT_LIMIT,
253
+ );
254
+ if (text.length === 0) return undefined;
255
+ return decodeSidebarMessagePreviewV1({
256
+ schemaVersion: 1,
257
+ text,
258
+ at: role === "assistant" ? settledAt : turn.acceptedAt,
259
+ role,
260
+ });
261
+ }
262
+
263
+ /**
264
+ * Records a settled chat Turn. Monotonic: a cursor that is not newer than the
265
+ * one already recorded leaves the record byte-for-byte unchanged, so a
266
+ * recovered Turn settling a second time cannot move the badge.
267
+ */
268
+ export function advanceUnreadActivityV1(
269
+ current: UnreadStateV1,
270
+ activity: { cursor: string; at: string },
271
+ ): UnreadStateV1 {
272
+ const cursor = decodeRunCursorV1(activity.cursor);
273
+ if (
274
+ current.lastActivityCursor !== undefined &&
275
+ current.lastActivityCursor >= cursor
276
+ ) {
277
+ return current;
278
+ }
279
+ return {
280
+ ...current,
281
+ lastActivityCursor: cursor,
282
+ lastActivityAt: activity.at,
283
+ };
284
+ }
285
+
286
+ /**
287
+ * Records that the User read up to a cursor. `max()` makes out-of-order
288
+ * delivery safe, and reading always clears the manual flag: the User is
289
+ * looking at the thread they marked unread.
290
+ */
291
+ export function markUnreadReadV1(
292
+ current: UnreadStateV1,
293
+ input: { upToCursor: string; at: string },
294
+ ): UnreadStateV1 {
295
+ const cursor = decodeRunCursorV1(input.upToCursor);
296
+ const lastSeenCursor =
297
+ current.lastSeenCursor !== undefined && current.lastSeenCursor > cursor
298
+ ? current.lastSeenCursor
299
+ : cursor;
300
+ const lastViewedAt =
301
+ lastSeenCursor === current.lastSeenCursor &&
302
+ current.lastViewedAt !== undefined &&
303
+ current.lastViewedAt > input.at
304
+ ? current.lastViewedAt
305
+ : input.at;
306
+ return {
307
+ ...current,
308
+ lastSeenCursor,
309
+ lastViewedAt,
310
+ manuallyUnread: false,
311
+ };
312
+ }
313
+
314
+ /** User intent, stored because nothing derives it. */
315
+ export function markUnreadV1(current: UnreadStateV1): UnreadStateV1 {
316
+ return { ...current, manuallyUnread: true };
317
+ }
318
+
319
+ /**
320
+ * The projection the sidebar renders. `count` is derived from the admission
321
+ * index every read; nothing here is stored.
322
+ */
323
+ export interface BotUnreadViewV1 {
324
+ schemaVersion: 1;
325
+ botId: string;
326
+ /** Settled chat Turns since `lastSeenCursor`, capped at {@link UNREAD_COUNT_CAP}. */
327
+ count: number;
328
+ /** True when the real count is above the cap — the "99+" case. */
329
+ capped: boolean;
330
+ /** Whether the row renders bold: a count, or the manual flag. */
331
+ unread: boolean;
332
+ manuallyUnread: boolean;
333
+ /** What a `bot/mark-read` should name as `upToCursor`. */
334
+ lastActivityCursor?: string;
335
+ lastActivityAt?: string;
336
+ lastViewedAt?: string;
337
+ /** Latest settled assistant/user line, projected for the sidebar only. */
338
+ lastMessage?: SidebarMessagePreviewV1;
339
+ }
340
+
341
+ export interface BotUnreadDirectoryViewV1 {
342
+ schemaVersion: 1;
343
+ unread: BotUnreadViewV1[];
344
+ }
345
+
346
+ /**
347
+ * Counts admission-index cursors in `(lastSeenCursor, lastActivityCursor]`.
348
+ * `cursors` is the newest-first index page; it only has to be one longer than
349
+ * the cap for the cap to be exact.
350
+ */
351
+ export function projectBotUnreadViewV1(
352
+ botId: string,
353
+ state: UnreadStateV1,
354
+ cursors: readonly string[],
355
+ lastMessage?: SidebarMessagePreviewV1,
356
+ ): BotUnreadViewV1 {
357
+ const ceiling = state.lastActivityCursor;
358
+ let counted = 0;
359
+ if (ceiling !== undefined) {
360
+ for (const cursor of cursors) {
361
+ if (cursor > ceiling) continue;
362
+ if (
363
+ state.lastSeenCursor !== undefined &&
364
+ cursor <= state.lastSeenCursor
365
+ ) {
366
+ break;
367
+ }
368
+ counted += 1;
369
+ if (counted > UNREAD_COUNT_CAP) break;
370
+ }
371
+ }
372
+ const capped = counted > UNREAD_COUNT_CAP;
373
+ const count = capped ? UNREAD_COUNT_CAP : counted;
374
+ return {
375
+ schemaVersion: 1,
376
+ botId,
377
+ count,
378
+ capped,
379
+ unread: count > 0 || state.manuallyUnread,
380
+ manuallyUnread: state.manuallyUnread,
381
+ ...(state.lastActivityCursor === undefined
382
+ ? {}
383
+ : { lastActivityCursor: state.lastActivityCursor }),
384
+ ...(state.lastActivityAt === undefined
385
+ ? {}
386
+ : { lastActivityAt: state.lastActivityAt }),
387
+ ...(state.lastViewedAt === undefined
388
+ ? {}
389
+ : { lastViewedAt: state.lastViewedAt }),
390
+ ...(lastMessage === undefined ? {} : { lastMessage }),
391
+ };
392
+ }
393
+
394
+ /**
395
+ * An authenticated command, never a side effect of a read: a background poll
396
+ * that listed runs must not clear a badge.
397
+ */
398
+ export interface BotUnreadCommandV1 {
399
+ schemaVersion: 1;
400
+ type: "bot/mark-read" | "bot/mark-unread";
401
+ commandId: string;
402
+ botId: string;
403
+ /** Required by `bot/mark-read`, refused on `bot/mark-unread`. */
404
+ upToCursor?: string;
405
+ }
406
+
407
+ export interface BotUnreadReceiptV1 {
408
+ schemaVersion: 1;
409
+ commandId: string;
410
+ status: "applied";
411
+ unread: BotUnreadViewV1;
412
+ }
413
+
414
+ export function decodeBotUnreadCommandV1(input: unknown): BotUnreadCommandV1 {
415
+ const value = record(input, "unread command");
416
+ exactKeys(
417
+ value,
418
+ ["schemaVersion", "type", "commandId", "botId"],
419
+ ["upToCursor"],
420
+ "unread command",
421
+ );
422
+ if (value.schemaVersion !== 1) {
423
+ throw new UnreadDecodeError("unread command schemaVersion is invalid");
424
+ }
425
+ if (value.type !== "bot/mark-read" && value.type !== "bot/mark-unread") {
426
+ throw new UnreadDecodeError("unread command type is invalid");
427
+ }
428
+ if (
429
+ typeof value.commandId !== "string" ||
430
+ value.commandId.length === 0 ||
431
+ value.commandId.length > MAX_COMMAND_ID_LENGTH
432
+ ) {
433
+ throw new UnreadDecodeError("unread command commandId is invalid");
434
+ }
435
+ if (typeof value.botId !== "string" || !isPublicIdentifier(value.botId)) {
436
+ throw new UnreadDecodeError("unread command botId is invalid");
437
+ }
438
+ const upToCursor = optionalCursor(value, "upToCursor", "unread command");
439
+ if ((value.type === "bot/mark-read") !== (upToCursor !== undefined)) {
440
+ throw new UnreadDecodeError(
441
+ "unread command upToCursor belongs only to bot/mark-read",
442
+ );
443
+ }
444
+ return {
445
+ schemaVersion: 1,
446
+ type: value.type,
447
+ commandId: value.commandId,
448
+ botId: value.botId,
449
+ ...(upToCursor === undefined ? {} : { upToCursor }),
450
+ };
451
+ }
452
+
453
+ /** The same canonicalization every other command family is fingerprinted with. */
454
+ export function botUnreadCommandFingerprintV1(
455
+ command: BotUnreadCommandV1,
456
+ ): string {
457
+ const { commandId: _commandId, ...semantic } = command;
458
+ return canonicalCommandFingerprintV1("bot-unread-command-v1", semantic);
459
+ }
460
+
461
+ export function unreadReceiptKeyV1(commandId: string): string {
462
+ return `${UNREAD_RECEIPT_PREFIX}${commandId}`;
463
+ }
464
+
465
+ function decodeBotUnreadViewV1(input: unknown): BotUnreadViewV1 {
466
+ const value = record(input, "unread view");
467
+ exactKeys(
468
+ value,
469
+ ["schemaVersion", "botId", "count", "capped", "unread", "manuallyUnread"],
470
+ ["lastActivityCursor", "lastActivityAt", "lastViewedAt", "lastMessage"],
471
+ "unread view",
472
+ );
473
+ if (value.schemaVersion !== 1) {
474
+ throw new UnreadDecodeError("unread view schemaVersion is invalid");
475
+ }
476
+ if (typeof value.botId !== "string" || !isPublicIdentifier(value.botId)) {
477
+ throw new UnreadDecodeError("unread view botId is invalid");
478
+ }
479
+ if (
480
+ !Number.isSafeInteger(value.count) ||
481
+ (value.count as number) < 0 ||
482
+ (value.count as number) > UNREAD_COUNT_CAP
483
+ ) {
484
+ throw new UnreadDecodeError("unread view count is invalid");
485
+ }
486
+ if (
487
+ typeof value.capped !== "boolean" ||
488
+ typeof value.unread !== "boolean" ||
489
+ typeof value.manuallyUnread !== "boolean"
490
+ ) {
491
+ throw new UnreadDecodeError("unread view flags are invalid");
492
+ }
493
+ const lastActivityCursor = optionalCursor(
494
+ value,
495
+ "lastActivityCursor",
496
+ "unread view",
497
+ );
498
+ const lastActivityAt = optionalTimestamp(
499
+ value,
500
+ "lastActivityAt",
501
+ "unread view",
502
+ );
503
+ const lastViewedAt = optionalTimestamp(value, "lastViewedAt", "unread view");
504
+ const lastMessage = optionalSidebarMessagePreviewV1(value.lastMessage);
505
+ return {
506
+ schemaVersion: 1,
507
+ botId: value.botId,
508
+ count: value.count as number,
509
+ capped: value.capped,
510
+ unread: value.unread,
511
+ manuallyUnread: value.manuallyUnread,
512
+ ...(lastActivityCursor === undefined ? {} : { lastActivityCursor }),
513
+ ...(lastActivityAt === undefined ? {} : { lastActivityAt }),
514
+ ...(lastViewedAt === undefined ? {} : { lastViewedAt }),
515
+ ...(lastMessage === undefined ? {} : { lastMessage }),
516
+ };
517
+ }
518
+
519
+ export function decodeBotUnreadDirectoryViewV1(
520
+ input: unknown,
521
+ ): BotUnreadDirectoryViewV1 {
522
+ const value = record(input, "unread directory");
523
+ exactKeys(value, ["schemaVersion", "unread"], [], "unread directory");
524
+ if (value.schemaVersion !== 1 || !Array.isArray(value.unread)) {
525
+ throw new UnreadDecodeError("unread directory is invalid");
526
+ }
527
+ return {
528
+ schemaVersion: 1,
529
+ unread: value.unread.map(decodeBotUnreadViewV1),
530
+ };
531
+ }
532
+
533
+ export function decodeBotUnreadReceiptV1(input: unknown): BotUnreadReceiptV1 {
534
+ const value = record(input, "unread receipt");
535
+ exactKeys(
536
+ value,
537
+ ["schemaVersion", "commandId", "status", "unread"],
538
+ [],
539
+ "unread receipt",
540
+ );
541
+ if (
542
+ value.schemaVersion !== 1 ||
543
+ typeof value.commandId !== "string" ||
544
+ value.status !== "applied"
545
+ ) {
546
+ throw new UnreadDecodeError("unread receipt is invalid");
547
+ }
548
+ return {
549
+ schemaVersion: 1,
550
+ commandId: value.commandId,
551
+ status: "applied",
552
+ unread: decodeBotUnreadViewV1(value.unread),
553
+ };
554
+ }
555
+
556
+ /**
557
+ * One pending notification intent, carrying the Bot it belongs to. The
558
+ * per-Bot route already answers the open Bot's intents; this is the bounded
559
+ * fan-out that lets a completion on a Bot nobody is looking at surface.
560
+ */
561
+ export interface BotPendingNotificationV1 {
562
+ schemaVersion: 1;
563
+ botId: string;
564
+ notificationId: string;
565
+ runId: string;
566
+ createdAt: string;
567
+ title: string;
568
+ body: string;
569
+ }
570
+
571
+ export interface BotNotificationDirectoryViewV1 {
572
+ schemaVersion: 1;
573
+ notifications: BotPendingNotificationV1[];
574
+ }
575
+
576
+ const MAX_NOTIFICATION_TITLE_LENGTH = 512;
577
+ const MAX_NOTIFICATION_BODY_LENGTH = 2_000;
578
+ const MAX_NOTIFICATION_ID_LENGTH = 256;
579
+
580
+ function boundedText(
581
+ value: unknown,
582
+ maximum: number,
583
+ label: string,
584
+ allowEmpty = false,
585
+ ): string {
586
+ if (
587
+ typeof value !== "string" ||
588
+ (!allowEmpty && value.length === 0) ||
589
+ value.length > maximum
590
+ ) {
591
+ throw new UnreadDecodeError(`${label} is invalid`);
592
+ }
593
+ return value;
594
+ }
595
+
596
+ function decodeBotPendingNotificationV1(
597
+ input: unknown,
598
+ ): BotPendingNotificationV1 {
599
+ const value = record(input, "pending notification");
600
+ exactKeys(
601
+ value,
602
+ [
603
+ "schemaVersion",
604
+ "botId",
605
+ "notificationId",
606
+ "runId",
607
+ "createdAt",
608
+ "title",
609
+ "body",
610
+ ],
611
+ [],
612
+ "pending notification",
613
+ );
614
+ if (value.schemaVersion !== 1) {
615
+ throw new UnreadDecodeError(
616
+ "pending notification schemaVersion is invalid",
617
+ );
618
+ }
619
+ if (typeof value.botId !== "string" || !isPublicIdentifier(value.botId)) {
620
+ throw new UnreadDecodeError("pending notification botId is invalid");
621
+ }
622
+ const createdAt = optionalTimestamp(
623
+ value,
624
+ "createdAt",
625
+ "pending notification",
626
+ );
627
+ if (createdAt === undefined) {
628
+ throw new UnreadDecodeError("pending notification createdAt is invalid");
629
+ }
630
+ return {
631
+ schemaVersion: 1,
632
+ botId: value.botId,
633
+ notificationId: boundedText(
634
+ value.notificationId,
635
+ MAX_NOTIFICATION_ID_LENGTH,
636
+ "pending notification notificationId",
637
+ ),
638
+ runId: boundedText(
639
+ value.runId,
640
+ MAX_NOTIFICATION_ID_LENGTH,
641
+ "pending notification runId",
642
+ ),
643
+ createdAt,
644
+ title: boundedText(
645
+ value.title,
646
+ MAX_NOTIFICATION_TITLE_LENGTH,
647
+ "pending notification title",
648
+ ),
649
+ body: boundedText(
650
+ value.body,
651
+ MAX_NOTIFICATION_BODY_LENGTH,
652
+ "pending notification body",
653
+ true,
654
+ ),
655
+ };
656
+ }
657
+
658
+ export function decodeBotNotificationDirectoryViewV1(
659
+ input: unknown,
660
+ ): BotNotificationDirectoryViewV1 {
661
+ const value = record(input, "notification directory");
662
+ exactKeys(
663
+ value,
664
+ ["schemaVersion", "notifications"],
665
+ [],
666
+ "notification directory",
667
+ );
668
+ if (value.schemaVersion !== 1 || !Array.isArray(value.notifications)) {
669
+ throw new UnreadDecodeError("notification directory is invalid");
670
+ }
671
+ return {
672
+ schemaVersion: 1,
673
+ notifications: value.notifications.map(decodeBotPendingNotificationV1),
674
+ };
675
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,18 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2023",
4
+ "module": "ESNext",
5
+ "moduleResolution": "Bundler",
6
+ "allowImportingTsExtensions": true,
7
+ "baseUrl": ".",
8
+ "paths": {
9
+ "@cordisjs/client": ["src/client/cordis-client-shim.d.ts"]
10
+ },
11
+ "strict": true,
12
+ "noEmit": true,
13
+ "skipLibCheck": true,
14
+ "lib": ["ES2023", "DOM", "DOM.Iterable", "WebWorker"],
15
+ "types": ["bun", "node", "vite/client", "@cloudflare/workers-types"]
16
+ },
17
+ "include": ["src/**/*.ts", "src/**/*.vue", "vite.config.ts"]
18
+ }