@poodle64/librarian 2026.9.14 → 2026.9.15
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 +77 -19
- package/dist/client.d.ts +16 -1
- package/dist/client.js +55 -8
- package/dist/components/activity-group/activity-group.svelte +76 -8
- package/dist/components/agent-transcript/agent-transcript.svelte +345 -155
- package/dist/components/agent-transcript/agent-transcript.svelte.d.ts +7 -0
- package/dist/components/artefact-card/artefact-card.svelte +30 -2
- package/dist/components/artefact-pane/artefact-pane.svelte +141 -50
- package/dist/components/composer/composer.svelte +253 -39
- package/dist/components/composer/composer.svelte.d.ts +5 -1
- package/dist/components/conversation/conversation.svelte +247 -89
- package/dist/components/conversation/conversation.svelte.d.ts +6 -2
- package/dist/components/document-pane/document-pane.svelte +216 -33
- package/dist/components/markdown/markdown.svelte +73 -24
- package/dist/components/scope-statement/scope-statement.svelte +79 -12
- package/dist/components/source-list/index.d.ts +2 -0
- package/dist/components/source-list/index.js +2 -0
- package/dist/components/source-list/source-list.svelte +137 -0
- package/dist/components/source-list/source-list.svelte.d.ts +13 -0
- package/dist/components/thinking-row/thinking-row.svelte +69 -18
- package/dist/components/tool-row/tool-row.svelte +132 -26
- package/dist/components/working/working.svelte +51 -7
- package/dist/components/working/working.svelte.d.ts +3 -0
- package/dist/copy.d.ts +44 -12
- package/dist/copy.js +77 -33
- package/dist/transcript.svelte.d.ts +10 -0
- package/dist/transcript.svelte.js +4 -1
- package/package.json +4 -2
package/README.md
CHANGED
|
@@ -1,12 +1,16 @@
|
|
|
1
1
|
# @poodle64/librarian
|
|
2
2
|
|
|
3
|
-
|
|
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
|
-
|
|
9
|
-
|
|
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
|
-
|
|
48
|
-
|
|
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
|
-
@
|
|
65
|
+
@import '@poodle64/design-tokens/tokens.css';
|
|
52
66
|
```
|
|
53
67
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
|
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
|
-
###
|
|
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
|
-
|
|
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
|
|
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,
|
|
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
|
|
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
|
-
/**
|
|
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,23 +47,65 @@ function requestInit(options, signal) {
|
|
|
47
47
|
form.append('kind', options.kind);
|
|
48
48
|
return { method: 'POST', credentials: 'include', body: form, signal };
|
|
49
49
|
}
|
|
50
|
-
/**
|
|
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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
+
const kind = response.headers.get('content-type') ?? '';
|
|
83
|
+
if (!response.ok || !response.body || !kind.includes('text/event-stream')) {
|
|
84
|
+
yield { type: 'library_error' };
|
|
56
85
|
return;
|
|
57
86
|
}
|
|
58
87
|
const reader = response.body.pipeThrough(new TextDecoderStream()).getReader();
|
|
59
88
|
// Frames are separated by a blank line and split across network reads at
|
|
60
89
|
// arbitrary points, so the tail of each read is carried rather than parsed.
|
|
61
90
|
let buffered = '';
|
|
91
|
+
// A turn that ends with no terminal frame ended by accident. Tracked here
|
|
92
|
+
// rather than left to the renderer, which cannot tell a stream that died
|
|
93
|
+
// from one still arriving.
|
|
94
|
+
let terminal = false;
|
|
62
95
|
for (;;) {
|
|
63
|
-
|
|
64
|
-
|
|
96
|
+
let chunk;
|
|
97
|
+
try {
|
|
98
|
+
chunk = await reader.read();
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
if (options.signal?.aborted)
|
|
102
|
+
return;
|
|
103
|
+
yield { type: 'library_error' };
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
if (chunk.done)
|
|
65
107
|
break;
|
|
66
|
-
buffered += value;
|
|
108
|
+
buffered += chunk.value;
|
|
67
109
|
let boundary = buffered.indexOf('\n\n');
|
|
68
110
|
while (boundary !== -1) {
|
|
69
111
|
const frame = buffered.slice(0, boundary);
|
|
@@ -79,11 +121,16 @@ export async function* ask(options) {
|
|
|
79
121
|
if (!data)
|
|
80
122
|
continue;
|
|
81
123
|
try {
|
|
82
|
-
|
|
124
|
+
const event = JSON.parse(data);
|
|
125
|
+
if (event.type === 'result' || event.type === 'library_error')
|
|
126
|
+
terminal = true;
|
|
127
|
+
yield event;
|
|
83
128
|
}
|
|
84
129
|
catch {
|
|
85
130
|
/* a partial frame at end of stream is not an error */
|
|
86
131
|
}
|
|
87
132
|
}
|
|
88
133
|
}
|
|
134
|
+
if (!terminal && !options.signal?.aborted)
|
|
135
|
+
yield { type: 'library_error' };
|
|
89
136
|
}
|
|
@@ -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
|
|
7
|
-
to mention. So there is exactly one row in every state — "Working…"
|
|
8
|
-
runs, a count of what was done once it settles — and the steps are
|
|
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="
|
|
39
|
+
class="ds-lib-activity-toggle"
|
|
40
40
|
onclick={() => (expanded = !expanded)}
|
|
41
41
|
aria-expanded={expanded}
|
|
42
42
|
>
|
|
43
|
-
<
|
|
43
|
+
<span class="ds-lib-activity-chevron" class:is-open={expanded}>
|
|
44
|
+
<ChevronRightIcon size={14} />
|
|
45
|
+
</span>
|
|
44
46
|
{#if live}
|
|
45
|
-
<span class="
|
|
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="
|
|
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>
|