@poodle64/librarian 2026.9.1

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 (38) hide show
  1. package/README.md +115 -0
  2. package/dist/client.d.ts +57 -0
  3. package/dist/client.js +60 -0
  4. package/dist/components/activity-group/activity-group.svelte +63 -0
  5. package/dist/components/activity-group/activity-group.svelte.d.ts +9 -0
  6. package/dist/components/activity-group/index.d.ts +2 -0
  7. package/dist/components/activity-group/index.js +2 -0
  8. package/dist/components/agent-transcript/agent-transcript.svelte +71 -0
  9. package/dist/components/agent-transcript/agent-transcript.svelte.d.ts +12 -0
  10. package/dist/components/agent-transcript/index.d.ts +2 -0
  11. package/dist/components/agent-transcript/index.js +2 -0
  12. package/dist/components/composer/composer.svelte +115 -0
  13. package/dist/components/composer/composer.svelte.d.ts +15 -0
  14. package/dist/components/composer/index.d.ts +3 -0
  15. package/dist/components/composer/index.js +2 -0
  16. package/dist/components/markdown/index.d.ts +3 -0
  17. package/dist/components/markdown/index.js +3 -0
  18. package/dist/components/markdown/markdown.d.ts +37 -0
  19. package/dist/components/markdown/markdown.js +132 -0
  20. package/dist/components/markdown/markdown.svelte +221 -0
  21. package/dist/components/markdown/markdown.svelte.d.ts +9 -0
  22. package/dist/components/thinking-row/index.d.ts +2 -0
  23. package/dist/components/thinking-row/index.js +2 -0
  24. package/dist/components/thinking-row/thinking-row.svelte +35 -0
  25. package/dist/components/thinking-row/thinking-row.svelte.d.ts +8 -0
  26. package/dist/components/tool-row/index.d.ts +2 -0
  27. package/dist/components/tool-row/index.js +2 -0
  28. package/dist/components/tool-row/tool-row.svelte +77 -0
  29. package/dist/components/tool-row/tool-row.svelte.d.ts +11 -0
  30. package/dist/components/working/index.d.ts +2 -0
  31. package/dist/components/working/index.js +2 -0
  32. package/dist/components/working/working.svelte +50 -0
  33. package/dist/components/working/working.svelte.d.ts +7 -0
  34. package/dist/history.svelte.d.ts +37 -0
  35. package/dist/history.svelte.js +59 -0
  36. package/dist/transcript.svelte.d.ts +105 -0
  37. package/dist/transcript.svelte.js +316 -0
  38. package/package.json +70 -0
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Fold Claude Code's event stream into what a reader needs to see.
3
+ *
4
+ * It folds, it does not translate: every block here is a real content block
5
+ * from the stream (text, thinking, tool_use) and the fold only assembles the
6
+ * deltas that belong to each. An event type this does not know about is kept
7
+ * verbatim under `other`, so nothing is silently dropped.
8
+ */
9
+ import type { AgentEvent } from './client';
10
+ export interface TextBlock {
11
+ kind: 'text';
12
+ index: number;
13
+ text: string;
14
+ }
15
+ export interface ThinkingBlock {
16
+ kind: 'thinking';
17
+ index: number;
18
+ text: string;
19
+ }
20
+ export interface ToolBlock {
21
+ kind: 'tool';
22
+ index: number;
23
+ name: string;
24
+ /** Accumulated `input_json_delta`. Parsed lazily — it is invalid JSON mid-stream. */
25
+ rawInput: string;
26
+ result?: string;
27
+ isError?: boolean;
28
+ }
29
+ export type Block = TextBlock | ThinkingBlock | ToolBlock;
30
+ export interface Outcome {
31
+ turns?: number;
32
+ costUsd?: number;
33
+ durationMs?: number;
34
+ isError?: boolean;
35
+ error?: string;
36
+ }
37
+ /** What the agent is DOING, in words a reader who has never seen a shell knows.
38
+ *
39
+ * The collapsed row is read by someone asking a question about documents, not
40
+ * by an engineer: `Bash ls -1 .` tells them nothing and looks like a leak from
41
+ * the machine room. The raw command is still one click away on expand, so this
42
+ * hides nothing — it just stops the transcript opening with jargon.
43
+ */
44
+ export declare function describe(block: ToolBlock): {
45
+ verb: string;
46
+ object: string;
47
+ };
48
+ /** Best-effort one-line summary of a tool call, for the collapsed row.
49
+ *
50
+ * A staged path leads with a 64-character content hash and ends with the
51
+ * document's title — so truncating from the LEFT, as a path naturally does,
52
+ * shows the reader the noise and cuts the signal. Paths are elided from the
53
+ * front instead, which is why this is not just `slice(0, n)`.
54
+ */
55
+ export declare function summarise(block: ToolBlock): string;
56
+ export declare class Transcript {
57
+ #private;
58
+ blocks: Block[];
59
+ /** Bumped on every applied event.
60
+ *
61
+ * Streaming grows an EXISTING block's `.text` in place, and Svelte's
62
+ * fine-grained tracking never sees that unless something reads it. An
63
+ * effect keyed on `blocks.length` therefore fires when a new block is
64
+ * pushed and never again — which is why a single long prose answer used to
65
+ * stream 870px past the fold with auto-scroll still "pinned" at the top.
66
+ * Consumers key on this instead. */
67
+ version: number;
68
+ sessionId: string | null;
69
+ model: string | null;
70
+ outcome: Outcome | null;
71
+ other: AgentEvent[];
72
+ reset(): void;
73
+ apply(event: AgentEvent): void;
74
+ }
75
+ export interface ActivityStep {
76
+ /** The block this step stands for; a repeated step keeps the FIRST. */
77
+ block: ToolBlock | ThinkingBlock;
78
+ /** How many identical consecutive steps collapsed into this one. */
79
+ repeats: number;
80
+ }
81
+ export interface ActivityGroup {
82
+ kind: 'activity';
83
+ index: number;
84
+ steps: ActivityStep[];
85
+ /** Distinct collections touched, for the one-line summary. */
86
+ collections: string[];
87
+ documents: number;
88
+ searches: number;
89
+ }
90
+ export type Segment = ActivityGroup | TextBlock;
91
+ /**
92
+ * Fold a turn's flat block list into what a reader should actually see.
93
+ *
94
+ * Two problems this solves, both reported off a real transcript:
95
+ *
96
+ * 1. Fifteen tool rows stood between the question and the first word of the
97
+ * answer, so the answer had to be scrolled to. Contiguous activity becomes
98
+ * ONE group the caller can collapse.
99
+ * 2. "Reading pspf guidelines 2026" appeared five times in a row — five pages
100
+ * of one document, which is one act of reading to a human. Consecutive
101
+ * steps with the same label collapse to one row carrying a count.
102
+ */
103
+ export declare function segment(blocks: Block[]): Segment[];
104
+ /** One line describing a whole investigation, for the collapsed state. */
105
+ export declare function summariseActivity(group: ActivityGroup): string;
@@ -0,0 +1,316 @@
1
+ /**
2
+ * Fold Claude Code's event stream into what a reader needs to see.
3
+ *
4
+ * It folds, it does not translate: every block here is a real content block
5
+ * from the stream (text, thinking, tool_use) and the fold only assembles the
6
+ * deltas that belong to each. An event type this does not know about is kept
7
+ * verbatim under `other`, so nothing is silently dropped.
8
+ */
9
+ /** What the agent is DOING, in words a reader who has never seen a shell knows.
10
+ *
11
+ * The collapsed row is read by someone asking a question about documents, not
12
+ * by an engineer: `Bash ls -1 .` tells them nothing and looks like a leak from
13
+ * the machine room. The raw command is still one click away on expand, so this
14
+ * hides nothing — it just stops the transcript opening with jargon.
15
+ */
16
+ export function describe(block) {
17
+ const input = parseInput(block);
18
+ const command = typeof input.command === 'string' ? input.command : '';
19
+ if (block.name === 'Read') {
20
+ const path = str(input.file_path ?? input.path);
21
+ return { verb: 'Reading', object: documentName(path) };
22
+ }
23
+ if (block.name === 'Grep') {
24
+ return { verb: 'Searching for', object: str(input.pattern) };
25
+ }
26
+ if (block.name === 'Glob') {
27
+ return { verb: 'Looking for files', object: str(input.pattern) };
28
+ }
29
+ if (block.name === 'Bash') {
30
+ if (/\bgrep\b|\brg\b/.test(command)) {
31
+ const quoted = command.match(/["']([^"']{2,60})["']/);
32
+ return { verb: 'Searching for', object: quoted?.[1] ?? 'a phrase' };
33
+ }
34
+ if (/\bls\b/.test(command))
35
+ return { verb: 'Listing', object: listTarget(command) };
36
+ if (/\bfind\b/.test(command))
37
+ return { verb: 'Looking for files', object: '' };
38
+ if (/\bcat\b|\bhead\b|\bsed\b/.test(command)) {
39
+ return { verb: 'Reading', object: documentName(lastPath(command)) };
40
+ }
41
+ if (/\bwc\b/.test(command))
42
+ return { verb: 'Counting', object: '' };
43
+ return { verb: 'Running a command', object: '' };
44
+ }
45
+ return { verb: block.name, object: summarise(block) };
46
+ }
47
+ function parseInput(block) {
48
+ try {
49
+ return JSON.parse(block.rawInput);
50
+ }
51
+ catch {
52
+ return {};
53
+ }
54
+ }
55
+ const str = (value) => (typeof value === 'string' ? value : '');
56
+ /** A staged path ends with the document's title slug; that is its name. */
57
+ function documentName(path) {
58
+ if (!path)
59
+ return '';
60
+ const segments = path.split('/').filter((s) => s && s !== '.');
61
+ const page = segments.at(-1) ?? '';
62
+ // `.../<title-slug>/page-004.md` — the page number is noise, the slug is not.
63
+ if (/^page-\d+\.md$/.test(page))
64
+ return prettify(segments.at(-2) ?? '');
65
+ return prettify(page.replace(/\.md$/, ''));
66
+ }
67
+ function listTarget(command) {
68
+ const target = command.trim().split(/\s+/).at(-1) ?? '';
69
+ if (!target || target === '.' || target.startsWith('-'))
70
+ return 'the collections';
71
+ return prettify(target.replace(/\/$/, ''));
72
+ }
73
+ function lastPath(command) {
74
+ return (command
75
+ .split(/\s+/)
76
+ .filter((t) => t.includes('/') || t.endsWith('.md'))
77
+ .at(-1) ?? '');
78
+ }
79
+ /** Slugs are the corpus's own titles; a reader should see words, not kebab-case. */
80
+ function prettify(slug) {
81
+ if (!slug)
82
+ return '';
83
+ return slug.replace(/[-_]+/g, ' ');
84
+ }
85
+ /** Best-effort one-line summary of a tool call, for the collapsed row.
86
+ *
87
+ * A staged path leads with a 64-character content hash and ends with the
88
+ * document's title — so truncating from the LEFT, as a path naturally does,
89
+ * shows the reader the noise and cuts the signal. Paths are elided from the
90
+ * front instead, which is why this is not just `slice(0, n)`.
91
+ */
92
+ export function summarise(block) {
93
+ let value = block.rawInput.slice(0, 200);
94
+ try {
95
+ const input = JSON.parse(block.rawInput);
96
+ const first = input.command ?? input.pattern ?? input.file_path ?? input.path ?? input.description;
97
+ if (typeof first === 'string')
98
+ value = first;
99
+ }
100
+ catch {
101
+ /* mid-stream JSON is expected to be partial */
102
+ }
103
+ return value.includes('/') ? elidePath(value) : value;
104
+ }
105
+ /** Keep the last two path segments — the document title and the page. */
106
+ function elidePath(value) {
107
+ const segments = value.split('/').filter(Boolean);
108
+ if (segments.length <= 2)
109
+ return value;
110
+ return `…/${segments.slice(-2).join('/')}`;
111
+ }
112
+ export class Transcript {
113
+ blocks = $state([]);
114
+ /** Bumped on every applied event.
115
+ *
116
+ * Streaming grows an EXISTING block's `.text` in place, and Svelte's
117
+ * fine-grained tracking never sees that unless something reads it. An
118
+ * effect keyed on `blocks.length` therefore fires when a new block is
119
+ * pushed and never again — which is why a single long prose answer used to
120
+ * stream 870px past the fold with auto-scroll still "pinned" at the top.
121
+ * Consumers key on this instead. */
122
+ version = $state(0);
123
+ sessionId = $state(null);
124
+ model = $state(null);
125
+ outcome = $state(null);
126
+ other = $state([]);
127
+ /** Content-block index is per MESSAGE, so it repeats across turns; this
128
+ * maps the live index onto a position in the flat list. Cleared whenever a
129
+ * message starts, which is what stops turn two overwriting turn one. */
130
+ // Deliberately a plain Map, not a SvelteMap: nothing renders it, it is
131
+ // written on every content-block delta while an answer streams, and giving
132
+ // each entry its own reactive signal would buy a re-render nobody reads.
133
+ // eslint-disable-next-line svelte/prefer-svelte-reactivity
134
+ #open = new Map();
135
+ reset() {
136
+ this.blocks = [];
137
+ this.outcome = null;
138
+ this.other = [];
139
+ this.#open.clear();
140
+ }
141
+ apply(event) {
142
+ this.version += 1;
143
+ if (event.type === 'system' && event.subtype === 'init') {
144
+ this.sessionId = event.session_id ?? null;
145
+ this.model = event.model ?? null;
146
+ return;
147
+ }
148
+ if (event.type === 'result') {
149
+ this.outcome = {
150
+ turns: event.num_turns,
151
+ costUsd: event.total_cost_usd,
152
+ durationMs: event.duration_ms,
153
+ isError: event.is_error
154
+ };
155
+ return;
156
+ }
157
+ if (event.type === 'library_error') {
158
+ this.outcome = { isError: true, error: event.error ?? 'the agent failed' };
159
+ return;
160
+ }
161
+ // A tool RESULT arrives as a user message carrying tool_result blocks.
162
+ if (event.type === 'user') {
163
+ for (const block of event.message?.content ?? []) {
164
+ if (block.type !== 'tool_result')
165
+ continue;
166
+ const target = [...this.blocks].reverse().find((b) => b.kind === 'tool' && !b.result);
167
+ if (target && target.kind === 'tool') {
168
+ target.result = renderResult(block.content);
169
+ target.isError = block.is_error === true;
170
+ }
171
+ }
172
+ return;
173
+ }
174
+ if (event.type !== 'stream_event' || !event.event) {
175
+ if (event.type !== 'assistant')
176
+ this.other.push(event);
177
+ return;
178
+ }
179
+ const inner = event.event;
180
+ if (inner.type === 'message_start') {
181
+ this.#open.clear();
182
+ return;
183
+ }
184
+ if (inner.type === 'content_block_start' && inner.index !== undefined) {
185
+ const cb = inner.content_block;
186
+ if (!cb)
187
+ return;
188
+ const position = this.blocks.length;
189
+ this.#open.set(inner.index, position);
190
+ if (cb.type === 'text')
191
+ this.blocks.push({ kind: 'text', index: position, text: '' });
192
+ else if (cb.type === 'thinking')
193
+ this.blocks.push({ kind: 'thinking', index: position, text: '' });
194
+ else if (cb.type === 'tool_use')
195
+ this.blocks.push({
196
+ kind: 'tool',
197
+ index: position,
198
+ name: cb.name ?? 'tool',
199
+ rawInput: ''
200
+ });
201
+ return;
202
+ }
203
+ if (inner.type === 'content_block_delta' && inner.index !== undefined) {
204
+ const position = this.#open.get(inner.index);
205
+ if (position === undefined)
206
+ return;
207
+ const block = this.blocks[position];
208
+ const delta = inner.delta;
209
+ if (!block || !delta)
210
+ return;
211
+ if (delta.type === 'text_delta' && block.kind === 'text')
212
+ block.text += delta.text ?? '';
213
+ else if (delta.type === 'thinking_delta' && block.kind === 'thinking')
214
+ block.text += delta.thinking ?? '';
215
+ else if (delta.type === 'input_json_delta' && block.kind === 'tool')
216
+ block.rawInput += delta.partial_json ?? '';
217
+ }
218
+ }
219
+ }
220
+ function renderResult(content) {
221
+ if (typeof content === 'string')
222
+ return content;
223
+ if (Array.isArray(content)) {
224
+ return content
225
+ .map((part) => typeof part === 'object' && part !== null && 'text' in part
226
+ ? String(part.text)
227
+ : '')
228
+ .join('');
229
+ }
230
+ return '';
231
+ }
232
+ /**
233
+ * Fold a turn's flat block list into what a reader should actually see.
234
+ *
235
+ * Two problems this solves, both reported off a real transcript:
236
+ *
237
+ * 1. Fifteen tool rows stood between the question and the first word of the
238
+ * answer, so the answer had to be scrolled to. Contiguous activity becomes
239
+ * ONE group the caller can collapse.
240
+ * 2. "Reading pspf guidelines 2026" appeared five times in a row — five pages
241
+ * of one document, which is one act of reading to a human. Consecutive
242
+ * steps with the same label collapse to one row carrying a count.
243
+ */
244
+ export function segment(blocks) {
245
+ const out = [];
246
+ let current = null;
247
+ for (const block of blocks) {
248
+ if (block.kind === 'text') {
249
+ current = null;
250
+ out.push(block);
251
+ continue;
252
+ }
253
+ // A thinking block with no text is a row whose chevron opens on nothing.
254
+ // Claude Code's thinking display defaults to "omitted", so most arrive
255
+ // empty — rendering them is worse than dropping them.
256
+ if (block.kind === 'thinking' && !block.text.trim())
257
+ continue;
258
+ if (!current) {
259
+ current = {
260
+ kind: 'activity',
261
+ index: block.index,
262
+ steps: [],
263
+ collections: [],
264
+ documents: 0,
265
+ searches: 0
266
+ };
267
+ out.push(current);
268
+ }
269
+ const last = current.steps.at(-1);
270
+ if (last && sameStep(last.block, block)) {
271
+ last.repeats += 1;
272
+ }
273
+ else {
274
+ current.steps.push({ block, repeats: 1 });
275
+ }
276
+ if (block.kind === 'tool')
277
+ tally(current, block);
278
+ }
279
+ return out;
280
+ }
281
+ function sameStep(a, b) {
282
+ if (a.kind !== b.kind)
283
+ return false;
284
+ if (a.kind === 'thinking')
285
+ return true;
286
+ const left = describe(a);
287
+ const right = describe(b);
288
+ return left.verb === right.verb && left.object === right.object;
289
+ }
290
+ function tally(group, block) {
291
+ const { verb } = describe(block);
292
+ if (verb === 'Searching for')
293
+ group.searches += 1;
294
+ if (verb === 'Reading')
295
+ group.documents += 1;
296
+ const collection = collectionOf(block);
297
+ if (collection && !group.collections.includes(collection))
298
+ group.collections.push(collection);
299
+ }
300
+ /** The first path segment under the corpus root IS the collection name. */
301
+ function collectionOf(block) {
302
+ const raw = block.rawInput;
303
+ const match = raw.match(/(?:^|["'\s/])([a-z0-9]+(?:-[a-z0-9]+)+)\/local\//);
304
+ return match?.[1] ?? '';
305
+ }
306
+ /** One line describing a whole investigation, for the collapsed state. */
307
+ export function summariseActivity(group) {
308
+ const parts = [];
309
+ if (group.searches)
310
+ parts.push(`${group.searches} search${group.searches === 1 ? '' : 'es'}`);
311
+ if (group.documents)
312
+ parts.push(`${group.documents} document${group.documents === 1 ? '' : 's'} read`);
313
+ if (group.collections.length)
314
+ parts.push(`${group.collections.length} collection${group.collections.length === 1 ? '' : 's'}`);
315
+ return parts.length ? parts.join(' · ') : 'Worked on it';
316
+ }
package/package.json ADDED
@@ -0,0 +1,70 @@
1
+ {
2
+ "name": "@poodle64/librarian",
3
+ "version": "2026.9.1",
4
+ "description": "Milton's conversation surface as a consumable Svelte 5 package: the stream client, transcript state and chat components (transcript, composer, markdown) every household app renders instead of rebuilding.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "repository": "github:radar-hooves/design-system",
8
+ "publishConfig": {
9
+ "registry": "https://registry.npmjs.org",
10
+ "access": "public",
11
+ "provenance": false
12
+ },
13
+ "files": [
14
+ "dist"
15
+ ],
16
+ "exports": {
17
+ "./package.json": "./package.json",
18
+ "./client": {
19
+ "types": "./dist/client.d.ts",
20
+ "svelte": "./dist/client.js"
21
+ },
22
+ "./transcript": {
23
+ "types": "./dist/transcript.svelte.d.ts",
24
+ "svelte": "./dist/transcript.svelte.js"
25
+ },
26
+ "./history": {
27
+ "types": "./dist/history.svelte.d.ts",
28
+ "svelte": "./dist/history.svelte.js"
29
+ },
30
+ "./*": {
31
+ "types": "./dist/components/*/index.d.ts",
32
+ "svelte": "./dist/components/*/index.js"
33
+ }
34
+ },
35
+ "peerDependencies": {
36
+ "svelte": "^5.54.0",
37
+ "@poodle64/ui": "^2026.9.2",
38
+ "@lucide/svelte": "^1.7.0",
39
+ "marked": "^18.0.11",
40
+ "isomorphic-dompurify": "^3.23.0",
41
+ "shiki": "^4.4.3"
42
+ },
43
+ "devDependencies": {
44
+ "@lucide/svelte": "^1.7.0",
45
+ "@poodle64/ui": "workspace:*",
46
+ "@sveltejs/kit": "^2.63.0",
47
+ "@sveltejs/package": "^2.5.8",
48
+ "@sveltejs/vite-plugin-svelte": "^7.1.2",
49
+ "@testing-library/jest-dom": "^7.0.0",
50
+ "@testing-library/svelte": "^5.4.2",
51
+ "@types/node": "^22.20.1",
52
+ "isomorphic-dompurify": "^3.23.0",
53
+ "jsdom": "^29.1.1",
54
+ "marked": "^18.0.11",
55
+ "publint": "^0.3.15",
56
+ "shiki": "^4.4.3",
57
+ "svelte": "^5.56.2",
58
+ "svelte-check": "^4.6.0",
59
+ "typescript": "^6.0.3",
60
+ "vite": "^8.0.16",
61
+ "vitest": "^4.1.10"
62
+ },
63
+ "scripts": {
64
+ "build": "svelte-kit sync && svelte-package && publint",
65
+ "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
66
+ "test": "pnpm run build && vitest run",
67
+ "prepublishOnly": "pnpm run build"
68
+ },
69
+ "packageManager": "pnpm@10.28.0"
70
+ }