@poodle64/librarian 2026.9.5 → 2026.9.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/README.md +143 -44
  2. package/dist/attachments.d.ts +30 -0
  3. package/dist/attachments.js +80 -0
  4. package/dist/citations.d.ts +63 -0
  5. package/dist/citations.js +89 -0
  6. package/dist/client.d.ts +5 -0
  7. package/dist/client.js +39 -13
  8. package/dist/components/activity-group/activity-group.svelte +36 -44
  9. package/dist/components/activity-group/activity-group.svelte.d.ts +1 -1
  10. package/dist/components/agent-transcript/agent-transcript.svelte +166 -26
  11. package/dist/components/agent-transcript/agent-transcript.svelte.d.ts +8 -0
  12. package/dist/components/composer/composer.svelte +212 -59
  13. package/dist/components/composer/composer.svelte.d.ts +5 -1
  14. package/dist/components/conversation/conversation.svelte +179 -0
  15. package/dist/components/conversation/conversation.svelte.d.ts +26 -0
  16. package/dist/components/conversation/index.d.ts +2 -0
  17. package/dist/components/conversation/index.js +2 -0
  18. package/dist/components/document-pane/document-pane.svelte +177 -0
  19. package/dist/components/document-pane/document-pane.svelte.d.ts +9 -0
  20. package/dist/components/document-pane/index.d.ts +2 -0
  21. package/dist/components/document-pane/index.js +2 -0
  22. package/dist/components/markdown/index.d.ts +1 -1
  23. package/dist/components/markdown/index.js +1 -1
  24. package/dist/components/markdown/markdown.d.ts +13 -0
  25. package/dist/components/markdown/markdown.js +49 -1
  26. package/dist/components/markdown/markdown.svelte +85 -3
  27. package/dist/components/markdown/markdown.svelte.d.ts +6 -0
  28. package/dist/components/tool-row/tool-row.svelte +5 -11
  29. package/dist/components/working/working.svelte +1 -9
  30. package/dist/follow-scroll.svelte.d.ts +34 -0
  31. package/dist/follow-scroll.svelte.js +46 -0
  32. package/dist/transcript.svelte.d.ts +36 -4
  33. package/dist/transcript.svelte.js +50 -26
  34. package/package.json +16 -2
@@ -1,11 +1,12 @@
1
1
  <!--
2
- A whole investigation, as one line the reader can open.
2
+ A whole investigation, as ONE line the reader can open.
3
3
 
4
- Fifteen tool rows stood between the question and the first word of the
5
- answer, so the answer had to be scrolled to. While the agent is working the
6
- steps are worth watching, so they show live. The moment prose starts
7
- arriving they fold to a single summary — the same move Claude makes with
8
- "Researched for 49s".
4
+ Not a stack. Fifteen tool rows between the question and the first word of the
5
+ answer is the machine room on show, and a running commentary of "Let me
6
+ check…" is worse: it narrates a mechanism Milton is under instruction never
7
+ to mention. So there is exactly one row in every state — "Working…" while it
8
+ runs, a count of what was done once it settles — and the steps are behind a
9
+ disclosure for the reader who wants them.
9
10
  -->
10
11
  <script lang="ts">
11
12
  import ChevronRightIcon from '@lucide/svelte/icons/chevron-right';
@@ -15,49 +16,40 @@
15
16
 
16
17
  interface Props {
17
18
  group: ActivityGroup;
18
- /** True while this group is the live one — it stays open. */
19
+ /** True while this group is the live one. */
19
20
  live: boolean;
20
21
  }
21
22
 
22
23
  let { group, live }: Props = $props();
23
24
  let expanded = $state(false);
24
- const open = $derived(live || expanded);
25
+
26
+ // A live group has nothing to count yet, so counting it reads as "0
27
+ // searches" flickering up to the real number.
28
+ const label = $derived(live ? 'Working…' : summariseActivity(group));
25
29
  </script>
26
30
 
27
- {#if live}
28
- <div class="flex flex-col gap-1.5">
29
- {#each group.steps as step (step.block.index)}
30
- {#if step.block.kind === 'tool'}
31
- <ToolRow block={step.block} repeats={step.repeats} running={true} />
32
- {:else}
33
- <ThinkingRow
34
- block={step.block}
35
- active={step.block.index === group.steps.at(-1)?.block.index}
36
- />
37
- {/if}
38
- {/each}
39
- </div>
40
- {:else}
41
- <div>
42
- <button
43
- type="button"
44
- class="text-muted-foreground hover:text-foreground flex items-center gap-1.5 text-sm transition-colors"
45
- onclick={() => (expanded = !expanded)}
46
- aria-expanded={expanded}
47
- >
48
- <ChevronRightIcon class="size-3.5 transition-transform {expanded ? 'rotate-90' : ''}" />
49
- <span>{summariseActivity(group)}</span>
50
- </button>
51
- {#if open}
52
- <div class="border-border mt-2 ml-1.75 flex flex-col gap-1.5 border-l pl-3">
53
- {#each group.steps as step (step.block.index)}
54
- {#if step.block.kind === 'tool'}
55
- <ToolRow block={step.block} repeats={step.repeats} running={false} />
56
- {:else}
57
- <ThinkingRow block={step.block} active={false} />
58
- {/if}
59
- {/each}
60
- </div>
31
+ <div>
32
+ <button
33
+ type="button"
34
+ class="text-muted-foreground hover:text-foreground flex items-center gap-1.5 text-sm transition-colors"
35
+ onclick={() => (expanded = !expanded)}
36
+ aria-expanded={expanded}
37
+ >
38
+ <ChevronRightIcon class="size-3.5 transition-transform {expanded ? 'rotate-90' : ''}" />
39
+ {#if live}
40
+ <span class="bg-status-info size-1.5 shrink-0 animate-pulse rounded-full"></span>
61
41
  {/if}
62
- </div>
63
- {/if}
42
+ <span>{label}</span>
43
+ </button>
44
+ {#if expanded}
45
+ <div class="border-border mt-2 ml-1.75 flex flex-col gap-1.5 border-l pl-3">
46
+ {#each group.steps as step (step.block.index)}
47
+ {#if step.block.kind === 'tool'}
48
+ <ToolRow block={step.block} repeats={step.repeats} running={live} />
49
+ {:else}
50
+ <ThinkingRow block={step.block} active={false} />
51
+ {/if}
52
+ {/each}
53
+ </div>
54
+ {/if}
55
+ </div>
@@ -1,7 +1,7 @@
1
1
  import { type ActivityGroup } from '../../transcript.svelte';
2
2
  interface Props {
3
3
  group: ActivityGroup;
4
- /** True while this group is the live one — it stays open. */
4
+ /** True while this group is the live one. */
5
5
  live: boolean;
6
6
  }
7
7
  declare const ActivityGroup: import("svelte").Component<Props, {}, "">;
@@ -1,12 +1,28 @@
1
1
  <!--
2
- One question and everything the agent did answering it.
2
+ One question and everything Milton did answering it.
3
3
 
4
- User turn as a rounded bubble, assistant prose led by a status dot, tool
5
- calls as rows in the flow, thinking as stacked phase labels that dim once
6
- superseded.
4
+ Three things carry the shape. The question is a bubble against the edge, so a
5
+ reader scanning back finds their own words without reading any of the answer.
6
+ The answer is plain prose held to a reading measure, because a full-width
7
+ line at 1440 is the single clearest tell that a surface was never read on.
8
+ Everything Milton DID is one quiet line above it, never a stack.
7
9
  -->
8
10
  <script lang="ts">
9
- import { segment, type Block, type Outcome } from '../../transcript.svelte';
11
+ import CheckIcon from '@lucide/svelte/icons/check';
12
+ import CopyIcon from '@lucide/svelte/icons/copy';
13
+ import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw';
14
+ import {
15
+ readerQuestion,
16
+ segment,
17
+ type Block,
18
+ type Outcome
19
+ } from '../../transcript.svelte';
20
+ import {
21
+ citationMarkers,
22
+ resolveCitations,
23
+ splitSources,
24
+ type Citation
25
+ } from '../../citations';
10
26
  import Working from '../working/working.svelte';
11
27
  import ActivityGroup from '../activity-group/activity-group.svelte';
12
28
  import Markdown from '../markdown/markdown.svelte';
@@ -16,16 +32,76 @@
16
32
  blocks: Block[];
17
33
  outcome: Outcome | null;
18
34
  running: boolean;
35
+ /** Sources from the library's `citations` frame. Absent falls back to
36
+ * whatever a "## Sources" block in the prose says. */
37
+ citations?: Citation[];
19
38
  /** Forwarded to Markdown; omit if the caller has no collections to chip. */
20
39
  collectionNames?: Set<string>;
40
+ /** Opens the document pane. Omit and chips render but do not open. */
41
+ oncite?: (citation: Citation) => void;
42
+ /** Offered on the LAST answer only — re-asks the same question. */
43
+ onregenerate?: () => void;
21
44
  }
22
45
 
23
- let { question, blocks, outcome, running, collectionNames = new Set() }: Props = $props();
46
+ let {
47
+ question,
48
+ blocks,
49
+ outcome,
50
+ running,
51
+ citations = [],
52
+ collectionNames = new Set(),
53
+ oncite,
54
+ onregenerate
55
+ }: Props = $props();
24
56
 
25
- // An activity group stays live — and therefore open — only while it is the
26
- // last thing in the turn. As soon as prose arrives after it, it folds.
57
+ const asked = $derived(readerQuestion(question));
58
+
59
+ // An activity group stays live — and therefore labelled "Working…" — only
60
+ // while it is the last thing in the turn. As soon as prose arrives, it
61
+ // settles into its count.
27
62
  const segments = $derived(segment(blocks));
28
63
  const lastIndex = $derived(segments.at(-1)?.index ?? -1);
64
+
65
+ // The prose block a Sources list may be hiding at the end of is the last
66
+ // one; splitting every text segment would eat a "## Sources" heading a
67
+ // reader legitimately asked about mid-answer.
68
+ const answer = $derived(
69
+ segments
70
+ .filter((s) => s.kind === 'text')
71
+ .map((s) => s.text)
72
+ .join('\n\n')
73
+ );
74
+ const split = $derived(running ? { body: '', citations: [] } : splitSources(answer));
75
+ const sources = $derived(resolveCitations(citations, split.citations));
76
+ // Only a number the prose actually marks becomes a chip; a source list may
77
+ // legitimately carry an entry the answer never points at inline.
78
+ const numbers = $derived(
79
+ new Set(citationMarkers(answer).filter((n) => sources.some((c) => c.n === n)))
80
+ );
81
+
82
+ function prose(text: string, isLast: boolean): string {
83
+ // Only the last text segment can carry the trailing Sources block, and
84
+ // only once the answer has settled — mid-stream the heading may be all
85
+ // that has arrived.
86
+ return isLast && split.citations.length > 0 ? split.body : text;
87
+ }
88
+
89
+ const hasAnswer = $derived(segments.some((s) => s.kind === 'text'));
90
+ // A failed turn settles too, and "Ask again" is the one thing a reader wants
91
+ // from it — there is just nothing to copy.
92
+ const settled = $derived(!running && (hasAnswer || Boolean(outcome?.error)));
93
+
94
+ let copied = $state(false);
95
+ async function copy() {
96
+ try {
97
+ await navigator.clipboard.writeText(answer);
98
+ copied = true;
99
+ setTimeout(() => (copied = false), 1600);
100
+ } catch {
101
+ // A denied clipboard permission is not worth an error state on an
102
+ // answer the reader can still select by hand.
103
+ }
104
+ }
29
105
  </script>
30
106
 
31
107
  <article class="flex flex-col gap-3">
@@ -33,9 +109,9 @@
33
109
  panel: this surface is read by people who arrive with those two as
34
110
  their model of what a chat looks like. -->
35
111
  <div
36
- class="bg-surface-3 text-foreground border-border max-w-[85%] self-end rounded-2xl border px-4 py-2.5 text-base"
112
+ class="bg-surface-3 text-foreground border-border max-w-[85%] self-end rounded-2xl border px-4 py-2.5 text-base break-words whitespace-pre-wrap"
37
113
  >
38
- {question}
114
+ {asked}
39
115
  </div>
40
116
 
41
117
  {#if running && segments.length === 0}
@@ -46,26 +122,90 @@
46
122
  {#if seg.kind === 'activity'}
47
123
  <ActivityGroup group={seg} live={running && seg.index === lastIndex} />
48
124
  {:else}
49
- <div class="flex gap-2.5">
50
- <span class="bg-muted-foreground/40 mt-2.5 size-1.5 shrink-0 rounded-full"></span>
51
- <div class="min-w-0 flex-1">
52
- <Markdown
53
- content={seg.text}
54
- streaming={running && seg.index === lastIndex}
55
- {collectionNames}
56
- />
57
- </div>
125
+ <div class="max-w-[72ch] min-w-0">
126
+ <Markdown
127
+ content={prose(seg.text, seg.index === lastIndex)}
128
+ streaming={running && seg.index === lastIndex}
129
+ {collectionNames}
130
+ citationNumbers={numbers}
131
+ oncite={(n) => {
132
+ const found = sources.find((c) => c.n === n);
133
+ if (found) oncite?.(found);
134
+ }}
135
+ />
58
136
  </div>
59
137
  {/if}
60
138
  {/each}
61
139
 
62
- {#if outcome}
63
- <p class="text-muted-foreground pl-4 font-mono text-xs tabular-nums">
64
- {#if outcome.error}
65
- {outcome.error}
66
- {:else}
67
- {outcome.turns} turns · {((outcome.durationMs ?? 0) / 1000).toFixed(1)}s
68
- {/if}
140
+ {#if sources.length > 0}
141
+ <section class="max-w-[72ch]">
142
+ <h2 class="text-muted-foreground mb-1.5 text-xs font-medium tracking-wide uppercase">
143
+ Sources
144
+ </h2>
145
+ <ol class="flex flex-col gap-1">
146
+ {#each sources as source (source.n)}
147
+ <li>
148
+ <button
149
+ type="button"
150
+ onclick={() => oncite?.(source)}
151
+ disabled={!source.document_id || !oncite}
152
+ class="border-border hover:border-border-strong hover:bg-surface-2 focus-visible:ring-ring flex w-full items-baseline gap-2 rounded-lg border px-2.5 py-1.5 text-left text-sm transition-colors focus-visible:ring-2 focus-visible:outline-none disabled:cursor-default disabled:hover:bg-transparent"
153
+ >
154
+ <span
155
+ class="bg-primary/15 text-foreground shrink-0 rounded px-1.5 font-mono text-xs tabular-nums"
156
+ >{source.n}</span
157
+ >
158
+ <span class="min-w-0">
159
+ <span class="text-foreground">{source.title}</span>
160
+ {#if source.section}<span class="text-muted-foreground">
161
+ · {source.section}</span
162
+ >{/if}
163
+ </span>
164
+ </button>
165
+ </li>
166
+ {/each}
167
+ </ol>
168
+ </section>
169
+ {/if}
170
+
171
+ {#if outcome?.error}
172
+ <p
173
+ class="border-status-error/40 bg-status-error/10 text-foreground max-w-[72ch] rounded-lg border px-3 py-2 text-sm"
174
+ role="alert"
175
+ >
176
+ {outcome.error}
69
177
  </p>
70
178
  {/if}
179
+
180
+ {#if settled}
181
+ <div class="text-muted-foreground -ml-1.5 flex items-center gap-1">
182
+ {#if hasAnswer}
183
+ <button
184
+ type="button"
185
+ onclick={copy}
186
+ aria-label={copied ? 'Copied' : 'Copy answer'}
187
+ class="hover:text-foreground hover:bg-surface-2 focus-visible:ring-ring flex items-center gap-1.5 rounded-md px-1.5 py-1 text-xs transition-colors focus-visible:ring-2 focus-visible:outline-none"
188
+ >
189
+ {#if copied}<CheckIcon class="size-3.5" />{:else}<CopyIcon class="size-3.5" />{/if}
190
+ <span>{copied ? 'Copied' : 'Copy'}</span>
191
+ </button>
192
+ {/if}
193
+ {#if onregenerate}
194
+ <button
195
+ type="button"
196
+ onclick={onregenerate}
197
+ aria-label="Ask again"
198
+ class="hover:text-foreground hover:bg-surface-2 focus-visible:ring-ring flex items-center gap-1.5 rounded-md px-1.5 py-1 text-xs transition-colors focus-visible:ring-2 focus-visible:outline-none"
199
+ >
200
+ <RefreshCwIcon class="size-3.5" />
201
+ <span>Ask again</span>
202
+ </button>
203
+ {/if}
204
+ {#if outcome && !outcome.error}
205
+ <span class="pl-1 font-mono text-xs tabular-nums opacity-70">
206
+ {((outcome.durationMs ?? 0) / 1000).toFixed(1)}s
207
+ </span>
208
+ {/if}
209
+ </div>
210
+ {/if}
71
211
  </article>
@@ -1,11 +1,19 @@
1
1
  import { type Block, type Outcome } from '../../transcript.svelte';
2
+ import { type Citation } from '../../citations';
2
3
  interface Props {
3
4
  question: string;
4
5
  blocks: Block[];
5
6
  outcome: Outcome | null;
6
7
  running: boolean;
8
+ /** Sources from the library's `citations` frame. Absent falls back to
9
+ * whatever a "## Sources" block in the prose says. */
10
+ citations?: Citation[];
7
11
  /** Forwarded to Markdown; omit if the caller has no collections to chip. */
8
12
  collectionNames?: Set<string>;
13
+ /** Opens the document pane. Omit and chips render but do not open. */
14
+ oncite?: (citation: Citation) => void;
15
+ /** Offered on the LAST answer only — re-asks the same question. */
16
+ onregenerate?: () => void;
9
17
  }
10
18
  declare const AgentTranscript: import("svelte").Component<Props, {}, "">;
11
19
  type AgentTranscript = ReturnType<typeof AgentTranscript>;