@poodle64/librarian 2026.9.25 → 2026.9.27

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
@@ -29,6 +29,7 @@ src/lib/
29
29
  conversation/ the whole reading surface: scroll, pill, pane, composer slot
30
30
  conversation-list/ past conversations, newest first: reopen, rename, download, delete
31
31
  answer-mark/ helpful or not, with a note, on the last answer
32
+ answer-share/ share an answer read-only, and stop sharing it
32
33
  fair-use-notice/ today's allowance, beside the composer
33
34
  scope-statement/ what this room answers from, and what it does not hold
34
35
  agent-transcript/ one question and everything Milton did answering it
@@ -252,6 +253,46 @@ artefact })` says that kind opens, on a live turn and a reopened one alike:
252
253
  `{ title: 'Briefing', isAnswer: true }` cards it and opens its prose in the
253
254
  column. `again()` asks it as the same kind.
254
255
 
256
+ ### Depth: Quick or Thorough, and what it cost
257
+
258
+ `Composer`'s `depth` is optional and bindable, and entirely opt-in: omit it
259
+ and the composer renders exactly as it always has. Bind it, the host's own
260
+ `$state<Depth>('quick')`, and the switch always renders, both sides always
261
+ enabled. The operator's ruling (30/09/2026) is that once a room offers the
262
+ choice, it is never disabled, hidden, greyed out, or explained by naming a
263
+ model. `depth` is `'quick' | 'thorough'`, exported from
264
+ `@poodle64/librarian/composer` and `@poodle64/librarian/client`.
265
+
266
+ ```svelte
267
+ <Composer bind:value={chat.draft} bind:depth onsubmit={() => chat.ask(undefined, undefined, depth)} ... />
268
+ ```
269
+
270
+ `chat.ask(question?, kind?, depth?)` carries it to `client.ask()`'s `depth`,
271
+ `StoredTurn.depth` reads it back, and `again()` keeps it: the same shape as
272
+ `kind`, alongside it rather than replacing it. A host whose route does not
273
+ read `depth` yet is unaffected, since the field rides along and is ignored.
274
+
275
+ Under a settled answer, `AgentTranscript`'s footer folds the turn's `depth`
276
+ and `outcome.costUsd` into the ONE line that already showed the duration:
277
+ `Quick · 56 s · about A$0.34`. Both are opt-in the same way: pass `depth` to
278
+ `Conversation`/`AgentTranscript` (it already has it, from `Turn.depth`) and a
279
+ `formatCost(usd) => string` to price it in the host's own currency and words
280
+ ("about A$0.34"; cadmus converts USD to AUD with its own setting). Without
281
+ `depth` the line renders exactly as before (`56.3s`). Without `formatCost`,
282
+ or where `costUsd` is `0` (a local model), it shows depth and duration only,
283
+ never a raw USD figure and never which model answered.
284
+
285
+ Past `turnLimit` turns (`Composer`'s prop, 8 by default), a quiet banner
286
+ appears above the box, "This conversation is getting long. A new question
287
+ keeps Milton quick." with a "New question" button, because a follow-up
288
+ re-reads the whole conversation, so each one costs more than the last.
289
+ `turnCount` and `onnewquestion` are both required for it to render; either
290
+ missing and the composer stays as it is today.
291
+
292
+ None of this prices anything before asking, and none of it names a model.
293
+ That is the operator's ruling on what a colleague sees, and the package
294
+ never puts a control in front of it.
295
+
255
296
  The transport is the app's own client over its routes, and the shapes are
256
297
  the routes' own bodies (`ConversationSummary`, `ConversationRead`,
257
298
  `StoredTurn`, `Quota`), so the stamped slice returns them as they are and
@@ -268,6 +309,7 @@ the transport passes them through:
268
309
  | `remove` | `DELETE conversations/{id}` |
269
310
  | `download` | `GET conversations/{id}/export`, as `{ name, body }` |
270
311
  | `mark` | its answer mark, where the agent takes one (optional) |
312
+ | `share` | share an answer read-only, or stop (optional) |
271
313
 
272
314
  What the controller relies on the routes to say:
273
315
 
@@ -299,6 +341,8 @@ the job may go on by itself; `sendWhileRunning` is true.
299
341
  | Concern | How |
300
342
  | ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------ |
301
343
  | The answer mark | `onmark(turn, verdict)` on `Conversation`: resolve true once recorded. Offered on the last settled answer only; without it no mark renders |
344
+ | Sharing an answer | `onshare(turn, share)` on `Conversation`, over `chat.share`: every settled answer offers Share, which copies the link `StoredTurn.shared` names; a shared one offers Copy link and Stop sharing. Nothing is shared unless the reader asks. A host's own `turn` places `AnswerShare` (`./answer-share`) with the `onshare` and `shared` it is handed |
345
+ | A shared answer, read | `Conversation` given `[storedTurn(id, 0, answer)]` from `./chat` and no handlers, no composer: the question, the answer and its sources, read-only, citations opening as in the room |
302
346
  | Waiting, still answering | `waiting` and `answering` on `Conversation`, from the controller |
303
347
  | 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 |
304
348
  | 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 |
@@ -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,10 @@ 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;
48
+ /** Where this answer's read-only view lives while it is shared. */
49
+ shared?: string | null;
46
50
  }
47
51
  /** A conversation reopened. */
48
52
  export interface ConversationRead extends ConversationSummary {
@@ -73,6 +77,8 @@ export interface AskRequest {
73
77
  signal: AbortSignal;
74
78
  /** What the host asked for, when it is not a plain question: `client.ask()`'s `kind`. */
75
79
  kind?: string;
80
+ /** How hard the persona should work on this one: `client.ask()`'s `depth`. */
81
+ depth?: Depth;
76
82
  }
77
83
  /**
78
84
  * A room's routes, as the host's own client reaches them. Every method but
@@ -103,6 +109,10 @@ export interface RoomTransport {
103
109
  /** The answer mark, where the agent takes one. `turn` is the answer's
104
110
  * zero-based position in the conversation. */
105
111
  mark?(id: string, turn: number, verdict: Verdict): Promise<void>;
112
+ /** Shares answer `turn` read-only with whoever may enter the room, or
113
+ * stops sharing it. Resolves to where it is read while shared, null once
114
+ * it is not. */
115
+ share?(id: string, turn: number, share: boolean): Promise<string | null>;
106
116
  }
107
117
  /** A job's routes. */
108
118
  export interface JobTransport {
@@ -171,13 +181,13 @@ export declare class Chat {
171
181
  new(): void;
172
182
  /**
173
183
  * 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.
184
+ * a plain question, at a `depth` when the host offers the choice. Resolves
185
+ * once the turn settles: true if it was asked, false if it was refused,
186
+ * here or by the server, in which case typed words go back into the box.
177
187
  */
178
- ask(question?: string, kind?: string): Promise<boolean>;
188
+ ask(question?: string, kind?: string, depth?: Depth): Promise<boolean>;
179
189
  /** The last question again, as a new turn: the conversation is append-only,
180
- * because the agent's transcript is. */
190
+ * because the agent's transcript is. Same kind, same depth. */
181
191
  again(): Promise<boolean>;
182
192
  /** Stop the answer being written, wherever it is being written. */
183
193
  stop(): Promise<void>;
@@ -195,4 +205,10 @@ export declare class Chat {
195
205
  download(id: string): Promise<boolean>;
196
206
  /** Mark an answer the agent holds. False when it could not be recorded. */
197
207
  mark(turn: Turn, verdict: Verdict): Promise<boolean>;
208
+ /** Share an answer the agent holds, or stop sharing it. False when that
209
+ * was not recorded. */
210
+ share(turn: Turn, share: boolean): Promise<boolean>;
198
211
  }
212
+ /** A turn as read back, ready for `Conversation`: given one and no handlers,
213
+ * that is the read-only view of a shared answer. */
214
+ export declare function storedTurn(conversation: string, index: number, stored: StoredTurn): Turn;
@@ -62,6 +62,8 @@ export class Chat {
62
62
  #watch = null;
63
63
  #woken = false;
64
64
  #wake = null;
65
+ // The turns the server recorded, which its routes count by place; a mark
66
+ // or a share on one still being written is the server's to refuse.
65
67
  // Plain, as `Session`'s seen-set: nothing renders it.
66
68
  // eslint-disable-next-line svelte/prefer-svelte-reactivity
67
69
  #held = new Set();
@@ -145,11 +147,11 @@ export class Chat {
145
147
  }
146
148
  /**
147
149
  * 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.
150
+ * a plain question, at a `depth` when the host offers the choice. Resolves
151
+ * once the turn settles: true if it was asked, false if it was refused,
152
+ * here or by the server, in which case typed words go back into the box.
151
153
  */
152
- async ask(question, kind) {
154
+ async ask(question, kind, depth) {
153
155
  const typed = question === undefined;
154
156
  const text = (question ?? this.draft).trim();
155
157
  if (!text)
@@ -163,13 +165,15 @@ export class Chat {
163
165
  this.draft = '';
164
166
  this.files = [];
165
167
  }
166
- return this.#ask(text, files, typed, kind);
168
+ return this.#ask(text, files, typed, kind, depth);
167
169
  }
168
170
  /** The last question again, as a new turn: the conversation is append-only,
169
- * because the agent's transcript is. */
171
+ * because the agent's transcript is. Same kind, same depth. */
170
172
  again() {
171
173
  const last = this.turns.at(-1);
172
- return last?.question ? this.ask(last.question, last.kind) : Promise.resolve(false);
174
+ return last?.question
175
+ ? this.ask(last.question, last.kind, last.depth)
176
+ : Promise.resolve(false);
173
177
  }
174
178
  /** Stop the answer being written, wherever it is being written. */
175
179
  async stop() {
@@ -277,15 +281,33 @@ export class Chat {
277
281
  const id = this.conversationId;
278
282
  if (!id || !this.#room?.mark || !this.#held.has(turn.id))
279
283
  return false;
280
- const position = this.turns.filter((t) => this.#held.has(t.id)).findIndex((t) => t.id === turn.id);
281
284
  try {
282
- await this.#room.mark(id, position, verdict);
285
+ await this.#room.mark(id, this.#position(turn), verdict);
283
286
  return true;
284
287
  }
285
288
  catch {
286
289
  return false;
287
290
  }
288
291
  }
292
+ /** Share an answer the agent holds, or stop sharing it. False when that
293
+ * was not recorded. */
294
+ async share(turn, share) {
295
+ const id = this.conversationId;
296
+ if (!id || !this.#room?.share || !this.#held.has(turn.id))
297
+ return false;
298
+ try {
299
+ turn.shared = (await this.#room.share(id, this.#position(turn), share)) ?? undefined;
300
+ this.#version += 1;
301
+ return true;
302
+ }
303
+ catch {
304
+ return false;
305
+ }
306
+ }
307
+ /** A turn's place among the questions the agent was asked: the server's count. */
308
+ #position(turn) {
309
+ return this.turns.filter((t) => this.#held.has(t.id)).findIndex((t) => t.id === turn.id);
310
+ }
289
311
  #leave() {
290
312
  this.#generation += 1;
291
313
  // A stop still waiting to be addressed is carried on the stream it
@@ -301,7 +323,7 @@ export class Chat {
301
323
  this.answering = null;
302
324
  this.waiting = false;
303
325
  }
304
- async #ask(text, files, typed, kind) {
326
+ async #ask(text, files, typed, kind, depth) {
305
327
  const room = this.#room;
306
328
  const mine = this.#generation;
307
329
  const stream = { controller: new AbortController(), stopWanted: false };
@@ -315,6 +337,7 @@ export class Chat {
315
337
  citations: [],
316
338
  suggestions: [],
317
339
  kind,
340
+ depth,
318
341
  artefact: this.#card(kind)
319
342
  });
320
343
  const live = this.#turns[this.#turns.length - 1];
@@ -329,7 +352,8 @@ export class Chat {
329
352
  files,
330
353
  resume: this.conversationId,
331
354
  signal: stream.controller.signal,
332
- kind
355
+ kind,
356
+ depth
333
357
  })) {
334
358
  const named = event.type === 'system' && event.subtype === 'init' ? event.session_id : undefined;
335
359
  if (stream.stopWanted) {
@@ -543,7 +567,9 @@ export class Chat {
543
567
  return true;
544
568
  }
545
569
  }
546
- function storedTurn(conversation, index, stored) {
570
+ /** A turn as read back, ready for `Conversation`: given one and no handlers,
571
+ * that is the read-only view of a shared answer. */
572
+ export function storedTurn(conversation, index, stored) {
547
573
  return {
548
574
  id: `${conversation}:${index}`,
549
575
  question: stored.question,
@@ -551,7 +577,9 @@ function storedTurn(conversation, index, stored) {
551
577
  blocks: stored.answer ? [{ kind: 'text', index: 0, text: stored.answer }] : [],
552
578
  outcome: null,
553
579
  citations: stored.citations ?? [],
554
- kind: stored.kind ?? undefined
580
+ kind: stored.kind ?? undefined,
581
+ depth: stored.depth ?? undefined,
582
+ shared: stored.shared ?? undefined
555
583
  };
556
584
  }
557
585
  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';
@@ -35,6 +37,7 @@
35
37
  import { DEFAULT_PERSONA, personaName, resolveCopy, type LibrarianCopy } from '../../copy';
36
38
  import type { Verdict } from '../../chat.svelte';
37
39
  import AnswerMark from '../answer-mark/answer-mark.svelte';
40
+ import AnswerShare from '../answer-share/answer-share.svelte';
38
41
  import Working from '../working/working.svelte';
39
42
  import ActivityGroup from '../activity-group/activity-group.svelte';
40
43
  import ArtefactCard from '../artefact-card/artefact-card.svelte';
@@ -88,6 +91,21 @@
88
91
  /** Records a verdict on this answer. Omit and no mark renders: a
89
92
  * control that records nothing is worse than none. */
90
93
  onmark?: (verdict: Verdict) => Promise<boolean>;
94
+ /** Where this answer's read-only view lives while it is shared, as the
95
+ * host's own address. */
96
+ shared?: string;
97
+ /** Shares this answer (true) or stops sharing it (false), resolving
98
+ * true once recorded. Omit and nothing about sharing renders: nothing
99
+ * is shared unless the reader asks. */
100
+ onshare?: (share: boolean) => Promise<boolean>;
101
+ /** How hard the persona worked on this one, for the footer's cost line
102
+ * (`Quick · 56 s · about A$0.34`). Absent renders the footer exactly
103
+ * as it always has: the duration alone. */
104
+ depth?: Depth;
105
+ /** Prices `outcome.costUsd` in the host's own currency and words
106
+ * ("about A$0.34"). The package never converts currency itself,
107
+ * and a zero-cost run (a local model) shows no cost regardless. */
108
+ formatCost?: (usd: number) => string;
91
109
  }
92
110
 
93
111
  let {
@@ -110,7 +128,11 @@
110
128
  describeTool,
111
129
  waiting = false,
112
130
  answering,
113
- onmark
131
+ onmark,
132
+ shared,
133
+ onshare,
134
+ depth,
135
+ formatCost
114
136
  }: AgentTranscriptProps = $props();
115
137
 
116
138
  // The prose IS the artefact: it reads in the column, and the transcript
@@ -361,6 +383,9 @@
361
383
  <span>{copied ? words.copiedAnswer : words.copyAnswer}</span>
362
384
  </button>
363
385
  {/if}
386
+ {#if onshare && hasAnswer && !failure}
387
+ <AnswerShare {onshare} {shared} copy={words} />
388
+ {/if}
364
389
  {#if onmark && hasAnswer && !failure}
365
390
  <AnswerMark {onmark} copy={words} />
366
391
  {/if}
@@ -375,11 +400,12 @@
375
400
  <span>{words.askAgain}</span>
376
401
  </button>
377
402
  {/if}
378
- <!-- How long it took, which is not the same fact as when it was
403
+ <!-- What it took, which is not the same fact as when it was
379
404
  asked; both are on the card and neither stands in for the
380
- other. -->
405
+ other. Depth and cost fold into the SAME line, never a
406
+ second one, once the host offers either. -->
381
407
  {#if outcome && !failure}
382
- <span class="ds-lib-duration">{((outcome.durationMs ?? 0) / 1000).toFixed(1)}s</span>
408
+ <span class="ds-lib-duration">{outcomeLine(outcome, depth, words, formatCost)}</span>
383
409
  {/if}
384
410
  </div>
385
411
  {/if}
@@ -557,8 +583,10 @@
557
583
  color: var(--ds-color-muted-foreground);
558
584
  }
559
585
 
586
+ /* 44px tall: each is a thumb's target on a phone. */
560
587
  .ds-lib-action {
561
588
  display: flex;
589
+ min-height: 2.75rem;
562
590
  align-items: center;
563
591
  gap: 0.375rem;
564
592
  border: 0;
@@ -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,21 @@ 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
+ /** Where this answer's read-only view lives while it is shared, as the
53
+ * host's own address. */
54
+ shared?: string;
55
+ /** Shares this answer (true) or stops sharing it (false), resolving
56
+ * true once recorded. Omit and nothing about sharing renders: nothing
57
+ * is shared unless the reader asks. */
58
+ onshare?: (share: boolean) => Promise<boolean>;
59
+ /** How hard the persona worked on this one, for the footer's cost line
60
+ * (`Quick · 56 s · about A$0.34`). Absent renders the footer exactly
61
+ * as it always has: the duration alone. */
62
+ depth?: Depth;
63
+ /** Prices `outcome.costUsd` in the host's own currency and words
64
+ * ("about A$0.34"). The package never converts currency itself,
65
+ * and a zero-cost run (a local model) shows no cost regardless. */
66
+ formatCost?: (usd: number) => string;
52
67
  }
53
68
  declare const AgentTranscript: import("svelte").Component<AgentTranscriptProps, {}, "">;
54
69
  type AgentTranscript = ReturnType<typeof AgentTranscript>;
@@ -0,0 +1,129 @@
1
+ <!--
2
+ Share one answer read-only with whoever may ask here, and stop sharing it.
3
+
4
+ Rendered by `AgentTranscript` in its action row, and by a host's own turn
5
+ beside its own presentation, only when the host passes `onshare`: nothing
6
+ is shared unless the reader asks. A share copies its link at once; a
7
+ browser that refuses a copy after the round trip (Safari) still has "Copy
8
+ link". Several roots, as `AnswerMark`: the buttons sit in the row, and the
9
+ line saying who can open it drops under it.
10
+ -->
11
+ <script lang="ts">
12
+ import { tick } from 'svelte';
13
+ import CheckIcon from '@lucide/svelte/icons/check';
14
+ import LinkIcon from '@lucide/svelte/icons/link';
15
+ import Share2Icon from '@lucide/svelte/icons/share-2';
16
+ import { resolveCopy, type LibrarianCopy } from '../../copy';
17
+
18
+ interface Props {
19
+ /** Shares the answer (true) or stops sharing it (false), resolving true
20
+ * once recorded. */
21
+ onshare: (share: boolean) => Promise<boolean>;
22
+ /** Where the read-only view lives while the answer is shared, as the
23
+ * host's own address. */
24
+ shared?: string;
25
+ copy?: Partial<LibrarianCopy>;
26
+ }
27
+
28
+ let { onshare, shared, copy }: Props = $props();
29
+
30
+ const words = $derived(resolveCopy(copy));
31
+
32
+ let saving = $state(false);
33
+ let failed = $state(false);
34
+ let copied = $state(false);
35
+
36
+ async function send(share: boolean) {
37
+ if (saving) return;
38
+ saving = true;
39
+ failed = false;
40
+ try {
41
+ failed = !(await onshare(share));
42
+ } finally {
43
+ saving = false;
44
+ }
45
+ if (share && !failed) {
46
+ await tick();
47
+ await copyLink();
48
+ }
49
+ }
50
+
51
+ async function copyLink() {
52
+ if (!shared) return;
53
+ try {
54
+ await navigator.clipboard.writeText(new URL(shared, location.href).href);
55
+ copied = true;
56
+ setTimeout(() => (copied = false), 1600);
57
+ } catch {
58
+ // Refused; "Copy link" is still there to tap.
59
+ }
60
+ }
61
+ </script>
62
+
63
+ {#if shared}
64
+ <button type="button" class="ds-lib-share" onclick={copyLink} disabled={saving}>
65
+ {#if copied}<CheckIcon size={14} />{:else}<LinkIcon size={14} />{/if}
66
+ <span>{copied ? words.linkCopied : words.copyLink}</span>
67
+ </button>
68
+ <button type="button" class="ds-lib-share" onclick={() => send(false)} disabled={saving}>
69
+ <span>{words.stopSharing}</span>
70
+ </button>
71
+ {:else}
72
+ <button type="button" class="ds-lib-share" onclick={() => send(true)} disabled={saving}>
73
+ <Share2Icon size={14} />
74
+ <span>{words.share}</span>
75
+ </button>
76
+ {/if}
77
+ {#if shared || failed}
78
+ <p class="ds-lib-share-line" class:is-failed={failed} role="status">
79
+ {failed ? words.shareFailed : words.sharedWith}
80
+ </p>
81
+ {/if}
82
+
83
+ <style>
84
+ /* 44px tall: a thumb's target on a phone. */
85
+ .ds-lib-share {
86
+ display: flex;
87
+ min-height: 2.75rem;
88
+ align-items: center;
89
+ gap: 0.375rem;
90
+ border: 0;
91
+ border-radius: var(--ds-radius-md);
92
+ background: none;
93
+ padding: 0.25rem 0.375rem;
94
+ color: inherit;
95
+ font: inherit;
96
+ font-size: var(--ds-text-2xs);
97
+ cursor: pointer;
98
+ transition:
99
+ color 150ms ease,
100
+ background-color 150ms ease;
101
+ }
102
+
103
+ .ds-lib-share:hover:not(:disabled) {
104
+ background: var(--ds-color-surface-1);
105
+ color: var(--ds-color-foreground);
106
+ }
107
+
108
+ .ds-lib-share:disabled {
109
+ cursor: default;
110
+ opacity: 0.6;
111
+ }
112
+
113
+ .ds-lib-share:focus-visible {
114
+ outline: 2px solid var(--ds-color-ring);
115
+ outline-offset: 2px;
116
+ }
117
+
118
+ /* Its own line under the row, whatever sits after the buttons in it. */
119
+ .ds-lib-share-line {
120
+ order: 1;
121
+ flex-basis: 100%;
122
+ margin: 0 0 0 0.375rem;
123
+ font-size: var(--ds-text-2xs);
124
+ }
125
+
126
+ .ds-lib-share-line.is-failed {
127
+ color: var(--ds-color-status-error);
128
+ }
129
+ </style>
@@ -0,0 +1,13 @@
1
+ import { type LibrarianCopy } from '../../copy';
2
+ interface Props {
3
+ /** Shares the answer (true) or stops sharing it (false), resolving true
4
+ * once recorded. */
5
+ onshare: (share: boolean) => Promise<boolean>;
6
+ /** Where the read-only view lives while the answer is shared, as the
7
+ * host's own address. */
8
+ shared?: string;
9
+ copy?: Partial<LibrarianCopy>;
10
+ }
11
+ declare const AnswerShare: import("svelte").Component<Props, {}, "">;
12
+ type AnswerShare = ReturnType<typeof AnswerShare>;
13
+ export default AnswerShare;
@@ -0,0 +1,2 @@
1
+ export { default as AnswerShare } from './answer-share.svelte';
2
+ export { default } from './answer-share.svelte';
@@ -0,0 +1,2 @@
1
+ export { default as AnswerShare } from './answer-share.svelte';
2
+ export { default } from './answer-share.svelte';
@@ -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';
@@ -57,6 +57,9 @@
57
57
  /** Records a verdict on an answer. Offered on the last answer only, as
58
58
  * asking again is; without it no mark renders. */
59
59
  onmark?: (turn: Turn, verdict: Verdict) => Promise<boolean>;
60
+ /** Shares an answer read-only, or stops sharing it (`Chat.share`).
61
+ * Offered on every settled answer; without it nothing is shareable. */
62
+ onshare?: (turn: Turn, share: boolean) => Promise<boolean>;
60
63
  /** The last question waits behind somebody else's (`Chat.waiting`). */
61
64
  waiting?: boolean;
62
65
  /** When the last answer began, epoch ms, where this page did not see it
@@ -78,6 +81,11 @@
78
81
  showing?: string;
79
82
  /** The persona's own words for its tools, and what they read. */
80
83
  describeTool?: DescribeTool;
84
+ /** Prices a turn's `outcome.costUsd` in the host's own currency and
85
+ * words ("about A$0.34"), for the footer's cost line beside a turn's
86
+ * `depth`. The package never converts currency itself; omitted, the
87
+ * line shows depth and duration only. */
88
+ formatCost?: (usd: number) => string;
81
89
  collectionNames?: Set<string>;
82
90
  /** The composer, rendered INSIDE the transcript column so the source
83
91
  * pane narrows it too — a composer the host places outside slides
@@ -106,6 +114,7 @@
106
114
  onregenerate,
107
115
  onsuggest,
108
116
  onmark,
117
+ onshare,
109
118
  waiting = false,
110
119
  answering = null,
111
120
  copy,
@@ -117,7 +126,8 @@
117
126
  collectionNames = new Set(),
118
127
  composer,
119
128
  turn: presentTurn,
120
- lead
129
+ lead,
130
+ formatCost
121
131
  }: Props = $props();
122
132
 
123
133
  // Resolved ONCE, here, and handed down whole: every child takes `copy` and
@@ -311,11 +321,16 @@
311
321
  artefactOpen: isShowing(turn),
312
322
  onopenartefact: opener(turn),
313
323
  describeTool,
324
+ depth: turn.depth,
325
+ formatCost,
314
326
  oncite: oncite || loadDocument ? cite : undefined,
315
327
  onregenerate: last && !running ? onregenerate : undefined,
316
328
  onsuggest: last && !running ? onsuggest : undefined,
317
329
  onmark:
318
- last && !running && onmark ? (verdict: Verdict) => onmark(turn, verdict) : undefined
330
+ last && !running && onmark ? (verdict: Verdict) => onmark(turn, verdict) : undefined,
331
+ shared: turn.shared,
332
+ onshare:
333
+ onshare && !(last && running) ? (share: boolean) => onshare(turn, share) : undefined
319
334
  } satisfies AgentTranscriptProps}
320
335
  {#if presentTurn}
321
336
  {@render presentTurn(props)}
@@ -33,6 +33,9 @@ interface Props {
33
33
  /** Records a verdict on an answer. Offered on the last answer only, as
34
34
  * asking again is; without it no mark renders. */
35
35
  onmark?: (turn: Turn, verdict: Verdict) => Promise<boolean>;
36
+ /** Shares an answer read-only, or stops sharing it (`Chat.share`).
37
+ * Offered on every settled answer; without it nothing is shareable. */
38
+ onshare?: (turn: Turn, share: boolean) => Promise<boolean>;
36
39
  /** The last question waits behind somebody else's (`Chat.waiting`). */
37
40
  waiting?: boolean;
38
41
  /** When the last answer began, epoch ms, where this page did not see it
@@ -54,6 +57,11 @@ interface Props {
54
57
  showing?: string;
55
58
  /** The persona's own words for its tools, and what they read. */
56
59
  describeTool?: DescribeTool;
60
+ /** Prices a turn's `outcome.costUsd` in the host's own currency and
61
+ * words ("about A$0.34"), for the footer's cost line beside a turn's
62
+ * `depth`. The package never converts currency itself; omitted, the
63
+ * line shows depth and duration only. */
64
+ formatCost?: (usd: number) => string;
57
65
  collectionNames?: Set<string>;
58
66
  /** The composer, rendered INSIDE the transcript column so the source
59
67
  * 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. */
@@ -68,6 +80,14 @@ export interface LibrarianCopy {
68
80
  markSend: string;
69
81
  markNoted: string;
70
82
  markFailed: string;
83
+ /** Sharing an answer: the action, once shared, who can open it, and a
84
+ * share or a stop that did not go through. */
85
+ share: string;
86
+ copyLink: string;
87
+ linkCopied: string;
88
+ stopSharing: string;
89
+ sharedWith: string;
90
+ shareFailed: string;
71
91
  /** The fair-use notice, while there are questions left and once there are
72
92
  * none, filled by `fill()`: `{resets}` is a time already, "midnight" or
73
93
  * "2:00 pm". */
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',
@@ -84,6 +90,12 @@ export function copyFor(name = DEFAULT_PERSONA) {
84
90
  markSend: 'Send',
85
91
  markNoted: 'Thanks, noted.',
86
92
  markFailed: "That couldn't be sent. Nothing was recorded.",
93
+ share: 'Share',
94
+ copyLink: 'Copy link',
95
+ linkCopied: 'Link copied',
96
+ stopSharing: 'Stop sharing',
97
+ sharedWith: `Shared: anyone who can ask ${who} here can open the link.`,
98
+ shareFailed: "That didn't go through. Try again.",
87
99
  // The industry wording, whole: the number, that it is a fair-use limit
88
100
  // rather than a fault, and when it comes back. No apology.
89
101
  questionsLeft: '{remaining} of {limit} questions left today. Resets at {resets}.',
@@ -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,12 @@ 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;
216
+ /** Where this answer's read-only view lives while it is shared, as the
217
+ * host's own address. Absent while it is not. */
218
+ shared?: string;
211
219
  }
212
220
  /** A turn's artefact, in the host's own words. */
213
221
  export interface Artefact {
@@ -258,6 +266,23 @@ export declare function segment(blocks: Block[], describeTool?: DescribeTool): S
258
266
  * it organises what it knows is not — no collection count, no shelf, no corpus.
259
267
  */
260
268
  export declare function summariseActivity(group: ActivityGroup): string;
269
+ /**
270
+ * What a settled answer took, as one quiet line: `Quick · 56 s · about
271
+ * A$0.34`. Depth first (absent on a turn the host never asked one for, or
272
+ * one read back before the feature shipped), then the duration, then what it
273
+ * cost — only when the host can price it (`formatCost`) and the run actually
274
+ * spent something, since a zero-cost run (a local model) has nothing worth
275
+ * saying about money.
276
+ *
277
+ * The host prices `outcome.costUsd`, never this package: the currency and
278
+ * the rate are the app's own setting, not something a shared component
279
+ * should be converting.
280
+ *
281
+ * A turn asked with no depth at all renders exactly what this line always
282
+ * has (`56.3s`, one decimal, no gap) — a host that never wires depth sees no
283
+ * change here either.
284
+ */
285
+ export declare function outcomeLine(outcome: Outcome, depth: Depth | undefined, words: Pick<LibrarianCopy, 'depthQuick' | 'depthThorough'>, formatCost?: (usd: number) => string): string;
261
286
  /**
262
287
  * What a turn's calls read that a reader can open, numbered in the order it
263
288
  * 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.27",
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",