@poodle64/librarian 2026.10.2 → 2026.10.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/README.md +10 -4
  2. package/dist/chat.svelte.d.ts +40 -0
  3. package/dist/chat.svelte.js +57 -3
  4. package/dist/client.d.ts +3 -0
  5. package/dist/client.js +4 -1
  6. package/dist/components/activity-group/activity-group.svelte +21 -108
  7. package/dist/components/activity-group/activity-group.svelte.d.ts +3 -1
  8. package/dist/components/agent-transcript/agent-transcript.svelte +164 -4
  9. package/dist/components/agent-transcript/agent-transcript.svelte.d.ts +18 -0
  10. package/dist/components/artefact-pane/artefact-pane.svelte +162 -18
  11. package/dist/components/artefact-pane/artefact-pane.svelte.d.ts +10 -1
  12. package/dist/components/conversation/conversation.svelte +83 -6
  13. package/dist/components/conversation/conversation.svelte.d.ts +15 -0
  14. package/dist/components/fair-use-notice/fair-use-notice.svelte +1 -1
  15. package/dist/components/markdown/markdown.d.ts +11 -0
  16. package/dist/components/markdown/markdown.js +62 -2
  17. package/dist/components/markdown/markdown.svelte +24 -3
  18. package/dist/components/markdown/markdown.svelte.d.ts +4 -0
  19. package/dist/components/scope-statement/scope-statement.svelte +17 -20
  20. package/dist/components/thinking-row/thinking-row.svelte +1 -0
  21. package/dist/components/thinking-row/thinking-row.svelte.d.ts +1 -0
  22. package/dist/components/tool-row/tool-row.svelte +40 -20
  23. package/dist/components/tool-row/tool-row.svelte.d.ts +2 -3
  24. package/dist/components/web-list/index.d.ts +2 -0
  25. package/dist/components/web-list/index.js +2 -0
  26. package/dist/components/web-list/web-list.svelte +84 -0
  27. package/dist/components/web-list/web-list.svelte.d.ts +9 -0
  28. package/dist/components/written-card/index.d.ts +2 -0
  29. package/dist/components/written-card/index.js +2 -0
  30. package/dist/components/written-card/written-card.svelte +130 -0
  31. package/dist/components/written-card/written-card.svelte.d.ts +15 -0
  32. package/dist/copy.d.ts +19 -0
  33. package/dist/copy.js +13 -0
  34. package/dist/transcript.svelte.d.ts +15 -34
  35. package/dist/transcript.svelte.js +52 -55
  36. package/dist/web-sources.d.ts +18 -0
  37. package/dist/web-sources.js +58 -0
  38. package/dist/written.d.ts +46 -0
  39. package/dist/written.js +41 -0
  40. package/package.json +5 -1
@@ -6,6 +6,7 @@
6
6
  * deltas that belong to each. An event type this does not know about is kept
7
7
  * verbatim under `other`, so nothing is silently dropped.
8
8
  */
9
+ import { hostOf } from './web-sources';
9
10
  /**
10
11
  * The question as the READER asked it.
11
12
  *
@@ -39,20 +40,35 @@ export function describe(block) {
39
40
  const lines = block.result ? block.result.split('\n').length : 0;
40
41
  return lines ? { ...said, detail: lines === 1 ? '1 line' : `${lines} lines` } : said;
41
42
  }
42
- const READ = {
43
- tally: ['document read', 'documents read'],
44
- repeat: ['page', 'pages']
45
- };
46
- const SEARCH = { tally: ['search', 'searches'] };
47
43
  function describeCall(block) {
48
44
  const input = parseInput(block);
49
45
  const command = typeof input.command === 'string' ? input.command : '';
50
46
  if (block.name === 'Read') {
51
47
  const path = str(input.file_path ?? input.path);
52
- return { verb: 'Read', object: documentName(path), ...READ };
48
+ return { verb: 'Read', object: documentName(path) };
49
+ }
50
+ if (block.name === 'Write')
51
+ return { verb: 'Wrote', object: baseName(str(input.file_path)) };
52
+ if (block.name === 'Edit')
53
+ return { verb: 'Edited', object: baseName(str(input.file_path)) };
54
+ if (block.name === 'mcp__library__search') {
55
+ return { verb: 'Searched the library for', object: str(input.query) };
56
+ }
57
+ if (block.name === 'mcp__library__document_content' || block.name === 'mcp__library__document_detail') {
58
+ return { verb: 'Read a document', object: '' };
59
+ }
60
+ if (block.name === 'mcp__supacrawl__supacrawl_search') {
61
+ return { verb: 'Searched the web for', object: str(input.query) };
62
+ }
63
+ if (block.name === 'mcp__supacrawl__supacrawl_scrape') {
64
+ return { verb: 'Read', object: hostOf(str(input.url)) };
65
+ }
66
+ if (block.name.startsWith('mcp__') && block.name.endsWith('__ocr')) {
67
+ const file = str(input.file_path ?? input.path ?? input.file ?? input.filename ?? input.url);
68
+ return { verb: 'Read the scanned pages of', object: baseName(file) };
53
69
  }
54
70
  if (block.name === 'Grep') {
55
- return { verb: 'Looked for', object: str(input.pattern), ...SEARCH };
71
+ return { verb: 'Looked for', object: str(input.pattern) };
56
72
  }
57
73
  if (block.name === 'Glob') {
58
74
  return { verb: 'Looked for documents', object: '' };
@@ -60,14 +76,14 @@ function describeCall(block) {
60
76
  if (block.name === 'Bash') {
61
77
  if (/\bgrep\b|\brg\b/.test(command)) {
62
78
  const quoted = command.match(/["']([^"']{2,60})["']/);
63
- return { verb: 'Looked for', object: quoted?.[1] ?? 'a phrase', ...SEARCH };
79
+ return { verb: 'Looked for', object: quoted?.[1] ?? 'a phrase' };
64
80
  }
65
81
  if (/\bls\b/.test(command))
66
82
  return { verb: 'Looked through the library', object: '' };
67
83
  if (/\bfind\b/.test(command))
68
84
  return { verb: 'Looked for documents', object: '' };
69
85
  if (/\bcat\b|\bhead\b|\bsed\b/.test(command)) {
70
- return { verb: 'Read', object: documentName(lastPath(command)), ...READ };
86
+ return { verb: 'Read', object: documentName(lastPath(command)) };
71
87
  }
72
88
  if (/\bwc\b/.test(command))
73
89
  return { verb: 'Checked', object: '' };
@@ -88,6 +104,7 @@ function parseInput(block) {
88
104
  }
89
105
  }
90
106
  const str = (value) => (typeof value === 'string' ? value : '');
107
+ const baseName = (path) => path.split('/').filter(Boolean).at(-1) ?? '';
91
108
  /** A staged path ends with the document's title slug; that is its name. */
92
109
  function documentName(path) {
93
110
  if (!path)
@@ -154,12 +171,15 @@ export class Transcript {
154
171
  outcome = $state(null);
155
172
  /** Sources for the answer, from the app's `citations` frame. */
156
173
  citations = $state([]);
174
+ /** The document versions the persona wrote, in order. */
175
+ written = $state([]);
157
176
  other = $state([]);
158
177
  #fold = foldState();
159
178
  reset() {
160
179
  this.blocks = [];
161
180
  this.outcome = null;
162
181
  this.citations = [];
182
+ this.written = [];
163
183
  this.other = [];
164
184
  this.#fold = foldState();
165
185
  }
@@ -204,6 +224,22 @@ export function fold(into, state, event) {
204
224
  into.citations = (event.items ?? []).filter((item) => typeof item === 'object' && item !== null);
205
225
  return true;
206
226
  }
227
+ // The app emits this right after the tool result of a successful Write or
228
+ // Edit: one version of one document the persona wrote.
229
+ if (event.type === 'artefact') {
230
+ if (typeof event.name === 'string' && typeof event.version === 'number') {
231
+ // Assigned, then pushed through the state's own proxy: `??=` hands back the raw array, and that push redraws nothing.
232
+ into.written ??= [];
233
+ into.written.push({
234
+ name: event.name,
235
+ version: event.version,
236
+ media_type: typeof event.media_type === 'string' ? event.media_type : '',
237
+ bytes: typeof event.bytes === 'number' ? event.bytes : undefined,
238
+ formats: Array.isArray(event.formats) ? event.formats.map(String) : []
239
+ });
240
+ }
241
+ return true;
242
+ }
207
243
  // No persona and no sentence at this layer: `client.ts` raises this for a
208
244
  // stream that would not open or died half-way, and the turn renders the
209
245
  // unreachable line in whichever persona's voice the host named.
@@ -321,15 +357,11 @@ function renderResult(content) {
321
357
  /**
322
358
  * Fold a turn's flat block list into what a reader should actually see.
323
359
  *
324
- * Three problems this solves, all reported off a real transcript:
360
+ * Two problems this solves, reported off a real transcript:
325
361
  *
326
- * 1. Fifteen tool rows stood between the question and the first word of the
327
- * answer, so the answer had to be scrolled to. Contiguous activity becomes
328
- * ONE group the caller can collapse.
329
- * 2. "Reading pspf guidelines 2026" appeared five times in a row — five pages
330
- * of one document, which is one act of reading to a human. Consecutive
331
- * steps with the same label collapse to one row carrying a count.
332
- * 3. The persona's between-tool narration was rendering unfolded, as answer prose.
362
+ * 1. Contiguous activity becomes ONE group, so the answer's prose is never
363
+ * split by each step; every step stays its own collapsed row in it.
364
+ * 2. The persona's between-tool narration was rendering unfolded, as answer prose.
333
365
  * The caller stream carries no `thinking` blocks at all — measured on
334
366
  * production 11/09/2026 — so "Let me also check whether…" arrives as an
335
367
  * ordinary `text` block, indistinguishable from the answer except by
@@ -337,7 +369,7 @@ function renderResult(content) {
337
369
  * narration; only the run of text after the LAST tool call is the answer.
338
370
  * Narration folds into the activity group as a thinking-shaped step.
339
371
  *
340
- * That third rule is provisional while a turn streams, and deliberately so: a
372
+ * That second rule is provisional while a turn streams, and deliberately so: a
341
373
  * text block that is currently last IS the answer as far as anything can know,
342
374
  * and renders as prose. The tool call that arrives after it re-homes it into
343
375
  * the group. Nothing here holds state to make that work — `segment` is pure
@@ -371,19 +403,11 @@ export function segment(blocks, describeTool) {
371
403
  if (block.kind !== 'tool' && !block.text.trim())
372
404
  continue;
373
405
  if (!current) {
374
- current = { kind: 'activity', index: block.index, steps: [], tallies: [] };
406
+ current = { kind: 'activity', index: block.index, steps: [] };
375
407
  out.push(current);
376
408
  }
377
409
  const words = block.kind === 'tool' ? wordsFor(block, describeTool) : undefined;
378
- const last = current.steps.at(-1);
379
- if (last && sameStep(last, block, words)) {
380
- last.repeats += 1;
381
- }
382
- else {
383
- current.steps.push({ block, repeats: 1, words });
384
- }
385
- if (words?.tally)
386
- count(current, words.tally);
410
+ current.steps.push({ block, words });
387
411
  }
388
412
  return out;
389
413
  }
@@ -399,33 +423,6 @@ const HANDS_IN = 'StructuredOutput';
399
423
  function isStep(block) {
400
424
  return block.kind === 'tool' && block.name !== HANDS_IN;
401
425
  }
402
- function sameStep(last, block, words) {
403
- if (last.block.kind !== block.kind)
404
- return false;
405
- if (block.kind === 'thinking')
406
- return true;
407
- // Two narration sentences are two things the persona said; collapsing them to
408
- // one row with a count would lose the second one entirely.
409
- if (block.kind === 'text')
410
- return false;
411
- return last.words?.verb === words?.verb && last.words?.object === words?.object;
412
- }
413
- function count(group, [one, many]) {
414
- const seen = group.tallies.find((t) => t.one === one && t.many === many);
415
- if (seen)
416
- seen.count += 1;
417
- else
418
- group.tallies.push({ one, many, count: 1 });
419
- }
420
- /** One line describing a whole investigation, for the collapsed state.
421
- *
422
- * Counts of what the persona did are fine ("1 search · 2 documents read"); how
423
- * it organises what it knows is not — no collection count, no corpus.
424
- */
425
- export function summariseActivity(group) {
426
- const parts = group.tallies.map((t) => `${t.count} ${t.count === 1 ? t.one : t.many}`);
427
- return parts.length ? parts.join(' · ') : 'Looked into it';
428
- }
429
426
  /**
430
427
  * What a settled answer took, as one quiet line: `56.3s`, then what it cost
431
428
  * — only when the host can price it (`formatCost`) and the run actually
@@ -0,0 +1,18 @@
1
+ /**
2
+ * The web pages an answer drew on, listed apart from the library's numbered
3
+ * sources: the pages the turn's supacrawl calls read, and the Markdown links
4
+ * in the answer itself. A search's results are what it found, not what the
5
+ * answer drew on, so they stay in the search's own row. Never numbered,
6
+ * because nothing here is a passage the persona was handed to cite.
7
+ */
8
+ import type { Block } from './transcript.svelte';
9
+ export interface WebSource {
10
+ url: string;
11
+ /** The page's title, or its host where nothing gave one. */
12
+ title: string;
13
+ host: string;
14
+ }
15
+ /** A URL's host without a leading `www.`; the text as given where it is no URL. */
16
+ export declare function hostOf(url: string): string;
17
+ /** The pages, once each, in the order the turn met them. */
18
+ export declare function webSources(blocks: Block[], answer: string): WebSource[];
@@ -0,0 +1,58 @@
1
+ /**
2
+ * The web pages an answer drew on, listed apart from the library's numbered
3
+ * sources: the pages the turn's supacrawl calls read, and the Markdown links
4
+ * in the answer itself. A search's results are what it found, not what the
5
+ * answer drew on, so they stay in the search's own row. Never numbered,
6
+ * because nothing here is a passage the persona was handed to cite.
7
+ */
8
+ /** A URL's host without a leading `www.`; the text as given where it is no URL. */
9
+ export function hostOf(url) {
10
+ try {
11
+ return new URL(url).hostname.replace(/^www\./, '');
12
+ }
13
+ catch {
14
+ return url;
15
+ }
16
+ }
17
+ const LINK = /\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g;
18
+ function isWeb(url) {
19
+ return typeof url === 'string' && /^https?:\/\//i.test(url);
20
+ }
21
+ function parse(text) {
22
+ try {
23
+ return text ? JSON.parse(text) : undefined;
24
+ }
25
+ catch {
26
+ return undefined;
27
+ }
28
+ }
29
+ function fromTool(block) {
30
+ if (!block.name.startsWith('mcp__supacrawl__') || !block.name.endsWith('_scrape') || block.isError)
31
+ return [];
32
+ const input = parse(block.rawInput);
33
+ if (!isWeb(input?.url))
34
+ return [];
35
+ const page = parse(block.result);
36
+ const title = [page?.title, page?.metadata?.title].find((t) => typeof t === 'string');
37
+ return [{ url: input.url, title: title }];
38
+ }
39
+ /** The pages, once each, in the order the turn met them. */
40
+ export function webSources(blocks, answer) {
41
+ const seen = new Map();
42
+ const add = (url, title) => {
43
+ const key = url.replace(/#.*$/, '').replace(/\/$/, '');
44
+ if (seen.has(key))
45
+ return;
46
+ const host = hostOf(url);
47
+ if (host === url)
48
+ return;
49
+ seen.set(key, { url, title: title?.trim() || host, host });
50
+ };
51
+ for (const block of blocks)
52
+ if (block.kind === 'tool')
53
+ for (const p of fromTool(block))
54
+ add(p.url, p.title);
55
+ for (const match of answer.matchAll(LINK))
56
+ add(match[2], match[1]);
57
+ return [...seen.values()];
58
+ }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * The documents a persona writes while it answers.
3
+ *
4
+ * The app emits an `artefact` frame after each successful Write or Edit, and a
5
+ * reopened conversation lists the same versions on its turns. The page shows
6
+ * each version in the reading column and links it for download; the bytes
7
+ * come from the host's own route, never from this package.
8
+ */
9
+ import type { ChatCopy } from './copy';
10
+ import type { Turn } from './transcript.svelte';
11
+ /** One version of one document, as the wire names it. */
12
+ export interface Written {
13
+ /** A path relative to the conversation's folder; may contain "/". */
14
+ name: string;
15
+ /** Counts per name from 1 across the whole conversation. */
16
+ version: number;
17
+ /** `text/markdown` or `text/html`. */
18
+ media_type: string;
19
+ bytes?: number;
20
+ /** What the app can render it as beyond the source (`docx`, `pdf`); empty
21
+ * where it has no renderer. */
22
+ formats: string[];
23
+ }
24
+ /** The source of one version, to show it in the pane. */
25
+ export type LoadWritten = (name: string, version: number) => Promise<{
26
+ text: string;
27
+ mediaType: string;
28
+ }>;
29
+ /** Where one version downloads as `format` (`source` or one of its `formats`). */
30
+ export type WrittenUrl = (name: string, version: number, format: string) => string;
31
+ export declare const writtenKey: (written: Written) => string;
32
+ export declare const isHtml: (mediaType: string) => boolean;
33
+ /** Every version of `name` across the conversation, oldest first. */
34
+ export declare function writtenVersions(turns: Turn[], name: string): Written[];
35
+ /** What a version downloads as: its source, then each format the app renders. */
36
+ export declare function downloads(written: Written, words: ChatCopy): Array<{
37
+ format: string;
38
+ label: string;
39
+ }>;
40
+ /** An empty sandbox stops scripts, not fetches: a written page's <img> or CSS url() would be a beacon without this. */
41
+ export declare const FRAME_CSP = "<meta http-equiv=\"Content-Security-Policy\" content=\"default-src 'none'; style-src 'unsafe-inline'; img-src data:; font-src data:\">";
42
+ /**
43
+ * A written HTML page as its sandboxed frame shows it: under `FRAME_CSP`, and
44
+ * without a refresh, which navigates the frame itself, a fetch no policy stops.
45
+ */
46
+ export declare function framed(html: string): string;
@@ -0,0 +1,41 @@
1
+ /**
2
+ * The documents a persona writes while it answers.
3
+ *
4
+ * The app emits an `artefact` frame after each successful Write or Edit, and a
5
+ * reopened conversation lists the same versions on its turns. The page shows
6
+ * each version in the reading column and links it for download; the bytes
7
+ * come from the host's own route, never from this package.
8
+ */
9
+ export const writtenKey = (written) => `${written.name}@${written.version}`;
10
+ export const isHtml = (mediaType) => mediaType.toLowerCase().startsWith('text/html');
11
+ /** Every version of `name` across the conversation, oldest first. */
12
+ export function writtenVersions(turns, name) {
13
+ const found = new Map();
14
+ for (const turn of turns)
15
+ for (const written of turn.written ?? [])
16
+ if (written.name === name)
17
+ found.set(written.version, written);
18
+ return [...found.values()].sort((a, b) => a.version - b.version);
19
+ }
20
+ /** What a version downloads as: its source, then each format the app renders. */
21
+ export function downloads(written, words) {
22
+ const label = (format) => format === 'docx' ? words.formatWord : format === 'pdf' ? words.formatPdf : format.toUpperCase();
23
+ return [
24
+ { format: 'source', label: isHtml(written.media_type) ? words.formatHtml : words.formatMarkdown },
25
+ ...written.formats.map((format) => ({ format, label: label(format) }))
26
+ ];
27
+ }
28
+ /** An empty sandbox stops scripts, not fetches: a written page's <img> or CSS url() would be a beacon without this. */
29
+ export const FRAME_CSP = '<meta http-equiv="Content-Security-Policy" content="default-src \'none\'; style-src \'unsafe-inline\'; img-src data:; font-src data:">';
30
+ /**
31
+ * A written HTML page as its sandboxed frame shows it: under `FRAME_CSP`, and
32
+ * without a refresh, which navigates the frame itself, a fetch no policy stops.
33
+ */
34
+ export function framed(html) {
35
+ const page = new DOMParser().parseFromString(html, 'text/html');
36
+ for (const meta of page.querySelectorAll('meta[http-equiv]')) {
37
+ if (meta.getAttribute('http-equiv')?.trim().toLowerCase() === 'refresh')
38
+ meta.remove();
39
+ }
40
+ return FRAME_CSP + page.documentElement.outerHTML;
41
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@poodle64/librarian",
3
- "version": "2026.10.2",
3
+ "version": "2026.10.4",
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",
@@ -43,6 +43,10 @@
43
43
  "types": "./dist/copy.d.ts",
44
44
  "svelte": "./dist/copy.js"
45
45
  },
46
+ "./written": {
47
+ "types": "./dist/written.d.ts",
48
+ "svelte": "./dist/written.js"
49
+ },
46
50
  "./attachments": {
47
51
  "types": "./dist/attachments.d.ts",
48
52
  "svelte": "./dist/attachments.js"