@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.
- package/dist/citations.d.ts +7 -1
- package/dist/citations.js +17 -4
- package/dist/components/agent-transcript/agent-transcript.svelte +24 -18
- package/dist/components/composer/composer.svelte +5 -2
- package/dist/components/conversation/conversation.svelte +1 -0
- package/dist/components/document-pane/document-pane.svelte +5 -1
- package/package.json +1 -1
package/dist/citations.d.ts
CHANGED
|
@@ -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)) {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
66
|
-
//
|
|
67
|
-
//
|
|
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
|
-
|
|
70
|
-
.
|
|
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
|
-
|
|
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
|
-
|
|
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={
|
|
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.
|
|
105
|
-
// disabled
|
|
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;
|
|
@@ -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:
|
|
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.
|
|
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",
|