@frockbot/plugin-shell 0.3.11 → 0.3.13

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 (42) hide show
  1. package/package.json +35 -33
  2. package/src/agent.test.ts +78 -0
  3. package/src/agent.ts +130 -2
  4. package/src/backend-configuration.test.ts +26 -26
  5. package/src/backend-recovery-integration.test.ts +10 -10
  6. package/src/backend-runner.ts +19 -2
  7. package/src/backend.ts +85 -18
  8. package/src/client/AppletCanvas.vue +19 -6
  9. package/src/client/FrockBotApp.vue +405 -75
  10. package/src/client/activity-trail.test.ts +205 -0
  11. package/src/client/activity-trail.ts +227 -0
  12. package/src/client/applets-client.test.ts +62 -0
  13. package/src/client/applets-client.ts +19 -0
  14. package/src/client/index.test.ts +128 -21
  15. package/src/client/index.ts +359 -114
  16. package/src/client/model-presentation.test.ts +3 -3
  17. package/src/client/no-bot-model-label.test.ts +7 -7
  18. package/src/client/skill-invocation.test.ts +34 -0
  19. package/src/client/skill-invocation.ts +22 -0
  20. package/src/client/styles.css +69 -19
  21. package/src/client/transcript-cache.test.ts +125 -0
  22. package/src/client/transcript-cache.ts +190 -0
  23. package/src/compaction-scheduler.test.ts +96 -0
  24. package/src/compaction-scheduler.ts +108 -0
  25. package/src/compaction-transcript.test.ts +174 -0
  26. package/src/compaction.test.ts +596 -0
  27. package/src/compaction.ts +539 -0
  28. package/src/focus.test.ts +222 -0
  29. package/src/focus.ts +93 -0
  30. package/src/history.ts +86 -8
  31. package/src/legacy-frock-model-id.test.ts +148 -0
  32. package/src/notification-id.ts +0 -0
  33. package/src/run-failure-copy.test.ts +150 -0
  34. package/src/run-failure-copy.ts +110 -0
  35. package/src/run-protocol.test.ts +50 -7
  36. package/src/run-protocol.ts +152 -43
  37. package/src/settings-links.test.ts +8 -2
  38. package/src/settings-links.ts +11 -2
  39. package/src/shared.ts +36 -0
  40. package/tsconfig.json +1 -2
  41. package/src/client/activity-ring.test.ts +0 -89
  42. package/src/client/activity-ring.ts +0 -94
@@ -42,11 +42,11 @@ describe("model runtime presentation", () => {
42
42
  modelRuntimeLabel({
43
43
  source: "platform",
44
44
  modelDisplayName: "Auto",
45
- providerModelId: "@flock/auto",
46
- packageDisplayName: "Flock AI",
45
+ providerModelId: "@frock/auto",
46
+ packageDisplayName: "Frock AI",
47
47
  fallback: true,
48
48
  }),
49
- ).toBe("Auto · Flock AI · your chosen model is unavailable");
49
+ ).toBe("Auto · Frock AI · your chosen model is unavailable");
50
50
  });
51
51
 
52
52
  test("shows unavailable and backend failure states", () => {
@@ -20,7 +20,7 @@ const { shellClientPlugin } = await import("./index.js");
20
20
 
21
21
  /**
22
22
  * A first-run account: the platform model resolves against a ready ambient
23
- * Flock AI Connection whose Catalog is fresh, and no Bot has been created yet.
23
+ * Frock AI Connection whose Catalog is fresh, and no Bot has been created yet.
24
24
  * The account's model is available, so the shell must not tell the User it is
25
25
  * unavailable before they have made their first Bot.
26
26
  */
@@ -37,7 +37,7 @@ test("does not report the account model unavailable before a Bot exists", async
37
37
  connectionId: "flock-ai-ambient",
38
38
  packageId: "provider-flock-ai",
39
39
  connectionTypeId: "flock-ai-account",
40
- displayName: "Flock AI",
40
+ displayName: "Frock AI",
41
41
  state: "ready",
42
42
  providerType: "flock-ai",
43
43
  safeMetadata: {},
@@ -47,7 +47,7 @@ test("does not report the account model unavailable before a Bot exists", async
47
47
  state: "fresh",
48
48
  models: [
49
49
  {
50
- providerModelId: "@flock/auto",
50
+ providerModelId: "@frock/auto",
51
51
  displayName: "Auto (recommended)",
52
52
  capabilities: { tools: true, vision: false, reasoning: true },
53
53
  source: "discovered",
@@ -58,7 +58,7 @@ test("does not report the account model unavailable before a Bot exists", async
58
58
  ],
59
59
  platformModel: {
60
60
  connectionId: "flock-ai-ambient",
61
- providerModelId: "@flock/auto",
61
+ providerModelId: "@frock/auto",
62
62
  },
63
63
  };
64
64
 
@@ -74,7 +74,7 @@ test("does not report the account model unavailable before a Bot exists", async
74
74
  packages: [
75
75
  {
76
76
  id: "provider-flock-ai",
77
- displayName: "Flock AI",
77
+ displayName: "Frock AI",
78
78
  version: "0.0.1",
79
79
  contributions: ["backend", "runtime"],
80
80
  configuration: {
@@ -82,7 +82,7 @@ test("does not report the account model unavailable before a Bot exists", async
82
82
  connectionTypes: [
83
83
  {
84
84
  id: "flock-ai-account",
85
- displayName: "Flock AI",
85
+ displayName: "Frock AI",
86
86
  allowMultiple: false,
87
87
  authorization: { kind: "ambient-native" },
88
88
  capabilities: ["flock-ai-models"],
@@ -117,6 +117,6 @@ test("does not report the account model unavailable before a Bot exists", async
117
117
 
118
118
  // No Bot has been created, so `activeBotId` is unset.
119
119
  expect(provided.value.activeBotId).toBeUndefined();
120
- expect(provided.value.modelLabel).toBe("Auto (recommended) · Flock AI");
120
+ expect(provided.value.modelLabel).toBe("Auto (recommended) · Frock AI");
121
121
  expect(provided.value.modelReady).toBe(true);
122
122
  });
@@ -1,6 +1,7 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
  import type { ClientSkillCatalogEntryV1 } from "../skill-protocol.js";
3
3
  import {
4
+ keptSkillHighlightV1,
4
5
  nextSkillHighlightV1,
5
6
  rankSkillCandidatesV1,
6
7
  SkillAttachmentStore,
@@ -140,4 +141,37 @@ describe("keyboard navigation", () => {
140
141
  expect(nextSkillHighlightV1(0, 3, -1)).toBe(2);
141
142
  expect(nextSkillHighlightV1(0, 0, 1)).toBe(0);
142
143
  });
144
+
145
+ test("a refilter keeps the highlight on the Skill it was on", () => {
146
+ const ranked = rankSkillCandidatesV1(catalog, "standup");
147
+ // The composer refreshes the popover on every keyup, the arrow key's own
148
+ // keyup included. Row two has to survive that refresh, or the highlight
149
+ // snaps back to the first row and the arrow keys look dead.
150
+ const highlighted = ranked[1]!.entry.ref;
151
+ expect(keptSkillHighlightV1(highlighted, ranked)).toBe(1);
152
+ });
153
+
154
+ test("a narrower query keeps the highlight when the Skill is still offered", () => {
155
+ const before = rankSkillCandidatesV1(catalog, "s");
156
+ const highlighted = before.find(
157
+ (candidate) => candidate.entry.ref === "bot/standup-notes",
158
+ )!.entry.ref;
159
+ const after = rankSkillCandidatesV1(catalog, "standup-n");
160
+ expect(after.map((candidate) => candidate.entry.ref)).toContain(
161
+ highlighted,
162
+ );
163
+ expect(keptSkillHighlightV1(highlighted, after)).toBe(
164
+ after.findIndex((candidate) => candidate.entry.ref === highlighted),
165
+ );
166
+ });
167
+
168
+ test("falls back to the first row once the highlighted Skill is gone", () => {
169
+ const ranked = rankSkillCandidatesV1(catalog, "standup");
170
+ expect(keptSkillHighlightV1("bot/weekly-report", ranked)).toBe(0);
171
+ expect(keptSkillHighlightV1(undefined, ranked)).toBe(0);
172
+ });
173
+
174
+ test("an empty list has no highlight to keep", () => {
175
+ expect(keptSkillHighlightV1("bot/daily-standup", [])).toBe(0);
176
+ });
143
177
  });
@@ -164,6 +164,28 @@ export class SkillAttachmentStore {
164
164
  }
165
165
  }
166
166
 
167
+ /**
168
+ * The highlight to keep once the candidate list has been recomputed.
169
+ *
170
+ * The popover is refreshed from the composer's own `keyup` — including the
171
+ * `keyup` of the arrow key that has just moved the highlight — so a refresh
172
+ * that reset the highlight to the first row made the arrow keys look like they
173
+ * did nothing at all. The highlight is carried by ref rather than by index: the
174
+ * Skill under it keeps its place for as long as the query still offers it, and
175
+ * only a Skill that has dropped out of the list hands the highlight back to the
176
+ * first row.
177
+ */
178
+ export function keptSkillHighlightV1(
179
+ highlightedRef: string | undefined,
180
+ candidates: readonly SkillCandidateV1[],
181
+ ): number {
182
+ if (candidates.length === 0) return 0;
183
+ const index = candidates.findIndex(
184
+ (candidate) => candidate.entry.ref === highlightedRef,
185
+ );
186
+ return index === -1 ? 0 : index;
187
+ }
188
+
167
189
  /** Moves the popover's highlight, wrapping at both ends. */
168
190
  export function nextSkillHighlightV1(
169
191
  highlighted: number,
@@ -201,6 +201,18 @@
201
201
  -webkit-app-region: no-drag;
202
202
  }
203
203
 
204
+ /*
205
+ * The Bot's own header controls on a phone, where the right panel that
206
+ * usually carries them is a closed drawer.
207
+ */
208
+ .topbar-bot-actions {
209
+ display: flex;
210
+ flex: 0 0 auto;
211
+ align-items: center;
212
+ gap: 2px;
213
+ margin-left: auto;
214
+ }
215
+
204
216
  /* Thread */
205
217
 
206
218
  .thread {
@@ -215,6 +227,20 @@
215
227
  background-image: var(--frock-thread-gradient);
216
228
  scrollbar-color: var(--frock-scrollbar) transparent;
217
229
  scrollbar-width: thin;
230
+ /*
231
+ * New content is added at the end, so the browser keeps the reader where
232
+ * they are rather than letting the growth push the view.
233
+ */
234
+ overflow-anchor: auto;
235
+ }
236
+
237
+ /*
238
+ * Laid out and measured, deliberately not painted. A conversation is put at
239
+ * its end while this holds, so it opens there instead of opening at the top
240
+ * and scrolling down where the reader can see it.
241
+ */
242
+ .thread-settling {
243
+ visibility: hidden;
218
244
  }
219
245
 
220
246
  .empty-thread {
@@ -334,26 +360,48 @@
334
360
  * notices and sends stack inside it, so a one-word reply is a bubble the width
335
361
  * of its word rather than a sliver of a shared row.
336
362
  *
337
- * The avatar is beside that column only while the Bot is working. Every reply
338
- * in this transcript is from the same Bot there are no group conversations
339
- * so a sheep on every settled line said nothing and cost the column its left
340
- * edge. The one on the running Turn does say something, which is why it stays.
363
+ * The avatar is not in that column, and not in the article at all: while the
364
+ * Bot is working it sits on its own row at the very end of the thread, so the
365
+ * trail streaming off its right has the width of the transcript to run through
366
+ * rather than the gutter beside a bubble, and a message sent mid-Turn lands
367
+ * above it. Every reply here is from the same Bot — there are no group
368
+ * conversations — so a sheep on every settled line said nothing and cost the
369
+ * column its left edge.
341
370
  */
342
371
  .message-assistant {
343
- flex-direction: row;
344
- align-items: flex-start;
372
+ flex-direction: column;
373
+ align-items: stretch;
345
374
  gap: 8px;
346
375
  }
347
376
 
348
377
  .message-column {
349
378
  display: flex;
350
379
  min-width: 0;
351
- flex: 1 1 auto;
352
380
  flex-direction: column;
353
381
  align-items: flex-start;
354
382
  gap: 6px;
355
383
  }
356
384
 
385
+ /*
386
+ * The working row. Its height is the trail's canvas: tall enough for the
387
+ * wobble to be visible, short enough that it reads as one line of the thread.
388
+ * It is the thread's last child, so it takes the same top margin every message
389
+ * has and adds no width of its own — the bubbles above never move sideways
390
+ * when it appears or goes.
391
+ */
392
+ .bot-working {
393
+ display: flex;
394
+ width: 100%;
395
+ height: 44px;
396
+ align-items: center;
397
+ margin-top: 6px;
398
+ gap: 0;
399
+ }
400
+
401
+ .bot-working-indicator {
402
+ height: 100%;
403
+ }
404
+
357
405
  .bot-avatar {
358
406
  position: relative;
359
407
  display: grid;
@@ -361,7 +409,6 @@
361
409
  height: var(--frock-avatar-sm);
362
410
  flex: 0 0 auto;
363
411
  place-items: center;
364
- margin-top: 2px;
365
412
  }
366
413
 
367
414
  .bot-avatar-fallback {
@@ -396,16 +443,17 @@
396
443
  }
397
444
 
398
445
  /*
399
- * The ring's way off screen. The Turn has settled, the reply is the answer
400
- * now, and the stroke fades rather than vanishing between two frames.
446
+ * The working row's way off screen. The Turn has settled, the reply is the
447
+ * answer now, and the row fades rather than vanishing between two frames
448
+ * which is also long enough for the particles still in flight to drain.
401
449
  */
402
- .activity-ring-enter-active,
403
- .activity-ring-leave-active {
450
+ .bot-working-enter-active,
451
+ .bot-working-leave-active {
404
452
  transition: opacity 420ms ease-out;
405
453
  }
406
454
 
407
- .activity-ring-enter-from,
408
- .activity-ring-leave-to {
455
+ .bot-working-enter-from,
456
+ .bot-working-leave-to {
409
457
  opacity: 0;
410
458
  }
411
459
 
@@ -1094,19 +1142,21 @@
1094
1142
  padding-left: 0;
1095
1143
  }
1096
1144
 
1145
+ /* No window chrome to clear, but the panel toggle still sits at the
1146
+ trailing edge, so the row ends before it rather than under it. */
1097
1147
  .topbar {
1098
1148
  gap: 8px;
1099
- padding: 0 100px 0 4px;
1100
- }
1101
-
1102
- .brand-mark {
1103
- font-size: var(--frock-text-lg);
1149
+ padding: 0 52px 0 4px;
1104
1150
  }
1105
1151
 
1106
1152
  .window-actions {
1107
1153
  padding: 0 8px;
1108
1154
  }
1109
1155
 
1156
+ .brand-mark {
1157
+ font-size: var(--frock-text-lg);
1158
+ }
1159
+
1110
1160
  .nav-toggle {
1111
1161
  flex: 0 0 auto;
1112
1162
  }
@@ -0,0 +1,125 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import type { WebChatMessage } from "../shared.js";
3
+ import {
4
+ TRANSCRIPT_CACHE_LIMIT,
5
+ TranscriptCache,
6
+ TRANSCRIPT_FRESH_MS,
7
+ } from "./transcript-cache.js";
8
+
9
+ function message(runId: string, text = "hello"): WebChatMessage {
10
+ return {
11
+ id: `${runId}:user`,
12
+ runId,
13
+ role: "user",
14
+ text,
15
+ status: "completed",
16
+ tools: [],
17
+ sends: [],
18
+ };
19
+ }
20
+
21
+ function snapshot(conversationKey = "bot:1", runId = "run-1") {
22
+ return { conversationKey, messages: [message(runId)] };
23
+ }
24
+
25
+ describe("TranscriptCache", () => {
26
+ test("gives a saved conversation back without a read", () => {
27
+ const cache = new TranscriptCache();
28
+ cache.save("alpha", snapshot());
29
+ const restored = cache.take("alpha", "bot:1");
30
+ expect(restored?.messages.map((entry) => entry.runId)).toEqual(["run-1"]);
31
+ expect(restored?.stale).toBe(false);
32
+ });
33
+
34
+ test("hands back copies, so the caller's edits never reach the cache", () => {
35
+ const cache = new TranscriptCache();
36
+ cache.save("alpha", snapshot());
37
+ const restored = cache.take("alpha", "bot:1");
38
+ restored?.messages.push(message("run-2"));
39
+ const [first] = restored?.messages ?? [];
40
+ if (first) first.text = "rewritten";
41
+ expect(cache.take("alpha", "bot:1")?.messages).toEqual([message("run-1")]);
42
+ });
43
+
44
+ test("a different conversation on the same Bot is a miss, not the old one", () => {
45
+ const cache = new TranscriptCache();
46
+ cache.save("alpha", snapshot("bot:1"));
47
+ // ADR 0027: "new conversation" moves the Bot to a new Session, and the
48
+ // transcript that belonged to the previous one must not come back.
49
+ expect(cache.take("alpha", "bot:1#2")).toBeUndefined();
50
+ // The miss drops it: nothing will ask for that conversation again.
51
+ expect(cache.take("alpha", "bot:1")).toBeUndefined();
52
+ });
53
+
54
+ test("keeps the last N Bots and evicts the least recently used", () => {
55
+ const cache = new TranscriptCache();
56
+ for (let index = 0; index < TRANSCRIPT_CACHE_LIMIT + 2; index += 1) {
57
+ cache.save(`bot-${index}`, snapshot(`key-${index}`, `run-${index}`));
58
+ }
59
+ expect(cache.size).toBe(TRANSCRIPT_CACHE_LIMIT);
60
+ expect(cache.take("bot-0", "key-0")).toBeUndefined();
61
+ expect(cache.take("bot-1", "key-1")).toBeUndefined();
62
+ expect(cache.take("bot-2", "key-2")).toBeDefined();
63
+ });
64
+
65
+ test("reading a transcript makes it the last one evicted", () => {
66
+ const cache = new TranscriptCache({ limit: 2 });
67
+ cache.save("alpha", snapshot("a"));
68
+ cache.save("beta", snapshot("b"));
69
+ // Alpha is the oldest write but the newest use.
70
+ expect(cache.take("alpha", "a")).toBeDefined();
71
+ cache.save("gamma", snapshot("c"));
72
+ expect(cache.take("beta", "b")).toBeUndefined();
73
+ expect(cache.take("alpha", "a")).toBeDefined();
74
+ });
75
+
76
+ test("an empty transcript is not held", () => {
77
+ const cache = new TranscriptCache();
78
+ cache.save("alpha", snapshot());
79
+ cache.save("alpha", { conversationKey: "bot:1", messages: [] });
80
+ expect(cache.size).toBe(0);
81
+ });
82
+
83
+ test("a channel notice leaves the transcript drawable but owes a read", () => {
84
+ const cache = new TranscriptCache();
85
+ cache.save("alpha", snapshot());
86
+ cache.markStale("alpha");
87
+ const restored = cache.take("alpha", "bot:1");
88
+ expect(restored?.messages).toHaveLength(1);
89
+ expect(restored?.stale).toBe(true);
90
+ });
91
+
92
+ test("a transcript past its freshness window owes a read", () => {
93
+ let clock = 1_000;
94
+ const cache = new TranscriptCache({ now: () => clock });
95
+ cache.save("alpha", snapshot());
96
+ clock += TRANSCRIPT_FRESH_MS + 1;
97
+ expect(cache.take("alpha", "bot:1")?.stale).toBe(true);
98
+ });
99
+
100
+ test("forget drops one Bot, or every Bot", () => {
101
+ const cache = new TranscriptCache();
102
+ cache.save("alpha", snapshot("a"));
103
+ cache.save("beta", snapshot("b"));
104
+ cache.forget("alpha");
105
+ expect(cache.take("alpha", "a")).toBeUndefined();
106
+ expect(cache.take("beta", "b")).toBeDefined();
107
+ // Signing out is not "some conversations are stale", it is "none of these
108
+ // are this User's".
109
+ cache.forget();
110
+ expect(cache.size).toBe(0);
111
+ });
112
+
113
+ test("remembers where the reader had the thread", () => {
114
+ const cache = new TranscriptCache();
115
+ cache.save("alpha", snapshot());
116
+ cache.rememberViewport("alpha", { scrollTop: 420, pinnedToLatest: false });
117
+ expect(cache.take("alpha", "bot:1")?.viewport).toEqual({
118
+ scrollTop: 420,
119
+ pinnedToLatest: false,
120
+ });
121
+ // A viewport for a Bot that is not held is dropped rather than resurrecting it.
122
+ cache.rememberViewport("ghost", { scrollTop: 1, pinnedToLatest: true });
123
+ expect(cache.take("ghost", "bot:1")).toBeUndefined();
124
+ });
125
+ });
@@ -0,0 +1,190 @@
1
+ /**
2
+ * What the client keeps of a conversation it is not currently showing.
3
+ *
4
+ * Clicking between Bots used to throw the transcript away and read it back:
5
+ * every switch was an empty thread, a network round trip, and a scroll jump.
6
+ * A Bot's conversation is small, already durable behind it, and cheap to hold,
7
+ * so the last few are kept in memory and redrawn immediately.
8
+ *
9
+ * Two rules keep the cache from lying:
10
+ *
11
+ * - **It is keyed by conversation, not by Bot.** ADR 0027 makes "new
12
+ * conversation" change the Session a Bot's Turns record, so the transcript
13
+ * that belonged to the previous one must not come back under the same key.
14
+ * - **A cached transcript is still revalidated.** The entry carries when it
15
+ * was written; past {@link TRANSCRIPT_FRESH_MS}, or once something has told
16
+ * the client the Bot's runs moved, the restore is followed by a read. Inside
17
+ * that window the click costs nothing, which is the whole point.
18
+ *
19
+ * The cache is memory only and never outlives the page: nothing about one
20
+ * User's conversations reaches the next one through it.
21
+ */
22
+ import type { WebActiveRun, WebChatMessage } from "../shared.ts";
23
+
24
+ /** How many Bots' transcripts are held before the least recent is dropped. */
25
+ export const TRANSCRIPT_CACHE_LIMIT = 8;
26
+
27
+ /**
28
+ * How long a cached transcript is served without a read behind it. Long
29
+ * enough that clicking between Bots is free, short enough that a conversation
30
+ * changed on another device is never stale for long.
31
+ */
32
+ export const TRANSCRIPT_FRESH_MS = 30_000;
33
+
34
+ /** Where the reader had the thread when they switched away. */
35
+ export interface TranscriptViewport {
36
+ scrollTop: number;
37
+ /**
38
+ * True when they were at the end. Restored as "the end" rather than as the
39
+ * pixel offset, so a transcript that grew while they were away comes back
40
+ * pinned to the newest Turn and not to where it used to be.
41
+ */
42
+ pinnedToLatest: boolean;
43
+ }
44
+
45
+ /** One conversation, as the thread last drew it. */
46
+ export interface TranscriptSnapshot {
47
+ /** Distinguishes this conversation from the next one on the same Bot. */
48
+ conversationKey: string;
49
+ messages: WebChatMessage[];
50
+ activeRun?: WebActiveRun;
51
+ activeRunId?: string;
52
+ runningRunId?: string;
53
+ viewport?: TranscriptViewport;
54
+ }
55
+
56
+ interface CacheEntry extends TranscriptSnapshot {
57
+ writtenAt: number;
58
+ stale: boolean;
59
+ }
60
+
61
+ /** A restored transcript, and whether reading it back is still owed. */
62
+ export interface TranscriptRestore extends TranscriptSnapshot {
63
+ /** True when the caller should revalidate before trusting this for long. */
64
+ stale: boolean;
65
+ }
66
+
67
+ export interface TranscriptCacheOptions {
68
+ limit?: number;
69
+ freshMs?: number;
70
+ now?: () => number;
71
+ }
72
+
73
+ /**
74
+ * The last few Bots' conversations, most recently used last.
75
+ *
76
+ * `Map` iteration order is insertion order, so "touch on read" is a delete
77
+ * followed by a set and the first key is always the eviction candidate.
78
+ */
79
+ export class TranscriptCache {
80
+ readonly #entries = new Map<string, CacheEntry>();
81
+ readonly #limit: number;
82
+ readonly #freshMs: number;
83
+ readonly #now: () => number;
84
+
85
+ constructor(options: TranscriptCacheOptions = {}) {
86
+ this.#limit = options.limit ?? TRANSCRIPT_CACHE_LIMIT;
87
+ this.#freshMs = options.freshMs ?? TRANSCRIPT_FRESH_MS;
88
+ this.#now = options.now ?? (() => Date.now());
89
+ }
90
+
91
+ /** The Bots held, least recently used first. */
92
+ get botIds(): readonly string[] {
93
+ return [...this.#entries.keys()];
94
+ }
95
+
96
+ get size(): number {
97
+ return this.#entries.size;
98
+ }
99
+
100
+ /**
101
+ * The transcript for this Bot's current conversation, if it is held.
102
+ *
103
+ * A key mismatch is a miss and drops the entry: the conversation it holds
104
+ * is over, and nothing will ask for it again.
105
+ */
106
+ take(botId: string, conversationKey: string): TranscriptRestore | undefined {
107
+ const entry = this.#entries.get(botId);
108
+ if (!entry) return undefined;
109
+ if (entry.conversationKey !== conversationKey) {
110
+ this.#entries.delete(botId);
111
+ return undefined;
112
+ }
113
+ // Reading is using: this Bot is now the most recent and the last to go.
114
+ this.#entries.delete(botId);
115
+ this.#entries.set(botId, entry);
116
+ return {
117
+ conversationKey: entry.conversationKey,
118
+ // Copies, so the caller's edits never reach back into the cache.
119
+ messages: entry.messages.map((message) => ({ ...message })),
120
+ ...(entry.activeRun ? { activeRun: { ...entry.activeRun } } : {}),
121
+ ...(entry.activeRunId ? { activeRunId: entry.activeRunId } : {}),
122
+ ...(entry.runningRunId ? { runningRunId: entry.runningRunId } : {}),
123
+ ...(entry.viewport ? { viewport: { ...entry.viewport } } : {}),
124
+ stale: entry.stale || this.#now() - entry.writtenAt > this.#freshMs,
125
+ };
126
+ }
127
+
128
+ /** Holds this conversation, evicting the least recently used past the limit. */
129
+ save(botId: string, snapshot: TranscriptSnapshot): void {
130
+ // An empty transcript is not worth a slot: restoring it looks exactly like
131
+ // the read it would have saved.
132
+ if (snapshot.messages.length === 0) {
133
+ this.#entries.delete(botId);
134
+ return;
135
+ }
136
+ // The scroll position is written after the transcript, by the thread that
137
+ // still has it on screen, so a save that carries none keeps the last one.
138
+ const viewport =
139
+ snapshot.viewport ?? this.#entries.get(botId)?.viewport ?? undefined;
140
+ this.#entries.delete(botId);
141
+ this.#entries.set(botId, {
142
+ conversationKey: snapshot.conversationKey,
143
+ messages: snapshot.messages.map((message) => ({ ...message })),
144
+ ...(snapshot.activeRun ? { activeRun: { ...snapshot.activeRun } } : {}),
145
+ ...(snapshot.activeRunId ? { activeRunId: snapshot.activeRunId } : {}),
146
+ ...(snapshot.runningRunId ? { runningRunId: snapshot.runningRunId } : {}),
147
+ ...(viewport ? { viewport: { ...viewport } } : {}),
148
+ writtenAt: this.#now(),
149
+ stale: false,
150
+ });
151
+ while (this.#entries.size > this.#limit) {
152
+ const oldest = this.#entries.keys().next();
153
+ if (oldest.done) break;
154
+ this.#entries.delete(oldest.value);
155
+ }
156
+ }
157
+
158
+ /** Records where the reader had the thread, without touching the transcript. */
159
+ rememberViewport(botId: string, viewport: TranscriptViewport): void {
160
+ const entry = this.#entries.get(botId);
161
+ if (!entry) return;
162
+ entry.viewport = { ...viewport };
163
+ }
164
+
165
+ /** Where the reader had this Bot's thread, if it is still held. */
166
+ viewportFor(botId: string): TranscriptViewport | undefined {
167
+ const viewport = this.#entries.get(botId)?.viewport;
168
+ return viewport ? { ...viewport } : undefined;
169
+ }
170
+
171
+ /**
172
+ * Says this Bot's runs have moved. The transcript still draws immediately —
173
+ * a stale answer beats an empty thread — but a read follows it.
174
+ */
175
+ markStale(botId: string): void {
176
+ const entry = this.#entries.get(botId);
177
+ if (entry) entry.stale = true;
178
+ }
179
+
180
+ /**
181
+ * Drops what is held for this Bot, or for every Bot.
182
+ *
183
+ * Called where the cached transcript would be a lie rather than merely old:
184
+ * archive, delete, rename, and a change of signed-in User.
185
+ */
186
+ forget(botId?: string): void {
187
+ if (botId === undefined) this.#entries.clear();
188
+ else this.#entries.delete(botId);
189
+ }
190
+ }