@ixo/oracle-runtime-workers 0.35.1 → 0.36.1
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/package.json +1 -1
- package/src/artifacts/tool.ts +7 -4
- package/src/channels/contract.ts +3 -2
- package/src/channels/turns.ts +27 -9
- package/src/core/env.ts +4 -2
- package/src/core/llm.ts +70 -1
- package/src/core/main-agent.ts +5 -0
- package/src/core/middlewares/result-cap.ts +2 -0
- package/src/core/middlewares/turn-boundary.ts +14 -0
- package/src/delivery/plan.ts +89 -20
- package/src/delivery/profile.ts +25 -20
- package/src/delivery/prompt.ts +34 -7
- package/src/delivery/schema.ts +69 -5
- package/src/delivery/shaper.ts +329 -87
- package/src/delivery/types.ts +48 -8
- package/src/do/contracts.ts +2 -0
- package/src/do/run-coordinator.ts +13 -3
- package/src/do/run-store.ts +53 -3
- package/src/do/transcript.ts +17 -1
- package/src/do/user-oracle-do.ts +51 -3
- package/src/matrix/reply-parts.ts +9 -1
- package/src/plugins/ixo-chain/names/client.ts +7 -3
- package/src/plugins/sandbox/index.ts +1 -0
- package/src/plugins/sandbox/sandbox.plugin.ts +41 -8
- package/src/plugins/sandbox/send-file.ts +315 -0
- package/src/sqlite/blob-codec.ts +16 -31
package/src/delivery/schema.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { ActionReferenceSchema } from '@ixo/ixo-transaction/action-proposals';
|
|
2
2
|
import { parseReference, type ActionReferenceV1 } from '@ixo/action-protocol';
|
|
3
3
|
import { z } from 'zod';
|
|
4
|
-
import type { ReplyContent, ReplyPlan } from './types';
|
|
4
|
+
import type { ReplyContent, ReplyFile, ReplyPlan } from './types';
|
|
5
5
|
|
|
6
6
|
const ArtifactRefSchema = z.object({
|
|
7
7
|
artifactId: z.string(),
|
|
@@ -12,6 +12,33 @@ const ArtifactRefSchema = z.object({
|
|
|
12
12
|
expiresAt: z.string(),
|
|
13
13
|
});
|
|
14
14
|
|
|
15
|
+
function isSignedLink(value: string): boolean {
|
|
16
|
+
try {
|
|
17
|
+
const url = new URL(value);
|
|
18
|
+
return url.protocol === 'https:' && !url.username && !url.password;
|
|
19
|
+
} catch {
|
|
20
|
+
return false;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The most a gateway takes (`maxFileUrlChars`): with room for its caption the
|
|
26
|
+
* link must still fit one 4,096-character text message.
|
|
27
|
+
*/
|
|
28
|
+
export const REPLY_FILE_URL_MAX = 3800;
|
|
29
|
+
|
|
30
|
+
export const ReplyFileSchema = z.object({
|
|
31
|
+
url: z
|
|
32
|
+
.string()
|
|
33
|
+
.max(REPLY_FILE_URL_MAX)
|
|
34
|
+
.refine(isSignedLink, 'must be an https URL without credentials'),
|
|
35
|
+
filename: z.string().min(1),
|
|
36
|
+
mimetype: z.string().min(1),
|
|
37
|
+
bytes: z.number().int().nonnegative().optional(),
|
|
38
|
+
caption: z.string().max(1024).optional(),
|
|
39
|
+
expiresAt: z.string().optional(),
|
|
40
|
+
});
|
|
41
|
+
|
|
15
42
|
export const ReplyPlanSchema = z.object({
|
|
16
43
|
v: z.literal(1),
|
|
17
44
|
parts: z.array(
|
|
@@ -44,6 +71,11 @@ export const ReplyPlanSchema = z.object({
|
|
|
44
71
|
kind: z.literal('artifact'),
|
|
45
72
|
artifact: ArtifactRefSchema,
|
|
46
73
|
}),
|
|
74
|
+
z.object({
|
|
75
|
+
partId: z.string(),
|
|
76
|
+
kind: z.literal('file'),
|
|
77
|
+
file: ReplyFileSchema,
|
|
78
|
+
}),
|
|
47
79
|
]),
|
|
48
80
|
),
|
|
49
81
|
});
|
|
@@ -65,10 +97,17 @@ function linkLabel(title: string): string {
|
|
|
65
97
|
return title.replace(/[[\]]/g, '');
|
|
66
98
|
}
|
|
67
99
|
|
|
100
|
+
function fileLine(file: ReplyFile): string {
|
|
101
|
+
const link = `[${linkLabel(file.filename)}](${file.url})`;
|
|
102
|
+
return file.caption ? `${file.caption}\n\n${link}` : link;
|
|
103
|
+
}
|
|
104
|
+
|
|
68
105
|
/**
|
|
69
|
-
* The plan as one Markdown message: what
|
|
70
|
-
*
|
|
71
|
-
*
|
|
106
|
+
* The plan as one Markdown message: what gateways that predate plans receive.
|
|
107
|
+
* A file is a link line, so a gateway that cannot read the `file` part
|
|
108
|
+
* degrades instead of losing the file. Never stored as a run's text, which
|
|
109
|
+
* stays the model's own, and never shown to the model or mirrored to a room:
|
|
110
|
+
* see `planTextForModel`.
|
|
72
111
|
*/
|
|
73
112
|
export function planText(plan: { parts: readonly ReplyContent[] }): string {
|
|
74
113
|
return plan.parts
|
|
@@ -79,7 +118,32 @@ export function planText(plan: { parts: readonly ReplyContent[] }): string {
|
|
|
79
118
|
? `[${linkLabel(part.action.title)}](${part.action.url})`
|
|
80
119
|
: part.kind === 'hub-action'
|
|
81
120
|
? `[${linkLabel(part.summary)}](${part.actionReference.url})`
|
|
82
|
-
:
|
|
121
|
+
: part.kind === 'file'
|
|
122
|
+
? fileLine(part.file)
|
|
123
|
+
: `[${linkLabel(part.artifact.title)}](${part.artifact.url})`,
|
|
83
124
|
)
|
|
84
125
|
.join('\n\n');
|
|
85
126
|
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* `planText` without a file's signed link, which is named instead: for the
|
|
130
|
+
* model, as when a user quotes an earlier reply, for the room mirror, and for
|
|
131
|
+
* the transcript, all of which outlive the link.
|
|
132
|
+
*/
|
|
133
|
+
export function planTextForModel(plan: {
|
|
134
|
+
parts: readonly ReplyContent[];
|
|
135
|
+
}): string {
|
|
136
|
+
return planText({
|
|
137
|
+
parts: plan.parts.map(
|
|
138
|
+
(part): ReplyContent =>
|
|
139
|
+
part.kind === 'file'
|
|
140
|
+
? {
|
|
141
|
+
kind: 'text',
|
|
142
|
+
text: [part.file.caption, `[File sent: ${part.file.filename}]`]
|
|
143
|
+
.filter(Boolean)
|
|
144
|
+
.join('\n\n'),
|
|
145
|
+
}
|
|
146
|
+
: part,
|
|
147
|
+
),
|
|
148
|
+
});
|
|
149
|
+
}
|
package/src/delivery/shaper.ts
CHANGED
|
@@ -1,8 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The deterministic shaper: one model step's Markdown → chat messages, or a
|
|
3
|
-
* spill (lead message, artefact, closing
|
|
4
|
-
*
|
|
5
|
-
*
|
|
3
|
+
* spill (lead message, artefact, closing message) when the step is
|
|
4
|
+
* document-length: longer than `spillChars`, or holding a list, table or
|
|
5
|
+
* code block too long to read in chat. Everything else stays in chat: short
|
|
6
|
+
* messages are grouped toward `maxBubbles`, a step that cannot fit that many
|
|
7
|
+
* keeps more messages rather than losing text, a table is rewritten as one
|
|
8
|
+
* line per row, and no message is longer than `bubbleMax`. No model call; the same
|
|
9
|
+
* input always gives the same output, which is what makes a Reply Plan safe
|
|
10
|
+
* to rebuild on recovery.
|
|
6
11
|
*/
|
|
7
12
|
import { marked, type Token, type Tokens } from 'marked';
|
|
8
13
|
import type { ChatLimits } from './types';
|
|
@@ -11,9 +16,17 @@ export interface SpillShape {
|
|
|
11
16
|
title: string;
|
|
12
17
|
/** The whole step as the model wrote it: the artefact's content. */
|
|
13
18
|
markdown: string;
|
|
14
|
-
/**
|
|
19
|
+
/**
|
|
20
|
+
* What stays in chat before the link, within `bubbleMax`: the first block
|
|
21
|
+
* whole (a paragraph over `bubbleMax` cut at a sentence), or a list's or
|
|
22
|
+
* table's lead-in and first items.
|
|
23
|
+
*/
|
|
15
24
|
lead: string;
|
|
16
|
-
/**
|
|
25
|
+
/**
|
|
26
|
+
* What stays in chat after the link, within `bubbleMax`: the step's last
|
|
27
|
+
* paragraph (with the paragraph before it when both fit), or its closing
|
|
28
|
+
* list or table previewed. Its conclusion, next actions or question.
|
|
29
|
+
*/
|
|
17
30
|
closing?: string;
|
|
18
31
|
}
|
|
19
32
|
|
|
@@ -37,9 +50,13 @@ const isCode = (t: Token): t is Tokens.Code => t.type === 'code';
|
|
|
37
50
|
const isTable = (t: Token): t is Tokens.Table => t.type === 'table';
|
|
38
51
|
const isHtml = (t: Token): t is Tokens.HTML => t.type === 'html';
|
|
39
52
|
|
|
40
|
-
|
|
53
|
+
// A pinned locale, so a rebuilt plan splits the same way on every runtime.
|
|
54
|
+
const sentenceSegmenter = new Intl.Segmenter('en', {
|
|
41
55
|
granularity: 'sentence',
|
|
42
56
|
});
|
|
57
|
+
const graphemeSegmenter = new Intl.Segmenter('en', {
|
|
58
|
+
granularity: 'grapheme',
|
|
59
|
+
});
|
|
43
60
|
|
|
44
61
|
/**
|
|
45
62
|
* HTML elements a model mixes into Markdown. Anything else in angle brackets
|
|
@@ -145,19 +162,34 @@ function plainText(text: string): string {
|
|
|
145
162
|
.trim();
|
|
146
163
|
}
|
|
147
164
|
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
.
|
|
165
|
+
/** One table row as a chat line: `- **first cell** · Header: value · …`. */
|
|
166
|
+
function rowLine(first: string, rest: string[]): string {
|
|
167
|
+
const parts = [...(first ? [`**${first}**`] : []), ...rest].filter(Boolean);
|
|
168
|
+
return parts.length ? `- ${parts.join(' · ')}` : '';
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** A table's chat lines, one per row; a table with no body rows is its header. */
|
|
172
|
+
function tableLines(table: Tokens.Table): string[] {
|
|
173
|
+
const headers = table.header.map((cell) => cell.text.trim());
|
|
174
|
+
const lines =
|
|
175
|
+
table.rows.length === 0
|
|
176
|
+
? [rowLine(headers[0] ?? '', headers.slice(1))]
|
|
177
|
+
: table.rows.map((row) => {
|
|
178
|
+
const [first, ...rest] = row.map((cell) => cell.text.trim());
|
|
179
|
+
return rowLine(
|
|
180
|
+
first ?? '',
|
|
181
|
+
rest.map((value, i) => {
|
|
182
|
+
const header = headers[i + 1];
|
|
183
|
+
return header && value ? `${header}: ${value}` : value;
|
|
184
|
+
}),
|
|
185
|
+
);
|
|
186
|
+
});
|
|
187
|
+
return lines.filter(Boolean);
|
|
159
188
|
}
|
|
160
189
|
|
|
190
|
+
const tableAsList = (table: Tokens.Table): string =>
|
|
191
|
+
tableLines(table).join('\n');
|
|
192
|
+
|
|
161
193
|
function toBlocks(markdown: string, limits: ChatLimits): Block[] {
|
|
162
194
|
const blocks: Block[] = [];
|
|
163
195
|
for (const token of marked.lexer(markdown)) {
|
|
@@ -171,11 +203,8 @@ function toBlocks(markdown: string, limits: ChatLimits): Block[] {
|
|
|
171
203
|
} else if (isCode(token)) {
|
|
172
204
|
blocks.push({ kind: 'code', md: token.raw.trim(), token });
|
|
173
205
|
} else if (isTable(token)) {
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
md: limits.tables ? token.raw.trim() : tableAsList(token),
|
|
177
|
-
token,
|
|
178
|
-
});
|
|
206
|
+
const md = limits.tables ? token.raw.trim() : tableAsList(token);
|
|
207
|
+
if (!isBlank(md)) blocks.push({ kind: 'table', md, token });
|
|
179
208
|
} else if (token.type === 'blockquote') {
|
|
180
209
|
blocks.push({ kind: 'quote', md: chatMarkdown(token).trim(), token });
|
|
181
210
|
} else {
|
|
@@ -228,11 +257,40 @@ export function splitText(text: string, max: number): string[] {
|
|
|
228
257
|
if (sentences.length > 1) return packPieces(sentences, '', size);
|
|
229
258
|
const words = text.split(/(?<=\s)/);
|
|
230
259
|
if (words.length > 1) return packPieces(words, '', size);
|
|
260
|
+
// The hard cut never splits a grapheme (an emoji, a surrogate pair); one
|
|
261
|
+
// grapheme longer than `size` is a piece of its own.
|
|
231
262
|
const out: string[] = [];
|
|
232
|
-
|
|
263
|
+
let rest = text;
|
|
264
|
+
while (rest) {
|
|
265
|
+
const piece = graphemePrefix(rest, size) || firstGrapheme(rest);
|
|
266
|
+
out.push(piece);
|
|
267
|
+
rest = rest.slice(piece.length);
|
|
268
|
+
}
|
|
269
|
+
return out;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** The longest prefix of whole graphemes within `max` characters. */
|
|
273
|
+
function graphemePrefix(text: string, max: number): string {
|
|
274
|
+
let out = '';
|
|
275
|
+
for (const { segment } of graphemeSegmenter.segment(text)) {
|
|
276
|
+
if (out.length + segment.length > max) break;
|
|
277
|
+
out += segment;
|
|
278
|
+
}
|
|
233
279
|
return out;
|
|
234
280
|
}
|
|
235
281
|
|
|
282
|
+
function firstGrapheme(text: string): string {
|
|
283
|
+
for (const { segment } of graphemeSegmenter.segment(text)) return segment;
|
|
284
|
+
return '';
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** `text` cut to `max` characters with "…", at a word when one is near. */
|
|
288
|
+
function cutWithEllipsis(text: string, max: number): string {
|
|
289
|
+
const cut = graphemePrefix(text, max - 1);
|
|
290
|
+
const space = cut.lastIndexOf(' ');
|
|
291
|
+
return `${(space > max / 2 ? cut.slice(0, space) : cut).trimEnd()}…`;
|
|
292
|
+
}
|
|
293
|
+
|
|
236
294
|
/** A code block too long for one message is re-fenced per piece. */
|
|
237
295
|
function splitBlock(block: Block, max: number): string[] {
|
|
238
296
|
if (block.md.length <= max) return [block.md];
|
|
@@ -255,42 +313,159 @@ function trimToSentence(text: string, max: number): string {
|
|
|
255
313
|
out += sentence;
|
|
256
314
|
}
|
|
257
315
|
if (out.trim()) return out.trim();
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
316
|
+
return cutWithEllipsis(text, max);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/** The last sentences of `text` that fit `max`, or '' when none does. */
|
|
320
|
+
function lastSentences(text: string, max: number): string {
|
|
321
|
+
let out = '';
|
|
322
|
+
for (const sentence of sentencesOf(text).reverse()) {
|
|
323
|
+
if ((sentence + out).trim().length > max) break;
|
|
324
|
+
out = sentence + out;
|
|
325
|
+
}
|
|
326
|
+
return out.trim();
|
|
261
327
|
}
|
|
262
328
|
|
|
263
329
|
function titleOf(text: string): string {
|
|
264
330
|
const plain = plainText(sentencesOf(text)[0] ?? text).replace(/[:.]$/, '');
|
|
265
331
|
if (plain.length <= TITLE_MAX) return plain;
|
|
266
|
-
|
|
267
|
-
const space = cut.lastIndexOf(' ');
|
|
268
|
-
return `${(space > TITLE_MAX / 2 ? cut.slice(0, space) : cut).trimEnd()}…`;
|
|
332
|
+
return cutWithEllipsis(plain, TITLE_MAX);
|
|
269
333
|
}
|
|
270
334
|
|
|
271
335
|
function firstLine(text: string): string {
|
|
272
336
|
return (text.split('\n')[0] ?? '').trim();
|
|
273
337
|
}
|
|
274
338
|
|
|
275
|
-
|
|
339
|
+
/** A list's or a table's items as chat lines: a list item by its first line. */
|
|
340
|
+
function itemLines(block: Block): string[] {
|
|
341
|
+
if (isTable(block.token)) return tableLines(block.token);
|
|
342
|
+
if (!isList(block.token)) return [];
|
|
343
|
+
const list = block.token;
|
|
276
344
|
const start = typeof list.start === 'number' ? list.start : 1;
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
345
|
+
return list.items.map(
|
|
346
|
+
(item, i) =>
|
|
347
|
+
`${list.ordered ? `${start + i}.` : '-'} ${firstLine(item.text)}`,
|
|
348
|
+
);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function previewText(head: string, lines: string[], total: number): string {
|
|
352
|
+
const more = total - lines.length;
|
|
353
|
+
return joinBlocks(
|
|
354
|
+
head,
|
|
355
|
+
lines.join('\n') + (more > 0 ? `\n…and ${more} more` : ''),
|
|
356
|
+
);
|
|
285
357
|
}
|
|
286
358
|
|
|
359
|
+
/**
|
|
360
|
+
* A list or table previewed within `bubbleMax`, after an optional "…:"
|
|
361
|
+
* lead-in: its first `previewItems` items and "…and N more". Fewer items
|
|
362
|
+
* are kept (down to one) until it fits; then that item, and if needed the
|
|
363
|
+
* lead-in, is cut with "…". The trailer and its count always stay.
|
|
364
|
+
*/
|
|
365
|
+
function fittedPreview(head: string, block: Block, limits: ChatLimits): string {
|
|
366
|
+
const lines = itemLines(block);
|
|
367
|
+
const max = limits.bubbleMax;
|
|
368
|
+
if (lines.length === 0)
|
|
369
|
+
return head.length <= max ? head : trimToSentence(head, max);
|
|
370
|
+
for (
|
|
371
|
+
let count = Math.min(limits.previewItems, lines.length);
|
|
372
|
+
count >= 1;
|
|
373
|
+
count--
|
|
374
|
+
) {
|
|
375
|
+
const text = previewText(head, lines.slice(0, count), lines.length);
|
|
376
|
+
if (text.length <= max) return text;
|
|
377
|
+
}
|
|
378
|
+
const more = lines.length - 1;
|
|
379
|
+
const trailer = more > 0 ? `\n…and ${more} more` : '';
|
|
380
|
+
const line = lines[0]!;
|
|
381
|
+
// Room for the item first (up to a third of the message), then the lead-in.
|
|
382
|
+
const itemRoom = Math.min(line.length, Math.floor(max / 3));
|
|
383
|
+
const headMax = max - 2 - itemRoom - trailer.length;
|
|
384
|
+
const fittedHead =
|
|
385
|
+
!head || head.length <= headMax ? head : trimToSentence(head, headMax);
|
|
386
|
+
const lineMax =
|
|
387
|
+
max - (fittedHead ? fittedHead.length + 2 : 0) - trailer.length;
|
|
388
|
+
const fittedLine =
|
|
389
|
+
line.length <= lineMax ? line : cutWithEllipsis(line, lineMax);
|
|
390
|
+
return previewText(fittedHead, [fittedLine], lines.length);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
const isItems = (block: Block): boolean =>
|
|
394
|
+
block.kind === 'list' || block.kind === 'table';
|
|
395
|
+
|
|
287
396
|
const endsWithColon = (md: string): boolean => /:\s*$/.test(md);
|
|
288
397
|
const joinBlocks = (a: string, b: string): string => (a ? `${a}\n\n${b}` : b);
|
|
289
398
|
|
|
290
|
-
/**
|
|
399
|
+
/**
|
|
400
|
+
* One message being packed: `code` when it holds a fenced code block,
|
|
401
|
+
* `opens` when it must start a message (a heading or lead-in sent ahead of
|
|
402
|
+
* its body; the body may still join it).
|
|
403
|
+
*/
|
|
404
|
+
interface Unit {
|
|
405
|
+
text: string;
|
|
406
|
+
code: boolean;
|
|
407
|
+
opens?: boolean;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* Units filled left to right into messages of at most `max` characters. A
|
|
412
|
+
* code unit is never joined to another, so a fenced block keeps its own
|
|
413
|
+
* message. Greedy filling gives the fewest messages for the order given.
|
|
414
|
+
*/
|
|
415
|
+
function fill(units: Unit[], max: number): Unit[] {
|
|
416
|
+
const out: Unit[] = [];
|
|
417
|
+
for (const unit of units) {
|
|
418
|
+
const last = out.at(-1);
|
|
419
|
+
if (
|
|
420
|
+
last &&
|
|
421
|
+
!last.code &&
|
|
422
|
+
!unit.code &&
|
|
423
|
+
!unit.opens &&
|
|
424
|
+
last.text.length + 2 + unit.text.length <= max
|
|
425
|
+
)
|
|
426
|
+
out[out.length - 1] = {
|
|
427
|
+
text: joinBlocks(last.text, unit.text),
|
|
428
|
+
code: false,
|
|
429
|
+
...(last.opens ? { opens: true } : {}),
|
|
430
|
+
};
|
|
431
|
+
else out.push(unit);
|
|
432
|
+
}
|
|
433
|
+
return out;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* Toward `maxBubbles` messages. A step already within it keeps the model's
|
|
438
|
+
* own message breaks. Otherwise units are filled up to `bubbleTarget`, and
|
|
439
|
+
* when that still leaves too many, the original units are filled again up
|
|
440
|
+
* to `bubbleMax`. A step that cannot fit stays at more messages: nothing is
|
|
441
|
+
* dropped, and nothing spills on count.
|
|
442
|
+
*/
|
|
443
|
+
function preferFewer(units: Unit[], limits: ChatLimits): Unit[] {
|
|
444
|
+
if (units.length <= limits.maxBubbles) return units;
|
|
445
|
+
const byTarget = fill(units, limits.bubbleTarget);
|
|
446
|
+
return byTarget.length <= limits.maxBubbles
|
|
447
|
+
? byTarget
|
|
448
|
+
: fill(units, limits.bubbleMax);
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/**
|
|
452
|
+
* Blocks → messages, each within `bubbleMax`. Headings and "…:" lead-ins
|
|
453
|
+
* start the message that holds what follows them: when joining would pass
|
|
454
|
+
* `bubbleMax`, the block after them is split with the room left, so they
|
|
455
|
+
* ride on its first piece; only a lead-in too long for that (over half of
|
|
456
|
+
* `bubbleMax`) is sent ahead as messages of its own, which nothing joins
|
|
457
|
+
* from behind. Fragments under `minBubble` merge forward when the result
|
|
458
|
+
* fits, then short messages are grouped toward `maxBubbles` (`preferFewer`).
|
|
459
|
+
*/
|
|
291
460
|
function pack(blocks: Block[], limits: ChatLimits): string[] {
|
|
292
|
-
const units:
|
|
461
|
+
const units: Unit[] = [];
|
|
293
462
|
let carry = '';
|
|
463
|
+
const flush = (opens: boolean): void => {
|
|
464
|
+
for (const piece of splitText(carry, limits.bubbleMax))
|
|
465
|
+
if (!isBlank(piece))
|
|
466
|
+
units.push({ text: piece, code: false, ...(opens ? { opens } : {}) });
|
|
467
|
+
carry = '';
|
|
468
|
+
};
|
|
294
469
|
blocks.forEach((block, i) => {
|
|
295
470
|
const next = blocks[i + 1];
|
|
296
471
|
const leadIn =
|
|
@@ -302,23 +477,56 @@ function pack(blocks: Block[], limits: ChatLimits): string[] {
|
|
|
302
477
|
carry = joinBlocks(carry, block.md);
|
|
303
478
|
return;
|
|
304
479
|
}
|
|
305
|
-
const
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
480
|
+
const code = block.kind === 'code';
|
|
481
|
+
const room = limits.bubbleMax - carry.length - 2;
|
|
482
|
+
let pieces: string[];
|
|
483
|
+
if (!carry || joinBlocks(carry, block.md).length <= limits.bubbleMax)
|
|
484
|
+
pieces = splitBlock(block, limits.bubbleMax);
|
|
485
|
+
else if (room >= limits.bubbleMax / 2) pieces = splitBlock(block, room);
|
|
486
|
+
else {
|
|
487
|
+
flush(true);
|
|
488
|
+
pieces = splitBlock(block, limits.bubbleMax);
|
|
489
|
+
}
|
|
490
|
+
const [head = '', ...rest] = pieces;
|
|
491
|
+
units.push({ text: joinBlocks(carry, head), code });
|
|
492
|
+
for (const piece of rest) units.push({ text: piece, code });
|
|
309
493
|
carry = '';
|
|
310
494
|
});
|
|
311
|
-
if (carry)
|
|
312
|
-
const merged:
|
|
495
|
+
if (carry) flush(false);
|
|
496
|
+
const merged: Unit[] = [];
|
|
313
497
|
for (let i = 0; i < units.length; i++) {
|
|
314
498
|
const unit = units[i]!;
|
|
315
|
-
|
|
316
|
-
|
|
499
|
+
const next = units[i + 1];
|
|
500
|
+
const joined = next ? joinBlocks(unit.text, next.text) : '';
|
|
501
|
+
if (
|
|
502
|
+
next &&
|
|
503
|
+
unit.text.length < limits.minBubble &&
|
|
504
|
+
joined.length <= limits.bubbleMax
|
|
505
|
+
)
|
|
506
|
+
units[i + 1] = {
|
|
507
|
+
text: joined,
|
|
508
|
+
code: unit.code || next.code,
|
|
509
|
+
...(unit.opens ? { opens: true } : {}),
|
|
510
|
+
};
|
|
317
511
|
else merged.push(unit);
|
|
318
512
|
}
|
|
319
|
-
return merged;
|
|
513
|
+
return preferFewer(merged, limits).map((unit) => unit.text);
|
|
320
514
|
}
|
|
321
515
|
|
|
516
|
+
/**
|
|
517
|
+
* What a spill keeps in chat, so the conversation still carries the
|
|
518
|
+
* substance when the document cannot be opened, each within `bubbleMax`:
|
|
519
|
+
*
|
|
520
|
+
* - lead: the first block after any heading (after a leading code block,
|
|
521
|
+
* which only the artefact holds, unless that block is a lone trailing
|
|
522
|
+
* paragraph, which is the closing instead): a paragraph or quote
|
|
523
|
+
* whole when it fits, else cut at a sentence; a list or table, with its
|
|
524
|
+
* "…:" lead-in, previewed (`fittedPreview`);
|
|
525
|
+
* - closing: the last block, when the lead did not use it: a list or table
|
|
526
|
+
* (with its lead-in) previewed, or the last paragraph, joined to the
|
|
527
|
+
* paragraph before it when both fit (a conclusion and its question), or
|
|
528
|
+
* its last sentences that fit.
|
|
529
|
+
*/
|
|
322
530
|
function spillOf(
|
|
323
531
|
markdown: string,
|
|
324
532
|
blocks: Block[],
|
|
@@ -326,30 +534,63 @@ function spillOf(
|
|
|
326
534
|
): SpillShape {
|
|
327
535
|
const heading = blocks.find((b) => b.kind === 'heading');
|
|
328
536
|
const content = blocks.filter((b) => b.kind !== 'heading');
|
|
329
|
-
const
|
|
537
|
+
const max = limits.bubbleMax;
|
|
538
|
+
const used = new Set<Block>();
|
|
539
|
+
const start = content.findIndex((b) => b.kind !== 'code');
|
|
540
|
+
const first = start >= 0 ? content[start] : undefined;
|
|
541
|
+
const second = start >= 0 ? content[start + 1] : undefined;
|
|
542
|
+
// After leading code, a lone trailing paragraph ("Want me to apply it?")
|
|
543
|
+
// is the closing, after the link, not the lead.
|
|
544
|
+
const onlyClosing =
|
|
545
|
+
start > 0 && first === content.at(-1) && first?.kind === 'paragraph';
|
|
330
546
|
let lead = '';
|
|
331
|
-
if (
|
|
332
|
-
lead
|
|
333
|
-
else if (
|
|
547
|
+
if (onlyClosing) {
|
|
548
|
+
// No lead: the closing below takes the paragraph.
|
|
549
|
+
} else if (first && isItems(first)) {
|
|
550
|
+
lead = fittedPreview('', first, limits);
|
|
551
|
+
used.add(first);
|
|
552
|
+
} else if (
|
|
334
553
|
first?.kind === 'paragraph' &&
|
|
335
554
|
endsWithColon(first.md) &&
|
|
336
555
|
second &&
|
|
337
|
-
|
|
338
|
-
)
|
|
339
|
-
lead =
|
|
340
|
-
|
|
341
|
-
|
|
556
|
+
isItems(second)
|
|
557
|
+
) {
|
|
558
|
+
lead = fittedPreview(first.md, second, limits);
|
|
559
|
+
used.add(first).add(second);
|
|
560
|
+
} else if (first && (first.kind === 'paragraph' || first.kind === 'quote')) {
|
|
561
|
+
lead = first.md.length <= max ? first.md : trimToSentence(first.md, max);
|
|
562
|
+
used.add(first);
|
|
563
|
+
}
|
|
342
564
|
const last = content.at(-1);
|
|
343
|
-
const
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
last
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
565
|
+
const before = content.at(-2);
|
|
566
|
+
let closing: string | undefined;
|
|
567
|
+
if (last && !used.has(last)) {
|
|
568
|
+
if (isItems(last)) {
|
|
569
|
+
const leadIn =
|
|
570
|
+
before?.kind === 'paragraph' &&
|
|
571
|
+
endsWithColon(before.md) &&
|
|
572
|
+
!used.has(before)
|
|
573
|
+
? before.md
|
|
574
|
+
: '';
|
|
575
|
+
closing = fittedPreview(leadIn, last, limits);
|
|
576
|
+
} else if (last.kind === 'paragraph') {
|
|
577
|
+
const pair =
|
|
578
|
+
before?.kind === 'paragraph' && !used.has(before)
|
|
579
|
+
? joinBlocks(before.md, last.md)
|
|
580
|
+
: '';
|
|
581
|
+
closing =
|
|
582
|
+
pair && pair.length <= max
|
|
583
|
+
? pair
|
|
584
|
+
: last.md.length <= max
|
|
585
|
+
? last.md
|
|
586
|
+
: lastSentences(last.md, max);
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
if (closing === lead || (closing !== undefined && isBlank(closing)))
|
|
590
|
+
closing = undefined;
|
|
351
591
|
const titleSource =
|
|
352
|
-
heading?.md ??
|
|
592
|
+
heading?.md ??
|
|
593
|
+
(content[0]?.kind === 'paragraph' ? content[0].md : undefined);
|
|
353
594
|
return {
|
|
354
595
|
title: (titleSource && titleOf(titleSource)) || 'Details',
|
|
355
596
|
markdown,
|
|
@@ -359,9 +600,14 @@ function spillOf(
|
|
|
359
600
|
}
|
|
360
601
|
|
|
361
602
|
/**
|
|
362
|
-
* Shape one model step for a chat surface.
|
|
363
|
-
*
|
|
364
|
-
*
|
|
603
|
+
* Shape one model step for a chat surface. A step spills only when it is
|
|
604
|
+
* document-length: its rendered text (blocks joined by blank lines) is over
|
|
605
|
+
* `spillChars`, a list or a table has more than `maxListItems` items or
|
|
606
|
+
* rows, or a code block more than `maxCodeLines` lines. Otherwise it stays in
|
|
607
|
+
* chat as the messages it packs into (grouped toward `maxBubbles`, never
|
|
608
|
+
* spilled on count; the run's cap is `capParts`' job). With `canSpill` false
|
|
609
|
+
* (no artefact storage configured) the step is always split into messages
|
|
610
|
+
* and nothing is dropped.
|
|
365
611
|
*/
|
|
366
612
|
export function shapeStep(
|
|
367
613
|
markdown: string,
|
|
@@ -369,21 +615,17 @@ export function shapeStep(
|
|
|
369
615
|
canSpill: boolean,
|
|
370
616
|
): StepShape {
|
|
371
617
|
const blocks = toBlocks(markdown.trim(), limits);
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
b.token.
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
messages.length > limits.maxBubbles
|
|
386
|
-
)
|
|
387
|
-
return { kind: 'spill', spill: spillOf(markdown.trim(), blocks, limits) };
|
|
388
|
-
return { kind: 'messages', messages };
|
|
618
|
+
if (canSpill) {
|
|
619
|
+
const length = blocks.map((b) => b.md).join('\n\n').length;
|
|
620
|
+
const tooLongForChat = blocks.some(
|
|
621
|
+
(b) =>
|
|
622
|
+
(isCode(b.token) &&
|
|
623
|
+
b.token.text.split('\n').length > limits.maxCodeLines) ||
|
|
624
|
+
(isList(b.token) && b.token.items.length > limits.maxListItems) ||
|
|
625
|
+
(isTable(b.token) && b.token.rows.length > limits.maxListItems),
|
|
626
|
+
);
|
|
627
|
+
if (length > limits.spillChars || tooLongForChat)
|
|
628
|
+
return { kind: 'spill', spill: spillOf(markdown.trim(), blocks, limits) };
|
|
629
|
+
}
|
|
630
|
+
return { kind: 'messages', messages: pack(blocks, limits) };
|
|
389
631
|
}
|