@poodle64/librarian 2026.9.18 → 2026.9.19

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}
@@ -30,7 +30,9 @@
30
30
  interface Props {
31
31
  value: string;
32
32
  running: boolean;
33
- scope: Scope;
33
+ /** What the next message is about. A surface with one scope — a
34
+ * session over one document — passes none and offers no choice. */
35
+ scope?: Scope;
34
36
  /** Files to send with the next question. Cleared by the caller on send. */
35
37
  files?: File[];
36
38
  /** Names for the two narrower scopes; absent means that scope is unavailable. */
@@ -38,7 +40,7 @@
38
40
  collectionName?: string;
39
41
  /** Hides the paperclip for a host whose ask route takes no files. */
40
42
  attachments?: boolean;
41
- onscope: (scope: Scope) => void;
43
+ onscope?: (scope: Scope) => void;
42
44
  onsubmit: () => void;
43
45
  onstop: () => void;
44
46
  /** Asks for a study artefact instead of an ordinary answer. Omit and no
@@ -50,6 +52,10 @@
50
52
  name?: string;
51
53
  /** Overrides for the package's own words. */
52
54
  copy?: Partial<LibrarianCopy>;
55
+ /** One quiet line in the box's footer, where a scope choice would
56
+ * sit, in the host's words: what sending does here ("It carries on
57
+ * from where it stopped."). */
58
+ note?: string;
53
59
  }
54
60
 
55
61
  let {
@@ -65,7 +71,8 @@
65
71
  onstop,
66
72
  onbriefing,
67
73
  name = DEFAULT_PERSONA,
68
- copy
74
+ copy,
75
+ note
69
76
  }: Props = $props();
70
77
 
71
78
  const words = $derived(resolveCopy(copy, name));
@@ -87,7 +94,7 @@
87
94
  // passing a name — no pick to make, so the row renders nothing rather
88
95
  // than a chip that only ever reselects itself (design-system, the
89
96
  // fixed-scope Composer defect).
90
- const hasChoice = $derived(choices.length > 1);
97
+ const hasChoice = $derived(scope !== undefined && choices.length > 1);
91
98
 
92
99
  // Three chips in a narrow column clipped all three to fragments
93
100
  // ("defence-s…", "All colle…"). Wrapping beats truncating: a chip a reader
@@ -266,12 +273,14 @@
266
273
  type="button"
267
274
  class="ds-lib-scope-chip"
268
275
  class:is-chosen={scope === choice.id}
269
- onclick={() => onscope(choice.id)}
276
+ onclick={() => onscope?.(choice.id)}
270
277
  >
271
278
  {choice.label}
272
279
  </button>
273
280
  {/each}
274
281
  </div>
282
+ {:else if note}
283
+ <span class="ds-lib-note">{note}</span>
275
284
  {/if}
276
285
  <span class="ds-lib-spacer"></span>
277
286
  {#if running}
@@ -502,6 +511,12 @@
502
511
  flex: 1;
503
512
  }
504
513
 
514
+ .ds-lib-note {
515
+ min-width: 0;
516
+ color: var(--ds-color-muted-foreground);
517
+ font-size: var(--ds-text-2xs);
518
+ }
519
+
505
520
  .ds-lib-rejected {
506
521
  margin: 0;
507
522
  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,10 @@ interface Props {
23
25
  name?: string;
24
26
  /** Overrides for the package's own words. */
25
27
  copy?: Partial<LibrarianCopy>;
28
+ /** One quiet line in the box's footer, where a scope choice would
29
+ * sit, in the host's words: what sending does here ("It carries on
30
+ * from where it stopped."). */
31
+ note?: string;
26
32
  }
27
33
  declare const Composer: import("svelte").Component<Props, {}, "value" | "files">;
28
34
  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
+ }