@poodle64/librarian 2026.10.3 → 2026.10.5
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 +12 -4
- package/dist/chat.svelte.d.ts +40 -0
- package/dist/chat.svelte.js +57 -3
- package/dist/client.d.ts +3 -0
- package/dist/client.js +4 -1
- package/dist/components/activity-group/activity-group.svelte +72 -77
- package/dist/components/activity-group/activity-group.svelte.d.ts +6 -1
- package/dist/components/agent-transcript/agent-transcript.svelte +225 -9
- package/dist/components/agent-transcript/agent-transcript.svelte.d.ts +19 -0
- package/dist/components/artefact-pane/artefact-pane.svelte +387 -43
- package/dist/components/artefact-pane/artefact-pane.svelte.d.ts +10 -1
- package/dist/components/conversation/conversation.svelte +74 -4
- package/dist/components/conversation/conversation.svelte.d.ts +15 -0
- package/dist/components/markdown/markdown.d.ts +30 -2
- package/dist/components/markdown/markdown.js +142 -5
- package/dist/components/markdown/markdown.svelte +130 -16
- package/dist/components/markdown/markdown.svelte.d.ts +12 -0
- package/dist/components/source-fold/index.d.ts +2 -0
- package/dist/components/source-fold/index.js +2 -0
- package/dist/components/source-fold/source-fold.svelte +225 -0
- package/dist/components/source-fold/source-fold.svelte.d.ts +17 -0
- package/dist/components/source-list/source-list.svelte +5 -3
- package/dist/components/source-list/source-list.svelte.d.ts +2 -0
- package/dist/components/thinking-row/thinking-row.svelte +38 -16
- package/dist/components/thinking-row/thinking-row.svelte.d.ts +3 -0
- package/dist/components/tool-row/tool-row.svelte +161 -49
- package/dist/components/tool-row/tool-row.svelte.d.ts +2 -3
- package/dist/components/visual-block/entity-card.svelte +94 -0
- package/dist/components/visual-block/entity-card.svelte.d.ts +7 -0
- package/dist/components/visual-block/timeline.svelte +99 -0
- package/dist/components/visual-block/timeline.svelte.d.ts +7 -0
- package/dist/components/visual-block/vega-chart.svelte +110 -0
- package/dist/components/visual-block/vega-chart.svelte.d.ts +7 -0
- package/dist/components/visual-block/visual-block.svelte +53 -0
- package/dist/components/visual-block/visual-block.svelte.d.ts +13 -0
- package/dist/components/written-card/index.d.ts +2 -0
- package/dist/components/written-card/index.js +2 -0
- package/dist/components/written-card/written-card.svelte +178 -0
- package/dist/components/written-card/written-card.svelte.d.ts +15 -0
- package/dist/copy.d.ts +73 -0
- package/dist/copy.js +56 -0
- package/dist/diff.d.ts +8 -0
- package/dist/diff.js +37 -0
- package/dist/steps.d.ts +28 -0
- package/dist/steps.js +168 -0
- package/dist/transcript.svelte.d.ts +19 -34
- package/dist/transcript.svelte.js +64 -56
- package/dist/visuals.d.ts +38 -0
- package/dist/visuals.js +82 -0
- package/dist/web-sources.d.ts +34 -0
- package/dist/web-sources.js +90 -0
- package/dist/written.d.ts +71 -0
- package/dist/written.js +83 -0
- package/package.json +14 -1
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
import type { AgentEvent } from './client';
|
|
10
10
|
import type { Citation } from './citations';
|
|
11
|
+
import type { Written } from './written';
|
|
11
12
|
export interface TextBlock {
|
|
12
13
|
kind: 'text';
|
|
13
14
|
index: number;
|
|
@@ -17,6 +18,10 @@ export interface ThinkingBlock {
|
|
|
17
18
|
kind: 'thinking';
|
|
18
19
|
index: number;
|
|
19
20
|
text: string;
|
|
21
|
+
/** Epoch ms the block opened and closed on this page, measured live; absent
|
|
22
|
+
* for one the stream delivered whole or read back. */
|
|
23
|
+
startedAt?: number;
|
|
24
|
+
endedAt?: number;
|
|
20
25
|
}
|
|
21
26
|
export interface ToolBlock {
|
|
22
27
|
kind: 'tool';
|
|
@@ -66,12 +71,6 @@ export interface ToolWords {
|
|
|
66
71
|
object: string;
|
|
67
72
|
/** One dimmed line under the row. */
|
|
68
73
|
detail?: string;
|
|
69
|
-
/** How the activity line counts the call, singular and plural:
|
|
70
|
-
* `['search', 'searches']` reads "2 searches". Absent is not counted. */
|
|
71
|
-
tally?: [one: string, many: string];
|
|
72
|
-
/** How the row counts a run of identical calls: `['page', 'pages']`
|
|
73
|
-
* reads "· 5 pages". Absent reads "× 5". */
|
|
74
|
-
repeat?: [one: string, many: string];
|
|
75
74
|
/** Something the call read that a reader can open, listed under the
|
|
76
75
|
* answer and handed back to the host's `oncite` when tapped. */
|
|
77
76
|
source?: {
|
|
@@ -120,6 +119,8 @@ export declare class Transcript {
|
|
|
120
119
|
outcome: Outcome | null;
|
|
121
120
|
/** Sources for the answer, from the app's `citations` frame. */
|
|
122
121
|
citations: Citation[];
|
|
122
|
+
/** The document versions the persona wrote, in order. */
|
|
123
|
+
written: Written[];
|
|
123
124
|
other: AgentEvent[];
|
|
124
125
|
reset(): void;
|
|
125
126
|
apply(event: AgentEvent): void;
|
|
@@ -129,6 +130,7 @@ export interface FoldTarget {
|
|
|
129
130
|
blocks: Block[];
|
|
130
131
|
outcome: Outcome | null;
|
|
131
132
|
citations?: Citation[];
|
|
133
|
+
written?: Written[];
|
|
132
134
|
}
|
|
133
135
|
/** Where one turn's fold keeps its place between events. */
|
|
134
136
|
export interface FoldState {
|
|
@@ -155,29 +157,18 @@ export declare function fold(into: FoldTarget, state: FoldState, event: AgentEve
|
|
|
155
157
|
/** A message's content blocks; a user frame may carry a bare string instead. */
|
|
156
158
|
export declare function contentOf(event: AgentEvent): Array<Record<string, unknown>>;
|
|
157
159
|
export interface ActivityStep {
|
|
158
|
-
/** The block this step stands for
|
|
160
|
+
/** The block this step stands for.
|
|
159
161
|
*
|
|
160
162
|
* A `text` block here is interstitial narration, not the answer — see
|
|
161
163
|
* `segment()`. */
|
|
162
164
|
block: Block;
|
|
163
|
-
/**
|
|
164
|
-
repeats: number;
|
|
165
|
-
/** A tool step in the persona's words, resolved once here so the row, the
|
|
166
|
-
* count and the collapse all agree. */
|
|
165
|
+
/** A tool step in the persona's words, resolved once here. */
|
|
167
166
|
words?: ToolWords;
|
|
168
167
|
}
|
|
169
|
-
/** One count on the activity line: "2 searches". */
|
|
170
|
-
export interface Tally {
|
|
171
|
-
one: string;
|
|
172
|
-
many: string;
|
|
173
|
-
count: number;
|
|
174
|
-
}
|
|
175
168
|
export interface ActivityGroup {
|
|
176
169
|
kind: 'activity';
|
|
177
170
|
index: number;
|
|
178
171
|
steps: ActivityStep[];
|
|
179
|
-
/** What the steps counted as, in the order each first appeared. */
|
|
180
|
-
tallies: Tally[];
|
|
181
172
|
}
|
|
182
173
|
export type Segment = ActivityGroup | TextBlock;
|
|
183
174
|
/** One question and the answer to it, as the transcript renders it.
|
|
@@ -196,6 +187,10 @@ export interface Turn {
|
|
|
196
187
|
blocks: Block[];
|
|
197
188
|
outcome: Outcome | null;
|
|
198
189
|
citations?: Citation[];
|
|
190
|
+
/** The names of the files attached to the question. */
|
|
191
|
+
files?: string[];
|
|
192
|
+
/** The document versions the persona wrote answering it, in order. */
|
|
193
|
+
written?: Written[];
|
|
199
194
|
/** Something this turn produced for the reader to open, carded under the
|
|
200
195
|
* answer once the turn settles. */
|
|
201
196
|
artefact?: Artefact;
|
|
@@ -223,15 +218,11 @@ export interface Artefact {
|
|
|
223
218
|
/**
|
|
224
219
|
* Fold a turn's flat block list into what a reader should actually see.
|
|
225
220
|
*
|
|
226
|
-
*
|
|
221
|
+
* Two problems this solves, reported off a real transcript:
|
|
227
222
|
*
|
|
228
|
-
* 1.
|
|
229
|
-
*
|
|
230
|
-
*
|
|
231
|
-
* 2. "Reading pspf guidelines 2026" appeared five times in a row — five pages
|
|
232
|
-
* of one document, which is one act of reading to a human. Consecutive
|
|
233
|
-
* steps with the same label collapse to one row carrying a count.
|
|
234
|
-
* 3. The persona's between-tool narration was rendering unfolded, as answer prose.
|
|
223
|
+
* 1. Contiguous activity becomes ONE group, so the answer's prose is never
|
|
224
|
+
* split by each step; every step stays its own collapsed row in it.
|
|
225
|
+
* 2. The persona's between-tool narration was rendering unfolded, as answer prose.
|
|
235
226
|
* The caller stream carries no `thinking` blocks at all — measured on
|
|
236
227
|
* production 11/09/2026 — so "Let me also check whether…" arrives as an
|
|
237
228
|
* ordinary `text` block, indistinguishable from the answer except by
|
|
@@ -239,7 +230,7 @@ export interface Artefact {
|
|
|
239
230
|
* narration; only the run of text after the LAST tool call is the answer.
|
|
240
231
|
* Narration folds into the activity group as a thinking-shaped step.
|
|
241
232
|
*
|
|
242
|
-
* That
|
|
233
|
+
* That second rule is provisional while a turn streams, and deliberately so: a
|
|
243
234
|
* text block that is currently last IS the answer as far as anything can know,
|
|
244
235
|
* and renders as prose. The tool call that arrives after it re-homes it into
|
|
245
236
|
* the group. Nothing here holds state to make that work — `segment` is pure
|
|
@@ -252,12 +243,6 @@ export interface Artefact {
|
|
|
252
243
|
* off the prose itself would be unexplainable the first time it misfired.
|
|
253
244
|
*/
|
|
254
245
|
export declare function segment(blocks: Block[], describeTool?: DescribeTool): Segment[];
|
|
255
|
-
/** One line describing a whole investigation, for the collapsed state.
|
|
256
|
-
*
|
|
257
|
-
* Counts of what the persona did are fine ("1 search · 2 documents read"); how
|
|
258
|
-
* it organises what it knows is not — no collection count, no corpus.
|
|
259
|
-
*/
|
|
260
|
-
export declare function summariseActivity(group: ActivityGroup): string;
|
|
261
246
|
/**
|
|
262
247
|
* What a settled answer took, as one quiet line: `56.3s`, then what it cost
|
|
263
248
|
* — only when the host can price it (`formatCost`) and the run actually
|
|
@@ -6,6 +6,7 @@
|
|
|
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 { hostOf } from './web-sources';
|
|
9
10
|
/**
|
|
10
11
|
* The question as the READER asked it.
|
|
11
12
|
*
|
|
@@ -39,20 +40,35 @@ export function describe(block) {
|
|
|
39
40
|
const lines = block.result ? block.result.split('\n').length : 0;
|
|
40
41
|
return lines ? { ...said, detail: lines === 1 ? '1 line' : `${lines} lines` } : said;
|
|
41
42
|
}
|
|
42
|
-
const READ = {
|
|
43
|
-
tally: ['document read', 'documents read'],
|
|
44
|
-
repeat: ['page', 'pages']
|
|
45
|
-
};
|
|
46
|
-
const SEARCH = { tally: ['search', 'searches'] };
|
|
47
43
|
function describeCall(block) {
|
|
48
44
|
const input = parseInput(block);
|
|
49
45
|
const command = typeof input.command === 'string' ? input.command : '';
|
|
50
46
|
if (block.name === 'Read') {
|
|
51
47
|
const path = str(input.file_path ?? input.path);
|
|
52
|
-
return { verb: 'Read', object: documentName(path)
|
|
48
|
+
return { verb: 'Read', object: documentName(path) };
|
|
49
|
+
}
|
|
50
|
+
if (block.name === 'Write')
|
|
51
|
+
return { verb: 'Wrote', object: baseName(str(input.file_path)) };
|
|
52
|
+
if (block.name === 'Edit')
|
|
53
|
+
return { verb: 'Edited', object: baseName(str(input.file_path)) };
|
|
54
|
+
if (block.name === 'mcp__library__search') {
|
|
55
|
+
return { verb: 'Searched the library for', object: str(input.query) };
|
|
56
|
+
}
|
|
57
|
+
if (block.name === 'mcp__library__document_content' || block.name === 'mcp__library__document_detail') {
|
|
58
|
+
return { verb: 'Read a document', object: '' };
|
|
59
|
+
}
|
|
60
|
+
if (block.name === 'mcp__supacrawl__supacrawl_search') {
|
|
61
|
+
return { verb: 'Searched the web for', object: str(input.query) };
|
|
62
|
+
}
|
|
63
|
+
if (block.name === 'mcp__supacrawl__supacrawl_scrape') {
|
|
64
|
+
return { verb: 'Read', object: hostOf(str(input.url)) };
|
|
65
|
+
}
|
|
66
|
+
if (block.name.startsWith('mcp__') && block.name.endsWith('__ocr')) {
|
|
67
|
+
const file = str(input.file_path ?? input.path ?? input.file ?? input.filename ?? input.url);
|
|
68
|
+
return { verb: 'Read the scanned pages of', object: baseName(file) };
|
|
53
69
|
}
|
|
54
70
|
if (block.name === 'Grep') {
|
|
55
|
-
return { verb: 'Looked for', object: str(input.pattern)
|
|
71
|
+
return { verb: 'Looked for', object: str(input.pattern) };
|
|
56
72
|
}
|
|
57
73
|
if (block.name === 'Glob') {
|
|
58
74
|
return { verb: 'Looked for documents', object: '' };
|
|
@@ -60,14 +76,14 @@ function describeCall(block) {
|
|
|
60
76
|
if (block.name === 'Bash') {
|
|
61
77
|
if (/\bgrep\b|\brg\b/.test(command)) {
|
|
62
78
|
const quoted = command.match(/["']([^"']{2,60})["']/);
|
|
63
|
-
return { verb: 'Looked for', object: quoted?.[1] ?? 'a phrase'
|
|
79
|
+
return { verb: 'Looked for', object: quoted?.[1] ?? 'a phrase' };
|
|
64
80
|
}
|
|
65
81
|
if (/\bls\b/.test(command))
|
|
66
82
|
return { verb: 'Looked through the library', object: '' };
|
|
67
83
|
if (/\bfind\b/.test(command))
|
|
68
84
|
return { verb: 'Looked for documents', object: '' };
|
|
69
85
|
if (/\bcat\b|\bhead\b|\bsed\b/.test(command)) {
|
|
70
|
-
return { verb: 'Read', object: documentName(lastPath(command))
|
|
86
|
+
return { verb: 'Read', object: documentName(lastPath(command)) };
|
|
71
87
|
}
|
|
72
88
|
if (/\bwc\b/.test(command))
|
|
73
89
|
return { verb: 'Checked', object: '' };
|
|
@@ -88,6 +104,7 @@ function parseInput(block) {
|
|
|
88
104
|
}
|
|
89
105
|
}
|
|
90
106
|
const str = (value) => (typeof value === 'string' ? value : '');
|
|
107
|
+
const baseName = (path) => path.split('/').filter(Boolean).at(-1) ?? '';
|
|
91
108
|
/** A staged path ends with the document's title slug; that is its name. */
|
|
92
109
|
function documentName(path) {
|
|
93
110
|
if (!path)
|
|
@@ -154,12 +171,15 @@ export class Transcript {
|
|
|
154
171
|
outcome = $state(null);
|
|
155
172
|
/** Sources for the answer, from the app's `citations` frame. */
|
|
156
173
|
citations = $state([]);
|
|
174
|
+
/** The document versions the persona wrote, in order. */
|
|
175
|
+
written = $state([]);
|
|
157
176
|
other = $state([]);
|
|
158
177
|
#fold = foldState();
|
|
159
178
|
reset() {
|
|
160
179
|
this.blocks = [];
|
|
161
180
|
this.outcome = null;
|
|
162
181
|
this.citations = [];
|
|
182
|
+
this.written = [];
|
|
163
183
|
this.other = [];
|
|
164
184
|
this.#fold = foldState();
|
|
165
185
|
}
|
|
@@ -204,6 +224,27 @@ export function fold(into, state, event) {
|
|
|
204
224
|
into.citations = (event.items ?? []).filter((item) => typeof item === 'object' && item !== null);
|
|
205
225
|
return true;
|
|
206
226
|
}
|
|
227
|
+
// The app emits this right after the tool result of a successful Write or
|
|
228
|
+
// Edit: one version of one document the persona wrote.
|
|
229
|
+
if (event.type === 'artefact') {
|
|
230
|
+
if (typeof event.name === 'string' && typeof event.version === 'number') {
|
|
231
|
+
// Assigned, then pushed through the state's own proxy: `??=` hands back the raw array, and that push redraws nothing.
|
|
232
|
+
into.written ??= [];
|
|
233
|
+
into.written.push({
|
|
234
|
+
name: event.name,
|
|
235
|
+
version: event.version,
|
|
236
|
+
media_type: typeof event.media_type === 'string' ? event.media_type : '',
|
|
237
|
+
bytes: typeof event.bytes === 'number' ? event.bytes : undefined,
|
|
238
|
+
formats: Array.isArray(event.formats) ? event.formats.map(String) : [],
|
|
239
|
+
title: typeof event.title === 'string' && event.title ? event.title : event.name,
|
|
240
|
+
kind: typeof event.kind === 'string' ? event.kind : null,
|
|
241
|
+
changes: typeof event.changes === 'string' ? event.changes : null,
|
|
242
|
+
pages: typeof event.pages === 'number' ? event.pages : 0,
|
|
243
|
+
at: Date.now()
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
return true;
|
|
247
|
+
}
|
|
207
248
|
// No persona and no sentence at this layer: `client.ts` raises this for a
|
|
208
249
|
// stream that would not open or died half-way, and the turn renders the
|
|
209
250
|
// unreachable line in whichever persona's voice the host named.
|
|
@@ -257,7 +298,7 @@ export function fold(into, state, event) {
|
|
|
257
298
|
if (cb.type === 'text')
|
|
258
299
|
into.blocks.push({ kind: 'text', index: position, text: '' });
|
|
259
300
|
else if (cb.type === 'thinking')
|
|
260
|
-
into.blocks.push({ kind: 'thinking', index: position, text: '' });
|
|
301
|
+
into.blocks.push({ kind: 'thinking', index: position, text: '', startedAt: Date.now() });
|
|
261
302
|
else if (cb.type === 'tool_use')
|
|
262
303
|
into.blocks.push({
|
|
263
304
|
kind: 'tool',
|
|
@@ -268,6 +309,12 @@ export function fold(into, state, event) {
|
|
|
268
309
|
});
|
|
269
310
|
return true;
|
|
270
311
|
}
|
|
312
|
+
if (inner.type === 'content_block_stop' && inner.index !== undefined) {
|
|
313
|
+
const block = into.blocks[state.open.get(inner.index) ?? -1];
|
|
314
|
+
if (block?.kind === 'thinking' && block.startedAt !== undefined)
|
|
315
|
+
block.endedAt = Date.now();
|
|
316
|
+
return true;
|
|
317
|
+
}
|
|
271
318
|
if (inner.type === 'content_block_delta' && inner.index !== undefined) {
|
|
272
319
|
const position = state.open.get(inner.index);
|
|
273
320
|
if (position === undefined)
|
|
@@ -321,15 +368,11 @@ function renderResult(content) {
|
|
|
321
368
|
/**
|
|
322
369
|
* Fold a turn's flat block list into what a reader should actually see.
|
|
323
370
|
*
|
|
324
|
-
*
|
|
371
|
+
* Two problems this solves, reported off a real transcript:
|
|
325
372
|
*
|
|
326
|
-
* 1.
|
|
327
|
-
*
|
|
328
|
-
*
|
|
329
|
-
* 2. "Reading pspf guidelines 2026" appeared five times in a row — five pages
|
|
330
|
-
* of one document, which is one act of reading to a human. Consecutive
|
|
331
|
-
* steps with the same label collapse to one row carrying a count.
|
|
332
|
-
* 3. The persona's between-tool narration was rendering unfolded, as answer prose.
|
|
373
|
+
* 1. Contiguous activity becomes ONE group, so the answer's prose is never
|
|
374
|
+
* split by each step; every step stays its own collapsed row in it.
|
|
375
|
+
* 2. The persona's between-tool narration was rendering unfolded, as answer prose.
|
|
333
376
|
* The caller stream carries no `thinking` blocks at all — measured on
|
|
334
377
|
* production 11/09/2026 — so "Let me also check whether…" arrives as an
|
|
335
378
|
* ordinary `text` block, indistinguishable from the answer except by
|
|
@@ -337,7 +380,7 @@ function renderResult(content) {
|
|
|
337
380
|
* narration; only the run of text after the LAST tool call is the answer.
|
|
338
381
|
* Narration folds into the activity group as a thinking-shaped step.
|
|
339
382
|
*
|
|
340
|
-
* That
|
|
383
|
+
* That second rule is provisional while a turn streams, and deliberately so: a
|
|
341
384
|
* text block that is currently last IS the answer as far as anything can know,
|
|
342
385
|
* and renders as prose. The tool call that arrives after it re-homes it into
|
|
343
386
|
* the group. Nothing here holds state to make that work — `segment` is pure
|
|
@@ -371,19 +414,11 @@ export function segment(blocks, describeTool) {
|
|
|
371
414
|
if (block.kind !== 'tool' && !block.text.trim())
|
|
372
415
|
continue;
|
|
373
416
|
if (!current) {
|
|
374
|
-
current = { kind: 'activity', index: block.index, steps: []
|
|
417
|
+
current = { kind: 'activity', index: block.index, steps: [] };
|
|
375
418
|
out.push(current);
|
|
376
419
|
}
|
|
377
420
|
const words = block.kind === 'tool' ? wordsFor(block, describeTool) : undefined;
|
|
378
|
-
|
|
379
|
-
if (last && sameStep(last, block, words)) {
|
|
380
|
-
last.repeats += 1;
|
|
381
|
-
}
|
|
382
|
-
else {
|
|
383
|
-
current.steps.push({ block, repeats: 1, words });
|
|
384
|
-
}
|
|
385
|
-
if (words?.tally)
|
|
386
|
-
count(current, words.tally);
|
|
421
|
+
current.steps.push({ block, words });
|
|
387
422
|
}
|
|
388
423
|
return out;
|
|
389
424
|
}
|
|
@@ -399,33 +434,6 @@ const HANDS_IN = 'StructuredOutput';
|
|
|
399
434
|
function isStep(block) {
|
|
400
435
|
return block.kind === 'tool' && block.name !== HANDS_IN;
|
|
401
436
|
}
|
|
402
|
-
function sameStep(last, block, words) {
|
|
403
|
-
if (last.block.kind !== block.kind)
|
|
404
|
-
return false;
|
|
405
|
-
if (block.kind === 'thinking')
|
|
406
|
-
return true;
|
|
407
|
-
// Two narration sentences are two things the persona said; collapsing them to
|
|
408
|
-
// one row with a count would lose the second one entirely.
|
|
409
|
-
if (block.kind === 'text')
|
|
410
|
-
return false;
|
|
411
|
-
return last.words?.verb === words?.verb && last.words?.object === words?.object;
|
|
412
|
-
}
|
|
413
|
-
function count(group, [one, many]) {
|
|
414
|
-
const seen = group.tallies.find((t) => t.one === one && t.many === many);
|
|
415
|
-
if (seen)
|
|
416
|
-
seen.count += 1;
|
|
417
|
-
else
|
|
418
|
-
group.tallies.push({ one, many, count: 1 });
|
|
419
|
-
}
|
|
420
|
-
/** One line describing a whole investigation, for the collapsed state.
|
|
421
|
-
*
|
|
422
|
-
* Counts of what the persona did are fine ("1 search · 2 documents read"); how
|
|
423
|
-
* it organises what it knows is not — no collection count, no corpus.
|
|
424
|
-
*/
|
|
425
|
-
export function summariseActivity(group) {
|
|
426
|
-
const parts = group.tallies.map((t) => `${t.count} ${t.count === 1 ? t.one : t.many}`);
|
|
427
|
-
return parts.length ? parts.join(' · ') : 'Looked into it';
|
|
428
|
-
}
|
|
429
437
|
/**
|
|
430
438
|
* What a settled answer took, as one quiet line: `56.3s`, then what it cost
|
|
431
439
|
* — only when the host can price it (`formatCost`) and the run actually
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The renderers for a persona's in-answer visuals, keyed on a fence's language.
|
|
3
|
+
*
|
|
4
|
+
* The persona writes a fenced block and the page draws it: the data is the
|
|
5
|
+
* persona's, every colour, face and size is the app's. A renderer is a
|
|
6
|
+
* component and the `parse` that turns the block's text into the `value` it
|
|
7
|
+
* draws, throwing on a block that is not one (the page then shows the code).
|
|
8
|
+
* `parse` returns anything, not JSON alone, so a renderer for a language that
|
|
9
|
+
* is not JSON registers the same way.
|
|
10
|
+
*/
|
|
11
|
+
import type { Component } from 'svelte';
|
|
12
|
+
export interface Visual {
|
|
13
|
+
component: Component<{
|
|
14
|
+
value: unknown;
|
|
15
|
+
source: string;
|
|
16
|
+
}>;
|
|
17
|
+
/** The block's text as the value its component draws; throws on a block that is invalid. */
|
|
18
|
+
parse: (source: string) => unknown;
|
|
19
|
+
}
|
|
20
|
+
export interface TimelineValue {
|
|
21
|
+
title: string;
|
|
22
|
+
events: Array<{
|
|
23
|
+
date: string;
|
|
24
|
+
title: string;
|
|
25
|
+
detail: string;
|
|
26
|
+
}>;
|
|
27
|
+
}
|
|
28
|
+
export interface CardValue {
|
|
29
|
+
name: string;
|
|
30
|
+
kind: string;
|
|
31
|
+
summary: string;
|
|
32
|
+
facts: Array<{
|
|
33
|
+
label: string;
|
|
34
|
+
value: string;
|
|
35
|
+
}>;
|
|
36
|
+
}
|
|
37
|
+
/** A chart, a timeline and an entity card. An app adds to these or replaces one through `ChatExtensions.visuals`. */
|
|
38
|
+
export declare const defaultVisuals: Record<string, Visual>;
|
package/dist/visuals.js
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The renderers for a persona's in-answer visuals, keyed on a fence's language.
|
|
3
|
+
*
|
|
4
|
+
* The persona writes a fenced block and the page draws it: the data is the
|
|
5
|
+
* persona's, every colour, face and size is the app's. A renderer is a
|
|
6
|
+
* component and the `parse` that turns the block's text into the `value` it
|
|
7
|
+
* draws, throwing on a block that is not one (the page then shows the code).
|
|
8
|
+
* `parse` returns anything, not JSON alone, so a renderer for a language that
|
|
9
|
+
* is not JSON registers the same way.
|
|
10
|
+
*/
|
|
11
|
+
import EntityCard from './components/visual-block/entity-card.svelte';
|
|
12
|
+
import Timeline from './components/visual-block/timeline.svelte';
|
|
13
|
+
import VegaChart from './components/visual-block/vega-chart.svelte';
|
|
14
|
+
const isRecord = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
15
|
+
const text = (value) => typeof value === 'string' ? value : typeof value === 'number' ? String(value) : '';
|
|
16
|
+
const object = (source) => {
|
|
17
|
+
const value = JSON.parse(source);
|
|
18
|
+
if (!isRecord(value))
|
|
19
|
+
throw new Error('not an object');
|
|
20
|
+
return value;
|
|
21
|
+
};
|
|
22
|
+
/** Any string naming an address: a spec that carries one could fetch it, or link to it carrying the conversation out. */
|
|
23
|
+
const ADDRESS = /^[a-z][a-z0-9+.-]*:|\/\//i;
|
|
24
|
+
/**
|
|
25
|
+
* A spec that names an address anywhere, by key or by value, or carries
|
|
26
|
+
* `usermeta`, whose `embedOptions` vega-embed merges over the app's own
|
|
27
|
+
* (`config` or `patch` as a URL is fetched, `actions` turns the export menu back on).
|
|
28
|
+
*/
|
|
29
|
+
function addressed(value) {
|
|
30
|
+
if (typeof value === 'string')
|
|
31
|
+
return ADDRESS.test(value.trim());
|
|
32
|
+
if (Array.isArray(value))
|
|
33
|
+
return value.some(addressed);
|
|
34
|
+
if (!isRecord(value))
|
|
35
|
+
return false;
|
|
36
|
+
return Object.entries(value).some(
|
|
37
|
+
// `$schema` names the spec's own version and is never drawn or fetched (`vega-chart` drops it).
|
|
38
|
+
([key, inner]) => key !== '$schema' && (key === 'url' || key === 'href' || key === 'usermeta' || addressed(inner)));
|
|
39
|
+
}
|
|
40
|
+
const VIEWS = ['mark', 'layer', 'hconcat', 'vconcat', 'concat', 'facet', 'repeat'];
|
|
41
|
+
function parseVega(source) {
|
|
42
|
+
const spec = object(source);
|
|
43
|
+
if (!VIEWS.some((key) => key in spec))
|
|
44
|
+
throw new Error('not a view');
|
|
45
|
+
if (addressed(spec))
|
|
46
|
+
throw new Error('names an address');
|
|
47
|
+
return spec;
|
|
48
|
+
}
|
|
49
|
+
function parseTimeline(source) {
|
|
50
|
+
const value = object(source);
|
|
51
|
+
if (!Array.isArray(value.events))
|
|
52
|
+
throw new Error('no events');
|
|
53
|
+
return {
|
|
54
|
+
title: text(value.title),
|
|
55
|
+
events: value.events.map((event) => {
|
|
56
|
+
if (!isRecord(event) || !text(event.title))
|
|
57
|
+
throw new Error('an event with no title');
|
|
58
|
+
return { date: text(event.date), title: text(event.title), detail: text(event.detail) };
|
|
59
|
+
})
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
function parseCard(source) {
|
|
63
|
+
const value = object(source);
|
|
64
|
+
if (!text(value.name))
|
|
65
|
+
throw new Error('no name');
|
|
66
|
+
const facts = Array.isArray(value.facts) ? value.facts : [];
|
|
67
|
+
return {
|
|
68
|
+
name: text(value.name),
|
|
69
|
+
kind: text(value.kind),
|
|
70
|
+
summary: text(value.summary),
|
|
71
|
+
facts: facts
|
|
72
|
+
.filter(isRecord)
|
|
73
|
+
.map((fact) => ({ label: text(fact.label), value: text(fact.value) }))
|
|
74
|
+
.filter((fact) => fact.label || fact.value)
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
/** A chart, a timeline and an entity card. An app adds to these or replaces one through `ChatExtensions.visuals`. */
|
|
78
|
+
export const defaultVisuals = {
|
|
79
|
+
'vega-lite': { component: VegaChart, parse: parseVega },
|
|
80
|
+
timeline: { component: Timeline, parse: parseTimeline },
|
|
81
|
+
card: { component: EntityCard, parse: parseCard }
|
|
82
|
+
};
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The web pages an answer drew on, listed apart from the library's numbered
|
|
3
|
+
* sources: the pages the turn's supacrawl calls read, and the Markdown links
|
|
4
|
+
* in the answer itself. A search's results are what it found, not what the
|
|
5
|
+
* answer drew on, so they stay in the search's own row (`searchPages`). The
|
|
6
|
+
* page numbers them after the library's, since the persona cites a page by its
|
|
7
|
+
* link and nothing here is a passage it was handed with a number.
|
|
8
|
+
*/
|
|
9
|
+
import type { Block, ToolBlock } from './transcript.svelte';
|
|
10
|
+
export interface WebSource {
|
|
11
|
+
url: string;
|
|
12
|
+
/** The page's title, or its host where nothing gave one. */
|
|
13
|
+
title: string;
|
|
14
|
+
host: string;
|
|
15
|
+
/** The turn read this page whole (a scrape), rather than only linking it. */
|
|
16
|
+
read?: true;
|
|
17
|
+
/** The page's own date, as it gave it, where it gave one. */
|
|
18
|
+
date?: string;
|
|
19
|
+
}
|
|
20
|
+
/** A page a search found, for its step's expansion. */
|
|
21
|
+
export interface FoundPage {
|
|
22
|
+
url: string;
|
|
23
|
+
title: string;
|
|
24
|
+
host: string;
|
|
25
|
+
snippet: string;
|
|
26
|
+
}
|
|
27
|
+
/** The key two spellings of one page share. */
|
|
28
|
+
export declare const webKey: (url: string) => string;
|
|
29
|
+
/** A URL's host without a leading `www.`; the text as given where it is no URL. */
|
|
30
|
+
export declare function hostOf(url: string): string;
|
|
31
|
+
/** The pages a successful web search step found. */
|
|
32
|
+
export declare function searchPages(block: ToolBlock): FoundPage[];
|
|
33
|
+
/** The pages, once each, in the order the turn met them. */
|
|
34
|
+
export declare function webSources(blocks: Block[], answer: string): WebSource[];
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The web pages an answer drew on, listed apart from the library's numbered
|
|
3
|
+
* sources: the pages the turn's supacrawl calls read, and the Markdown links
|
|
4
|
+
* in the answer itself. A search's results are what it found, not what the
|
|
5
|
+
* answer drew on, so they stay in the search's own row (`searchPages`). The
|
|
6
|
+
* page numbers them after the library's, since the persona cites a page by its
|
|
7
|
+
* link and nothing here is a passage it was handed with a number.
|
|
8
|
+
*/
|
|
9
|
+
/** The key two spellings of one page share. */
|
|
10
|
+
export const webKey = (url) => url.replace(/#.*$/, '').replace(/\/$/, '');
|
|
11
|
+
/** A URL's host without a leading `www.`; the text as given where it is no URL. */
|
|
12
|
+
export function hostOf(url) {
|
|
13
|
+
try {
|
|
14
|
+
return new URL(url).hostname.replace(/^www\./, '');
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
return url;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
const LINK = /\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g;
|
|
21
|
+
function isWeb(url) {
|
|
22
|
+
return typeof url === 'string' && /^https?:\/\//i.test(url);
|
|
23
|
+
}
|
|
24
|
+
function parse(text) {
|
|
25
|
+
try {
|
|
26
|
+
return text ? JSON.parse(text) : undefined;
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
return undefined;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
/** The pages a successful web search step found. */
|
|
33
|
+
export function searchPages(block) {
|
|
34
|
+
if (block.name !== 'mcp__supacrawl__supacrawl_search' || block.isError)
|
|
35
|
+
return [];
|
|
36
|
+
const found = parse(block.result);
|
|
37
|
+
if (!Array.isArray(found?.results))
|
|
38
|
+
return [];
|
|
39
|
+
const pages = [];
|
|
40
|
+
for (const item of found.results) {
|
|
41
|
+
if (typeof item !== 'object' || item === null || !isWeb(item.url))
|
|
42
|
+
continue;
|
|
43
|
+
const host = hostOf(item.url);
|
|
44
|
+
const title = [item.title, host].find((t) => typeof t === 'string' && t.trim());
|
|
45
|
+
const snippet = [item.snippet, item.description, item.content].find((t) => typeof t === 'string' && t.trim());
|
|
46
|
+
pages.push({ url: item.url, title: title.trim(), host, snippet: (snippet ?? '').trim() });
|
|
47
|
+
}
|
|
48
|
+
return pages;
|
|
49
|
+
}
|
|
50
|
+
function fromTool(block) {
|
|
51
|
+
if (!block.name.startsWith('mcp__supacrawl__') || !block.name.endsWith('_scrape') || block.isError)
|
|
52
|
+
return [];
|
|
53
|
+
const input = parse(block.rawInput);
|
|
54
|
+
if (!isWeb(input?.url))
|
|
55
|
+
return [];
|
|
56
|
+
const page = parse(block.result);
|
|
57
|
+
const title = [page?.title, page?.metadata?.title].find((t) => typeof t === 'string');
|
|
58
|
+
const date = [
|
|
59
|
+
page?.metadata?.publishedTime,
|
|
60
|
+
page?.metadata?.published,
|
|
61
|
+
page?.metadata?.date,
|
|
62
|
+
page?.metadata?.modifiedTime
|
|
63
|
+
].find((d) => typeof d === 'string' && !Number.isNaN(Date.parse(d)));
|
|
64
|
+
return [{ url: input.url, title: title, date: date }];
|
|
65
|
+
}
|
|
66
|
+
/** The pages, once each, in the order the turn met them. */
|
|
67
|
+
export function webSources(blocks, answer) {
|
|
68
|
+
const seen = new Map();
|
|
69
|
+
const add = (url, title, read) => {
|
|
70
|
+
const key = webKey(url);
|
|
71
|
+
if (seen.has(key))
|
|
72
|
+
return;
|
|
73
|
+
const host = hostOf(url);
|
|
74
|
+
if (host === url)
|
|
75
|
+
return;
|
|
76
|
+
seen.set(key, {
|
|
77
|
+
url,
|
|
78
|
+
title: title?.trim() || host,
|
|
79
|
+
host,
|
|
80
|
+
...(read ? { read: true, date: read.date } : {})
|
|
81
|
+
});
|
|
82
|
+
};
|
|
83
|
+
for (const block of blocks)
|
|
84
|
+
if (block.kind === 'tool')
|
|
85
|
+
for (const p of fromTool(block))
|
|
86
|
+
add(p.url, p.title, { date: p.date });
|
|
87
|
+
for (const match of answer.matchAll(LINK))
|
|
88
|
+
add(match[2], match[1]);
|
|
89
|
+
return [...seen.values()];
|
|
90
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The documents a persona writes while it answers.
|
|
3
|
+
*
|
|
4
|
+
* The app emits an `artefact` frame after each successful Write or Edit, and a
|
|
5
|
+
* reopened conversation lists the same versions on its turns. The page shows
|
|
6
|
+
* each version in the reading column and links it for download; the bytes
|
|
7
|
+
* come from the host's own route, never from this package.
|
|
8
|
+
*/
|
|
9
|
+
import { type ChatCopy } from './copy';
|
|
10
|
+
import type { Turn } from './transcript.svelte';
|
|
11
|
+
/** One version of one document, as the wire names it. */
|
|
12
|
+
export interface Written {
|
|
13
|
+
/** A path relative to the conversation's folder; may contain "/". */
|
|
14
|
+
name: string;
|
|
15
|
+
/** Counts per name from 1 across the whole conversation. */
|
|
16
|
+
version: number;
|
|
17
|
+
/** `text/markdown` or `text/html`. */
|
|
18
|
+
media_type: string;
|
|
19
|
+
bytes?: number;
|
|
20
|
+
/** What the app can render it as beyond the source (`docx`, `pdf`); empty
|
|
21
|
+
* where it has no renderer. */
|
|
22
|
+
formats: string[];
|
|
23
|
+
/** What a reader calls it: "Minute: increased rent ceiling". */
|
|
24
|
+
title: string;
|
|
25
|
+
/** What sort of document it is ("minute"); null where nothing says. */
|
|
26
|
+
kind: string | null;
|
|
27
|
+
/** What this version changed since the one before it, in a phrase; null
|
|
28
|
+
* for the first, or where nothing says. */
|
|
29
|
+
changes: string | null;
|
|
30
|
+
/** How many pages it runs to; 0 where the app cannot tell. */
|
|
31
|
+
pages: number;
|
|
32
|
+
/** When this page saw the version arrive, epoch ms. Absent for one read back. */
|
|
33
|
+
at?: number;
|
|
34
|
+
}
|
|
35
|
+
/** The source of one version, to show it in the pane. */
|
|
36
|
+
export type LoadWritten = (name: string, version: number) => Promise<{
|
|
37
|
+
text: string;
|
|
38
|
+
mediaType: string;
|
|
39
|
+
}>;
|
|
40
|
+
/** Where one version downloads as `format` (`source` or one of its `formats`). */
|
|
41
|
+
export type WrittenUrl = (name: string, version: number, format: string) => string;
|
|
42
|
+
export declare const writtenKey: (written: Written) => string;
|
|
43
|
+
export declare const isHtml: (mediaType: string) => boolean;
|
|
44
|
+
/** The newest version of each document in `written`, in the order they first appear. */
|
|
45
|
+
export declare function newestPerName(written: Written[]): Written[];
|
|
46
|
+
/** "Minute", or "Document" where the document has no kind. */
|
|
47
|
+
export declare function kindLabel(written: Written, words: ChatCopy): string;
|
|
48
|
+
/** "Minute · 1 page · v1": kind, pages where known, version. */
|
|
49
|
+
export declare function writtenMeta(written: Written, words: ChatCopy): string;
|
|
50
|
+
/** The download a card's chip offers: Word where the app renders it, else the source as written. */
|
|
51
|
+
export declare function chipFor(written: Written, words: ChatCopy): {
|
|
52
|
+
format: string;
|
|
53
|
+
file: string;
|
|
54
|
+
label: string;
|
|
55
|
+
};
|
|
56
|
+
/** A leading `---` front matter block is metadata, not the document. */
|
|
57
|
+
export declare function stripFrontMatter(text: string): string;
|
|
58
|
+
/** Every version of `name` across the conversation, oldest first. */
|
|
59
|
+
export declare function writtenVersions(turns: Turn[], name: string): Written[];
|
|
60
|
+
/** What a version downloads as: its source, then each format the app renders. */
|
|
61
|
+
export declare function downloads(written: Written, words: ChatCopy): Array<{
|
|
62
|
+
format: string;
|
|
63
|
+
label: string;
|
|
64
|
+
}>;
|
|
65
|
+
/** An empty sandbox stops scripts, not fetches: a written page's <img> or CSS url() would be a beacon without this. */
|
|
66
|
+
export declare const FRAME_CSP = "<meta http-equiv=\"Content-Security-Policy\" content=\"default-src 'none'; style-src 'unsafe-inline'; img-src data:; font-src data:\">";
|
|
67
|
+
/**
|
|
68
|
+
* A written HTML page as its sandboxed frame shows it: under `FRAME_CSP`, and
|
|
69
|
+
* without a refresh, which navigates the frame itself, a fetch no policy stops.
|
|
70
|
+
*/
|
|
71
|
+
export declare function framed(html: string): string;
|