@poodle64/librarian 2026.9.18 → 2026.9.20

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.
@@ -1,5 +1,5 @@
1
1
  <!--
2
- A study artefact, open in the reading column.
2
+ An artefact that is its answer's own prose, open in the reading column.
3
3
 
4
4
  The same shell shape `DocumentPane` uses (a column at 64rem, a bottom sheet
5
5
  below it) because decision 2 of the agent surface is that a colleague never
@@ -61,10 +61,10 @@
61
61
 
62
62
  <header class="ds-lib-pane-header">
63
63
  <div class="ds-lib-pane-heading">
64
- <h2 class="ds-lib-pane-title">{turn.title ?? 'Briefing'}</h2>
65
- <p class="ds-lib-pane-subtitle">
66
- Briefing doc · {sources.length} source{sources.length === 1 ? '' : 's'}
67
- </p>
64
+ <h2 class="ds-lib-pane-title">{turn.artefact?.title}</h2>
65
+ {#if turn.artefact?.summary}
66
+ <p class="ds-lib-pane-subtitle">{turn.artefact.summary}</p>
67
+ {/if}
68
68
  </div>
69
69
  <button
70
70
  bind:this={closeButton}
@@ -7,8 +7,10 @@
7
7
  chrome grows with the text and nothing else moves.
8
8
 
9
9
  While Milton is answering the box is disabled and the send button becomes a
10
- stop button. Queuing the next message is not built, and a box that takes
11
- typing it will then discard is worse than one that plainly waits.
10
+ stop button: an ask route takes one question per request, and a box that
11
+ takes typing it will then discard is worse than one that plainly waits. A
12
+ host whose session reads messages while it works (`sendWhileRunning`) keeps
13
+ the box open, with Send beside Stop.
12
14
  -->
13
15
  <script lang="ts">
14
16
  import ArrowUpIcon from '@lucide/svelte/icons/arrow-up';
@@ -30,7 +32,9 @@
30
32
  interface Props {
31
33
  value: string;
32
34
  running: boolean;
33
- scope: Scope;
35
+ /** What the next message is about. A surface with one scope — a
36
+ * session over one document — passes none and offers no choice. */
37
+ scope?: Scope;
34
38
  /** Files to send with the next question. Cleared by the caller on send. */
35
39
  files?: File[];
36
40
  /** Names for the two narrower scopes; absent means that scope is unavailable. */
@@ -38,7 +42,7 @@
38
42
  collectionName?: string;
39
43
  /** Hides the paperclip for a host whose ask route takes no files. */
40
44
  attachments?: boolean;
41
- onscope: (scope: Scope) => void;
45
+ onscope?: (scope: Scope) => void;
42
46
  onsubmit: () => void;
43
47
  onstop: () => void;
44
48
  /** Asks for a study artefact instead of an ordinary answer. Omit and no
@@ -50,6 +54,15 @@
50
54
  name?: string;
51
55
  /** Overrides for the package's own words. */
52
56
  copy?: Partial<LibrarianCopy>;
57
+ /** The session reads a message sent while it works — a job whose
58
+ * route writes it onto the running process — so the box stays open
59
+ * and Send sits beside Stop. Words only: the paperclip still waits for
60
+ * the run. Absent, a chat waits for its answer. */
61
+ sendWhileRunning?: boolean;
62
+ /** One quiet line in the box's footer, where a scope choice would
63
+ * sit, in the host's words: what sending does here ("It carries on
64
+ * from where it stopped."). */
65
+ note?: string;
53
66
  }
54
67
 
55
68
  let {
@@ -65,9 +78,14 @@
65
78
  onstop,
66
79
  onbriefing,
67
80
  name = DEFAULT_PERSONA,
68
- copy
81
+ copy,
82
+ note,
83
+ sendWhileRunning = false
69
84
  }: Props = $props();
70
85
 
86
+ /** Nothing can be said right now: a chat whose answer is still coming. */
87
+ const waiting = $derived(running && !sendWhileRunning);
88
+
71
89
  const words = $derived(resolveCopy(copy, name));
72
90
 
73
91
  // What Milton is being asked about — this document, this collection, or
@@ -87,7 +105,7 @@
87
105
  // passing a name — no pick to make, so the row renders nothing rather
88
106
  // than a chip that only ever reselects itself (design-system, the
89
107
  // fixed-scope Composer defect).
90
- const hasChoice = $derived(choices.length > 1);
108
+ const hasChoice = $derived(scope !== undefined && choices.length > 1);
91
109
 
92
110
  // Three chips in a narrow column clipped all three to fragments
93
111
  // ("defence-s…", "All colle…"). Wrapping beats truncating: a chip a reader
@@ -117,21 +135,23 @@
117
135
  });
118
136
 
119
137
  // Focus comes back once Milton is done, so a reader can keep going without
120
- // reaching for the mouse. It has to be the transition OUT of `running`: a
138
+ // reaching for the mouse. It has to be the transition OUT of `waiting`: a
121
139
  // disabled element cannot hold focus, so the focus call in `submit()` below
122
- // survives only for a host that never sets `running` — measured, focus lands
123
- // on `body` the frame the box disables and comes back here.
140
+ // survives only for a box that never disables — measured, focus lands on
141
+ // `body` the frame the box disables and comes back here. A box that stayed
142
+ // open never lost focus, so it takes none back from wherever the reader
143
+ // went while the run worked.
124
144
  //
125
145
  // Plain, not `$state`: read and written in the same effect, which would
126
146
  // otherwise re-trigger itself forever.
127
- let wasRunning = false;
147
+ let wasWaiting = false;
128
148
  $effect(() => {
129
- if (wasRunning && !running) textarea?.focus();
130
- wasRunning = running;
149
+ if (wasWaiting && !waiting) textarea?.focus();
150
+ wasWaiting = waiting;
131
151
  });
132
152
 
133
153
  function submit() {
134
- if (!value.trim() || running) return;
154
+ if (!value.trim() || waiting) return;
135
155
  onsubmit();
136
156
  textarea?.focus();
137
157
  }
@@ -218,8 +238,8 @@
218
238
  oninput={grow}
219
239
  onkeydown={keydown}
220
240
  rows="1"
221
- disabled={running}
222
- placeholder={running ? words.answeringPlaceholder : words.askPlaceholder}
241
+ disabled={waiting}
242
+ placeholder={waiting ? words.answeringPlaceholder : words.askPlaceholder}
223
243
  class="ds-lib-input"
224
244
  ></textarea>
225
245
 
@@ -266,19 +286,22 @@
266
286
  type="button"
267
287
  class="ds-lib-scope-chip"
268
288
  class:is-chosen={scope === choice.id}
269
- onclick={() => onscope(choice.id)}
289
+ onclick={() => onscope?.(choice.id)}
270
290
  >
271
291
  {choice.label}
272
292
  </button>
273
293
  {/each}
274
294
  </div>
295
+ {:else if note}
296
+ <span class="ds-lib-note">{note}</span>
275
297
  {/if}
276
298
  <span class="ds-lib-spacer"></span>
277
299
  {#if running}
278
300
  <button type="button" class="ds-lib-stop" onclick={onstop} aria-label="Stop">
279
301
  <SquareIcon size={14} fill="currentColor" />
280
302
  </button>
281
- {:else}
303
+ {/if}
304
+ {#if !waiting}
282
305
  <button
283
306
  type="button"
284
307
  class="ds-lib-send"
@@ -502,6 +525,12 @@
502
525
  flex: 1;
503
526
  }
504
527
 
528
+ .ds-lib-note {
529
+ min-width: 0;
530
+ color: var(--ds-color-muted-foreground);
531
+ font-size: var(--ds-text-2xs);
532
+ }
533
+
505
534
  .ds-lib-rejected {
506
535
  margin: 0;
507
536
  padding: 0.375rem 0.25rem 0;
@@ -3,7 +3,9 @@ export type Scope = 'document' | 'collection' | 'library';
3
3
  interface Props {
4
4
  value: string;
5
5
  running: boolean;
6
- scope: Scope;
6
+ /** What the next message is about. A surface with one scope — a
7
+ * session over one document — passes none and offers no choice. */
8
+ scope?: Scope;
7
9
  /** Files to send with the next question. Cleared by the caller on send. */
8
10
  files?: File[];
9
11
  /** Names for the two narrower scopes; absent means that scope is unavailable. */
@@ -11,7 +13,7 @@ interface Props {
11
13
  collectionName?: string;
12
14
  /** Hides the paperclip for a host whose ask route takes no files. */
13
15
  attachments?: boolean;
14
- onscope: (scope: Scope) => void;
16
+ onscope?: (scope: Scope) => void;
15
17
  onsubmit: () => void;
16
18
  onstop: () => void;
17
19
  /** Asks for a study artefact instead of an ordinary answer. Omit and no
@@ -23,6 +25,15 @@ interface Props {
23
25
  name?: string;
24
26
  /** Overrides for the package's own words. */
25
27
  copy?: Partial<LibrarianCopy>;
28
+ /** The session reads a message sent while it works — a job whose
29
+ * route writes it onto the running process — so the box stays open
30
+ * and Send sits beside Stop. Words only: the paperclip still waits for
31
+ * the run. Absent, a chat waits for its answer. */
32
+ sendWhileRunning?: boolean;
33
+ /** One quiet line in the box's footer, where a scope choice would
34
+ * sit, in the host's words: what sending does here ("It carries on
35
+ * from where it stopped."). */
36
+ note?: string;
26
37
  }
27
38
  declare const Composer: import("svelte").Component<Props, {}, "value" | "files">;
28
39
  type Composer = ReturnType<typeof Composer>;
@@ -11,7 +11,7 @@
11
11
  <script lang="ts">
12
12
  import type { Snippet } from 'svelte';
13
13
  import ArrowDownIcon from '@lucide/svelte/icons/arrow-down';
14
- import type { Turn } from '../../transcript.svelte';
14
+ import type { DescribeTool, Turn } from '../../transcript.svelte';
15
15
  import type { Citation, LoadDocument } from '../../citations';
16
16
  import { DEFAULT_PERSONA, personaName, resolveCopy, type LibrarianCopy } from '../../copy';
17
17
  import { FollowScroll } from '../../follow-scroll.svelte';
@@ -55,6 +55,18 @@
55
55
  copy?: Partial<LibrarianCopy>;
56
56
  /** Enables the source pane. Without it, chips render but do not open. */
57
57
  loadDocument?: LoadDocument;
58
+ /** Takes every citation tap instead, for a host that shows the source
59
+ * in a viewer of its own. */
60
+ oncite?: (citation: Citation) => void;
61
+ /** Takes every artefact card's tap, for a host that shows an artefact
62
+ * in a column of its own. Without it, an artefact that IS its answer
63
+ * opens in this surface's own pane, and any other informs and does
64
+ * not open. */
65
+ onopenartefact?: (turn: Turn) => void;
66
+ /** The id of the turn whose artefact the host is showing now. */
67
+ showing?: string;
68
+ /** The persona's own words for its tools, and what they read. */
69
+ describeTool?: DescribeTool;
58
70
  collectionNames?: Set<string>;
59
71
  /** The composer, rendered INSIDE the transcript column so the source
60
72
  * pane narrows it too — a composer the host places outside slides
@@ -75,6 +87,10 @@
75
87
  onsuggest,
76
88
  copy,
77
89
  loadDocument,
90
+ oncite,
91
+ onopenartefact,
92
+ showing,
93
+ describeTool,
78
94
  collectionNames = new Set(),
79
95
  composer
80
96
  }: Props = $props();
@@ -140,7 +156,8 @@
140
156
  // colleague who just asked for one is asking to read it, not merely to
141
157
  // see that it exists. Fires on the RUNNING → settled transition only, so
142
158
  // reopening a past conversation whose last turn happens to be an
143
- // artefact does not reopen a pane the reader may have since closed.
159
+ // artefact does not reopen a pane the reader may have since closed. A
160
+ // host that opens artefacts itself decides this for itself.
144
161
  //
145
162
  // Plain, not `$state`: read and written in the same effect, which would
146
163
  // otherwise re-trigger itself forever (the pattern this file's `seen`
@@ -148,7 +165,13 @@
148
165
  let wasRunning = false;
149
166
  $effect(() => {
150
167
  const last = turns.at(-1);
151
- if (wasRunning && !running && last?.kind === 'artefact' && !last.outcome?.isError) {
168
+ if (
169
+ !onopenartefact &&
170
+ wasRunning &&
171
+ !running &&
172
+ last?.artefact?.isAnswer &&
173
+ !last.outcome?.isError
174
+ ) {
152
175
  column = { kind: 'artefact', turn: last };
153
176
  }
154
177
  wasRunning = running;
@@ -165,14 +188,23 @@
165
188
  }
166
189
 
167
190
  function cite(citation: Citation) {
191
+ if (oncite) return oncite(citation);
168
192
  // A citation derived from the prose has no id to read, so its chip is
169
193
  // legible but inert rather than opening an empty pane. Replaces
170
194
  // whatever the column held, artefact included — never a second pane.
171
195
  if (loadDocument && citation.document_id) column = { kind: 'document', citation };
172
196
  }
173
197
 
174
- function openArtefact(turn: Turn) {
175
- column = { kind: 'artefact', turn };
198
+ /** Who opens this turn's artefact, if anyone can. */
199
+ function opener(turn: Turn): (() => void) | undefined {
200
+ if (onopenartefact) return () => onopenartefact(turn);
201
+ if (turn.artefact?.isAnswer) return () => (column = { kind: 'artefact', turn });
202
+ return undefined;
203
+ }
204
+
205
+ function isShowing(turn: Turn): boolean {
206
+ if (onopenartefact) return showing === turn.id;
207
+ return column?.kind === 'artefact' && column.turn.id === turn.id;
176
208
  }
177
209
  </script>
178
210
 
@@ -235,10 +267,11 @@
235
267
  copy={words}
236
268
  name={who}
237
269
  at={turn.at ?? stamps[turn.id]}
238
- kind={turn.kind}
239
- title={turn.title}
240
- onopenartefact={() => openArtefact(turn)}
241
- oncite={cite}
270
+ artefact={turn.artefact}
271
+ artefactOpen={isShowing(turn)}
272
+ onopenartefact={opener(turn)}
273
+ {describeTool}
274
+ oncite={oncite || loadDocument ? cite : undefined}
242
275
  onregenerate={index === turns.length - 1 && !running ? onregenerate : undefined}
243
276
  onsuggest={index === turns.length - 1 && !running ? onsuggest : undefined}
244
277
  />
@@ -282,8 +315,12 @@
282
315
  </div>
283
316
 
284
317
  <style>
318
+ /* `min-width: 0` for a host that sets this in a row beside columns of its
319
+ own: a flex item's automatic minimum is its widest content, and a table
320
+ in an answer then pushed the whole page sideways at 390. */
285
321
  .ds-lib-surface {
286
322
  display: flex;
323
+ min-width: 0;
287
324
  min-height: 0;
288
325
  flex: 1;
289
326
  }
@@ -1,6 +1,6 @@
1
1
  import type { Snippet } from 'svelte';
2
- import type { Turn } from '../../transcript.svelte';
3
- import type { LoadDocument } from '../../citations';
2
+ import type { DescribeTool, Turn } from '../../transcript.svelte';
3
+ import type { Citation, LoadDocument } from '../../citations';
4
4
  import { type LibrarianCopy } from '../../copy';
5
5
  interface Props {
6
6
  turns: Turn[];
@@ -32,6 +32,18 @@ interface Props {
32
32
  copy?: Partial<LibrarianCopy>;
33
33
  /** Enables the source pane. Without it, chips render but do not open. */
34
34
  loadDocument?: LoadDocument;
35
+ /** Takes every citation tap instead, for a host that shows the source
36
+ * in a viewer of its own. */
37
+ oncite?: (citation: Citation) => void;
38
+ /** Takes every artefact card's tap, for a host that shows an artefact
39
+ * in a column of its own. Without it, an artefact that IS its answer
40
+ * opens in this surface's own pane, and any other informs and does
41
+ * not open. */
42
+ onopenartefact?: (turn: Turn) => void;
43
+ /** The id of the turn whose artefact the host is showing now. */
44
+ showing?: string;
45
+ /** The persona's own words for its tools, and what they read. */
46
+ describeTool?: DescribeTool;
35
47
  collectionNames?: Set<string>;
36
48
  /** The composer, rendered INSIDE the transcript column so the source
37
49
  * pane narrows it too — a composer the host places outside slides
@@ -6,12 +6,13 @@
6
6
  five separate events rather than one train of thought.
7
7
 
8
8
  The row says what the persona DID, in a reader's own words — never the
9
- tool's name or the raw command it ran. Expanding a settled row shows what
10
- came back, never what was typed.
9
+ tool's name or the raw command it ran: the host's `DescribeTool`, or the
10
+ package's own `describe()`. Expanding a settled row shows what came back,
11
+ never what was typed.
11
12
  -->
12
13
  <script lang="ts">
13
14
  import ChevronRightIcon from '@lucide/svelte/icons/chevron-right';
14
- import { describe, type ToolBlock } from '../../transcript.svelte';
15
+ import { describe, type ToolBlock, type ToolWords } from '../../transcript.svelte';
15
16
 
16
17
  interface Props {
17
18
  block: ToolBlock;
@@ -19,15 +20,21 @@
19
20
  /** Identical consecutive steps folded into this row — five pages of one
20
21
  * document is one act of reading to a human. */
21
22
  repeats?: number;
23
+ /** The call in the persona's words. Absent takes the package's own. */
24
+ words?: ToolWords;
22
25
  }
23
26
 
24
- let { block, running, repeats = 1 }: Props = $props();
27
+ let { block, running, repeats = 1, words }: Props = $props();
25
28
  let open = $state(false);
26
29
 
27
30
  const settled = $derived(block.result !== undefined);
28
- const said = $derived(describe(block));
31
+ const said = $derived(words ?? describe(block));
29
32
  const tone = $derived(block.isError ? 'error' : settled ? 'success' : 'info');
30
- const lines = $derived(block.result ? block.result.split('\n').length : 0);
33
+ const again = $derived(
34
+ said.repeat
35
+ ? `· ${repeats} ${repeats === 1 ? said.repeat[0] : said.repeat[1]}`
36
+ : `× ${repeats}`
37
+ );
31
38
  </script>
32
39
 
33
40
  <div class="ds-lib-tool">
@@ -45,7 +52,7 @@
45
52
  <span class="ds-lib-tool-said">
46
53
  <span class="ds-lib-tool-verb">{said.verb}</span>
47
54
  {#if said.object}<span class="ds-lib-tool-object">{said.object}</span>{/if}
48
- {#if repeats > 1}<span>· {repeats} pages</span>{/if}
55
+ {#if repeats > 1}<span>{again}</span>{/if}
49
56
  </span>
50
57
  </button>
51
58
 
@@ -55,8 +62,8 @@
55
62
  <pre class="ds-lib-tool-result">{block.result}</pre>
56
63
  {/if}
57
64
  </div>
58
- {:else if settled && lines > 0}
59
- <p class="ds-lib-tool-lines">{lines === 1 ? '1 line' : `${lines} lines`}</p>
65
+ {:else if said.detail}
66
+ <p class="ds-lib-tool-lines">{said.detail}</p>
60
67
  {/if}
61
68
  </div>
62
69
 
@@ -1,10 +1,12 @@
1
- import { type ToolBlock } from '../../transcript.svelte';
1
+ import { type ToolBlock, type ToolWords } from '../../transcript.svelte';
2
2
  interface Props {
3
3
  block: ToolBlock;
4
4
  running: boolean;
5
5
  /** Identical consecutive steps folded into this row — five pages of one
6
6
  * document is one act of reading to a human. */
7
7
  repeats?: number;
8
+ /** The call in the persona's words. Absent takes the package's own. */
9
+ words?: ToolWords;
8
10
  }
9
11
  declare const ToolRow: import("svelte").Component<Props, {}, "">;
10
12
  type ToolRow = ReturnType<typeof ToolRow>;
package/dist/copy.d.ts CHANGED
@@ -52,6 +52,9 @@ export interface LibrarianCopy {
52
52
  jumpToLatest: string;
53
53
  closeSource: string;
54
54
  documentUnavailable: string;
55
+ /** An artefact card's action, and the same action while it is open. */
56
+ openArtefact: string;
57
+ showingArtefact: string;
55
58
  }
56
59
  /**
57
60
  * The display name for a persona a host identified by its slug.
package/dist/copy.js CHANGED
@@ -72,7 +72,9 @@ export function copyFor(name = DEFAULT_PERSONA) {
72
72
  askAgain: 'Ask again',
73
73
  jumpToLatest: 'Jump to latest',
74
74
  closeSource: 'Close source',
75
- documentUnavailable: "That document can't be opened right now."
75
+ documentUnavailable: "That document can't be opened right now.",
76
+ openArtefact: 'Open',
77
+ showingArtefact: 'Showing'
76
78
  };
77
79
  }
78
80
  /** The library's own words, for a host that names no persona. */
@@ -0,0 +1,31 @@
1
+ /**
2
+ * A whole session as a conversation: every run a stream carries, each one a
3
+ * turn with the reader's words on one side and the persona's on the other.
4
+ *
5
+ * `Transcript` folds the events of ONE question the host asked. A session the
6
+ * APP started — a job that reads a document with nobody asking — is a
7
+ * different stream: its prompt and every later message arrive as the CLI
8
+ * echoes each one back (`--replay-user-messages`), and each run ends in its
9
+ * own `result`. So a replayed user frame opens a turn, everything after it
10
+ * folds into that turn, and its `result` settles it.
11
+ *
12
+ * Every Claude Code event carries a `uuid`, and one already folded is
13
+ * skipped: a watch route replays from the first event, so opening it again
14
+ * after the session moves on folds only what is new. A frame the server adds
15
+ * itself (an error, the library's citations) carries none, and is known by
16
+ * the event it follows.
17
+ */
18
+ import type { AgentEvent } from './client';
19
+ import { type Turn } from './transcript.svelte';
20
+ export declare class Session {
21
+ #private;
22
+ /** One per run, in order: plain objects, so a host may spread one to add
23
+ * its own `artefact`. */
24
+ turns: Turn[];
25
+ /** Bumped on every applied event — see `Transcript.version`. */
26
+ version: number;
27
+ /** The last run has started and not settled. A run the reader stopped
28
+ * never settles, so a host ANDs this with its own "the stream is open". */
29
+ get working(): boolean;
30
+ apply(event: AgentEvent): void;
31
+ }
@@ -0,0 +1,105 @@
1
+ /**
2
+ * A whole session as a conversation: every run a stream carries, each one a
3
+ * turn with the reader's words on one side and the persona's on the other.
4
+ *
5
+ * `Transcript` folds the events of ONE question the host asked. A session the
6
+ * APP started — a job that reads a document with nobody asking — is a
7
+ * different stream: its prompt and every later message arrive as the CLI
8
+ * echoes each one back (`--replay-user-messages`), and each run ends in its
9
+ * own `result`. So a replayed user frame opens a turn, everything after it
10
+ * folds into that turn, and its `result` settles it.
11
+ *
12
+ * Every Claude Code event carries a `uuid`, and one already folded is
13
+ * skipped: a watch route replays from the first event, so opening it again
14
+ * after the session moves on folds only what is new. A frame the server adds
15
+ * itself (an error, the library's citations) carries none, and is known by
16
+ * the event it follows.
17
+ */
18
+ import { contentOf, fold, foldState } from './transcript.svelte';
19
+ /** The events a run is made of. One arriving after its turn settled is the
20
+ * next run, whether or not its prompt was echoed. */
21
+ const RUN = new Set(['stream_event', 'assistant', 'user', 'result']);
22
+ /** Everything else that lands on a turn: the library's trailing frames, and
23
+ * a stream that broke. Anything else (`system`, rate limits) is the CLI
24
+ * talking about itself. */
25
+ const FOLDED = new Set([...RUN, 'library_error', 'citations', 'suggestions']);
26
+ export class Session {
27
+ /** One per run, in order: plain objects, so a host may spread one to add
28
+ * its own `artefact`. */
29
+ turns = $state([]);
30
+ /** Bumped on every applied event — see `Transcript.version`. */
31
+ version = $state(0);
32
+ #folds = [];
33
+ // A plain Set, as `Transcript`'s fold map is: nothing renders it.
34
+ // eslint-disable-next-line svelte/prefer-svelte-reactivity
35
+ #seen = new Set();
36
+ /** The last `uuid` in stream order, folded or skipped. */
37
+ #after = '';
38
+ /** The last run has started and not settled. A run the reader stopped
39
+ * never settles, so a host ANDs this with its own "the stream is open". */
40
+ get working() {
41
+ const last = this.turns.at(-1);
42
+ return last !== undefined && last.outcome === null;
43
+ }
44
+ apply(event) {
45
+ const key = event.uuid ?? `${this.#after}|${JSON.stringify(event)}`;
46
+ if (event.uuid)
47
+ this.#after = event.uuid;
48
+ if (this.#seen.has(key))
49
+ return;
50
+ this.#seen.add(key);
51
+ if (event.type === 'user' && event.isReplay) {
52
+ this.#open(event.uuid ?? `turn-${this.turns.length}`, said(event), stamp(event));
53
+ this.version += 1;
54
+ return;
55
+ }
56
+ if (!FOLDED.has(event.type))
57
+ return;
58
+ // Settled by its own `result`. A stream that broke mid-run is not: the
59
+ // run goes on, and a watch opened again folds the rest onto it.
60
+ const last = this.turns.at(-1);
61
+ const settled = last?.outcome != null && !last.outcome.unreachable;
62
+ // The watch broke while nothing was running, and no run failed.
63
+ if (event.type === 'library_error' && settled)
64
+ return;
65
+ // A run whose prompt was never echoed — a session started without the
66
+ // replay flag, or one that failed before it said anything — still has
67
+ // an answer to show; its question is simply not on the wire.
68
+ if (!last || (settled && RUN.has(event.type))) {
69
+ this.#open(`turn-${this.turns.length}`, '', undefined);
70
+ }
71
+ else if (last.outcome?.unreachable && RUN.has(event.type)) {
72
+ // The stream came back and the run is still going.
73
+ last.outcome = null;
74
+ }
75
+ fold(this.turns[this.turns.length - 1], this.#folds[this.#folds.length - 1], event);
76
+ this.version += 1;
77
+ }
78
+ #open(id, question, at) {
79
+ this.turns.push({
80
+ id,
81
+ question,
82
+ at,
83
+ blocks: [],
84
+ outcome: null,
85
+ citations: [],
86
+ suggestions: []
87
+ });
88
+ this.#folds.push(foldState());
89
+ }
90
+ }
91
+ /** The words of a replayed frame: a bare string, or its text blocks. */
92
+ function said(event) {
93
+ const content = event.message?.content;
94
+ if (typeof content === 'string')
95
+ return content;
96
+ return contentOf(event)
97
+ .filter((block) => block.type === 'text' && typeof block.text === 'string')
98
+ .map((block) => block.text)
99
+ .join('\n\n');
100
+ }
101
+ /** When the CLI consumed the frame, which is when this run began. */
102
+ function stamp(event) {
103
+ const at = event.timestamp ? Date.parse(event.timestamp) : NaN;
104
+ return Number.isNaN(at) ? undefined : at;
105
+ }