@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.
@@ -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
- verb: string;
65
- object: string;
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
- /** Distinct collections touched, for the one-line summary. */
112
- collections: string[];
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
- /** A study artefact rather than an ordinary answer: renders as a card once
137
- * settled, never as prose, and opens the reading column instead of a
138
- * citation. Absent (or `'answer'`) is every ordinary turn. */
139
- kind?: 'answer' | 'artefact';
140
- /** The artefact's own name. Only meaningful when `kind` is `'artefact'`. */
141
- title?: string;
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 Milton did are fine ("1 search · 2 documents read"); how he
178
- * organises what he knows is not — no collection count, no shelf, no corpus.
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
- /** Content-block index is per MESSAGE, so it repeats across turns; this
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.#open.clear();
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 (event.type === 'result') {
169
- this.outcome = {
170
- turns: event.num_turns,
171
- costUsd: event.total_cost_usd,
172
- durationMs: event.duration_ms,
173
- isError: event.is_error
174
- };
175
- return;
176
- }
177
- // Emitted after the final assistant text, so it lands on a turn that is
178
- // otherwise complete. Both frames put their payload on `items`, so each
179
- // keeps only what its own shape admits: a `citations` frame carrying
180
- // strings, or a `suggestions` frame carrying objects, is the library
181
- // having changed under us, and rendering it would be worse than
182
- // rendering nothing.
183
- if (event.type === 'citations') {
184
- this.citations = (event.items ?? []).filter((item) => typeof item === 'object' && item !== null);
185
- return;
186
- }
187
- // Last of the two, and only when the librarian named any.
188
- if (event.type === 'suggestions') {
189
- this.suggestions = (event.items ?? []).filter((item) => typeof item === 'string' && item.trim().length > 0);
190
- return;
191
- }
192
- // No persona and no sentence at this layer: `client.ts` raises this for a
193
- // stream that would not open or died half-way, and the turn renders the
194
- // unreachable line in whichever persona's voice the host named.
195
- if (event.type === 'library_error') {
196
- this.outcome = { isError: true, unreachable: true, error: event.error };
197
- return;
198
- }
199
- // A tool RESULT arrives as a user message carrying tool_result blocks.
200
- if (event.type === 'user') {
201
- for (const block of event.message?.content ?? []) {
202
- if (block.type !== 'tool_result')
203
- continue;
204
- const target = [...this.blocks].reverse().find((b) => b.kind === 'tool' && !b.result);
205
- if (target && target.kind === 'tool') {
206
- target.result = renderResult(block.content);
207
- target.isError = block.is_error === true;
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
- const inner = event.event;
218
- if (inner.type === 'message_start') {
219
- this.#open.clear();
220
- return;
221
- }
222
- if (inner.type === 'content_block_start' && inner.index !== undefined) {
223
- const cb = inner.content_block;
224
- if (!cb)
225
- return;
226
- const position = this.blocks.length;
227
- this.#open.set(inner.index, position);
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.block, block)) {
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 (block.kind === 'tool')
339
- tally(current, block);
395
+ if (words?.tally)
396
+ count(current, words.tally);
340
397
  }
341
398
  return out;
342
399
  }
343
- function sameStep(a, b) {
344
- if (a.kind !== b.kind)
400
+ function sameStep(last, block, words) {
401
+ if (last.block.kind !== block.kind)
345
402
  return false;
346
- if (a.kind === 'thinking')
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 (a.kind === 'text')
407
+ if (block.kind === 'text')
351
408
  return false;
352
- const left = describe(a);
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
- /** The first path segment under the corpus root IS the collection name. */
367
- function collectionOf(block) {
368
- const raw = block.rawInput;
369
- const match = raw.match(/(?:^|["'\s/])([a-z0-9]+(?:-[a-z0-9]+)+)\/local\//);
370
- return match?.[1] ?? '';
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 Milton did are fine ("1 search · 2 documents read"); how he
375
- * organises what he knows is not — no collection count, no shelf, no corpus.
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.18",
3
+ "version": "2026.9.19",
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",