@poodle64/librarian 2026.9.18 → 2026.9.20
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 +89 -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 +46 -17
- package/dist/components/composer/composer.svelte.d.ts +13 -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
|
@@ -21,6 +21,8 @@ export interface ThinkingBlock {
|
|
|
21
21
|
export interface ToolBlock {
|
|
22
22
|
kind: 'tool';
|
|
23
23
|
index: number;
|
|
24
|
+
/** The call's `tool_use` id, which its result names. */
|
|
25
|
+
id?: string;
|
|
24
26
|
name: string;
|
|
25
27
|
/** Accumulated `input_json_delta`. Parsed lazily — it is invalid JSON mid-stream. */
|
|
26
28
|
rawInput: string;
|
|
@@ -39,6 +41,9 @@ export interface Outcome {
|
|
|
39
41
|
* sentence a reader sees is the persona's own (`copy.unreachable`), which
|
|
40
42
|
* only the rendering layer knows, so this carries the FACT and no words. */
|
|
41
43
|
unreachable?: boolean;
|
|
44
|
+
/** What the run returned under `--json-schema`: the thing a host cards
|
|
45
|
+
* as a turn's `artefact`. */
|
|
46
|
+
structuredOutput?: unknown;
|
|
42
47
|
}
|
|
43
48
|
/**
|
|
44
49
|
* The question as the READER asked it.
|
|
@@ -52,6 +57,32 @@ export interface Outcome {
|
|
|
52
57
|
* entire question is "You are wrong about the leave rule" must still see it.
|
|
53
58
|
*/
|
|
54
59
|
export declare function readerQuestion(question: string): string;
|
|
60
|
+
/** One tool call in the persona's own words: its row, its part of the
|
|
61
|
+
* activity line, and what it read. */
|
|
62
|
+
export interface ToolWords {
|
|
63
|
+
/** What it did: "Read", "Looked for". */
|
|
64
|
+
verb: string;
|
|
65
|
+
/** What it did it to, beside the verb. */
|
|
66
|
+
object: string;
|
|
67
|
+
/** One dimmed line under the row. */
|
|
68
|
+
detail?: string;
|
|
69
|
+
/** How the activity line counts the call, singular and plural:
|
|
70
|
+
* `['search', 'searches']` reads "2 searches". Absent is not counted. */
|
|
71
|
+
tally?: [one: string, many: string];
|
|
72
|
+
/** How the row counts a run of identical calls: `['page', 'pages']`
|
|
73
|
+
* reads "· 5 pages". Absent reads "× 5". */
|
|
74
|
+
repeat?: [one: string, many: string];
|
|
75
|
+
/** Something the call read that a reader can open, listed under the
|
|
76
|
+
* answer and handed back to the host's `oncite` when tapped. */
|
|
77
|
+
source?: {
|
|
78
|
+
id: string;
|
|
79
|
+
title: string;
|
|
80
|
+
section?: string;
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
/** A host's words for its persona's tools. `undefined` leaves the call to
|
|
84
|
+
* `describe()`, the package's own. */
|
|
85
|
+
export type DescribeTool = (block: ToolBlock) => ToolWords | undefined;
|
|
55
86
|
/** What Milton DID, in a reader's own words — never the tool's name or the
|
|
56
87
|
* raw command it ran.
|
|
57
88
|
*
|
|
@@ -59,11 +90,11 @@ export declare function readerQuestion(question: string): string;
|
|
|
59
90
|
* by an engineer: `Bash ls -1 .` tells them nothing and looks like a leak from
|
|
60
91
|
* the machine room. Milton never narrates how he searched, so a tool this
|
|
61
92
|
* does not recognise falls back to something that names no mechanism at all.
|
|
93
|
+
* A persona with tools of its own brings a `DescribeTool` in front of this.
|
|
62
94
|
*/
|
|
63
|
-
export declare function describe(block: ToolBlock):
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
};
|
|
95
|
+
export declare function describe(block: ToolBlock): ToolWords;
|
|
96
|
+
/** The host's words for a call, or the package's where it has none. */
|
|
97
|
+
export declare function wordsFor(block: ToolBlock, describeTool?: DescribeTool): ToolWords;
|
|
67
98
|
/** Best-effort one-line summary of a tool call, for the collapsed row.
|
|
68
99
|
*
|
|
69
100
|
* A staged path leads with a 64-character content hash and ends with the
|
|
@@ -95,6 +126,37 @@ export declare class Transcript {
|
|
|
95
126
|
reset(): void;
|
|
96
127
|
apply(event: AgentEvent): void;
|
|
97
128
|
}
|
|
129
|
+
/** What one turn's fold writes: a `Transcript`, or a turn of a `Session`. */
|
|
130
|
+
export interface FoldTarget {
|
|
131
|
+
blocks: Block[];
|
|
132
|
+
outcome: Outcome | null;
|
|
133
|
+
citations?: Citation[];
|
|
134
|
+
suggestions?: string[];
|
|
135
|
+
}
|
|
136
|
+
/** Where one turn's fold keeps its place between events. */
|
|
137
|
+
export interface FoldState {
|
|
138
|
+
/** Content-block index is per MESSAGE, so it repeats across a turn; this
|
|
139
|
+
* maps the live index onto a position in the flat list. Cleared whenever a
|
|
140
|
+
* message starts, which is what stops message two overwriting message one. */
|
|
141
|
+
open: Map<number, number>;
|
|
142
|
+
/** The stream carries token-level deltas (`--include-partial-messages`),
|
|
143
|
+
* and the CLI then ALSO emits every message whole: folding both would
|
|
144
|
+
* print each block twice. A stream without the flag carries only the
|
|
145
|
+
* whole messages, and those are the turn. */
|
|
146
|
+
partial: boolean;
|
|
147
|
+
}
|
|
148
|
+
export declare const foldState: () => FoldState;
|
|
149
|
+
/**
|
|
150
|
+
* Fold one event into one turn. False for an event this does not know, which
|
|
151
|
+
* the caller keeps rather than drops.
|
|
152
|
+
*
|
|
153
|
+
* It folds, it does not translate: every block is a real content block from
|
|
154
|
+
* the stream, assembled from its deltas when the stream carries them and
|
|
155
|
+
* taken whole when it does not.
|
|
156
|
+
*/
|
|
157
|
+
export declare function fold(into: FoldTarget, state: FoldState, event: AgentEvent): boolean;
|
|
158
|
+
/** A message's content blocks; a user frame may carry a bare string instead. */
|
|
159
|
+
export declare function contentOf(event: AgentEvent): Array<Record<string, unknown>>;
|
|
98
160
|
export interface ActivityStep {
|
|
99
161
|
/** The block this step stands for; a repeated step keeps the FIRST.
|
|
100
162
|
*
|
|
@@ -103,15 +165,22 @@ export interface ActivityStep {
|
|
|
103
165
|
block: Block;
|
|
104
166
|
/** How many identical consecutive steps collapsed into this one. */
|
|
105
167
|
repeats: number;
|
|
168
|
+
/** A tool step in the persona's words, resolved once here so the row, the
|
|
169
|
+
* count and the collapse all agree. */
|
|
170
|
+
words?: ToolWords;
|
|
171
|
+
}
|
|
172
|
+
/** One count on the activity line: "2 searches". */
|
|
173
|
+
export interface Tally {
|
|
174
|
+
one: string;
|
|
175
|
+
many: string;
|
|
176
|
+
count: number;
|
|
106
177
|
}
|
|
107
178
|
export interface ActivityGroup {
|
|
108
179
|
kind: 'activity';
|
|
109
180
|
index: number;
|
|
110
181
|
steps: ActivityStep[];
|
|
111
|
-
/**
|
|
112
|
-
|
|
113
|
-
documents: number;
|
|
114
|
-
searches: number;
|
|
182
|
+
/** What the steps counted as, in the order each first appeared. */
|
|
183
|
+
tallies: Tally[];
|
|
115
184
|
}
|
|
116
185
|
export type Segment = ActivityGroup | TextBlock;
|
|
117
186
|
/** One question and the answer to it, as the transcript renders it.
|
|
@@ -133,12 +202,20 @@ export interface Turn {
|
|
|
133
202
|
/** Follow-ups offered after this answer. Absent on a turn read back from
|
|
134
203
|
* history: they belonged to the moment it was asked. */
|
|
135
204
|
suggestions?: string[];
|
|
136
|
-
/**
|
|
137
|
-
*
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
205
|
+
/** Something this turn produced for the reader to open, carded under the
|
|
206
|
+
* answer once the turn settles. */
|
|
207
|
+
artefact?: Artefact;
|
|
208
|
+
}
|
|
209
|
+
/** A turn's artefact, in the host's own words. */
|
|
210
|
+
export interface Artefact {
|
|
211
|
+
title: string;
|
|
212
|
+
/** One line under the title: what it holds. */
|
|
213
|
+
summary?: string;
|
|
214
|
+
/** The answer's prose IS the artefact (a briefing the persona wrote): it
|
|
215
|
+
* opens in the reading column, and the turn shows the card alone once it
|
|
216
|
+
* settles. Absent, the artefact is something beside the answer, and the
|
|
217
|
+
* card sits under the prose. */
|
|
218
|
+
isAnswer?: boolean;
|
|
142
219
|
}
|
|
143
220
|
/**
|
|
144
221
|
* Fold a turn's flat block list into what a reader should actually see.
|
|
@@ -171,10 +248,20 @@ export interface Turn {
|
|
|
171
248
|
* the position of a block is the only signal the stream gives, and a rule read
|
|
172
249
|
* off the prose itself would be unexplainable the first time it misfired.
|
|
173
250
|
*/
|
|
174
|
-
export declare function segment(blocks: Block[]): Segment[];
|
|
251
|
+
export declare function segment(blocks: Block[], describeTool?: DescribeTool): Segment[];
|
|
175
252
|
/** One line describing a whole investigation, for the collapsed state.
|
|
176
253
|
*
|
|
177
|
-
* Counts of what
|
|
178
|
-
* organises what
|
|
254
|
+
* Counts of what the persona did are fine ("1 search · 2 documents read"); how
|
|
255
|
+
* it organises what it knows is not — no collection count, no shelf, no corpus.
|
|
179
256
|
*/
|
|
180
257
|
export declare function summariseActivity(group: ActivityGroup): string;
|
|
258
|
+
/**
|
|
259
|
+
* What a turn's calls read that a reader can open, numbered in the order it
|
|
260
|
+
* was first read: the host's `ToolWords.source`, as the sources the answer
|
|
261
|
+
* lists under itself. A call still running, or one that failed, read nothing.
|
|
262
|
+
*
|
|
263
|
+
* `derived`, because nothing checked any of it against a publisher: a trust
|
|
264
|
+
* mark on a page the persona just looked at would be a claim about a record
|
|
265
|
+
* nobody read.
|
|
266
|
+
*/
|
|
267
|
+
export declare function readFrom(blocks: Block[], describeTool?: DescribeTool): Citation[];
|
|
@@ -32,16 +32,27 @@ export function readerQuestion(question) {
|
|
|
32
32
|
* by an engineer: `Bash ls -1 .` tells them nothing and looks like a leak from
|
|
33
33
|
* the machine room. Milton never narrates how he searched, so a tool this
|
|
34
34
|
* does not recognise falls back to something that names no mechanism at all.
|
|
35
|
+
* A persona with tools of its own brings a `DescribeTool` in front of this.
|
|
35
36
|
*/
|
|
36
37
|
export function describe(block) {
|
|
38
|
+
const said = describeCall(block);
|
|
39
|
+
const lines = block.result ? block.result.split('\n').length : 0;
|
|
40
|
+
return lines ? { ...said, detail: lines === 1 ? '1 line' : `${lines} lines` } : said;
|
|
41
|
+
}
|
|
42
|
+
const READ = {
|
|
43
|
+
tally: ['document read', 'documents read'],
|
|
44
|
+
repeat: ['page', 'pages']
|
|
45
|
+
};
|
|
46
|
+
const SEARCH = { tally: ['search', 'searches'] };
|
|
47
|
+
function describeCall(block) {
|
|
37
48
|
const input = parseInput(block);
|
|
38
49
|
const command = typeof input.command === 'string' ? input.command : '';
|
|
39
50
|
if (block.name === 'Read') {
|
|
40
51
|
const path = str(input.file_path ?? input.path);
|
|
41
|
-
return { verb: 'Read', object: documentName(path) };
|
|
52
|
+
return { verb: 'Read', object: documentName(path), ...READ };
|
|
42
53
|
}
|
|
43
54
|
if (block.name === 'Grep') {
|
|
44
|
-
return { verb: 'Looked for', object: str(input.pattern) };
|
|
55
|
+
return { verb: 'Looked for', object: str(input.pattern), ...SEARCH };
|
|
45
56
|
}
|
|
46
57
|
if (block.name === 'Glob') {
|
|
47
58
|
return { verb: 'Looked for documents', object: '' };
|
|
@@ -49,14 +60,14 @@ export function describe(block) {
|
|
|
49
60
|
if (block.name === 'Bash') {
|
|
50
61
|
if (/\bgrep\b|\brg\b/.test(command)) {
|
|
51
62
|
const quoted = command.match(/["']([^"']{2,60})["']/);
|
|
52
|
-
return { verb: 'Looked for', object: quoted?.[1] ?? 'a phrase' };
|
|
63
|
+
return { verb: 'Looked for', object: quoted?.[1] ?? 'a phrase', ...SEARCH };
|
|
53
64
|
}
|
|
54
65
|
if (/\bls\b/.test(command))
|
|
55
66
|
return { verb: 'Looked through the library', object: '' };
|
|
56
67
|
if (/\bfind\b/.test(command))
|
|
57
68
|
return { verb: 'Looked for documents', object: '' };
|
|
58
69
|
if (/\bcat\b|\bhead\b|\bsed\b/.test(command)) {
|
|
59
|
-
return { verb: 'Read', object: documentName(lastPath(command)) };
|
|
70
|
+
return { verb: 'Read', object: documentName(lastPath(command)), ...READ };
|
|
60
71
|
}
|
|
61
72
|
if (/\bwc\b/.test(command))
|
|
62
73
|
return { verb: 'Checked', object: '' };
|
|
@@ -64,6 +75,10 @@ export function describe(block) {
|
|
|
64
75
|
}
|
|
65
76
|
return { verb: 'Looked into it', object: '' };
|
|
66
77
|
}
|
|
78
|
+
/** The host's words for a call, or the package's where it has none. */
|
|
79
|
+
export function wordsFor(block, describeTool) {
|
|
80
|
+
return describeTool?.(block) ?? describe(block);
|
|
81
|
+
}
|
|
67
82
|
function parseInput(block) {
|
|
68
83
|
try {
|
|
69
84
|
return JSON.parse(block.rawInput);
|
|
@@ -142,21 +157,14 @@ export class Transcript {
|
|
|
142
157
|
/** Follow-ups the librarian named, from the `suggestions` frame after it. */
|
|
143
158
|
suggestions = $state([]);
|
|
144
159
|
other = $state([]);
|
|
145
|
-
|
|
146
|
-
* maps the live index onto a position in the flat list. Cleared whenever a
|
|
147
|
-
* message starts, which is what stops turn two overwriting turn one. */
|
|
148
|
-
// Deliberately a plain Map, not a SvelteMap: nothing renders it, it is
|
|
149
|
-
// written on every content-block delta while an answer streams, and giving
|
|
150
|
-
// each entry its own reactive signal would buy a re-render nobody reads.
|
|
151
|
-
// eslint-disable-next-line svelte/prefer-svelte-reactivity
|
|
152
|
-
#open = new Map();
|
|
160
|
+
#fold = foldState();
|
|
153
161
|
reset() {
|
|
154
162
|
this.blocks = [];
|
|
155
163
|
this.outcome = null;
|
|
156
164
|
this.citations = [];
|
|
157
165
|
this.suggestions = [];
|
|
158
166
|
this.other = [];
|
|
159
|
-
this.#
|
|
167
|
+
this.#fold = foldState();
|
|
160
168
|
}
|
|
161
169
|
apply(event) {
|
|
162
170
|
this.version += 1;
|
|
@@ -165,95 +173,150 @@ export class Transcript {
|
|
|
165
173
|
this.model = event.model ?? null;
|
|
166
174
|
return;
|
|
167
175
|
}
|
|
168
|
-
if (
|
|
169
|
-
this.
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
176
|
+
if (!fold(this, this.#fold, event))
|
|
177
|
+
this.other.push(event);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
// Deliberately a plain Map, not a SvelteMap: nothing renders it, it is
|
|
181
|
+
// written on every content-block delta while an answer streams, and giving
|
|
182
|
+
// each entry its own reactive signal would buy a re-render nobody reads.
|
|
183
|
+
// eslint-disable-next-line svelte/prefer-svelte-reactivity
|
|
184
|
+
export const foldState = () => ({ open: new Map(), partial: false });
|
|
185
|
+
/**
|
|
186
|
+
* Fold one event into one turn. False for an event this does not know, which
|
|
187
|
+
* the caller keeps rather than drops.
|
|
188
|
+
*
|
|
189
|
+
* It folds, it does not translate: every block is a real content block from
|
|
190
|
+
* the stream, assembled from its deltas when the stream carries them and
|
|
191
|
+
* taken whole when it does not.
|
|
192
|
+
*/
|
|
193
|
+
export function fold(into, state, event) {
|
|
194
|
+
if (event.type === 'result') {
|
|
195
|
+
into.outcome = {
|
|
196
|
+
turns: event.num_turns,
|
|
197
|
+
costUsd: event.total_cost_usd,
|
|
198
|
+
durationMs: event.duration_ms,
|
|
199
|
+
isError: event.is_error,
|
|
200
|
+
structuredOutput: event.structured_output ?? undefined
|
|
201
|
+
};
|
|
202
|
+
return true;
|
|
203
|
+
}
|
|
204
|
+
// Emitted after the final assistant text, so it lands on a turn that is
|
|
205
|
+
// otherwise complete. Both frames put their payload on `items`, so each
|
|
206
|
+
// keeps only what its own shape admits: a `citations` frame carrying
|
|
207
|
+
// strings, or a `suggestions` frame carrying objects, is the library
|
|
208
|
+
// having changed under us, and rendering it would be worse than
|
|
209
|
+
// rendering nothing.
|
|
210
|
+
if (event.type === 'citations') {
|
|
211
|
+
into.citations = (event.items ?? []).filter((item) => typeof item === 'object' && item !== null);
|
|
212
|
+
return true;
|
|
213
|
+
}
|
|
214
|
+
// Last of the two, and only when the librarian named any.
|
|
215
|
+
if (event.type === 'suggestions') {
|
|
216
|
+
into.suggestions = (event.items ?? []).filter((item) => typeof item === 'string' && item.trim().length > 0);
|
|
217
|
+
return true;
|
|
218
|
+
}
|
|
219
|
+
// No persona and no sentence at this layer: `client.ts` raises this for a
|
|
220
|
+
// stream that would not open or died half-way, and the turn renders the
|
|
221
|
+
// unreachable line in whichever persona's voice the host named.
|
|
222
|
+
if (event.type === 'library_error') {
|
|
223
|
+
into.outcome = { isError: true, unreachable: true, error: event.error };
|
|
224
|
+
return true;
|
|
225
|
+
}
|
|
226
|
+
// A tool RESULT arrives as a user message carrying tool_result blocks, and
|
|
227
|
+
// names its call by id: two calls made at once answer in either order, and
|
|
228
|
+
// pinning a result on the latest open call put the first page's text under
|
|
229
|
+
// the second page's row.
|
|
230
|
+
if (event.type === 'user') {
|
|
231
|
+
for (const block of contentOf(event)) {
|
|
232
|
+
if (block.type !== 'tool_result')
|
|
233
|
+
continue;
|
|
234
|
+
const open = into.blocks.filter((b) => b.kind === 'tool' && !b.result);
|
|
235
|
+
const target = open.find((b) => b.id !== undefined && b.id === block.tool_use_id) ?? open.at(-1);
|
|
236
|
+
if (target) {
|
|
237
|
+
target.result = renderResult(block.content);
|
|
238
|
+
target.isError = block.is_error === true;
|
|
209
239
|
}
|
|
210
|
-
return;
|
|
211
|
-
}
|
|
212
|
-
if (event.type !== 'stream_event' || !event.event) {
|
|
213
|
-
if (event.type !== 'assistant')
|
|
214
|
-
this.other.push(event);
|
|
215
|
-
return;
|
|
216
240
|
}
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
if (
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
if (cb.type === 'text')
|
|
229
|
-
this.blocks.push({ kind: 'text', index: position, text: '' });
|
|
230
|
-
else if (cb.type === 'thinking')
|
|
231
|
-
this.blocks.push({ kind: 'thinking', index: position, text: '' });
|
|
232
|
-
else if (cb.type === 'tool_use')
|
|
233
|
-
this.blocks.push({
|
|
234
|
-
kind: 'tool',
|
|
235
|
-
index: position,
|
|
236
|
-
name: cb.name ?? 'tool',
|
|
237
|
-
rawInput: ''
|
|
238
|
-
});
|
|
239
|
-
return;
|
|
240
|
-
}
|
|
241
|
-
if (inner.type === 'content_block_delta' && inner.index !== undefined) {
|
|
242
|
-
const position = this.#open.get(inner.index);
|
|
243
|
-
if (position === undefined)
|
|
244
|
-
return;
|
|
245
|
-
const block = this.blocks[position];
|
|
246
|
-
const delta = inner.delta;
|
|
247
|
-
if (!block || !delta)
|
|
248
|
-
return;
|
|
249
|
-
if (delta.type === 'text_delta' && block.kind === 'text')
|
|
250
|
-
block.text += delta.text ?? '';
|
|
251
|
-
else if (delta.type === 'thinking_delta' && block.kind === 'thinking')
|
|
252
|
-
block.text += delta.thinking ?? '';
|
|
253
|
-
else if (delta.type === 'input_json_delta' && block.kind === 'tool')
|
|
254
|
-
block.rawInput += delta.partial_json ?? '';
|
|
241
|
+
return true;
|
|
242
|
+
}
|
|
243
|
+
// One whole content block per event, several events per message — measured
|
|
244
|
+
// on Claude Code 2.1.283, where every one carries `stop_reason: null`.
|
|
245
|
+
if (event.type === 'assistant') {
|
|
246
|
+
if (state.partial)
|
|
247
|
+
return true;
|
|
248
|
+
for (const block of contentOf(event)) {
|
|
249
|
+
const whole = wholeBlock(block, into.blocks.length);
|
|
250
|
+
if (whole)
|
|
251
|
+
into.blocks.push(whole);
|
|
255
252
|
}
|
|
253
|
+
return true;
|
|
254
|
+
}
|
|
255
|
+
if (event.type !== 'stream_event' || !event.event)
|
|
256
|
+
return false;
|
|
257
|
+
state.partial = true;
|
|
258
|
+
const inner = event.event;
|
|
259
|
+
if (inner.type === 'message_start') {
|
|
260
|
+
state.open.clear();
|
|
261
|
+
return true;
|
|
262
|
+
}
|
|
263
|
+
if (inner.type === 'content_block_start' && inner.index !== undefined) {
|
|
264
|
+
const cb = inner.content_block;
|
|
265
|
+
if (!cb)
|
|
266
|
+
return true;
|
|
267
|
+
const position = into.blocks.length;
|
|
268
|
+
state.open.set(inner.index, position);
|
|
269
|
+
if (cb.type === 'text')
|
|
270
|
+
into.blocks.push({ kind: 'text', index: position, text: '' });
|
|
271
|
+
else if (cb.type === 'thinking')
|
|
272
|
+
into.blocks.push({ kind: 'thinking', index: position, text: '' });
|
|
273
|
+
else if (cb.type === 'tool_use')
|
|
274
|
+
into.blocks.push({
|
|
275
|
+
kind: 'tool',
|
|
276
|
+
index: position,
|
|
277
|
+
id: cb.id,
|
|
278
|
+
name: cb.name ?? 'tool',
|
|
279
|
+
rawInput: ''
|
|
280
|
+
});
|
|
281
|
+
return true;
|
|
282
|
+
}
|
|
283
|
+
if (inner.type === 'content_block_delta' && inner.index !== undefined) {
|
|
284
|
+
const position = state.open.get(inner.index);
|
|
285
|
+
if (position === undefined)
|
|
286
|
+
return true;
|
|
287
|
+
const block = into.blocks[position];
|
|
288
|
+
const delta = inner.delta;
|
|
289
|
+
if (!block || !delta)
|
|
290
|
+
return true;
|
|
291
|
+
if (delta.type === 'text_delta' && block.kind === 'text')
|
|
292
|
+
block.text += delta.text ?? '';
|
|
293
|
+
else if (delta.type === 'thinking_delta' && block.kind === 'thinking')
|
|
294
|
+
block.text += delta.thinking ?? '';
|
|
295
|
+
else if (delta.type === 'input_json_delta' && block.kind === 'tool')
|
|
296
|
+
block.rawInput += delta.partial_json ?? '';
|
|
256
297
|
}
|
|
298
|
+
return true;
|
|
299
|
+
}
|
|
300
|
+
/** A message's content blocks; a user frame may carry a bare string instead. */
|
|
301
|
+
export function contentOf(event) {
|
|
302
|
+
const content = event.message?.content;
|
|
303
|
+
return Array.isArray(content) ? content : [];
|
|
304
|
+
}
|
|
305
|
+
function wholeBlock(block, index) {
|
|
306
|
+
if (block.type === 'text')
|
|
307
|
+
return { kind: 'text', index, text: str(block.text) };
|
|
308
|
+
if (block.type === 'thinking')
|
|
309
|
+
return { kind: 'thinking', index, text: str(block.thinking) };
|
|
310
|
+
if (block.type === 'tool_use') {
|
|
311
|
+
return {
|
|
312
|
+
kind: 'tool',
|
|
313
|
+
index,
|
|
314
|
+
id: str(block.id) || undefined,
|
|
315
|
+
name: str(block.name) || 'tool',
|
|
316
|
+
rawInput: JSON.stringify(block.input ?? {})
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
return null;
|
|
257
320
|
}
|
|
258
321
|
function renderResult(content) {
|
|
259
322
|
if (typeof content === 'string')
|
|
@@ -298,7 +361,7 @@ function renderResult(content) {
|
|
|
298
361
|
* the position of a block is the only signal the stream gives, and a rule read
|
|
299
362
|
* off the prose itself would be unexplainable the first time it misfired.
|
|
300
363
|
*/
|
|
301
|
-
export function segment(blocks) {
|
|
364
|
+
export function segment(blocks, describeTool) {
|
|
302
365
|
const out = [];
|
|
303
366
|
let current = null;
|
|
304
367
|
let lastTool = -1;
|
|
@@ -318,67 +381,73 @@ export function segment(blocks) {
|
|
|
318
381
|
if (block.kind !== 'tool' && !block.text.trim())
|
|
319
382
|
continue;
|
|
320
383
|
if (!current) {
|
|
321
|
-
current = {
|
|
322
|
-
kind: 'activity',
|
|
323
|
-
index: block.index,
|
|
324
|
-
steps: [],
|
|
325
|
-
collections: [],
|
|
326
|
-
documents: 0,
|
|
327
|
-
searches: 0
|
|
328
|
-
};
|
|
384
|
+
current = { kind: 'activity', index: block.index, steps: [], tallies: [] };
|
|
329
385
|
out.push(current);
|
|
330
386
|
}
|
|
387
|
+
const words = block.kind === 'tool' ? wordsFor(block, describeTool) : undefined;
|
|
331
388
|
const last = current.steps.at(-1);
|
|
332
|
-
if (last && sameStep(last
|
|
389
|
+
if (last && sameStep(last, block, words)) {
|
|
333
390
|
last.repeats += 1;
|
|
334
391
|
}
|
|
335
392
|
else {
|
|
336
|
-
current.steps.push({ block, repeats: 1 });
|
|
393
|
+
current.steps.push({ block, repeats: 1, words });
|
|
337
394
|
}
|
|
338
|
-
if (
|
|
339
|
-
|
|
395
|
+
if (words?.tally)
|
|
396
|
+
count(current, words.tally);
|
|
340
397
|
}
|
|
341
398
|
return out;
|
|
342
399
|
}
|
|
343
|
-
function sameStep(
|
|
344
|
-
if (
|
|
400
|
+
function sameStep(last, block, words) {
|
|
401
|
+
if (last.block.kind !== block.kind)
|
|
345
402
|
return false;
|
|
346
|
-
if (
|
|
403
|
+
if (block.kind === 'thinking')
|
|
347
404
|
return true;
|
|
348
405
|
// Two narration sentences are two things Milton said; collapsing them to
|
|
349
406
|
// one row with a count would lose the second one entirely.
|
|
350
|
-
if (
|
|
407
|
+
if (block.kind === 'text')
|
|
351
408
|
return false;
|
|
352
|
-
|
|
353
|
-
const right = describe(b);
|
|
354
|
-
return left.verb === right.verb && left.object === right.object;
|
|
355
|
-
}
|
|
356
|
-
function tally(group, block) {
|
|
357
|
-
const { verb } = describe(block);
|
|
358
|
-
if (verb === 'Looked for')
|
|
359
|
-
group.searches += 1;
|
|
360
|
-
if (verb === 'Read')
|
|
361
|
-
group.documents += 1;
|
|
362
|
-
const collection = collectionOf(block);
|
|
363
|
-
if (collection && !group.collections.includes(collection))
|
|
364
|
-
group.collections.push(collection);
|
|
409
|
+
return last.words?.verb === words?.verb && last.words?.object === words?.object;
|
|
365
410
|
}
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
411
|
+
function count(group, [one, many]) {
|
|
412
|
+
const seen = group.tallies.find((t) => t.one === one && t.many === many);
|
|
413
|
+
if (seen)
|
|
414
|
+
seen.count += 1;
|
|
415
|
+
else
|
|
416
|
+
group.tallies.push({ one, many, count: 1 });
|
|
371
417
|
}
|
|
372
418
|
/** One line describing a whole investigation, for the collapsed state.
|
|
373
419
|
*
|
|
374
|
-
* Counts of what
|
|
375
|
-
* organises what
|
|
420
|
+
* Counts of what the persona did are fine ("1 search · 2 documents read"); how
|
|
421
|
+
* it organises what it knows is not — no collection count, no shelf, no corpus.
|
|
376
422
|
*/
|
|
377
423
|
export function summariseActivity(group) {
|
|
378
|
-
const parts =
|
|
379
|
-
if (group.searches)
|
|
380
|
-
parts.push(`${group.searches} search${group.searches === 1 ? '' : 'es'}`);
|
|
381
|
-
if (group.documents)
|
|
382
|
-
parts.push(`${group.documents} document${group.documents === 1 ? '' : 's'} read`);
|
|
424
|
+
const parts = group.tallies.map((t) => `${t.count} ${t.count === 1 ? t.one : t.many}`);
|
|
383
425
|
return parts.length ? parts.join(' · ') : 'Looked into it';
|
|
384
426
|
}
|
|
427
|
+
/**
|
|
428
|
+
* What a turn's calls read that a reader can open, numbered in the order it
|
|
429
|
+
* was first read: the host's `ToolWords.source`, as the sources the answer
|
|
430
|
+
* lists under itself. A call still running, or one that failed, read nothing.
|
|
431
|
+
*
|
|
432
|
+
* `derived`, because nothing checked any of it against a publisher: a trust
|
|
433
|
+
* mark on a page the persona just looked at would be a claim about a record
|
|
434
|
+
* nobody read.
|
|
435
|
+
*/
|
|
436
|
+
export function readFrom(blocks, describeTool) {
|
|
437
|
+
const out = [];
|
|
438
|
+
for (const block of blocks) {
|
|
439
|
+
if (block.kind !== 'tool' || block.result === undefined || block.isError)
|
|
440
|
+
continue;
|
|
441
|
+
const source = wordsFor(block, describeTool).source;
|
|
442
|
+
if (!source || out.some((c) => c.document_id === source.id))
|
|
443
|
+
continue;
|
|
444
|
+
out.push({
|
|
445
|
+
n: out.length + 1,
|
|
446
|
+
document_id: source.id,
|
|
447
|
+
title: source.title,
|
|
448
|
+
section: source.section,
|
|
449
|
+
derived: true
|
|
450
|
+
});
|
|
451
|
+
}
|
|
452
|
+
return out;
|
|
453
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@poodle64/librarian",
|
|
3
|
-
"version": "2026.9.
|
|
3
|
+
"version": "2026.9.20",
|
|
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",
|
|
@@ -26,6 +26,10 @@
|
|
|
26
26
|
"types": "./dist/transcript.svelte.d.ts",
|
|
27
27
|
"svelte": "./dist/transcript.svelte.js"
|
|
28
28
|
},
|
|
29
|
+
"./session": {
|
|
30
|
+
"types": "./dist/session.svelte.d.ts",
|
|
31
|
+
"svelte": "./dist/session.svelte.js"
|
|
32
|
+
},
|
|
29
33
|
"./history": {
|
|
30
34
|
"types": "./dist/history.svelte.d.ts",
|
|
31
35
|
"svelte": "./dist/history.svelte.js"
|
|
@@ -54,7 +58,6 @@
|
|
|
54
58
|
"peerDependencies": {
|
|
55
59
|
"svelte": "^5.54.0",
|
|
56
60
|
"@poodle64/design-tokens": "^2026.8.1",
|
|
57
|
-
"@poodle64/ui": "^2026.9.2",
|
|
58
61
|
"@lucide/svelte": "^1.7.0",
|
|
59
62
|
"marked": "^18.0.11",
|
|
60
63
|
"isomorphic-dompurify": "^3.23.0",
|
|
@@ -63,7 +66,6 @@
|
|
|
63
66
|
"devDependencies": {
|
|
64
67
|
"@lucide/svelte": "^1.7.0",
|
|
65
68
|
"@poodle64/design-tokens": "workspace:*",
|
|
66
|
-
"@poodle64/ui": "workspace:*",
|
|
67
69
|
"@sveltejs/kit": "^2.63.0",
|
|
68
70
|
"@sveltejs/package": "^2.5.8",
|
|
69
71
|
"@sveltejs/vite-plugin-svelte": "^7.1.2",
|