@frockbot/plugin-shell 0.3.5 → 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.
@@ -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;
@@ -179,7 +181,12 @@ export interface ClientDynamicToolCallInputV1 {
179
181
 
180
182
  export type ClientRunOutcomeV1 =
181
183
  | { type: "completed"; text: string }
182
- | { type: "failed"; message: string }
184
+ /**
185
+ * A Turn that broke keeps what it had already said, for the same reason a
186
+ * stopped one does: the words arrived, the person read them, and replacing
187
+ * them with a notice would rewrite what they watched happen (ADR 0028).
188
+ */
189
+ | { type: "failed"; message: string; text?: string }
183
190
  /**
184
191
  * A Turn a Stop or a later message ended keeps what it had already said:
185
192
  * `text` is that partial answer, and `message` is the line saying why it
@@ -246,9 +253,85 @@ export interface ClientRunListV1 {
246
253
  announcements?: ClientAnnouncementV1[];
247
254
  }
248
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
+
249
326
  export interface ClientRunListQueryV1 {
250
327
  schemaVersion: 1;
251
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;
252
335
  }
253
336
 
254
337
  export interface ClientTurnCommandV1 {
@@ -673,6 +756,7 @@ export function projectClientRunV1(run: StoredRun): ClientRunV1 {
673
756
  run.failure ?? "Agent request failed.",
674
757
  MAX_FAILURE_BYTES,
675
758
  ),
759
+ ...interruptedOutcomeTextV1(run),
676
760
  } satisfies ClientRunOutcomeV1)
677
761
  : status === "cancelled"
678
762
  ? ({
@@ -1121,10 +1205,11 @@ function decodeOutcome(
1121
1205
  };
1122
1206
  }
1123
1207
  if (outcome.type === "failed" && runStatus === "failed") {
1124
- exactKeys(outcome, ["type", "message"], "run.outcome");
1208
+ exactKeys(outcome, ["type", "message", "text"], "run.outcome");
1125
1209
  return {
1126
1210
  type: "failed",
1127
1211
  message: wireString(outcome, "message", MAX_FAILURE_BYTES, "run.outcome"),
1212
+ ...decodeInterruptedTextV1(outcome),
1128
1213
  };
1129
1214
  }
1130
1215
  if (outcome.type === "cancelled" && runStatus === "cancelled") {
@@ -1249,7 +1334,12 @@ function decodeRun(value: unknown): ClientRun {
1249
1334
  ...(stopRequestedAt ? { stopRequestedAt } : {}),
1250
1335
  ...(run.queued === true ? { queued: true as const } : {}),
1251
1336
  ...(outcome?.type === "completed" ? { responseText: outcome.text } : {}),
1252
- ...(outcome?.type === "failed" ? { failure: outcome.message } : {}),
1337
+ ...(outcome?.type === "failed"
1338
+ ? {
1339
+ failure: outcome.message,
1340
+ ...(outcome.text ? { responseText: outcome.text } : {}),
1341
+ }
1342
+ : {}),
1253
1343
  ...(outcome?.type === "cancelled" || outcome?.type === "superseded"
1254
1344
  ? {
1255
1345
  failure: outcome.message,
@@ -1373,7 +1463,11 @@ export function decodeClientRunListQueryV1(
1373
1463
  input: unknown,
1374
1464
  ): ClientRunListQueryV1 {
1375
1465
  const query = record(input, "run list query");
1376
- exactKeys(query, ["schemaVersion", "before"], "run list query");
1466
+ exactKeys(
1467
+ query,
1468
+ ["schemaVersion", "before", "conversationId"],
1469
+ "run list query",
1470
+ );
1377
1471
  if (query.schemaVersion !== 1) {
1378
1472
  throw new Error("run list query.schemaVersion is invalid");
1379
1473
  }
@@ -1388,7 +1482,20 @@ export function decodeClientRunListQueryV1(
1388
1482
  throw new Error("run list query.before is invalid");
1389
1483
  }
1390
1484
  }
1391
- 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
+ };
1392
1499
  }
1393
1500
 
1394
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,