@poodle64/librarian 2026.9.18 → 2026.9.19
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 +80 -16
- package/dist/citations.d.ts +3 -1
- package/dist/client.d.ts +28 -1
- package/dist/client.js +29 -8
- package/dist/components/activity-group/activity-group.svelte +6 -1
- package/dist/components/agent-transcript/agent-transcript.svelte +39 -27
- package/dist/components/agent-transcript/agent-transcript.svelte.d.ts +9 -8
- package/dist/components/artefact-card/artefact-card.svelte +100 -39
- package/dist/components/artefact-card/artefact-card.svelte.d.ts +9 -3
- package/dist/components/artefact-pane/artefact-pane.svelte +5 -5
- package/dist/components/composer/composer.svelte +20 -5
- package/dist/components/composer/composer.svelte.d.ts +8 -2
- package/dist/components/conversation/conversation.svelte +46 -9
- package/dist/components/conversation/conversation.svelte.d.ts +14 -2
- package/dist/components/tool-row/tool-row.svelte +16 -9
- package/dist/components/tool-row/tool-row.svelte.d.ts +3 -1
- package/dist/copy.d.ts +3 -0
- package/dist/copy.js +3 -1
- package/dist/session.svelte.d.ts +31 -0
- package/dist/session.svelte.js +105 -0
- package/dist/transcript.svelte.d.ts +104 -17
- package/dist/transcript.svelte.js +209 -140
- package/package.json +5 -3
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
<!--
|
|
2
|
-
|
|
2
|
+
An artefact that is its answer's own prose, open in the reading column.
|
|
3
3
|
|
|
4
4
|
The same shell shape `DocumentPane` uses (a column at 64rem, a bottom sheet
|
|
5
5
|
below it) because decision 2 of the agent surface is that a colleague never
|
|
@@ -61,10 +61,10 @@
|
|
|
61
61
|
|
|
62
62
|
<header class="ds-lib-pane-header">
|
|
63
63
|
<div class="ds-lib-pane-heading">
|
|
64
|
-
<h2 class="ds-lib-pane-title">{turn.title
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
64
|
+
<h2 class="ds-lib-pane-title">{turn.artefact?.title}</h2>
|
|
65
|
+
{#if turn.artefact?.summary}
|
|
66
|
+
<p class="ds-lib-pane-subtitle">{turn.artefact.summary}</p>
|
|
67
|
+
{/if}
|
|
68
68
|
</div>
|
|
69
69
|
<button
|
|
70
70
|
bind:this={closeButton}
|
|
@@ -30,7 +30,9 @@
|
|
|
30
30
|
interface Props {
|
|
31
31
|
value: string;
|
|
32
32
|
running: boolean;
|
|
33
|
-
scope
|
|
33
|
+
/** What the next message is about. A surface with one scope — a
|
|
34
|
+
* session over one document — passes none and offers no choice. */
|
|
35
|
+
scope?: Scope;
|
|
34
36
|
/** Files to send with the next question. Cleared by the caller on send. */
|
|
35
37
|
files?: File[];
|
|
36
38
|
/** Names for the two narrower scopes; absent means that scope is unavailable. */
|
|
@@ -38,7 +40,7 @@
|
|
|
38
40
|
collectionName?: string;
|
|
39
41
|
/** Hides the paperclip for a host whose ask route takes no files. */
|
|
40
42
|
attachments?: boolean;
|
|
41
|
-
onscope
|
|
43
|
+
onscope?: (scope: Scope) => void;
|
|
42
44
|
onsubmit: () => void;
|
|
43
45
|
onstop: () => void;
|
|
44
46
|
/** Asks for a study artefact instead of an ordinary answer. Omit and no
|
|
@@ -50,6 +52,10 @@
|
|
|
50
52
|
name?: string;
|
|
51
53
|
/** Overrides for the package's own words. */
|
|
52
54
|
copy?: Partial<LibrarianCopy>;
|
|
55
|
+
/** One quiet line in the box's footer, where a scope choice would
|
|
56
|
+
* sit, in the host's words: what sending does here ("It carries on
|
|
57
|
+
* from where it stopped."). */
|
|
58
|
+
note?: string;
|
|
53
59
|
}
|
|
54
60
|
|
|
55
61
|
let {
|
|
@@ -65,7 +71,8 @@
|
|
|
65
71
|
onstop,
|
|
66
72
|
onbriefing,
|
|
67
73
|
name = DEFAULT_PERSONA,
|
|
68
|
-
copy
|
|
74
|
+
copy,
|
|
75
|
+
note
|
|
69
76
|
}: Props = $props();
|
|
70
77
|
|
|
71
78
|
const words = $derived(resolveCopy(copy, name));
|
|
@@ -87,7 +94,7 @@
|
|
|
87
94
|
// passing a name — no pick to make, so the row renders nothing rather
|
|
88
95
|
// than a chip that only ever reselects itself (design-system, the
|
|
89
96
|
// fixed-scope Composer defect).
|
|
90
|
-
const hasChoice = $derived(choices.length > 1);
|
|
97
|
+
const hasChoice = $derived(scope !== undefined && choices.length > 1);
|
|
91
98
|
|
|
92
99
|
// Three chips in a narrow column clipped all three to fragments
|
|
93
100
|
// ("defence-s…", "All colle…"). Wrapping beats truncating: a chip a reader
|
|
@@ -266,12 +273,14 @@
|
|
|
266
273
|
type="button"
|
|
267
274
|
class="ds-lib-scope-chip"
|
|
268
275
|
class:is-chosen={scope === choice.id}
|
|
269
|
-
onclick={() => onscope(choice.id)}
|
|
276
|
+
onclick={() => onscope?.(choice.id)}
|
|
270
277
|
>
|
|
271
278
|
{choice.label}
|
|
272
279
|
</button>
|
|
273
280
|
{/each}
|
|
274
281
|
</div>
|
|
282
|
+
{:else if note}
|
|
283
|
+
<span class="ds-lib-note">{note}</span>
|
|
275
284
|
{/if}
|
|
276
285
|
<span class="ds-lib-spacer"></span>
|
|
277
286
|
{#if running}
|
|
@@ -502,6 +511,12 @@
|
|
|
502
511
|
flex: 1;
|
|
503
512
|
}
|
|
504
513
|
|
|
514
|
+
.ds-lib-note {
|
|
515
|
+
min-width: 0;
|
|
516
|
+
color: var(--ds-color-muted-foreground);
|
|
517
|
+
font-size: var(--ds-text-2xs);
|
|
518
|
+
}
|
|
519
|
+
|
|
505
520
|
.ds-lib-rejected {
|
|
506
521
|
margin: 0;
|
|
507
522
|
padding: 0.375rem 0.25rem 0;
|
|
@@ -3,7 +3,9 @@ export type Scope = 'document' | 'collection' | 'library';
|
|
|
3
3
|
interface Props {
|
|
4
4
|
value: string;
|
|
5
5
|
running: boolean;
|
|
6
|
-
scope
|
|
6
|
+
/** What the next message is about. A surface with one scope — a
|
|
7
|
+
* session over one document — passes none and offers no choice. */
|
|
8
|
+
scope?: Scope;
|
|
7
9
|
/** Files to send with the next question. Cleared by the caller on send. */
|
|
8
10
|
files?: File[];
|
|
9
11
|
/** Names for the two narrower scopes; absent means that scope is unavailable. */
|
|
@@ -11,7 +13,7 @@ interface Props {
|
|
|
11
13
|
collectionName?: string;
|
|
12
14
|
/** Hides the paperclip for a host whose ask route takes no files. */
|
|
13
15
|
attachments?: boolean;
|
|
14
|
-
onscope
|
|
16
|
+
onscope?: (scope: Scope) => void;
|
|
15
17
|
onsubmit: () => void;
|
|
16
18
|
onstop: () => void;
|
|
17
19
|
/** Asks for a study artefact instead of an ordinary answer. Omit and no
|
|
@@ -23,6 +25,10 @@ interface Props {
|
|
|
23
25
|
name?: string;
|
|
24
26
|
/** Overrides for the package's own words. */
|
|
25
27
|
copy?: Partial<LibrarianCopy>;
|
|
28
|
+
/** One quiet line in the box's footer, where a scope choice would
|
|
29
|
+
* sit, in the host's words: what sending does here ("It carries on
|
|
30
|
+
* from where it stopped."). */
|
|
31
|
+
note?: string;
|
|
26
32
|
}
|
|
27
33
|
declare const Composer: import("svelte").Component<Props, {}, "value" | "files">;
|
|
28
34
|
type Composer = ReturnType<typeof Composer>;
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
<script lang="ts">
|
|
12
12
|
import type { Snippet } from 'svelte';
|
|
13
13
|
import ArrowDownIcon from '@lucide/svelte/icons/arrow-down';
|
|
14
|
-
import type { Turn } from '../../transcript.svelte';
|
|
14
|
+
import type { DescribeTool, Turn } from '../../transcript.svelte';
|
|
15
15
|
import type { Citation, LoadDocument } from '../../citations';
|
|
16
16
|
import { DEFAULT_PERSONA, personaName, resolveCopy, type LibrarianCopy } from '../../copy';
|
|
17
17
|
import { FollowScroll } from '../../follow-scroll.svelte';
|
|
@@ -55,6 +55,18 @@
|
|
|
55
55
|
copy?: Partial<LibrarianCopy>;
|
|
56
56
|
/** Enables the source pane. Without it, chips render but do not open. */
|
|
57
57
|
loadDocument?: LoadDocument;
|
|
58
|
+
/** Takes every citation tap instead, for a host that shows the source
|
|
59
|
+
* in a viewer of its own. */
|
|
60
|
+
oncite?: (citation: Citation) => void;
|
|
61
|
+
/** Takes every artefact card's tap, for a host that shows an artefact
|
|
62
|
+
* in a column of its own. Without it, an artefact that IS its answer
|
|
63
|
+
* opens in this surface's own pane, and any other informs and does
|
|
64
|
+
* not open. */
|
|
65
|
+
onopenartefact?: (turn: Turn) => void;
|
|
66
|
+
/** The id of the turn whose artefact the host is showing now. */
|
|
67
|
+
showing?: string;
|
|
68
|
+
/** The persona's own words for its tools, and what they read. */
|
|
69
|
+
describeTool?: DescribeTool;
|
|
58
70
|
collectionNames?: Set<string>;
|
|
59
71
|
/** The composer, rendered INSIDE the transcript column so the source
|
|
60
72
|
* pane narrows it too — a composer the host places outside slides
|
|
@@ -75,6 +87,10 @@
|
|
|
75
87
|
onsuggest,
|
|
76
88
|
copy,
|
|
77
89
|
loadDocument,
|
|
90
|
+
oncite,
|
|
91
|
+
onopenartefact,
|
|
92
|
+
showing,
|
|
93
|
+
describeTool,
|
|
78
94
|
collectionNames = new Set(),
|
|
79
95
|
composer
|
|
80
96
|
}: Props = $props();
|
|
@@ -140,7 +156,8 @@
|
|
|
140
156
|
// colleague who just asked for one is asking to read it, not merely to
|
|
141
157
|
// see that it exists. Fires on the RUNNING → settled transition only, so
|
|
142
158
|
// reopening a past conversation whose last turn happens to be an
|
|
143
|
-
// artefact does not reopen a pane the reader may have since closed.
|
|
159
|
+
// artefact does not reopen a pane the reader may have since closed. A
|
|
160
|
+
// host that opens artefacts itself decides this for itself.
|
|
144
161
|
//
|
|
145
162
|
// Plain, not `$state`: read and written in the same effect, which would
|
|
146
163
|
// otherwise re-trigger itself forever (the pattern this file's `seen`
|
|
@@ -148,7 +165,13 @@
|
|
|
148
165
|
let wasRunning = false;
|
|
149
166
|
$effect(() => {
|
|
150
167
|
const last = turns.at(-1);
|
|
151
|
-
if (
|
|
168
|
+
if (
|
|
169
|
+
!onopenartefact &&
|
|
170
|
+
wasRunning &&
|
|
171
|
+
!running &&
|
|
172
|
+
last?.artefact?.isAnswer &&
|
|
173
|
+
!last.outcome?.isError
|
|
174
|
+
) {
|
|
152
175
|
column = { kind: 'artefact', turn: last };
|
|
153
176
|
}
|
|
154
177
|
wasRunning = running;
|
|
@@ -165,14 +188,23 @@
|
|
|
165
188
|
}
|
|
166
189
|
|
|
167
190
|
function cite(citation: Citation) {
|
|
191
|
+
if (oncite) return oncite(citation);
|
|
168
192
|
// A citation derived from the prose has no id to read, so its chip is
|
|
169
193
|
// legible but inert rather than opening an empty pane. Replaces
|
|
170
194
|
// whatever the column held, artefact included — never a second pane.
|
|
171
195
|
if (loadDocument && citation.document_id) column = { kind: 'document', citation };
|
|
172
196
|
}
|
|
173
197
|
|
|
174
|
-
|
|
175
|
-
|
|
198
|
+
/** Who opens this turn's artefact, if anyone can. */
|
|
199
|
+
function opener(turn: Turn): (() => void) | undefined {
|
|
200
|
+
if (onopenartefact) return () => onopenartefact(turn);
|
|
201
|
+
if (turn.artefact?.isAnswer) return () => (column = { kind: 'artefact', turn });
|
|
202
|
+
return undefined;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function isShowing(turn: Turn): boolean {
|
|
206
|
+
if (onopenartefact) return showing === turn.id;
|
|
207
|
+
return column?.kind === 'artefact' && column.turn.id === turn.id;
|
|
176
208
|
}
|
|
177
209
|
</script>
|
|
178
210
|
|
|
@@ -235,10 +267,11 @@
|
|
|
235
267
|
copy={words}
|
|
236
268
|
name={who}
|
|
237
269
|
at={turn.at ?? stamps[turn.id]}
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
onopenartefact={(
|
|
241
|
-
|
|
270
|
+
artefact={turn.artefact}
|
|
271
|
+
artefactOpen={isShowing(turn)}
|
|
272
|
+
onopenartefact={opener(turn)}
|
|
273
|
+
{describeTool}
|
|
274
|
+
oncite={oncite || loadDocument ? cite : undefined}
|
|
242
275
|
onregenerate={index === turns.length - 1 && !running ? onregenerate : undefined}
|
|
243
276
|
onsuggest={index === turns.length - 1 && !running ? onsuggest : undefined}
|
|
244
277
|
/>
|
|
@@ -282,8 +315,12 @@
|
|
|
282
315
|
</div>
|
|
283
316
|
|
|
284
317
|
<style>
|
|
318
|
+
/* `min-width: 0` for a host that sets this in a row beside columns of its
|
|
319
|
+
own: a flex item's automatic minimum is its widest content, and a table
|
|
320
|
+
in an answer then pushed the whole page sideways at 390. */
|
|
285
321
|
.ds-lib-surface {
|
|
286
322
|
display: flex;
|
|
323
|
+
min-width: 0;
|
|
287
324
|
min-height: 0;
|
|
288
325
|
flex: 1;
|
|
289
326
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { Snippet } from 'svelte';
|
|
2
|
-
import type { Turn } from '../../transcript.svelte';
|
|
3
|
-
import type { LoadDocument } from '../../citations';
|
|
2
|
+
import type { DescribeTool, Turn } from '../../transcript.svelte';
|
|
3
|
+
import type { Citation, LoadDocument } from '../../citations';
|
|
4
4
|
import { type LibrarianCopy } from '../../copy';
|
|
5
5
|
interface Props {
|
|
6
6
|
turns: Turn[];
|
|
@@ -32,6 +32,18 @@ interface Props {
|
|
|
32
32
|
copy?: Partial<LibrarianCopy>;
|
|
33
33
|
/** Enables the source pane. Without it, chips render but do not open. */
|
|
34
34
|
loadDocument?: LoadDocument;
|
|
35
|
+
/** Takes every citation tap instead, for a host that shows the source
|
|
36
|
+
* in a viewer of its own. */
|
|
37
|
+
oncite?: (citation: Citation) => void;
|
|
38
|
+
/** Takes every artefact card's tap, for a host that shows an artefact
|
|
39
|
+
* in a column of its own. Without it, an artefact that IS its answer
|
|
40
|
+
* opens in this surface's own pane, and any other informs and does
|
|
41
|
+
* not open. */
|
|
42
|
+
onopenartefact?: (turn: Turn) => void;
|
|
43
|
+
/** The id of the turn whose artefact the host is showing now. */
|
|
44
|
+
showing?: string;
|
|
45
|
+
/** The persona's own words for its tools, and what they read. */
|
|
46
|
+
describeTool?: DescribeTool;
|
|
35
47
|
collectionNames?: Set<string>;
|
|
36
48
|
/** The composer, rendered INSIDE the transcript column so the source
|
|
37
49
|
* pane narrows it too — a composer the host places outside slides
|
|
@@ -6,12 +6,13 @@
|
|
|
6
6
|
five separate events rather than one train of thought.
|
|
7
7
|
|
|
8
8
|
The row says what the persona DID, in a reader's own words — never the
|
|
9
|
-
tool's name or the raw command it ran
|
|
10
|
-
|
|
9
|
+
tool's name or the raw command it ran: the host's `DescribeTool`, or the
|
|
10
|
+
package's own `describe()`. Expanding a settled row shows what came back,
|
|
11
|
+
never what was typed.
|
|
11
12
|
-->
|
|
12
13
|
<script lang="ts">
|
|
13
14
|
import ChevronRightIcon from '@lucide/svelte/icons/chevron-right';
|
|
14
|
-
import { describe, type ToolBlock } from '../../transcript.svelte';
|
|
15
|
+
import { describe, type ToolBlock, type ToolWords } from '../../transcript.svelte';
|
|
15
16
|
|
|
16
17
|
interface Props {
|
|
17
18
|
block: ToolBlock;
|
|
@@ -19,15 +20,21 @@
|
|
|
19
20
|
/** Identical consecutive steps folded into this row — five pages of one
|
|
20
21
|
* document is one act of reading to a human. */
|
|
21
22
|
repeats?: number;
|
|
23
|
+
/** The call in the persona's words. Absent takes the package's own. */
|
|
24
|
+
words?: ToolWords;
|
|
22
25
|
}
|
|
23
26
|
|
|
24
|
-
let { block, running, repeats = 1 }: Props = $props();
|
|
27
|
+
let { block, running, repeats = 1, words }: Props = $props();
|
|
25
28
|
let open = $state(false);
|
|
26
29
|
|
|
27
30
|
const settled = $derived(block.result !== undefined);
|
|
28
|
-
const said = $derived(describe(block));
|
|
31
|
+
const said = $derived(words ?? describe(block));
|
|
29
32
|
const tone = $derived(block.isError ? 'error' : settled ? 'success' : 'info');
|
|
30
|
-
const
|
|
33
|
+
const again = $derived(
|
|
34
|
+
said.repeat
|
|
35
|
+
? `· ${repeats} ${repeats === 1 ? said.repeat[0] : said.repeat[1]}`
|
|
36
|
+
: `× ${repeats}`
|
|
37
|
+
);
|
|
31
38
|
</script>
|
|
32
39
|
|
|
33
40
|
<div class="ds-lib-tool">
|
|
@@ -45,7 +52,7 @@
|
|
|
45
52
|
<span class="ds-lib-tool-said">
|
|
46
53
|
<span class="ds-lib-tool-verb">{said.verb}</span>
|
|
47
54
|
{#if said.object}<span class="ds-lib-tool-object">{said.object}</span>{/if}
|
|
48
|
-
{#if repeats > 1}<span
|
|
55
|
+
{#if repeats > 1}<span>{again}</span>{/if}
|
|
49
56
|
</span>
|
|
50
57
|
</button>
|
|
51
58
|
|
|
@@ -55,8 +62,8 @@
|
|
|
55
62
|
<pre class="ds-lib-tool-result">{block.result}</pre>
|
|
56
63
|
{/if}
|
|
57
64
|
</div>
|
|
58
|
-
{:else if
|
|
59
|
-
<p class="ds-lib-tool-lines">{
|
|
65
|
+
{:else if said.detail}
|
|
66
|
+
<p class="ds-lib-tool-lines">{said.detail}</p>
|
|
60
67
|
{/if}
|
|
61
68
|
</div>
|
|
62
69
|
|
|
@@ -1,10 +1,12 @@
|
|
|
1
|
-
import { type ToolBlock } from '../../transcript.svelte';
|
|
1
|
+
import { type ToolBlock, type ToolWords } from '../../transcript.svelte';
|
|
2
2
|
interface Props {
|
|
3
3
|
block: ToolBlock;
|
|
4
4
|
running: boolean;
|
|
5
5
|
/** Identical consecutive steps folded into this row — five pages of one
|
|
6
6
|
* document is one act of reading to a human. */
|
|
7
7
|
repeats?: number;
|
|
8
|
+
/** The call in the persona's words. Absent takes the package's own. */
|
|
9
|
+
words?: ToolWords;
|
|
8
10
|
}
|
|
9
11
|
declare const ToolRow: import("svelte").Component<Props, {}, "">;
|
|
10
12
|
type ToolRow = ReturnType<typeof ToolRow>;
|
package/dist/copy.d.ts
CHANGED
|
@@ -52,6 +52,9 @@ export interface LibrarianCopy {
|
|
|
52
52
|
jumpToLatest: string;
|
|
53
53
|
closeSource: string;
|
|
54
54
|
documentUnavailable: string;
|
|
55
|
+
/** An artefact card's action, and the same action while it is open. */
|
|
56
|
+
openArtefact: string;
|
|
57
|
+
showingArtefact: string;
|
|
55
58
|
}
|
|
56
59
|
/**
|
|
57
60
|
* The display name for a persona a host identified by its slug.
|
package/dist/copy.js
CHANGED
|
@@ -72,7 +72,9 @@ export function copyFor(name = DEFAULT_PERSONA) {
|
|
|
72
72
|
askAgain: 'Ask again',
|
|
73
73
|
jumpToLatest: 'Jump to latest',
|
|
74
74
|
closeSource: 'Close source',
|
|
75
|
-
documentUnavailable: "That document can't be opened right now."
|
|
75
|
+
documentUnavailable: "That document can't be opened right now.",
|
|
76
|
+
openArtefact: 'Open',
|
|
77
|
+
showingArtefact: 'Showing'
|
|
76
78
|
};
|
|
77
79
|
}
|
|
78
80
|
/** The library's own words, for a host that names no persona. */
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A whole session as a conversation: every run a stream carries, each one a
|
|
3
|
+
* turn with the reader's words on one side and the persona's on the other.
|
|
4
|
+
*
|
|
5
|
+
* `Transcript` folds the events of ONE question the host asked. A session the
|
|
6
|
+
* APP started — a job that reads a document with nobody asking — is a
|
|
7
|
+
* different stream: its prompt and every later message arrive as the CLI
|
|
8
|
+
* echoes each one back (`--replay-user-messages`), and each run ends in its
|
|
9
|
+
* own `result`. So a replayed user frame opens a turn, everything after it
|
|
10
|
+
* folds into that turn, and its `result` settles it.
|
|
11
|
+
*
|
|
12
|
+
* Every Claude Code event carries a `uuid`, and one already folded is
|
|
13
|
+
* skipped: a watch route replays from the first event, so opening it again
|
|
14
|
+
* after the session moves on folds only what is new. A frame the server adds
|
|
15
|
+
* itself (an error, the library's citations) carries none, and is known by
|
|
16
|
+
* the event it follows.
|
|
17
|
+
*/
|
|
18
|
+
import type { AgentEvent } from './client';
|
|
19
|
+
import { type Turn } from './transcript.svelte';
|
|
20
|
+
export declare class Session {
|
|
21
|
+
#private;
|
|
22
|
+
/** One per run, in order: plain objects, so a host may spread one to add
|
|
23
|
+
* its own `artefact`. */
|
|
24
|
+
turns: Turn[];
|
|
25
|
+
/** Bumped on every applied event — see `Transcript.version`. */
|
|
26
|
+
version: number;
|
|
27
|
+
/** The last run has started and not settled. A run the reader stopped
|
|
28
|
+
* never settles, so a host ANDs this with its own "the stream is open". */
|
|
29
|
+
get working(): boolean;
|
|
30
|
+
apply(event: AgentEvent): void;
|
|
31
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A whole session as a conversation: every run a stream carries, each one a
|
|
3
|
+
* turn with the reader's words on one side and the persona's on the other.
|
|
4
|
+
*
|
|
5
|
+
* `Transcript` folds the events of ONE question the host asked. A session the
|
|
6
|
+
* APP started — a job that reads a document with nobody asking — is a
|
|
7
|
+
* different stream: its prompt and every later message arrive as the CLI
|
|
8
|
+
* echoes each one back (`--replay-user-messages`), and each run ends in its
|
|
9
|
+
* own `result`. So a replayed user frame opens a turn, everything after it
|
|
10
|
+
* folds into that turn, and its `result` settles it.
|
|
11
|
+
*
|
|
12
|
+
* Every Claude Code event carries a `uuid`, and one already folded is
|
|
13
|
+
* skipped: a watch route replays from the first event, so opening it again
|
|
14
|
+
* after the session moves on folds only what is new. A frame the server adds
|
|
15
|
+
* itself (an error, the library's citations) carries none, and is known by
|
|
16
|
+
* the event it follows.
|
|
17
|
+
*/
|
|
18
|
+
import { contentOf, fold, foldState } from './transcript.svelte';
|
|
19
|
+
/** The events a run is made of. One arriving after its turn settled is the
|
|
20
|
+
* next run, whether or not its prompt was echoed. */
|
|
21
|
+
const RUN = new Set(['stream_event', 'assistant', 'user', 'result']);
|
|
22
|
+
/** Everything else that lands on a turn: the library's trailing frames, and
|
|
23
|
+
* a stream that broke. Anything else (`system`, rate limits) is the CLI
|
|
24
|
+
* talking about itself. */
|
|
25
|
+
const FOLDED = new Set([...RUN, 'library_error', 'citations', 'suggestions']);
|
|
26
|
+
export class Session {
|
|
27
|
+
/** One per run, in order: plain objects, so a host may spread one to add
|
|
28
|
+
* its own `artefact`. */
|
|
29
|
+
turns = $state([]);
|
|
30
|
+
/** Bumped on every applied event — see `Transcript.version`. */
|
|
31
|
+
version = $state(0);
|
|
32
|
+
#folds = [];
|
|
33
|
+
// A plain Set, as `Transcript`'s fold map is: nothing renders it.
|
|
34
|
+
// eslint-disable-next-line svelte/prefer-svelte-reactivity
|
|
35
|
+
#seen = new Set();
|
|
36
|
+
/** The last `uuid` in stream order, folded or skipped. */
|
|
37
|
+
#after = '';
|
|
38
|
+
/** The last run has started and not settled. A run the reader stopped
|
|
39
|
+
* never settles, so a host ANDs this with its own "the stream is open". */
|
|
40
|
+
get working() {
|
|
41
|
+
const last = this.turns.at(-1);
|
|
42
|
+
return last !== undefined && last.outcome === null;
|
|
43
|
+
}
|
|
44
|
+
apply(event) {
|
|
45
|
+
const key = event.uuid ?? `${this.#after}|${JSON.stringify(event)}`;
|
|
46
|
+
if (event.uuid)
|
|
47
|
+
this.#after = event.uuid;
|
|
48
|
+
if (this.#seen.has(key))
|
|
49
|
+
return;
|
|
50
|
+
this.#seen.add(key);
|
|
51
|
+
if (event.type === 'user' && event.isReplay) {
|
|
52
|
+
this.#open(event.uuid ?? `turn-${this.turns.length}`, said(event), stamp(event));
|
|
53
|
+
this.version += 1;
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
if (!FOLDED.has(event.type))
|
|
57
|
+
return;
|
|
58
|
+
// Settled by its own `result`. A stream that broke mid-run is not: the
|
|
59
|
+
// run goes on, and a watch opened again folds the rest onto it.
|
|
60
|
+
const last = this.turns.at(-1);
|
|
61
|
+
const settled = last?.outcome != null && !last.outcome.unreachable;
|
|
62
|
+
// The watch broke while nothing was running, and no run failed.
|
|
63
|
+
if (event.type === 'library_error' && settled)
|
|
64
|
+
return;
|
|
65
|
+
// A run whose prompt was never echoed — a session started without the
|
|
66
|
+
// replay flag, or one that failed before it said anything — still has
|
|
67
|
+
// an answer to show; its question is simply not on the wire.
|
|
68
|
+
if (!last || (settled && RUN.has(event.type))) {
|
|
69
|
+
this.#open(`turn-${this.turns.length}`, '', undefined);
|
|
70
|
+
}
|
|
71
|
+
else if (last.outcome?.unreachable && RUN.has(event.type)) {
|
|
72
|
+
// The stream came back and the run is still going.
|
|
73
|
+
last.outcome = null;
|
|
74
|
+
}
|
|
75
|
+
fold(this.turns[this.turns.length - 1], this.#folds[this.#folds.length - 1], event);
|
|
76
|
+
this.version += 1;
|
|
77
|
+
}
|
|
78
|
+
#open(id, question, at) {
|
|
79
|
+
this.turns.push({
|
|
80
|
+
id,
|
|
81
|
+
question,
|
|
82
|
+
at,
|
|
83
|
+
blocks: [],
|
|
84
|
+
outcome: null,
|
|
85
|
+
citations: [],
|
|
86
|
+
suggestions: []
|
|
87
|
+
});
|
|
88
|
+
this.#folds.push(foldState());
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
/** The words of a replayed frame: a bare string, or its text blocks. */
|
|
92
|
+
function said(event) {
|
|
93
|
+
const content = event.message?.content;
|
|
94
|
+
if (typeof content === 'string')
|
|
95
|
+
return content;
|
|
96
|
+
return contentOf(event)
|
|
97
|
+
.filter((block) => block.type === 'text' && typeof block.text === 'string')
|
|
98
|
+
.map((block) => block.text)
|
|
99
|
+
.join('\n\n');
|
|
100
|
+
}
|
|
101
|
+
/** When the CLI consumed the frame, which is when this run began. */
|
|
102
|
+
function stamp(event) {
|
|
103
|
+
const at = event.timestamp ? Date.parse(event.timestamp) : NaN;
|
|
104
|
+
return Number.isNaN(at) ? undefined : at;
|
|
105
|
+
}
|