@poodle64/librarian 2026.9.10 → 2026.9.12

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/README.md CHANGED
@@ -24,11 +24,13 @@ src/lib/
24
24
  scope-statement/ what this room answers from, and what it does not hold
25
25
  agent-transcript/ one question and everything Milton did answering it
26
26
  document-pane/ the cited document, open at the cited passage
27
+ artefact-card/ a study artefact's card in the transcript
28
+ artefact-pane/ a study artefact, open in the reading column
27
29
  composer/ the input box: attachments, scope chips, send/stop
28
30
  markdown/ sanitised, streaming-safe markdown + highlighting
29
31
  activity-group/ a whole investigation, as one quiet line
30
32
  tool-row/ one tool call
31
- thinking-row/ one thinking block
33
+ thinking-row/ one thought, or one line of between-tool narration
32
34
  working/ the pre-first-token "something is happening" indicator
33
35
  ```
34
36
 
@@ -153,14 +155,14 @@ on it fires once and never again.
153
155
 
154
156
  | Concern | How |
155
157
  | ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
156
- | The ask route | `ask({ endpoint })`: a room's `/api/rooms/{id}/ask`, a caller's `/api/caller/ask` |
158
+ | The ask route | `ask({ endpoint })`: a room's `/api/rooms/{id}/ask`, a caller's `/api/caller/ask` |
157
159
  | Attachments | nothing: `ask()` posts multipart (`question`, `resume`, `collections[]`, `files[]`) whenever `files` is non-empty, and JSON when it is not. The route must accept both |
158
160
  | Reading a cited document | `loadDocument(document_id) => Promise<{title, sections: [{anchor, heading, text}]}>`, proxied through the app's own authenticated route (cadmus: `GET /api/sources/documents/{id}/content`) |
159
- | Asking again | `onregenerate`: re-send the last question as a NEW turn; the package exposes the action and never re-asks by itself |
161
+ | Asking again | `onregenerate`: re-send the last question as a NEW turn; the package exposes the action and never re-asks by itself |
160
162
  | Asking a follow-up | `onsuggest(question)`: ask it as a NEW turn. Without the handler the chips do not render at all — a chip that does nothing is worse than no chip |
161
163
  | The empty state | `welcome` and up to three `examples` |
162
164
  | What this surface covers | `scope`: one statement per room, in the host's own words. Rendered above the first turn and folded to a line once the conversation starts |
163
- | The words themselves | `copy`: a partial of `LibrarianCopy`. Every component resolves it itself, so overriding one line does not mean restating the rest |
165
+ | The words themselves | `copy`: a partial of `LibrarianCopy`. Every component resolves it itself, so overriding one line does not mean restating the rest |
164
166
 
165
167
  Nothing here fetches on its own behalf. The library's document read is
166
168
  authenticated, and a package that called it directly would be reaching past
@@ -231,6 +233,23 @@ one asks it through `onsuggest` and takes the whole row with it — the moment
231
233
  between the click and the new turn arriving is otherwise long enough to ask a
232
234
  second question by mistake.
233
235
 
236
+ ### Where the answer starts
237
+
238
+ Milton narrates between tool calls — "Let me also check whether…" — and the
239
+ caller stream gives that nowhere to arrive: it carries no `thinking` blocks
240
+ at all, so narration is an ordinary `text` block, identical to the answer
241
+ except in POSITION. `segment()` reads that position: a text block with any
242
+ tool call still to come in the turn is narration and folds into the activity
243
+ group as a thinking-shaped row; the run of text after the LAST tool call is
244
+ the answer. While a turn streams the judgement is provisional — a block that
245
+ is currently last renders as prose, and a tool call arriving after it
246
+ re-homes it — which is why `segment()` is pure and re-derived per event
247
+ rather than deciding once.
248
+
249
+ The cost is an answer Milton interrupts to go back to the shelf: its first
250
+ half folds away. Position is the only signal the stream gives, and a rule
251
+ read off the prose itself would be unexplainable the first time it misfired.
252
+
234
253
  ### Changing the words
235
254
 
236
255
  Every user-visible string this package renders lives in
@@ -271,15 +290,16 @@ pnpm run test # build + vitest
271
290
  pnpm run screenshots # the state grid, real engine (see below)
272
291
  ```
273
292
 
274
- `docs/screenshots/` is ten states x three widths x both themes, taken by
293
+ `docs/screenshots/` is eleven states x three widths x both themes, taken by
275
294
  `scripts/screenshots.mjs` against the console's `/librarian` lab route
276
295
  running from its own static build. The same script asserts what a screenshot
277
296
  cannot: that nothing scrolls sideways at any width, that the source pane
278
297
  opens and closes from the keyboard with focus returning to the chip and is
279
298
  really draggable, that the scope statement folds once there is a
280
- conversation over it and reopens from that line, and that a follow-up chip
281
- asks its question and takes the rest of the row with it. It exits non-zero
282
- on any of them.
299
+ conversation over it and reopens from that line, that a follow-up chip asks
300
+ its question and takes the rest of the row with it, and that Milton's
301
+ between-tool narration is nowhere in the answer prose before the activity
302
+ line is opened. It exits non-zero on any of them.
283
303
 
284
304
  ```bash
285
305
  pnpm --filter @poodle64/console run build
package/dist/client.d.ts CHANGED
@@ -57,6 +57,11 @@ export interface AskOptions {
57
57
  collections?: string[];
58
58
  /** Files Milton reads for this question. Switches the request to multipart. */
59
59
  files?: File[];
60
+ /** What kind of turn this ask should become. Absent means an ordinary
61
+ * question; a host that offers a study artefact sends its own kind
62
+ * (`'briefing'`) and interprets what comes back accordingly — the wire
63
+ * vocabulary is the host's own, never a fixed set here. */
64
+ kind?: string;
60
65
  signal?: AbortSignal;
61
66
  /** Where the ask lands. Each app mounts its own ask route. */
62
67
  endpoint?: string;
package/dist/client.js CHANGED
@@ -29,7 +29,8 @@ function requestInit(options, signal) {
29
29
  question: options.question,
30
30
  resume: options.resume ?? null,
31
31
  subtree: options.subtree ?? '',
32
- collections: options.collections ?? []
32
+ collections: options.collections ?? [],
33
+ kind: options.kind
33
34
  }),
34
35
  signal
35
36
  };
@@ -42,6 +43,8 @@ function requestInit(options, signal) {
42
43
  form.append('collections[]', collection);
43
44
  for (const file of options.files)
44
45
  form.append('files[]', file, file.name);
46
+ if (options.kind)
47
+ form.append('kind', options.kind);
45
48
  return { method: 'POST', credentials: 'include', body: form, signal };
46
49
  }
47
50
  /** Async-iterate the events of one question. */
@@ -7,6 +7,11 @@
7
7
  to mention. So there is exactly one row in every state — "Working…" while it
8
8
  runs, a count of what was done once it settles — and the steps are behind a
9
9
  disclosure for the reader who wants them.
10
+
11
+ That commentary is a step in here too. It reaches the caller as an ordinary
12
+ `text` block, not a `thinking` one, so `segment()` re-homes any text with a
13
+ tool call still to come into this group; it renders as a ThinkingRow beside
14
+ the tools, folded like the rest.
10
15
  -->
11
16
  <script lang="ts">
12
17
  import ChevronRightIcon from '@lucide/svelte/icons/chevron-right';
@@ -28,6 +28,7 @@
28
28
  import { resolveCopy, type LibrarianCopy } from '../../copy';
29
29
  import Working from '../working/working.svelte';
30
30
  import ActivityGroup from '../activity-group/activity-group.svelte';
31
+ import ArtefactCard from '../artefact-card/artefact-card.svelte';
31
32
  import Markdown from '../markdown/markdown.svelte';
32
33
 
33
34
  interface Props {
@@ -51,6 +52,14 @@
51
52
  * no chip. */
52
53
  onsuggest?: (question: string) => void;
53
54
  copy?: Partial<LibrarianCopy>;
55
+ /** A study artefact rather than an ordinary answer: once settled, this
56
+ * renders as a card instead of prose. */
57
+ kind?: 'answer' | 'artefact';
58
+ /** The artefact's own name, for the card. */
59
+ title?: string;
60
+ /** Opens the artefact in the reading column. Required wherever `kind`
61
+ * is `'artefact'`. */
62
+ onopenartefact?: () => void;
54
63
  }
55
64
 
56
65
  let {
@@ -64,9 +73,14 @@
64
73
  oncite,
65
74
  onregenerate,
66
75
  onsuggest,
67
- copy
76
+ copy,
77
+ kind = 'answer',
78
+ title,
79
+ onopenartefact
68
80
  }: Props = $props();
69
81
 
82
+ const isArtefact = $derived(kind === 'artefact');
83
+
70
84
  const words = $derived(resolveCopy(copy));
71
85
 
72
86
  const asked = $derived(readerQuestion(question));
@@ -181,7 +195,7 @@
181
195
  {#each segments as seg (seg.index)}
182
196
  {#if seg.kind === 'activity'}
183
197
  <ActivityGroup group={seg} live={running && seg.index === lastIndex} />
184
- {:else}
198
+ {:else if !isArtefact}
185
199
  <div class="max-w-[72ch] min-w-0">
186
200
  <Markdown
187
201
  content={seg.index === lastTextIndex && split.citations.length > 0
@@ -199,7 +213,11 @@
199
213
  {/if}
200
214
  {/each}
201
215
 
202
- {#if sources.length > 0}
216
+ {#if isArtefact && settled}
217
+ <ArtefactCard title={title ?? 'Briefing'} citationCount={sources.length} onopen={() => onopenartefact?.()} />
218
+ {/if}
219
+
220
+ {#if !isArtefact && sources.length > 0}
203
221
  <section class="max-w-[72ch]">
204
222
  <h2 class="text-muted-foreground mb-1.5 text-xs font-medium tracking-wide uppercase">
205
223
  {words.sources}
@@ -237,11 +255,11 @@
237
255
  </section>
238
256
  {/if}
239
257
 
240
- {#if notHeld}
258
+ {#if !isArtefact && notHeld}
241
259
  <p class="text-muted-foreground max-w-[72ch] text-sm">{words.notHeld}</p>
242
260
  {/if}
243
261
 
244
- {#if followUps.length > 0}
262
+ {#if !isArtefact && followUps.length > 0}
245
263
  <section class="max-w-[72ch]">
246
264
  <h2 class="text-muted-foreground mb-1.5 text-xs font-medium tracking-wide uppercase">
247
265
  {words.suggestions}
@@ -276,7 +294,7 @@
276
294
  </p>
277
295
  {/if}
278
296
 
279
- {#if settled}
297
+ {#if !isArtefact && settled}
280
298
  <div class="text-muted-foreground -ml-1.5 flex items-center gap-1">
281
299
  {#if hasAnswer}
282
300
  <button
@@ -22,6 +22,14 @@ interface Props {
22
22
  * no chip. */
23
23
  onsuggest?: (question: string) => void;
24
24
  copy?: Partial<LibrarianCopy>;
25
+ /** A study artefact rather than an ordinary answer: once settled, this
26
+ * renders as a card instead of prose. */
27
+ kind?: 'answer' | 'artefact';
28
+ /** The artefact's own name, for the card. */
29
+ title?: string;
30
+ /** Opens the artefact in the reading column. Required wherever `kind`
31
+ * is `'artefact'`. */
32
+ onopenartefact?: () => void;
25
33
  }
26
34
  declare const AgentTranscript: import("svelte").Component<Props, {}, "">;
27
35
  type AgentTranscript = ReturnType<typeof AgentTranscript>;
@@ -0,0 +1,40 @@
1
+ <!--
2
+ A study artefact's card in the transcript: what a colleague taps to read it.
3
+ It is not the artefact itself — that lives in the reading column
4
+ (`ArtefactPane`), never stacked beside this card and never inline as prose,
5
+ which is the whole of the one-surface decision this package exists to keep
6
+ every consumer honest about.
7
+ -->
8
+ <script lang="ts">
9
+ import FileTextIcon from '@lucide/svelte/icons/file-text';
10
+ import Panel from '@poodle64/ui/panel';
11
+
12
+ interface Props {
13
+ title: string;
14
+ citationCount: number;
15
+ onopen: () => void;
16
+ }
17
+
18
+ let { title, citationCount, onopen }: Props = $props();
19
+ </script>
20
+
21
+ <Panel
22
+ icon={FileTextIcon}
23
+ {title}
24
+ subtitle="Briefing doc"
25
+ role="button"
26
+ tabindex={0}
27
+ onclick={onopen}
28
+ onkeydown={(event: KeyboardEvent) => {
29
+ if (event.key !== 'Enter' && event.key !== ' ') return;
30
+ event.preventDefault();
31
+ onopen();
32
+ }}
33
+ class="hover:border-border-strong focus-visible:ring-ring w-full max-w-[28rem] cursor-pointer text-left transition-colors focus-visible:ring-2 focus-visible:outline-none"
34
+ >
35
+ {#snippet children()}
36
+ <p class="text-muted-foreground text-xs">
37
+ {citationCount} source{citationCount === 1 ? '' : 's'}
38
+ </p>
39
+ {/snippet}
40
+ </Panel>
@@ -0,0 +1,8 @@
1
+ interface Props {
2
+ title: string;
3
+ citationCount: number;
4
+ onopen: () => void;
5
+ }
6
+ declare const ArtefactCard: import("svelte").Component<Props, {}, "">;
7
+ type ArtefactCard = ReturnType<typeof ArtefactCard>;
8
+ export default ArtefactCard;
@@ -0,0 +1 @@
1
+ export { default } from './artefact-card.svelte';
@@ -0,0 +1 @@
1
+ export { default } from './artefact-card.svelte';
@@ -0,0 +1,120 @@
1
+ <!--
2
+ A study artefact, open in the reading column.
3
+
4
+ The same shell shape `DocumentPane` uses (a column at `lg`, a bottom sheet
5
+ below it) because decision 2 of the ask-milton surface is that a colleague
6
+ never has to learn which of the two things is in the column before they
7
+ open it — only ever one, in the same place, closing whichever was there.
8
+ -->
9
+ <script lang="ts">
10
+ import XIcon from '@lucide/svelte/icons/x';
11
+ import { segment, type TextBlock, type Turn } from '../../transcript.svelte';
12
+ import { resolveCitations, splitSources, trustMark, type Citation } from '../../citations';
13
+ import { resolveCopy, type LibrarianCopy } from '../../copy';
14
+ import Markdown from '../markdown/markdown.svelte';
15
+
16
+ interface Props {
17
+ turn: Turn;
18
+ onclose: () => void;
19
+ /** Opens the cited document instead — swapping the column back, never
20
+ * stacking it beside the artefact. Omit and the sources still list, but
21
+ * do not open. */
22
+ oncite?: (citation: Citation) => void;
23
+ copy?: Partial<LibrarianCopy>;
24
+ }
25
+
26
+ let { turn, onclose, oncite, copy }: Props = $props();
27
+
28
+ const words = $derived(resolveCopy(copy));
29
+
30
+ const texts = $derived(segment(turn.blocks).filter((s): s is TextBlock => s.kind === 'text'));
31
+ const rawAnswer = $derived(texts.map((t) => t.text).join('\n\n'));
32
+ const split = $derived(splitSources(rawAnswer));
33
+ const sources = $derived(resolveCitations(turn.citations ?? [], split.citations));
34
+
35
+ let closeButton = $state<HTMLButtonElement | null>(null);
36
+
37
+ // Opening a pane a reader reached with the keyboard must move focus into
38
+ // it, or Escape and Tab both act on the transcript behind it.
39
+ $effect(() => {
40
+ const returnTo = globalThis.document?.activeElement as HTMLElement | null;
41
+ closeButton?.focus();
42
+ return () => returnTo?.focus?.();
43
+ });
44
+
45
+ function keydown(event: KeyboardEvent) {
46
+ if (event.key === 'Escape') {
47
+ event.stopPropagation();
48
+ onclose();
49
+ }
50
+ }
51
+ </script>
52
+
53
+ <svelte:window onkeydown={keydown} />
54
+
55
+ <aside
56
+ aria-label="Study artefact"
57
+ class="bg-surface-1 border-border fixed inset-x-0 bottom-0 z-40 flex h-[80svh] flex-col rounded-t-2xl border-t shadow-2xl lg:relative lg:h-auto lg:w-[560px] lg:shrink-0 lg:rounded-none lg:border-t-0 lg:border-l lg:shadow-none"
58
+ >
59
+ <!-- The sheet's grabber. Below `lg` this is an overlay a reader has to be
60
+ able to see the top edge of; on a desktop it is a column, so there is
61
+ nothing here to grab. -->
62
+ <div class="flex justify-center pt-2 pb-1 lg:hidden" aria-hidden="true">
63
+ <span class="bg-border h-1 w-9 rounded-full"></span>
64
+ </div>
65
+
66
+ <header class="border-border flex items-start gap-2 border-b px-4 py-3 lg:pt-3">
67
+ <div class="min-w-0 flex-1">
68
+ <h2 class="text-foreground truncate text-sm font-semibold">{turn.title ?? 'Briefing'}</h2>
69
+ <p class="text-muted-foreground truncate text-xs">
70
+ Briefing doc · {sources.length} source{sources.length === 1 ? '' : 's'}
71
+ </p>
72
+ </div>
73
+ <button
74
+ bind:this={closeButton}
75
+ type="button"
76
+ onclick={onclose}
77
+ aria-label={words.closeSource}
78
+ class="text-muted-foreground hover:text-foreground hover:bg-surface-2 focus-visible:ring-ring -mt-1 flex size-8 shrink-0 items-center justify-center rounded-lg transition-colors focus-visible:ring-2 focus-visible:outline-none"
79
+ >
80
+ <XIcon class="size-4" />
81
+ </button>
82
+ </header>
83
+
84
+ <div class="min-h-0 flex-1 overflow-y-auto overscroll-contain px-4 py-3">
85
+ <Markdown content={split.citations.length > 0 ? split.body : rawAnswer} />
86
+
87
+ {#if sources.length > 0}
88
+ <section class="mt-6">
89
+ <h3 class="text-muted-foreground mb-1.5 text-xs font-medium tracking-wide uppercase">
90
+ {words.sources}
91
+ </h3>
92
+ <ol class="flex flex-col gap-1">
93
+ {#each sources as source (source.n)}
94
+ {@const mark = trustMark(source, words)}
95
+ <li>
96
+ <button
97
+ type="button"
98
+ onclick={() => oncite?.(source)}
99
+ disabled={!source.document_id || !oncite}
100
+ class="border-border hover:border-border-strong hover:bg-surface-2 focus-visible:ring-ring flex w-full items-baseline gap-2 rounded-lg border px-2.5 py-1.5 text-left text-sm transition-colors focus-visible:ring-2 focus-visible:outline-none disabled:cursor-default disabled:hover:bg-transparent"
101
+ >
102
+ <span
103
+ class="bg-primary/15 text-foreground shrink-0 rounded px-1.5 font-mono text-xs tabular-nums"
104
+ >{source.n}</span
105
+ >
106
+ <span class="min-w-0">
107
+ <span class="text-foreground">{source.title}</span>
108
+ {#if source.section}<span class="text-muted-foreground">
109
+ · {source.section}</span
110
+ >{/if}
111
+ {#if mark}<span class="text-muted-foreground/80"> · {mark}</span>{/if}
112
+ </span>
113
+ </button>
114
+ </li>
115
+ {/each}
116
+ </ol>
117
+ </section>
118
+ {/if}
119
+ </div>
120
+ </aside>
@@ -0,0 +1,15 @@
1
+ import { type Turn } from '../../transcript.svelte';
2
+ import { type Citation } from '../../citations';
3
+ import { type LibrarianCopy } from '../../copy';
4
+ interface Props {
5
+ turn: Turn;
6
+ onclose: () => void;
7
+ /** Opens the cited document instead — swapping the column back, never
8
+ * stacking it beside the artefact. Omit and the sources still list, but
9
+ * do not open. */
10
+ oncite?: (citation: Citation) => void;
11
+ copy?: Partial<LibrarianCopy>;
12
+ }
13
+ declare const ArtefactPane: import("svelte").Component<Props, {}, "">;
14
+ type ArtefactPane = ReturnType<typeof ArtefactPane>;
15
+ export default ArtefactPane;
@@ -0,0 +1 @@
1
+ export { default } from './artefact-pane.svelte';
@@ -0,0 +1 @@
1
+ export { default } from './artefact-pane.svelte';
@@ -12,6 +12,7 @@
12
12
  -->
13
13
  <script lang="ts">
14
14
  import ArrowUpIcon from '@lucide/svelte/icons/arrow-up';
15
+ import FileTextIcon from '@lucide/svelte/icons/file-text';
15
16
  import PaperclipIcon from '@lucide/svelte/icons/paperclip';
16
17
  import SquareIcon from '@lucide/svelte/icons/square';
17
18
  import XIcon from '@lucide/svelte/icons/x';
@@ -39,6 +40,10 @@
39
40
  onscope: (scope: Scope) => void;
40
41
  onsubmit: () => void;
41
42
  onstop: () => void;
43
+ /** Asks for a study artefact instead of an ordinary answer. Omit and no
44
+ * control renders — studying is a mode of asking, never a tab, so a
45
+ * host with nothing to build offers none rather than a disabled one. */
46
+ onbriefing?: () => void;
42
47
  }
43
48
 
44
49
  let {
@@ -51,7 +56,8 @@
51
56
  attachments = true,
52
57
  onscope,
53
58
  onsubmit,
54
- onstop
59
+ onstop,
60
+ onbriefing
55
61
  }: Props = $props();
56
62
 
57
63
  // What Milton is being asked about — this document, this collection, or
@@ -235,6 +241,19 @@
235
241
  </button>
236
242
  {/if}
237
243
 
244
+ {#if onbriefing}
245
+ <button
246
+ type="button"
247
+ onclick={onbriefing}
248
+ disabled={running}
249
+ aria-label="Ask for a briefing"
250
+ title="Ask for a briefing"
251
+ class="text-muted-foreground hover:text-foreground hover:bg-surface-2 focus-visible:ring-ring flex size-8 shrink-0 items-center justify-center rounded-full transition-colors focus-visible:ring-2 focus-visible:outline-none disabled:opacity-30"
252
+ >
253
+ <FileTextIcon class="size-4" />
254
+ </button>
255
+ {/if}
256
+
238
257
  {#if hasChoice}
239
258
  <div class="flex min-w-0 flex-wrap items-center gap-1">
240
259
  {#each choices as choice (choice.id)}
@@ -13,6 +13,10 @@ interface Props {
13
13
  onscope: (scope: Scope) => void;
14
14
  onsubmit: () => void;
15
15
  onstop: () => void;
16
+ /** Asks for a study artefact instead of an ordinary answer. Omit and no
17
+ * control renders — studying is a mode of asking, never a tab, so a
18
+ * host with nothing to build offers none rather than a disabled one. */
19
+ onbriefing?: () => void;
16
20
  }
17
21
  declare const Composer: import("svelte").Component<Props, {}, "value" | "files">;
18
22
  type Composer = ReturnType<typeof Composer>;
@@ -16,9 +16,15 @@
16
16
  import { resolveCopy, type LibrarianCopy } from '../../copy';
17
17
  import { FollowScroll } from '../../follow-scroll.svelte';
18
18
  import AgentTranscript from '../agent-transcript/agent-transcript.svelte';
19
+ import ArtefactPane from '../artefact-pane/artefact-pane.svelte';
19
20
  import DocumentPane from '../document-pane/document-pane.svelte';
20
21
  import ScopeStatement from '../scope-statement/scope-statement.svelte';
21
22
 
23
+ /** What the reading column holds — a cited document or a study artefact,
24
+ * never both and never two panes: whichever the reader last opened
25
+ * replaces whatever was there. */
26
+ type Column = { kind: 'document'; citation: Citation } | { kind: 'artefact'; turn: Turn } | null;
27
+
22
28
  interface Props {
23
29
  turns: Turn[];
24
30
  running: boolean;
@@ -68,7 +74,7 @@
68
74
  const words = $derived(resolveCopy(copy));
69
75
 
70
76
  let viewport = $state<HTMLElement | null>(null);
71
- let open = $state<Citation | null>(null);
77
+ let column = $state<Column>(null);
72
78
  const follow = new FollowScroll();
73
79
 
74
80
  function metrics(el: HTMLElement) {
@@ -113,8 +119,13 @@
113
119
 
114
120
  function cite(citation: Citation) {
115
121
  // A citation derived from the prose has no id to read, so its chip is
116
- // legible but inert rather than opening an empty pane.
117
- if (loadDocument && citation.document_id) open = citation;
122
+ // legible but inert rather than opening an empty pane. Replaces
123
+ // whatever the column held, artefact included — never a second pane.
124
+ if (loadDocument && citation.document_id) column = { kind: 'document', citation };
125
+ }
126
+
127
+ function openArtefact(turn: Turn) {
128
+ column = { kind: 'artefact', turn };
118
129
  }
119
130
  </script>
120
131
 
@@ -173,6 +184,9 @@
173
184
  suggestions={turn.suggestions ?? []}
174
185
  {collectionNames}
175
186
  {copy}
187
+ kind={turn.kind}
188
+ title={turn.title}
189
+ onopenartefact={() => openArtefact(turn)}
176
190
  oncite={cite}
177
191
  onregenerate={index === turns.length - 1 && !running ? onregenerate : undefined}
178
192
  onsuggest={index === turns.length - 1 && !running ? onsuggest : undefined}
@@ -201,7 +215,14 @@
201
215
 
202
216
  </div>
203
217
 
204
- {#if open && loadDocument}
205
- <DocumentPane citation={open} {loadDocument} {copy} onclose={() => (open = null)} />
218
+ {#if column?.kind === 'document' && loadDocument}
219
+ <DocumentPane citation={column.citation} {loadDocument} {copy} onclose={() => (column = null)} />
220
+ {:else if column?.kind === 'artefact'}
221
+ <ArtefactPane
222
+ turn={column.turn}
223
+ {copy}
224
+ onclose={() => (column = null)}
225
+ oncite={(citation) => cite(citation)}
226
+ />
206
227
  {/if}
207
228
  </div>
@@ -1,9 +1,20 @@
1
+ <!--
2
+ One line of Milton's working-out, behind a chevron.
3
+
4
+ Two different blocks render as this row and deliberately read the same. A
5
+ real `thinking` block is one; the other is a `text` block Milton wrote
6
+ BETWEEN two tool calls — "Let me also check whether…" — which the caller
7
+ stream gives no way to tell from the answer except by position, and which
8
+ read as answer prose on production until `segment()` started re-homing it
9
+ here. To a reader both are the same thing: what he was working through, not
10
+ what he concluded. So both fold, and both fold by default.
11
+ -->
1
12
  <script lang="ts">
2
13
  import ChevronRightIcon from '@lucide/svelte/icons/chevron-right';
3
- import type { ThinkingBlock } from '../../transcript.svelte';
14
+ import type { TextBlock, ThinkingBlock } from '../../transcript.svelte';
4
15
 
5
16
  interface Props {
6
- block: ThinkingBlock;
17
+ block: ThinkingBlock | TextBlock;
7
18
  active: boolean;
8
19
  }
9
20
 
@@ -1,6 +1,6 @@
1
- import type { ThinkingBlock } from '../../transcript.svelte';
1
+ import type { TextBlock, ThinkingBlock } from '../../transcript.svelte';
2
2
  interface Props {
3
- block: ThinkingBlock;
3
+ block: ThinkingBlock | TextBlock;
4
4
  active: boolean;
5
5
  }
6
6
  declare const ThinkingRow: import("svelte").Component<Props, {}, "">;
@@ -91,8 +91,11 @@ export declare class Transcript {
91
91
  apply(event: AgentEvent): void;
92
92
  }
93
93
  export interface ActivityStep {
94
- /** The block this step stands for; a repeated step keeps the FIRST. */
95
- block: ToolBlock | ThinkingBlock;
94
+ /** The block this step stands for; a repeated step keeps the FIRST.
95
+ *
96
+ * A `text` block here is interstitial narration, not the answer — see
97
+ * `segment()`. */
98
+ block: Block;
96
99
  /** How many identical consecutive steps collapsed into this one. */
97
100
  repeats: number;
98
101
  }
@@ -120,11 +123,17 @@ export interface Turn {
120
123
  /** Follow-ups offered after this answer. Absent on a turn read back from
121
124
  * history: they belonged to the moment it was asked. */
122
125
  suggestions?: string[];
126
+ /** A study artefact rather than an ordinary answer: renders as a card once
127
+ * settled, never as prose, and opens the reading column instead of a
128
+ * citation. Absent (or `'answer'`) is every ordinary turn. */
129
+ kind?: 'answer' | 'artefact';
130
+ /** The artefact's own name. Only meaningful when `kind` is `'artefact'`. */
131
+ title?: string;
123
132
  }
124
133
  /**
125
134
  * Fold a turn's flat block list into what a reader should actually see.
126
135
  *
127
- * Two problems this solves, both reported off a real transcript:
136
+ * Three problems this solves, all reported off a real transcript:
128
137
  *
129
138
  * 1. Fifteen tool rows stood between the question and the first word of the
130
139
  * answer, so the answer had to be scrolled to. Contiguous activity becomes
@@ -132,6 +141,25 @@ export interface Turn {
132
141
  * 2. "Reading pspf guidelines 2026" appeared five times in a row — five pages
133
142
  * of one document, which is one act of reading to a human. Consecutive
134
143
  * steps with the same label collapse to one row carrying a count.
144
+ * 3. Milton's between-tool narration was rendering unfolded, as answer prose.
145
+ * The caller stream carries no `thinking` blocks at all — measured on
146
+ * production 11/09/2026 — so "Let me also check whether…" arrives as an
147
+ * ordinary `text` block, indistinguishable from the answer except by
148
+ * POSITION. A text block with any tool call still to come in this turn is
149
+ * narration; only the run of text after the LAST tool call is the answer.
150
+ * Narration folds into the activity group as a thinking-shaped step.
151
+ *
152
+ * That third rule is provisional while a turn streams, and deliberately so: a
153
+ * text block that is currently last IS the answer as far as anything can know,
154
+ * and renders as prose. The tool call that arrives after it re-homes it into
155
+ * the group. Nothing here holds state to make that work — `segment` is pure
156
+ * and re-derived on every event, so re-homing is just the next call returning
157
+ * a different shape.
158
+ *
159
+ * The cost of the rule is a real answer that Milton interrupts to go back to
160
+ * the shelf: its first half folds away. That is the trade taken knowingly —
161
+ * the position of a block is the only signal the stream gives, and a rule read
162
+ * off the prose itself would be unexplainable the first time it misfired.
135
163
  */
136
164
  export declare function segment(blocks: Block[]): Segment[];
137
165
  /** One line describing a whole investigation, for the collapsed state.
@@ -267,7 +267,7 @@ function renderResult(content) {
267
267
  /**
268
268
  * Fold a turn's flat block list into what a reader should actually see.
269
269
  *
270
- * Two problems this solves, both reported off a real transcript:
270
+ * Three problems this solves, all reported off a real transcript:
271
271
  *
272
272
  * 1. Fifteen tool rows stood between the question and the first word of the
273
273
  * answer, so the answer had to be scrolled to. Contiguous activity becomes
@@ -275,20 +275,44 @@ function renderResult(content) {
275
275
  * 2. "Reading pspf guidelines 2026" appeared five times in a row — five pages
276
276
  * of one document, which is one act of reading to a human. Consecutive
277
277
  * steps with the same label collapse to one row carrying a count.
278
+ * 3. Milton's between-tool narration was rendering unfolded, as answer prose.
279
+ * The caller stream carries no `thinking` blocks at all — measured on
280
+ * production 11/09/2026 — so "Let me also check whether…" arrives as an
281
+ * ordinary `text` block, indistinguishable from the answer except by
282
+ * POSITION. A text block with any tool call still to come in this turn is
283
+ * narration; only the run of text after the LAST tool call is the answer.
284
+ * Narration folds into the activity group as a thinking-shaped step.
285
+ *
286
+ * That third rule is provisional while a turn streams, and deliberately so: a
287
+ * text block that is currently last IS the answer as far as anything can know,
288
+ * and renders as prose. The tool call that arrives after it re-homes it into
289
+ * the group. Nothing here holds state to make that work — `segment` is pure
290
+ * and re-derived on every event, so re-homing is just the next call returning
291
+ * a different shape.
292
+ *
293
+ * The cost of the rule is a real answer that Milton interrupts to go back to
294
+ * the shelf: its first half folds away. That is the trade taken knowingly —
295
+ * the position of a block is the only signal the stream gives, and a rule read
296
+ * off the prose itself would be unexplainable the first time it misfired.
278
297
  */
279
298
  export function segment(blocks) {
280
299
  const out = [];
281
300
  let current = null;
282
- for (const block of blocks) {
283
- if (block.kind === 'text') {
301
+ let lastTool = -1;
302
+ for (let i = 0; i < blocks.length; i += 1)
303
+ if (blocks[i].kind === 'tool')
304
+ lastTool = i;
305
+ for (const [i, block] of blocks.entries()) {
306
+ if (block.kind === 'text' && i > lastTool) {
284
307
  current = null;
285
308
  out.push(block);
286
309
  continue;
287
310
  }
288
311
  // A thinking block with no text is a row whose chevron opens on nothing.
289
312
  // Claude Code's thinking display defaults to "omitted", so most arrive
290
- // empty — rendering them is worse than dropping them.
291
- if (block.kind === 'thinking' && !block.text.trim())
313
+ // empty — rendering them is worse than dropping them. An empty narration
314
+ // block is the same row, for the same reason.
315
+ if (block.kind !== 'tool' && !block.text.trim())
292
316
  continue;
293
317
  if (!current) {
294
318
  current = {
@@ -318,6 +342,10 @@ function sameStep(a, b) {
318
342
  return false;
319
343
  if (a.kind === 'thinking')
320
344
  return true;
345
+ // Two narration sentences are two things Milton said; collapsing them to
346
+ // one row with a count would lose the second one entirely.
347
+ if (a.kind === 'text')
348
+ return false;
321
349
  const left = describe(a);
322
350
  const right = describe(b);
323
351
  return left.verb === right.verb && left.object === right.object;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@poodle64/librarian",
3
- "version": "2026.9.10",
3
+ "version": "2026.9.12",
4
4
  "description": "Milton's conversation surface as a consumable Svelte 5 package: the stream client, transcript state and chat components (transcript, composer, markdown) every household app renders instead of rebuilding.",
5
5
  "type": "module",
6
6
  "license": "MIT",