@poodle64/librarian 2026.9.22 → 2026.9.24

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
@@ -179,6 +179,8 @@ on it fires once and never again.
179
179
  | The empty state | `welcome` and up to three `examples` |
180
180
  | 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 |
181
181
  | The words themselves | `copy`: a partial of `LibrarianCopy`. Every component resolves it itself, so overriding one line does not mean restating the rest |
182
+ | An answer its own way | `turn`: a snippet given each turn's `AgentTranscriptProps` in place of `AgentTranscript`. Render your own from the parts, or `AgentTranscript` inside your own to add to it |
183
+ | What leads a conversation | `lead`: a snippet inside the scroll, under the opening and the scope, so a tall one scrolls with the conversation instead of taking its height |
182
184
 
183
185
  Nothing here fetches on its own behalf. The library's document read is
184
186
  authenticated, and a package that called it directly would be reaching past
@@ -243,6 +245,13 @@ the room's routes, and the components bound to it:
243
245
 
244
246
  `chat.list()` reads the list once; after that it follows every answer.
245
247
 
248
+ A turn the host asks as a `kind` (`chat.ask(question, 'briefing')`, from
249
+ `Composer`'s `onbriefing`) goes to the route as `client.ask()`'s `kind`, comes
250
+ back on `StoredTurn.kind`, and carries whatever `new Chat(transport, {
251
+ artefact })` says that kind opens, on a live turn and a reopened one alike:
252
+ `{ title: 'Briefing', isAnswer: true }` cards it and opens its prose in the
253
+ column. `again()` asks it as the same kind.
254
+
246
255
  The transport is the app's own client over its routes, and the shapes are
247
256
  the routes' own bodies (`ConversationSummary`, `ConversationRead`,
248
257
  `StoredTurn`, `Quota`), so the stamped slice returns them as they are and
@@ -568,6 +577,6 @@ Where Playwright's own Chromium will not start (NixOS), name one that does:
568
577
  1. Change a component; bump `version` in `package.json` (CalVer).
569
578
  2. `pnpm build`, which runs `svelte-package` then `publint`.
570
579
  3. Commit, tag `librarian-v<version>`, push the tag.
571
- 4. `.github/workflows/publish.yaml` runs on that push and publishes through
572
- npm's trusted publisher. Watch it: `gh run list --workflow=publish.yaml`,
580
+ 4. `.github/workflows/publish-kit-typescript.yaml` (repo root) runs on that push and publishes through
581
+ npm's trusted publisher. Watch it: `gh run list -R radar-hooves/app-factory --workflow=publish-kit-typescript.yaml`,
573
582
  then `npm view @poodle64/librarian version`.
@@ -21,7 +21,7 @@
21
21
  */
22
22
  import { type AgentEvent } from './client';
23
23
  import type { Citation } from './citations';
24
- import { type Turn } from './transcript.svelte';
24
+ import { type Artefact, type Turn } from './transcript.svelte';
25
25
  /** One conversation in the list. */
26
26
  export interface ConversationSummary {
27
27
  /** The agent's own session id: what resumes it, what names it in the address. */
@@ -41,6 +41,8 @@ export interface StoredTurn {
41
41
  /** ISO 8601, when it was asked. */
42
42
  at?: string | null;
43
43
  citations?: Citation[];
44
+ /** What the host asked for, when it was not a plain question. */
45
+ kind?: string | null;
44
46
  }
45
47
  /** A conversation reopened. */
46
48
  export interface ConversationRead extends ConversationSummary {
@@ -69,6 +71,8 @@ export interface AskRequest {
69
71
  /** The conversation this carries on; null starts one. */
70
72
  resume: string | null;
71
73
  signal: AbortSignal;
74
+ /** What the host asked for, when it is not a plain question: `client.ask()`'s `kind`. */
75
+ kind?: string;
72
76
  }
73
77
  /**
74
78
  * A room's routes, as the host's own client reaches them. Every method but
@@ -119,6 +123,10 @@ export interface ChatOptions {
119
123
  pollMs?: number;
120
124
  /** How often a closed job is asked whether it carries on. */
121
125
  rewatchMs?: number;
126
+ /** What a turn asked as `kind` produces for the reader to open, in the
127
+ * host's words (a briefing: `{ title, isAnswer: true }`), on a turn asked
128
+ * here and on one read back alike. None for a plain question. */
129
+ artefact?: (kind: string) => Artefact | undefined;
122
130
  }
123
131
  export declare class Chat {
124
132
  #private;
@@ -162,11 +170,12 @@ export declare class Chat {
162
170
  * from an effect, as `open` is. */
163
171
  new(): void;
164
172
  /**
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.
173
+ * Ask `question`, or what is in the box, as a `kind` of turn when it is not
174
+ * a plain question. Resolves once the turn settles: true if it was asked,
175
+ * false if it was refused, here or by the server, in which case typed words
176
+ * go back into the box.
168
177
  */
169
- ask(question?: string): Promise<boolean>;
178
+ ask(question?: string, kind?: string): Promise<boolean>;
170
179
  /** The last question again, as a new turn: the conversation is append-only,
171
180
  * because the agent's transcript is. */
172
181
  again(): Promise<boolean>;
@@ -48,6 +48,7 @@ export class Chat {
48
48
  #job = null;
49
49
  #pollMs;
50
50
  #rewatchMs;
51
+ #artefact;
51
52
  #turns = $state([]);
52
53
  #version = $state(0);
53
54
  #running = $state(false);
@@ -71,6 +72,7 @@ export class Chat {
71
72
  this.#room = transport;
72
73
  this.#pollMs = options.pollMs ?? 5000;
73
74
  this.#rewatchMs = options.rewatchMs ?? 2000;
75
+ this.#artefact = options.artefact;
74
76
  }
75
77
  /** Every turn, in order. A live one grows in place; key on `version`. */
76
78
  get turns() {
@@ -142,11 +144,12 @@ export class Chat {
142
144
  });
143
145
  }
144
146
  /**
145
- * Ask `question`, or what is in the box. Resolves once the turn settles:
146
- * true if it was asked, false if it was refused, here or by the server, in
147
- * which case typed words go back into the box.
147
+ * Ask `question`, or what is in the box, as a `kind` of turn when it is not
148
+ * a plain question. Resolves once the turn settles: true if it was asked,
149
+ * false if it was refused, here or by the server, in which case typed words
150
+ * go back into the box.
148
151
  */
149
- async ask(question) {
152
+ async ask(question, kind) {
150
153
  const typed = question === undefined;
151
154
  const text = (question ?? this.draft).trim();
152
155
  if (!text)
@@ -160,13 +163,13 @@ export class Chat {
160
163
  this.draft = '';
161
164
  this.files = [];
162
165
  }
163
- return this.#ask(text, files, typed);
166
+ return this.#ask(text, files, typed, kind);
164
167
  }
165
168
  /** The last question again, as a new turn: the conversation is append-only,
166
169
  * because the agent's transcript is. */
167
170
  again() {
168
- const last = this.turns.at(-1)?.question;
169
- return last ? this.ask(last) : Promise.resolve(false);
171
+ const last = this.turns.at(-1);
172
+ return last?.question ? this.ask(last.question, last.kind) : Promise.resolve(false);
170
173
  }
171
174
  /** Stop the answer being written, wherever it is being written. */
172
175
  async stop() {
@@ -298,7 +301,7 @@ export class Chat {
298
301
  this.answering = null;
299
302
  this.waiting = false;
300
303
  }
301
- async #ask(text, files, typed) {
304
+ async #ask(text, files, typed, kind) {
302
305
  const room = this.#room;
303
306
  const mine = this.#generation;
304
307
  const stream = { controller: new AbortController(), stopWanted: false };
@@ -310,7 +313,9 @@ export class Chat {
310
313
  blocks: [],
311
314
  outcome: null,
312
315
  citations: [],
313
- suggestions: []
316
+ suggestions: [],
317
+ kind,
318
+ artefact: this.#card(kind)
314
319
  });
315
320
  const live = this.#turns[this.#turns.length - 1];
316
321
  const state = foldState();
@@ -323,7 +328,8 @@ export class Chat {
323
328
  question: text,
324
329
  files,
325
330
  resume: this.conversationId,
326
- signal: stream.controller.signal
331
+ signal: stream.controller.signal,
332
+ kind
327
333
  })) {
328
334
  const named = event.type === 'system' && event.subtype === 'init' ? event.session_id : undefined;
329
335
  if (stream.stopWanted) {
@@ -400,6 +406,10 @@ export class Chat {
400
406
  this.#finish();
401
407
  return true;
402
408
  }
409
+ /** What a turn of `kind` opens, if the host says it opens anything. */
410
+ #card(kind) {
411
+ return kind ? this.#artefact?.(kind) : undefined;
412
+ }
403
413
  /** A turn this page streamed has settled, one way or another. */
404
414
  #finish() {
405
415
  this.#stream = null;
@@ -415,7 +425,10 @@ export class Chat {
415
425
  * read again until it settles. */
416
426
  #show(read, mine) {
417
427
  clearTimeout(this.#poll);
418
- const turns = read.turns.map((stored, index) => storedTurn(read.id, index, stored));
428
+ const turns = read.turns.map((stored, index) => ({
429
+ ...storedTurn(read.id, index, stored),
430
+ artefact: this.#card(stored.kind)
431
+ }));
419
432
  this.#held = new Set(turns.map((t) => t.id));
420
433
  const since = epoch(read.answering_since);
421
434
  if (since !== null) {
@@ -537,7 +550,8 @@ function storedTurn(conversation, index, stored) {
537
550
  at: epoch(stored.at) ?? undefined,
538
551
  blocks: stored.answer ? [{ kind: 'text', index: 0, text: stored.answer }] : [],
539
552
  outcome: null,
540
- citations: stored.citations ?? []
553
+ citations: stored.citations ?? [],
554
+ kind: stored.kind ?? undefined
541
555
  };
542
556
  }
543
557
  function epoch(value) {
@@ -41,7 +41,9 @@
41
41
  import Markdown from '../markdown/markdown.svelte';
42
42
  import SourceList from '../source-list/source-list.svelte';
43
43
 
44
- interface Props {
44
+ /** Everything one turn renders from: what `Conversation` hands a host's
45
+ * own `turn` snippet, so it can render this component or its own. */
46
+ export interface AgentTranscriptProps {
45
47
  question: string;
46
48
  blocks: Block[];
47
49
  outcome: Outcome | null;
@@ -109,7 +111,7 @@
109
111
  waiting = false,
110
112
  answering,
111
113
  onmark
112
- }: Props = $props();
114
+ }: AgentTranscriptProps = $props();
113
115
 
114
116
  // The prose IS the artefact: it reads in the column, and the transcript
115
117
  // shows the card alone.
@@ -393,7 +395,7 @@
393
395
 
394
396
  .ds-lib-ask {
395
397
  display: flex;
396
- max-width: 100%;
398
+ width: 100%;
397
399
  flex-direction: column;
398
400
  align-items: flex-end;
399
401
  gap: 0.25rem;
@@ -2,7 +2,9 @@ import { type Artefact, type Block, type DescribeTool, type Outcome } from '../.
2
2
  import { type Citation } from '../../citations';
3
3
  import { type LibrarianCopy } from '../../copy';
4
4
  import type { Verdict } from '../../chat.svelte';
5
- interface Props {
5
+ /** Everything one turn renders from: what `Conversation` hands a host's
6
+ * own `turn` snippet, so it can render this component or its own. */
7
+ export interface AgentTranscriptProps {
6
8
  question: string;
7
9
  blocks: Block[];
8
10
  outcome: Outcome | null;
@@ -48,6 +50,6 @@ interface Props {
48
50
  * control that records nothing is worse than none. */
49
51
  onmark?: (verdict: Verdict) => Promise<boolean>;
50
52
  }
51
- declare const AgentTranscript: import("svelte").Component<Props, {}, "">;
53
+ declare const AgentTranscript: import("svelte").Component<AgentTranscriptProps, {}, "">;
52
54
  type AgentTranscript = ReturnType<typeof AgentTranscript>;
53
55
  export default AgentTranscript;
@@ -1,2 +1,2 @@
1
- export { default as AgentTranscript } from './agent-transcript.svelte';
1
+ export { default as AgentTranscript, type AgentTranscriptProps } from './agent-transcript.svelte';
2
2
  export { default } from './agent-transcript.svelte';
@@ -16,7 +16,9 @@
16
16
  import type { Citation, LoadDocument } from '../../citations';
17
17
  import { DEFAULT_PERSONA, personaName, resolveCopy, type LibrarianCopy } from '../../copy';
18
18
  import { FollowScroll } from '../../follow-scroll.svelte';
19
- import AgentTranscript from '../agent-transcript/agent-transcript.svelte';
19
+ import AgentTranscript, {
20
+ type AgentTranscriptProps
21
+ } from '../agent-transcript/agent-transcript.svelte';
20
22
  import ArtefactPane from '../artefact-pane/artefact-pane.svelte';
21
23
  import DocumentPane from '../document-pane/document-pane.svelte';
22
24
  import ScopeStatement from '../scope-statement/scope-statement.svelte';
@@ -81,6 +83,15 @@
81
83
  * pane narrows it too — a composer the host places outside slides
82
84
  * under the pane the moment one opens. */
83
85
  composer?: Snippet;
86
+ /** Renders each turn in place of `AgentTranscript`, given the props it
87
+ * would have taken: a host that presents an answer its own way (its
88
+ * prose, its citation marks, its links) renders its own, and one that
89
+ * only adds to it renders `AgentTranscript` inside it. */
90
+ turn?: Snippet<[AgentTranscriptProps]>;
91
+ /** Before the first turn, inside the scroll and under the opening and
92
+ * the scope: what the host shows ahead of a question, which scrolls
93
+ * with the conversation rather than taking its height. */
94
+ lead?: Snippet;
84
95
  }
85
96
 
86
97
  let {
@@ -104,7 +115,9 @@
104
115
  showing,
105
116
  describeTool,
106
117
  collectionNames = new Set(),
107
- composer
118
+ composer,
119
+ turn: presentTurn,
120
+ lead
108
121
  }: Props = $props();
109
122
 
110
123
  // Resolved ONCE, here, and handed down whole: every child takes `copy` and
@@ -267,32 +280,38 @@
267
280
  <ScopeStatement statement={scope} expanded={turns.length === 0} copy={words} />
268
281
  {/if}
269
282
 
283
+ {@render lead?.()}
284
+
270
285
  {#each turns as turn, index (turn.id)}
271
286
  {@const last = index === turns.length - 1}
272
- <AgentTranscript
273
- question={turn.question}
274
- blocks={turn.blocks}
275
- outcome={turn.outcome}
276
- running={running && last}
277
- waiting={waiting && last}
278
- answering={last ? (answering ?? undefined) : undefined}
279
- citations={turn.citations ?? []}
280
- suggestions={turn.suggestions ?? []}
281
- {collectionNames}
282
- copy={words}
283
- name={who}
284
- at={turn.at ?? stamps[turn.id]}
285
- artefact={turn.artefact}
286
- artefactOpen={isShowing(turn)}
287
- onopenartefact={opener(turn)}
288
- {describeTool}
289
- oncite={oncite || loadDocument ? cite : undefined}
290
- onregenerate={last && !running ? onregenerate : undefined}
291
- onsuggest={last && !running ? onsuggest : undefined}
292
- onmark={last && !running && onmark
293
- ? (verdict) => onmark(turn, verdict)
294
- : undefined}
295
- />
287
+ {@const props = {
288
+ question: turn.question,
289
+ blocks: turn.blocks,
290
+ outcome: turn.outcome,
291
+ running: running && last,
292
+ waiting: waiting && last,
293
+ answering: last ? (answering ?? undefined) : undefined,
294
+ citations: turn.citations ?? [],
295
+ suggestions: turn.suggestions ?? [],
296
+ collectionNames,
297
+ copy: words,
298
+ name: who,
299
+ at: turn.at ?? stamps[turn.id],
300
+ artefact: turn.artefact,
301
+ artefactOpen: isShowing(turn),
302
+ onopenartefact: opener(turn),
303
+ describeTool,
304
+ oncite: oncite || loadDocument ? cite : undefined,
305
+ onregenerate: last && !running ? onregenerate : undefined,
306
+ onsuggest: last && !running ? onsuggest : undefined,
307
+ onmark:
308
+ last && !running && onmark ? (verdict: Verdict) => onmark(turn, verdict) : undefined
309
+ } satisfies AgentTranscriptProps}
310
+ {#if presentTurn}
311
+ {@render presentTurn(props)}
312
+ {:else}
313
+ <AgentTranscript {...props} />
314
+ {/if}
296
315
  {/each}
297
316
  </div>
298
317
  </div>
@@ -3,6 +3,7 @@ import type { DescribeTool, Turn } from '../../transcript.svelte';
3
3
  import type { Verdict } from '../../chat.svelte';
4
4
  import type { Citation, LoadDocument } from '../../citations';
5
5
  import { type LibrarianCopy } from '../../copy';
6
+ import { type AgentTranscriptProps } from '../agent-transcript/agent-transcript.svelte';
6
7
  interface Props {
7
8
  turns: Turn[];
8
9
  running: boolean;
@@ -58,6 +59,15 @@ interface Props {
58
59
  * pane narrows it too — a composer the host places outside slides
59
60
  * under the pane the moment one opens. */
60
61
  composer?: Snippet;
62
+ /** Renders each turn in place of `AgentTranscript`, given the props it
63
+ * would have taken: a host that presents an answer its own way (its
64
+ * prose, its citation marks, its links) renders its own, and one that
65
+ * only adds to it renders `AgentTranscript` inside it. */
66
+ turn?: Snippet<[AgentTranscriptProps]>;
67
+ /** Before the first turn, inside the scroll and under the opening and
68
+ * the scope: what the host shows ahead of a question, which scrolls
69
+ * with the conversation rather than taking its height. */
70
+ lead?: Snippet;
61
71
  }
62
72
  declare const Conversation: import("svelte").Component<Props, {}, "">;
63
73
  type Conversation = ReturnType<typeof Conversation>;
@@ -205,6 +205,9 @@ export interface Turn {
205
205
  /** Something this turn produced for the reader to open, carded under the
206
206
  * answer once the turn settles. */
207
207
  artefact?: Artefact;
208
+ /** What the host asked for when this was not a plain question
209
+ * (`'briefing'`), in its own word, as `client.ask()` sent it. */
210
+ kind?: string;
208
211
  }
209
212
  /** A turn's artefact, in the host's own words. */
210
213
  export interface Artefact {
package/package.json CHANGED
@@ -1,17 +1,18 @@
1
1
  {
2
2
  "name": "@poodle64/librarian",
3
- "version": "2026.9.22",
3
+ "version": "2026.9.24",
4
4
  "description": "An agent's conversation surface as a consumable Svelte 5 package: the stream client, transcript state and self-styling chat components (transcript, composer, markdown) every household app renders instead of rebuilding, speaking as whichever persona the app names.",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "repository": {
8
8
  "type": "git",
9
- "url": "git+https://github.com/radar-hooves/design-system.git"
9
+ "url": "git+https://github.com/radar-hooves/app-factory.git",
10
+ "directory": "kits/typescript/packages/librarian"
10
11
  },
11
12
  "publishConfig": {
12
13
  "registry": "https://registry.npmjs.org",
13
14
  "access": "public",
14
- "provenance": false
15
+ "provenance": true
15
16
  },
16
17
  "files": [
17
18
  "dist"