@poodle64/librarian 2026.9.6 → 2026.9.8

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 (32) hide show
  1. package/README.md +128 -43
  2. package/dist/attachments.d.ts +30 -0
  3. package/dist/attachments.js +80 -0
  4. package/dist/citations.d.ts +69 -0
  5. package/dist/citations.js +102 -0
  6. package/dist/client.d.ts +5 -0
  7. package/dist/client.js +38 -12
  8. package/dist/components/activity-group/activity-group.svelte +36 -44
  9. package/dist/components/activity-group/activity-group.svelte.d.ts +1 -1
  10. package/dist/components/agent-transcript/agent-transcript.svelte +172 -26
  11. package/dist/components/agent-transcript/agent-transcript.svelte.d.ts +8 -0
  12. package/dist/components/composer/composer.svelte +209 -53
  13. package/dist/components/composer/composer.svelte.d.ts +5 -1
  14. package/dist/components/conversation/conversation.svelte +180 -0
  15. package/dist/components/conversation/conversation.svelte.d.ts +26 -0
  16. package/dist/components/conversation/index.d.ts +2 -0
  17. package/dist/components/conversation/index.js +2 -0
  18. package/dist/components/document-pane/document-pane.svelte +181 -0
  19. package/dist/components/document-pane/document-pane.svelte.d.ts +9 -0
  20. package/dist/components/document-pane/index.d.ts +2 -0
  21. package/dist/components/document-pane/index.js +2 -0
  22. package/dist/components/markdown/index.d.ts +1 -1
  23. package/dist/components/markdown/index.js +1 -1
  24. package/dist/components/markdown/markdown.d.ts +13 -0
  25. package/dist/components/markdown/markdown.js +49 -1
  26. package/dist/components/markdown/markdown.svelte +85 -3
  27. package/dist/components/markdown/markdown.svelte.d.ts +6 -0
  28. package/dist/follow-scroll.svelte.d.ts +34 -0
  29. package/dist/follow-scroll.svelte.js +46 -0
  30. package/dist/transcript.svelte.d.ts +27 -0
  31. package/dist/transcript.svelte.js +27 -0
  32. package/package.json +16 -2
package/README.md CHANGED
@@ -1,34 +1,35 @@
1
1
  # @poodle64/librarian
2
2
 
3
3
  Milton's conversation surface, as a Svelte 5 package: the stream client, the
4
- transcript state, and the chat components (transcript, composer, markdown,
5
- tool/thinking rows, the working indicator). An app renders the librarian
6
- instead of rebuilding it.
4
+ transcript state, and the chat components an app renders instead of
5
+ rebuilding: the transcript with its own follow-scroll, the composer with
6
+ attachments, citation chips and the source pane they open.
7
7
 
8
- Owned by the library — this is Milton's surface; design-system is its press.
8
+ Owned by the library. This is Milton's surface; design-system is its press.
9
9
  Change it here, consume it there.
10
10
 
11
11
  ## What is here
12
12
 
13
13
  ```text
14
14
  src/lib/
15
- client.ts ask(): streams Claude Code's OWN events, unaltered
15
+ client.ts ask(): streams Claude Code's OWN events, unaltered
16
16
  transcript.svelte.ts Transcript state, the fold/segment/describe helpers
17
- history.svelte.ts the browser-held conversation list, namespaced per caller
17
+ citations.ts the Citation shape, `[n]` markers, "## Sources"
18
+ attachments.ts what a reader may attach, and the limits
19
+ follow-scroll.svelte.ts follow the stream until the reader disagrees
20
+ history.svelte.ts the browser-held conversation list, per caller
18
21
  components/
19
- agent-transcript/ one question and everything the agent did answering it
20
- composer/ the input box: value, scope chips, send/stop
21
- markdown/ sanitised, streaming-safe markdown + syntax highlighting
22
- activity-group/ a run of tool calls, collapsed to one line
23
- tool-row/ one tool call
24
- thinking-row/ one thinking block
25
- working/ the pre-first-token "something is happening" indicator
22
+ conversation/ the whole reading surface: scroll, pill, pane, composer slot
23
+ agent-transcript/ one question and everything Milton did answering it
24
+ document-pane/ the cited document, open at the cited passage
25
+ composer/ the input box: attachments, scope chips, send/stop
26
+ markdown/ sanitised, streaming-safe markdown + highlighting
27
+ activity-group/ a whole investigation, as one quiet line
28
+ tool-row/ one tool call
29
+ thinking-row/ one thinking block
30
+ working/ the pre-first-token "something is happening" indicator
26
31
  ```
27
32
 
28
- Deliberately excluded: the library console's own `CorpusTree`, `DocumentPane`
29
- and collection picker. Those are furniture for browsing a corpus, not part of
30
- talking to Milton, and stay in the library's own frontend.
31
-
32
33
  ## Installation
33
34
 
34
35
  ```bash
@@ -52,37 +53,53 @@ lint hit, just an unstyled transcript.
52
53
 
53
54
  ## Consuming the package
54
55
 
55
- Every export is its own subpath, matching `@poodle64/ui`'s convention:
56
+ Every export is its own subpath, matching `@poodle64/ui`'s convention.
57
+ `Conversation` owns the scroll container and the source pane, so the host
58
+ gives it a height and a composer and nothing else:
56
59
 
57
60
  ```svelte
58
61
  <script lang="ts">
59
62
  import { ask } from '@poodle64/librarian/client';
60
- import { Transcript } from '@poodle64/librarian/transcript';
61
- import AgentTranscript from '@poodle64/librarian/agent-transcript';
63
+ import { Transcript, type Turn } from '@poodle64/librarian/transcript';
64
+ import Conversation from '@poodle64/librarian/conversation';
62
65
  import Composer from '@poodle64/librarian/composer';
63
66
 
64
67
  let question = $state('');
68
+ let files = $state<File[]>([]);
65
69
  let running = $state(false);
70
+ let turns = $state<Turn[]>([]);
71
+ let asked = $state('');
66
72
  const transcript = new Transcript();
67
73
  let controller: AbortController | null = null;
68
74
 
69
75
  async function submit() {
70
- const asked = question.trim();
76
+ asked = question.trim();
71
77
  if (!asked || running) return;
78
+ await run(asked, files);
79
+ }
72
80
 
81
+ async function run(text: string, attached: File[]) {
73
82
  question = '';
83
+ files = [];
74
84
  running = true;
75
85
  transcript.reset();
86
+ turns = [...turns, { id: crypto.randomUUID(), question: text, blocks: [], outcome: null }];
76
87
  controller = new AbortController();
77
88
 
78
89
  try {
79
90
  for await (const event of ask({
80
- question: asked,
91
+ question: text,
92
+ files: attached,
81
93
  endpoint: '/api/caller/ask',
82
94
  signal: controller.signal
83
95
  })) {
84
96
  transcript.apply(event);
85
- if (event.type === 'result' || event.type === 'library_error') running = false;
97
+ const live = turns.at(-1);
98
+ if (live) {
99
+ live.blocks = transcript.blocks;
100
+ live.outcome = transcript.outcome;
101
+ live.citations = transcript.citations;
102
+ }
86
103
  }
87
104
  } finally {
88
105
  running = false;
@@ -90,31 +107,86 @@ Every export is its own subpath, matching `@poodle64/ui`'s convention:
90
107
  }
91
108
  }
92
109
 
93
- function stop() {
94
- controller?.abort();
95
- running = false;
110
+ async function loadDocument(id: string) {
111
+ const response = await fetch(`/api/sources/documents/${id}/content?page=all`);
112
+ const doc = await response.json();
113
+ return { title: doc.document_title, sections: sectionsFrom(doc) };
96
114
  }
97
115
  </script>
98
116
 
99
- <AgentTranscript {question} blocks={transcript.blocks} outcome={transcript.outcome} {running} />
117
+ <div class="flex h-dvh flex-col">
118
+ <Conversation
119
+ {turns}
120
+ {running}
121
+ version={transcript.version}
122
+ welcome="Ask Milton about pay, allowances, leave and conditions of service."
123
+ examples={['How much recreation leave do I get?']}
124
+ onexample={(q) => (question = q)}
125
+ onregenerate={() => run(asked, [])}
126
+ {loadDocument}
127
+ >
128
+ {#snippet composer()}
129
+ <Composer
130
+ bind:value={question}
131
+ bind:files
132
+ {running}
133
+ scope="library"
134
+ onscope={() => {}}
135
+ onsubmit={submit}
136
+ onstop={() => controller?.abort()}
137
+ />
138
+ {/snippet}
139
+ </Conversation>
140
+ </div>
141
+ ```
142
+
143
+ `version` is what keeps the scroll following: streaming grows an EXISTING
144
+ block's text in place, so a count of turns never changes and an effect keyed
145
+ on it fires once and never again.
146
+
147
+ ### What the host must wire
148
+
149
+ | Concern | How |
150
+ | ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
151
+ | The ask route | `ask({ endpoint })`: a room's `/api/rooms/{id}/ask`, a caller's `/api/caller/ask` |
152
+ | Attachments | nothing: `ask()` posts multipart (`question`, `resume`, `collections[]`, `files[]`) whenever `files` is non-empty, and JSON when it is not. The route must accept both |
153
+ | Reading a cited document | `loadDocument(document_id) => Promise<{title, sections: [{anchor, heading, text}]}>`, proxied through the app's own authenticated route (cadmus: `GET /api/sources/documents/{id}/content`) |
154
+ | Asking again | `onregenerate`: re-send the last question as a NEW turn; the package exposes the action and never re-asks by itself |
155
+ | The empty state | `welcome` and up to three `examples` |
156
+
157
+ Nothing here fetches on its own behalf. The library's document read is
158
+ authenticated, and a package that called it directly would be reaching past
159
+ the app's proxy with a session it has no business holding.
160
+
161
+ ### Citations
100
162
 
101
- <Composer
102
- bind:value={question}
103
- {running}
104
- scope="library"
105
- onscope={() => {}}
106
- onsubmit={submit}
107
- onstop={stop}
108
- />
163
+ The library emits one SSE frame after the final assistant text:
164
+
165
+ ```json
166
+ {
167
+ "type": "citations",
168
+ "items": [
169
+ { "n": 1, "document_id": "…", "title": "…", "section": "…", "anchor": "…", "snippet": "…" }
170
+ ]
171
+ }
109
172
  ```
110
173
 
111
- `ask()`'s `endpoint` defaults to `/api/agent/ask`; pass whatever route the
112
- consuming app mounts (a room's `/api/rooms/{id}/ask`, a caller's
113
- `/api/caller/ask`) and an optional `fetch` for a caller-authenticated wrapper.
174
+ `Transcript.apply()` puts it on `transcript.citations`; inline `[n]` markers
175
+ in the prose become chips, and the chip opens `DocumentPane` at the cited
176
+ section. Until that frame ships everywhere, the same chips are DERIVED from
177
+ a trailing "## Sources" block in the answer: those render and read, and are
178
+ inert, because a title and a section are not an id.
179
+
180
+ ### The system preamble
181
+
182
+ A host prepends its own instruction to every question (cadmus sends
183
+ `{room.preamble}\n\n{question}`). `readerQuestion()` strips leading
184
+ paragraphs addressed to the model before the question renders, so a
185
+ colleague never sees it. Pass the question as it went on the wire; the
186
+ transcript shows what they asked.
114
187
 
115
188
  `createHistory(namespace)` from `@poodle64/librarian/history` gives each app,
116
- or each room inside an app, its own `localStorage` key, so two consumers
117
- never collide on one conversation list:
189
+ or each room inside an app, its own `localStorage` key:
118
190
 
119
191
  ```ts
120
192
  import { createHistory, titleFrom } from '@poodle64/librarian/history';
@@ -126,9 +198,22 @@ history.load();
126
198
  ## Verifying a change
127
199
 
128
200
  ```bash
129
- pnpm run build # svelte-package + publint
130
- pnpm run check # svelte-check
131
- pnpm run test # build + vitest
201
+ pnpm run build # svelte-package + publint
202
+ pnpm run check # svelte-check
203
+ pnpm run test # build + vitest
204
+ pnpm run screenshots # the state grid, real engine (see below)
205
+ ```
206
+
207
+ `docs/screenshots/` is eight states x three widths x both themes, taken by
208
+ `scripts/screenshots.mjs` against the console's `/librarian` lab route
209
+ running from its own static build. The same script asserts what a screenshot
210
+ cannot: that nothing scrolls sideways at any width, and that the source pane
211
+ opens and closes from the keyboard with focus returning to the chip. It
212
+ exits non-zero on either.
213
+
214
+ ```bash
215
+ pnpm --filter @poodle64/console run build
216
+ pnpm --filter @poodle64/librarian run screenshots
132
217
  ```
133
218
 
134
219
  ## Releasing
@@ -0,0 +1,30 @@
1
+ /**
2
+ * What a reader may hand Milton with a question.
3
+ *
4
+ * Session-scoped by design: these files are read for THIS conversation and
5
+ * never filed into a shelf, so the limits here are about what a browser can
6
+ * post and a model can read in one turn, not about a corpus.
7
+ */
8
+ export declare const MAX_FILES = 10;
9
+ export declare const MAX_BYTES: number;
10
+ export declare const ACCEPTED_TYPES: readonly ["image/png", "image/jpeg", "image/webp", "application/pdf", "application/vnd.openxmlformats-officedocument.wordprocessingml.document", "text/plain", "text/markdown"];
11
+ export declare const ACCEPT_ATTRIBUTE: string;
12
+ export interface RejectedFile {
13
+ name: string;
14
+ reason: 'type' | 'size' | 'count';
15
+ }
16
+ export interface FileCheck {
17
+ accepted: File[];
18
+ rejected: RejectedFile[];
19
+ }
20
+ /**
21
+ * Merge a drop or a picker's selection into the files already attached.
22
+ *
23
+ * Returns the WHOLE new list rather than only the additions, because the count
24
+ * limit is a property of the list and a caller that appended the return value
25
+ * would silently exceed it.
26
+ */
27
+ export declare function acceptFiles(existing: File[], incoming: File[] | FileList): FileCheck;
28
+ /** One line a reader can act on, or empty when everything was taken. */
29
+ export declare function rejectionMessage(rejected: RejectedFile[]): string;
30
+ export declare function formatSize(bytes: number): string;
@@ -0,0 +1,80 @@
1
+ /**
2
+ * What a reader may hand Milton with a question.
3
+ *
4
+ * Session-scoped by design: these files are read for THIS conversation and
5
+ * never filed into a shelf, so the limits here are about what a browser can
6
+ * post and a model can read in one turn, not about a corpus.
7
+ */
8
+ export const MAX_FILES = 10;
9
+ export const MAX_BYTES = 20 * 1024 * 1024;
10
+ const DOCX = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document';
11
+ export const ACCEPTED_TYPES = [
12
+ 'image/png',
13
+ 'image/jpeg',
14
+ 'image/webp',
15
+ 'application/pdf',
16
+ DOCX,
17
+ 'text/plain',
18
+ 'text/markdown'
19
+ ];
20
+ /** Browsers disagree about a `.md` file's type — Safari says `text/markdown`,
21
+ * Chrome on some platforms says `''` — so the extension is checked too, and
22
+ * the `accept` attribute lists both forms for the same reason. */
23
+ const ACCEPTED_EXTENSIONS = ['.png', '.jpg', '.jpeg', '.webp', '.pdf', '.docx', '.txt', '.md'];
24
+ export const ACCEPT_ATTRIBUTE = [...ACCEPTED_TYPES, ...ACCEPTED_EXTENSIONS].join(',');
25
+ function typeAllowed(file) {
26
+ if (ACCEPTED_TYPES.includes(file.type))
27
+ return true;
28
+ const name = file.name.toLowerCase();
29
+ return ACCEPTED_EXTENSIONS.some((ext) => name.endsWith(ext));
30
+ }
31
+ /**
32
+ * Merge a drop or a picker's selection into the files already attached.
33
+ *
34
+ * Returns the WHOLE new list rather than only the additions, because the count
35
+ * limit is a property of the list and a caller that appended the return value
36
+ * would silently exceed it.
37
+ */
38
+ export function acceptFiles(existing, incoming) {
39
+ const accepted = [...existing];
40
+ const rejected = [];
41
+ for (const file of Array.from(incoming)) {
42
+ if (!typeAllowed(file)) {
43
+ rejected.push({ name: file.name, reason: 'type' });
44
+ continue;
45
+ }
46
+ if (file.size > MAX_BYTES) {
47
+ rejected.push({ name: file.name, reason: 'size' });
48
+ continue;
49
+ }
50
+ if (accepted.length >= MAX_FILES) {
51
+ rejected.push({ name: file.name, reason: 'count' });
52
+ continue;
53
+ }
54
+ if (accepted.some((f) => f.name === file.name && f.size === file.size))
55
+ continue;
56
+ accepted.push(file);
57
+ }
58
+ return { accepted, rejected };
59
+ }
60
+ /** One line a reader can act on, or empty when everything was taken. */
61
+ export function rejectionMessage(rejected) {
62
+ if (rejected.length === 0)
63
+ return '';
64
+ const reasons = {
65
+ type: "isn't a file type Milton can read",
66
+ size: 'is over 20 MB',
67
+ count: `won't fit — ${MAX_FILES} files is the limit`
68
+ };
69
+ const [first] = rejected;
70
+ const rest = rejected.length - 1;
71
+ return `${first.name} ${reasons[first.reason]}${rest ? `, and ${rest} more` : ''}.`;
72
+ }
73
+ export function formatSize(bytes) {
74
+ if (bytes < 1024)
75
+ return `${bytes} B`;
76
+ const kb = bytes / 1024;
77
+ if (kb < 1024)
78
+ return `${Math.round(kb)} KB`;
79
+ return `${(kb / 1024).toFixed(kb / 1024 < 10 ? 1 : 0)} MB`;
80
+ }
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Where an answer came from.
3
+ *
4
+ * Two sources, one shape. The library emits a `citations` event after the
5
+ * final assistant text, and that is the real one — it carries a document id,
6
+ * so a chip can open the document. Until it ships everywhere, the same chips
7
+ * are DERIVED from the "## Sources" block Milton already writes, which names
8
+ * a title and a section but no id: those chips render and read, and cannot
9
+ * open a pane. A derived citation is marked `derived` so the UI can tell.
10
+ */
11
+ export interface Citation {
12
+ n: number;
13
+ document_id: string;
14
+ title: string;
15
+ section?: string;
16
+ anchor?: string;
17
+ snippet?: string;
18
+ /** True when this came from the prose block rather than the wire event. */
19
+ derived?: boolean;
20
+ }
21
+ /** Inline `[n]` markers, in order of first appearance.
22
+ *
23
+ * Deliberately not a global "[digits]" match: `[1](…)` is a markdown link and
24
+ * `[1]: …` a link definition, and both appear in real answers.
25
+ *
26
+ * A following `[` is NOT excluded, and that is the point: `[1][2]` is how an
27
+ * answer cites two sources for one claim, and a lookahead that rejected it
28
+ * dropped the FIRST of the pair — `matchAll` resumes after the failed attempt
29
+ * rather than backtracking, so `[1]` was never seen at all and rendered as
30
+ * dead text beside a live `[2]`. */
31
+ export declare function citationMarkers(markdown: string): number[];
32
+ export interface SplitAnswer {
33
+ /** The prose, with any trailing Sources block removed. */
34
+ body: string;
35
+ /** Citations read out of that block; empty when there was none. */
36
+ citations: Citation[];
37
+ }
38
+ /**
39
+ * Split a trailing "## Sources" block off an answer.
40
+ *
41
+ * The block is rendered by the Sources list instead, so leaving it in the
42
+ * prose would print every source twice. A block that parses to nothing is left
43
+ * where it was rather than silently deleted.
44
+ */
45
+ export declare function splitSources(markdown: string): SplitAnswer;
46
+ /**
47
+ * The citations a turn should render.
48
+ *
49
+ * The wire event wins outright whenever it arrived — it is the same list with
50
+ * ids attached — so a turn never shows both.
51
+ */
52
+ export declare function resolveCitations(fromEvent: Citation[], fromProse: Citation[]): Citation[];
53
+ /** One addressable slice of a document — the granularity a citation names. */
54
+ export interface DocumentSection {
55
+ anchor: string;
56
+ heading: string;
57
+ text: string;
58
+ }
59
+ export interface LoadedDocument {
60
+ title: string;
61
+ sections: DocumentSection[];
62
+ }
63
+ /**
64
+ * How the pane reads a document. The host supplies it, because the host is the
65
+ * one holding the caller's session: the library's document read is
66
+ * authenticated, and a package that fetched it directly would be reaching past
67
+ * the app's own proxy with credentials it has no business holding.
68
+ */
69
+ export type LoadDocument = (documentId: string) => Promise<LoadedDocument>;
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Where an answer came from.
3
+ *
4
+ * Two sources, one shape. The library emits a `citations` event after the
5
+ * final assistant text, and that is the real one — it carries a document id,
6
+ * so a chip can open the document. Until it ships everywhere, the same chips
7
+ * are DERIVED from the "## Sources" block Milton already writes, which names
8
+ * a title and a section but no id: those chips render and read, and cannot
9
+ * open a pane. A derived citation is marked `derived` so the UI can tell.
10
+ */
11
+ /** Inline `[n]` markers, in order of first appearance.
12
+ *
13
+ * Deliberately not a global "[digits]" match: `[1](…)` is a markdown link and
14
+ * `[1]: …` a link definition, and both appear in real answers.
15
+ *
16
+ * A following `[` is NOT excluded, and that is the point: `[1][2]` is how an
17
+ * answer cites two sources for one claim, and a lookahead that rejected it
18
+ * dropped the FIRST of the pair — `matchAll` resumes after the failed attempt
19
+ * rather than backtracking, so `[1]` was never seen at all and rendered as
20
+ * dead text beside a live `[2]`. */
21
+ export function citationMarkers(markdown) {
22
+ const seen = [];
23
+ for (const match of markdown.matchAll(/\[(\d{1,3})\](?![(:])/g)) {
24
+ const n = Number(match[1]);
25
+ if (!seen.includes(n))
26
+ seen.push(n);
27
+ }
28
+ return seen;
29
+ }
30
+ const SOURCES_HEADING = /^[ \t]{0,3}#{1,6}[ \t]*sources[ \t]*:?[ \t]*$/im;
31
+ /**
32
+ * Split a trailing "## Sources" block off an answer.
33
+ *
34
+ * The block is rendered by the Sources list instead, so leaving it in the
35
+ * prose would print every source twice. A block that parses to nothing is left
36
+ * where it was rather than silently deleted.
37
+ */
38
+ export function splitSources(markdown) {
39
+ const heading = markdown.match(SOURCES_HEADING);
40
+ if (!heading || heading.index === undefined)
41
+ return { body: markdown, citations: [] };
42
+ // The block runs to the NEXT heading, not to the end of the answer. Cutting
43
+ // to the end loses anything Milton wrote after his sources — a closing note,
44
+ // a caveat — and loses it silently, which is the worst way to lose it.
45
+ const after = markdown.slice(heading.index + heading[0].length);
46
+ const next = after.search(/^[ \t]{0,3}#{1,6}[ \t]/m);
47
+ const block = next === -1 ? after : after.slice(0, next);
48
+ const tail = next === -1 ? '' : after.slice(next).trim();
49
+ const citations = parseSourceLines(block);
50
+ if (citations.length === 0)
51
+ return { body: markdown, citations: [] };
52
+ const before = markdown.slice(0, heading.index).trimEnd();
53
+ return { body: tail ? `${before}\n\n${tail}` : before, citations };
54
+ }
55
+ /** One list item per source; anything else in the block is ignored. */
56
+ function parseSourceLines(block) {
57
+ const out = [];
58
+ for (const raw of block.split('\n')) {
59
+ const line = raw.trim();
60
+ if (!line)
61
+ continue;
62
+ // A second heading ends the block — the Sources list is the tail of the
63
+ // answer, not a section anything follows.
64
+ if (/^#{1,6}\s/.test(line))
65
+ break;
66
+ const item = line.match(/^(?:[-*+]|\[?(\d{1,3})\]?[.)])\s+(.*)$/);
67
+ if (!item)
68
+ continue;
69
+ const numbered = item[1] ? Number(item[1]) : undefined;
70
+ const parsed = parseSource(item[2]);
71
+ if (!parsed.title)
72
+ continue;
73
+ out.push({
74
+ n: numbered ?? out.length + 1,
75
+ document_id: '',
76
+ derived: true,
77
+ ...parsed
78
+ });
79
+ }
80
+ return out;
81
+ }
82
+ /** `**Title** — Section`, `Title – Section`, `Title: Section`, or just a title. */
83
+ function parseSource(text) {
84
+ const plain = text
85
+ .replace(/\[(\d{1,3})\]\s*/, '')
86
+ .replace(/\*\*/g, '')
87
+ .replace(/[`*_]/g, '')
88
+ .trim();
89
+ const split = plain.match(/^(.+?)\s*(?:—|–|\s-\s|:)\s*(.+)$/);
90
+ if (!split)
91
+ return { title: plain };
92
+ return { title: split[1].trim(), section: split[2].trim() };
93
+ }
94
+ /**
95
+ * The citations a turn should render.
96
+ *
97
+ * The wire event wins outright whenever it arrived — it is the same list with
98
+ * ids attached — so a turn never shows both.
99
+ */
100
+ export function resolveCitations(fromEvent, fromProse) {
101
+ return fromEvent.length > 0 ? fromEvent : fromProse;
102
+ }
package/dist/client.d.ts CHANGED
@@ -7,6 +7,7 @@
7
7
  * backend sent, so the Console renders the real thing and a new Claude Code
8
8
  * event type needs no change on either side.
9
9
  */
10
+ import type { Citation } from './citations';
10
11
  /** One Claude Code stream event. Typed only where we branch on it. */
11
12
  export interface AgentEvent {
12
13
  type: string;
@@ -39,6 +40,8 @@ export interface AgentEvent {
39
40
  detail?: string;
40
41
  tools?: string[];
41
42
  model?: string;
43
+ /** `citations` frames only: the library's sources for the answer just sent. */
44
+ items?: Citation[];
42
45
  [key: string]: unknown;
43
46
  }
44
47
  export interface AskOptions {
@@ -47,6 +50,8 @@ export interface AskOptions {
47
50
  subtree?: string;
48
51
  /** Collections this question may see. Empty means all of them. */
49
52
  collections?: string[];
53
+ /** Files Milton reads for this question. Switches the request to multipart. */
54
+ files?: File[];
50
55
  signal?: AbortSignal;
51
56
  /** Where the ask lands. Each app mounts its own ask route. */
52
57
  endpoint?: string;
package/dist/client.js CHANGED
@@ -7,21 +7,47 @@
7
7
  * backend sent, so the Console renders the real thing and a new Claude Code
8
8
  * event type needs no change on either side.
9
9
  */
10
+ /**
11
+ * The request body for one ask.
12
+ *
13
+ * JSON when there are no files, multipart when there are — one endpoint, two
14
+ * encodings, because a `File` cannot cross a JSON body and base64 would double
15
+ * a 20 MB attachment on the wire for nothing. The multipart field names are
16
+ * the form convention (`collections[]`, `files[]`) rather than the JSON keys.
17
+ *
18
+ * `Content-Type` is deliberately absent for multipart: setting it by hand
19
+ * omits the boundary the browser generates, and the server then reads zero
20
+ * fields from a body that is on the wire perfectly.
21
+ */
22
+ function requestInit(options, signal) {
23
+ if (!options.files?.length) {
24
+ return {
25
+ method: 'POST',
26
+ headers: { 'Content-Type': 'application/json' },
27
+ credentials: 'include',
28
+ body: JSON.stringify({
29
+ question: options.question,
30
+ resume: options.resume ?? null,
31
+ subtree: options.subtree ?? '',
32
+ collections: options.collections ?? []
33
+ }),
34
+ signal
35
+ };
36
+ }
37
+ const form = new FormData();
38
+ form.append('question', options.question);
39
+ if (options.resume)
40
+ form.append('resume', options.resume);
41
+ for (const collection of options.collections ?? [])
42
+ form.append('collections[]', collection);
43
+ for (const file of options.files)
44
+ form.append('files[]', file, file.name);
45
+ return { method: 'POST', credentials: 'include', body: form, signal };
46
+ }
10
47
  /** Async-iterate the events of one question. */
11
48
  export async function* ask(options) {
12
49
  const doFetch = options.fetch ?? fetch;
13
- const response = await doFetch(options.endpoint ?? '/api/agent/ask', {
14
- method: 'POST',
15
- headers: { 'Content-Type': 'application/json' },
16
- credentials: 'include',
17
- body: JSON.stringify({
18
- question: options.question,
19
- resume: options.resume ?? null,
20
- subtree: options.subtree ?? '',
21
- collections: options.collections ?? []
22
- }),
23
- signal: options.signal
24
- });
50
+ const response = await doFetch(options.endpoint ?? '/api/agent/ask', requestInit(options, options.signal));
25
51
  if (!response.ok || !response.body) {
26
52
  yield { type: 'library_error', error: "Milton can't be reached right now." };
27
53
  return;