@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.
@@ -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 the user's oracle room mirror of a
70
- * channel turn and gateways that predate plans receive. Never stored as a
71
- * run's text, which stays the model's own.
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
- : `[${linkLabel(part.artifact.title)}](${part.artifact.url})`,
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
+ }
@@ -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 question) when the step is too long
4
- * or too structured for chat. No model call; the same input always gives the
5
- * same output, which is what makes a Reply Plan safe to rebuild on recovery.
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
- /** What stays in chat: the first block, or a list's lead-in and first items. */
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
- /** The step's closing question, kept as its own message after the link. */
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
- const sentenceSegmenter = new Intl.Segmenter(undefined, {
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
- function tableAsList(table: Tokens.Table): string {
149
- return table.rows
150
- .map((row) => {
151
- const [first, ...rest] = row.map((cell) => cell.text.trim());
152
- const pairs = rest.map((value, i) => {
153
- const header = table.header[i + 1]?.text.trim();
154
- return header ? `${header}: ${value}` : value;
155
- });
156
- return `- **${first ?? ''}**${pairs.length ? ` · ${pairs.join(' · ')}` : ''}`;
157
- })
158
- .join('\n');
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
- blocks.push({
175
- kind: 'table',
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
- for (let i = 0; i < text.length; i += size) out.push(text.slice(i, i + size));
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
- const cut = text.slice(0, max - 1);
259
- const space = cut.lastIndexOf(' ');
260
- return `${(space > max / 2 ? cut.slice(0, space) : cut).trimEnd()}…`;
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
- const cut = plain.slice(0, TITLE_MAX - 1);
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
- function preview(list: Tokens.List, count: number): string {
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
- const items = list.items
278
- .slice(0, count)
279
- .map(
280
- (item, i) =>
281
- `${list.ordered ? `${start + i}.` : '-'} ${firstLine(item.text)}`,
282
- );
283
- const more = list.items.length - count;
284
- return items.join('\n') + (more > 0 ? `\n…and ${more} more` : '');
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
- /** Blocks → messages: headings and "…:" lead-ins stay with what follows. */
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: string[] = [];
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 pieces = splitBlock(block, limits.bubbleMax);
306
- pieces.forEach((piece, j) => {
307
- units.push(j === 0 ? joinBlocks(carry, piece) : piece);
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) units.push(carry);
312
- const merged: string[] = [];
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
- if (unit.length < limits.minBubble && i + 1 < units.length)
316
- units[i + 1] = joinBlocks(unit, units[i + 1]!);
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 [first, second] = content;
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 (first && isList(first.token))
332
- lead = preview(first.token, limits.previewItems);
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
- isList(second.token)
338
- )
339
- lead = joinBlocks(first.md, preview(second.token, limits.previewItems));
340
- else if (first && (first.kind === 'paragraph' || first.kind === 'quote'))
341
- lead = trimToSentence(first.md, limits.bubbleTarget);
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 closing =
344
- last &&
345
- last !== first &&
346
- last.kind === 'paragraph' &&
347
- /\?\s*$/.test(last.md) &&
348
- last.md.length <= limits.bubbleMax
349
- ? last.md
350
- : undefined;
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 ?? (first?.kind === 'paragraph' ? first.md : undefined);
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. With `canSpill` false (no
363
- * artefact storage configured) the step is always split into messages:
364
- * tables become lists and nothing is dropped.
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
- const messages = pack(blocks, limits);
373
- if (!canSpill) return { kind: 'messages', messages };
374
- const length = blocks.reduce((n, b) => n + b.md.length + 2, 0);
375
- const tooStructured = blocks.some(
376
- (b) =>
377
- (b.kind === 'table' && !limits.tables) ||
378
- (isCode(b.token) &&
379
- b.token.text.split('\n').length > limits.maxCodeLines) ||
380
- (isList(b.token) && b.token.items.length > limits.maxListItems),
381
- );
382
- if (
383
- length > limits.spillChars ||
384
- tooStructured ||
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
  }