@frockbot/plugin-shell 0.3.10 → 0.3.12

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 (37) hide show
  1. package/package.json +34 -32
  2. package/src/agent.test.ts +66 -0
  3. package/src/agent.ts +107 -2
  4. package/src/backend-configuration.test.ts +15 -9
  5. package/src/backend-package-catalog.ts +75 -26
  6. package/src/backend-runner.ts +19 -2
  7. package/src/backend.ts +118 -27
  8. package/src/client/FrockBotApp.vue +405 -134
  9. package/src/client/activity-trail.test.ts +205 -0
  10. package/src/client/activity-trail.ts +227 -0
  11. package/src/client/index.test.ts +25 -5
  12. package/src/client/index.ts +191 -47
  13. package/src/client/model-presentation.test.ts +3 -3
  14. package/src/client/no-bot-model-label.test.ts +7 -7
  15. package/src/client/skill-invocation.test.ts +34 -0
  16. package/src/client/skill-invocation.ts +22 -0
  17. package/src/client/styles.css +134 -89
  18. package/src/client/transcript-cache.test.ts +125 -0
  19. package/src/client/transcript-cache.ts +190 -0
  20. package/src/compaction-scheduler.test.ts +96 -0
  21. package/src/compaction-scheduler.ts +108 -0
  22. package/src/compaction-transcript.test.ts +174 -0
  23. package/src/compaction.test.ts +596 -0
  24. package/src/compaction.ts +539 -0
  25. package/src/focus.test.ts +222 -0
  26. package/src/focus.ts +93 -0
  27. package/src/history.ts +86 -8
  28. package/src/legacy-frock-model-id.test.ts +148 -0
  29. package/src/notification-id.test.ts +26 -0
  30. package/src/notification-id.ts +0 -0
  31. package/src/run-protocol.test.ts +37 -0
  32. package/src/run-protocol.ts +148 -38
  33. package/src/settings-links.test.ts +8 -2
  34. package/src/settings-links.ts +17 -2
  35. package/src/shared.ts +36 -0
  36. package/src/unread.ts +23 -1
  37. package/tsconfig.json +1 -2
@@ -5,7 +5,10 @@ import {
5
5
  type SessionEvent,
6
6
  type SkillRefV1,
7
7
  } from "@frockbot/kernel-contracts";
8
- import { isPublicIdentifier } from "@frockbot/configuration-core";
8
+ import {
9
+ isPublicIdentifier,
10
+ isRpcIdentifier,
11
+ } from "@frockbot/configuration-core";
9
12
  import { decodeRunCursorV1, RUN_CURSOR_PATTERN } from "./run-cursor.js";
10
13
  export { decodeRunCursorV1, RUN_CURSOR_PATTERN };
11
14
  import type {
@@ -241,14 +244,30 @@ export interface ClientRunPageV1 {
241
244
  * A durable Session event that belongs to no Turn. The WebUI renders it as a
242
245
  * system line in the conversation.
243
246
  */
244
- export interface ClientAnnouncementV1 {
245
- type: "bot/renamed";
246
- announcementId: string;
247
- at: string;
248
- from: string;
249
- to: string;
250
- namedBy: "user" | "bot";
251
- }
247
+ /**
248
+ * A session-level line in the transcript that belongs to neither party.
249
+ *
250
+ * `conversation/compacted` is ADR 0030's one user-visible surface: the earlier
251
+ * Turns are still there and still readable, and this says plainly that the
252
+ * model now carries a summary of them instead of the Turns themselves. ADR
253
+ * 0027's "not summarised" notice still stands where Turns were genuinely
254
+ * evicted, so the two never claim each other's ground.
255
+ */
256
+ export type ClientAnnouncementV1 =
257
+ | {
258
+ type: "bot/renamed";
259
+ announcementId: string;
260
+ at: string;
261
+ from: string;
262
+ to: string;
263
+ namedBy: "user" | "bot";
264
+ }
265
+ | {
266
+ type: "conversation/compacted";
267
+ announcementId: string;
268
+ at: string;
269
+ throughTurn: number;
270
+ };
252
271
 
253
272
  export interface ClientRunListV1 {
254
273
  schemaVersion: 1;
@@ -283,6 +302,19 @@ export interface ClientConversationListV1 {
283
302
  conversations: ClientConversationV1[];
284
303
  }
285
304
 
305
+ /**
306
+ * The answer to "start a new conversation": the list, or the reason not now.
307
+ *
308
+ * A refusal is a value, not an exception. The request crosses a Durable Object
309
+ * boundary and a Worker boundary to get here, and an exception that crosses
310
+ * either is logged by workerd as `Uncaught Error` — the log then showed the
311
+ * isolate going down with a broken pipe behind it. Carrying the refusal as
312
+ * data means the only thing that reaches the client is the 409 it expects.
313
+ */
314
+ export type ClientConversationOutcomeV1 =
315
+ | ({ status: "started" } & ClientConversationListV1)
316
+ | { status: "refused"; schemaVersion: 1; reason: string };
317
+
286
318
  export function decodeClientConversationListV1(
287
319
  input: unknown,
288
320
  ): ClientConversationListV1 {
@@ -980,24 +1012,74 @@ export function createClientRunListV1(
980
1012
  const MAX_ANNOUNCEMENTS = 64;
981
1013
  const MAX_ANNOUNCEMENT_NAME_BYTES = 400;
982
1014
 
983
- /** Projects the Bot's durable announcement events onto the wire. */
1015
+ /**
1016
+ * Where each Turn ended, by Turn number.
1017
+ *
1018
+ * A compaction is written at the end of the Turn that crossed the threshold,
1019
+ * which is the *newest* Turn — so its own timestamp would place its marker at
1020
+ * the bottom of the thread, far from the range it describes. The boundary it
1021
+ * actually names is the end of `throughTurn`, and that is what the marker is
1022
+ * dated with.
1023
+ */
1024
+ function turnEndTimestampsV1(
1025
+ session: readonly SessionEvent[],
1026
+ ): Map<number, string> {
1027
+ const ends = new Map<number, string>();
1028
+ for (const event of session) {
1029
+ if (event.type === "turn/end") ends.set(event.turn, event.timestamp);
1030
+ }
1031
+ return ends;
1032
+ }
1033
+
1034
+ /**
1035
+ * Projects the Bot's durable announcement events onto the wire.
1036
+ *
1037
+ * `session` is the conversation's own log, used only to date a compaction
1038
+ * marker at the boundary it covers. Omitting it dates the marker by when the
1039
+ * compaction was written, which is where it used to sit.
1040
+ */
984
1041
  export function projectClientAnnouncementsV1(
985
1042
  events: readonly SessionEvent[],
1043
+ session: readonly SessionEvent[] = events,
986
1044
  ): ClientAnnouncementV1[] {
987
- return events.flatMap((event) =>
988
- event.type === "bot/renamed"
989
- ? [
990
- {
991
- type: "bot/renamed" as const,
992
- announcementId: `announcement-${event.seq}`,
993
- at: truncate(event.timestamp, MAX_TIMESTAMP_LENGTH),
994
- from: truncateWireString(event.from, MAX_ANNOUNCEMENT_NAME_BYTES),
995
- to: truncateWireString(event.to, MAX_ANNOUNCEMENT_NAME_BYTES),
996
- namedBy: event.namedBy,
997
- },
998
- ]
999
- : [],
1000
- );
1045
+ const turnEnds = turnEndTimestampsV1(session);
1046
+ return events.flatMap((event): ClientAnnouncementV1[] => {
1047
+ if (event.type === "bot/renamed") {
1048
+ return [
1049
+ {
1050
+ type: "bot/renamed" as const,
1051
+ announcementId: `announcement-${event.seq}`,
1052
+ at: truncate(event.timestamp, MAX_TIMESTAMP_LENGTH),
1053
+ from: truncateWireString(event.from, MAX_ANNOUNCEMENT_NAME_BYTES),
1054
+ to: truncateWireString(event.to, MAX_ANNOUNCEMENT_NAME_BYTES),
1055
+ namedBy: event.namedBy,
1056
+ },
1057
+ ];
1058
+ }
1059
+ if (event.type === "conversation/compacted") {
1060
+ // The summary itself is deliberately not on the wire. A person can read
1061
+ // every Turn it covers, unchanged, immediately above this line; the
1062
+ // summary is what the model carries, and it belongs to the audit view.
1063
+ return [
1064
+ {
1065
+ type: "conversation/compacted" as const,
1066
+ // A distinct prefix: a compaction is numbered by the session log and
1067
+ // a rename by this Bot's announcement log, and the two counters would
1068
+ // otherwise collide on an id the client upserts by.
1069
+ announcementId: `compaction-${event.seq}`,
1070
+ // Dated where the covered range ends, not when the summariser ran,
1071
+ // so the marker sits between the last compacted Turn and the first
1072
+ // verbatim one and stays there as newer Turns arrive.
1073
+ at: truncate(
1074
+ turnEnds.get(event.throughTurn) ?? event.timestamp,
1075
+ MAX_TIMESTAMP_LENGTH,
1076
+ ),
1077
+ throughTurn: event.throughTurn,
1078
+ },
1079
+ ];
1080
+ }
1081
+ return [];
1082
+ });
1001
1083
  }
1002
1084
 
1003
1085
  export function clientRunListWireBytes(value: ClientRunListV1): number {
@@ -1648,7 +1730,15 @@ export function decodeClientNotificationAcknowledgementCommandV1(
1648
1730
  MAX_RUN_ID_LENGTH,
1649
1731
  "notification acknowledgement command",
1650
1732
  );
1651
- if (!isPublicIdentifier(notificationId)) {
1733
+ // `isRpcIdentifier`, not `isPublicIdentifier`: the same bounded alphabet
1734
+ // plus `:` and `@`. New ids are minted through `notificationIdV1` and carry
1735
+ // neither, but Bots in the field are already holding notifications whose ids
1736
+ // were interpolated by hand — `composition-failure:<generationId>:<attempt>`
1737
+ // and three more like it. Under the stricter pattern those could never be
1738
+ // acknowledged, so the client retried them forever and the Bot answered 400
1739
+ // on every poll for the rest of its life. Accepting them here is what lets
1740
+ // an already-wedged Bot recover without a storage sweep.
1741
+ if (!isRpcIdentifier(notificationId)) {
1652
1742
  throw new Error(
1653
1743
  "notification acknowledgement command.notificationId is invalid",
1654
1744
  );
@@ -1817,14 +1907,19 @@ export function decodeClientRunLookupV1(input: unknown): ClientRunLookup {
1817
1907
 
1818
1908
  function decodeAnnouncement(value: unknown): ClientAnnouncementV1 {
1819
1909
  const announcement = record(value, "run list.announcement");
1910
+ if (
1911
+ announcement.type !== "bot/renamed" &&
1912
+ announcement.type !== "conversation/compacted"
1913
+ ) {
1914
+ throw new Error("run list.announcement.type is invalid");
1915
+ }
1820
1916
  exactKeys(
1821
1917
  announcement,
1822
- ["type", "announcementId", "at", "from", "to", "namedBy"],
1918
+ announcement.type === "conversation/compacted"
1919
+ ? ["type", "announcementId", "at", "throughTurn"]
1920
+ : ["type", "announcementId", "at", "from", "to", "namedBy"],
1823
1921
  "run list.announcement",
1824
1922
  );
1825
- if (announcement.type !== "bot/renamed") {
1826
- throw new Error("run list.announcement.type is invalid");
1827
- }
1828
1923
  const at = string(
1829
1924
  announcement,
1830
1925
  "at",
@@ -1834,20 +1929,35 @@ function decodeAnnouncement(value: unknown): ClientAnnouncementV1 {
1834
1929
  if (!Number.isFinite(Date.parse(at))) {
1835
1930
  throw new Error("run list.announcement.at is invalid");
1836
1931
  }
1932
+ const announcementId = publicEventId(
1933
+ string(
1934
+ announcement,
1935
+ "announcementId",
1936
+ MAX_EVENT_ID_LENGTH,
1937
+ "run list.announcement",
1938
+ ),
1939
+ "run list.announcement.announcementId",
1940
+ );
1941
+ if (announcement.type === "conversation/compacted") {
1942
+ if (
1943
+ !Number.isSafeInteger(announcement.throughTurn) ||
1944
+ (announcement.throughTurn as number) < 1
1945
+ ) {
1946
+ throw new Error("run list.announcement.throughTurn is invalid");
1947
+ }
1948
+ return {
1949
+ type: "conversation/compacted",
1950
+ announcementId,
1951
+ at,
1952
+ throughTurn: announcement.throughTurn as number,
1953
+ };
1954
+ }
1837
1955
  if (announcement.namedBy !== "user" && announcement.namedBy !== "bot") {
1838
1956
  throw new Error("run list.announcement.namedBy is invalid");
1839
1957
  }
1840
1958
  return {
1841
1959
  type: "bot/renamed",
1842
- announcementId: publicEventId(
1843
- string(
1844
- announcement,
1845
- "announcementId",
1846
- MAX_EVENT_ID_LENGTH,
1847
- "run list.announcement",
1848
- ),
1849
- "run list.announcement.announcementId",
1850
- ),
1960
+ announcementId,
1851
1961
  at,
1852
1962
  from: wireString(
1853
1963
  announcement,
@@ -62,10 +62,16 @@ describe("settings link scheme", () => {
62
62
  });
63
63
  });
64
64
 
65
- test("drops a fragment belonging to another surface", () => {
65
+ test("a fragment belonging to another surface opens that surface", () => {
66
+ // The row is the specific request; the query parameter is where the link
67
+ // was written from, and rows move between surfaces.
66
68
  expect(
67
69
  decodeSettingsLinkV1("/?settings=bot-settings#bot-info-computer"),
68
- ).toEqual({ surface: "bot-settings" });
70
+ ).toEqual({ surface: "bot-panel", anchor: "bot-info-computer" });
71
+ expect(decodeSettingsLinkV1("/?settings=bot-panel#bot-routines")).toEqual({
72
+ surface: "bot-settings",
73
+ anchor: "bot-routines",
74
+ });
69
75
  });
70
76
 
71
77
  test("drops an anchor nobody registered", () => {
@@ -81,6 +81,12 @@ export const SETTINGS_ANCHORS_V1: readonly SettingsAnchorV1[] = [
81
81
  label: "Label",
82
82
  scope: "bot",
83
83
  },
84
+ {
85
+ anchor: "bot-pinned",
86
+ surface: "bot-settings",
87
+ label: "Pinned",
88
+ scope: "bot",
89
+ },
84
90
  {
85
91
  anchor: "bot-description",
86
92
  surface: "bot-settings",
@@ -269,9 +275,18 @@ export function decodeSettingsLinkV1(
269
275
  const fragment = decodeURIComponent(url.hash.replace(/^#/u, ""));
270
276
  const entry = fragment ? settingsAnchorV1(fragment) : undefined;
271
277
  const botId = url.searchParams.get("bot") ?? undefined;
278
+ /*
279
+ * A fragment naming a row that lives somewhere else is still a request for
280
+ * that row, so the anchor decides which surface opens. Dropping it instead
281
+ * is what made `?settings=bot-panel#bot-routines` open the panel and then
282
+ * sit there: a link that resolves to nothing visible reads as a broken app,
283
+ * and a row moving between surfaces is exactly what these links have to
284
+ * survive. A fragment nobody registered still opens the named surface with
285
+ * no row.
286
+ */
272
287
  return {
273
- surface,
274
- ...(entry && entry.surface === surface ? { anchor: entry.anchor } : {}),
288
+ surface: entry ? entry.surface : surface,
289
+ ...(entry ? { anchor: entry.anchor } : {}),
275
290
  ...(botId ? { botId } : {}),
276
291
  };
277
292
  }
package/src/shared.ts CHANGED
@@ -222,6 +222,29 @@ export function withoutConnectionReturnV1(search: string): string {
222
222
  return rest ? `?${rest}` : "";
223
223
  }
224
224
 
225
+ /**
226
+ * What the shell holds of the conversations the User is not looking at, as
227
+ * everything outside the shell may address it.
228
+ *
229
+ * Two callers: the thread, which records and reads back where the reader had
230
+ * each Bot scrolled, and any surface that changes a Bot enough that its held
231
+ * transcript would be a lie rather than merely old — archive, delete, and a
232
+ * change of signed-in User.
233
+ */
234
+ export interface TranscriptMemoryV1 {
235
+ /** Records where the reader had this Bot's thread. */
236
+ rememberViewport(
237
+ botId: string,
238
+ viewport: { scrollTop: number; pinnedToLatest: boolean },
239
+ ): void;
240
+ /** Where the reader had this Bot's thread, if it is still held. */
241
+ viewportFor(
242
+ botId: string,
243
+ ): { scrollTop: number; pinnedToLatest: boolean } | undefined;
244
+ /** Drops what is held for one Bot, or — with no argument — for every Bot. */
245
+ forget(botId?: string): void;
246
+ }
247
+
225
248
  export interface FrockBotWebData {
226
249
  connection: WebConnection;
227
250
  modelLabel: string;
@@ -249,6 +272,14 @@ export interface FrockBotWebData {
249
272
  runningRunId?: string;
250
273
  activeRun?: WebActiveRun;
251
274
  error?: string;
275
+ /**
276
+ * The Bot list could not be read, so "no Bot is open" means "we do not know
277
+ * yet" rather than "this account has none". Set by whichever Package owns
278
+ * the list. Without it a transport failure reads as data loss: the
279
+ * first-run empty state offering to add a first Bot to a User who has
280
+ * several is the worst thing this surface can say.
281
+ */
282
+ botsUnavailable?: boolean;
252
283
  botSettings?: BotSettingsViewV1;
253
284
  userSettings?: UserSettingsViewV1;
254
285
  pluginCatalog: PluginCatalogItem[];
@@ -328,6 +359,11 @@ export interface FrockBotWebData {
328
359
  * cancelled or failed grant is reported rather than silently discarded.
329
360
  */
330
361
  connectionReturn?: ConnectionReturnV1;
362
+ /**
363
+ * The conversations this client is still holding, so switching back to a
364
+ * Bot is a paint rather than a reload.
365
+ */
366
+ transcripts: TranscriptMemoryV1;
331
367
  selectBot(botId: string): Promise<void>;
332
368
  loadBotSettings(): Promise<void>;
333
369
  saveBotProfile(profile: BotProfile): Promise<void>;
package/src/unread.ts CHANGED
@@ -389,6 +389,15 @@ export interface BotUnreadViewV1 {
389
389
  lastViewedAt?: string;
390
390
  /** Latest settled assistant/user line, projected for the sidebar only. */
391
391
  lastMessage?: SidebarMessagePreviewV1;
392
+ /**
393
+ * Whether this Bot has a Turn running right now.
394
+ *
395
+ * The sidebar draws it as an activity ring on the row's avatar, so somebody
396
+ * reading one conversation can see another Bot still working rather than
397
+ * assuming it stalled. Optional: a view a client older than the Bot decodes,
398
+ * or one stored before this existed, simply draws no ring.
399
+ */
400
+ working?: boolean;
392
401
  }
393
402
 
394
403
  export interface BotUnreadDirectoryViewV1 {
@@ -414,6 +423,8 @@ export function projectBotUnreadViewV1(
414
423
  * so it counts here even though the firing that produced it does not.
415
424
  */
416
425
  automationFailures = 0,
426
+ /** True while a Turn of this Bot's is running. Drawn as the row's ring. */
427
+ working = false,
417
428
  ): BotUnreadViewV1 {
418
429
  const ceiling = state.lastActivityCursor;
419
430
  let counted = Math.max(0, automationFailures);
@@ -449,6 +460,7 @@ export function projectBotUnreadViewV1(
449
460
  ? {}
450
461
  : { lastViewedAt: state.lastViewedAt }),
451
462
  ...(lastMessage === undefined ? {} : { lastMessage }),
463
+ ...(working ? { working: true } : {}),
452
464
  };
453
465
  }
454
466
 
@@ -528,7 +540,13 @@ function decodeBotUnreadViewV1(input: unknown): BotUnreadViewV1 {
528
540
  exactKeys(
529
541
  value,
530
542
  ["schemaVersion", "botId", "count", "capped", "unread", "manuallyUnread"],
531
- ["lastActivityCursor", "lastActivityAt", "lastViewedAt", "lastMessage"],
543
+ [
544
+ "lastActivityCursor",
545
+ "lastActivityAt",
546
+ "lastViewedAt",
547
+ "lastMessage",
548
+ "working",
549
+ ],
532
550
  "unread view",
533
551
  );
534
552
  if (value.schemaVersion !== 1) {
@@ -563,6 +581,9 @@ function decodeBotUnreadViewV1(input: unknown): BotUnreadViewV1 {
563
581
  );
564
582
  const lastViewedAt = optionalTimestamp(value, "lastViewedAt", "unread view");
565
583
  const lastMessage = optionalSidebarMessagePreviewV1(value.lastMessage);
584
+ if (value.working !== undefined && typeof value.working !== "boolean") {
585
+ throw new UnreadDecodeError("unread view working is invalid");
586
+ }
566
587
  return {
567
588
  schemaVersion: 1,
568
589
  botId: value.botId,
@@ -574,6 +595,7 @@ function decodeBotUnreadViewV1(input: unknown): BotUnreadViewV1 {
574
595
  ...(lastActivityAt === undefined ? {} : { lastActivityAt }),
575
596
  ...(lastViewedAt === undefined ? {} : { lastViewedAt }),
576
597
  ...(lastMessage === undefined ? {} : { lastMessage }),
598
+ ...(value.working === true ? { working: true } : {}),
577
599
  };
578
600
  }
579
601
 
package/tsconfig.json CHANGED
@@ -4,9 +4,8 @@
4
4
  "module": "ESNext",
5
5
  "moduleResolution": "Bundler",
6
6
  "allowImportingTsExtensions": true,
7
- "baseUrl": ".",
8
7
  "paths": {
9
- "@cordisjs/client": ["src/client/cordis-client-shim.d.ts"]
8
+ "@cordisjs/client": ["./src/client/cordis-client-shim.d.ts"]
10
9
  },
11
10
  "strict": true,
12
11
  "noEmit": true,