@poodle64/librarian 2026.9.8 → 2026.9.10

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
@@ -14,12 +14,14 @@ Change it here, consume it there.
14
14
  src/lib/
15
15
  client.ts ask(): streams Claude Code's OWN events, unaltered
16
16
  transcript.svelte.ts Transcript state, the fold/segment/describe helpers
17
- citations.ts the Citation shape, `[n]` markers, "## Sources"
17
+ citations.ts the Citation shape, `[n]` markers, the trust mark
18
+ copy.ts every word this package says, and the host's overrides
18
19
  attachments.ts what a reader may attach, and the limits
19
20
  follow-scroll.svelte.ts follow the stream until the reader disagrees
20
21
  history.svelte.ts the browser-held conversation list, per caller
21
22
  components/
22
23
  conversation/ the whole reading surface: scroll, pill, pane, composer slot
24
+ scope-statement/ what this room answers from, and what it does not hold
23
25
  agent-transcript/ one question and everything Milton did answering it
24
26
  document-pane/ the cited document, open at the cited passage
25
27
  composer/ the input box: attachments, scope chips, send/stop
@@ -99,6 +101,7 @@ gives it a height and a composer and nothing else:
99
101
  live.blocks = transcript.blocks;
100
102
  live.outcome = transcript.outcome;
101
103
  live.citations = transcript.citations;
104
+ live.suggestions = transcript.suggestions;
102
105
  }
103
106
  }
104
107
  } finally {
@@ -123,6 +126,8 @@ gives it a height and a composer and nothing else:
123
126
  examples={['How much recreation leave do I get?']}
124
127
  onexample={(q) => (question = q)}
125
128
  onregenerate={() => run(asked, [])}
129
+ onsuggest={(q) => run(q, [])}
130
+ scope="Milton answers from the ADF Pay and Conditions Manual (PACMAN). He does not hold your own pay records or anything about your individual case."
126
131
  {loadDocument}
127
132
  >
128
133
  {#snippet composer()}
@@ -152,7 +157,10 @@ on it fires once and never again.
152
157
  | 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 |
153
158
  | 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`) |
154
159
  | Asking again | `onregenerate`: re-send the last question as a NEW turn; the package exposes the action and never re-asks by itself |
160
+ | 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 |
155
161
  | The empty state | `welcome` and up to three `examples` |
162
+ | 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 |
156
164
 
157
165
  Nothing here fetches on its own behalf. The library's document read is
158
166
  authenticated, and a package that called it directly would be reaching past
@@ -166,7 +174,15 @@ The library emits one SSE frame after the final assistant text:
166
174
  {
167
175
  "type": "citations",
168
176
  "items": [
169
- { "n": 1, "document_id": "…", "title": "…", "section": "…", "anchor": "…", "snippet": "…" }
177
+ {
178
+ "n": 1,
179
+ "document_id": "…",
180
+ "title": "…",
181
+ "section": "…",
182
+ "anchor": "…",
183
+ "snippet": "…",
184
+ "verified_at": "2026-06-23"
185
+ }
170
186
  ]
171
187
  }
172
188
  ```
@@ -177,6 +193,57 @@ section. Until that frame ships everywhere, the same chips are DERIVED from
177
193
  a trailing "## Sources" block in the answer: those render and read, and are
178
194
  inert, because a title and a section are not an id.
179
195
 
196
+ ### The trust mark
197
+
198
+ `verified_at` is the date the last recheck found the document unchanged at
199
+ its publisher, `YYYY-MM-DD`, resolved by the library from its own catalogue —
200
+ never from what the model wrote, because a trust mark a model can author is
201
+ not a trust mark. Null means nothing has ever confirmed it.
202
+
203
+ Both the source list and the pane render it in words: **verified 23 Jun 2026**
204
+ or **not verified**, in the same muted register as the section beside it. An
205
+ unverified source is not an error and is not dressed as one — the words carry
206
+ the difference, and a red one would have a colleague discount a document that
207
+ is simply new.
208
+
209
+ A DERIVED citation carries no mark at all. It has no catalogued document
210
+ behind it, so "not verified" would be a claim about a record nothing here
211
+ ever read.
212
+
213
+ An answer that settles having cited nothing says so, once, under the prose:
214
+ "Milton answered this one without a source. He may not hold a document that
215
+ covers it." Only on a turn whose stream this surface actually watched finish
216
+ — a turn read back out of `createHistory()` has no citations because history
217
+ stores none, and labelling it as holding nothing would be a lie about an
218
+ answer that may have cited three documents.
219
+
220
+ ### Follow-ups
221
+
222
+ One more frame follows the citations, and only when the librarian named any:
223
+
224
+ ```json
225
+ { "type": "suggestions", "items": ["Can I carry leave over when I post?"] }
226
+ ```
227
+
228
+ At most three, each short enough to fit a chip. `Transcript.suggestions`
229
+ carries them; `Conversation` offers them under the LAST answer, and clicking
230
+ one asks it through `onsuggest` and takes the whole row with it — the moment
231
+ between the click and the new turn arriving is otherwise long enough to ask a
232
+ second question by mistake.
233
+
234
+ ### Changing the words
235
+
236
+ Every user-visible string this package renders lives in
237
+ `@poodle64/librarian/copy`, and every component takes a partial of it:
238
+
239
+ ```svelte
240
+ <Conversation {turns} {running} copy={{ notVerified: 'not checked yet' }} />
241
+ ```
242
+
243
+ Overriding one line leaves the rest as the package wrote them, and a key
244
+ passed as `undefined` — the shape a host produces from state that has not
245
+ loaded — is ignored rather than rendering nothing where a word belongs.
246
+
180
247
  ### The system preamble
181
248
 
182
249
  A host prepends its own instruction to every question (cadmus sends
@@ -204,12 +271,15 @@ pnpm run test # build + vitest
204
271
  pnpm run screenshots # the state grid, real engine (see below)
205
272
  ```
206
273
 
207
- `docs/screenshots/` is eight states x three widths x both themes, taken by
274
+ `docs/screenshots/` is ten states x three widths x both themes, taken by
208
275
  `scripts/screenshots.mjs` against the console's `/librarian` lab route
209
276
  running from its own static build. The same script asserts what a screenshot
210
- cannot: that nothing scrolls sideways at any width, and that the source pane
211
- opens and closes from the keyboard with focus returning to the chip. It
212
- exits non-zero on either.
277
+ cannot: that nothing scrolls sideways at any width, that the source pane
278
+ opens and closes from the keyboard with focus returning to the chip and is
279
+ 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.
213
283
 
214
284
  ```bash
215
285
  pnpm --filter @poodle64/console run build
@@ -221,17 +291,6 @@ pnpm --filter @poodle64/librarian run screenshots
221
291
  1. Change a component; bump `version` in `package.json` (CalVer).
222
292
  2. `pnpm build`, which runs `svelte-package` then `publint`.
223
293
  3. Commit, tag `librarian-v<version>`, push the tag.
224
- 4. `.github/workflows/publish.yaml` runs on that push and should publish via
225
- npm OIDC trusted publishing: check it with
226
- `gh run list --workflow=publish.yaml`. As of 2026.9.5 every run since
227
- `ui-v2026.9.2` fails at the `npm publish` step with
228
- `E404 Not Found - PUT .../@poodle64%2flibrarian` (an OIDC/trusted-publisher
229
- binding issue, since `Build and test package` passes first). Until that
230
- is diagnosed and fixed, publish manually from `packages/librarian/`:
231
- ```bash
232
- printf '//registry.npmjs.org/:_authToken=${NODE_AUTH_TOKEN}\n' > .npmrc
233
- signet exec --identity huginn-claude --broker https://portcullis.godswood.au \
234
- --credential npm-publish-token --env-var NODE_AUTH_TOKEN -- npm publish
235
- rm -f .npmrc
236
- ```
237
- Confirm with `npm view @poodle64/librarian version`.
294
+ 4. `.github/workflows/publish.yaml` runs on that push and publishes through
295
+ npm's trusted publisher. Watch it: `gh run list --workflow=publish.yaml`,
296
+ then `npm view @poodle64/librarian version`.
@@ -8,6 +8,7 @@
8
8
  * a title and a section but no id: those chips render and read, and cannot
9
9
  * open a pane. A derived citation is marked `derived` so the UI can tell.
10
10
  */
11
+ import type { LibrarianCopy } from './copy';
11
12
  export interface Citation {
12
13
  n: number;
13
14
  document_id: string;
@@ -15,6 +16,16 @@ export interface Citation {
15
16
  section?: string;
16
17
  anchor?: string;
17
18
  snippet?: string;
19
+ /**
20
+ * The date the last recheck found this document unchanged at its
21
+ * publisher, `YYYY-MM-DD`. Null when nothing has ever confirmed it.
22
+ *
23
+ * The library resolves this from its own catalogue, never from what the
24
+ * model wrote — a trust mark a model can author is not a trust mark — so
25
+ * a DERIVED citation carries none at all, and absent is not the same fact
26
+ * as null.
27
+ */
28
+ verified_at?: string | null;
18
29
  /** True when this came from the prose block rather than the wire event. */
19
30
  derived?: boolean;
20
31
  }
@@ -50,6 +61,30 @@ export declare function splitSources(markdown: string): SplitAnswer;
50
61
  * ids attached — so a turn never shows both.
51
62
  */
52
63
  export declare function resolveCitations(fromEvent: Citation[], fromProse: Citation[]): Citation[];
64
+ /**
65
+ * `2026-06-23` as `23 Jun 2026`, or null if it is not a date.
66
+ *
67
+ * Read off the string rather than through `new Date()`: a date-only string is
68
+ * parsed as UTC midnight and then RENDERED in the reader's own zone, so every
69
+ * reader west of Greenwich would be shown the day before the one the library
70
+ * recorded. There is no time here to convert — a date is what was stored.
71
+ *
72
+ * `Intl` is not used either: `en-AU` abbreviates June as "June", so the mark
73
+ * would change length month to month for no reason a reader benefits from.
74
+ */
75
+ export declare function formatVerified(iso: string): string | null;
76
+ /**
77
+ * How current this source is, in words a reader already has.
78
+ *
79
+ * Three outcomes, and the third is the one worth stating: a citation the
80
+ * package DERIVED from a "## Sources" block gets no mark at all. It has no
81
+ * catalogued document behind it, so "not verified" would be a claim about a
82
+ * record we never read — and a wrong trust mark is worse than none.
83
+ *
84
+ * A `verified_at` that is present but not a date is treated as no date, for
85
+ * the same reason: what is shown must be what is known.
86
+ */
87
+ export declare function trustMark(citation: Citation, copy: LibrarianCopy): string | null;
53
88
  /** One addressable slice of a document — the granularity a citation names. */
54
89
  export interface DocumentSection {
55
90
  anchor: string;
package/dist/citations.js CHANGED
@@ -100,3 +100,46 @@ function parseSource(text) {
100
100
  export function resolveCitations(fromEvent, fromProse) {
101
101
  return fromEvent.length > 0 ? fromEvent : fromProse;
102
102
  }
103
+ const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
104
+ /**
105
+ * `2026-06-23` as `23 Jun 2026`, or null if it is not a date.
106
+ *
107
+ * Read off the string rather than through `new Date()`: a date-only string is
108
+ * parsed as UTC midnight and then RENDERED in the reader's own zone, so every
109
+ * reader west of Greenwich would be shown the day before the one the library
110
+ * recorded. There is no time here to convert — a date is what was stored.
111
+ *
112
+ * `Intl` is not used either: `en-AU` abbreviates June as "June", so the mark
113
+ * would change length month to month for no reason a reader benefits from.
114
+ */
115
+ export function formatVerified(iso) {
116
+ const match = /^(\d{4})-(\d{2})-(\d{2})/.exec(iso.trim());
117
+ if (!match)
118
+ return null;
119
+ const [, year, month, day] = match;
120
+ const index = Number(month) - 1;
121
+ const date = Number(day);
122
+ // Round-tripped rather than bounds-checked: 1-31 admits "31 Apr 2026",
123
+ // and a mark that shows a day that never happened is worse than no mark.
124
+ const made = new Date(Date.UTC(Number(year), index, date));
125
+ if (made.getUTCMonth() !== index || made.getUTCDate() !== date)
126
+ return null;
127
+ return `${date} ${MONTHS[index]} ${year}`;
128
+ }
129
+ /**
130
+ * How current this source is, in words a reader already has.
131
+ *
132
+ * Three outcomes, and the third is the one worth stating: a citation the
133
+ * package DERIVED from a "## Sources" block gets no mark at all. It has no
134
+ * catalogued document behind it, so "not verified" would be a claim about a
135
+ * record we never read — and a wrong trust mark is worse than none.
136
+ *
137
+ * A `verified_at` that is present but not a date is treated as no date, for
138
+ * the same reason: what is shown must be what is known.
139
+ */
140
+ export function trustMark(citation, copy) {
141
+ if (citation.derived)
142
+ return null;
143
+ const when = citation.verified_at ? formatVerified(citation.verified_at) : null;
144
+ return when ? `${copy.verified} ${when}` : copy.notVerified;
145
+ }
package/dist/client.d.ts CHANGED
@@ -40,8 +40,13 @@ export interface AgentEvent {
40
40
  detail?: string;
41
41
  tools?: string[];
42
42
  model?: string;
43
- /** `citations` frames only: the library's sources for the answer just sent. */
44
- items?: Citation[];
43
+ /**
44
+ * The payload of the library's own two trailing frames, which share a key
45
+ * and not a shape: `citations` carries sources, `suggestions` carries
46
+ * follow-up questions as plain strings. Narrowed on `type` by whoever
47
+ * reads it (`Transcript.apply`).
48
+ */
49
+ items?: Citation[] | string[];
45
50
  [key: string]: unknown;
46
51
  }
47
52
  export interface AskOptions {
@@ -22,8 +22,10 @@
22
22
  citationMarkers,
23
23
  resolveCitations,
24
24
  splitSources,
25
+ trustMark,
25
26
  type Citation
26
27
  } from '../../citations';
28
+ import { resolveCopy, type LibrarianCopy } from '../../copy';
27
29
  import Working from '../working/working.svelte';
28
30
  import ActivityGroup from '../activity-group/activity-group.svelte';
29
31
  import Markdown from '../markdown/markdown.svelte';
@@ -38,10 +40,17 @@
38
40
  citations?: Citation[];
39
41
  /** Forwarded to Markdown; omit if the caller has no collections to chip. */
40
42
  collectionNames?: Set<string>;
43
+ /** Follow-ups from the library's `suggestions` frame. */
44
+ suggestions?: string[];
41
45
  /** Opens the document pane. Omit and chips render but do not open. */
42
46
  oncite?: (citation: Citation) => void;
43
47
  /** Offered on the LAST answer only — re-asks the same question. */
44
48
  onregenerate?: () => void;
49
+ /** Asks a follow-up. Offered on the LAST answer only; omit and the
50
+ * chips do not render, because a chip that does nothing is worse than
51
+ * no chip. */
52
+ onsuggest?: (question: string) => void;
53
+ copy?: Partial<LibrarianCopy>;
45
54
  }
46
55
 
47
56
  let {
@@ -51,10 +60,15 @@
51
60
  running,
52
61
  citations = [],
53
62
  collectionNames = new Set(),
63
+ suggestions = [],
54
64
  oncite,
55
- onregenerate
65
+ onregenerate,
66
+ onsuggest,
67
+ copy
56
68
  }: Props = $props();
57
69
 
70
+ const words = $derived(resolveCopy(copy));
71
+
58
72
  const asked = $derived(readerQuestion(question));
59
73
 
60
74
  // An activity group stays live — and therefore labelled "Working…" — only
@@ -91,12 +105,54 @@
91
105
  );
92
106
 
93
107
  const hasAnswer = $derived(texts.length > 0);
108
+
109
+ /**
110
+ * What went wrong, if anything did — in words, whether or not the run gave
111
+ * any.
112
+ *
113
+ * Two different failures reach a turn and only one of them carries a
114
+ * message. `library_error` (the stream never opened) sets `error`; a run
115
+ * that opened and then failed sets `is_error` on its terminal frame and
116
+ * says nothing at all — Claude Code's own `error_max_turns` and
117
+ * `error_during_execution` are exactly that shape. Reading only `error`
118
+ * left the second kind rendering as a clean, complete answer: no banner,
119
+ * a duration badge, and — worse — the "he holds nothing on this" line,
120
+ * which is a claim about the shelf made off a run that never finished
121
+ * looking.
122
+ */
123
+ const failure = $derived(
124
+ outcome?.error ?? (outcome?.isError ? words.answerFailed : null)
125
+ );
126
+
94
127
  // A failed turn settles too, and "Ask again" is the one thing a reader wants
95
128
  // from it — there is just nothing to copy.
96
- const settled = $derived(!running && (hasAnswer || Boolean(outcome?.error)));
129
+ const settled = $derived(!running && (hasAnswer || Boolean(failure)));
130
+
131
+ /**
132
+ * Milton answered and cited nothing, and we WATCHED him do it.
133
+ *
134
+ * `outcome` is the gate, not merely an empty source list: a turn read back
135
+ * out of the conversation history is prose with no citations and no
136
+ * outcome, because history stores neither — and a stored answer that cited
137
+ * three documents would be labelled as holding nothing. Same for an answer
138
+ * a reader stopped: the frame naming its sources never arrived, so nothing
139
+ * here knows whether there were any.
140
+ */
141
+ const notHeld = $derived(
142
+ settled && hasAnswer && sources.length === 0 && Boolean(outcome) && !failure
143
+ );
144
+
145
+ // A follow-up is offered once. Clicking it asks the question, which puts a
146
+ // new turn below this one — and leaving the row up for the moment before
147
+ // that arrives invites a second click on a third suggestion, asking two
148
+ // questions the reader only meant to ask one of.
149
+ let used = $state(false);
150
+ const followUps = $derived(!used && onsuggest ? suggestions : []);
97
151
 
98
152
  let copied = $state(false);
99
- async function copy() {
153
+ // Not `copy`: the prop of that name is the package's own words, and a
154
+ // function shadowing it here would be a redeclaration, not a shadow.
155
+ async function copyToClipboard() {
100
156
  try {
101
157
  await navigator.clipboard.writeText(answer);
102
158
  copied = true;
@@ -146,10 +202,11 @@
146
202
  {#if sources.length > 0}
147
203
  <section class="max-w-[72ch]">
148
204
  <h2 class="text-muted-foreground mb-1.5 text-xs font-medium tracking-wide uppercase">
149
- Sources
205
+ {words.sources}
150
206
  </h2>
151
207
  <ol class="flex flex-col gap-1">
152
208
  {#each sources as source (source.n)}
209
+ {@const mark = trustMark(source, words)}
153
210
  <li>
154
211
  <button
155
212
  type="button"
@@ -166,6 +223,12 @@
166
223
  {#if source.section}<span class="text-muted-foreground">
167
224
  · {source.section}</span
168
225
  >{/if}
226
+ <!-- Same muted register as the section, deliberately. An
227
+ unverified source is not an error and must not be
228
+ dressed as one; the words carry the difference, and a
229
+ red one would have a colleague discount a document
230
+ that is simply new. -->
231
+ {#if mark}<span class="text-muted-foreground/80"> · {mark}</span>{/if}
169
232
  </span>
170
233
  </button>
171
234
  </li>
@@ -174,12 +237,42 @@
174
237
  </section>
175
238
  {/if}
176
239
 
177
- {#if outcome?.error}
240
+ {#if notHeld}
241
+ <p class="text-muted-foreground max-w-[72ch] text-sm">{words.notHeld}</p>
242
+ {/if}
243
+
244
+ {#if followUps.length > 0}
245
+ <section class="max-w-[72ch]">
246
+ <h2 class="text-muted-foreground mb-1.5 text-xs font-medium tracking-wide uppercase">
247
+ {words.suggestions}
248
+ </h2>
249
+ <!-- Wrapping, not scrolling: at 390 a question is most of a line, so a
250
+ row of three would clip two of them to fragments. -->
251
+ <ul class="flex flex-wrap gap-2">
252
+ {#each followUps as followUp (followUp)}
253
+ <li class="max-w-full">
254
+ <button
255
+ type="button"
256
+ onclick={() => {
257
+ used = true;
258
+ onsuggest?.(followUp);
259
+ }}
260
+ class="border-border hover:border-border-strong hover:bg-surface-2 focus-visible:ring-ring text-foreground max-w-full rounded-full border px-3.5 py-2 text-left text-sm transition-colors focus-visible:ring-2 focus-visible:outline-none"
261
+ >
262
+ {followUp}
263
+ </button>
264
+ </li>
265
+ {/each}
266
+ </ul>
267
+ </section>
268
+ {/if}
269
+
270
+ {#if failure}
178
271
  <p
179
272
  class="border-status-error/40 bg-status-error/10 text-foreground max-w-[72ch] rounded-lg border px-3 py-2 text-sm"
180
273
  role="alert"
181
274
  >
182
- {outcome.error}
275
+ {failure}
183
276
  </p>
184
277
  {/if}
185
278
 
@@ -188,26 +281,26 @@
188
281
  {#if hasAnswer}
189
282
  <button
190
283
  type="button"
191
- onclick={copy}
192
- aria-label={copied ? 'Copied' : 'Copy answer'}
284
+ onclick={copyToClipboard}
285
+ aria-label={copied ? words.copiedAnswer : words.copyAnswer}
193
286
  class="hover:text-foreground hover:bg-surface-2 focus-visible:ring-ring flex items-center gap-1.5 rounded-md px-1.5 py-1 text-xs transition-colors focus-visible:ring-2 focus-visible:outline-none"
194
287
  >
195
288
  {#if copied}<CheckIcon class="size-3.5" />{:else}<CopyIcon class="size-3.5" />{/if}
196
- <span>{copied ? 'Copied' : 'Copy'}</span>
289
+ <span>{copied ? words.copiedAnswer : words.copyAnswer}</span>
197
290
  </button>
198
291
  {/if}
199
292
  {#if onregenerate}
200
293
  <button
201
294
  type="button"
202
295
  onclick={onregenerate}
203
- aria-label="Ask again"
296
+ aria-label={words.askAgain}
204
297
  class="hover:text-foreground hover:bg-surface-2 focus-visible:ring-ring flex items-center gap-1.5 rounded-md px-1.5 py-1 text-xs transition-colors focus-visible:ring-2 focus-visible:outline-none"
205
298
  >
206
299
  <RefreshCwIcon class="size-3.5" />
207
- <span>Ask again</span>
300
+ <span>{words.askAgain}</span>
208
301
  </button>
209
302
  {/if}
210
- {#if outcome && !outcome.error}
303
+ {#if outcome && !failure}
211
304
  <span class="pl-1 font-mono text-xs tabular-nums opacity-70">
212
305
  {((outcome.durationMs ?? 0) / 1000).toFixed(1)}s
213
306
  </span>
@@ -1,5 +1,6 @@
1
1
  import { type Block, type Outcome } from '../../transcript.svelte';
2
2
  import { type Citation } from '../../citations';
3
+ import { type LibrarianCopy } from '../../copy';
3
4
  interface Props {
4
5
  question: string;
5
6
  blocks: Block[];
@@ -10,10 +11,17 @@ interface Props {
10
11
  citations?: Citation[];
11
12
  /** Forwarded to Markdown; omit if the caller has no collections to chip. */
12
13
  collectionNames?: Set<string>;
14
+ /** Follow-ups from the library's `suggestions` frame. */
15
+ suggestions?: string[];
13
16
  /** Opens the document pane. Omit and chips render but do not open. */
14
17
  oncite?: (citation: Citation) => void;
15
18
  /** Offered on the LAST answer only — re-asks the same question. */
16
19
  onregenerate?: () => void;
20
+ /** Asks a follow-up. Offered on the LAST answer only; omit and the
21
+ * chips do not render, because a chip that does nothing is worse than
22
+ * no chip. */
23
+ onsuggest?: (question: string) => void;
24
+ copy?: Partial<LibrarianCopy>;
17
25
  }
18
26
  declare const AgentTranscript: import("svelte").Component<Props, {}, "">;
19
27
  type AgentTranscript = ReturnType<typeof AgentTranscript>;
@@ -13,9 +13,11 @@
13
13
  import ArrowDownIcon from '@lucide/svelte/icons/arrow-down';
14
14
  import type { Turn } from '../../transcript.svelte';
15
15
  import type { Citation, LoadDocument } from '../../citations';
16
+ import { resolveCopy, type LibrarianCopy } from '../../copy';
16
17
  import { FollowScroll } from '../../follow-scroll.svelte';
17
18
  import AgentTranscript from '../agent-transcript/agent-transcript.svelte';
18
19
  import DocumentPane from '../document-pane/document-pane.svelte';
20
+ import ScopeStatement from '../scope-statement/scope-statement.svelte';
19
21
 
20
22
  interface Props {
21
23
  turns: Turn[];
@@ -27,8 +29,17 @@
27
29
  /** Up to three, shown in the empty state as tappable pills. */
28
30
  examples?: string[];
29
31
  onexample?: (question: string) => void;
32
+ /** What this surface answers from and what it does not hold, in the
33
+ * host's own words. One statement per room, above the first turn,
34
+ * folded once the conversation starts. */
35
+ scope?: string;
30
36
  /** Re-asks the last question. Offered on the last answer only. */
31
37
  onregenerate?: () => void;
38
+ /** Asks a follow-up the librarian suggested. Offered on the last answer
39
+ * only; without it the suggestion chips do not render. */
40
+ onsuggest?: (question: string) => void;
41
+ /** Overrides for the package's own words. */
42
+ copy?: Partial<LibrarianCopy>;
32
43
  /** Enables the source pane. Without it, chips render but do not open. */
33
44
  loadDocument?: LoadDocument;
34
45
  collectionNames?: Set<string>;
@@ -45,12 +56,17 @@
45
56
  welcome = 'Ask Milton a question about the library.',
46
57
  examples = [],
47
58
  onexample,
59
+ scope,
48
60
  onregenerate,
61
+ onsuggest,
62
+ copy,
49
63
  loadDocument,
50
64
  collectionNames = new Set(),
51
65
  composer
52
66
  }: Props = $props();
53
67
 
68
+ const words = $derived(resolveCopy(copy));
69
+
54
70
  let viewport = $state<HTMLElement | null>(null);
55
71
  let open = $state<Citation | null>(null);
56
72
  const follow = new FollowScroll();
@@ -139,6 +155,14 @@
139
155
  </div>
140
156
  {/if}
141
157
 
158
+ <!-- Above the first turn, and in ONE place in the markup: the empty
159
+ state carries the welcome, this carries the boundary, and both
160
+ sit at the top of the same column so a reader meets them in
161
+ the same order whether or not they have asked anything yet. -->
162
+ {#if scope}
163
+ <ScopeStatement statement={scope} expanded={turns.length === 0} {copy} />
164
+ {/if}
165
+
142
166
  {#each turns as turn, index (turn.id)}
143
167
  <AgentTranscript
144
168
  question={turn.question}
@@ -146,9 +170,12 @@
146
170
  outcome={turn.outcome}
147
171
  running={running && index === turns.length - 1}
148
172
  citations={turn.citations ?? []}
173
+ suggestions={turn.suggestions ?? []}
149
174
  {collectionNames}
175
+ {copy}
150
176
  oncite={cite}
151
177
  onregenerate={index === turns.length - 1 && !running ? onregenerate : undefined}
178
+ onsuggest={index === turns.length - 1 && !running ? onsuggest : undefined}
152
179
  />
153
180
  {/each}
154
181
  </div>
@@ -161,7 +188,7 @@
161
188
  class="border-border bg-surface-1 text-foreground hover:bg-surface-2 focus-visible:ring-ring absolute bottom-3 left-1/2 flex -translate-x-1/2 items-center gap-1.5 rounded-full border px-3 py-1.5 text-xs shadow-lg transition-colors focus-visible:ring-2 focus-visible:outline-none"
162
189
  >
163
190
  <ArrowDownIcon class="size-3.5" />
164
- Jump to latest
191
+ {words.jumpToLatest}
165
192
  </button>
166
193
  {/if}
167
194
  </div>
@@ -175,6 +202,6 @@
175
202
  </div>
176
203
 
177
204
  {#if open && loadDocument}
178
- <DocumentPane citation={open} {loadDocument} onclose={() => (open = null)} />
205
+ <DocumentPane citation={open} {loadDocument} {copy} onclose={() => (open = null)} />
179
206
  {/if}
180
207
  </div>
@@ -1,6 +1,7 @@
1
1
  import type { Snippet } from 'svelte';
2
2
  import type { Turn } from '../../transcript.svelte';
3
3
  import type { LoadDocument } from '../../citations';
4
+ import { type LibrarianCopy } from '../../copy';
4
5
  interface Props {
5
6
  turns: Turn[];
6
7
  running: boolean;
@@ -11,8 +12,17 @@ interface Props {
11
12
  /** Up to three, shown in the empty state as tappable pills. */
12
13
  examples?: string[];
13
14
  onexample?: (question: string) => void;
15
+ /** What this surface answers from and what it does not hold, in the
16
+ * host's own words. One statement per room, above the first turn,
17
+ * folded once the conversation starts. */
18
+ scope?: string;
14
19
  /** Re-asks the last question. Offered on the last answer only. */
15
20
  onregenerate?: () => void;
21
+ /** Asks a follow-up the librarian suggested. Offered on the last answer
22
+ * only; without it the suggestion chips do not render. */
23
+ onsuggest?: (question: string) => void;
24
+ /** Overrides for the package's own words. */
25
+ copy?: Partial<LibrarianCopy>;
16
26
  /** Enables the source pane. Without it, chips render but do not open. */
17
27
  loadDocument?: LoadDocument;
18
28
  collectionNames?: Set<string>;
@@ -9,16 +9,25 @@
9
9
  -->
10
10
  <script lang="ts">
11
11
  import XIcon from '@lucide/svelte/icons/x';
12
- import type { Citation, LoadDocument, LoadedDocument } from '../../citations';
12
+ import { trustMark, type Citation, type LoadDocument, type LoadedDocument } from '../../citations';
13
+ import { resolveCopy, type LibrarianCopy } from '../../copy';
13
14
  import Markdown from '../markdown/markdown.svelte';
14
15
 
15
16
  interface Props {
16
17
  citation: Citation;
17
18
  loadDocument: LoadDocument;
18
19
  onclose: () => void;
20
+ copy?: Partial<LibrarianCopy>;
19
21
  }
20
22
 
21
- let { citation, loadDocument, onclose }: Props = $props();
23
+ let { citation, loadDocument, onclose, copy }: Props = $props();
24
+
25
+ const words = $derived(resolveCopy(copy));
26
+ // The same mark the chip carried, repeated where the reader has the
27
+ // document open in front of them: this is the moment they decide whether
28
+ // to act on it, and a currency they had to remember from a chip two
29
+ // scrolls up is one they will not have.
30
+ const mark = $derived(trustMark(citation, words));
22
31
 
23
32
  const MIN_WIDTH = 320;
24
33
  /** ~40% of a 1440 desktop, which is the width the pane is designed at. */
@@ -143,12 +152,15 @@
143
152
  {#if citation.section}
144
153
  <p class="text-muted-foreground truncate text-xs">{citation.section}</p>
145
154
  {/if}
155
+ {#if mark}
156
+ <p class="text-muted-foreground/80 text-xs">{mark}</p>
157
+ {/if}
146
158
  </div>
147
159
  <button
148
160
  bind:this={closeButton}
149
161
  type="button"
150
162
  onclick={onclose}
151
- aria-label="Close source"
163
+ aria-label={words.closeSource}
152
164
  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"
153
165
  >
154
166
  <XIcon class="size-4" />
@@ -157,7 +169,7 @@
157
169
 
158
170
  <div bind:this={scroller} class="min-h-0 flex-1 overflow-y-auto overscroll-contain px-4 py-3">
159
171
  {#if failed}
160
- <p class="text-muted-foreground text-sm">That document can't be opened right now.</p>
172
+ <p class="text-muted-foreground text-sm">{words.documentUnavailable}</p>
161
173
  {:else if !document_}
162
174
  <div class="flex flex-col gap-2" aria-hidden="true">
163
175
  {#each [0, 1, 2, 3] as row (row)}
@@ -1,8 +1,10 @@
1
- import type { Citation, LoadDocument } from '../../citations';
1
+ import { type Citation, type LoadDocument } from '../../citations';
2
+ import { type LibrarianCopy } from '../../copy';
2
3
  interface Props {
3
4
  citation: Citation;
4
5
  loadDocument: LoadDocument;
5
6
  onclose: () => void;
7
+ copy?: Partial<LibrarianCopy>;
6
8
  }
7
9
  declare const DocumentPane: import("svelte").Component<Props, {}, "">;
8
10
  type DocumentPane = ReturnType<typeof DocumentPane>;
@@ -0,0 +1,2 @@
1
+ export { default as ScopeStatement } from './scope-statement.svelte';
2
+ export { default } from './scope-statement.svelte';
@@ -0,0 +1,2 @@
1
+ export { default as ScopeStatement } from './scope-statement.svelte';
2
+ export { default } from './scope-statement.svelte';
@@ -0,0 +1,71 @@
1
+ <!--
2
+ What Milton answers from, and what he does not hold.
3
+
4
+ A colleague's first question about a room is not the one they type — it is
5
+ whether the answer they are about to read covers their case at all. Saying it
6
+ once, in the room's own words, is cheaper than every answer hedging.
7
+
8
+ It leads while there is nothing else on the surface, and folds to one line as
9
+ soon as there is: a boundary a reader has already read is a banner in the way
10
+ of the conversation they came for. Folded is not gone — the same line reopens
11
+ it, and a reader who opened it stays opened until the surface itself changes
12
+ state.
13
+ -->
14
+ <script lang="ts">
15
+ import { untrack } from 'svelte';
16
+ import ChevronDownIcon from '@lucide/svelte/icons/chevron-down';
17
+ import InfoIcon from '@lucide/svelte/icons/info';
18
+ import { resolveCopy, type LibrarianCopy } from '../../copy';
19
+
20
+ interface Props {
21
+ /** The statement itself, in the host's own words. */
22
+ statement: string;
23
+ /** Open on arrival. Goes false once the conversation has a first turn. */
24
+ expanded?: boolean;
25
+ copy?: Partial<LibrarianCopy>;
26
+ }
27
+
28
+ let { statement, expanded = true, copy }: Props = $props();
29
+
30
+ const words = $derived(resolveCopy(copy));
31
+
32
+ // `untrack`, not a bare read: capturing the ARRIVING value is the intent —
33
+ // the effect below owns every later change — and Svelte rightly warns
34
+ // about a prop read in a position that will never see one.
35
+ let open = $state(untrack(() => expanded));
36
+ // Plain, not `$state`: the effect below both reads and writes it, and
37
+ // nothing renders it. It exists so the effect fires on a CHANGE of the
38
+ // prop rather than on every re-render — a reader who opened the statement
39
+ // back up mid-conversation keeps it open until the surface itself moves on.
40
+ let followed = untrack(() => expanded);
41
+ $effect(() => {
42
+ if (expanded === followed) return;
43
+ followed = expanded;
44
+ open = expanded;
45
+ });
46
+ </script>
47
+
48
+ <section
49
+ class="border-border bg-surface-2/60 rounded-xl border px-3 py-2"
50
+ aria-label={words.scope}
51
+ >
52
+ <button
53
+ type="button"
54
+ onclick={() => (open = !open)}
55
+ aria-expanded={open}
56
+ class="text-muted-foreground hover:text-foreground focus-visible:ring-ring flex w-full items-center gap-2 rounded-md text-left text-xs transition-colors focus-visible:ring-2 focus-visible:outline-none"
57
+ >
58
+ <InfoIcon class="size-3.5 shrink-0" />
59
+ <span class="min-w-0 flex-1 truncate font-medium tracking-wide uppercase">{words.scope}</span>
60
+ <ChevronDownIcon class="size-3.5 shrink-0 transition-transform {open ? 'rotate-180' : ''}" />
61
+ </button>
62
+
63
+ {#if open}
64
+ <!-- No measure of its own: the card IS the measure, and a paragraph
65
+ capped narrower than the border around it leaves a hand's width of
66
+ empty card down the right at 1440. -->
67
+ <p class="text-muted-foreground mt-1.5 text-sm leading-6 whitespace-pre-line">
68
+ {statement}
69
+ </p>
70
+ {/if}
71
+ </section>
@@ -0,0 +1,11 @@
1
+ import { type LibrarianCopy } from '../../copy';
2
+ interface Props {
3
+ /** The statement itself, in the host's own words. */
4
+ statement: string;
5
+ /** Open on arrival. Goes false once the conversation has a first turn. */
6
+ expanded?: boolean;
7
+ copy?: Partial<LibrarianCopy>;
8
+ }
9
+ declare const ScopeStatement: import("svelte").Component<Props, {}, "">;
10
+ type ScopeStatement = ReturnType<typeof ScopeStatement>;
11
+ export default ScopeStatement;
package/dist/copy.d.ts ADDED
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Every word this package puts in front of a reader, in one place.
3
+ *
4
+ * Here rather than inline in each component because these sentences are the
5
+ * package's opinion about how Milton talks to a colleague, and an app that
6
+ * needs a different register — a different name for the librarian, a room
7
+ * whose boundary is worded by its own subject-matter owner — must be able to
8
+ * change one string without forking a component.
9
+ *
10
+ * Every component takes `copy` as a PARTIAL and resolves it itself, so a host
11
+ * overriding one line does not have to restate the other eleven, and a
12
+ * component used directly behaves the same as one reached through
13
+ * `Conversation`.
14
+ */
15
+ export interface LibrarianCopy {
16
+ /** Prefixes the date on a verified source: "verified 23 Jun 2026". */
17
+ verified: string;
18
+ /** A source the catalogue has never confirmed against its publisher. */
19
+ notVerified: string;
20
+ /** Shown under an answer that finished without citing anything. */
21
+ notHeld: string;
22
+ /** Shown when the run itself ended in error and said nothing about why. */
23
+ answerFailed: string;
24
+ /** Heading over the list of what an answer cited. */
25
+ sources: string;
26
+ /** Heading over the follow-up questions the librarian offered. */
27
+ suggestions: string;
28
+ /** The collapsed scope statement's own label. */
29
+ scope: string;
30
+ copyAnswer: string;
31
+ copiedAnswer: string;
32
+ askAgain: string;
33
+ jumpToLatest: string;
34
+ closeSource: string;
35
+ documentUnavailable: string;
36
+ }
37
+ export declare const DEFAULT_COPY: LibrarianCopy;
38
+ /**
39
+ * The package's words with a host's overrides on top.
40
+ *
41
+ * An explicitly `undefined` key is dropped rather than spread, because
42
+ * `{...defaults, ...{ sources: undefined }}` leaves a component rendering
43
+ * nothing at all — and that is exactly the shape a host produces by passing a
44
+ * value it computed from state that has not loaded yet.
45
+ */
46
+ export declare function resolveCopy(overrides?: Partial<LibrarianCopy>): LibrarianCopy;
package/dist/copy.js ADDED
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Every word this package puts in front of a reader, in one place.
3
+ *
4
+ * Here rather than inline in each component because these sentences are the
5
+ * package's opinion about how Milton talks to a colleague, and an app that
6
+ * needs a different register — a different name for the librarian, a room
7
+ * whose boundary is worded by its own subject-matter owner — must be able to
8
+ * change one string without forking a component.
9
+ *
10
+ * Every component takes `copy` as a PARTIAL and resolves it itself, so a host
11
+ * overriding one line does not have to restate the other eleven, and a
12
+ * component used directly behaves the same as one reached through
13
+ * `Conversation`.
14
+ */
15
+ export const DEFAULT_COPY = {
16
+ verified: 'verified',
17
+ notVerified: 'not verified',
18
+ // Two sentences, both plain: what happened, and the likeliest reason. It
19
+ // says nothing about collections, indexes or retrieval — a colleague
20
+ // reading it has no model of any of those, and the room's own instruction
21
+ // to Milton forbids him naming them either.
22
+ notHeld: 'Milton answered this one without a source. He may not hold a document that covers it.',
23
+ // A run can end `is_error` carrying no message at all, and a turn that
24
+ // simply stops is the one thing a reader must not have to guess about.
25
+ answerFailed: 'Milton stopped before he finished this one.',
26
+ sources: 'Sources',
27
+ suggestions: 'Ask next',
28
+ scope: 'What Milton answers from',
29
+ copyAnswer: 'Copy',
30
+ copiedAnswer: 'Copied',
31
+ askAgain: 'Ask again',
32
+ jumpToLatest: 'Jump to latest',
33
+ closeSource: 'Close source',
34
+ documentUnavailable: "That document can't be opened right now."
35
+ };
36
+ /**
37
+ * The package's words with a host's overrides on top.
38
+ *
39
+ * An explicitly `undefined` key is dropped rather than spread, because
40
+ * `{...defaults, ...{ sources: undefined }}` leaves a component rendering
41
+ * nothing at all — and that is exactly the shape a host produces by passing a
42
+ * value it computed from state that has not loaded yet.
43
+ */
44
+ export function resolveCopy(overrides) {
45
+ if (!overrides)
46
+ return DEFAULT_COPY;
47
+ const given = Object.fromEntries(Object.entries(overrides).filter(([, value]) => value !== undefined));
48
+ return { ...DEFAULT_COPY, ...given };
49
+ }
@@ -84,6 +84,8 @@ export declare class Transcript {
84
84
  outcome: Outcome | null;
85
85
  /** Sources for the answer, from the library's own `citations` frame. */
86
86
  citations: Citation[];
87
+ /** Follow-ups the librarian named, from the `suggestions` frame after it. */
88
+ suggestions: string[];
87
89
  other: AgentEvent[];
88
90
  reset(): void;
89
91
  apply(event: AgentEvent): void;
@@ -115,6 +117,9 @@ export interface Turn {
115
117
  blocks: Block[];
116
118
  outcome: Outcome | null;
117
119
  citations?: Citation[];
120
+ /** Follow-ups offered after this answer. Absent on a turn read back from
121
+ * history: they belonged to the moment it was asked. */
122
+ suggestions?: string[];
118
123
  }
119
124
  /**
120
125
  * Fold a turn's flat block list into what a reader should actually see.
@@ -20,7 +20,8 @@
20
20
  export function readerQuestion(question) {
21
21
  const parts = question.split(/\n{2,}/);
22
22
  let start = 0;
23
- while (start < parts.length - 1 && /^(you are|you're|your role|act as|system:)\b/i.test(parts[start].trim()))
23
+ while (start < parts.length - 1 &&
24
+ /^(you are|you're|your role|act as|system:)\b/i.test(parts[start].trim()))
24
25
  start += 1;
25
26
  return parts.slice(start).join('\n\n').trim();
26
27
  }
@@ -138,6 +139,8 @@ export class Transcript {
138
139
  outcome = $state(null);
139
140
  /** Sources for the answer, from the library's own `citations` frame. */
140
141
  citations = $state([]);
142
+ /** Follow-ups the librarian named, from the `suggestions` frame after it. */
143
+ suggestions = $state([]);
141
144
  other = $state([]);
142
145
  /** Content-block index is per MESSAGE, so it repeats across turns; this
143
146
  * maps the live index onto a position in the flat list. Cleared whenever a
@@ -151,6 +154,7 @@ export class Transcript {
151
154
  this.blocks = [];
152
155
  this.outcome = null;
153
156
  this.citations = [];
157
+ this.suggestions = [];
154
158
  this.other = [];
155
159
  this.#open.clear();
156
160
  }
@@ -171,9 +175,18 @@ export class Transcript {
171
175
  return;
172
176
  }
173
177
  // Emitted after the final assistant text, so it lands on a turn that is
174
- // otherwise complete.
178
+ // otherwise complete. Both frames put their payload on `items`, so each
179
+ // keeps only what its own shape admits: a `citations` frame carrying
180
+ // strings, or a `suggestions` frame carrying objects, is the library
181
+ // having changed under us, and rendering it would be worse than
182
+ // rendering nothing.
175
183
  if (event.type === 'citations') {
176
- this.citations = event.items ?? [];
184
+ this.citations = (event.items ?? []).filter((item) => typeof item === 'object' && item !== null);
185
+ return;
186
+ }
187
+ // Last of the two, and only when the librarian named any.
188
+ if (event.type === 'suggestions') {
189
+ this.suggestions = (event.items ?? []).filter((item) => typeof item === 'string' && item.trim().length > 0);
177
190
  return;
178
191
  }
179
192
  if (event.type === 'library_error') {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@poodle64/librarian",
3
- "version": "2026.9.8",
3
+ "version": "2026.9.10",
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",
@@ -34,6 +34,10 @@
34
34
  "types": "./dist/citations.d.ts",
35
35
  "svelte": "./dist/citations.js"
36
36
  },
37
+ "./copy": {
38
+ "types": "./dist/copy.d.ts",
39
+ "svelte": "./dist/copy.js"
40
+ },
37
41
  "./attachments": {
38
42
  "types": "./dist/attachments.d.ts",
39
43
  "svelte": "./dist/attachments.js"