@poodle64/librarian 2026.9.14 → 2026.9.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (28) hide show
  1. package/README.md +77 -19
  2. package/dist/client.d.ts +16 -1
  3. package/dist/client.js +91 -21
  4. package/dist/components/activity-group/activity-group.svelte +76 -8
  5. package/dist/components/agent-transcript/agent-transcript.svelte +345 -155
  6. package/dist/components/agent-transcript/agent-transcript.svelte.d.ts +7 -0
  7. package/dist/components/artefact-card/artefact-card.svelte +30 -2
  8. package/dist/components/artefact-pane/artefact-pane.svelte +141 -50
  9. package/dist/components/composer/composer.svelte +253 -39
  10. package/dist/components/composer/composer.svelte.d.ts +5 -1
  11. package/dist/components/conversation/conversation.svelte +247 -89
  12. package/dist/components/conversation/conversation.svelte.d.ts +6 -2
  13. package/dist/components/document-pane/document-pane.svelte +216 -33
  14. package/dist/components/markdown/markdown.svelte +73 -24
  15. package/dist/components/scope-statement/scope-statement.svelte +79 -12
  16. package/dist/components/source-list/index.d.ts +2 -0
  17. package/dist/components/source-list/index.js +2 -0
  18. package/dist/components/source-list/source-list.svelte +137 -0
  19. package/dist/components/source-list/source-list.svelte.d.ts +13 -0
  20. package/dist/components/thinking-row/thinking-row.svelte +69 -18
  21. package/dist/components/tool-row/tool-row.svelte +132 -26
  22. package/dist/components/working/working.svelte +51 -7
  23. package/dist/components/working/working.svelte.d.ts +3 -0
  24. package/dist/copy.d.ts +44 -12
  25. package/dist/copy.js +77 -33
  26. package/dist/transcript.svelte.d.ts +10 -0
  27. package/dist/transcript.svelte.js +4 -1
  28. package/package.json +4 -2
package/README.md CHANGED
@@ -1,12 +1,16 @@
1
1
  # @poodle64/librarian
2
2
 
3
- Milton's conversation surface, as a Svelte 5 package: the stream client, the
3
+ An agent's conversation surface, as a Svelte 5 package: the stream client, the
4
4
  transcript state, and the chat components an app renders instead of
5
5
  rebuilding: the transcript with its own follow-scroll, the composer with
6
6
  attachments, citation chips and the source pane they open.
7
7
 
8
- Owned by the library. This is Milton's surface; design-system is its press.
9
- Change it here, consume it there.
8
+ The persona is an argument. Name it once — `name="penny"` — and every word the
9
+ package says is composed from it. Milton is the library's own persona and the
10
+ default; no string in this package names him.
11
+
12
+ Owned by the library. Design-system is its press: change it here, consume it
13
+ there.
10
14
 
11
15
  ## What is here
12
16
 
@@ -44,16 +48,28 @@ pnpm add @poodle64/librarian @poodle64/ui @lucide/svelte
44
48
  and `shiki` are peer dependencies: declare them yourself so Renovate tracks
45
49
  their versions and `pnpm ls` shows them.
46
50
 
47
- The components style themselves with Tailwind utility classes, same as
48
- `@poodle64/ui`, so the same line is needed in the app's `app.css`:
51
+ **No Tailwind content-scan line, and deliberately none.** These components
52
+ carry their own CSS, written against the `--ds-*` tokens and compiled by
53
+ whatever bundler the app already runs, so the transcript looks the same in
54
+ every consumer whether or not that consumer scans `node_modules`. It did not
55
+ always: the package styled itself with Tailwind utilities and asked each app
56
+ for an `@source` line, Pebblestone's `app.css` never had one, and the console
57
+ shipped a 2302px-wide transcript with no cards, no measure and unstyled
58
+ tables — no build error, no lint hit, nothing failing. A package whose
59
+ appearance depends on a line in its consumer's stylesheet does not have a
60
+ look; it has a hope.
61
+
62
+ What it does need is the token layer every app already imports:
49
63
 
50
64
  ```css
51
- @source '../node_modules/@poodle64/librarian/dist'; /* Tailwind content scan */
65
+ @import '@poodle64/design-tokens/tokens.css';
52
66
  ```
53
67
 
54
- Without it the classes ship in `dist` but Tailwind's default content scan
55
- never sees `node_modules`, so nothing compiles for them — no build error, no
56
- lint hit, just an unstyled transcript.
68
+ The composed page chrome this package borrows from `@poodle64/ui` (`Panel`)
69
+ still follows that package's own `@source` line, which every app has.
70
+
71
+ One knob: `--ds-lib-measure` (default `46rem`) sets the transcript's reading
72
+ column. Set it on any ancestor.
57
73
 
58
74
  ## Consuming the package
59
75
 
@@ -213,7 +229,7 @@ behind it, so "not verified" would be a claim about a record nothing here
213
229
  ever read.
214
230
 
215
231
  An answer that settles having cited nothing says so, once, under the prose:
216
- "Milton answered this one without a source. He may not hold a document that
232
+ "Milton answered this one without a source. There may be no document here that
217
233
  covers it." Only on a turn whose stream this surface actually watched finish
218
234
  — a turn read back out of `createHistory()` has no citations because history
219
235
  stores none, and labelling it as holding nothing would be a lie about an
@@ -235,7 +251,7 @@ second question by mistake.
235
251
 
236
252
  ### Where the answer starts
237
253
 
238
- Milton narrates between tool calls — "Let me also check whether…" — and the
254
+ The persona narrates between tool calls — "Let me also check whether…" — and the
239
255
  caller stream gives that nowhere to arrive: it carries no `thinking` blocks
240
256
  at all, so narration is an ordinary `text` block, identical to the answer
241
257
  except in POSITION. `segment()` reads that position: a text block with any
@@ -246,23 +262,60 @@ is currently last renders as prose, and a tool call arriving after it
246
262
  re-homes it — which is why `segment()` is pure and re-derived per event
247
263
  rather than deciding once.
248
264
 
249
- The cost is an answer Milton interrupts to go back to the shelf: its first
265
+ The cost is an answer the persona interrupts to go back to the shelf: its first
250
266
  half folds away. Position is the only signal the stream gives, and a rule
251
267
  read off the prose itself would be unexplainable the first time it misfired.
252
268
 
253
- ### Changing the words
269
+ ### Who is speaking, and in what words
270
+
271
+ Every user-visible string lives in `@poodle64/librarian/copy`, and every one
272
+ of them is composed from the persona's name:
273
+
274
+ ```svelte
275
+ <Conversation {turns} {running} name="penny" />
276
+ <Composer bind:value {running} name="penny" {scope} {onscope} {onsubmit} {onstop} />
277
+ ```
278
+
279
+ That one prop carries the working line ("Penny is looking…"), both composer
280
+ placeholders, the uncited-answer note, both failure sentences, the scope
281
+ label, the opening line, the name signed on every answer card and the
282
+ accessible name of the scroll region. A slug is fine: `penny` renders as
283
+ "Penny", `chief-engineer` as "Chief Engineer", and a name the host already
284
+ capitalised is left exactly as written.
254
285
 
255
- Every user-visible string this package renders lives in
256
- `@poodle64/librarian/copy`, and every component takes a partial of it:
286
+ A host that wants different words still overrides one at a time:
257
287
 
258
288
  ```svelte
259
- <Conversation {turns} {running} copy={{ notVerified: 'not checked yet' }} />
289
+ <Conversation {turns} {running} name="penny" copy={{ notVerified: 'not checked yet' }} />
260
290
  ```
261
291
 
262
292
  Overriding one line leaves the rest as the package wrote them, and a key
263
293
  passed as `undefined` — the shape a host produces from state that has not
264
294
  loaded — is ignored rather than rendering nothing where a word belongs.
265
295
 
296
+ ### When the stream fails
297
+
298
+ `ask()` never throws. A refused route, a `fetch` the browser blocks on CORS
299
+ after an expired session redirects it, a 200 that turns out to be a login
300
+ page, a connection that dies mid-answer and a stream that simply stops
301
+ without a terminal frame all arrive as one `library_error` event and a
302
+ finished iteration. That matters because the host's `for await` loop is what
303
+ re-enables its Send button: a thrown generator left Pebblestone's console
304
+ disabled, silent and waiting indefinitely every time a session expired.
305
+
306
+ The event carries no words — this layer does not know whose voice to say them
307
+ in — so the turn renders `copy.unreachable` in the persona's own name and
308
+ settles, offering "Ask again".
309
+
310
+ ### Timestamps
311
+
312
+ `Turn.at` (epoch ms) renders as a clock time on the question and the answer
313
+ card, which is a different fact from the duration badge beside the actions. A
314
+ host that persists conversations sets it; a host that does not gets one
315
+ stamped when the turn first appears, and a turn already on screen at mount —
316
+ one read back out of history — deliberately shows none rather than claiming
317
+ it was asked this afternoon.
318
+
266
319
  ### The system preamble
267
320
 
268
321
  A host prepends its own instruction to every question (cadmus sends
@@ -290,16 +343,21 @@ pnpm run test # build + vitest
290
343
  pnpm run screenshots # the state grid, real engine (see below)
291
344
  ```
292
345
 
293
- `docs/screenshots/` is eleven states x three widths x both themes, taken by
346
+ `docs/screenshots/` is thirteen states x three widths x both themes, taken by
294
347
  `scripts/screenshots.mjs` against the console's `/librarian` lab route
295
348
  running from its own static build. The same script asserts what a screenshot
296
349
  cannot: that nothing scrolls sideways at any width, that the source pane
297
350
  opens and closes from the keyboard with focus returning to the chip and is
298
351
  really draggable, that the scope statement folds once there is a
299
352
  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
353
+ its question and takes the rest of the row with it, that the persona's
301
354
  between-tool narration is nowhere in the answer prose before the activity
302
- line is opened. It exits non-zero on any of them.
355
+ line is opened, and that the reading column holds its 46rem measure and stays
356
+ centred at every width. It exits non-zero on any of them.
357
+
358
+ The console's `app.css` deliberately does NOT scan this package's `dist`, so
359
+ the grid is taken in a consumer that compiles none of its Tailwind classes —
360
+ which is the only way the measure claim above means anything.
303
361
 
304
362
  ```bash
305
363
  pnpm --filter @poodle64/console run build
package/dist/client.d.ts CHANGED
@@ -68,5 +68,20 @@ export interface AskOptions {
68
68
  /** Override for the ambient `fetch`, e.g. a caller-authenticated wrapper. */
69
69
  fetch?: typeof fetch;
70
70
  }
71
- /** Async-iterate the events of one question. */
71
+ /**
72
+ * Async-iterate the events of one question.
73
+ *
74
+ * It never throws. Every way a stream can fail to open or die half-way through
75
+ * — the network dropping, an expired session redirecting the POST to an
76
+ * identity provider the browser then blocks on CORS, a server closing the
77
+ * connection mid-answer — comes back as one `library_error` event and a
78
+ * finished iteration, because a host's loop is what re-enables its Send
79
+ * button. A thrown generator leaves that loop unfinished: Pebblestone's
80
+ * console sat with Send disabled and no message on screen, indefinitely,
81
+ * every time a session expired mid-conversation.
82
+ *
83
+ * The event carries no words. The words name a persona and this layer has no
84
+ * idea which one is speaking, so `copyFor(name).unreachable` supplies them
85
+ * where the turn is rendered.
86
+ */
72
87
  export declare function ask(options: AskOptions): AsyncGenerator<AgentEvent>;
package/dist/client.js CHANGED
@@ -47,43 +47,113 @@ function requestInit(options, signal) {
47
47
  form.append('kind', options.kind);
48
48
  return { method: 'POST', credentials: 'include', body: form, signal };
49
49
  }
50
- /** Async-iterate the events of one question. */
50
+ /**
51
+ * Async-iterate the events of one question.
52
+ *
53
+ * It never throws. Every way a stream can fail to open or die half-way through
54
+ * — the network dropping, an expired session redirecting the POST to an
55
+ * identity provider the browser then blocks on CORS, a server closing the
56
+ * connection mid-answer — comes back as one `library_error` event and a
57
+ * finished iteration, because a host's loop is what re-enables its Send
58
+ * button. A thrown generator leaves that loop unfinished: Pebblestone's
59
+ * console sat with Send disabled and no message on screen, indefinitely,
60
+ * every time a session expired mid-conversation.
61
+ *
62
+ * The event carries no words. The words name a persona and this layer has no
63
+ * idea which one is speaking, so `copyFor(name).unreachable` supplies them
64
+ * where the turn is rendered.
65
+ */
51
66
  export async function* ask(options) {
52
67
  const doFetch = options.fetch ?? fetch;
53
- const response = await doFetch(options.endpoint ?? '/api/agent/ask', requestInit(options, options.signal));
54
- if (!response.ok || !response.body) {
55
- yield { type: 'library_error', error: "Milton can't be reached right now." };
68
+ let response;
69
+ try {
70
+ response = await doFetch(options.endpoint ?? '/api/agent/ask', requestInit(options, options.signal));
71
+ }
72
+ catch {
73
+ // A reader who pressed stop asked for this one; it is not a failure.
74
+ if (options.signal?.aborted)
75
+ return;
76
+ yield { type: 'library_error' };
77
+ return;
78
+ }
79
+ // A session that expired mid-conversation is the shape this catches: the
80
+ // POST is redirected to a login page, which answers 200 with HTML, and a
81
+ // reader whose stream "opened" then waits for frames that can never come.
82
+ //
83
+ // A response that DECLARES something other than an event stream is refused;
84
+ // one that declares nothing is read anyway. A route behind a proxy that
85
+ // drops the header is still streaming, and refusing it here would be this
86
+ // layer breaking a working consumer over a header — the terminal-frame
87
+ // check at the end of this function catches it if it really says nothing.
88
+ const kind = (response.headers.get('content-type') ?? '').toLowerCase();
89
+ if (!response.ok || !response.body || (kind && !kind.includes('text/event-stream'))) {
90
+ yield { type: 'library_error' };
56
91
  return;
57
92
  }
58
93
  const reader = response.body.pipeThrough(new TextDecoderStream()).getReader();
59
94
  // Frames are separated by a blank line and split across network reads at
60
95
  // arbitrary points, so the tail of each read is carried rather than parsed.
61
96
  let buffered = '';
97
+ // A turn that ends with no terminal frame ended by accident. Tracked here
98
+ // rather than left to the renderer, which cannot tell a stream that died
99
+ // from one still arriving.
100
+ let terminal = false;
101
+ /** One frame's `data:` lines, as the event they carry. */
102
+ function parse(frame) {
103
+ // `data:` only — the type lives inside the payload where Claude Code
104
+ // puts it, so there is no second place to look.
105
+ const data = frame
106
+ .split('\n')
107
+ .filter((l) => l.startsWith('data:'))
108
+ .map((l) => l.slice(5).trim())
109
+ .join('');
110
+ if (!data)
111
+ return null;
112
+ try {
113
+ return JSON.parse(data);
114
+ }
115
+ catch {
116
+ // A partial frame at the end of a stream is not an error.
117
+ return null;
118
+ }
119
+ }
62
120
  for (;;) {
63
- const { done, value } = await reader.read();
64
- if (done)
121
+ let chunk;
122
+ try {
123
+ chunk = await reader.read();
124
+ }
125
+ catch {
126
+ if (options.signal?.aborted)
127
+ return;
128
+ yield { type: 'library_error' };
129
+ return;
130
+ }
131
+ if (chunk.done)
65
132
  break;
66
- buffered += value;
133
+ buffered += chunk.value;
67
134
  let boundary = buffered.indexOf('\n\n');
68
135
  while (boundary !== -1) {
69
136
  const frame = buffered.slice(0, boundary);
70
137
  buffered = buffered.slice(boundary + 2);
71
138
  boundary = buffered.indexOf('\n\n');
72
- // `data:` only — the type lives inside the payload where Claude Code
73
- // puts it, so there is no second place to look.
74
- const data = frame
75
- .split('\n')
76
- .filter((l) => l.startsWith('data:'))
77
- .map((l) => l.slice(5).trim())
78
- .join('');
79
- if (!data)
139
+ const event = parse(frame);
140
+ if (!event)
80
141
  continue;
81
- try {
82
- yield JSON.parse(data);
83
- }
84
- catch {
85
- /* a partial frame at end of stream is not an error */
86
- }
142
+ if (event.type === 'result' || event.type === 'library_error')
143
+ terminal = true;
144
+ yield event;
87
145
  }
88
146
  }
147
+ // The last frame of a stream that closed cleanly without its blank line.
148
+ // It is usually the terminal `result`, carrying the duration and the
149
+ // sources, so dropping it both loses the answer's furniture and makes a
150
+ // finished turn look like one that died.
151
+ const last = parse(buffered);
152
+ if (last) {
153
+ if (last.type === 'result' || last.type === 'library_error')
154
+ terminal = true;
155
+ yield last;
156
+ }
157
+ if (!terminal && !options.signal?.aborted)
158
+ yield { type: 'library_error' };
89
159
  }
@@ -3,10 +3,10 @@
3
3
 
4
4
  Not a stack. Fifteen tool rows between the question and the first word of the
5
5
  answer is the machine room on show, and a running commentary of "Let me
6
- check…" is worse: it narrates a mechanism Milton is under instruction never
7
- to mention. So there is exactly one row in every state — "Working…" while it
8
- runs, a count of what was done once it settles — and the steps are behind a
9
- disclosure for the reader who wants them.
6
+ check…" is worse: it narrates a mechanism the persona is under instruction
7
+ never to mention. So there is exactly one row in every state — "Working…"
8
+ while it runs, a count of what was done once it settles — and the steps are
9
+ behind a disclosure for the reader who wants them.
10
10
 
11
11
  That commentary is a step in here too. It reaches the caller as an ordinary
12
12
  `text` block, not a `thinking` one, so `segment()` re-homes any text with a
@@ -36,18 +36,20 @@
36
36
  <div>
37
37
  <button
38
38
  type="button"
39
- class="text-muted-foreground hover:text-foreground flex items-center gap-1.5 text-sm transition-colors"
39
+ class="ds-lib-activity-toggle"
40
40
  onclick={() => (expanded = !expanded)}
41
41
  aria-expanded={expanded}
42
42
  >
43
- <ChevronRightIcon class="size-3.5 transition-transform {expanded ? 'rotate-90' : ''}" />
43
+ <span class="ds-lib-activity-chevron" class:is-open={expanded}>
44
+ <ChevronRightIcon size={14} />
45
+ </span>
44
46
  {#if live}
45
- <span class="bg-status-info size-1.5 shrink-0 animate-pulse rounded-full"></span>
47
+ <span class="ds-lib-activity-dot"></span>
46
48
  {/if}
47
49
  <span>{label}</span>
48
50
  </button>
49
51
  {#if expanded}
50
- <div class="border-border mt-2 ml-1.75 flex flex-col gap-1.5 border-l pl-3">
52
+ <div class="ds-lib-activity-steps">
51
53
  {#each group.steps as step (step.block.index)}
52
54
  {#if step.block.kind === 'tool'}
53
55
  <ToolRow block={step.block} repeats={step.repeats} running={live} />
@@ -58,3 +60,69 @@
58
60
  </div>
59
61
  {/if}
60
62
  </div>
63
+
64
+ <style>
65
+ .ds-lib-activity-toggle {
66
+ display: flex;
67
+ align-items: center;
68
+ gap: 0.375rem;
69
+ border: 0;
70
+ background: none;
71
+ padding: 0;
72
+ color: var(--ds-color-muted-foreground);
73
+ font: inherit;
74
+ font-size: 0.875rem;
75
+ line-height: 1.25rem;
76
+ cursor: pointer;
77
+ transition: color 150ms ease;
78
+ }
79
+
80
+ .ds-lib-activity-toggle:hover {
81
+ color: var(--ds-color-foreground);
82
+ }
83
+
84
+ .ds-lib-activity-chevron {
85
+ display: flex;
86
+ flex: none;
87
+ transition: transform 150ms ease;
88
+ }
89
+
90
+ .ds-lib-activity-chevron.is-open {
91
+ transform: rotate(90deg);
92
+ }
93
+
94
+ .ds-lib-activity-dot {
95
+ width: 0.375rem;
96
+ height: 0.375rem;
97
+ flex: none;
98
+ border-radius: var(--ds-radius-full);
99
+ background: var(--ds-color-status-info);
100
+ animation: ds-lib-activity-pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
101
+ }
102
+
103
+ .ds-lib-activity-steps {
104
+ display: flex;
105
+ flex-direction: column;
106
+ gap: 0.375rem;
107
+ margin-top: 0.5rem;
108
+ margin-inline-start: 0.4375rem;
109
+ border-inline-start: 1px solid var(--ds-color-border);
110
+ padding-inline-start: 0.75rem;
111
+ }
112
+
113
+ @keyframes ds-lib-activity-pulse {
114
+ 50% {
115
+ opacity: 0.4;
116
+ }
117
+ }
118
+
119
+ @media (prefers-reduced-motion: reduce) {
120
+ .ds-lib-activity-dot {
121
+ animation: none;
122
+ }
123
+
124
+ .ds-lib-activity-chevron {
125
+ transition: none;
126
+ }
127
+ }
128
+ </style>