@poodle64/librarian 2026.9.10 → 2026.9.12
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 +28 -8
- package/dist/client.d.ts +5 -0
- package/dist/client.js +4 -1
- package/dist/components/activity-group/activity-group.svelte +5 -0
- package/dist/components/agent-transcript/agent-transcript.svelte +24 -6
- package/dist/components/agent-transcript/agent-transcript.svelte.d.ts +8 -0
- package/dist/components/artefact-card/artefact-card.svelte +40 -0
- package/dist/components/artefact-card/artefact-card.svelte.d.ts +8 -0
- package/dist/components/artefact-card/index.d.ts +1 -0
- package/dist/components/artefact-card/index.js +1 -0
- package/dist/components/artefact-pane/artefact-pane.svelte +120 -0
- package/dist/components/artefact-pane/artefact-pane.svelte.d.ts +15 -0
- package/dist/components/artefact-pane/index.d.ts +1 -0
- package/dist/components/artefact-pane/index.js +1 -0
- package/dist/components/composer/composer.svelte +20 -1
- package/dist/components/composer/composer.svelte.d.ts +4 -0
- package/dist/components/conversation/conversation.svelte +26 -5
- package/dist/components/thinking-row/thinking-row.svelte +13 -2
- package/dist/components/thinking-row/thinking-row.svelte.d.ts +2 -2
- package/dist/transcript.svelte.d.ts +31 -3
- package/dist/transcript.svelte.js +33 -5
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -24,11 +24,13 @@ src/lib/
|
|
|
24
24
|
scope-statement/ what this room answers from, and what it does not hold
|
|
25
25
|
agent-transcript/ one question and everything Milton did answering it
|
|
26
26
|
document-pane/ the cited document, open at the cited passage
|
|
27
|
+
artefact-card/ a study artefact's card in the transcript
|
|
28
|
+
artefact-pane/ a study artefact, open in the reading column
|
|
27
29
|
composer/ the input box: attachments, scope chips, send/stop
|
|
28
30
|
markdown/ sanitised, streaming-safe markdown + highlighting
|
|
29
31
|
activity-group/ a whole investigation, as one quiet line
|
|
30
32
|
tool-row/ one tool call
|
|
31
|
-
thinking-row/ one
|
|
33
|
+
thinking-row/ one thought, or one line of between-tool narration
|
|
32
34
|
working/ the pre-first-token "something is happening" indicator
|
|
33
35
|
```
|
|
34
36
|
|
|
@@ -153,14 +155,14 @@ on it fires once and never again.
|
|
|
153
155
|
|
|
154
156
|
| Concern | How |
|
|
155
157
|
| ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
156
|
-
| The ask route | `ask({ endpoint })`: a room's `/api/rooms/{id}/ask`, a caller's `/api/caller/ask`
|
|
158
|
+
| The ask route | `ask({ endpoint })`: a room's `/api/rooms/{id}/ask`, a caller's `/api/caller/ask` |
|
|
157
159
|
| Attachments | nothing: `ask()` posts multipart (`question`, `resume`, `collections[]`, `files[]`) whenever `files` is non-empty, and JSON when it is not. The route must accept both |
|
|
158
160
|
| Reading a cited document | `loadDocument(document_id) => Promise<{title, sections: [{anchor, heading, text}]}>`, proxied through the app's own authenticated route (cadmus: `GET /api/sources/documents/{id}/content`) |
|
|
159
|
-
| Asking again | `onregenerate`: re-send the last question as a NEW turn; the package exposes the action and never re-asks by itself
|
|
161
|
+
| Asking again | `onregenerate`: re-send the last question as a NEW turn; the package exposes the action and never re-asks by itself |
|
|
160
162
|
| Asking a follow-up | `onsuggest(question)`: ask it as a NEW turn. Without the handler the chips do not render at all — a chip that does nothing is worse than no chip |
|
|
161
163
|
| The empty state | `welcome` and up to three `examples` |
|
|
162
164
|
| What this surface covers | `scope`: one statement per room, in the host's own words. Rendered above the first turn and folded to a line once the conversation starts |
|
|
163
|
-
| The words themselves | `copy`: a partial of `LibrarianCopy`. Every component resolves it itself, so overriding one line does not mean restating the rest
|
|
165
|
+
| The words themselves | `copy`: a partial of `LibrarianCopy`. Every component resolves it itself, so overriding one line does not mean restating the rest |
|
|
164
166
|
|
|
165
167
|
Nothing here fetches on its own behalf. The library's document read is
|
|
166
168
|
authenticated, and a package that called it directly would be reaching past
|
|
@@ -231,6 +233,23 @@ one asks it through `onsuggest` and takes the whole row with it — the moment
|
|
|
231
233
|
between the click and the new turn arriving is otherwise long enough to ask a
|
|
232
234
|
second question by mistake.
|
|
233
235
|
|
|
236
|
+
### Where the answer starts
|
|
237
|
+
|
|
238
|
+
Milton narrates between tool calls — "Let me also check whether…" — and the
|
|
239
|
+
caller stream gives that nowhere to arrive: it carries no `thinking` blocks
|
|
240
|
+
at all, so narration is an ordinary `text` block, identical to the answer
|
|
241
|
+
except in POSITION. `segment()` reads that position: a text block with any
|
|
242
|
+
tool call still to come in the turn is narration and folds into the activity
|
|
243
|
+
group as a thinking-shaped row; the run of text after the LAST tool call is
|
|
244
|
+
the answer. While a turn streams the judgement is provisional — a block that
|
|
245
|
+
is currently last renders as prose, and a tool call arriving after it
|
|
246
|
+
re-homes it — which is why `segment()` is pure and re-derived per event
|
|
247
|
+
rather than deciding once.
|
|
248
|
+
|
|
249
|
+
The cost is an answer Milton interrupts to go back to the shelf: its first
|
|
250
|
+
half folds away. Position is the only signal the stream gives, and a rule
|
|
251
|
+
read off the prose itself would be unexplainable the first time it misfired.
|
|
252
|
+
|
|
234
253
|
### Changing the words
|
|
235
254
|
|
|
236
255
|
Every user-visible string this package renders lives in
|
|
@@ -271,15 +290,16 @@ pnpm run test # build + vitest
|
|
|
271
290
|
pnpm run screenshots # the state grid, real engine (see below)
|
|
272
291
|
```
|
|
273
292
|
|
|
274
|
-
`docs/screenshots/` is
|
|
293
|
+
`docs/screenshots/` is eleven states x three widths x both themes, taken by
|
|
275
294
|
`scripts/screenshots.mjs` against the console's `/librarian` lab route
|
|
276
295
|
running from its own static build. The same script asserts what a screenshot
|
|
277
296
|
cannot: that nothing scrolls sideways at any width, that the source pane
|
|
278
297
|
opens and closes from the keyboard with focus returning to the chip and is
|
|
279
298
|
really draggable, that the scope statement folds once there is a
|
|
280
|
-
conversation over it and reopens from that line,
|
|
281
|
-
|
|
282
|
-
|
|
299
|
+
conversation over it and reopens from that line, that a follow-up chip asks
|
|
300
|
+
its question and takes the rest of the row with it, and that Milton's
|
|
301
|
+
between-tool narration is nowhere in the answer prose before the activity
|
|
302
|
+
line is opened. It exits non-zero on any of them.
|
|
283
303
|
|
|
284
304
|
```bash
|
|
285
305
|
pnpm --filter @poodle64/console run build
|
package/dist/client.d.ts
CHANGED
|
@@ -57,6 +57,11 @@ export interface AskOptions {
|
|
|
57
57
|
collections?: string[];
|
|
58
58
|
/** Files Milton reads for this question. Switches the request to multipart. */
|
|
59
59
|
files?: File[];
|
|
60
|
+
/** What kind of turn this ask should become. Absent means an ordinary
|
|
61
|
+
* question; a host that offers a study artefact sends its own kind
|
|
62
|
+
* (`'briefing'`) and interprets what comes back accordingly — the wire
|
|
63
|
+
* vocabulary is the host's own, never a fixed set here. */
|
|
64
|
+
kind?: string;
|
|
60
65
|
signal?: AbortSignal;
|
|
61
66
|
/** Where the ask lands. Each app mounts its own ask route. */
|
|
62
67
|
endpoint?: string;
|
package/dist/client.js
CHANGED
|
@@ -29,7 +29,8 @@ function requestInit(options, signal) {
|
|
|
29
29
|
question: options.question,
|
|
30
30
|
resume: options.resume ?? null,
|
|
31
31
|
subtree: options.subtree ?? '',
|
|
32
|
-
collections: options.collections ?? []
|
|
32
|
+
collections: options.collections ?? [],
|
|
33
|
+
kind: options.kind
|
|
33
34
|
}),
|
|
34
35
|
signal
|
|
35
36
|
};
|
|
@@ -42,6 +43,8 @@ function requestInit(options, signal) {
|
|
|
42
43
|
form.append('collections[]', collection);
|
|
43
44
|
for (const file of options.files)
|
|
44
45
|
form.append('files[]', file, file.name);
|
|
46
|
+
if (options.kind)
|
|
47
|
+
form.append('kind', options.kind);
|
|
45
48
|
return { method: 'POST', credentials: 'include', body: form, signal };
|
|
46
49
|
}
|
|
47
50
|
/** Async-iterate the events of one question. */
|
|
@@ -7,6 +7,11 @@
|
|
|
7
7
|
to mention. So there is exactly one row in every state — "Working…" while it
|
|
8
8
|
runs, a count of what was done once it settles — and the steps are behind a
|
|
9
9
|
disclosure for the reader who wants them.
|
|
10
|
+
|
|
11
|
+
That commentary is a step in here too. It reaches the caller as an ordinary
|
|
12
|
+
`text` block, not a `thinking` one, so `segment()` re-homes any text with a
|
|
13
|
+
tool call still to come into this group; it renders as a ThinkingRow beside
|
|
14
|
+
the tools, folded like the rest.
|
|
10
15
|
-->
|
|
11
16
|
<script lang="ts">
|
|
12
17
|
import ChevronRightIcon from '@lucide/svelte/icons/chevron-right';
|
|
@@ -28,6 +28,7 @@
|
|
|
28
28
|
import { resolveCopy, type LibrarianCopy } from '../../copy';
|
|
29
29
|
import Working from '../working/working.svelte';
|
|
30
30
|
import ActivityGroup from '../activity-group/activity-group.svelte';
|
|
31
|
+
import ArtefactCard from '../artefact-card/artefact-card.svelte';
|
|
31
32
|
import Markdown from '../markdown/markdown.svelte';
|
|
32
33
|
|
|
33
34
|
interface Props {
|
|
@@ -51,6 +52,14 @@
|
|
|
51
52
|
* no chip. */
|
|
52
53
|
onsuggest?: (question: string) => void;
|
|
53
54
|
copy?: Partial<LibrarianCopy>;
|
|
55
|
+
/** A study artefact rather than an ordinary answer: once settled, this
|
|
56
|
+
* renders as a card instead of prose. */
|
|
57
|
+
kind?: 'answer' | 'artefact';
|
|
58
|
+
/** The artefact's own name, for the card. */
|
|
59
|
+
title?: string;
|
|
60
|
+
/** Opens the artefact in the reading column. Required wherever `kind`
|
|
61
|
+
* is `'artefact'`. */
|
|
62
|
+
onopenartefact?: () => void;
|
|
54
63
|
}
|
|
55
64
|
|
|
56
65
|
let {
|
|
@@ -64,9 +73,14 @@
|
|
|
64
73
|
oncite,
|
|
65
74
|
onregenerate,
|
|
66
75
|
onsuggest,
|
|
67
|
-
copy
|
|
76
|
+
copy,
|
|
77
|
+
kind = 'answer',
|
|
78
|
+
title,
|
|
79
|
+
onopenartefact
|
|
68
80
|
}: Props = $props();
|
|
69
81
|
|
|
82
|
+
const isArtefact = $derived(kind === 'artefact');
|
|
83
|
+
|
|
70
84
|
const words = $derived(resolveCopy(copy));
|
|
71
85
|
|
|
72
86
|
const asked = $derived(readerQuestion(question));
|
|
@@ -181,7 +195,7 @@
|
|
|
181
195
|
{#each segments as seg (seg.index)}
|
|
182
196
|
{#if seg.kind === 'activity'}
|
|
183
197
|
<ActivityGroup group={seg} live={running && seg.index === lastIndex} />
|
|
184
|
-
{:else}
|
|
198
|
+
{:else if !isArtefact}
|
|
185
199
|
<div class="max-w-[72ch] min-w-0">
|
|
186
200
|
<Markdown
|
|
187
201
|
content={seg.index === lastTextIndex && split.citations.length > 0
|
|
@@ -199,7 +213,11 @@
|
|
|
199
213
|
{/if}
|
|
200
214
|
{/each}
|
|
201
215
|
|
|
202
|
-
{#if
|
|
216
|
+
{#if isArtefact && settled}
|
|
217
|
+
<ArtefactCard title={title ?? 'Briefing'} citationCount={sources.length} onopen={() => onopenartefact?.()} />
|
|
218
|
+
{/if}
|
|
219
|
+
|
|
220
|
+
{#if !isArtefact && sources.length > 0}
|
|
203
221
|
<section class="max-w-[72ch]">
|
|
204
222
|
<h2 class="text-muted-foreground mb-1.5 text-xs font-medium tracking-wide uppercase">
|
|
205
223
|
{words.sources}
|
|
@@ -237,11 +255,11 @@
|
|
|
237
255
|
</section>
|
|
238
256
|
{/if}
|
|
239
257
|
|
|
240
|
-
{#if notHeld}
|
|
258
|
+
{#if !isArtefact && notHeld}
|
|
241
259
|
<p class="text-muted-foreground max-w-[72ch] text-sm">{words.notHeld}</p>
|
|
242
260
|
{/if}
|
|
243
261
|
|
|
244
|
-
{#if followUps.length > 0}
|
|
262
|
+
{#if !isArtefact && followUps.length > 0}
|
|
245
263
|
<section class="max-w-[72ch]">
|
|
246
264
|
<h2 class="text-muted-foreground mb-1.5 text-xs font-medium tracking-wide uppercase">
|
|
247
265
|
{words.suggestions}
|
|
@@ -276,7 +294,7 @@
|
|
|
276
294
|
</p>
|
|
277
295
|
{/if}
|
|
278
296
|
|
|
279
|
-
{#if settled}
|
|
297
|
+
{#if !isArtefact && settled}
|
|
280
298
|
<div class="text-muted-foreground -ml-1.5 flex items-center gap-1">
|
|
281
299
|
{#if hasAnswer}
|
|
282
300
|
<button
|
|
@@ -22,6 +22,14 @@ interface Props {
|
|
|
22
22
|
* no chip. */
|
|
23
23
|
onsuggest?: (question: string) => void;
|
|
24
24
|
copy?: Partial<LibrarianCopy>;
|
|
25
|
+
/** A study artefact rather than an ordinary answer: once settled, this
|
|
26
|
+
* renders as a card instead of prose. */
|
|
27
|
+
kind?: 'answer' | 'artefact';
|
|
28
|
+
/** The artefact's own name, for the card. */
|
|
29
|
+
title?: string;
|
|
30
|
+
/** Opens the artefact in the reading column. Required wherever `kind`
|
|
31
|
+
* is `'artefact'`. */
|
|
32
|
+
onopenartefact?: () => void;
|
|
25
33
|
}
|
|
26
34
|
declare const AgentTranscript: import("svelte").Component<Props, {}, "">;
|
|
27
35
|
type AgentTranscript = ReturnType<typeof AgentTranscript>;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
A study artefact's card in the transcript: what a colleague taps to read it.
|
|
3
|
+
It is not the artefact itself — that lives in the reading column
|
|
4
|
+
(`ArtefactPane`), never stacked beside this card and never inline as prose,
|
|
5
|
+
which is the whole of the one-surface decision this package exists to keep
|
|
6
|
+
every consumer honest about.
|
|
7
|
+
-->
|
|
8
|
+
<script lang="ts">
|
|
9
|
+
import FileTextIcon from '@lucide/svelte/icons/file-text';
|
|
10
|
+
import Panel from '@poodle64/ui/panel';
|
|
11
|
+
|
|
12
|
+
interface Props {
|
|
13
|
+
title: string;
|
|
14
|
+
citationCount: number;
|
|
15
|
+
onopen: () => void;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
let { title, citationCount, onopen }: Props = $props();
|
|
19
|
+
</script>
|
|
20
|
+
|
|
21
|
+
<Panel
|
|
22
|
+
icon={FileTextIcon}
|
|
23
|
+
{title}
|
|
24
|
+
subtitle="Briefing doc"
|
|
25
|
+
role="button"
|
|
26
|
+
tabindex={0}
|
|
27
|
+
onclick={onopen}
|
|
28
|
+
onkeydown={(event: KeyboardEvent) => {
|
|
29
|
+
if (event.key !== 'Enter' && event.key !== ' ') return;
|
|
30
|
+
event.preventDefault();
|
|
31
|
+
onopen();
|
|
32
|
+
}}
|
|
33
|
+
class="hover:border-border-strong focus-visible:ring-ring w-full max-w-[28rem] cursor-pointer text-left transition-colors focus-visible:ring-2 focus-visible:outline-none"
|
|
34
|
+
>
|
|
35
|
+
{#snippet children()}
|
|
36
|
+
<p class="text-muted-foreground text-xs">
|
|
37
|
+
{citationCount} source{citationCount === 1 ? '' : 's'}
|
|
38
|
+
</p>
|
|
39
|
+
{/snippet}
|
|
40
|
+
</Panel>
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default } from './artefact-card.svelte';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default } from './artefact-card.svelte';
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
A study artefact, open in the reading column.
|
|
3
|
+
|
|
4
|
+
The same shell shape `DocumentPane` uses (a column at `lg`, a bottom sheet
|
|
5
|
+
below it) because decision 2 of the ask-milton surface is that a colleague
|
|
6
|
+
never has to learn which of the two things is in the column before they
|
|
7
|
+
open it — only ever one, in the same place, closing whichever was there.
|
|
8
|
+
-->
|
|
9
|
+
<script lang="ts">
|
|
10
|
+
import XIcon from '@lucide/svelte/icons/x';
|
|
11
|
+
import { segment, type TextBlock, type Turn } from '../../transcript.svelte';
|
|
12
|
+
import { resolveCitations, splitSources, trustMark, type Citation } from '../../citations';
|
|
13
|
+
import { resolveCopy, type LibrarianCopy } from '../../copy';
|
|
14
|
+
import Markdown from '../markdown/markdown.svelte';
|
|
15
|
+
|
|
16
|
+
interface Props {
|
|
17
|
+
turn: Turn;
|
|
18
|
+
onclose: () => void;
|
|
19
|
+
/** Opens the cited document instead — swapping the column back, never
|
|
20
|
+
* stacking it beside the artefact. Omit and the sources still list, but
|
|
21
|
+
* do not open. */
|
|
22
|
+
oncite?: (citation: Citation) => void;
|
|
23
|
+
copy?: Partial<LibrarianCopy>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
let { turn, onclose, oncite, copy }: Props = $props();
|
|
27
|
+
|
|
28
|
+
const words = $derived(resolveCopy(copy));
|
|
29
|
+
|
|
30
|
+
const texts = $derived(segment(turn.blocks).filter((s): s is TextBlock => s.kind === 'text'));
|
|
31
|
+
const rawAnswer = $derived(texts.map((t) => t.text).join('\n\n'));
|
|
32
|
+
const split = $derived(splitSources(rawAnswer));
|
|
33
|
+
const sources = $derived(resolveCitations(turn.citations ?? [], split.citations));
|
|
34
|
+
|
|
35
|
+
let closeButton = $state<HTMLButtonElement | null>(null);
|
|
36
|
+
|
|
37
|
+
// Opening a pane a reader reached with the keyboard must move focus into
|
|
38
|
+
// it, or Escape and Tab both act on the transcript behind it.
|
|
39
|
+
$effect(() => {
|
|
40
|
+
const returnTo = globalThis.document?.activeElement as HTMLElement | null;
|
|
41
|
+
closeButton?.focus();
|
|
42
|
+
return () => returnTo?.focus?.();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
function keydown(event: KeyboardEvent) {
|
|
46
|
+
if (event.key === 'Escape') {
|
|
47
|
+
event.stopPropagation();
|
|
48
|
+
onclose();
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
</script>
|
|
52
|
+
|
|
53
|
+
<svelte:window onkeydown={keydown} />
|
|
54
|
+
|
|
55
|
+
<aside
|
|
56
|
+
aria-label="Study artefact"
|
|
57
|
+
class="bg-surface-1 border-border fixed inset-x-0 bottom-0 z-40 flex h-[80svh] flex-col rounded-t-2xl border-t shadow-2xl lg:relative lg:h-auto lg:w-[560px] lg:shrink-0 lg:rounded-none lg:border-t-0 lg:border-l lg:shadow-none"
|
|
58
|
+
>
|
|
59
|
+
<!-- The sheet's grabber. Below `lg` this is an overlay a reader has to be
|
|
60
|
+
able to see the top edge of; on a desktop it is a column, so there is
|
|
61
|
+
nothing here to grab. -->
|
|
62
|
+
<div class="flex justify-center pt-2 pb-1 lg:hidden" aria-hidden="true">
|
|
63
|
+
<span class="bg-border h-1 w-9 rounded-full"></span>
|
|
64
|
+
</div>
|
|
65
|
+
|
|
66
|
+
<header class="border-border flex items-start gap-2 border-b px-4 py-3 lg:pt-3">
|
|
67
|
+
<div class="min-w-0 flex-1">
|
|
68
|
+
<h2 class="text-foreground truncate text-sm font-semibold">{turn.title ?? 'Briefing'}</h2>
|
|
69
|
+
<p class="text-muted-foreground truncate text-xs">
|
|
70
|
+
Briefing doc · {sources.length} source{sources.length === 1 ? '' : 's'}
|
|
71
|
+
</p>
|
|
72
|
+
</div>
|
|
73
|
+
<button
|
|
74
|
+
bind:this={closeButton}
|
|
75
|
+
type="button"
|
|
76
|
+
onclick={onclose}
|
|
77
|
+
aria-label={words.closeSource}
|
|
78
|
+
class="text-muted-foreground hover:text-foreground hover:bg-surface-2 focus-visible:ring-ring -mt-1 flex size-8 shrink-0 items-center justify-center rounded-lg transition-colors focus-visible:ring-2 focus-visible:outline-none"
|
|
79
|
+
>
|
|
80
|
+
<XIcon class="size-4" />
|
|
81
|
+
</button>
|
|
82
|
+
</header>
|
|
83
|
+
|
|
84
|
+
<div class="min-h-0 flex-1 overflow-y-auto overscroll-contain px-4 py-3">
|
|
85
|
+
<Markdown content={split.citations.length > 0 ? split.body : rawAnswer} />
|
|
86
|
+
|
|
87
|
+
{#if sources.length > 0}
|
|
88
|
+
<section class="mt-6">
|
|
89
|
+
<h3 class="text-muted-foreground mb-1.5 text-xs font-medium tracking-wide uppercase">
|
|
90
|
+
{words.sources}
|
|
91
|
+
</h3>
|
|
92
|
+
<ol class="flex flex-col gap-1">
|
|
93
|
+
{#each sources as source (source.n)}
|
|
94
|
+
{@const mark = trustMark(source, words)}
|
|
95
|
+
<li>
|
|
96
|
+
<button
|
|
97
|
+
type="button"
|
|
98
|
+
onclick={() => oncite?.(source)}
|
|
99
|
+
disabled={!source.document_id || !oncite}
|
|
100
|
+
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"
|
|
101
|
+
>
|
|
102
|
+
<span
|
|
103
|
+
class="bg-primary/15 text-foreground shrink-0 rounded px-1.5 font-mono text-xs tabular-nums"
|
|
104
|
+
>{source.n}</span
|
|
105
|
+
>
|
|
106
|
+
<span class="min-w-0">
|
|
107
|
+
<span class="text-foreground">{source.title}</span>
|
|
108
|
+
{#if source.section}<span class="text-muted-foreground">
|
|
109
|
+
· {source.section}</span
|
|
110
|
+
>{/if}
|
|
111
|
+
{#if mark}<span class="text-muted-foreground/80"> · {mark}</span>{/if}
|
|
112
|
+
</span>
|
|
113
|
+
</button>
|
|
114
|
+
</li>
|
|
115
|
+
{/each}
|
|
116
|
+
</ol>
|
|
117
|
+
</section>
|
|
118
|
+
{/if}
|
|
119
|
+
</div>
|
|
120
|
+
</aside>
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { type Turn } from '../../transcript.svelte';
|
|
2
|
+
import { type Citation } from '../../citations';
|
|
3
|
+
import { type LibrarianCopy } from '../../copy';
|
|
4
|
+
interface Props {
|
|
5
|
+
turn: Turn;
|
|
6
|
+
onclose: () => void;
|
|
7
|
+
/** Opens the cited document instead — swapping the column back, never
|
|
8
|
+
* stacking it beside the artefact. Omit and the sources still list, but
|
|
9
|
+
* do not open. */
|
|
10
|
+
oncite?: (citation: Citation) => void;
|
|
11
|
+
copy?: Partial<LibrarianCopy>;
|
|
12
|
+
}
|
|
13
|
+
declare const ArtefactPane: import("svelte").Component<Props, {}, "">;
|
|
14
|
+
type ArtefactPane = ReturnType<typeof ArtefactPane>;
|
|
15
|
+
export default ArtefactPane;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default } from './artefact-pane.svelte';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default } from './artefact-pane.svelte';
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
-->
|
|
13
13
|
<script lang="ts">
|
|
14
14
|
import ArrowUpIcon from '@lucide/svelte/icons/arrow-up';
|
|
15
|
+
import FileTextIcon from '@lucide/svelte/icons/file-text';
|
|
15
16
|
import PaperclipIcon from '@lucide/svelte/icons/paperclip';
|
|
16
17
|
import SquareIcon from '@lucide/svelte/icons/square';
|
|
17
18
|
import XIcon from '@lucide/svelte/icons/x';
|
|
@@ -39,6 +40,10 @@
|
|
|
39
40
|
onscope: (scope: Scope) => void;
|
|
40
41
|
onsubmit: () => void;
|
|
41
42
|
onstop: () => void;
|
|
43
|
+
/** Asks for a study artefact instead of an ordinary answer. Omit and no
|
|
44
|
+
* control renders — studying is a mode of asking, never a tab, so a
|
|
45
|
+
* host with nothing to build offers none rather than a disabled one. */
|
|
46
|
+
onbriefing?: () => void;
|
|
42
47
|
}
|
|
43
48
|
|
|
44
49
|
let {
|
|
@@ -51,7 +56,8 @@
|
|
|
51
56
|
attachments = true,
|
|
52
57
|
onscope,
|
|
53
58
|
onsubmit,
|
|
54
|
-
onstop
|
|
59
|
+
onstop,
|
|
60
|
+
onbriefing
|
|
55
61
|
}: Props = $props();
|
|
56
62
|
|
|
57
63
|
// What Milton is being asked about — this document, this collection, or
|
|
@@ -235,6 +241,19 @@
|
|
|
235
241
|
</button>
|
|
236
242
|
{/if}
|
|
237
243
|
|
|
244
|
+
{#if onbriefing}
|
|
245
|
+
<button
|
|
246
|
+
type="button"
|
|
247
|
+
onclick={onbriefing}
|
|
248
|
+
disabled={running}
|
|
249
|
+
aria-label="Ask for a briefing"
|
|
250
|
+
title="Ask for a briefing"
|
|
251
|
+
class="text-muted-foreground hover:text-foreground hover:bg-surface-2 focus-visible:ring-ring flex size-8 shrink-0 items-center justify-center rounded-full transition-colors focus-visible:ring-2 focus-visible:outline-none disabled:opacity-30"
|
|
252
|
+
>
|
|
253
|
+
<FileTextIcon class="size-4" />
|
|
254
|
+
</button>
|
|
255
|
+
{/if}
|
|
256
|
+
|
|
238
257
|
{#if hasChoice}
|
|
239
258
|
<div class="flex min-w-0 flex-wrap items-center gap-1">
|
|
240
259
|
{#each choices as choice (choice.id)}
|
|
@@ -13,6 +13,10 @@ interface Props {
|
|
|
13
13
|
onscope: (scope: Scope) => void;
|
|
14
14
|
onsubmit: () => void;
|
|
15
15
|
onstop: () => void;
|
|
16
|
+
/** Asks for a study artefact instead of an ordinary answer. Omit and no
|
|
17
|
+
* control renders — studying is a mode of asking, never a tab, so a
|
|
18
|
+
* host with nothing to build offers none rather than a disabled one. */
|
|
19
|
+
onbriefing?: () => void;
|
|
16
20
|
}
|
|
17
21
|
declare const Composer: import("svelte").Component<Props, {}, "value" | "files">;
|
|
18
22
|
type Composer = ReturnType<typeof Composer>;
|
|
@@ -16,9 +16,15 @@
|
|
|
16
16
|
import { resolveCopy, type LibrarianCopy } from '../../copy';
|
|
17
17
|
import { FollowScroll } from '../../follow-scroll.svelte';
|
|
18
18
|
import AgentTranscript from '../agent-transcript/agent-transcript.svelte';
|
|
19
|
+
import ArtefactPane from '../artefact-pane/artefact-pane.svelte';
|
|
19
20
|
import DocumentPane from '../document-pane/document-pane.svelte';
|
|
20
21
|
import ScopeStatement from '../scope-statement/scope-statement.svelte';
|
|
21
22
|
|
|
23
|
+
/** What the reading column holds — a cited document or a study artefact,
|
|
24
|
+
* never both and never two panes: whichever the reader last opened
|
|
25
|
+
* replaces whatever was there. */
|
|
26
|
+
type Column = { kind: 'document'; citation: Citation } | { kind: 'artefact'; turn: Turn } | null;
|
|
27
|
+
|
|
22
28
|
interface Props {
|
|
23
29
|
turns: Turn[];
|
|
24
30
|
running: boolean;
|
|
@@ -68,7 +74,7 @@
|
|
|
68
74
|
const words = $derived(resolveCopy(copy));
|
|
69
75
|
|
|
70
76
|
let viewport = $state<HTMLElement | null>(null);
|
|
71
|
-
let
|
|
77
|
+
let column = $state<Column>(null);
|
|
72
78
|
const follow = new FollowScroll();
|
|
73
79
|
|
|
74
80
|
function metrics(el: HTMLElement) {
|
|
@@ -113,8 +119,13 @@
|
|
|
113
119
|
|
|
114
120
|
function cite(citation: Citation) {
|
|
115
121
|
// A citation derived from the prose has no id to read, so its chip is
|
|
116
|
-
// legible but inert rather than opening an empty pane.
|
|
117
|
-
|
|
122
|
+
// legible but inert rather than opening an empty pane. Replaces
|
|
123
|
+
// whatever the column held, artefact included — never a second pane.
|
|
124
|
+
if (loadDocument && citation.document_id) column = { kind: 'document', citation };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function openArtefact(turn: Turn) {
|
|
128
|
+
column = { kind: 'artefact', turn };
|
|
118
129
|
}
|
|
119
130
|
</script>
|
|
120
131
|
|
|
@@ -173,6 +184,9 @@
|
|
|
173
184
|
suggestions={turn.suggestions ?? []}
|
|
174
185
|
{collectionNames}
|
|
175
186
|
{copy}
|
|
187
|
+
kind={turn.kind}
|
|
188
|
+
title={turn.title}
|
|
189
|
+
onopenartefact={() => openArtefact(turn)}
|
|
176
190
|
oncite={cite}
|
|
177
191
|
onregenerate={index === turns.length - 1 && !running ? onregenerate : undefined}
|
|
178
192
|
onsuggest={index === turns.length - 1 && !running ? onsuggest : undefined}
|
|
@@ -201,7 +215,14 @@
|
|
|
201
215
|
|
|
202
216
|
</div>
|
|
203
217
|
|
|
204
|
-
{#if
|
|
205
|
-
<DocumentPane citation={
|
|
218
|
+
{#if column?.kind === 'document' && loadDocument}
|
|
219
|
+
<DocumentPane citation={column.citation} {loadDocument} {copy} onclose={() => (column = null)} />
|
|
220
|
+
{:else if column?.kind === 'artefact'}
|
|
221
|
+
<ArtefactPane
|
|
222
|
+
turn={column.turn}
|
|
223
|
+
{copy}
|
|
224
|
+
onclose={() => (column = null)}
|
|
225
|
+
oncite={(citation) => cite(citation)}
|
|
226
|
+
/>
|
|
206
227
|
{/if}
|
|
207
228
|
</div>
|
|
@@ -1,9 +1,20 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
One line of Milton's working-out, behind a chevron.
|
|
3
|
+
|
|
4
|
+
Two different blocks render as this row and deliberately read the same. A
|
|
5
|
+
real `thinking` block is one; the other is a `text` block Milton wrote
|
|
6
|
+
BETWEEN two tool calls — "Let me also check whether…" — which the caller
|
|
7
|
+
stream gives no way to tell from the answer except by position, and which
|
|
8
|
+
read as answer prose on production until `segment()` started re-homing it
|
|
9
|
+
here. To a reader both are the same thing: what he was working through, not
|
|
10
|
+
what he concluded. So both fold, and both fold by default.
|
|
11
|
+
-->
|
|
1
12
|
<script lang="ts">
|
|
2
13
|
import ChevronRightIcon from '@lucide/svelte/icons/chevron-right';
|
|
3
|
-
import type { ThinkingBlock } from '../../transcript.svelte';
|
|
14
|
+
import type { TextBlock, ThinkingBlock } from '../../transcript.svelte';
|
|
4
15
|
|
|
5
16
|
interface Props {
|
|
6
|
-
block: ThinkingBlock;
|
|
17
|
+
block: ThinkingBlock | TextBlock;
|
|
7
18
|
active: boolean;
|
|
8
19
|
}
|
|
9
20
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import type { ThinkingBlock } from '../../transcript.svelte';
|
|
1
|
+
import type { TextBlock, ThinkingBlock } from '../../transcript.svelte';
|
|
2
2
|
interface Props {
|
|
3
|
-
block: ThinkingBlock;
|
|
3
|
+
block: ThinkingBlock | TextBlock;
|
|
4
4
|
active: boolean;
|
|
5
5
|
}
|
|
6
6
|
declare const ThinkingRow: import("svelte").Component<Props, {}, "">;
|
|
@@ -91,8 +91,11 @@ export declare class Transcript {
|
|
|
91
91
|
apply(event: AgentEvent): void;
|
|
92
92
|
}
|
|
93
93
|
export interface ActivityStep {
|
|
94
|
-
/** The block this step stands for; a repeated step keeps the FIRST.
|
|
95
|
-
|
|
94
|
+
/** The block this step stands for; a repeated step keeps the FIRST.
|
|
95
|
+
*
|
|
96
|
+
* A `text` block here is interstitial narration, not the answer — see
|
|
97
|
+
* `segment()`. */
|
|
98
|
+
block: Block;
|
|
96
99
|
/** How many identical consecutive steps collapsed into this one. */
|
|
97
100
|
repeats: number;
|
|
98
101
|
}
|
|
@@ -120,11 +123,17 @@ export interface Turn {
|
|
|
120
123
|
/** Follow-ups offered after this answer. Absent on a turn read back from
|
|
121
124
|
* history: they belonged to the moment it was asked. */
|
|
122
125
|
suggestions?: string[];
|
|
126
|
+
/** A study artefact rather than an ordinary answer: renders as a card once
|
|
127
|
+
* settled, never as prose, and opens the reading column instead of a
|
|
128
|
+
* citation. Absent (or `'answer'`) is every ordinary turn. */
|
|
129
|
+
kind?: 'answer' | 'artefact';
|
|
130
|
+
/** The artefact's own name. Only meaningful when `kind` is `'artefact'`. */
|
|
131
|
+
title?: string;
|
|
123
132
|
}
|
|
124
133
|
/**
|
|
125
134
|
* Fold a turn's flat block list into what a reader should actually see.
|
|
126
135
|
*
|
|
127
|
-
*
|
|
136
|
+
* Three problems this solves, all reported off a real transcript:
|
|
128
137
|
*
|
|
129
138
|
* 1. Fifteen tool rows stood between the question and the first word of the
|
|
130
139
|
* answer, so the answer had to be scrolled to. Contiguous activity becomes
|
|
@@ -132,6 +141,25 @@ export interface Turn {
|
|
|
132
141
|
* 2. "Reading pspf guidelines 2026" appeared five times in a row — five pages
|
|
133
142
|
* of one document, which is one act of reading to a human. Consecutive
|
|
134
143
|
* steps with the same label collapse to one row carrying a count.
|
|
144
|
+
* 3. Milton's between-tool narration was rendering unfolded, as answer prose.
|
|
145
|
+
* The caller stream carries no `thinking` blocks at all — measured on
|
|
146
|
+
* production 11/09/2026 — so "Let me also check whether…" arrives as an
|
|
147
|
+
* ordinary `text` block, indistinguishable from the answer except by
|
|
148
|
+
* POSITION. A text block with any tool call still to come in this turn is
|
|
149
|
+
* narration; only the run of text after the LAST tool call is the answer.
|
|
150
|
+
* Narration folds into the activity group as a thinking-shaped step.
|
|
151
|
+
*
|
|
152
|
+
* That third rule is provisional while a turn streams, and deliberately so: a
|
|
153
|
+
* text block that is currently last IS the answer as far as anything can know,
|
|
154
|
+
* and renders as prose. The tool call that arrives after it re-homes it into
|
|
155
|
+
* the group. Nothing here holds state to make that work — `segment` is pure
|
|
156
|
+
* and re-derived on every event, so re-homing is just the next call returning
|
|
157
|
+
* a different shape.
|
|
158
|
+
*
|
|
159
|
+
* The cost of the rule is a real answer that Milton interrupts to go back to
|
|
160
|
+
* the shelf: its first half folds away. That is the trade taken knowingly —
|
|
161
|
+
* the position of a block is the only signal the stream gives, and a rule read
|
|
162
|
+
* off the prose itself would be unexplainable the first time it misfired.
|
|
135
163
|
*/
|
|
136
164
|
export declare function segment(blocks: Block[]): Segment[];
|
|
137
165
|
/** One line describing a whole investigation, for the collapsed state.
|
|
@@ -267,7 +267,7 @@ function renderResult(content) {
|
|
|
267
267
|
/**
|
|
268
268
|
* Fold a turn's flat block list into what a reader should actually see.
|
|
269
269
|
*
|
|
270
|
-
*
|
|
270
|
+
* Three problems this solves, all reported off a real transcript:
|
|
271
271
|
*
|
|
272
272
|
* 1. Fifteen tool rows stood between the question and the first word of the
|
|
273
273
|
* answer, so the answer had to be scrolled to. Contiguous activity becomes
|
|
@@ -275,20 +275,44 @@ function renderResult(content) {
|
|
|
275
275
|
* 2. "Reading pspf guidelines 2026" appeared five times in a row — five pages
|
|
276
276
|
* of one document, which is one act of reading to a human. Consecutive
|
|
277
277
|
* steps with the same label collapse to one row carrying a count.
|
|
278
|
+
* 3. Milton's between-tool narration was rendering unfolded, as answer prose.
|
|
279
|
+
* The caller stream carries no `thinking` blocks at all — measured on
|
|
280
|
+
* production 11/09/2026 — so "Let me also check whether…" arrives as an
|
|
281
|
+
* ordinary `text` block, indistinguishable from the answer except by
|
|
282
|
+
* POSITION. A text block with any tool call still to come in this turn is
|
|
283
|
+
* narration; only the run of text after the LAST tool call is the answer.
|
|
284
|
+
* Narration folds into the activity group as a thinking-shaped step.
|
|
285
|
+
*
|
|
286
|
+
* That third rule is provisional while a turn streams, and deliberately so: a
|
|
287
|
+
* text block that is currently last IS the answer as far as anything can know,
|
|
288
|
+
* and renders as prose. The tool call that arrives after it re-homes it into
|
|
289
|
+
* the group. Nothing here holds state to make that work — `segment` is pure
|
|
290
|
+
* and re-derived on every event, so re-homing is just the next call returning
|
|
291
|
+
* a different shape.
|
|
292
|
+
*
|
|
293
|
+
* The cost of the rule is a real answer that Milton interrupts to go back to
|
|
294
|
+
* the shelf: its first half folds away. That is the trade taken knowingly —
|
|
295
|
+
* the position of a block is the only signal the stream gives, and a rule read
|
|
296
|
+
* off the prose itself would be unexplainable the first time it misfired.
|
|
278
297
|
*/
|
|
279
298
|
export function segment(blocks) {
|
|
280
299
|
const out = [];
|
|
281
300
|
let current = null;
|
|
282
|
-
|
|
283
|
-
|
|
301
|
+
let lastTool = -1;
|
|
302
|
+
for (let i = 0; i < blocks.length; i += 1)
|
|
303
|
+
if (blocks[i].kind === 'tool')
|
|
304
|
+
lastTool = i;
|
|
305
|
+
for (const [i, block] of blocks.entries()) {
|
|
306
|
+
if (block.kind === 'text' && i > lastTool) {
|
|
284
307
|
current = null;
|
|
285
308
|
out.push(block);
|
|
286
309
|
continue;
|
|
287
310
|
}
|
|
288
311
|
// A thinking block with no text is a row whose chevron opens on nothing.
|
|
289
312
|
// Claude Code's thinking display defaults to "omitted", so most arrive
|
|
290
|
-
// empty — rendering them is worse than dropping them.
|
|
291
|
-
|
|
313
|
+
// empty — rendering them is worse than dropping them. An empty narration
|
|
314
|
+
// block is the same row, for the same reason.
|
|
315
|
+
if (block.kind !== 'tool' && !block.text.trim())
|
|
292
316
|
continue;
|
|
293
317
|
if (!current) {
|
|
294
318
|
current = {
|
|
@@ -318,6 +342,10 @@ function sameStep(a, b) {
|
|
|
318
342
|
return false;
|
|
319
343
|
if (a.kind === 'thinking')
|
|
320
344
|
return true;
|
|
345
|
+
// Two narration sentences are two things Milton said; collapsing them to
|
|
346
|
+
// one row with a count would lose the second one entirely.
|
|
347
|
+
if (a.kind === 'text')
|
|
348
|
+
return false;
|
|
321
349
|
const left = describe(a);
|
|
322
350
|
const right = describe(b);
|
|
323
351
|
return left.verb === right.verb && left.object === right.object;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@poodle64/librarian",
|
|
3
|
-
"version": "2026.9.
|
|
3
|
+
"version": "2026.9.12",
|
|
4
4
|
"description": "Milton's conversation surface as a consumable Svelte 5 package: the stream client, transcript state and chat components (transcript, composer, markdown) every household app renders instead of rebuilding.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|