@poodle64/librarian 2026.9.24 → 2026.9.26
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 +40 -0
- package/dist/chat.svelte.d.ts +10 -6
- package/dist/chat.svelte.js +15 -10
- package/dist/client.d.ts +13 -0
- package/dist/client.js +4 -1
- package/dist/components/agent-transcript/agent-transcript.svelte +17 -4
- package/dist/components/agent-transcript/agent-transcript.svelte.d.ts +9 -1
- package/dist/components/composer/composer.svelte +155 -1
- package/dist/components/composer/composer.svelte.d.ts +17 -1
- package/dist/components/composer/index.d.ts +1 -0
- package/dist/components/conversation/conversation.svelte +19 -1
- package/dist/components/conversation/conversation.svelte.d.ts +5 -0
- package/dist/copy.d.ts +12 -0
- package/dist/copy.js +6 -0
- package/dist/transcript.svelte.d.ts +23 -1
- package/dist/transcript.svelte.js +25 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -252,6 +252,46 @@ artefact })` says that kind opens, on a live turn and a reopened one alike:
|
|
|
252
252
|
`{ title: 'Briefing', isAnswer: true }` cards it and opens its prose in the
|
|
253
253
|
column. `again()` asks it as the same kind.
|
|
254
254
|
|
|
255
|
+
### Depth: Quick or Thorough, and what it cost
|
|
256
|
+
|
|
257
|
+
`Composer`'s `depth` is optional and bindable, and entirely opt-in: omit it
|
|
258
|
+
and the composer renders exactly as it always has. Bind it, the host's own
|
|
259
|
+
`$state<Depth>('quick')`, and the switch always renders, both sides always
|
|
260
|
+
enabled. The operator's ruling (30/09/2026) is that once a room offers the
|
|
261
|
+
choice, it is never disabled, hidden, greyed out, or explained by naming a
|
|
262
|
+
model. `depth` is `'quick' | 'thorough'`, exported from
|
|
263
|
+
`@poodle64/librarian/composer` and `@poodle64/librarian/client`.
|
|
264
|
+
|
|
265
|
+
```svelte
|
|
266
|
+
<Composer bind:value={chat.draft} bind:depth onsubmit={() => chat.ask(undefined, undefined, depth)} ... />
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
`chat.ask(question?, kind?, depth?)` carries it to `client.ask()`'s `depth`,
|
|
270
|
+
`StoredTurn.depth` reads it back, and `again()` keeps it: the same shape as
|
|
271
|
+
`kind`, alongside it rather than replacing it. A host whose route does not
|
|
272
|
+
read `depth` yet is unaffected, since the field rides along and is ignored.
|
|
273
|
+
|
|
274
|
+
Under a settled answer, `AgentTranscript`'s footer folds the turn's `depth`
|
|
275
|
+
and `outcome.costUsd` into the ONE line that already showed the duration:
|
|
276
|
+
`Quick · 56 s · about A$0.34`. Both are opt-in the same way: pass `depth` to
|
|
277
|
+
`Conversation`/`AgentTranscript` (it already has it, from `Turn.depth`) and a
|
|
278
|
+
`formatCost(usd) => string` to price it in the host's own currency and words
|
|
279
|
+
("about A$0.34"; cadmus converts USD to AUD with its own setting). Without
|
|
280
|
+
`depth` the line renders exactly as before (`56.3s`). Without `formatCost`,
|
|
281
|
+
or where `costUsd` is `0` (a local model), it shows depth and duration only,
|
|
282
|
+
never a raw USD figure and never which model answered.
|
|
283
|
+
|
|
284
|
+
Past `turnLimit` turns (`Composer`'s prop, 8 by default), a quiet banner
|
|
285
|
+
appears above the box, "This conversation is getting long. A new question
|
|
286
|
+
keeps Milton quick." with a "New question" button, because a follow-up
|
|
287
|
+
re-reads the whole conversation, so each one costs more than the last.
|
|
288
|
+
`turnCount` and `onnewquestion` are both required for it to render; either
|
|
289
|
+
missing and the composer stays as it is today.
|
|
290
|
+
|
|
291
|
+
None of this prices anything before asking, and none of it names a model.
|
|
292
|
+
That is the operator's ruling on what a colleague sees, and the package
|
|
293
|
+
never puts a control in front of it.
|
|
294
|
+
|
|
255
295
|
The transport is the app's own client over its routes, and the shapes are
|
|
256
296
|
the routes' own bodies (`ConversationSummary`, `ConversationRead`,
|
|
257
297
|
`StoredTurn`, `Quota`), so the stamped slice returns them as they are and
|
package/dist/chat.svelte.d.ts
CHANGED
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
* routes, and its shapes are the routes' own bodies, so a stamped app passes
|
|
20
20
|
* what its client returns straight through.
|
|
21
21
|
*/
|
|
22
|
-
import { type AgentEvent } from './client';
|
|
22
|
+
import { type AgentEvent, type Depth } from './client';
|
|
23
23
|
import type { Citation } from './citations';
|
|
24
24
|
import { type Artefact, type Turn } from './transcript.svelte';
|
|
25
25
|
/** One conversation in the list. */
|
|
@@ -43,6 +43,8 @@ export interface StoredTurn {
|
|
|
43
43
|
citations?: Citation[];
|
|
44
44
|
/** What the host asked for, when it was not a plain question. */
|
|
45
45
|
kind?: string | null;
|
|
46
|
+
/** How hard the persona worked on this one. */
|
|
47
|
+
depth?: Depth | null;
|
|
46
48
|
}
|
|
47
49
|
/** A conversation reopened. */
|
|
48
50
|
export interface ConversationRead extends ConversationSummary {
|
|
@@ -73,6 +75,8 @@ export interface AskRequest {
|
|
|
73
75
|
signal: AbortSignal;
|
|
74
76
|
/** What the host asked for, when it is not a plain question: `client.ask()`'s `kind`. */
|
|
75
77
|
kind?: string;
|
|
78
|
+
/** How hard the persona should work on this one: `client.ask()`'s `depth`. */
|
|
79
|
+
depth?: Depth;
|
|
76
80
|
}
|
|
77
81
|
/**
|
|
78
82
|
* A room's routes, as the host's own client reaches them. Every method but
|
|
@@ -171,13 +175,13 @@ export declare class Chat {
|
|
|
171
175
|
new(): void;
|
|
172
176
|
/**
|
|
173
177
|
* Ask `question`, or what is in the box, as a `kind` of turn when it is not
|
|
174
|
-
* a plain question
|
|
175
|
-
*
|
|
176
|
-
* go back into the box.
|
|
178
|
+
* a plain question, at a `depth` when the host offers the choice. Resolves
|
|
179
|
+
* once the turn settles: true if it was asked, false if it was refused,
|
|
180
|
+
* here or by the server, in which case typed words go back into the box.
|
|
177
181
|
*/
|
|
178
|
-
ask(question?: string, kind?: string): Promise<boolean>;
|
|
182
|
+
ask(question?: string, kind?: string, depth?: Depth): Promise<boolean>;
|
|
179
183
|
/** The last question again, as a new turn: the conversation is append-only,
|
|
180
|
-
* because the agent's transcript is. */
|
|
184
|
+
* because the agent's transcript is. Same kind, same depth. */
|
|
181
185
|
again(): Promise<boolean>;
|
|
182
186
|
/** Stop the answer being written, wherever it is being written. */
|
|
183
187
|
stop(): Promise<void>;
|
package/dist/chat.svelte.js
CHANGED
|
@@ -145,11 +145,11 @@ export class Chat {
|
|
|
145
145
|
}
|
|
146
146
|
/**
|
|
147
147
|
* Ask `question`, or what is in the box, as a `kind` of turn when it is not
|
|
148
|
-
* a plain question
|
|
149
|
-
*
|
|
150
|
-
* go back into the box.
|
|
148
|
+
* a plain question, at a `depth` when the host offers the choice. Resolves
|
|
149
|
+
* once the turn settles: true if it was asked, false if it was refused,
|
|
150
|
+
* here or by the server, in which case typed words go back into the box.
|
|
151
151
|
*/
|
|
152
|
-
async ask(question, kind) {
|
|
152
|
+
async ask(question, kind, depth) {
|
|
153
153
|
const typed = question === undefined;
|
|
154
154
|
const text = (question ?? this.draft).trim();
|
|
155
155
|
if (!text)
|
|
@@ -163,13 +163,15 @@ export class Chat {
|
|
|
163
163
|
this.draft = '';
|
|
164
164
|
this.files = [];
|
|
165
165
|
}
|
|
166
|
-
return this.#ask(text, files, typed, kind);
|
|
166
|
+
return this.#ask(text, files, typed, kind, depth);
|
|
167
167
|
}
|
|
168
168
|
/** The last question again, as a new turn: the conversation is append-only,
|
|
169
|
-
* because the agent's transcript is. */
|
|
169
|
+
* because the agent's transcript is. Same kind, same depth. */
|
|
170
170
|
again() {
|
|
171
171
|
const last = this.turns.at(-1);
|
|
172
|
-
return last?.question
|
|
172
|
+
return last?.question
|
|
173
|
+
? this.ask(last.question, last.kind, last.depth)
|
|
174
|
+
: Promise.resolve(false);
|
|
173
175
|
}
|
|
174
176
|
/** Stop the answer being written, wherever it is being written. */
|
|
175
177
|
async stop() {
|
|
@@ -301,7 +303,7 @@ export class Chat {
|
|
|
301
303
|
this.answering = null;
|
|
302
304
|
this.waiting = false;
|
|
303
305
|
}
|
|
304
|
-
async #ask(text, files, typed, kind) {
|
|
306
|
+
async #ask(text, files, typed, kind, depth) {
|
|
305
307
|
const room = this.#room;
|
|
306
308
|
const mine = this.#generation;
|
|
307
309
|
const stream = { controller: new AbortController(), stopWanted: false };
|
|
@@ -315,6 +317,7 @@ export class Chat {
|
|
|
315
317
|
citations: [],
|
|
316
318
|
suggestions: [],
|
|
317
319
|
kind,
|
|
320
|
+
depth,
|
|
318
321
|
artefact: this.#card(kind)
|
|
319
322
|
});
|
|
320
323
|
const live = this.#turns[this.#turns.length - 1];
|
|
@@ -329,7 +332,8 @@ export class Chat {
|
|
|
329
332
|
files,
|
|
330
333
|
resume: this.conversationId,
|
|
331
334
|
signal: stream.controller.signal,
|
|
332
|
-
kind
|
|
335
|
+
kind,
|
|
336
|
+
depth
|
|
333
337
|
})) {
|
|
334
338
|
const named = event.type === 'system' && event.subtype === 'init' ? event.session_id : undefined;
|
|
335
339
|
if (stream.stopWanted) {
|
|
@@ -551,7 +555,8 @@ function storedTurn(conversation, index, stored) {
|
|
|
551
555
|
blocks: stored.answer ? [{ kind: 'text', index: 0, text: stored.answer }] : [],
|
|
552
556
|
outcome: null,
|
|
553
557
|
citations: stored.citations ?? [],
|
|
554
|
-
kind: stored.kind ?? undefined
|
|
558
|
+
kind: stored.kind ?? undefined,
|
|
559
|
+
depth: stored.depth ?? undefined
|
|
555
560
|
};
|
|
556
561
|
}
|
|
557
562
|
function epoch(value) {
|
package/dist/client.d.ts
CHANGED
|
@@ -8,6 +8,15 @@
|
|
|
8
8
|
* event type needs no change on either side.
|
|
9
9
|
*/
|
|
10
10
|
import type { Citation } from './citations';
|
|
11
|
+
/**
|
|
12
|
+
* How hard the persona works on one question: a reading budget and a model,
|
|
13
|
+
* both the operator's to set per deployment, never the colleague's concern.
|
|
14
|
+
* `quick` is always the default; `thorough` always means something — a
|
|
15
|
+
* bigger budget on the strongest model the room allows, even where that
|
|
16
|
+
* model is a local one — so a host never has a state where the choice is
|
|
17
|
+
* greyed out or explained away.
|
|
18
|
+
*/
|
|
19
|
+
export type Depth = 'quick' | 'thorough';
|
|
11
20
|
/** A Claude Code message, whole. `content` is a plain string only on a user frame. */
|
|
12
21
|
export interface AgentMessage {
|
|
13
22
|
id?: string;
|
|
@@ -95,6 +104,10 @@ export interface AskOptions {
|
|
|
95
104
|
* (`'briefing'`) and interprets what comes back accordingly — the wire
|
|
96
105
|
* vocabulary is the host's own, never a fixed set here. */
|
|
97
106
|
kind?: string;
|
|
107
|
+
/** How hard the persona should work on this one. Absent asks for
|
|
108
|
+
* whatever the route does by default, so a host that never reads
|
|
109
|
+
* `Composer`'s `depth` sends exactly what it always has. */
|
|
110
|
+
depth?: Depth;
|
|
98
111
|
signal?: AbortSignal;
|
|
99
112
|
/** Where the ask lands. Each app mounts its own ask route. */
|
|
100
113
|
endpoint?: string;
|
package/dist/client.js
CHANGED
|
@@ -34,7 +34,8 @@ function requestInit(options, signal) {
|
|
|
34
34
|
resume: options.resume ?? null,
|
|
35
35
|
subtree: options.subtree ?? '',
|
|
36
36
|
collections: options.collections ?? [],
|
|
37
|
-
kind: options.kind
|
|
37
|
+
kind: options.kind,
|
|
38
|
+
depth: options.depth
|
|
38
39
|
}),
|
|
39
40
|
signal
|
|
40
41
|
};
|
|
@@ -49,6 +50,8 @@ function requestInit(options, signal) {
|
|
|
49
50
|
form.append('files[]', file, file.name);
|
|
50
51
|
if (options.kind)
|
|
51
52
|
form.append('kind', options.kind);
|
|
53
|
+
if (options.depth)
|
|
54
|
+
form.append('depth', options.depth);
|
|
52
55
|
return { method: 'POST', credentials: 'include', body: form, signal };
|
|
53
56
|
}
|
|
54
57
|
/**
|
|
@@ -17,12 +17,14 @@
|
|
|
17
17
|
import CopyIcon from '@lucide/svelte/icons/copy';
|
|
18
18
|
import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw';
|
|
19
19
|
import {
|
|
20
|
+
outcomeLine,
|
|
20
21
|
readerQuestion,
|
|
21
22
|
readFrom,
|
|
22
23
|
segment,
|
|
23
24
|
type Artefact,
|
|
24
25
|
type Block,
|
|
25
26
|
type DescribeTool,
|
|
27
|
+
type Depth,
|
|
26
28
|
type Outcome,
|
|
27
29
|
type TextBlock
|
|
28
30
|
} from '../../transcript.svelte';
|
|
@@ -88,6 +90,14 @@
|
|
|
88
90
|
/** Records a verdict on this answer. Omit and no mark renders: a
|
|
89
91
|
* control that records nothing is worse than none. */
|
|
90
92
|
onmark?: (verdict: Verdict) => Promise<boolean>;
|
|
93
|
+
/** How hard the persona worked on this one, for the footer's cost line
|
|
94
|
+
* (`Quick · 56 s · about A$0.34`). Absent renders the footer exactly
|
|
95
|
+
* as it always has: the duration alone. */
|
|
96
|
+
depth?: Depth;
|
|
97
|
+
/** Prices `outcome.costUsd` in the host's own currency and words
|
|
98
|
+
* ("about A$0.34"). The package never converts currency itself,
|
|
99
|
+
* and a zero-cost run (a local model) shows no cost regardless. */
|
|
100
|
+
formatCost?: (usd: number) => string;
|
|
91
101
|
}
|
|
92
102
|
|
|
93
103
|
let {
|
|
@@ -110,7 +120,9 @@
|
|
|
110
120
|
describeTool,
|
|
111
121
|
waiting = false,
|
|
112
122
|
answering,
|
|
113
|
-
onmark
|
|
123
|
+
onmark,
|
|
124
|
+
depth,
|
|
125
|
+
formatCost
|
|
114
126
|
}: AgentTranscriptProps = $props();
|
|
115
127
|
|
|
116
128
|
// The prose IS the artefact: it reads in the column, and the transcript
|
|
@@ -375,11 +387,12 @@
|
|
|
375
387
|
<span>{words.askAgain}</span>
|
|
376
388
|
</button>
|
|
377
389
|
{/if}
|
|
378
|
-
<!--
|
|
390
|
+
<!-- What it took, which is not the same fact as when it was
|
|
379
391
|
asked; both are on the card and neither stands in for the
|
|
380
|
-
other.
|
|
392
|
+
other. Depth and cost fold into the SAME line, never a
|
|
393
|
+
second one, once the host offers either. -->
|
|
381
394
|
{#if outcome && !failure}
|
|
382
|
-
<span class="ds-lib-duration">{(
|
|
395
|
+
<span class="ds-lib-duration">{outcomeLine(outcome, depth, words, formatCost)}</span>
|
|
383
396
|
{/if}
|
|
384
397
|
</div>
|
|
385
398
|
{/if}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type Artefact, type Block, type DescribeTool, type Outcome } from '../../transcript.svelte';
|
|
1
|
+
import { type Artefact, type Block, type DescribeTool, type Depth, type Outcome } from '../../transcript.svelte';
|
|
2
2
|
import { type Citation } from '../../citations';
|
|
3
3
|
import { type LibrarianCopy } from '../../copy';
|
|
4
4
|
import type { Verdict } from '../../chat.svelte';
|
|
@@ -49,6 +49,14 @@ export interface AgentTranscriptProps {
|
|
|
49
49
|
/** Records a verdict on this answer. Omit and no mark renders: a
|
|
50
50
|
* control that records nothing is worse than none. */
|
|
51
51
|
onmark?: (verdict: Verdict) => Promise<boolean>;
|
|
52
|
+
/** How hard the persona worked on this one, for the footer's cost line
|
|
53
|
+
* (`Quick · 56 s · about A$0.34`). Absent renders the footer exactly
|
|
54
|
+
* as it always has: the duration alone. */
|
|
55
|
+
depth?: Depth;
|
|
56
|
+
/** Prices `outcome.costUsd` in the host's own currency and words
|
|
57
|
+
* ("about A$0.34"). The package never converts currency itself,
|
|
58
|
+
* and a zero-cost run (a local model) shows no cost regardless. */
|
|
59
|
+
formatCost?: (usd: number) => string;
|
|
52
60
|
}
|
|
53
61
|
declare const AgentTranscript: import("svelte").Component<AgentTranscriptProps, {}, "">;
|
|
54
62
|
type AgentTranscript = ReturnType<typeof AgentTranscript>;
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
-->
|
|
15
15
|
<script lang="ts">
|
|
16
16
|
import ArrowUpIcon from '@lucide/svelte/icons/arrow-up';
|
|
17
|
+
import ClockIcon from '@lucide/svelte/icons/clock';
|
|
17
18
|
import FileTextIcon from '@lucide/svelte/icons/file-text';
|
|
18
19
|
import PaperclipIcon from '@lucide/svelte/icons/paperclip';
|
|
19
20
|
import SquareIcon from '@lucide/svelte/icons/square';
|
|
@@ -25,8 +26,14 @@
|
|
|
25
26
|
MAX_FILES,
|
|
26
27
|
rejectionMessage
|
|
27
28
|
} from '../../attachments';
|
|
29
|
+
import type { Depth } from '../../client';
|
|
28
30
|
import { DEFAULT_PERSONA, resolveCopy, type LibrarianCopy } from '../../copy';
|
|
29
31
|
|
|
32
|
+
/** Past this many turns, the long-conversation banner offers to start
|
|
33
|
+
* over: a follow-up re-reads the whole conversation, so each one costs
|
|
34
|
+
* more than the last (measured — the longest ran 2.05M cached tokens). */
|
|
35
|
+
const DEFAULT_TURN_LIMIT = 8;
|
|
36
|
+
|
|
30
37
|
export type Scope = 'document' | 'collection' | 'library';
|
|
31
38
|
|
|
32
39
|
interface Props {
|
|
@@ -63,6 +70,21 @@
|
|
|
63
70
|
* sit, in the host's words: what sending does here ("It carries on
|
|
64
71
|
* from where it stopped."). */
|
|
65
72
|
note?: string;
|
|
73
|
+
/** Quick or Thorough for the next question, as a two-way switch with a
|
|
74
|
+
* one-line hint. Bind it and pass it to `ask()`; omit it and nothing
|
|
75
|
+
* renders. Once shown it is never disabled or hidden, and never names
|
|
76
|
+
* the model behind it. */
|
|
77
|
+
depth?: Depth;
|
|
78
|
+
/** How many turns are in the conversation so far. Past `turnLimit`
|
|
79
|
+
* (8 by default), a quiet banner appears above the box offering to
|
|
80
|
+
* start over. Omit and no banner ever renders — a host that does not
|
|
81
|
+
* track turns keeps today's composer. */
|
|
82
|
+
turnCount?: number;
|
|
83
|
+
turnLimit?: number;
|
|
84
|
+
/** Starts a new conversation, from the banner's own button. Without it
|
|
85
|
+
* the banner never renders, past the limit or not — an offer with no
|
|
86
|
+
* action is worse than none. */
|
|
87
|
+
onnewquestion?: () => void;
|
|
66
88
|
}
|
|
67
89
|
|
|
68
90
|
let {
|
|
@@ -80,7 +102,11 @@
|
|
|
80
102
|
name = DEFAULT_PERSONA,
|
|
81
103
|
copy,
|
|
82
104
|
note,
|
|
83
|
-
sendWhileRunning = false
|
|
105
|
+
sendWhileRunning = false,
|
|
106
|
+
depth = $bindable(),
|
|
107
|
+
turnCount,
|
|
108
|
+
turnLimit = DEFAULT_TURN_LIMIT,
|
|
109
|
+
onnewquestion
|
|
84
110
|
}: Props = $props();
|
|
85
111
|
|
|
86
112
|
/** Nothing can be said right now: a chat whose answer is still coming. */
|
|
@@ -88,6 +114,12 @@
|
|
|
88
114
|
|
|
89
115
|
const words = $derived(resolveCopy(copy, name));
|
|
90
116
|
|
|
117
|
+
// The banner needs somewhere to send a reader AND a reason to show at
|
|
118
|
+
// all: a host that never counts turns gets today's composer, unchanged.
|
|
119
|
+
const longConversation = $derived(
|
|
120
|
+
turnCount !== undefined && turnCount > turnLimit && Boolean(onnewquestion)
|
|
121
|
+
);
|
|
122
|
+
|
|
91
123
|
// What Milton is being asked about — this document, this collection, or
|
|
92
124
|
// the whole library. Callers that have no narrower scope pass neither
|
|
93
125
|
// name, leaving "The whole library" the sole entry — and a sole entry is
|
|
@@ -212,6 +244,16 @@
|
|
|
212
244
|
? '0px'
|
|
213
245
|
: 'env(safe-area-inset-bottom, 0px)'}"
|
|
214
246
|
>
|
|
247
|
+
{#if longConversation}
|
|
248
|
+
<div class="ds-lib-long" role="status">
|
|
249
|
+
<ClockIcon size={16} />
|
|
250
|
+
<span>{words.longConversation}</span>
|
|
251
|
+
<button type="button" class="ds-lib-long-new" onclick={onnewquestion}>
|
|
252
|
+
{words.newQuestion}
|
|
253
|
+
</button>
|
|
254
|
+
</div>
|
|
255
|
+
{/if}
|
|
256
|
+
|
|
215
257
|
<div class="ds-lib-box">
|
|
216
258
|
{#if files.length > 0}
|
|
217
259
|
<ul class="ds-lib-files">
|
|
@@ -279,6 +321,31 @@
|
|
|
279
321
|
</button>
|
|
280
322
|
{/if}
|
|
281
323
|
|
|
324
|
+
{#if depth !== undefined}
|
|
325
|
+
<div role="group" aria-label={words.depthGroupLabel} class="ds-lib-depth">
|
|
326
|
+
<button
|
|
327
|
+
type="button"
|
|
328
|
+
aria-pressed={depth === 'quick'}
|
|
329
|
+
class="ds-lib-depth-side"
|
|
330
|
+
class:is-chosen={depth === 'quick'}
|
|
331
|
+
onclick={() => (depth = 'quick')}
|
|
332
|
+
>
|
|
333
|
+
{words.depthQuick}
|
|
334
|
+
</button>
|
|
335
|
+
<button
|
|
336
|
+
type="button"
|
|
337
|
+
aria-pressed={depth === 'thorough'}
|
|
338
|
+
class="ds-lib-depth-side"
|
|
339
|
+
class:is-chosen={depth === 'thorough'}
|
|
340
|
+
onclick={() => (depth = 'thorough')}
|
|
341
|
+
>
|
|
342
|
+
{words.depthThorough}
|
|
343
|
+
</button>
|
|
344
|
+
</div>
|
|
345
|
+
<span class="ds-lib-note">
|
|
346
|
+
{depth === 'quick' ? words.depthQuickHint : words.depthThoroughHint}
|
|
347
|
+
</span>
|
|
348
|
+
{/if}
|
|
282
349
|
{#if hasChoice}
|
|
283
350
|
<div class="ds-lib-scopes">
|
|
284
351
|
{#each choices as choice (choice.id)}
|
|
@@ -415,8 +482,10 @@
|
|
|
415
482
|
|
|
416
483
|
.ds-lib-controls {
|
|
417
484
|
display: flex;
|
|
485
|
+
flex-wrap: wrap;
|
|
418
486
|
align-items: center;
|
|
419
487
|
gap: 0.5rem;
|
|
488
|
+
row-gap: 0.375rem;
|
|
420
489
|
padding: 0 0.75rem 0.625rem;
|
|
421
490
|
}
|
|
422
491
|
|
|
@@ -538,6 +607,91 @@
|
|
|
538
607
|
font-size: var(--ds-text-2xs);
|
|
539
608
|
}
|
|
540
609
|
|
|
610
|
+
/* A labelled button group, never a select: two states a reader compares
|
|
611
|
+
at a glance, not a list to open. 44px touch targets throughout — the
|
|
612
|
+
one row here a colleague reaches for on every single question. */
|
|
613
|
+
.ds-lib-depth {
|
|
614
|
+
display: flex;
|
|
615
|
+
flex: none;
|
|
616
|
+
gap: 0.125rem;
|
|
617
|
+
border-radius: var(--ds-radius-lg);
|
|
618
|
+
background: var(--ds-color-surface-3);
|
|
619
|
+
padding: 0.1875rem;
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
.ds-lib-depth-side {
|
|
623
|
+
min-width: 2.75rem;
|
|
624
|
+
min-height: 2.75rem;
|
|
625
|
+
border: 0;
|
|
626
|
+
border-radius: var(--ds-radius-md);
|
|
627
|
+
background: none;
|
|
628
|
+
padding: 0 0.75rem;
|
|
629
|
+
color: var(--ds-color-muted-foreground);
|
|
630
|
+
font: inherit;
|
|
631
|
+
font-size: var(--ds-text-2xs);
|
|
632
|
+
cursor: pointer;
|
|
633
|
+
transition:
|
|
634
|
+
color 150ms ease,
|
|
635
|
+
background-color 150ms ease;
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
.ds-lib-depth-side:hover {
|
|
639
|
+
color: var(--ds-color-foreground);
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
.ds-lib-depth-side.is-chosen {
|
|
643
|
+
background: var(--ds-color-primary);
|
|
644
|
+
color: var(--ds-color-primary-foreground);
|
|
645
|
+
font-weight: 600;
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
.ds-lib-depth-side:focus-visible {
|
|
649
|
+
outline: 2px solid var(--ds-color-ring);
|
|
650
|
+
outline-offset: 2px;
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
/* Above the box, never inside it: it is about the conversation so far,
|
|
654
|
+
not this one question. Warning-tinted, never error — nothing has gone
|
|
655
|
+
wrong, a follow-up just costs more than the last one did. */
|
|
656
|
+
.ds-lib-long {
|
|
657
|
+
display: flex;
|
|
658
|
+
align-items: center;
|
|
659
|
+
gap: 0.625rem;
|
|
660
|
+
border: 1px solid color-mix(in oklab, var(--ds-color-status-warning) 40%, transparent);
|
|
661
|
+
border-radius: var(--ds-radius-lg);
|
|
662
|
+
background: color-mix(in oklab, var(--ds-color-status-warning) 12%, transparent);
|
|
663
|
+
padding: 0.5rem 0.5rem 0.5rem 0.875rem;
|
|
664
|
+
color: var(--ds-color-foreground);
|
|
665
|
+
font-size: 0.875rem;
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
.ds-lib-long span {
|
|
669
|
+
flex: 1;
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
.ds-lib-long-new {
|
|
673
|
+
min-height: 2.75rem;
|
|
674
|
+
flex: none;
|
|
675
|
+
border: 1px solid var(--ds-color-border);
|
|
676
|
+
border-radius: var(--ds-radius-md);
|
|
677
|
+
background: transparent;
|
|
678
|
+
padding: 0 0.875rem;
|
|
679
|
+
color: var(--ds-color-foreground);
|
|
680
|
+
font: inherit;
|
|
681
|
+
font-size: var(--ds-text-2xs);
|
|
682
|
+
cursor: pointer;
|
|
683
|
+
transition: background-color 150ms ease;
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
.ds-lib-long-new:hover {
|
|
687
|
+
background: var(--ds-color-surface-2);
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
.ds-lib-long-new:focus-visible {
|
|
691
|
+
outline: 2px solid var(--ds-color-ring);
|
|
692
|
+
outline-offset: 2px;
|
|
693
|
+
}
|
|
694
|
+
|
|
541
695
|
@media (prefers-reduced-motion: reduce) {
|
|
542
696
|
.ds-lib-composer-lift {
|
|
543
697
|
transition: none;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { Depth } from '../../client';
|
|
1
2
|
import { type LibrarianCopy } from '../../copy';
|
|
2
3
|
export type Scope = 'document' | 'collection' | 'library';
|
|
3
4
|
interface Props {
|
|
@@ -34,7 +35,22 @@ interface Props {
|
|
|
34
35
|
* sit, in the host's words: what sending does here ("It carries on
|
|
35
36
|
* from where it stopped."). */
|
|
36
37
|
note?: string;
|
|
38
|
+
/** Quick or Thorough for the next question, as a two-way switch with a
|
|
39
|
+
* one-line hint. Bind it and pass it to `ask()`; omit it and nothing
|
|
40
|
+
* renders. Once shown it is never disabled or hidden, and never names
|
|
41
|
+
* the model behind it. */
|
|
42
|
+
depth?: Depth;
|
|
43
|
+
/** How many turns are in the conversation so far. Past `turnLimit`
|
|
44
|
+
* (8 by default), a quiet banner appears above the box offering to
|
|
45
|
+
* start over. Omit and no banner ever renders — a host that does not
|
|
46
|
+
* track turns keeps today's composer. */
|
|
47
|
+
turnCount?: number;
|
|
48
|
+
turnLimit?: number;
|
|
49
|
+
/** Starts a new conversation, from the banner's own button. Without it
|
|
50
|
+
* the banner never renders, past the limit or not — an offer with no
|
|
51
|
+
* action is worse than none. */
|
|
52
|
+
onnewquestion?: () => void;
|
|
37
53
|
}
|
|
38
|
-
declare const Composer: import("svelte").Component<Props, {}, "value" | "files">;
|
|
54
|
+
declare const Composer: import("svelte").Component<Props, {}, "value" | "depth" | "files">;
|
|
39
55
|
type Composer = ReturnType<typeof Composer>;
|
|
40
56
|
export default Composer;
|
|
@@ -78,6 +78,11 @@
|
|
|
78
78
|
showing?: string;
|
|
79
79
|
/** The persona's own words for its tools, and what they read. */
|
|
80
80
|
describeTool?: DescribeTool;
|
|
81
|
+
/** Prices a turn's `outcome.costUsd` in the host's own currency and
|
|
82
|
+
* words ("about A$0.34"), for the footer's cost line beside a turn's
|
|
83
|
+
* `depth`. The package never converts currency itself; omitted, the
|
|
84
|
+
* line shows depth and duration only. */
|
|
85
|
+
formatCost?: (usd: number) => string;
|
|
81
86
|
collectionNames?: Set<string>;
|
|
82
87
|
/** The composer, rendered INSIDE the transcript column so the source
|
|
83
88
|
* pane narrows it too — a composer the host places outside slides
|
|
@@ -117,7 +122,8 @@
|
|
|
117
122
|
collectionNames = new Set(),
|
|
118
123
|
composer,
|
|
119
124
|
turn: presentTurn,
|
|
120
|
-
lead
|
|
125
|
+
lead,
|
|
126
|
+
formatCost
|
|
121
127
|
}: Props = $props();
|
|
122
128
|
|
|
123
129
|
// Resolved ONCE, here, and handed down whole: every child takes `copy` and
|
|
@@ -231,6 +237,16 @@
|
|
|
231
237
|
if (onopenartefact) return showing === turn.id;
|
|
232
238
|
return column?.kind === 'artefact' && column.turn.id === turn.id;
|
|
233
239
|
}
|
|
240
|
+
|
|
241
|
+
// Lifts the shell's report button (`@poodle64/ui` ReportWidget) clear of
|
|
242
|
+
// Send, which shares its corner. Set on the document because the button
|
|
243
|
+
// renders outside this subtree; a constant because Send is always the
|
|
244
|
+
// composer's last row.
|
|
245
|
+
$effect(() => {
|
|
246
|
+
if (!composer || typeof document === 'undefined') return;
|
|
247
|
+
document.documentElement.style.setProperty('--ds-report-clearance', '4.5rem');
|
|
248
|
+
return () => document.documentElement.style.removeProperty('--ds-report-clearance');
|
|
249
|
+
});
|
|
234
250
|
</script>
|
|
235
251
|
|
|
236
252
|
<div class="ds-lib-surface">
|
|
@@ -301,6 +317,8 @@
|
|
|
301
317
|
artefactOpen: isShowing(turn),
|
|
302
318
|
onopenartefact: opener(turn),
|
|
303
319
|
describeTool,
|
|
320
|
+
depth: turn.depth,
|
|
321
|
+
formatCost,
|
|
304
322
|
oncite: oncite || loadDocument ? cite : undefined,
|
|
305
323
|
onregenerate: last && !running ? onregenerate : undefined,
|
|
306
324
|
onsuggest: last && !running ? onsuggest : undefined,
|
|
@@ -54,6 +54,11 @@ interface Props {
|
|
|
54
54
|
showing?: string;
|
|
55
55
|
/** The persona's own words for its tools, and what they read. */
|
|
56
56
|
describeTool?: DescribeTool;
|
|
57
|
+
/** Prices a turn's `outcome.costUsd` in the host's own currency and
|
|
58
|
+
* words ("about A$0.34"), for the footer's cost line beside a turn's
|
|
59
|
+
* `depth`. The package never converts currency itself; omitted, the
|
|
60
|
+
* line shows depth and duration only. */
|
|
61
|
+
formatCost?: (usd: number) => string;
|
|
57
62
|
collectionNames?: Set<string>;
|
|
58
63
|
/** The composer, rendered INSIDE the transcript column so the source
|
|
59
64
|
* pane narrows it too — a composer the host places outside slides
|
package/dist/copy.d.ts
CHANGED
|
@@ -44,6 +44,18 @@ export interface LibrarianCopy {
|
|
|
44
44
|
/** The composer's placeholder, idle and while an answer streams. */
|
|
45
45
|
askPlaceholder: string;
|
|
46
46
|
answeringPlaceholder: string;
|
|
47
|
+
/** The depth switch: its group's accessible name, each side's label, and
|
|
48
|
+
* the one-line hint beside it saying what the picked side is for. Never
|
|
49
|
+
* which model sits behind it — that is never the colleague's concern. */
|
|
50
|
+
depthGroupLabel: string;
|
|
51
|
+
depthQuick: string;
|
|
52
|
+
depthThorough: string;
|
|
53
|
+
depthQuickHint: string;
|
|
54
|
+
depthThoroughHint: string;
|
|
55
|
+
/** Past a few turns, the quiet line above the composer that offers to
|
|
56
|
+
* start over — a follow-up re-reads the whole conversation, so each one
|
|
57
|
+
* costs more than the last. */
|
|
58
|
+
longConversation: string;
|
|
47
59
|
/** The pre-first-token words, cycled one every 2.6s. */
|
|
48
60
|
working: string[];
|
|
49
61
|
/** The scroll region's accessible name. */
|
package/dist/copy.js
CHANGED
|
@@ -66,6 +66,12 @@ export function copyFor(name = DEFAULT_PERSONA) {
|
|
|
66
66
|
welcome: `Ask ${who} a question.`,
|
|
67
67
|
askPlaceholder: `Ask ${who}…`,
|
|
68
68
|
answeringPlaceholder: `${who} is answering…`,
|
|
69
|
+
depthGroupLabel: `How hard ${who} works`,
|
|
70
|
+
depthQuick: 'Quick',
|
|
71
|
+
depthThorough: 'Thorough',
|
|
72
|
+
depthQuickHint: 'Fast, for looking something up',
|
|
73
|
+
depthThoroughHint: 'Slower, reads more, for questions of interpretation',
|
|
74
|
+
longConversation: `This conversation is getting long. A new question keeps ${who} quick.`,
|
|
69
75
|
working: [`${who} is looking`, `${who} is reading`],
|
|
70
76
|
conversationLabel: `Conversation with ${who}`,
|
|
71
77
|
copyAnswer: 'Copy',
|
|
@@ -6,8 +6,10 @@
|
|
|
6
6
|
* deltas that belong to each. An event type this does not know about is kept
|
|
7
7
|
* verbatim under `other`, so nothing is silently dropped.
|
|
8
8
|
*/
|
|
9
|
-
import type { AgentEvent } from './client';
|
|
9
|
+
import type { AgentEvent, Depth } from './client';
|
|
10
10
|
import type { Citation } from './citations';
|
|
11
|
+
import type { LibrarianCopy } from './copy';
|
|
12
|
+
export type { Depth };
|
|
11
13
|
export interface TextBlock {
|
|
12
14
|
kind: 'text';
|
|
13
15
|
index: number;
|
|
@@ -208,6 +210,9 @@ export interface Turn {
|
|
|
208
210
|
/** What the host asked for when this was not a plain question
|
|
209
211
|
* (`'briefing'`), in its own word, as `client.ask()` sent it. */
|
|
210
212
|
kind?: string;
|
|
213
|
+
/** How hard the persona worked on this one, as `client.ask()` sent it.
|
|
214
|
+
* Absent, a host that never offers the choice. */
|
|
215
|
+
depth?: Depth;
|
|
211
216
|
}
|
|
212
217
|
/** A turn's artefact, in the host's own words. */
|
|
213
218
|
export interface Artefact {
|
|
@@ -258,6 +263,23 @@ export declare function segment(blocks: Block[], describeTool?: DescribeTool): S
|
|
|
258
263
|
* it organises what it knows is not — no collection count, no shelf, no corpus.
|
|
259
264
|
*/
|
|
260
265
|
export declare function summariseActivity(group: ActivityGroup): string;
|
|
266
|
+
/**
|
|
267
|
+
* What a settled answer took, as one quiet line: `Quick · 56 s · about
|
|
268
|
+
* A$0.34`. Depth first (absent on a turn the host never asked one for, or
|
|
269
|
+
* one read back before the feature shipped), then the duration, then what it
|
|
270
|
+
* cost — only when the host can price it (`formatCost`) and the run actually
|
|
271
|
+
* spent something, since a zero-cost run (a local model) has nothing worth
|
|
272
|
+
* saying about money.
|
|
273
|
+
*
|
|
274
|
+
* The host prices `outcome.costUsd`, never this package: the currency and
|
|
275
|
+
* the rate are the app's own setting, not something a shared component
|
|
276
|
+
* should be converting.
|
|
277
|
+
*
|
|
278
|
+
* A turn asked with no depth at all renders exactly what this line always
|
|
279
|
+
* has (`56.3s`, one decimal, no gap) — a host that never wires depth sees no
|
|
280
|
+
* change here either.
|
|
281
|
+
*/
|
|
282
|
+
export declare function outcomeLine(outcome: Outcome, depth: Depth | undefined, words: Pick<LibrarianCopy, 'depthQuick' | 'depthThorough'>, formatCost?: (usd: number) => string): string;
|
|
261
283
|
/**
|
|
262
284
|
* What a turn's calls read that a reader can open, numbered in the order it
|
|
263
285
|
* was first read: the host's `ToolWords.source`, as the sources the answer
|
|
@@ -438,6 +438,31 @@ export function summariseActivity(group) {
|
|
|
438
438
|
const parts = group.tallies.map((t) => `${t.count} ${t.count === 1 ? t.one : t.many}`);
|
|
439
439
|
return parts.length ? parts.join(' · ') : 'Looked into it';
|
|
440
440
|
}
|
|
441
|
+
/**
|
|
442
|
+
* What a settled answer took, as one quiet line: `Quick · 56 s · about
|
|
443
|
+
* A$0.34`. Depth first (absent on a turn the host never asked one for, or
|
|
444
|
+
* one read back before the feature shipped), then the duration, then what it
|
|
445
|
+
* cost — only when the host can price it (`formatCost`) and the run actually
|
|
446
|
+
* spent something, since a zero-cost run (a local model) has nothing worth
|
|
447
|
+
* saying about money.
|
|
448
|
+
*
|
|
449
|
+
* The host prices `outcome.costUsd`, never this package: the currency and
|
|
450
|
+
* the rate are the app's own setting, not something a shared component
|
|
451
|
+
* should be converting.
|
|
452
|
+
*
|
|
453
|
+
* A turn asked with no depth at all renders exactly what this line always
|
|
454
|
+
* has (`56.3s`, one decimal, no gap) — a host that never wires depth sees no
|
|
455
|
+
* change here either.
|
|
456
|
+
*/
|
|
457
|
+
export function outcomeLine(outcome, depth, words, formatCost) {
|
|
458
|
+
const seconds = (outcome.durationMs ?? 0) / 1000;
|
|
459
|
+
if (!depth)
|
|
460
|
+
return `${seconds.toFixed(1)}s`;
|
|
461
|
+
const parts = [depth === 'quick' ? words.depthQuick : words.depthThorough, `${Math.round(seconds)} s`];
|
|
462
|
+
if (formatCost && outcome.costUsd)
|
|
463
|
+
parts.push(formatCost(outcome.costUsd));
|
|
464
|
+
return parts.join(' · ');
|
|
465
|
+
}
|
|
441
466
|
/**
|
|
442
467
|
* What a turn's calls read that a reader can open, numbered in the order it
|
|
443
468
|
* was first read: the host's `ToolWords.source`, as the sources the answer
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@poodle64/librarian",
|
|
3
|
-
"version": "2026.9.
|
|
3
|
+
"version": "2026.9.26",
|
|
4
4
|
"description": "An agent's conversation surface as a consumable Svelte 5 package: the stream client, transcript state and self-styling chat components (transcript, composer, markdown) every household app renders instead of rebuilding, speaking as whichever persona the app names.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|