@frockbot/plugin-shell 0.3.6 → 0.3.7

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@frockbot/plugin-shell",
3
- "version": "0.3.6",
3
+ "version": "0.3.7",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "exports": {
@@ -47,41 +47,41 @@
47
47
  },
48
48
  "dependencies": {
49
49
  "@cordisjs/plugin-webui": "0.8.2",
50
- "@frockbot/agent-runtime": "0.3.6",
51
- "@frockbot/application-foundation": "0.3.6",
52
- "@frockbot/catalog-core": "0.3.6",
53
- "@frockbot/client-core": "0.3.6",
54
- "@frockbot/client-ui": "0.3.6",
55
- "@frockbot/computer-core": "0.3.6",
56
- "@frockbot/computer-host-protocol": "0.3.6",
57
- "@frockbot/configuration-core": "0.3.6",
58
- "@frockbot/connection-core": "0.3.6",
59
- "@frockbot/kernel-agent-loop": "0.3.6",
60
- "@frockbot/kernel-composition": "0.3.6",
61
- "@frockbot/kernel-contracts": "0.3.6",
62
- "@frockbot/kernel-do": "0.3.6",
63
- "@frockbot/machine-protocol": "0.3.6",
64
- "@frockbot/plugin-applets": "0.3.6",
65
- "@frockbot/plugin-authoring": "0.3.6",
66
- "@frockbot/plugin-bot-template": "0.3.6",
67
- "@frockbot/plugin-computer": "0.3.6",
68
- "@frockbot/plugin-flock": "0.3.6",
69
- "@frockbot/plugin-image": "0.3.6",
70
- "@frockbot/plugin-machine-messages": "0.3.6",
71
- "@frockbot/plugin-mcp": "0.3.6",
72
- "@frockbot/plugin-memory": "0.3.6",
73
- "@frockbot/plugin-package-catalog": "0.3.6",
74
- "@frockbot/plugin-routines": "0.3.6",
75
- "@frockbot/plugin-skills": "0.3.6",
76
- "@frockbot/plugin-subagents": "0.3.6",
77
- "@frockbot/plugin-user-machine": "0.3.6",
78
- "@frockbot/protocol": "0.3.6",
50
+ "@frockbot/agent-runtime": "0.3.7",
51
+ "@frockbot/application-foundation": "0.3.7",
52
+ "@frockbot/catalog-core": "0.3.7",
53
+ "@frockbot/client-core": "0.3.7",
54
+ "@frockbot/client-ui": "0.3.7",
55
+ "@frockbot/computer-core": "0.3.7",
56
+ "@frockbot/computer-host-protocol": "0.3.7",
57
+ "@frockbot/configuration-core": "0.3.7",
58
+ "@frockbot/connection-core": "0.3.7",
59
+ "@frockbot/kernel-agent-loop": "0.3.7",
60
+ "@frockbot/kernel-composition": "0.3.7",
61
+ "@frockbot/kernel-contracts": "0.3.7",
62
+ "@frockbot/kernel-do": "0.3.7",
63
+ "@frockbot/machine-protocol": "0.3.7",
64
+ "@frockbot/plugin-applets": "0.3.7",
65
+ "@frockbot/plugin-authoring": "0.3.7",
66
+ "@frockbot/plugin-bot-template": "0.3.7",
67
+ "@frockbot/plugin-computer": "0.3.7",
68
+ "@frockbot/plugin-flock": "0.3.7",
69
+ "@frockbot/plugin-image": "0.3.7",
70
+ "@frockbot/plugin-machine-messages": "0.3.7",
71
+ "@frockbot/plugin-mcp": "0.3.7",
72
+ "@frockbot/plugin-memory": "0.3.7",
73
+ "@frockbot/plugin-package-catalog": "0.3.7",
74
+ "@frockbot/plugin-routines": "0.3.7",
75
+ "@frockbot/plugin-skills": "0.3.7",
76
+ "@frockbot/plugin-subagents": "0.3.7",
77
+ "@frockbot/plugin-user-machine": "0.3.7",
78
+ "@frockbot/protocol": "0.3.7",
79
79
  "cordis": "4.0.0-rc.8",
80
80
  "vue": "3.5.41"
81
81
  },
82
82
  "devDependencies": {
83
83
  "@cloudflare/workers-types": "latest",
84
- "@frockbot/plugin-tools": "0.3.6",
84
+ "@frockbot/plugin-tools": "0.3.7",
85
85
  "@types/bun": "1.3.6",
86
86
  "@types/node": "26.2.0",
87
87
  "@vitejs/plugin-vue": "6.0.8",
@@ -354,26 +354,20 @@ describe("Bot recovery", () => {
354
354
  text: "uncertain",
355
355
  }),
356
356
  ).rejects.toThrow("response lost");
357
- expect(
358
- await storage.get<StoredRun>("run:ollama-run-uncertain"),
359
- ).toMatchObject({ status: "reconciliation-required" });
357
+ // Ollama keeps no addressable copy of a completion, so a failure raised
358
+ // before the first stream event is definitive rather than uncertain: the
359
+ // run settles as a failed Turn instead of parking on a retrieval this
360
+ // provider can never perform.
361
+ const uncertain = await storage.get<StoredRun>("run:ollama-run-uncertain");
362
+ expect(uncertain?.status).toBe("failed");
363
+ expect(uncertain?.failure).toContain("response lost");
364
+ expect(await storage.get("active-run")).toBeUndefined();
360
365
 
366
+ // The alarm has nothing left to recover: a settled run stays settled.
361
367
  await host().alarm();
362
- expect(
363
- await storage.get<StoredRun>("run:ollama-run-uncertain"),
364
- ).toMatchObject({ status: "reconciliation-required" });
365
- await expect(
366
- host().reconcileRun(
367
- { userId: "user-1", botId: "primary" },
368
- "ollama-run-uncertain",
369
- ),
370
- ).rejects.toThrow();
371
- expect(
372
- await storage.get<StoredRun>("run:ollama-run-uncertain"),
373
- ).toMatchObject({
374
- status: "failed",
375
- failure: expect.stringContaining("explicitly abandoned"),
376
- });
368
+ const settled = await storage.get<StoredRun>("run:ollama-run-uncertain");
369
+ expect(settled?.status).toBe("failed");
370
+ expect(settled?.failure).toContain("response lost");
377
371
  expect(await storage.get("active-run")).toBeUndefined();
378
372
  });
379
373
 
package/src/backend.ts CHANGED
@@ -403,6 +403,7 @@ import {
403
403
  projectClientAnnouncementsV1,
404
404
  projectClientTurnV1,
405
405
  type ClientRunLookupV1,
406
+ type ClientConversationListV1,
406
407
  type ClientRunListV1,
407
408
  type ClientRunStopReceiptV1,
408
409
  type ClientRunV1,
@@ -5443,6 +5444,15 @@ export class ShellBotBackendContribution {
5443
5444
  ): Promise<ClientRunListV1> {
5444
5445
  const query = decodeClientRunListQueryV1(input);
5445
5446
  await this.authority.recoverActiveRun();
5447
+ // The transcript is one conversation, not every Turn the Bot has ever
5448
+ // run. Absent means the conversation the Bot is on; naming an earlier one
5449
+ // reads it exactly as it was left. A Bot whose object has not learned its
5450
+ // identity yet has no conversation to filter by and shows what it has.
5451
+ const conversationId =
5452
+ query.conversationId ??
5453
+ (await this.authority.readConversationSessionId());
5454
+ const inConversation = (run: { sessionId: string }) =>
5455
+ conversationId === undefined || run.sessionId === conversationId;
5446
5456
  const activeRunId = query.before
5447
5457
  ? undefined
5448
5458
  : await this.authority.readActiveRunId();
@@ -5457,7 +5467,7 @@ export class ShellBotBackendContribution {
5457
5467
  // An automation firing occupies the object like any other run, and is
5458
5468
  // still not part of the conversation: the visible transcript never
5459
5469
  // shows one, running or settled.
5460
- if (active && isVisibleRunV1(active))
5470
+ if (active && isVisibleRunV1(active) && inConversation(active))
5461
5471
  selected.set(active.runId, {
5462
5472
  run: projectClientRunOrDegradedV1(active),
5463
5473
  });
@@ -5471,7 +5481,8 @@ export class ShellBotBackendContribution {
5471
5481
  continue;
5472
5482
  }
5473
5483
  const stored = await this.authority.readStoredRun(candidate.runId);
5474
- if (!stored || !isVisibleRunV1(stored)) continue;
5484
+ if (!stored || !isVisibleRunV1(stored) || !inConversation(stored))
5485
+ continue;
5475
5486
  const projected = projectClientRunOrDegradedV1(stored);
5476
5487
  const tentative = [
5477
5488
  ...selected.values(),
@@ -5527,6 +5538,36 @@ export class ShellBotBackendContribution {
5527
5538
  }
5528
5539
  return page;
5529
5540
  }
5541
+ /** The conversations this Bot has had, newest first. */
5542
+ async listConversations(): Promise<ClientConversationListV1> {
5543
+ return {
5544
+ schemaVersion: 1,
5545
+ conversations: (await this.authority.listConversations()).map(
5546
+ (conversation) => ({
5547
+ schemaVersion: 1 as const,
5548
+ conversationId: conversation.sessionId,
5549
+ ordinal: conversation.ordinal,
5550
+ startedAt: conversation.startedAt,
5551
+ ...(conversation.endedAt ? { endedAt: conversation.endedAt } : {}),
5552
+ }),
5553
+ ),
5554
+ };
5555
+ }
5556
+
5557
+ /**
5558
+ * Puts this conversation down and starts the next one.
5559
+ *
5560
+ * Memory is untouched: it is not conversation history, and the point of a
5561
+ * new conversation is to prove that it is not.
5562
+ */
5563
+ async startConversation(
5564
+ identity: BotIdentity,
5565
+ ): Promise<ClientConversationListV1> {
5566
+ await this.validateIdentity(identity);
5567
+ await this.authority.startConversation(identity);
5568
+ return this.listConversations();
5569
+ }
5570
+
5530
5571
  async lookupRun(input: unknown): Promise<ClientRunLookupV1> {
5531
5572
  const query = decodeClientRunLookupQueryV1(input);
5532
5573
  return projectClientRunLookupV1(await this.authority.readRun(query.runId));
@@ -1189,6 +1189,19 @@ function handleComposerKeydown(event: KeyboardEvent): void {
1189
1189
  @blur="closeSkillPopover"
1190
1190
  />
1191
1191
  </div>
1192
+ <!--
1193
+ Start a new conversation. Sits beside the composer because that is
1194
+ where you are when you decide the last one is finished. Disabled
1195
+ while a Turn is running: the Bot is still writing to it.
1196
+ -->
1197
+ <UiIconButton
1198
+ icon="plus"
1199
+ label="New conversation"
1200
+ variant="ghost"
1201
+ class="new-conversation-button"
1202
+ :disabled="isRunning"
1203
+ @click="web.startConversation()"
1204
+ />
1192
1205
  <UiIconButton
1193
1206
  v-if="showStop"
1194
1207
  class="stop-button"
@@ -70,6 +70,8 @@ import { defineComponent, h, ref, toRaw, watch, type Ref } from "vue";
70
70
  import {
71
71
  frockBotWebDataKey,
72
72
  type FrockBotWebData,
73
+ decodeConnectionReturnV1,
74
+ withoutConnectionReturnV1,
73
75
  type PluginCatalogItem,
74
76
  type SendPromptResult,
75
77
  type WebActiveRun,
@@ -862,6 +864,15 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
862
864
  let admissionObserver: AbortController | undefined;
863
865
  let runObserver: AbortController | undefined;
864
866
  let selectionGeneration = 0;
867
+ /*
868
+ * Which conversation the transcript is showing.
869
+ *
870
+ * A read that was already in flight when the User starts a new conversation
871
+ * answers with the conversation that just ended, and projecting it puts the
872
+ * old Turns back on a transcript the User has just been told is empty. The
873
+ * epoch is bumped at the boundary so those answers are dropped.
874
+ */
875
+ let conversationGeneration = 0;
865
876
  let userSettingsGeneration = 0;
866
877
  let pluginCatalogGeneration = 0;
867
878
  let packageCatalogGeneration = 0;
@@ -1028,6 +1039,7 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
1028
1039
  if (!ctx.transport.lookupRun) return;
1029
1040
  let delayMs = 250;
1030
1041
  let observationError: string | undefined;
1042
+ const conversation = conversationGeneration;
1031
1043
  while (!signal.aborted) {
1032
1044
  try {
1033
1045
  const run = await observeWhileAttached(
@@ -1037,6 +1049,9 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
1037
1049
  if (
1038
1050
  signal.aborted ||
1039
1051
  generation !== selectionGeneration ||
1052
+ // The Turn belongs to the conversation it was sent in, so a new one
1053
+ // ends the observation rather than drawing it on an empty thread.
1054
+ conversation !== conversationGeneration ||
1040
1055
  web.value.activeBotId !== botId
1041
1056
  ) {
1042
1057
  return;
@@ -1064,15 +1079,18 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
1064
1079
  botId: string,
1065
1080
  generation = selectionGeneration,
1066
1081
  ): Promise<void> {
1082
+ const conversation = conversationGeneration;
1083
+ const current = () =>
1084
+ generation === selectionGeneration &&
1085
+ conversation === conversationGeneration &&
1086
+ web.value.activeBotId === botId;
1067
1087
  const runs = await (ctx.transport.listRuns?.(botId) ?? Promise.resolve([]));
1068
- if (generation !== selectionGeneration || web.value.activeBotId !== botId)
1069
- return;
1088
+ if (!current()) return;
1070
1089
  projectDurableRuns(web.value, [], runs);
1071
1090
  try {
1072
1091
  const announcements = await (ctx.transport.listAnnouncements?.(botId) ??
1073
1092
  Promise.resolve([]));
1074
- if (generation === selectionGeneration && web.value.activeBotId === botId)
1075
- projectAnnouncements(web.value.messages, announcements);
1093
+ if (current()) projectAnnouncements(web.value.messages, announcements);
1076
1094
  } catch {
1077
1095
  // Announcements are conversational history, never admission: a Session
1078
1096
  // that cannot read them still shows every Turn.
@@ -1081,17 +1099,14 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
1081
1099
  try {
1082
1100
  notifications = await (ctx.transport.listNotifications?.(botId) ??
1083
1101
  Promise.resolve([]));
1084
- if (generation !== selectionGeneration || web.value.activeBotId !== botId)
1085
- return;
1102
+ if (!current()) return;
1086
1103
  } catch (error) {
1087
- if (generation !== selectionGeneration || web.value.activeBotId !== botId)
1088
- return;
1104
+ if (!current()) return;
1089
1105
  web.value.settingsError =
1090
1106
  error instanceof Error ? error.message : "Could not load notifications";
1091
1107
  return;
1092
1108
  }
1093
- if (generation !== selectionGeneration || web.value.activeBotId !== botId)
1094
- return;
1109
+ if (!current()) return;
1095
1110
  const projected = projectDurableRuns(web.value, notifications, runs);
1096
1111
  // A decision may have been recorded on another device since the last poll,
1097
1112
  // and an expiry is recorded by an alarm nobody clicked.
@@ -1099,12 +1114,10 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
1099
1114
  // A background subagent settles after its Turn is over, so the chips in
1100
1115
  // the transcript learn what became of it here and not from the run.
1101
1116
  await web.value.loadTasks();
1102
- if (generation !== selectionGeneration || web.value.activeBotId !== botId)
1103
- return;
1117
+ if (!current()) return;
1104
1118
  if (!ctx.transport.acknowledgeNotification) return;
1105
1119
  for (const notification of notifications) {
1106
- if (generation !== selectionGeneration || web.value.activeBotId !== botId)
1107
- return;
1120
+ if (!current()) return;
1108
1121
  if (!projected.has(notification.notificationId)) {
1109
1122
  web.value.settingsError = "A completed Bot result is waiting to load";
1110
1123
  continue;
@@ -1237,8 +1250,24 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
1237
1250
  ): Promise<void>;
1238
1251
  };
1239
1252
 
1253
+ // Read once, from the URL the authorization redirect landed on, and then
1254
+ // stripped so a reload does not report the same return again.
1255
+ const connectionReturn =
1256
+ typeof window === "undefined"
1257
+ ? undefined
1258
+ : decodeConnectionReturnV1(window.location.search);
1259
+ if (connectionReturn && typeof window !== "undefined") {
1260
+ const rest = withoutConnectionReturnV1(window.location.search);
1261
+ window.history?.replaceState?.(
1262
+ window.history.state,
1263
+ "",
1264
+ `${window.location.pathname}${rest}${window.location.hash}`,
1265
+ );
1266
+ }
1267
+
1240
1268
  const web: Ref<ShellWebData> = ref({
1241
1269
  connection: "ready",
1270
+ ...(connectionReturn ? { connectionReturn } : {}),
1242
1271
  modelLabel: "No model available — set one up in Models",
1243
1272
  modelReady: false,
1244
1273
  modelSource: "none",
@@ -1304,6 +1333,41 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
1304
1333
  }
1305
1334
  await web.value.loadBotSettings();
1306
1335
  },
1336
+ /**
1337
+ * Puts this conversation down and starts the next one.
1338
+ *
1339
+ * What the Bot knows about you is Memory and stays; what it carries into
1340
+ * the next model request is the new conversation and nothing else. The
1341
+ * transcript clears because it is showing the conversation, and the one
1342
+ * just ended is still durable behind it.
1343
+ */
1344
+ async startConversation(): Promise<void> {
1345
+ const start = ctx.transport.startConversation;
1346
+ const botId = web.value.activeBotId;
1347
+ if (!start || !botId) return;
1348
+ const generation = selectionGeneration;
1349
+ try {
1350
+ await start(botId);
1351
+ } catch (error) {
1352
+ web.value.settingsError =
1353
+ error instanceof Error
1354
+ ? error.message
1355
+ : "Could not start a new conversation";
1356
+ return;
1357
+ }
1358
+ if (generation !== selectionGeneration || web.value.activeBotId !== botId)
1359
+ return;
1360
+ // Reads already in flight answer with the conversation that just ended;
1361
+ // the epoch drops them instead of letting them redraw it.
1362
+ conversationGeneration += 1;
1363
+ runObserver?.abort();
1364
+ runObserver = undefined;
1365
+ web.value.messages = [];
1366
+ web.value.activeRun = undefined;
1367
+ web.value.activeRunId = undefined;
1368
+ web.value.runningRunId = undefined;
1369
+ web.value.settingsError = undefined;
1370
+ },
1307
1371
  async loadSkillCatalog(): Promise<void> {
1308
1372
  // A missing transport method or an unreadable catalog is an empty
1309
1373
  // popover, never a visible error: a Skill list the User did not ask for
@@ -0,0 +1,55 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ decodeConnectionReturnV1,
4
+ withoutConnectionReturnV1,
5
+ } from "./shared.js";
6
+
7
+ describe("authorization return parameter", () => {
8
+ test("reads the status the callback redirected with", () => {
9
+ expect(decodeConnectionReturnV1("?connection=composio-ready")).toEqual({
10
+ packageId: "composio",
11
+ status: "ready",
12
+ });
13
+ expect(decodeConnectionReturnV1("?connection=composio-pending")).toEqual({
14
+ packageId: "composio",
15
+ status: "pending",
16
+ });
17
+ });
18
+
19
+ test("carries the reason a failed grant came back with", () => {
20
+ expect(
21
+ decodeConnectionReturnV1(
22
+ "?connection=composio-failed&connection_reason=state%20has%20expired",
23
+ ),
24
+ ).toEqual({
25
+ packageId: "composio",
26
+ status: "failed",
27
+ reason: "state has expired",
28
+ });
29
+ });
30
+
31
+ test("ignores a query string that carries no return", () => {
32
+ expect(decodeConnectionReturnV1("")).toBeUndefined();
33
+ expect(decodeConnectionReturnV1("?as_user=someone")).toBeUndefined();
34
+ });
35
+
36
+ test("refuses a malformed or unknown return", () => {
37
+ expect(decodeConnectionReturnV1("?connection=composio")).toBeUndefined();
38
+ expect(
39
+ decodeConnectionReturnV1("?connection=composio-elsewhere"),
40
+ ).toBeUndefined();
41
+ expect(decodeConnectionReturnV1("?connection=-ready")).toBeUndefined();
42
+ expect(
43
+ decodeConnectionReturnV1("?connection=Not%20A%20Package-ready"),
44
+ ).toBeUndefined();
45
+ });
46
+
47
+ test("strips the return parameters and keeps the rest", () => {
48
+ expect(
49
+ withoutConnectionReturnV1(
50
+ "?as_user=someone&connection=composio-failed&connection_reason=nope",
51
+ ),
52
+ ).toBe("?as_user=someone");
53
+ expect(withoutConnectionReturnV1("?connection=composio-ready")).toBe("");
54
+ });
55
+ });
@@ -78,6 +78,58 @@ function scoped(events: SessionEvent[]): LlmMessage[] {
78
78
  });
79
79
  }
80
80
 
81
+ function scopedWithBudget(events: SessionEvent[], budget: number) {
82
+ return turnScopedMessagesV1({
83
+ events,
84
+ messages: derive(events),
85
+ pointer: automationParentPointerV1,
86
+ sessionId: "bot:scout",
87
+ budget,
88
+ });
89
+ }
90
+
91
+ describe("one request carries a bounded amount of history", () => {
92
+ test("keeps the current Turn whole and drops the oldest, with a notice", () => {
93
+ const events = log([
94
+ ...turn(1, "chat", "the oldest thing", "first reply"),
95
+ ...turn(2, "chat", "the middle thing", "second reply"),
96
+ ...turn(3, "chat", "the newest thing", ""),
97
+ ]);
98
+ // Room for the current Turn and one older one, not for all three.
99
+ const messages = scopedWithBudget(events, 200);
100
+
101
+ const contents = messages.map((message) => message.content);
102
+ expect(contents).toContain("the newest thing");
103
+ expect(contents.join(" ")).not.toContain("the oldest thing");
104
+ expect(contents[0]).toContain("not included here");
105
+ expect(contents[0]).toContain("1 Turn");
106
+ });
107
+
108
+ test("carries everything when it fits, and says nothing about omission", () => {
109
+ const events = log([
110
+ ...turn(1, "chat", "morning", "hello"),
111
+ ...turn(2, "chat", "anything new?", ""),
112
+ ]);
113
+
114
+ expect(
115
+ scopedWithBudget(events, 100_000).map((message) => message.content),
116
+ ).toEqual(["morning", "hello", "anything new?", ""]);
117
+ });
118
+
119
+ test("keeps the current Turn even when it alone exceeds the budget", () => {
120
+ const events = log([
121
+ ...turn(1, "chat", "old", "older"),
122
+ ...turn(2, "chat", "x".repeat(500), ""),
123
+ ]);
124
+ const messages = scopedWithBudget(events, 50);
125
+
126
+ // A Turn is never split: dropping the user message and keeping the reply
127
+ // would be a malformed request, so the current Turn survives whole.
128
+ expect(messages.at(-2)!.content).toBe("x".repeat(500));
129
+ expect(messages[0]!.content).toContain("not included here");
130
+ });
131
+ });
132
+
81
133
  describe("turn-scoped prompt history", () => {
82
134
  test("a chat Turn sees only the Turns admitted as chat", () => {
83
135
  const events = log([
package/src/history.ts CHANGED
@@ -68,6 +68,76 @@ export interface TurnScopedMessagesInputV1 {
68
68
  /** The parent-transcript pointer, used only on a non-chat Turn. */
69
69
  pointer(input: { sessionId: string; chatTurns: number }): string;
70
70
  sessionId: string;
71
+ /**
72
+ * How many characters of history one request may carry. The current Turn is
73
+ * always whole; older Turns fill what is left. Absent means the default.
74
+ */
75
+ budget?: number;
76
+ }
77
+
78
+ /**
79
+ * How much conversation one model request carries.
80
+ *
81
+ * A number in characters, not tokens: this is a Package policy bound whose job
82
+ * is to stop a request growing without limit, and it does not need to agree
83
+ * with any provider's tokenizer to do that. Roughly 150k characters is well
84
+ * inside every model FrockBot resolves today while being far more history than
85
+ * any conversation needs.
86
+ */
87
+ export const CHAT_HISTORY_BUDGET_CHARS_V1 = 150_000;
88
+
89
+ /**
90
+ * The line that stands where the dropped Turns were.
91
+ *
92
+ * It is said plainly, because a model that cannot see the beginning of a
93
+ * conversation and is not told so will confidently answer as though it had.
94
+ */
95
+ export function omittedHistoryNoticeV1(turns: number): string {
96
+ return `Earlier in this conversation there ${turns === 1 ? "was 1 Turn" : `were ${turns} Turns`} that are not included here. They are not summarised: if you need something from them, say so or search your memory rather than guessing.`;
97
+ }
98
+
99
+ function messageChars(message: LlmMessage): number {
100
+ return JSON.stringify(message).length;
101
+ }
102
+
103
+ /**
104
+ * Narrows history to a character budget, oldest Turns first.
105
+ *
106
+ * Eviction is by whole Turn on purpose. A tool result whose call has been
107
+ * dropped is a malformed request to every provider, and a Turn is the
108
+ * smallest unit that always holds both.
109
+ */
110
+ function budgetedMessagesV1(
111
+ messages: readonly LlmMessage[],
112
+ turns: readonly number[],
113
+ current: number,
114
+ budget: number,
115
+ ): LlmMessage[] {
116
+ const total = messages.reduce(
117
+ (sum, message) => sum + messageChars(message),
118
+ 0,
119
+ );
120
+ if (total <= budget) return [...messages];
121
+ const spendByTurn = new Map<number, number>();
122
+ for (const [index, message] of messages.entries()) {
123
+ const turn = turns[index]!;
124
+ spendByTurn.set(turn, (spendByTurn.get(turn) ?? 0) + messageChars(message));
125
+ }
126
+ const ordered = [...spendByTurn.keys()].sort((left, right) => right - left);
127
+ const kept = new Set<number>([current]);
128
+ let spent = spendByTurn.get(current) ?? 0;
129
+ for (const turn of ordered) {
130
+ if (turn === current) continue;
131
+ const cost = spendByTurn.get(turn) ?? 0;
132
+ if (spent + cost > budget) break;
133
+ kept.add(turn);
134
+ spent += cost;
135
+ }
136
+ const dropped = ordered.filter((turn) => !kept.has(turn)).length;
137
+ const narrowed = messages.filter((_, index) => kept.has(turns[index]!));
138
+ return dropped === 0
139
+ ? narrowed
140
+ : [{ role: "user", content: omittedHistoryNoticeV1(dropped) }, ...narrowed];
71
141
  }
72
142
 
73
143
  /**
@@ -92,7 +162,15 @@ export function turnScopedMessagesV1(
92
162
  const current = currentTurnV1(input.events);
93
163
  const chatTurn = (turn: number) => (types.get(turn) ?? "chat") === "chat";
94
164
  if (chatTurn(current)) {
95
- return input.messages.filter((_, index) => chatTurn(turns[index]!));
165
+ const conversation = input.messages.filter((_, index) =>
166
+ chatTurn(turns[index]!),
167
+ );
168
+ return budgetedMessagesV1(
169
+ conversation,
170
+ turns.filter((turn) => chatTurn(turn)),
171
+ current,
172
+ input.budget ?? CHAT_HISTORY_BUDGET_CHARS_V1,
173
+ );
96
174
  }
97
175
  const own = input.messages.filter((_, index) => turns[index] === current);
98
176
  const chatTurns = new Set(
@@ -36,6 +36,8 @@ const MAX_NOTIFICATION_TITLE_BYTES = 512;
36
36
  const MAX_NOTIFICATION_BODY_BYTES = 2_000;
37
37
  const MAX_CLIENT_TURN_BYTES = 256_000;
38
38
  const MAX_CURSOR_LENGTH = 320;
39
+ /** A conversation is named by its Session id, which the kernel bounds. */
40
+ const MAX_SESSION_ID_LENGTH = 320;
39
41
  const MAX_TASK_DESCRIPTION_BYTES = 800;
40
42
  const MAX_TASK_MODEL_BYTES = 512;
41
43
  export const CLIENT_RUN_PAGE_LIMIT = 32;
@@ -251,9 +253,85 @@ export interface ClientRunListV1 {
251
253
  announcements?: ClientAnnouncementV1[];
252
254
  }
253
255
 
256
+ /**
257
+ * One conversation a Bot has had.
258
+ *
259
+ * A Bot holds one conversation at a time and keeps the ones before it: the
260
+ * transcript shows the current one, and an earlier one is still readable.
261
+ */
262
+ export interface ClientConversationV1 {
263
+ schemaVersion: 1;
264
+ /** The Session id this conversation's Turns recorded. */
265
+ conversationId: string;
266
+ ordinal: number;
267
+ startedAt: string;
268
+ /** Absent while this is the conversation the Bot is on. */
269
+ endedAt?: string;
270
+ }
271
+
272
+ export interface ClientConversationListV1 {
273
+ schemaVersion: 1;
274
+ /** Newest first; the first entry is the conversation the Bot is on. */
275
+ conversations: ClientConversationV1[];
276
+ }
277
+
278
+ export function decodeClientConversationListV1(
279
+ input: unknown,
280
+ ): ClientConversationListV1 {
281
+ const list = record(input, "conversation list");
282
+ exactKeys(list, ["schemaVersion", "conversations"], "conversation list");
283
+ if (list.schemaVersion !== 1) {
284
+ throw new Error("conversation list.schemaVersion is invalid");
285
+ }
286
+ if (!Array.isArray(list.conversations)) {
287
+ throw new Error("conversation list.conversations is invalid");
288
+ }
289
+ return {
290
+ schemaVersion: 1,
291
+ conversations: list.conversations.map((entry) => {
292
+ const conversation = record(entry, "conversation");
293
+ exactKeys(
294
+ conversation,
295
+ ["schemaVersion", "conversationId", "ordinal", "startedAt", "endedAt"],
296
+ "conversation",
297
+ );
298
+ if (conversation.schemaVersion !== 1) {
299
+ throw new Error("conversation.schemaVersion is invalid");
300
+ }
301
+ if (
302
+ typeof conversation.ordinal !== "number" ||
303
+ !Number.isSafeInteger(conversation.ordinal) ||
304
+ conversation.ordinal < 1
305
+ ) {
306
+ throw new Error("conversation.ordinal is invalid");
307
+ }
308
+ return {
309
+ schemaVersion: 1 as const,
310
+ conversationId: string(
311
+ conversation,
312
+ "conversationId",
313
+ MAX_SESSION_ID_LENGTH,
314
+ "conversation",
315
+ ),
316
+ ordinal: conversation.ordinal,
317
+ startedAt: string(conversation, "startedAt", 64, "conversation"),
318
+ ...(conversation.endedAt === undefined
319
+ ? {}
320
+ : { endedAt: string(conversation, "endedAt", 64, "conversation") }),
321
+ };
322
+ }),
323
+ };
324
+ }
325
+
254
326
  export interface ClientRunListQueryV1 {
255
327
  schemaVersion: 1;
256
328
  before?: string;
329
+ /**
330
+ * The conversation to read. Absent means the one the Bot is on now, which
331
+ * is what the transcript shows; an earlier conversation is named by the
332
+ * Session id `listConversations` gave for it.
333
+ */
334
+ conversationId?: string;
257
335
  }
258
336
 
259
337
  export interface ClientTurnCommandV1 {
@@ -1385,7 +1463,11 @@ export function decodeClientRunListQueryV1(
1385
1463
  input: unknown,
1386
1464
  ): ClientRunListQueryV1 {
1387
1465
  const query = record(input, "run list query");
1388
- exactKeys(query, ["schemaVersion", "before"], "run list query");
1466
+ exactKeys(
1467
+ query,
1468
+ ["schemaVersion", "before", "conversationId"],
1469
+ "run list query",
1470
+ );
1389
1471
  if (query.schemaVersion !== 1) {
1390
1472
  throw new Error("run list query.schemaVersion is invalid");
1391
1473
  }
@@ -1400,7 +1482,20 @@ export function decodeClientRunListQueryV1(
1400
1482
  throw new Error("run list query.before is invalid");
1401
1483
  }
1402
1484
  }
1403
- return { schemaVersion: 1, ...(before ? { before } : {}) };
1485
+ const conversationId =
1486
+ query.conversationId === undefined
1487
+ ? undefined
1488
+ : string(
1489
+ query,
1490
+ "conversationId",
1491
+ MAX_SESSION_ID_LENGTH,
1492
+ "run list query",
1493
+ );
1494
+ return {
1495
+ schemaVersion: 1,
1496
+ ...(before ? { before } : {}),
1497
+ ...(conversationId ? { conversationId } : {}),
1498
+ };
1404
1499
  }
1405
1500
 
1406
1501
  export function decodeClientTurnCommandV1(input: unknown): ClientTurnCommandV1 {
package/src/shared.ts CHANGED
@@ -161,6 +161,60 @@ export interface PluginCatalogItem {
161
161
  settings?: PackageSettingDefinition[];
162
162
  }
163
163
 
164
+ /** What an external authorization redirect told the app on the way back. */
165
+ export interface ConnectionReturnV1 {
166
+ /** The Package that owns the Connection, e.g. `composio`. */
167
+ packageId: string;
168
+ status: "ready" | "pending" | "failed";
169
+ /** A provider- or callback-supplied explanation, when there is one. */
170
+ reason?: string;
171
+ }
172
+
173
+ const CONNECTION_RETURN_PARAM = "connection";
174
+ const CONNECTION_RETURN_REASON_PARAM = "connection_reason";
175
+ const MAX_CONNECTION_RETURN_REASON = 300;
176
+
177
+ /**
178
+ * Read an authorization return out of a URL query string.
179
+ *
180
+ * The callback redirects to `/?connection=<packageId>-<status>`. Left unread it
181
+ * is a stale query string and nothing else: the User is returned to the app
182
+ * with no confirmation, and a `failed` grant vanishes entirely.
183
+ */
184
+ export function decodeConnectionReturnV1(
185
+ search: string,
186
+ ): ConnectionReturnV1 | undefined {
187
+ let params: URLSearchParams;
188
+ try {
189
+ params = new URLSearchParams(search);
190
+ } catch {
191
+ return undefined;
192
+ }
193
+ const raw = params.get(CONNECTION_RETURN_PARAM);
194
+ if (!raw) return undefined;
195
+ const separator = raw.lastIndexOf("-");
196
+ if (separator <= 0) return undefined;
197
+ const packageId = raw.slice(0, separator);
198
+ const status = raw.slice(separator + 1);
199
+ if (status !== "ready" && status !== "pending" && status !== "failed") {
200
+ return undefined;
201
+ }
202
+ if (!/^[a-z0-9][a-z0-9-]{0,63}$/.test(packageId)) return undefined;
203
+ const reason = params
204
+ .get(CONNECTION_RETURN_REASON_PARAM)
205
+ ?.slice(0, MAX_CONNECTION_RETURN_REASON);
206
+ return { packageId, status, ...(reason ? { reason } : {}) };
207
+ }
208
+
209
+ /** The same query string with the return parameters removed. */
210
+ export function withoutConnectionReturnV1(search: string): string {
211
+ const params = new URLSearchParams(search);
212
+ params.delete(CONNECTION_RETURN_PARAM);
213
+ params.delete(CONNECTION_RETURN_REASON_PARAM);
214
+ const rest = params.toString();
215
+ return rest ? `?${rest}` : "";
216
+ }
217
+
164
218
  export interface FrockBotWebData {
165
219
  connection: WebConnection;
166
220
  modelLabel: string;
@@ -261,6 +315,12 @@ export interface FrockBotWebData {
261
315
  */
262
316
  mcpServers?: McpServerStatusViewV1;
263
317
  settingsError?: string;
318
+ /**
319
+ * What the browser came back from an external authorization with. Read once
320
+ * from the return URL at boot and cleared when the User has seen it, so a
321
+ * cancelled or failed grant is reported rather than silently discarded.
322
+ */
323
+ connectionReturn?: ConnectionReturnV1;
264
324
  selectBot(botId: string): Promise<void>;
265
325
  loadBotSettings(): Promise<void>;
266
326
  saveBotProfile(profile: BotProfile): Promise<void>;
@@ -365,6 +425,11 @@ export interface FrockBotWebData {
365
425
  values?: Record<string, JsonValue>,
366
426
  ): Promise<void>;
367
427
  uninstallPackage(packageId: string): Promise<void>;
428
+ /**
429
+ * Puts this conversation down and starts the next one. Memory is kept; only
430
+ * the history the next Turn carries is new (ADR 0027).
431
+ */
432
+ startConversation(): Promise<void>;
368
433
  startConnection(
369
434
  packageId: string,
370
435
  connectionTypeId: string,