@poodle64/librarian 2026.9.21 → 2026.9.23

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 +131 -8
  2. package/dist/chat.svelte.d.ts +189 -0
  3. package/dist/chat.svelte.js +554 -0
  4. package/dist/client.d.ts +29 -6
  5. package/dist/client.js +11 -3
  6. package/dist/components/agent-transcript/agent-transcript.svelte +40 -6
  7. package/dist/components/agent-transcript/agent-transcript.svelte.d.ts +9 -0
  8. package/dist/components/answer-mark/answer-mark.svelte +223 -0
  9. package/dist/components/answer-mark/answer-mark.svelte.d.ts +11 -0
  10. package/dist/components/answer-mark/index.d.ts +2 -0
  11. package/dist/components/answer-mark/index.js +2 -0
  12. package/dist/components/conversation/conversation.svelte +21 -3
  13. package/dist/components/conversation/conversation.svelte.d.ts +9 -0
  14. package/dist/components/conversation-list/conversation-list.svelte +496 -0
  15. package/dist/components/conversation-list/conversation-list.svelte.d.ts +26 -0
  16. package/dist/components/conversation-list/index.d.ts +2 -0
  17. package/dist/components/conversation-list/index.js +2 -0
  18. package/dist/components/fair-use-notice/fair-use-notice.svelte +97 -0
  19. package/dist/components/fair-use-notice/fair-use-notice.svelte.d.ts +11 -0
  20. package/dist/components/fair-use-notice/index.d.ts +2 -0
  21. package/dist/components/fair-use-notice/index.js +2 -0
  22. package/dist/components/working/working.svelte +8 -3
  23. package/dist/components/working/working.svelte.d.ts +3 -0
  24. package/dist/copy.d.ts +40 -0
  25. package/dist/copy.js +36 -1
  26. package/dist/session.svelte.js +1 -1
  27. package/dist/transcript.svelte.js +1 -1
  28. package/package.json +8 -3
package/README.md CHANGED
@@ -19,6 +19,7 @@ src/lib/
19
19
  client.ts ask() and watch(): Claude Code's OWN events, unaltered
20
20
  transcript.svelte.ts Transcript state, the fold/segment/describe helpers
21
21
  session.svelte.ts Session: a whole stream of runs, as turns
22
+ chat.svelte.ts Chat: the page's controller, over a room's routes or a job's
22
23
  citations.ts the Citation shape, `[n]` markers, the trust mark
23
24
  copy.ts every word this package says, and the host's overrides
24
25
  attachments.ts what a reader may attach, and the limits
@@ -26,6 +27,9 @@ src/lib/
26
27
  history.svelte.ts the browser-held conversation list, per caller
27
28
  components/
28
29
  conversation/ the whole reading surface: scroll, pill, pane, composer slot
30
+ conversation-list/ past conversations, newest first: reopen, rename, download, delete
31
+ answer-mark/ helpful or not, with a note, on the last answer
32
+ fair-use-notice/ today's allowance, beside the composer
29
33
  scope-statement/ what this room answers from, and what it does not hold
30
34
  agent-transcript/ one question and everything Milton did answering it
31
35
  document-pane/ the cited document, open at the cited passage
@@ -180,6 +184,116 @@ Nothing here fetches on its own behalf. The library's document read is
180
184
  authenticated, and a package that called it directly would be reaching past
181
185
  the app's proxy with a session it has no business holding.
182
186
 
187
+ ### A room's page, whole: `Chat`
188
+
189
+ The hand-wired page above is the parts. A stamped room page is `Chat` over
190
+ the room's routes, and the components bound to it:
191
+
192
+ ```svelte
193
+ <script lang="ts">
194
+ import { Chat } from '@poodle64/librarian/chat';
195
+ import Conversation from '@poodle64/librarian/conversation';
196
+ import ConversationList from '@poodle64/librarian/conversation-list';
197
+ import Composer from '@poodle64/librarian/composer';
198
+ import FairUseNotice from '@poodle64/librarian/fair-use-notice';
199
+
200
+ const chat = new Chat(roomTransport(room)); // the app's own client, below
201
+ const wanted = $derived(page.url.searchParams.get('c'));
202
+
203
+ // The address names the conversation; `open` and `new` are safe here.
204
+ $effect(() => {
205
+ if (wanted && wanted !== chat.conversationId) void chat.open(wanted);
206
+ else if (!wanted) chat.new();
207
+ });
208
+ </script>
209
+
210
+ <ConversationList
211
+ conversations={chat.conversations}
212
+ failed={chat.listFailed}
213
+ current={chat.conversationId}
214
+ href={(id) => `?c=${id}`}
215
+ onnew={() => chat.new()}
216
+ onrename={(id, title) => chat.rename(id, title)}
217
+ ondownload={(id) => chat.download(id)}
218
+ ondelete={(id) => chat.remove(id)}
219
+ />
220
+ <Conversation
221
+ turns={chat.turns}
222
+ running={chat.busy}
223
+ version={chat.version}
224
+ waiting={chat.waiting}
225
+ answering={chat.answering}
226
+ onregenerate={() => chat.again()}
227
+ onsuggest={(question) => chat.ask(question)}
228
+ onmark={(turn, verdict) => chat.mark(turn, verdict)}
229
+ >
230
+ {#snippet composer()}
231
+ <FairUseNotice quota={chat.quota} />
232
+ <Composer
233
+ bind:value={chat.draft}
234
+ bind:files={chat.files}
235
+ running={chat.busy}
236
+ sendWhileRunning={chat.sendWhileRunning}
237
+ onsubmit={() => chat.ask()}
238
+ onstop={() => chat.stop()}
239
+ />
240
+ {/snippet}
241
+ </Conversation>
242
+ ```
243
+
244
+ `chat.list()` reads the list once; after that it follows every answer.
245
+
246
+ The transport is the app's own client over its routes, and the shapes are
247
+ the routes' own bodies (`ConversationSummary`, `ConversationRead`,
248
+ `StoredTurn`, `Quota`), so the stamped slice returns them as they are and
249
+ the transport passes them through:
250
+
251
+ | `RoomTransport` | Route |
252
+ | --------------- | ----------------------------------------------------- |
253
+ | `ask` | `POST rooms/{room}/ask`, through `client.ask()` |
254
+ | `read` | `GET conversations/{id}` |
255
+ | `stop` | `POST conversations/{id}/stop` |
256
+ | `quota` | `GET quota` |
257
+ | `list` | this person's conversations in the room |
258
+ | `rename` | `PATCH conversations/{id}` |
259
+ | `remove` | `DELETE conversations/{id}` |
260
+ | `download` | `GET conversations/{id}/export`, as `{ name, body }` |
261
+ | `mark` | its answer mark, where the agent takes one (optional) |
262
+
263
+ What the controller relies on the routes to say:
264
+
265
+ | The route says | The page |
266
+ | -------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- |
267
+ | a `queued` frame, repeated while the ask waits | `waiting`, by the frame's type alone, until the agent starts: "Milton is answering another question first", beside the clock |
268
+ | `system/init` with `session_id` | `conversationId`, which the host puts in the address |
269
+ | 429 on the ask | drops the turn, puts the words and files back in the box, reads the allowance |
270
+ | 409 on the ask: the conversation is already being answered | drops the turn, keeps the words, shows it still answering |
271
+ | `answering_since` on a read | `answering`: the question in flight with "still answering" and its clock, read again every 5 s till it clears |
272
+ | a stream that dropped (`library_error` with `dropped`) on a named conversation | reads it rather than failing the turn: a locked phone loses the connection, not the answer |
273
+
274
+ The library's `queued` frame is `{ type: 'queued', message }`, `message` its
275
+ own sentence; `isQueued(event)` from `./client` narrows an event to it
276
+ (`QueuedEvent`) for a host that reads it.
277
+
278
+ A turn belongs to the server. Leaving a conversation, or the page, leaves
279
+ its answer being written; `stop()` asks the server to end it, and before the
280
+ agent has named a new conversation it holds the stream open, unseen, until
281
+ it does. `again()` asks the last question as a new turn: the conversation is
282
+ append-only because the agent's transcript is.
283
+
284
+ A job is `new Chat(jobTransport)`, where `JobTransport` is its `watch`,
285
+ `message` and `stop` (godswood's run view). The turns are `Session`'s; a
286
+ message goes onto the run while it works and opens its stream again once it
287
+ has settled; `carriesOn()` keeps a closed stream watched while the host says
288
+ the job may go on by itself; `sendWhileRunning` is true.
289
+
290
+ | Concern | How |
291
+ | ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------ |
292
+ | The answer mark | `onmark(turn, verdict)` on `Conversation`: resolve true once recorded. Offered on the last settled answer only; without it no mark renders |
293
+ | Waiting, still answering | `waiting` and `answering` on `Conversation`, from the controller |
294
+ | The allowance | `FairUseNotice quota={…}` in the composer snippet: a count while there are questions left, a notice with `support_url` once there are none. Nothing for `exempt`, or a `limit` of 0 |
295
+ | Past conversations | `ConversationList`: newest first, `href` makes each row a link, and each act renders only when its handler is given; a delete asks first |
296
+
183
297
  ### Watching a session somebody else started
184
298
 
185
299
  A session the APP started — a persona reading a document with nobody asking —
@@ -397,8 +511,10 @@ paragraphs addressed to the model before the question renders, so a
397
511
  colleague never sees it. Pass the question as it went on the wire; the
398
512
  transcript shows what they asked.
399
513
 
400
- `createHistory(namespace)` from `@poodle64/librarian/history` gives each app,
401
- or each room inside an app, its own `localStorage` key:
514
+ `createHistory(namespace)` from `@poodle64/librarian/history` keeps a
515
+ browser-only list for a page with no server-side one (a page on `Chat` reads
516
+ the server's), under its own `localStorage` key for each app, or each room
517
+ inside an app:
402
518
 
403
519
  ```ts
404
520
  import { createHistory, titleFrom } from '@poodle64/librarian/history';
@@ -416,7 +532,7 @@ pnpm run test # build + vitest
416
532
  pnpm run screenshots # the state grid, real engine (see below)
417
533
  ```
418
534
 
419
- `docs/screenshots/` is seventeen states x three widths x both themes, taken by
535
+ `docs/screenshots/` is twenty-three states x three widths x both themes, taken by
420
536
  `scripts/screenshots.mjs` against the console's `/librarian` lab route
421
537
  running from its own static build. The same script asserts what a screenshot
422
538
  cannot: that nothing scrolls sideways at any width, that the source pane
@@ -427,9 +543,13 @@ its question and takes the rest of the row with it, that the persona's
427
543
  between-tool narration is nowhere in the answer prose before the activity
428
544
  line is opened, that a job's artefact and the pages it read open in the
429
545
  host's own columns and never in a pane of the package's, and that the reading
430
- column holds its 46rem measure and stays centred at every width. It exits
431
- non-zero on any of them. The job states fold a stream captured from the real
432
- CLI (`src/test/fixtures/`).
546
+ column holds its 46rem measure and stays centred at every width. Its `room`
547
+ scene is `Chat` itself over a fake of the room's routes, driven: a question
548
+ waits behind another, streams, counts against the allowance and takes a mark;
549
+ one asked again is stopped; a conversation reopened mid-answer says so and
550
+ settles; one is renamed and the open one deleted. It exits non-zero on any of
551
+ them. The job states fold a stream captured from the real CLI
552
+ (`src/test/fixtures/`).
433
553
 
434
554
  The console's `app.css` deliberately does NOT scan this package's `dist`, so
435
555
  the grid is taken in a consumer that compiles none of its Tailwind classes —
@@ -440,11 +560,14 @@ pnpm --filter @poodle64/console run build
440
560
  pnpm --filter @poodle64/librarian run screenshots
441
561
  ```
442
562
 
563
+ Where Playwright's own Chromium will not start (NixOS), name one that does:
564
+ `CHROMIUM=/nix/store/…-playwright-browsers/chromium-…/chrome-linux/chrome`.
565
+
443
566
  ## Releasing
444
567
 
445
568
  1. Change a component; bump `version` in `package.json` (CalVer).
446
569
  2. `pnpm build`, which runs `svelte-package` then `publint`.
447
570
  3. Commit, tag `librarian-v<version>`, push the tag.
448
- 4. `.github/workflows/publish.yaml` runs on that push and publishes through
449
- npm's trusted publisher. Watch it: `gh run list --workflow=publish.yaml`,
571
+ 4. `.github/workflows/publish-kit-typescript.yaml` (repo root) runs on that push and publishes through
572
+ npm's trusted publisher. Watch it: `gh run list -R radar-hooves/app-factory --workflow=publish-kit-typescript.yaml`,
450
573
  then `npm view @poodle64/librarian version`.
@@ -0,0 +1,189 @@
1
+ /**
2
+ * The page's controller: one conversation with one agent, over a room's routes
3
+ * or a job's watch, message and stop.
4
+ *
5
+ * A room is asked. Each question streams its own turn, the agent names the
6
+ * conversation on its `init` event, and the conversation is read back whole
7
+ * when it is reopened. The turn belongs to the server, not the page: a
8
+ * refresh, a locked phone or a closed tab leaves it running, so a reopened
9
+ * conversation whose answer is still being written shows that, with its
10
+ * clock, and is read again once it settles. Stop is a request to the server
11
+ * for the same reason, never just a closed connection.
12
+ *
13
+ * A job is watched. Somebody else started it; the reader follows it from its
14
+ * first event, says things to it while it works, and stops it. Its stream is
15
+ * folded by `Session`, and a stream that closes is opened again when the
16
+ * reader sends a message, or while the host says the job may carry on.
17
+ *
18
+ * Nothing here knows a URL. The transport is the host's own client over the
19
+ * routes, and its shapes are the routes' own bodies, so a stamped app passes
20
+ * what its client returns straight through.
21
+ */
22
+ import { type AgentEvent } from './client';
23
+ import type { Citation } from './citations';
24
+ import { type Turn } from './transcript.svelte';
25
+ /** One conversation in the list. */
26
+ export interface ConversationSummary {
27
+ /** The agent's own session id: what resumes it, what names it in the address. */
28
+ id: string;
29
+ title: string;
30
+ /** ISO 8601. */
31
+ last_activity_at: string;
32
+ /** ISO 8601 while an answer is being written; null once it settles, or once
33
+ * the worker writing it is gone. */
34
+ answering_since?: string | null;
35
+ }
36
+ /** One question and its answer, as the agent's transcript keeps them. */
37
+ export interface StoredTurn {
38
+ /** As the reader asked it, never with a preamble. */
39
+ question: string;
40
+ answer: string;
41
+ /** ISO 8601, when it was asked. */
42
+ at?: string | null;
43
+ citations?: Citation[];
44
+ }
45
+ /** A conversation reopened. */
46
+ export interface ConversationRead extends ConversationSummary {
47
+ turns: StoredTurn[];
48
+ }
49
+ /** Today's fair-use allowance. */
50
+ export interface Quota {
51
+ /** 0: no limit. */
52
+ limit: number;
53
+ remaining: number;
54
+ /** Nothing is counted for this person, and nothing is shown. */
55
+ exempt: boolean;
56
+ reached: boolean;
57
+ /** ISO 8601. */
58
+ resets_at: string;
59
+ support_url?: string | null;
60
+ }
61
+ /** A reader's verdict on one answer; each one replaces the last. */
62
+ export interface Verdict {
63
+ helpful: boolean;
64
+ note?: string | null;
65
+ }
66
+ export interface AskRequest {
67
+ question: string;
68
+ files: File[];
69
+ /** The conversation this carries on; null starts one. */
70
+ resume: string | null;
71
+ signal: AbortSignal;
72
+ }
73
+ /**
74
+ * A room's routes, as the host's own client reaches them. Every method but
75
+ * `ask` may reject; the controller says what failed.
76
+ */
77
+ export interface RoomTransport {
78
+ /** `POST rooms/{room}/ask`: `client.ask()` over it, which never throws. A
79
+ * refusal arrives as `library_error` with its `status`: 429 is a spent
80
+ * allowance, 409 a conversation already being answered. */
81
+ ask(request: AskRequest): AsyncIterable<AgentEvent>;
82
+ /** `GET conversations/{id}`. */
83
+ read(id: string): Promise<ConversationRead>;
84
+ /** `POST conversations/{id}/stop`. */
85
+ stop(id: string): Promise<void>;
86
+ /** `GET quota`. */
87
+ quota(): Promise<Quota>;
88
+ /** This person's conversations in this room. */
89
+ list(): Promise<ConversationSummary[]>;
90
+ /** `PATCH conversations/{id}`. */
91
+ rename(id: string, title: string): Promise<void>;
92
+ /** `DELETE conversations/{id}`. */
93
+ remove(id: string): Promise<void>;
94
+ /** `GET conversations/{id}/export`: the file, and the name to save it as. */
95
+ download(id: string): Promise<{
96
+ name: string;
97
+ body: Blob;
98
+ }>;
99
+ /** The answer mark, where the agent takes one. `turn` is the answer's
100
+ * zero-based position in the conversation. */
101
+ mark?(id: string, turn: number, verdict: Verdict): Promise<void>;
102
+ }
103
+ /** A job's routes. */
104
+ export interface JobTransport {
105
+ /** `GET …/jobs/{id}/watch`: `client.watch()` over it, which never throws. */
106
+ watch(id: string, signal: AbortSignal): AsyncIterable<AgentEvent>;
107
+ /** `POST …/jobs/{id}/message`: onto the run while it works, a new run once
108
+ * it has settled. */
109
+ message(id: string, text: string): Promise<void>;
110
+ /** `POST …/jobs/{id}/stop`. */
111
+ stop(id: string): Promise<void>;
112
+ /** The job may carry on by itself after its stream closes (a pipeline
113
+ * still on it), so the stream is watched again. Absent, it is watched
114
+ * again only when the reader sends a message. */
115
+ carriesOn?(): boolean;
116
+ }
117
+ export interface ChatOptions {
118
+ /** How often a conversation answering out of sight is read again. */
119
+ pollMs?: number;
120
+ /** How often a closed job is asked whether it carries on. */
121
+ rewatchMs?: number;
122
+ }
123
+ export declare class Chat {
124
+ #private;
125
+ /** The words in the box and the files with them: bind the composer here,
126
+ * so a question the server refuses comes back exactly as typed. */
127
+ draft: string;
128
+ files: File[];
129
+ /** When an answer this page is not streaming began, epoch ms: a
130
+ * conversation reopened mid-answer. */
131
+ answering: number | null;
132
+ /** The agent is answering somebody else's question first. */
133
+ waiting: boolean;
134
+ /** Today's allowance; null until read, and in a job. */
135
+ quota: Quota | null;
136
+ /** The open conversation, or the job; null for a new one. The host keeps
137
+ * it in the address. */
138
+ conversationId: string | null;
139
+ /** This person's conversations, as `list()` last read them; null until then. */
140
+ conversations: ConversationSummary[] | null;
141
+ listFailed: boolean;
142
+ constructor(transport: RoomTransport | JobTransport, options?: ChatOptions);
143
+ /** Every turn, in order. A live one grows in place; key on `version`. */
144
+ get turns(): Turn[];
145
+ /** Bumped on every event folded, for `Conversation`'s follow-scroll. */
146
+ get version(): number;
147
+ /** This page is streaming an answer. */
148
+ get running(): boolean;
149
+ /** Something is being answered, here or out of sight: the composer waits
150
+ * and offers Stop. */
151
+ get busy(): boolean;
152
+ /** A job reads what the reader says while it works; a room waits for its answer. */
153
+ get sendWhileRunning(): boolean;
154
+ /** Reopen a conversation, or start watching a job. False when it could not
155
+ * be read, which leaves the page on a new conversation.
156
+ *
157
+ * Safe to call from an effect that follows the page's address: nothing
158
+ * it reads of its own becomes that effect's to follow. */
159
+ open(id: string): Promise<boolean>;
160
+ /** Back to a new conversation. A turn still being answered goes on without
161
+ * the page, and is there when its conversation is reopened. Safe to call
162
+ * from an effect, as `open` is. */
163
+ new(): void;
164
+ /**
165
+ * Ask `question`, or what is in the box. Resolves once the turn settles:
166
+ * true if it was asked, false if it was refused, here or by the server, in
167
+ * which case typed words go back into the box.
168
+ */
169
+ ask(question?: string): Promise<boolean>;
170
+ /** The last question again, as a new turn: the conversation is append-only,
171
+ * because the agent's transcript is. */
172
+ again(): Promise<boolean>;
173
+ /** Stop the answer being written, wherever it is being written. */
174
+ stop(): Promise<void>;
175
+ /** Read today's allowance again. One that cannot be read is left as it was. */
176
+ refreshQuota(): Promise<void>;
177
+ /** Read this person's conversations. Read again after every answer once
178
+ * read at all, so the list follows what they ask. */
179
+ list(): Promise<boolean>;
180
+ rename(id: string, title: string): Promise<boolean>;
181
+ /** Forget a conversation everywhere. The open one, forgotten, leaves a new one. */
182
+ remove(id: string): Promise<boolean>;
183
+ /** Save a conversation as the file the server names. Fetched rather than
184
+ * navigated to, so one deleted a moment ago elsewhere is a false here,
185
+ * not the reader's page replaced by an error body. */
186
+ download(id: string): Promise<boolean>;
187
+ /** Mark an answer the agent holds. False when it could not be recorded. */
188
+ mark(turn: Turn, verdict: Verdict): Promise<boolean>;
189
+ }