@poodle64/librarian 2026.9.25 → 2026.9.26

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
@@ -252,6 +252,46 @@ artefact })` says that kind opens, on a live turn and a reopened one alike:
252
252
  `{ title: 'Briefing', isAnswer: true }` cards it and opens its prose in the
253
253
  column. `again()` asks it as the same kind.
254
254
 
255
+ ### Depth: Quick or Thorough, and what it cost
256
+
257
+ `Composer`'s `depth` is optional and bindable, and entirely opt-in: omit it
258
+ and the composer renders exactly as it always has. Bind it, the host's own
259
+ `$state<Depth>('quick')`, and the switch always renders, both sides always
260
+ enabled. The operator's ruling (30/09/2026) is that once a room offers the
261
+ choice, it is never disabled, hidden, greyed out, or explained by naming a
262
+ model. `depth` is `'quick' | 'thorough'`, exported from
263
+ `@poodle64/librarian/composer` and `@poodle64/librarian/client`.
264
+
265
+ ```svelte
266
+ <Composer bind:value={chat.draft} bind:depth onsubmit={() => chat.ask(undefined, undefined, depth)} ... />
267
+ ```
268
+
269
+ `chat.ask(question?, kind?, depth?)` carries it to `client.ask()`'s `depth`,
270
+ `StoredTurn.depth` reads it back, and `again()` keeps it: the same shape as
271
+ `kind`, alongside it rather than replacing it. A host whose route does not
272
+ read `depth` yet is unaffected, since the field rides along and is ignored.
273
+
274
+ Under a settled answer, `AgentTranscript`'s footer folds the turn's `depth`
275
+ and `outcome.costUsd` into the ONE line that already showed the duration:
276
+ `Quick · 56 s · about A$0.34`. Both are opt-in the same way: pass `depth` to
277
+ `Conversation`/`AgentTranscript` (it already has it, from `Turn.depth`) and a
278
+ `formatCost(usd) => string` to price it in the host's own currency and words
279
+ ("about A$0.34"; cadmus converts USD to AUD with its own setting). Without
280
+ `depth` the line renders exactly as before (`56.3s`). Without `formatCost`,
281
+ or where `costUsd` is `0` (a local model), it shows depth and duration only,
282
+ never a raw USD figure and never which model answered.
283
+
284
+ Past `turnLimit` turns (`Composer`'s prop, 8 by default), a quiet banner
285
+ appears above the box, "This conversation is getting long. A new question
286
+ keeps Milton quick." with a "New question" button, because a follow-up
287
+ re-reads the whole conversation, so each one costs more than the last.
288
+ `turnCount` and `onnewquestion` are both required for it to render; either
289
+ missing and the composer stays as it is today.
290
+
291
+ None of this prices anything before asking, and none of it names a model.
292
+ That is the operator's ruling on what a colleague sees, and the package
293
+ never puts a control in front of it.
294
+
255
295
  The transport is the app's own client over its routes, and the shapes are
256
296
  the routes' own bodies (`ConversationSummary`, `ConversationRead`,
257
297
  `StoredTurn`, `Quota`), so the stamped slice returns them as they are and
@@ -19,7 +19,7 @@
19
19
  * routes, and its shapes are the routes' own bodies, so a stamped app passes
20
20
  * what its client returns straight through.
21
21
  */
22
- import { type AgentEvent } from './client';
22
+ import { type AgentEvent, type Depth } from './client';
23
23
  import type { Citation } from './citations';
24
24
  import { type Artefact, type Turn } from './transcript.svelte';
25
25
  /** One conversation in the list. */
@@ -43,6 +43,8 @@ export interface StoredTurn {
43
43
  citations?: Citation[];
44
44
  /** What the host asked for, when it was not a plain question. */
45
45
  kind?: string | null;
46
+ /** How hard the persona worked on this one. */
47
+ depth?: Depth | null;
46
48
  }
47
49
  /** A conversation reopened. */
48
50
  export interface ConversationRead extends ConversationSummary {
@@ -73,6 +75,8 @@ export interface AskRequest {
73
75
  signal: AbortSignal;
74
76
  /** What the host asked for, when it is not a plain question: `client.ask()`'s `kind`. */
75
77
  kind?: string;
78
+ /** How hard the persona should work on this one: `client.ask()`'s `depth`. */
79
+ depth?: Depth;
76
80
  }
77
81
  /**
78
82
  * A room's routes, as the host's own client reaches them. Every method but
@@ -171,13 +175,13 @@ export declare class Chat {
171
175
  new(): void;
172
176
  /**
173
177
  * 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.
178
+ * a plain question, at a `depth` when the host offers the choice. Resolves
179
+ * once the turn settles: true if it was asked, false if it was refused,
180
+ * here or by the server, in which case typed words go back into the box.
177
181
  */
178
- ask(question?: string, kind?: string): Promise<boolean>;
182
+ ask(question?: string, kind?: string, depth?: Depth): Promise<boolean>;
179
183
  /** The last question again, as a new turn: the conversation is append-only,
180
- * because the agent's transcript is. */
184
+ * because the agent's transcript is. Same kind, same depth. */
181
185
  again(): Promise<boolean>;
182
186
  /** Stop the answer being written, wherever it is being written. */
183
187
  stop(): Promise<void>;
@@ -145,11 +145,11 @@ export class Chat {
145
145
  }
146
146
  /**
147
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
+ * a plain question, at a `depth` when the host offers the choice. Resolves
149
+ * once the turn settles: true if it was asked, false if it was refused,
150
+ * here or by the server, in which case typed words go back into the box.
151
151
  */
152
- async ask(question, kind) {
152
+ async ask(question, kind, depth) {
153
153
  const typed = question === undefined;
154
154
  const text = (question ?? this.draft).trim();
155
155
  if (!text)
@@ -163,13 +163,15 @@ export class Chat {
163
163
  this.draft = '';
164
164
  this.files = [];
165
165
  }
166
- return this.#ask(text, files, typed, kind);
166
+ return this.#ask(text, files, typed, kind, depth);
167
167
  }
168
168
  /** The last question again, as a new turn: the conversation is append-only,
169
- * because the agent's transcript is. */
169
+ * because the agent's transcript is. Same kind, same depth. */
170
170
  again() {
171
171
  const last = this.turns.at(-1);
172
- return last?.question ? this.ask(last.question, last.kind) : Promise.resolve(false);
172
+ return last?.question
173
+ ? this.ask(last.question, last.kind, last.depth)
174
+ : Promise.resolve(false);
173
175
  }
174
176
  /** Stop the answer being written, wherever it is being written. */
175
177
  async stop() {
@@ -301,7 +303,7 @@ export class Chat {
301
303
  this.answering = null;
302
304
  this.waiting = false;
303
305
  }
304
- async #ask(text, files, typed, kind) {
306
+ async #ask(text, files, typed, kind, depth) {
305
307
  const room = this.#room;
306
308
  const mine = this.#generation;
307
309
  const stream = { controller: new AbortController(), stopWanted: false };
@@ -315,6 +317,7 @@ export class Chat {
315
317
  citations: [],
316
318
  suggestions: [],
317
319
  kind,
320
+ depth,
318
321
  artefact: this.#card(kind)
319
322
  });
320
323
  const live = this.#turns[this.#turns.length - 1];
@@ -329,7 +332,8 @@ export class Chat {
329
332
  files,
330
333
  resume: this.conversationId,
331
334
  signal: stream.controller.signal,
332
- kind
335
+ kind,
336
+ depth
333
337
  })) {
334
338
  const named = event.type === 'system' && event.subtype === 'init' ? event.session_id : undefined;
335
339
  if (stream.stopWanted) {
@@ -551,7 +555,8 @@ function storedTurn(conversation, index, stored) {
551
555
  blocks: stored.answer ? [{ kind: 'text', index: 0, text: stored.answer }] : [],
552
556
  outcome: null,
553
557
  citations: stored.citations ?? [],
554
- kind: stored.kind ?? undefined
558
+ kind: stored.kind ?? undefined,
559
+ depth: stored.depth ?? undefined
555
560
  };
556
561
  }
557
562
  function epoch(value) {
package/dist/client.d.ts CHANGED
@@ -8,6 +8,15 @@
8
8
  * event type needs no change on either side.
9
9
  */
10
10
  import type { Citation } from './citations';
11
+ /**
12
+ * How hard the persona works on one question: a reading budget and a model,
13
+ * both the operator's to set per deployment, never the colleague's concern.
14
+ * `quick` is always the default; `thorough` always means something — a
15
+ * bigger budget on the strongest model the room allows, even where that
16
+ * model is a local one — so a host never has a state where the choice is
17
+ * greyed out or explained away.
18
+ */
19
+ export type Depth = 'quick' | 'thorough';
11
20
  /** A Claude Code message, whole. `content` is a plain string only on a user frame. */
12
21
  export interface AgentMessage {
13
22
  id?: string;
@@ -95,6 +104,10 @@ export interface AskOptions {
95
104
  * (`'briefing'`) and interprets what comes back accordingly — the wire
96
105
  * vocabulary is the host's own, never a fixed set here. */
97
106
  kind?: string;
107
+ /** How hard the persona should work on this one. Absent asks for
108
+ * whatever the route does by default, so a host that never reads
109
+ * `Composer`'s `depth` sends exactly what it always has. */
110
+ depth?: Depth;
98
111
  signal?: AbortSignal;
99
112
  /** Where the ask lands. Each app mounts its own ask route. */
100
113
  endpoint?: string;
package/dist/client.js CHANGED
@@ -34,7 +34,8 @@ function requestInit(options, signal) {
34
34
  resume: options.resume ?? null,
35
35
  subtree: options.subtree ?? '',
36
36
  collections: options.collections ?? [],
37
- kind: options.kind
37
+ kind: options.kind,
38
+ depth: options.depth
38
39
  }),
39
40
  signal
40
41
  };
@@ -49,6 +50,8 @@ function requestInit(options, signal) {
49
50
  form.append('files[]', file, file.name);
50
51
  if (options.kind)
51
52
  form.append('kind', options.kind);
53
+ if (options.depth)
54
+ form.append('depth', options.depth);
52
55
  return { method: 'POST', credentials: 'include', body: form, signal };
53
56
  }
54
57
  /**
@@ -17,12 +17,14 @@
17
17
  import CopyIcon from '@lucide/svelte/icons/copy';
18
18
  import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw';
19
19
  import {
20
+ outcomeLine,
20
21
  readerQuestion,
21
22
  readFrom,
22
23
  segment,
23
24
  type Artefact,
24
25
  type Block,
25
26
  type DescribeTool,
27
+ type Depth,
26
28
  type Outcome,
27
29
  type TextBlock
28
30
  } from '../../transcript.svelte';
@@ -88,6 +90,14 @@
88
90
  /** Records a verdict on this answer. Omit and no mark renders: a
89
91
  * control that records nothing is worse than none. */
90
92
  onmark?: (verdict: Verdict) => Promise<boolean>;
93
+ /** How hard the persona worked on this one, for the footer's cost line
94
+ * (`Quick · 56 s · about A$0.34`). Absent renders the footer exactly
95
+ * as it always has: the duration alone. */
96
+ depth?: Depth;
97
+ /** Prices `outcome.costUsd` in the host's own currency and words
98
+ * ("about A$0.34"). The package never converts currency itself,
99
+ * and a zero-cost run (a local model) shows no cost regardless. */
100
+ formatCost?: (usd: number) => string;
91
101
  }
92
102
 
93
103
  let {
@@ -110,7 +120,9 @@
110
120
  describeTool,
111
121
  waiting = false,
112
122
  answering,
113
- onmark
123
+ onmark,
124
+ depth,
125
+ formatCost
114
126
  }: AgentTranscriptProps = $props();
115
127
 
116
128
  // The prose IS the artefact: it reads in the column, and the transcript
@@ -375,11 +387,12 @@
375
387
  <span>{words.askAgain}</span>
376
388
  </button>
377
389
  {/if}
378
- <!-- How long it took, which is not the same fact as when it was
390
+ <!-- What it took, which is not the same fact as when it was
379
391
  asked; both are on the card and neither stands in for the
380
- other. -->
392
+ other. Depth and cost fold into the SAME line, never a
393
+ second one, once the host offers either. -->
381
394
  {#if outcome && !failure}
382
- <span class="ds-lib-duration">{((outcome.durationMs ?? 0) / 1000).toFixed(1)}s</span>
395
+ <span class="ds-lib-duration">{outcomeLine(outcome, depth, words, formatCost)}</span>
383
396
  {/if}
384
397
  </div>
385
398
  {/if}
@@ -1,4 +1,4 @@
1
- import { type Artefact, type Block, type DescribeTool, type Outcome } from '../../transcript.svelte';
1
+ import { type Artefact, type Block, type DescribeTool, type Depth, type Outcome } from '../../transcript.svelte';
2
2
  import { type Citation } from '../../citations';
3
3
  import { type LibrarianCopy } from '../../copy';
4
4
  import type { Verdict } from '../../chat.svelte';
@@ -49,6 +49,14 @@ export interface AgentTranscriptProps {
49
49
  /** Records a verdict on this answer. Omit and no mark renders: a
50
50
  * control that records nothing is worse than none. */
51
51
  onmark?: (verdict: Verdict) => Promise<boolean>;
52
+ /** How hard the persona worked on this one, for the footer's cost line
53
+ * (`Quick · 56 s · about A$0.34`). Absent renders the footer exactly
54
+ * as it always has: the duration alone. */
55
+ depth?: Depth;
56
+ /** Prices `outcome.costUsd` in the host's own currency and words
57
+ * ("about A$0.34"). The package never converts currency itself,
58
+ * and a zero-cost run (a local model) shows no cost regardless. */
59
+ formatCost?: (usd: number) => string;
52
60
  }
53
61
  declare const AgentTranscript: import("svelte").Component<AgentTranscriptProps, {}, "">;
54
62
  type AgentTranscript = ReturnType<typeof AgentTranscript>;
@@ -14,6 +14,7 @@
14
14
  -->
15
15
  <script lang="ts">
16
16
  import ArrowUpIcon from '@lucide/svelte/icons/arrow-up';
17
+ import ClockIcon from '@lucide/svelte/icons/clock';
17
18
  import FileTextIcon from '@lucide/svelte/icons/file-text';
18
19
  import PaperclipIcon from '@lucide/svelte/icons/paperclip';
19
20
  import SquareIcon from '@lucide/svelte/icons/square';
@@ -25,8 +26,14 @@
25
26
  MAX_FILES,
26
27
  rejectionMessage
27
28
  } from '../../attachments';
29
+ import type { Depth } from '../../client';
28
30
  import { DEFAULT_PERSONA, resolveCopy, type LibrarianCopy } from '../../copy';
29
31
 
32
+ /** Past this many turns, the long-conversation banner offers to start
33
+ * over: a follow-up re-reads the whole conversation, so each one costs
34
+ * more than the last (measured — the longest ran 2.05M cached tokens). */
35
+ const DEFAULT_TURN_LIMIT = 8;
36
+
30
37
  export type Scope = 'document' | 'collection' | 'library';
31
38
 
32
39
  interface Props {
@@ -63,6 +70,21 @@
63
70
  * sit, in the host's words: what sending does here ("It carries on
64
71
  * from where it stopped."). */
65
72
  note?: string;
73
+ /** Quick or Thorough for the next question, as a two-way switch with a
74
+ * one-line hint. Bind it and pass it to `ask()`; omit it and nothing
75
+ * renders. Once shown it is never disabled or hidden, and never names
76
+ * the model behind it. */
77
+ depth?: Depth;
78
+ /** How many turns are in the conversation so far. Past `turnLimit`
79
+ * (8 by default), a quiet banner appears above the box offering to
80
+ * start over. Omit and no banner ever renders — a host that does not
81
+ * track turns keeps today's composer. */
82
+ turnCount?: number;
83
+ turnLimit?: number;
84
+ /** Starts a new conversation, from the banner's own button. Without it
85
+ * the banner never renders, past the limit or not — an offer with no
86
+ * action is worse than none. */
87
+ onnewquestion?: () => void;
66
88
  }
67
89
 
68
90
  let {
@@ -80,7 +102,11 @@
80
102
  name = DEFAULT_PERSONA,
81
103
  copy,
82
104
  note,
83
- sendWhileRunning = false
105
+ sendWhileRunning = false,
106
+ depth = $bindable(),
107
+ turnCount,
108
+ turnLimit = DEFAULT_TURN_LIMIT,
109
+ onnewquestion
84
110
  }: Props = $props();
85
111
 
86
112
  /** Nothing can be said right now: a chat whose answer is still coming. */
@@ -88,6 +114,12 @@
88
114
 
89
115
  const words = $derived(resolveCopy(copy, name));
90
116
 
117
+ // The banner needs somewhere to send a reader AND a reason to show at
118
+ // all: a host that never counts turns gets today's composer, unchanged.
119
+ const longConversation = $derived(
120
+ turnCount !== undefined && turnCount > turnLimit && Boolean(onnewquestion)
121
+ );
122
+
91
123
  // What Milton is being asked about — this document, this collection, or
92
124
  // the whole library. Callers that have no narrower scope pass neither
93
125
  // name, leaving "The whole library" the sole entry — and a sole entry is
@@ -212,6 +244,16 @@
212
244
  ? '0px'
213
245
  : 'env(safe-area-inset-bottom, 0px)'}"
214
246
  >
247
+ {#if longConversation}
248
+ <div class="ds-lib-long" role="status">
249
+ <ClockIcon size={16} />
250
+ <span>{words.longConversation}</span>
251
+ <button type="button" class="ds-lib-long-new" onclick={onnewquestion}>
252
+ {words.newQuestion}
253
+ </button>
254
+ </div>
255
+ {/if}
256
+
215
257
  <div class="ds-lib-box">
216
258
  {#if files.length > 0}
217
259
  <ul class="ds-lib-files">
@@ -279,6 +321,31 @@
279
321
  </button>
280
322
  {/if}
281
323
 
324
+ {#if depth !== undefined}
325
+ <div role="group" aria-label={words.depthGroupLabel} class="ds-lib-depth">
326
+ <button
327
+ type="button"
328
+ aria-pressed={depth === 'quick'}
329
+ class="ds-lib-depth-side"
330
+ class:is-chosen={depth === 'quick'}
331
+ onclick={() => (depth = 'quick')}
332
+ >
333
+ {words.depthQuick}
334
+ </button>
335
+ <button
336
+ type="button"
337
+ aria-pressed={depth === 'thorough'}
338
+ class="ds-lib-depth-side"
339
+ class:is-chosen={depth === 'thorough'}
340
+ onclick={() => (depth = 'thorough')}
341
+ >
342
+ {words.depthThorough}
343
+ </button>
344
+ </div>
345
+ <span class="ds-lib-note">
346
+ {depth === 'quick' ? words.depthQuickHint : words.depthThoroughHint}
347
+ </span>
348
+ {/if}
282
349
  {#if hasChoice}
283
350
  <div class="ds-lib-scopes">
284
351
  {#each choices as choice (choice.id)}
@@ -415,8 +482,10 @@
415
482
 
416
483
  .ds-lib-controls {
417
484
  display: flex;
485
+ flex-wrap: wrap;
418
486
  align-items: center;
419
487
  gap: 0.5rem;
488
+ row-gap: 0.375rem;
420
489
  padding: 0 0.75rem 0.625rem;
421
490
  }
422
491
 
@@ -538,6 +607,91 @@
538
607
  font-size: var(--ds-text-2xs);
539
608
  }
540
609
 
610
+ /* A labelled button group, never a select: two states a reader compares
611
+ at a glance, not a list to open. 44px touch targets throughout — the
612
+ one row here a colleague reaches for on every single question. */
613
+ .ds-lib-depth {
614
+ display: flex;
615
+ flex: none;
616
+ gap: 0.125rem;
617
+ border-radius: var(--ds-radius-lg);
618
+ background: var(--ds-color-surface-3);
619
+ padding: 0.1875rem;
620
+ }
621
+
622
+ .ds-lib-depth-side {
623
+ min-width: 2.75rem;
624
+ min-height: 2.75rem;
625
+ border: 0;
626
+ border-radius: var(--ds-radius-md);
627
+ background: none;
628
+ padding: 0 0.75rem;
629
+ color: var(--ds-color-muted-foreground);
630
+ font: inherit;
631
+ font-size: var(--ds-text-2xs);
632
+ cursor: pointer;
633
+ transition:
634
+ color 150ms ease,
635
+ background-color 150ms ease;
636
+ }
637
+
638
+ .ds-lib-depth-side:hover {
639
+ color: var(--ds-color-foreground);
640
+ }
641
+
642
+ .ds-lib-depth-side.is-chosen {
643
+ background: var(--ds-color-primary);
644
+ color: var(--ds-color-primary-foreground);
645
+ font-weight: 600;
646
+ }
647
+
648
+ .ds-lib-depth-side:focus-visible {
649
+ outline: 2px solid var(--ds-color-ring);
650
+ outline-offset: 2px;
651
+ }
652
+
653
+ /* Above the box, never inside it: it is about the conversation so far,
654
+ not this one question. Warning-tinted, never error — nothing has gone
655
+ wrong, a follow-up just costs more than the last one did. */
656
+ .ds-lib-long {
657
+ display: flex;
658
+ align-items: center;
659
+ gap: 0.625rem;
660
+ border: 1px solid color-mix(in oklab, var(--ds-color-status-warning) 40%, transparent);
661
+ border-radius: var(--ds-radius-lg);
662
+ background: color-mix(in oklab, var(--ds-color-status-warning) 12%, transparent);
663
+ padding: 0.5rem 0.5rem 0.5rem 0.875rem;
664
+ color: var(--ds-color-foreground);
665
+ font-size: 0.875rem;
666
+ }
667
+
668
+ .ds-lib-long span {
669
+ flex: 1;
670
+ }
671
+
672
+ .ds-lib-long-new {
673
+ min-height: 2.75rem;
674
+ flex: none;
675
+ border: 1px solid var(--ds-color-border);
676
+ border-radius: var(--ds-radius-md);
677
+ background: transparent;
678
+ padding: 0 0.875rem;
679
+ color: var(--ds-color-foreground);
680
+ font: inherit;
681
+ font-size: var(--ds-text-2xs);
682
+ cursor: pointer;
683
+ transition: background-color 150ms ease;
684
+ }
685
+
686
+ .ds-lib-long-new:hover {
687
+ background: var(--ds-color-surface-2);
688
+ }
689
+
690
+ .ds-lib-long-new:focus-visible {
691
+ outline: 2px solid var(--ds-color-ring);
692
+ outline-offset: 2px;
693
+ }
694
+
541
695
  @media (prefers-reduced-motion: reduce) {
542
696
  .ds-lib-composer-lift {
543
697
  transition: none;
@@ -1,3 +1,4 @@
1
+ import type { Depth } from '../../client';
1
2
  import { type LibrarianCopy } from '../../copy';
2
3
  export type Scope = 'document' | 'collection' | 'library';
3
4
  interface Props {
@@ -34,7 +35,22 @@ interface Props {
34
35
  * sit, in the host's words: what sending does here ("It carries on
35
36
  * from where it stopped."). */
36
37
  note?: string;
38
+ /** Quick or Thorough for the next question, as a two-way switch with a
39
+ * one-line hint. Bind it and pass it to `ask()`; omit it and nothing
40
+ * renders. Once shown it is never disabled or hidden, and never names
41
+ * the model behind it. */
42
+ depth?: Depth;
43
+ /** How many turns are in the conversation so far. Past `turnLimit`
44
+ * (8 by default), a quiet banner appears above the box offering to
45
+ * start over. Omit and no banner ever renders — a host that does not
46
+ * track turns keeps today's composer. */
47
+ turnCount?: number;
48
+ turnLimit?: number;
49
+ /** Starts a new conversation, from the banner's own button. Without it
50
+ * the banner never renders, past the limit or not — an offer with no
51
+ * action is worse than none. */
52
+ onnewquestion?: () => void;
37
53
  }
38
- declare const Composer: import("svelte").Component<Props, {}, "value" | "files">;
54
+ declare const Composer: import("svelte").Component<Props, {}, "value" | "depth" | "files">;
39
55
  type Composer = ReturnType<typeof Composer>;
40
56
  export default Composer;
@@ -1,3 +1,4 @@
1
1
  export { default as Composer } from './composer.svelte';
2
2
  export { default } from './composer.svelte';
3
3
  export type { Scope } from './composer.svelte';
4
+ export type { Depth } from '../../client';
@@ -78,6 +78,11 @@
78
78
  showing?: string;
79
79
  /** The persona's own words for its tools, and what they read. */
80
80
  describeTool?: DescribeTool;
81
+ /** Prices a turn's `outcome.costUsd` in the host's own currency and
82
+ * words ("about A$0.34"), for the footer's cost line beside a turn's
83
+ * `depth`. The package never converts currency itself; omitted, the
84
+ * line shows depth and duration only. */
85
+ formatCost?: (usd: number) => string;
81
86
  collectionNames?: Set<string>;
82
87
  /** The composer, rendered INSIDE the transcript column so the source
83
88
  * pane narrows it too — a composer the host places outside slides
@@ -117,7 +122,8 @@
117
122
  collectionNames = new Set(),
118
123
  composer,
119
124
  turn: presentTurn,
120
- lead
125
+ lead,
126
+ formatCost
121
127
  }: Props = $props();
122
128
 
123
129
  // Resolved ONCE, here, and handed down whole: every child takes `copy` and
@@ -311,6 +317,8 @@
311
317
  artefactOpen: isShowing(turn),
312
318
  onopenartefact: opener(turn),
313
319
  describeTool,
320
+ depth: turn.depth,
321
+ formatCost,
314
322
  oncite: oncite || loadDocument ? cite : undefined,
315
323
  onregenerate: last && !running ? onregenerate : undefined,
316
324
  onsuggest: last && !running ? onsuggest : undefined,
@@ -54,6 +54,11 @@ interface Props {
54
54
  showing?: string;
55
55
  /** The persona's own words for its tools, and what they read. */
56
56
  describeTool?: DescribeTool;
57
+ /** Prices a turn's `outcome.costUsd` in the host's own currency and
58
+ * words ("about A$0.34"), for the footer's cost line beside a turn's
59
+ * `depth`. The package never converts currency itself; omitted, the
60
+ * line shows depth and duration only. */
61
+ formatCost?: (usd: number) => string;
57
62
  collectionNames?: Set<string>;
58
63
  /** The composer, rendered INSIDE the transcript column so the source
59
64
  * pane narrows it too — a composer the host places outside slides
package/dist/copy.d.ts CHANGED
@@ -44,6 +44,18 @@ export interface LibrarianCopy {
44
44
  /** The composer's placeholder, idle and while an answer streams. */
45
45
  askPlaceholder: string;
46
46
  answeringPlaceholder: string;
47
+ /** The depth switch: its group's accessible name, each side's label, and
48
+ * the one-line hint beside it saying what the picked side is for. Never
49
+ * which model sits behind it — that is never the colleague's concern. */
50
+ depthGroupLabel: string;
51
+ depthQuick: string;
52
+ depthThorough: string;
53
+ depthQuickHint: string;
54
+ depthThoroughHint: string;
55
+ /** Past a few turns, the quiet line above the composer that offers to
56
+ * start over — a follow-up re-reads the whole conversation, so each one
57
+ * costs more than the last. */
58
+ longConversation: string;
47
59
  /** The pre-first-token words, cycled one every 2.6s. */
48
60
  working: string[];
49
61
  /** The scroll region's accessible name. */
package/dist/copy.js CHANGED
@@ -66,6 +66,12 @@ export function copyFor(name = DEFAULT_PERSONA) {
66
66
  welcome: `Ask ${who} a question.`,
67
67
  askPlaceholder: `Ask ${who}…`,
68
68
  answeringPlaceholder: `${who} is answering…`,
69
+ depthGroupLabel: `How hard ${who} works`,
70
+ depthQuick: 'Quick',
71
+ depthThorough: 'Thorough',
72
+ depthQuickHint: 'Fast, for looking something up',
73
+ depthThoroughHint: 'Slower, reads more, for questions of interpretation',
74
+ longConversation: `This conversation is getting long. A new question keeps ${who} quick.`,
69
75
  working: [`${who} is looking`, `${who} is reading`],
70
76
  conversationLabel: `Conversation with ${who}`,
71
77
  copyAnswer: 'Copy',
@@ -6,8 +6,10 @@
6
6
  * deltas that belong to each. An event type this does not know about is kept
7
7
  * verbatim under `other`, so nothing is silently dropped.
8
8
  */
9
- import type { AgentEvent } from './client';
9
+ import type { AgentEvent, Depth } from './client';
10
10
  import type { Citation } from './citations';
11
+ import type { LibrarianCopy } from './copy';
12
+ export type { Depth };
11
13
  export interface TextBlock {
12
14
  kind: 'text';
13
15
  index: number;
@@ -208,6 +210,9 @@ export interface Turn {
208
210
  /** What the host asked for when this was not a plain question
209
211
  * (`'briefing'`), in its own word, as `client.ask()` sent it. */
210
212
  kind?: string;
213
+ /** How hard the persona worked on this one, as `client.ask()` sent it.
214
+ * Absent, a host that never offers the choice. */
215
+ depth?: Depth;
211
216
  }
212
217
  /** A turn's artefact, in the host's own words. */
213
218
  export interface Artefact {
@@ -258,6 +263,23 @@ export declare function segment(blocks: Block[], describeTool?: DescribeTool): S
258
263
  * it organises what it knows is not — no collection count, no shelf, no corpus.
259
264
  */
260
265
  export declare function summariseActivity(group: ActivityGroup): string;
266
+ /**
267
+ * What a settled answer took, as one quiet line: `Quick · 56 s · about
268
+ * A$0.34`. Depth first (absent on a turn the host never asked one for, or
269
+ * one read back before the feature shipped), then the duration, then what it
270
+ * cost — only when the host can price it (`formatCost`) and the run actually
271
+ * spent something, since a zero-cost run (a local model) has nothing worth
272
+ * saying about money.
273
+ *
274
+ * The host prices `outcome.costUsd`, never this package: the currency and
275
+ * the rate are the app's own setting, not something a shared component
276
+ * should be converting.
277
+ *
278
+ * A turn asked with no depth at all renders exactly what this line always
279
+ * has (`56.3s`, one decimal, no gap) — a host that never wires depth sees no
280
+ * change here either.
281
+ */
282
+ export declare function outcomeLine(outcome: Outcome, depth: Depth | undefined, words: Pick<LibrarianCopy, 'depthQuick' | 'depthThorough'>, formatCost?: (usd: number) => string): string;
261
283
  /**
262
284
  * What a turn's calls read that a reader can open, numbered in the order it
263
285
  * was first read: the host's `ToolWords.source`, as the sources the answer
@@ -438,6 +438,31 @@ export function summariseActivity(group) {
438
438
  const parts = group.tallies.map((t) => `${t.count} ${t.count === 1 ? t.one : t.many}`);
439
439
  return parts.length ? parts.join(' · ') : 'Looked into it';
440
440
  }
441
+ /**
442
+ * What a settled answer took, as one quiet line: `Quick · 56 s · about
443
+ * A$0.34`. Depth first (absent on a turn the host never asked one for, or
444
+ * one read back before the feature shipped), then the duration, then what it
445
+ * cost — only when the host can price it (`formatCost`) and the run actually
446
+ * spent something, since a zero-cost run (a local model) has nothing worth
447
+ * saying about money.
448
+ *
449
+ * The host prices `outcome.costUsd`, never this package: the currency and
450
+ * the rate are the app's own setting, not something a shared component
451
+ * should be converting.
452
+ *
453
+ * A turn asked with no depth at all renders exactly what this line always
454
+ * has (`56.3s`, one decimal, no gap) — a host that never wires depth sees no
455
+ * change here either.
456
+ */
457
+ export function outcomeLine(outcome, depth, words, formatCost) {
458
+ const seconds = (outcome.durationMs ?? 0) / 1000;
459
+ if (!depth)
460
+ return `${seconds.toFixed(1)}s`;
461
+ const parts = [depth === 'quick' ? words.depthQuick : words.depthThorough, `${Math.round(seconds)} s`];
462
+ if (formatCost && outcome.costUsd)
463
+ parts.push(formatCost(outcome.costUsd));
464
+ return parts.join(' · ');
465
+ }
441
466
  /**
442
467
  * What a turn's calls read that a reader can open, numbered in the order it
443
468
  * was first read: the host's `ToolWords.source`, as the sources the answer
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@poodle64/librarian",
3
- "version": "2026.9.25",
3
+ "version": "2026.9.26",
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",