@poodle64/librarian 2026.9.26 → 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
@@ -308,6 +309,7 @@ the transport passes them through:
308
309
  | `remove` | `DELETE conversations/{id}` |
309
310
  | `download` | `GET conversations/{id}/export`, as `{ name, body }` |
310
311
  | `mark` | its answer mark, where the agent takes one (optional) |
312
+ | `share` | share an answer read-only, or stop (optional) |
311
313
 
312
314
  What the controller relies on the routes to say:
313
315
 
@@ -339,6 +341,8 @@ the job may go on by itself; `sendWhileRunning` is true.
339
341
  | Concern | How |
340
342
  | ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------ |
341
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 |
342
346
  | Waiting, still answering | `waiting` and `answering` on `Conversation`, from the controller |
343
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 |
344
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 |
@@ -45,6 +45,8 @@ export interface StoredTurn {
45
45
  kind?: string | null;
46
46
  /** How hard the persona worked on this one. */
47
47
  depth?: Depth | null;
48
+ /** Where this answer's read-only view lives while it is shared. */
49
+ shared?: string | null;
48
50
  }
49
51
  /** A conversation reopened. */
50
52
  export interface ConversationRead extends ConversationSummary {
@@ -107,6 +109,10 @@ export interface RoomTransport {
107
109
  /** The answer mark, where the agent takes one. `turn` is the answer's
108
110
  * zero-based position in the conversation. */
109
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>;
110
116
  }
111
117
  /** A job's routes. */
112
118
  export interface JobTransport {
@@ -199,4 +205,10 @@ export declare class Chat {
199
205
  download(id: string): Promise<boolean>;
200
206
  /** Mark an answer the agent holds. False when it could not be recorded. */
201
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>;
202
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();
@@ -279,15 +281,33 @@ export class Chat {
279
281
  const id = this.conversationId;
280
282
  if (!id || !this.#room?.mark || !this.#held.has(turn.id))
281
283
  return false;
282
- const position = this.turns.filter((t) => this.#held.has(t.id)).findIndex((t) => t.id === turn.id);
283
284
  try {
284
- await this.#room.mark(id, position, verdict);
285
+ await this.#room.mark(id, this.#position(turn), verdict);
285
286
  return true;
286
287
  }
287
288
  catch {
288
289
  return false;
289
290
  }
290
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
+ }
291
311
  #leave() {
292
312
  this.#generation += 1;
293
313
  // A stop still waiting to be addressed is carried on the stream it
@@ -547,7 +567,9 @@ export class Chat {
547
567
  return true;
548
568
  }
549
569
  }
550
- 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) {
551
573
  return {
552
574
  id: `${conversation}:${index}`,
553
575
  question: stored.question,
@@ -556,7 +578,8 @@ function storedTurn(conversation, index, stored) {
556
578
  outcome: null,
557
579
  citations: stored.citations ?? [],
558
580
  kind: stored.kind ?? undefined,
559
- depth: stored.depth ?? undefined
581
+ depth: stored.depth ?? undefined,
582
+ shared: stored.shared ?? undefined
560
583
  };
561
584
  }
562
585
  function epoch(value) {
@@ -37,6 +37,7 @@
37
37
  import { DEFAULT_PERSONA, personaName, resolveCopy, type LibrarianCopy } from '../../copy';
38
38
  import type { Verdict } from '../../chat.svelte';
39
39
  import AnswerMark from '../answer-mark/answer-mark.svelte';
40
+ import AnswerShare from '../answer-share/answer-share.svelte';
40
41
  import Working from '../working/working.svelte';
41
42
  import ActivityGroup from '../activity-group/activity-group.svelte';
42
43
  import ArtefactCard from '../artefact-card/artefact-card.svelte';
@@ -90,6 +91,13 @@
90
91
  /** Records a verdict on this answer. Omit and no mark renders: a
91
92
  * control that records nothing is worse than none. */
92
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>;
93
101
  /** How hard the persona worked on this one, for the footer's cost line
94
102
  * (`Quick · 56 s · about A$0.34`). Absent renders the footer exactly
95
103
  * as it always has: the duration alone. */
@@ -121,6 +129,8 @@
121
129
  waiting = false,
122
130
  answering,
123
131
  onmark,
132
+ shared,
133
+ onshare,
124
134
  depth,
125
135
  formatCost
126
136
  }: AgentTranscriptProps = $props();
@@ -373,6 +383,9 @@
373
383
  <span>{copied ? words.copiedAnswer : words.copyAnswer}</span>
374
384
  </button>
375
385
  {/if}
386
+ {#if onshare && hasAnswer && !failure}
387
+ <AnswerShare {onshare} {shared} copy={words} />
388
+ {/if}
376
389
  {#if onmark && hasAnswer && !failure}
377
390
  <AnswerMark {onmark} copy={words} />
378
391
  {/if}
@@ -570,8 +583,10 @@
570
583
  color: var(--ds-color-muted-foreground);
571
584
  }
572
585
 
586
+ /* 44px tall: each is a thumb's target on a phone. */
573
587
  .ds-lib-action {
574
588
  display: flex;
589
+ min-height: 2.75rem;
575
590
  align-items: center;
576
591
  gap: 0.375rem;
577
592
  border: 0;
@@ -49,6 +49,13 @@ 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>;
52
59
  /** How hard the persona worked on this one, for the footer's cost line
53
60
  * (`Quick · 56 s · about A$0.34`). Absent renders the footer exactly
54
61
  * as it always has: the duration alone. */
@@ -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';
@@ -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
@@ -111,6 +114,7 @@
111
114
  onregenerate,
112
115
  onsuggest,
113
116
  onmark,
117
+ onshare,
114
118
  waiting = false,
115
119
  answering = null,
116
120
  copy,
@@ -323,7 +327,10 @@
323
327
  onregenerate: last && !running ? onregenerate : undefined,
324
328
  onsuggest: last && !running ? onsuggest : undefined,
325
329
  onmark:
326
- 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
327
334
  } satisfies AgentTranscriptProps}
328
335
  {#if presentTurn}
329
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
package/dist/copy.d.ts CHANGED
@@ -80,6 +80,14 @@ export interface LibrarianCopy {
80
80
  markSend: string;
81
81
  markNoted: string;
82
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;
83
91
  /** The fair-use notice, while there are questions left and once there are
84
92
  * none, filled by `fill()`: `{resets}` is a time already, "midnight" or
85
93
  * "2:00 pm". */
package/dist/copy.js CHANGED
@@ -90,6 +90,12 @@ export function copyFor(name = DEFAULT_PERSONA) {
90
90
  markSend: 'Send',
91
91
  markNoted: 'Thanks, noted.',
92
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.",
93
99
  // The industry wording, whole: the number, that it is a fair-use limit
94
100
  // rather than a fault, and when it comes back. No apology.
95
101
  questionsLeft: '{remaining} of {limit} questions left today. Resets at {resets}.',
@@ -213,6 +213,9 @@ export interface Turn {
213
213
  /** How hard the persona worked on this one, as `client.ask()` sent it.
214
214
  * Absent, a host that never offers the choice. */
215
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;
216
219
  }
217
220
  /** A turn's artefact, in the host's own words. */
218
221
  export interface Artefact {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@poodle64/librarian",
3
- "version": "2026.9.26",
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",