@poodle64/librarian 2026.9.9 → 2026.9.11

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/README.md CHANGED
@@ -28,7 +28,7 @@ src/lib/
28
28
  markdown/ sanitised, streaming-safe markdown + highlighting
29
29
  activity-group/ a whole investigation, as one quiet line
30
30
  tool-row/ one tool call
31
- thinking-row/ one thinking block
31
+ thinking-row/ one thought, or one line of between-tool narration
32
32
  working/ the pre-first-token "something is happening" indicator
33
33
  ```
34
34
 
@@ -231,6 +231,23 @@ one asks it through `onsuggest` and takes the whole row with it — the moment
231
231
  between the click and the new turn arriving is otherwise long enough to ask a
232
232
  second question by mistake.
233
233
 
234
+ ### Where the answer starts
235
+
236
+ Milton narrates between tool calls — "Let me also check whether…" — and the
237
+ caller stream gives that nowhere to arrive: it carries no `thinking` blocks
238
+ at all, so narration is an ordinary `text` block, identical to the answer
239
+ except in POSITION. `segment()` reads that position: a text block with any
240
+ tool call still to come in the turn is narration and folds into the activity
241
+ group as a thinking-shaped row; the run of text after the LAST tool call is
242
+ the answer. While a turn streams the judgement is provisional — a block that
243
+ is currently last renders as prose, and a tool call arriving after it
244
+ re-homes it — which is why `segment()` is pure and re-derived per event
245
+ rather than deciding once.
246
+
247
+ The cost is an answer Milton interrupts to go back to the shelf: its first
248
+ half folds away. Position is the only signal the stream gives, and a rule
249
+ read off the prose itself would be unexplainable the first time it misfired.
250
+
234
251
  ### Changing the words
235
252
 
236
253
  Every user-visible string this package renders lives in
@@ -271,15 +288,16 @@ pnpm run test # build + vitest
271
288
  pnpm run screenshots # the state grid, real engine (see below)
272
289
  ```
273
290
 
274
- `docs/screenshots/` is ten states x three widths x both themes, taken by
291
+ `docs/screenshots/` is eleven states x three widths x both themes, taken by
275
292
  `scripts/screenshots.mjs` against the console's `/librarian` lab route
276
293
  running from its own static build. The same script asserts what a screenshot
277
294
  cannot: that nothing scrolls sideways at any width, that the source pane
278
295
  opens and closes from the keyboard with focus returning to the chip and is
279
296
  really draggable, that the scope statement folds once there is a
280
- conversation over it and reopens from that line, and that a follow-up chip
281
- asks its question and takes the rest of the row with it. It exits non-zero
282
- on any of them.
297
+ conversation over it and reopens from that line, that a follow-up chip asks
298
+ its question and takes the rest of the row with it, and that Milton's
299
+ between-tool narration is nowhere in the answer prose before the activity
300
+ line is opened. It exits non-zero on any of them.
283
301
 
284
302
  ```bash
285
303
  pnpm --filter @poodle64/console run build
package/dist/citations.js CHANGED
@@ -119,7 +119,10 @@ export function formatVerified(iso) {
119
119
  const [, year, month, day] = match;
120
120
  const index = Number(month) - 1;
121
121
  const date = Number(day);
122
- if (index < 0 || index > 11 || date < 1 || date > 31)
122
+ // Round-tripped rather than bounds-checked: 1-31 admits "31 Apr 2026",
123
+ // and a mark that shows a day that never happened is worse than no mark.
124
+ const made = new Date(Date.UTC(Number(year), index, date));
125
+ if (made.getUTCMonth() !== index || made.getUTCDate() !== date)
123
126
  return null;
124
127
  return `${date} ${MONTHS[index]} ${year}`;
125
128
  }
@@ -7,6 +7,11 @@
7
7
  to mention. So there is exactly one row in every state — "Working…" while it
8
8
  runs, a count of what was done once it settles — and the steps are behind a
9
9
  disclosure for the reader who wants them.
10
+
11
+ That commentary is a step in here too. It reaches the caller as an ordinary
12
+ `text` block, not a `thinking` one, so `segment()` re-homes any text with a
13
+ tool call still to come into this group; it renders as a ThinkingRow beside
14
+ the tools, folded like the rest.
10
15
  -->
11
16
  <script lang="ts">
12
17
  import ChevronRightIcon from '@lucide/svelte/icons/chevron-right';
@@ -105,9 +105,28 @@
105
105
  );
106
106
 
107
107
  const hasAnswer = $derived(texts.length > 0);
108
+
109
+ /**
110
+ * What went wrong, if anything did — in words, whether or not the run gave
111
+ * any.
112
+ *
113
+ * Two different failures reach a turn and only one of them carries a
114
+ * message. `library_error` (the stream never opened) sets `error`; a run
115
+ * that opened and then failed sets `is_error` on its terminal frame and
116
+ * says nothing at all — Claude Code's own `error_max_turns` and
117
+ * `error_during_execution` are exactly that shape. Reading only `error`
118
+ * left the second kind rendering as a clean, complete answer: no banner,
119
+ * a duration badge, and — worse — the "he holds nothing on this" line,
120
+ * which is a claim about the shelf made off a run that never finished
121
+ * looking.
122
+ */
123
+ const failure = $derived(
124
+ outcome?.error ?? (outcome?.isError ? words.answerFailed : null)
125
+ );
126
+
108
127
  // A failed turn settles too, and "Ask again" is the one thing a reader wants
109
128
  // from it — there is just nothing to copy.
110
- const settled = $derived(!running && (hasAnswer || Boolean(outcome?.error)));
129
+ const settled = $derived(!running && (hasAnswer || Boolean(failure)));
111
130
 
112
131
  /**
113
132
  * Milton answered and cited nothing, and we WATCHED him do it.
@@ -120,7 +139,7 @@
120
139
  * here knows whether there were any.
121
140
  */
122
141
  const notHeld = $derived(
123
- settled && hasAnswer && sources.length === 0 && Boolean(outcome) && !outcome?.error
142
+ settled && hasAnswer && sources.length === 0 && Boolean(outcome) && !failure
124
143
  );
125
144
 
126
145
  // A follow-up is offered once. Clicking it asks the question, which puts a
@@ -248,12 +267,12 @@
248
267
  </section>
249
268
  {/if}
250
269
 
251
- {#if outcome?.error}
270
+ {#if failure}
252
271
  <p
253
272
  class="border-status-error/40 bg-status-error/10 text-foreground max-w-[72ch] rounded-lg border px-3 py-2 text-sm"
254
273
  role="alert"
255
274
  >
256
- {outcome.error}
275
+ {failure}
257
276
  </p>
258
277
  {/if}
259
278
 
@@ -281,7 +300,7 @@
281
300
  <span>{words.askAgain}</span>
282
301
  </button>
283
302
  {/if}
284
- {#if outcome && !outcome.error}
303
+ {#if outcome && !failure}
285
304
  <span class="pl-1 font-mono text-xs tabular-nums opacity-70">
286
305
  {((outcome.durationMs ?? 0) / 1000).toFixed(1)}s
287
306
  </span>
@@ -1,9 +1,20 @@
1
+ <!--
2
+ One line of Milton's working-out, behind a chevron.
3
+
4
+ Two different blocks render as this row and deliberately read the same. A
5
+ real `thinking` block is one; the other is a `text` block Milton wrote
6
+ BETWEEN two tool calls — "Let me also check whether…" — which the caller
7
+ stream gives no way to tell from the answer except by position, and which
8
+ read as answer prose on production until `segment()` started re-homing it
9
+ here. To a reader both are the same thing: what he was working through, not
10
+ what he concluded. So both fold, and both fold by default.
11
+ -->
1
12
  <script lang="ts">
2
13
  import ChevronRightIcon from '@lucide/svelte/icons/chevron-right';
3
- import type { ThinkingBlock } from '../../transcript.svelte';
14
+ import type { TextBlock, ThinkingBlock } from '../../transcript.svelte';
4
15
 
5
16
  interface Props {
6
- block: ThinkingBlock;
17
+ block: ThinkingBlock | TextBlock;
7
18
  active: boolean;
8
19
  }
9
20
 
@@ -1,6 +1,6 @@
1
- import type { ThinkingBlock } from '../../transcript.svelte';
1
+ import type { TextBlock, ThinkingBlock } from '../../transcript.svelte';
2
2
  interface Props {
3
- block: ThinkingBlock;
3
+ block: ThinkingBlock | TextBlock;
4
4
  active: boolean;
5
5
  }
6
6
  declare const ThinkingRow: import("svelte").Component<Props, {}, "">;
package/dist/copy.d.ts CHANGED
@@ -19,6 +19,8 @@ export interface LibrarianCopy {
19
19
  notVerified: string;
20
20
  /** Shown under an answer that finished without citing anything. */
21
21
  notHeld: string;
22
+ /** Shown when the run itself ended in error and said nothing about why. */
23
+ answerFailed: string;
22
24
  /** Heading over the list of what an answer cited. */
23
25
  sources: string;
24
26
  /** Heading over the follow-up questions the librarian offered. */
package/dist/copy.js CHANGED
@@ -20,6 +20,9 @@ export const DEFAULT_COPY = {
20
20
  // reading it has no model of any of those, and the room's own instruction
21
21
  // to Milton forbids him naming them either.
22
22
  notHeld: 'Milton answered this one without a source. He may not hold a document that covers it.',
23
+ // A run can end `is_error` carrying no message at all, and a turn that
24
+ // simply stops is the one thing a reader must not have to guess about.
25
+ answerFailed: 'Milton stopped before he finished this one.',
23
26
  sources: 'Sources',
24
27
  suggestions: 'Ask next',
25
28
  scope: 'What Milton answers from',
@@ -91,8 +91,11 @@ export declare class Transcript {
91
91
  apply(event: AgentEvent): void;
92
92
  }
93
93
  export interface ActivityStep {
94
- /** The block this step stands for; a repeated step keeps the FIRST. */
95
- block: ToolBlock | ThinkingBlock;
94
+ /** The block this step stands for; a repeated step keeps the FIRST.
95
+ *
96
+ * A `text` block here is interstitial narration, not the answer — see
97
+ * `segment()`. */
98
+ block: Block;
96
99
  /** How many identical consecutive steps collapsed into this one. */
97
100
  repeats: number;
98
101
  }
@@ -124,7 +127,7 @@ export interface Turn {
124
127
  /**
125
128
  * Fold a turn's flat block list into what a reader should actually see.
126
129
  *
127
- * Two problems this solves, both reported off a real transcript:
130
+ * Three problems this solves, all reported off a real transcript:
128
131
  *
129
132
  * 1. Fifteen tool rows stood between the question and the first word of the
130
133
  * answer, so the answer had to be scrolled to. Contiguous activity becomes
@@ -132,6 +135,25 @@ export interface Turn {
132
135
  * 2. "Reading pspf guidelines 2026" appeared five times in a row — five pages
133
136
  * of one document, which is one act of reading to a human. Consecutive
134
137
  * steps with the same label collapse to one row carrying a count.
138
+ * 3. Milton's between-tool narration was rendering unfolded, as answer prose.
139
+ * The caller stream carries no `thinking` blocks at all — measured on
140
+ * production 11/09/2026 — so "Let me also check whether…" arrives as an
141
+ * ordinary `text` block, indistinguishable from the answer except by
142
+ * POSITION. A text block with any tool call still to come in this turn is
143
+ * narration; only the run of text after the LAST tool call is the answer.
144
+ * Narration folds into the activity group as a thinking-shaped step.
145
+ *
146
+ * That third rule is provisional while a turn streams, and deliberately so: a
147
+ * text block that is currently last IS the answer as far as anything can know,
148
+ * and renders as prose. The tool call that arrives after it re-homes it into
149
+ * the group. Nothing here holds state to make that work — `segment` is pure
150
+ * and re-derived on every event, so re-homing is just the next call returning
151
+ * a different shape.
152
+ *
153
+ * The cost of the rule is a real answer that Milton interrupts to go back to
154
+ * the shelf: its first half folds away. That is the trade taken knowingly —
155
+ * the position of a block is the only signal the stream gives, and a rule read
156
+ * off the prose itself would be unexplainable the first time it misfired.
135
157
  */
136
158
  export declare function segment(blocks: Block[]): Segment[];
137
159
  /** One line describing a whole investigation, for the collapsed state.
@@ -267,7 +267,7 @@ function renderResult(content) {
267
267
  /**
268
268
  * Fold a turn's flat block list into what a reader should actually see.
269
269
  *
270
- * Two problems this solves, both reported off a real transcript:
270
+ * Three problems this solves, all reported off a real transcript:
271
271
  *
272
272
  * 1. Fifteen tool rows stood between the question and the first word of the
273
273
  * answer, so the answer had to be scrolled to. Contiguous activity becomes
@@ -275,20 +275,44 @@ function renderResult(content) {
275
275
  * 2. "Reading pspf guidelines 2026" appeared five times in a row — five pages
276
276
  * of one document, which is one act of reading to a human. Consecutive
277
277
  * steps with the same label collapse to one row carrying a count.
278
+ * 3. Milton's between-tool narration was rendering unfolded, as answer prose.
279
+ * The caller stream carries no `thinking` blocks at all — measured on
280
+ * production 11/09/2026 — so "Let me also check whether…" arrives as an
281
+ * ordinary `text` block, indistinguishable from the answer except by
282
+ * POSITION. A text block with any tool call still to come in this turn is
283
+ * narration; only the run of text after the LAST tool call is the answer.
284
+ * Narration folds into the activity group as a thinking-shaped step.
285
+ *
286
+ * That third rule is provisional while a turn streams, and deliberately so: a
287
+ * text block that is currently last IS the answer as far as anything can know,
288
+ * and renders as prose. The tool call that arrives after it re-homes it into
289
+ * the group. Nothing here holds state to make that work — `segment` is pure
290
+ * and re-derived on every event, so re-homing is just the next call returning
291
+ * a different shape.
292
+ *
293
+ * The cost of the rule is a real answer that Milton interrupts to go back to
294
+ * the shelf: its first half folds away. That is the trade taken knowingly —
295
+ * the position of a block is the only signal the stream gives, and a rule read
296
+ * off the prose itself would be unexplainable the first time it misfired.
278
297
  */
279
298
  export function segment(blocks) {
280
299
  const out = [];
281
300
  let current = null;
282
- for (const block of blocks) {
283
- if (block.kind === 'text') {
301
+ let lastTool = -1;
302
+ for (let i = 0; i < blocks.length; i += 1)
303
+ if (blocks[i].kind === 'tool')
304
+ lastTool = i;
305
+ for (const [i, block] of blocks.entries()) {
306
+ if (block.kind === 'text' && i > lastTool) {
284
307
  current = null;
285
308
  out.push(block);
286
309
  continue;
287
310
  }
288
311
  // A thinking block with no text is a row whose chevron opens on nothing.
289
312
  // Claude Code's thinking display defaults to "omitted", so most arrive
290
- // empty — rendering them is worse than dropping them.
291
- if (block.kind === 'thinking' && !block.text.trim())
313
+ // empty — rendering them is worse than dropping them. An empty narration
314
+ // block is the same row, for the same reason.
315
+ if (block.kind !== 'tool' && !block.text.trim())
292
316
  continue;
293
317
  if (!current) {
294
318
  current = {
@@ -318,6 +342,10 @@ function sameStep(a, b) {
318
342
  return false;
319
343
  if (a.kind === 'thinking')
320
344
  return true;
345
+ // Two narration sentences are two things Milton said; collapsing them to
346
+ // one row with a count would lose the second one entirely.
347
+ if (a.kind === 'text')
348
+ return false;
321
349
  const left = describe(a);
322
350
  const right = describe(b);
323
351
  return left.verb === right.verb && left.object === right.object;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@poodle64/librarian",
3
- "version": "2026.9.9",
3
+ "version": "2026.9.11",
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",