@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,132 @@
1
+ /**
2
+ * Markdown for a STREAMING agent transcript.
3
+ *
4
+ * Two things separate this from calling `marked()` on a string, and both are
5
+ * what make a streaming transcript look finished rather than glitchy:
6
+ *
7
+ * 1. Half-arrived markdown is not valid markdown. A code fence that has opened
8
+ * and not yet closed makes `marked` treat the rest of the answer as code, so
9
+ * the message visibly flips between prose and a grey slab on every token.
10
+ * Unterminated constructs are closed before parsing.
11
+ * 2. Highlighting every code block on every token is wasted work the user never
12
+ * sees — the block changes again a few milliseconds later. Highlighting is a
13
+ * separate pass the caller runs once the text has settled.
14
+ *
15
+ * Sanitised on the way out, always. The content is model output rendered as
16
+ * HTML, which is exactly the case DOMPurify exists for.
17
+ */
18
+ import DOMPurify from 'isomorphic-dompurify';
19
+ import { marked } from 'marked';
20
+ marked.setOptions({ gfm: true, breaks: false });
21
+ /** Close anything the stream has opened but not yet finished. */
22
+ export function balance(markdown) {
23
+ let text = markdown;
24
+ // An odd number of ``` fences means one is still open.
25
+ const fences = text.match(/^```/gm)?.length ?? 0;
26
+ if (fences % 2 === 1)
27
+ text += '\n```';
28
+ // A trailing pipe row with no separator renders as a paragraph of pipes;
29
+ // leaving the partial row out reads as the table simply still growing.
30
+ const lines = text.split('\n');
31
+ const last = lines.at(-1) ?? '';
32
+ if (last.startsWith('|') && !last.endsWith('|') && lines.length > 1) {
33
+ lines.pop();
34
+ text = lines.join('\n');
35
+ }
36
+ return text;
37
+ }
38
+ export function render(markdown, { streaming = false } = {}) {
39
+ const source = streaming ? balance(markdown) : markdown;
40
+ const html = marked.parse(source, { async: false });
41
+ return DOMPurify.sanitize(html, {
42
+ ADD_ATTR: ['target', 'rel'],
43
+ FORBID_TAGS: ['style', 'form', 'input', 'button'],
44
+ FORBID_ATTR: ['style', 'onerror', 'onload']
45
+ });
46
+ }
47
+ let highlighterPromise = null;
48
+ /** Loaded once, lazily — shiki's engine and grammars are not small. */
49
+ async function getHighlighter() {
50
+ if (!highlighterPromise) {
51
+ highlighterPromise = import('shiki').then((shiki) => shiki.createHighlighter({
52
+ // Dual themes emit CSS variables, so one render serves both colour
53
+ // schemes and the app's existing theme switch drives it — no
54
+ // re-highlight on toggle, no second copy of the DOM.
55
+ themes: ['github-light', 'github-dark'],
56
+ langs: [
57
+ 'bash',
58
+ 'python',
59
+ 'typescript',
60
+ 'javascript',
61
+ 'json',
62
+ 'yaml',
63
+ 'markdown',
64
+ 'sql',
65
+ 'html',
66
+ 'css',
67
+ 'diff'
68
+ ]
69
+ }));
70
+ }
71
+ return highlighterPromise;
72
+ }
73
+ /**
74
+ * Mark every inline `code` span that names a known collection.
75
+ *
76
+ * The agent puts identifiers in backticks — document titles, requirement
77
+ * numbers, collection names — and they all render alike. Matching against
78
+ * the caller's real list rather than a prompt convention means the prompt
79
+ * cannot drift out of sync with the rendering.
80
+ */
81
+ export function markCollections(root, collections) {
82
+ if (collections.size === 0)
83
+ return;
84
+ for (const code of root.querySelectorAll('code')) {
85
+ if (code.parentElement?.tagName === 'PRE')
86
+ continue;
87
+ const name = (code.textContent ?? '').trim();
88
+ if (collections.has(name))
89
+ code.dataset.collection = 'true';
90
+ }
91
+ }
92
+ /**
93
+ * Replace every `<pre><code>` in a rendered fragment with a highlighted one.
94
+ * Runs against the DOM node rather than the HTML string so it can be applied
95
+ * after paint without re-parsing the markdown.
96
+ */
97
+ export async function highlight(root) {
98
+ const blocks = root.querySelectorAll('pre > code');
99
+ if (blocks.length === 0)
100
+ return;
101
+ const highlighter = await getHighlighter();
102
+ const supported = new Set(highlighter.getLoadedLanguages());
103
+ for (const block of blocks) {
104
+ const pre = block.parentElement;
105
+ if (!pre || pre.dataset.highlighted === 'true')
106
+ continue;
107
+ const declared = [...block.classList]
108
+ .find((c) => c.startsWith('language-'))
109
+ ?.slice('language-'.length);
110
+ const lang = declared && supported.has(declared) ? declared : 'text';
111
+ const code = block.textContent ?? '';
112
+ try {
113
+ const html = highlighter.codeToHtml(code, {
114
+ lang,
115
+ themes: { light: 'github-light', dark: 'github-dark' },
116
+ defaultColor: false
117
+ });
118
+ const replacement = new DOMParser().parseFromString(html, 'text/html').body.firstElementChild;
119
+ if (!replacement)
120
+ continue;
121
+ if (replacement instanceof HTMLElement) {
122
+ replacement.dataset.highlighted = 'true';
123
+ replacement.dataset.lang = lang;
124
+ }
125
+ pre.replaceWith(replacement);
126
+ }
127
+ catch {
128
+ // An unknown grammar is not worth failing a message over.
129
+ pre.dataset.highlighted = 'true';
130
+ }
131
+ }
132
+ }
@@ -0,0 +1,221 @@
1
+ <!--
2
+ Rendered markdown for a streaming agent message.
3
+
4
+ Prose styling lives here rather than in a global stylesheet because the
5
+ content is untrusted HTML with no classes on it — every rule targets a bare
6
+ tag, and letting those rules escape this component would restyle the whole
7
+ consuming app.
8
+ -->
9
+ <script lang="ts">
10
+ import { highlight, markCollections, render } from './markdown';
11
+
12
+ interface Props {
13
+ content: string;
14
+ streaming?: boolean;
15
+ /** Collection names to chip in backticks; omit if the caller has none. */
16
+ collectionNames?: Set<string>;
17
+ }
18
+
19
+ let { content, streaming = false, collectionNames = new Set() }: Props = $props();
20
+ let host = $state<HTMLElement | null>(null);
21
+
22
+ const html = $derived(render(content, { streaming }));
23
+
24
+ // Highlight only once the text has settled. Re-running per token would
25
+ // re-parse grammars for a block that changes again milliseconds later.
26
+ $effect(() => {
27
+ if (!host || !html) return;
28
+ // Chips are cheap and wanted DURING streaming; highlighting is not.
29
+ markCollections(host, collectionNames);
30
+ if (streaming) return;
31
+ void highlight(host);
32
+ });
33
+ </script>
34
+
35
+ <div bind:this={host} class="agent-prose text-foreground text-base leading-7">
36
+ <!-- eslint-disable-next-line svelte/no-at-html-tags -- sanitised in render() -->
37
+ {@html html}
38
+ </div>
39
+
40
+ <style>
41
+ .agent-prose :global(> *:first-child) {
42
+ margin-top: 0;
43
+ }
44
+
45
+ .agent-prose :global(> *:last-child) {
46
+ margin-bottom: 0;
47
+ }
48
+
49
+ .agent-prose :global(p),
50
+ .agent-prose :global(ul),
51
+ .agent-prose :global(ol),
52
+ .agent-prose :global(blockquote),
53
+ .agent-prose :global(pre),
54
+ .agent-prose :global(table) {
55
+ margin-block: 0.75em;
56
+ }
57
+
58
+ .agent-prose :global(h1),
59
+ .agent-prose :global(h2),
60
+ .agent-prose :global(h3),
61
+ .agent-prose :global(h4) {
62
+ font-family: var(--ds-font-display, inherit);
63
+ font-weight: 600;
64
+ line-height: 1.3;
65
+ margin-block: 1.6em 0.6em;
66
+ }
67
+
68
+ /* A hairline above each section. The palette's surfaces sit within ~1.2:1
69
+ of the page (measured, both themes), so weight and rule do the work that
70
+ a background tint cannot. */
71
+ .agent-prose :global(h2) {
72
+ border-top: 1px solid var(--border);
73
+ padding-top: 0.8em;
74
+ }
75
+
76
+ .agent-prose :global(h2:first-child) {
77
+ border-top: 0;
78
+ padding-top: 0;
79
+ }
80
+
81
+ .agent-prose :global(h1) {
82
+ font-size: 1.35em;
83
+ }
84
+
85
+ .agent-prose :global(h2) {
86
+ font-size: 1.2em;
87
+ }
88
+
89
+ .agent-prose :global(h3) {
90
+ font-size: 1.05em;
91
+ }
92
+
93
+ .agent-prose :global(h4) {
94
+ font-size: 1em;
95
+ }
96
+
97
+ .agent-prose :global(ul),
98
+ .agent-prose :global(ol) {
99
+ padding-inline-start: 1.4em;
100
+ }
101
+
102
+ .agent-prose :global(ul) {
103
+ list-style: disc;
104
+ }
105
+
106
+ .agent-prose :global(ol) {
107
+ list-style: decimal;
108
+ }
109
+
110
+ .agent-prose :global(li) {
111
+ margin-block: 0.3em;
112
+ }
113
+
114
+ .agent-prose :global(li > ul),
115
+ .agent-prose :global(li > ol) {
116
+ margin-block: 0.3em;
117
+ }
118
+
119
+ /* Lead paragraph: the answer someone could act on without reading on. */
120
+ .agent-prose :global(> p:first-child) {
121
+ font-size: 1.0625em;
122
+ line-height: 1.65;
123
+ }
124
+
125
+ .agent-prose :global(strong) {
126
+ font-weight: 600;
127
+ color: var(--foreground);
128
+ }
129
+
130
+ .agent-prose :global(a) {
131
+ color: var(--ds-color-primary, currentColor);
132
+ text-underline-offset: 0.2em;
133
+ text-decoration-line: underline;
134
+ }
135
+
136
+ .agent-prose :global(blockquote) {
137
+ border-inline-start: 2px solid var(--primary);
138
+ padding-inline: 1em 0;
139
+ color: var(--muted-foreground);
140
+ font-style: italic;
141
+ }
142
+
143
+ /* Inline code only — the fenced case is the `pre >` rule below. */
144
+
145
+ /* Inline code carries IDENTIFIERS here — document titles, collection names,
146
+ requirement numbers — so it is the main way a reader picks a reference
147
+ out of a paragraph. Bordered rather than merely tinted, because a tint
148
+ alone is invisible against this palette's flat surfaces. */
149
+ .agent-prose :global(code) {
150
+ font-family: var(--ds-font-mono, monospace);
151
+ font-size: 0.8125em;
152
+ background: var(--muted);
153
+ border: 1px solid var(--border);
154
+ border-radius: 0.35em;
155
+ padding: 0.1em 0.35em;
156
+ white-space: nowrap;
157
+ }
158
+
159
+ .agent-prose :global(pre) {
160
+ background: var(--muted);
161
+ border: 1px solid var(--border);
162
+ border-radius: var(--radius);
163
+ padding: 0.85em 1em;
164
+ overflow-x: auto;
165
+ }
166
+
167
+ .agent-prose :global(pre code) {
168
+ background: none;
169
+ padding: 0;
170
+ font-size: 0.8125em;
171
+ line-height: 1.6;
172
+ }
173
+
174
+ /* Shiki's dual-theme output: one DOM, both colour schemes, switched by the
175
+ app's own theme rather than by re-highlighting. */
176
+ .agent-prose :global(pre.shiki),
177
+ .agent-prose :global(pre.shiki span) {
178
+ color: var(--shiki-light);
179
+ }
180
+
181
+ :global(.dark) .agent-prose :global(pre.shiki),
182
+ :global(.dark) .agent-prose :global(pre.shiki span) {
183
+ color: var(--shiki-dark);
184
+ }
185
+
186
+ .agent-prose :global(table) {
187
+ width: 100%;
188
+ border-collapse: collapse;
189
+ font-size: 0.9em;
190
+ display: block;
191
+ overflow-x: auto;
192
+ }
193
+
194
+ .agent-prose :global(th),
195
+ .agent-prose :global(td) {
196
+ border: 1px solid var(--border);
197
+ padding: 0.4em 0.6em;
198
+ text-align: start;
199
+ }
200
+
201
+ .agent-prose :global(th) {
202
+ background: var(--muted);
203
+ font-weight: 600;
204
+ }
205
+
206
+ /* A collection reads as an entity rather than another monospace token when
207
+ the caller passes `collectionNames`. Accent-tinted rather than the flat
208
+ muted surface every other identifier sits on. */
209
+ .agent-prose :global(code[data-collection]) {
210
+ background: color-mix(in oklab, var(--primary) 16%, transparent);
211
+ border-color: color-mix(in oklab, var(--primary) 45%, transparent);
212
+ color: var(--foreground);
213
+ font-weight: 500;
214
+ }
215
+
216
+ .agent-prose :global(hr) {
217
+ border: 0;
218
+ border-top: 1px solid var(--border);
219
+ margin-block: 1.5em;
220
+ }
221
+ </style>
@@ -0,0 +1,9 @@
1
+ interface Props {
2
+ content: string;
3
+ streaming?: boolean;
4
+ /** Collection names to chip in backticks; omit if the caller has none. */
5
+ collectionNames?: Set<string>;
6
+ }
7
+ declare const Markdown: import("svelte").Component<Props, {}, "">;
8
+ type Markdown = ReturnType<typeof Markdown>;
9
+ export default Markdown;
@@ -0,0 +1,2 @@
1
+ export { default as ThinkingRow } from './thinking-row.svelte';
2
+ export { default } from './thinking-row.svelte';
@@ -0,0 +1,2 @@
1
+ export { default as ThinkingRow } from './thinking-row.svelte';
2
+ export { default } from './thinking-row.svelte';
@@ -0,0 +1,35 @@
1
+ <script lang="ts">
2
+ import ChevronRightIcon from '@lucide/svelte/icons/chevron-right';
3
+ import type { ThinkingBlock } from '../../transcript.svelte';
4
+
5
+ interface Props {
6
+ block: ThinkingBlock;
7
+ active: boolean;
8
+ }
9
+
10
+ let { block, active }: Props = $props();
11
+ let open = $state(false);
12
+ </script>
13
+
14
+ <div class="text-sm">
15
+ <button
16
+ type="button"
17
+ class="flex items-center gap-2 py-0.5 text-left"
18
+ onclick={() => (open = !open)}
19
+ aria-expanded={open}
20
+ >
21
+ <ChevronRightIcon
22
+ class="text-muted-foreground size-3 transition-transform {open ? 'rotate-90' : ''}"
23
+ />
24
+ <span class={active ? 'text-foreground font-medium' : 'text-muted-foreground'}>
25
+ {active ? 'Thinking…' : 'Thought'}
26
+ </span>
27
+ </button>
28
+ {#if open}
29
+ <p
30
+ class="text-muted-foreground border-border mt-1 ml-1.25 border-l pl-3 text-sm whitespace-pre-wrap"
31
+ >
32
+ {block.text}
33
+ </p>
34
+ {/if}
35
+ </div>
@@ -0,0 +1,8 @@
1
+ import type { ThinkingBlock } from '../../transcript.svelte';
2
+ interface Props {
3
+ block: ThinkingBlock;
4
+ active: boolean;
5
+ }
6
+ declare const ThinkingRow: import("svelte").Component<Props, {}, "">;
7
+ type ThinkingRow = ReturnType<typeof ThinkingRow>;
8
+ export default ThinkingRow;
@@ -0,0 +1,2 @@
1
+ export { default as ToolRow } from './tool-row.svelte';
2
+ export { default } from './tool-row.svelte';
@@ -0,0 +1,2 @@
1
+ export { default as ToolRow } from './tool-row.svelte';
2
+ export { default } from './tool-row.svelte';
@@ -0,0 +1,77 @@
1
+ <!--
2
+ One tool call, as a row in the transcript.
3
+
4
+ Not a card: a status dot, an action in bold, its target beside it, one
5
+ dimmed sub-line. Borders and badge pills make a run of five calls read as
6
+ five separate events rather than one train of thought.
7
+
8
+ The row says what the agent is DOING, not what it typed. A reader here is
9
+ asking about documents, not reading a terminal, and `Bash ls -1 .` looks
10
+ like a leak from the machine room. The raw command is one click away, so
11
+ nothing is hidden from anyone who wants it.
12
+ -->
13
+ <script lang="ts">
14
+ import ChevronRightIcon from '@lucide/svelte/icons/chevron-right';
15
+ import { describe, summarise, type ToolBlock } from '../../transcript.svelte';
16
+
17
+ interface Props {
18
+ block: ToolBlock;
19
+ running: boolean;
20
+ /** Identical consecutive steps folded into this row — five pages of one
21
+ * document is one act of reading to a human. */
22
+ repeats?: number;
23
+ }
24
+
25
+ let { block, running, repeats = 1 }: Props = $props();
26
+ let open = $state(false);
27
+
28
+ const settled = $derived(block.result !== undefined);
29
+ const said = $derived(describe(block));
30
+ const tone = $derived(
31
+ block.isError ? 'bg-status-error' : settled ? 'bg-status-success' : 'bg-status-info'
32
+ );
33
+ const lines = $derived(block.result ? block.result.split('\n').length : 0);
34
+ </script>
35
+
36
+ <div class="text-sm">
37
+ <button
38
+ type="button"
39
+ class="flex w-full items-center gap-2 rounded py-0.5 text-left"
40
+ onclick={() => (open = !open)}
41
+ disabled={!settled}
42
+ aria-expanded={open}
43
+ >
44
+ <span class="flex w-3 shrink-0 items-center justify-center">
45
+ {#if settled}
46
+ <ChevronRightIcon
47
+ class="text-muted-foreground size-3 transition-transform {open ? 'rotate-90' : ''}"
48
+ />
49
+ {/if}
50
+ </span>
51
+ <span class="size-1.5 shrink-0 rounded-full {tone} {!settled && running ? 'animate-pulse' : ''}"
52
+ ></span>
53
+ <span class="text-muted-foreground truncate">
54
+ <span class="text-foreground font-medium">{said.verb}</span>
55
+ {#if said.object}<span class="text-foreground/80">{said.object}</span>{/if}
56
+ {#if repeats > 1}<span class="text-muted-foreground">· {repeats} sections</span>{/if}
57
+ </span>
58
+ </button>
59
+
60
+ {#if open}
61
+ <div class="border-border mt-1 ml-5.5 border-l pl-3">
62
+ <!-- The real command, for anyone who wants it. -->
63
+ <p class="text-muted-foreground font-mono text-xs break-all">
64
+ {block.name}
65
+ {summarise(block)}
66
+ </p>
67
+ {#if block.result}
68
+ <pre
69
+ class="text-muted-foreground mt-1 max-h-72 overflow-auto font-mono text-xs whitespace-pre-wrap">{block.result}</pre>
70
+ {/if}
71
+ </div>
72
+ {:else if settled && lines > 0}
73
+ <p class="text-muted-foreground pl-5.5 text-xs">
74
+ {lines === 1 ? '1 line' : `${lines} lines`}
75
+ </p>
76
+ {/if}
77
+ </div>
@@ -0,0 +1,11 @@
1
+ import { type ToolBlock } from '../../transcript.svelte';
2
+ interface Props {
3
+ block: ToolBlock;
4
+ running: boolean;
5
+ /** Identical consecutive steps folded into this row — five pages of one
6
+ * document is one act of reading to a human. */
7
+ repeats?: number;
8
+ }
9
+ declare const ToolRow: import("svelte").Component<Props, {}, "">;
10
+ type ToolRow = ReturnType<typeof ToolRow>;
11
+ export default ToolRow;
@@ -0,0 +1,2 @@
1
+ export { default as Working } from './working.svelte';
2
+ export { default } from './working.svelte';
@@ -0,0 +1,2 @@
1
+ export { default as Working } from './working.svelte';
2
+ export { default } from './working.svelte';
@@ -0,0 +1,50 @@
1
+ <!--
2
+ Something is happening, and you can see it.
3
+
4
+ The CLI takes ~2.6s to boot before its first event and the model another
5
+ ~1.5s to its first token, so a naive transcript shows NOTHING for about four
6
+ seconds. Measured, and the operator counted it out loud. The lag itself is
7
+ mostly not ours to remove; a screen that visibly does nothing is.
8
+
9
+ Two moving parts, deliberately: a word that changes so the page is evidently
10
+ alive, and a clock that only goes up so a long wait still reads as progress
11
+ rather than as a hang.
12
+ -->
13
+ <script lang="ts">
14
+ interface Props {
15
+ /** Shown instead of the cycling word once real work is identifiable. */
16
+ label?: string;
17
+ }
18
+
19
+ let { label }: Props = $props();
20
+
21
+ const WORDS = [
22
+ 'Starting',
23
+ 'Reading the shelves',
24
+ 'Rummaging',
25
+ 'Cross-checking',
26
+ 'Thumbing pages',
27
+ 'Following a reference',
28
+ 'Chasing it down'
29
+ ];
30
+
31
+ let tick = $state(0);
32
+ let elapsed = $state(0);
33
+
34
+ $effect(() => {
35
+ const word = setInterval(() => (tick += 1), 2600);
36
+ const clock = setInterval(() => (elapsed += 1), 1000);
37
+ return () => {
38
+ clearInterval(word);
39
+ clearInterval(clock);
40
+ };
41
+ });
42
+
43
+ const word = $derived(label ?? WORDS[tick % WORDS.length]);
44
+ </script>
45
+
46
+ <div class="text-muted-foreground flex items-center gap-2 text-sm">
47
+ <span class="bg-status-info size-1.5 animate-pulse rounded-full"></span>
48
+ <span>{word}…</span>
49
+ <span class="text-muted-foreground/70 font-mono text-xs tabular-nums">{elapsed}s</span>
50
+ </div>
@@ -0,0 +1,7 @@
1
+ interface Props {
2
+ /** Shown instead of the cycling word once real work is identifiable. */
3
+ label?: string;
4
+ }
5
+ declare const Working: import("svelte").Component<Props, {}, "">;
6
+ type Working = ReturnType<typeof Working>;
7
+ export default Working;
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Past conversations.
3
+ *
4
+ * Held in the browser, deliberately and provisionally. The agent's own session
5
+ * survives server-side — Claude Code keeps it and `--resume` reaches it — so
6
+ * what is missing is only the RENDERED transcript, which is a display concern.
7
+ * Persisting that here needs no schema, no migration and no decision about
8
+ * whose conversation it is.
9
+ *
10
+ * Namespaced by the caller: two apps, or two rooms in one app, must not share
11
+ * a `localStorage` key, so the namespace is a constructor argument rather than
12
+ * a module-level constant.
13
+ */
14
+ export interface StoredTurn {
15
+ question: string;
16
+ answer: string;
17
+ }
18
+ export interface Conversation {
19
+ id: string;
20
+ /** The agent's session id, which is what `--resume` needs. */
21
+ sessionId: string | null;
22
+ title: string;
23
+ updated: number;
24
+ turns: StoredTurn[];
25
+ }
26
+ export declare class History {
27
+ #private;
28
+ items: Conversation[];
29
+ constructor(namespace: string);
30
+ load(): void;
31
+ save(conversation: Conversation): void;
32
+ remove(id: string): void;
33
+ }
34
+ /** One store per namespace — a distinct `localStorage` key per app or room. */
35
+ export declare function createHistory(namespace: string): History;
36
+ /** A conversation is named by its first question, trimmed to something legible. */
37
+ export declare function titleFrom(question: string): string;
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Past conversations.
3
+ *
4
+ * Held in the browser, deliberately and provisionally. The agent's own session
5
+ * survives server-side — Claude Code keeps it and `--resume` reaches it — so
6
+ * what is missing is only the RENDERED transcript, which is a display concern.
7
+ * Persisting that here needs no schema, no migration and no decision about
8
+ * whose conversation it is.
9
+ *
10
+ * Namespaced by the caller: two apps, or two rooms in one app, must not share
11
+ * a `localStorage` key, so the namespace is a constructor argument rather than
12
+ * a module-level constant.
13
+ */
14
+ const LIMIT = 40;
15
+ export class History {
16
+ items = $state([]);
17
+ #key;
18
+ constructor(namespace) {
19
+ this.#key = namespace;
20
+ }
21
+ load() {
22
+ if (typeof localStorage === 'undefined')
23
+ return;
24
+ try {
25
+ this.items = JSON.parse(localStorage.getItem(this.#key) ?? '[]');
26
+ }
27
+ catch {
28
+ this.items = [];
29
+ }
30
+ }
31
+ save(conversation) {
32
+ const rest = this.items.filter((c) => c.id !== conversation.id);
33
+ this.items = [conversation, ...rest].slice(0, LIMIT);
34
+ this.#flush();
35
+ }
36
+ remove(id) {
37
+ this.items = this.items.filter((c) => c.id !== id);
38
+ this.#flush();
39
+ }
40
+ #flush() {
41
+ if (typeof localStorage === 'undefined')
42
+ return;
43
+ try {
44
+ localStorage.setItem(this.#key, JSON.stringify(this.items));
45
+ }
46
+ catch {
47
+ // A full quota costs history, never the conversation in progress.
48
+ }
49
+ }
50
+ }
51
+ /** One store per namespace — a distinct `localStorage` key per app or room. */
52
+ export function createHistory(namespace) {
53
+ return new History(namespace);
54
+ }
55
+ /** A conversation is named by its first question, trimmed to something legible. */
56
+ export function titleFrom(question) {
57
+ const flat = question.replace(/\s+/g, ' ').trim();
58
+ return flat.length > 60 ? `${flat.slice(0, 57)}…` : flat;
59
+ }