@poodle64/librarian 2026.9.7 → 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.
@@ -21,7 +21,13 @@ export interface Citation {
21
21
  /** Inline `[n]` markers, in order of first appearance.
22
22
  *
23
23
  * Deliberately not a global "[digits]" match: `[1](…)` is a markdown link and
24
- * `[1]: …` a link definition, and both appear in real answers. */
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]`. */
25
31
  export declare function citationMarkers(markdown: string): number[];
26
32
  export interface SplitAnswer {
27
33
  /** The prose, with any trailing Sources block removed. */
package/dist/citations.js CHANGED
@@ -11,10 +11,16 @@
11
11
  /** Inline `[n]` markers, in order of first appearance.
12
12
  *
13
13
  * Deliberately not a global "[digits]" match: `[1](…)` is a markdown link and
14
- * `[1]: …` a link definition, and both appear in real answers. */
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]`. */
15
21
  export function citationMarkers(markdown) {
16
22
  const seen = [];
17
- for (const match of markdown.matchAll(/\[(\d{1,3})\](?![(:[])/g)) {
23
+ for (const match of markdown.matchAll(/\[(\d{1,3})\](?![(:])/g)) {
18
24
  const n = Number(match[1]);
19
25
  if (!seen.includes(n))
20
26
  seen.push(n);
@@ -33,11 +39,18 @@ export function splitSources(markdown) {
33
39
  const heading = markdown.match(SOURCES_HEADING);
34
40
  if (!heading || heading.index === undefined)
35
41
  return { body: markdown, citations: [] };
36
- const block = markdown.slice(heading.index + heading[0].length);
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();
37
49
  const citations = parseSourceLines(block);
38
50
  if (citations.length === 0)
39
51
  return { body: markdown, citations: [] };
40
- return { body: markdown.slice(0, heading.index).trimEnd(), citations };
52
+ const before = markdown.slice(0, heading.index).trimEnd();
53
+ return { body: tail ? `${before}\n\n${tail}` : before, citations };
41
54
  }
42
55
  /** One list item per source; anything else in the block is ignored. */
43
56
  function parseSourceLines(block) {
@@ -15,7 +15,8 @@
15
15
  readerQuestion,
16
16
  segment,
17
17
  type Block,
18
- type Outcome
18
+ type Outcome,
19
+ type TextBlock
19
20
  } from '../../transcript.svelte';
20
21
  import {
21
22
  citationMarkers,
@@ -62,31 +63,34 @@
62
63
  const segments = $derived(segment(blocks));
63
64
  const lastIndex = $derived(segments.at(-1)?.index ?? -1);
64
65
 
65
- // The prose block a Sources list may be hiding at the end of is the last
66
- // one; splitting every text segment would eat a "## Sources" heading a
67
- // reader legitimately asked about mid-answer.
66
+ // An answer can arrive as SEVERAL text blocks with tool calls between them,
67
+ // so the Sources block is at the end of the LAST one. It is split off that
68
+ // block alone: splitting the joined answer and substituting the result back
69
+ // into one segment would print every earlier block twice.
70
+ //
71
+ // And only once the answer has settled — mid-stream the "## Sources" heading
72
+ // may be all that has arrived, with the list still to come.
73
+ const texts = $derived(segments.filter((s): s is TextBlock => s.kind === 'text'));
74
+ const lastTextIndex = $derived(texts.at(-1)?.index ?? -1);
75
+ const split = $derived(
76
+ running ? { body: '', citations: [] } : splitSources(texts.at(-1)?.text ?? '')
77
+ );
78
+ const sources = $derived(resolveCitations(citations, split.citations));
79
+
80
+ /** The answer as a reader would paste it: every block, Sources stripped. */
68
81
  const answer = $derived(
69
- segments
70
- .filter((s) => s.kind === 'text')
71
- .map((s) => s.text)
82
+ texts
83
+ .map((s) => (s.index === lastTextIndex && split.citations.length > 0 ? split.body : s.text))
72
84
  .join('\n\n')
73
85
  );
74
- const split = $derived(running ? { body: '', citations: [] } : splitSources(answer));
75
- const sources = $derived(resolveCitations(citations, split.citations));
86
+
76
87
  // Only a number the prose actually marks becomes a chip; a source list may
77
88
  // legitimately carry an entry the answer never points at inline.
78
89
  const numbers = $derived(
79
90
  new Set(citationMarkers(answer).filter((n) => sources.some((c) => c.n === n)))
80
91
  );
81
92
 
82
- function prose(text: string, isLast: boolean): string {
83
- // Only the last text segment can carry the trailing Sources block, and
84
- // only once the answer has settled — mid-stream the heading may be all
85
- // that has arrived.
86
- return isLast && split.citations.length > 0 ? split.body : text;
87
- }
88
-
89
- const hasAnswer = $derived(segments.some((s) => s.kind === 'text'));
93
+ const hasAnswer = $derived(texts.length > 0);
90
94
  // A failed turn settles too, and "Ask again" is the one thing a reader wants
91
95
  // from it — there is just nothing to copy.
92
96
  const settled = $derived(!running && (hasAnswer || Boolean(outcome?.error)));
@@ -124,7 +128,9 @@
124
128
  {:else}
125
129
  <div class="max-w-[72ch] min-w-0">
126
130
  <Markdown
127
- content={prose(seg.text, seg.index === lastIndex)}
131
+ content={seg.index === lastTextIndex && split.citations.length > 0
132
+ ? split.body
133
+ : seg.text}
128
134
  streaming={running && seg.index === lastIndex}
129
135
  {collectionNames}
130
136
  citationNumbers={numbers}
@@ -101,8 +101,11 @@
101
101
  });
102
102
 
103
103
  // Focus comes back once Milton is done, so a reader can keep going without
104
- // reaching for the mouse. Focusing at send is pointless — the box is
105
- // disabled a frame later — so the transition out of `running` is the hook.
104
+ // reaching for the mouse. It has to be the transition OUT of `running`: a
105
+ // disabled element cannot hold focus, so the focus call in `submit()` below
106
+ // survives only for a host that never sets `running` — measured, focus lands
107
+ // on `body` the frame the box disables and comes back here.
108
+ //
106
109
  // Plain, not `$state`: read and written in the same effect, which would
107
110
  // otherwise re-trigger itself forever.
108
111
  let wasRunning = false;
@@ -113,6 +113,7 @@
113
113
  onscroll={scrolled}
114
114
  role="log"
115
115
  aria-live="polite"
116
+ aria-busy={running}
116
117
  aria-label="Conversation with Milton"
117
118
  class="min-h-0 flex-1 overflow-y-auto overscroll-contain"
118
119
  >
@@ -112,8 +112,12 @@
112
112
  <aside
113
113
  style="--pane-width: {width}px"
114
114
  aria-label="Source document"
115
- class="bg-surface-1 border-border fixed inset-x-0 bottom-0 z-40 flex h-[80svh] flex-col rounded-t-2xl border-t shadow-2xl lg:static lg:h-auto lg:w-[var(--pane-width)] lg:shrink-0 lg:rounded-none lg:border-t-0 lg:border-l lg:shadow-none"
115
+ class="bg-surface-1 border-border fixed inset-x-0 bottom-0 z-40 flex h-[80svh] flex-col rounded-t-2xl border-t shadow-2xl lg:relative lg:h-auto lg:w-[var(--pane-width)] lg:shrink-0 lg:rounded-none lg:border-t-0 lg:border-l lg:shadow-none"
116
116
  >
117
+ <!-- The drag handle. `lg:relative` on the pane above, not `lg:static`, is
118
+ what this depends on: an absolutely-positioned child needs a positioned
119
+ ancestor, and without one the handle lands somewhere else entirely and
120
+ the pane is silently not resizable. Measured — the drag moved nothing. -->
117
121
  <!-- svelte-ignore a11y_no_static_element_interactions -- a pointer-only
118
122
  affordance for a width that has a keyboard-independent default; the
119
123
  pane is fully usable without ever touching it. -->
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@poodle64/librarian",
3
- "version": "2026.9.7",
3
+ "version": "2026.9.8",
4
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
5
  "type": "module",
6
6
  "license": "MIT",