@ixo/oracle-runtime-workers 0.24.0 → 0.26.0

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.
@@ -19,6 +19,10 @@ import {
19
19
  MEMORY_DELETE_EDGE_MCP_NAME,
20
20
  MEMORY_DELETE_EPISODE_MCP_NAME,
21
21
  } from '../plugins/memory/memory-tools';
22
+ import {
23
+ ADD_ORACLE_DOCUMENT_TOOL_NAME,
24
+ DELETE_ORACLE_DOCUMENT_TOOL_NAME,
25
+ } from '../plugins/memory/oracle-document-tools';
22
26
 
23
27
  /** The memory engine tools that write or delete what the prefetch reads. */
24
28
  export const MEMORY_WRITE_TOOL_NAMES: readonly string[] = [
@@ -27,6 +31,8 @@ export const MEMORY_WRITE_TOOL_NAMES: readonly string[] = [
27
31
  MEMORY_DELETE_EPISODE_MCP_NAME,
28
32
  MEMORY_DELETE_EDGE_MCP_NAME,
29
33
  MEMORY_CLEAR_MCP_NAME,
34
+ ADD_ORACLE_DOCUMENT_TOOL_NAME,
35
+ DELETE_ORACLE_DOCUMENT_TOOL_NAME,
30
36
  ];
31
37
 
32
38
  export interface UserContextInvalidationOptions {
@@ -762,7 +762,26 @@ export interface RuntimeContext<TConfig = MergedConfig> {
762
762
  provider: string;
763
763
  bindingId: string;
764
764
  bindingRevision: number;
765
- }): Promise<import('@ixo/action-protocol').HubActionOptionsV2>;
765
+ }): Promise<import('@ixo/action-protocol').HubActionOptionsV3>;
766
+ /**
767
+ * A bank send of a token from the owner's options list: `amount` is a
768
+ * decimal in the token's display units; the host converts it to base
769
+ * units from the listed decimals and builds the operation (no fee).
770
+ */
771
+ transfer(input: {
772
+ ownerDid: string;
773
+ sessionId: string;
774
+ runId: string;
775
+ requestId: string;
776
+ provider: string;
777
+ bindingId: string;
778
+ bindingRevision: number;
779
+ requestKey: string;
780
+ toAddress: string;
781
+ denom: string;
782
+ amount: string;
783
+ memo: string;
784
+ }): Promise<ActionReferenceV1>;
766
785
  request(input: {
767
786
  ownerDid: string;
768
787
  sessionId: string;
@@ -774,7 +793,11 @@ export interface RuntimeContext<TConfig = MergedConfig> {
774
793
  requestKey: string;
775
794
  operation: ActionOperationV1;
776
795
  }): Promise<ActionReferenceV1>;
777
- status(ownerDid: string, actionId: string): Promise<ActionOutcomeV1 | null>;
796
+ /** The latest authenticated outcome and, for a transfer, its owner-facing line. */
797
+ status(
798
+ ownerDid: string,
799
+ actionId: string,
800
+ ): Promise<{ outcome: ActionOutcomeV1 | null; summary: string | null }>;
778
801
  };
779
802
 
780
803
  decisions: {
@@ -24,9 +24,37 @@ const statusSchema = z.strictObject({
24
24
  actionId: z.string().regex(/^[A-Za-z0-9_-]{32}$/),
25
25
  });
26
26
  const discoverSchema = z.strictObject({});
27
+ const transferSchema = z.strictObject({
28
+ toAddress: z
29
+ .string()
30
+ .min(1)
31
+ .max(100)
32
+ .describe('The recipient address the user gave, exactly.'),
33
+ denom: z
34
+ .string()
35
+ .min(1)
36
+ .max(128)
37
+ .describe(
38
+ 'The token, copied exactly from a `denom` in hub_action_options `transfer.tokens` (never guessed).',
39
+ ),
40
+ amount: z
41
+ .string()
42
+ .regex(/^(0|[1-9][0-9]*)(\.[0-9]+)?$/)
43
+ .max(80)
44
+ .describe(
45
+ "A decimal amount in the token's listed units (its `decimals`); for an unverified token (decimals 0) a whole number of base units.",
46
+ ),
47
+ memo: z.string().max(256).optional(),
48
+ });
27
49
 
28
50
  export type HubActionRequestResult =
29
- | { status: 'prepared'; actionReference: ActionReferenceV1; summary: string }
51
+ | {
52
+ status: 'prepared';
53
+ actionReference: ActionReferenceV1;
54
+ summary: string;
55
+ /** How the review reaches the user; set on a chat surface only. */
56
+ note?: string;
57
+ }
30
58
  | {
31
59
  status: 'unavailable' | 'expired';
32
60
  reason: string;
@@ -34,6 +62,34 @@ export type HubActionRequestResult =
34
62
  priorActionId?: string;
35
63
  };
36
64
 
65
+ /**
66
+ * What a prepared result tells the model on a chat surface. There the
67
+ * finished run's Reply Plan carries the review as its own `hub-action` part
68
+ * (a card with the title and a review button; its title linked to the review
69
+ * in a plain-text rendering), so text that repeats it reaches the user twice.
70
+ */
71
+ export const HUB_ACTION_CARD_NOTE =
72
+ 'The user is shown this action automatically as a card with its title and a Review action button that opens it in Auth Hub. Do not repeat the link or the title, and do not say that you have not executed or signed anything. Reply with one short line, or nothing.';
73
+
74
+ /**
75
+ * A prepared result. Only a chat turn's reply is a Reply Plan, so only a
76
+ * chat surface gets the note: on the Portal stream no card is added and the
77
+ * model's own text is the whole reply.
78
+ */
79
+ function preparedResult(
80
+ reference: ActionReferenceV1,
81
+ ctx: RuntimeContext,
82
+ ): HubActionRequestResult {
83
+ return {
84
+ status: 'prepared',
85
+ actionReference: reference,
86
+ summary: reference.title,
87
+ ...(ctx.session.surface?.kind === 'chat'
88
+ ? { note: HUB_ACTION_CARD_NOTE }
89
+ : {}),
90
+ };
91
+ }
92
+
37
93
  /**
38
94
  * The idempotency key the Hub sees: a fixed-length digest of the run and
39
95
  * tool call (the Hub allows 120 characters; a run id alone is 72).
@@ -74,6 +130,15 @@ async function requestHubAction(
74
130
  let operation;
75
131
  try {
76
132
  operation = parseOperation(parsed.data.operation);
133
+ if (operation.kind === 'ixo.bank.send.v1')
134
+ // A transfer is built by the runtime from a listed token and a
135
+ // display amount (request_hub_transfer): the model never supplies
136
+ // base units, a denom it chose or a fee.
137
+ return {
138
+ status: 'unavailable',
139
+ reason:
140
+ 'Use request_hub_transfer with a token from hub_action_options for a transfer.',
141
+ };
77
142
  } catch (error) {
78
143
  // The protocol's own words ("Invalid fromAddress"): the model can fix
79
144
  // the field instead of guessing.
@@ -96,28 +161,83 @@ async function requestHubAction(
96
161
  operation,
97
162
  });
98
163
  } catch (error) {
99
- // A cap reached, a request that expired, a run no user message
100
- // started: the runtime's own words, so the model can tell the user.
164
+ // A request that expired, a run no user message started, a Hub
165
+ // refusal: the runtime's own words, so the model can tell the user.
101
166
  // Anything else stays an error.
102
- if (
103
- error instanceof Error &&
104
- error.name === 'HubActionRefused' &&
105
- 'kind' in error
106
- )
107
- return {
108
- status: error.kind === 'expired' ? 'expired' : 'unavailable',
109
- reason: error.message,
110
- ...('priorActionId' in error && typeof error.priorActionId === 'string'
111
- ? { priorActionId: error.priorActionId }
112
- : {}),
113
- };
167
+ const refused = refusalOf(error);
168
+ if (refused) return refused;
114
169
  throw error;
115
170
  }
116
- return {
117
- status: 'prepared',
118
- actionReference: reference,
119
- summary: reference.title,
120
- };
171
+ return preparedResult(reference, ctx);
172
+ }
173
+
174
+ async function requestHubTransfer(
175
+ input: unknown,
176
+ ctx: RuntimeContext,
177
+ ): Promise<HubActionRequestResult> {
178
+ const parsed = transferSchema.safeParse(input);
179
+ if (!parsed.success)
180
+ return {
181
+ status: 'unavailable',
182
+ reason:
183
+ 'A recipient, a listed token denom and a plain decimal amount are required.',
184
+ };
185
+ if (ctx.config.HUB_ACTIONS_ENABLED !== 'true' || !ctx.hubActions)
186
+ return {
187
+ status: 'unavailable',
188
+ reason: 'Hub actions are not enabled for this oracle.',
189
+ };
190
+ const channel = ctx.session.channel;
191
+ if (
192
+ ctx.session.client !== 'channel' ||
193
+ !channel ||
194
+ !ctx.session.runId ||
195
+ !ctx.toolCallId
196
+ )
197
+ return {
198
+ status: 'unavailable',
199
+ reason: 'A verified channel turn is required.',
200
+ };
201
+ try {
202
+ const reference = await ctx.hubActions.transfer({
203
+ ownerDid: ctx.user.did,
204
+ sessionId: ctx.session.id,
205
+ runId: ctx.session.runId,
206
+ requestId: ctx.session.requestId,
207
+ provider: channel.provider,
208
+ bindingId: channel.bindingId,
209
+ bindingRevision: channel.bindingRevision,
210
+ requestKey: await hubRequestKey(ctx.session.runId, ctx.toolCallId),
211
+ toAddress: parsed.data.toAddress,
212
+ denom: parsed.data.denom,
213
+ amount: parsed.data.amount,
214
+ memo: parsed.data.memo ?? '',
215
+ });
216
+ return preparedResult(reference, ctx);
217
+ } catch (error) {
218
+ // The owner object turns the protocol refusing the operation built
219
+ // from the model's fields into a refusal; anything else is an error.
220
+ const refused = refusalOf(error);
221
+ if (refused) return refused;
222
+ throw error;
223
+ }
224
+ }
225
+
226
+ /** A typed runtime refusal as a result the model can relay, else null. */
227
+ function refusalOf(error: unknown): HubActionRequestResult | null {
228
+ if (
229
+ error instanceof Error &&
230
+ error.name === 'HubActionRefused' &&
231
+ 'kind' in error
232
+ )
233
+ return {
234
+ status: error.kind === 'expired' ? 'expired' : 'unavailable',
235
+ reason: error.message,
236
+ ...('priorActionId' in error && typeof error.priorActionId === 'string'
237
+ ? { priorActionId: error.priorActionId }
238
+ : {}),
239
+ };
240
+ return null;
121
241
  }
122
242
 
123
243
  async function hubActionStatus(
@@ -131,10 +251,11 @@ async function hubActionStatus(
131
251
  ctx.config.HUB_ACTIONS_ENABLED !== 'true'
132
252
  )
133
253
  return { status: 'unavailable' };
134
- return {
135
- status: 'known',
136
- outcome: await ctx.hubActions.status(ctx.user.did, parsed.data.actionId),
137
- };
254
+ const { outcome, summary } = await ctx.hubActions.status(
255
+ ctx.user.did,
256
+ parsed.data.actionId,
257
+ );
258
+ return { status: 'known', outcome, ...(summary ? { summary } : {}) };
138
259
  }
139
260
 
140
261
  async function hubActionCapabilities(
@@ -218,7 +339,7 @@ export class HubActionsPlugin extends OraclePlugin {
218
339
  {
219
340
  name: 'hub_action_options',
220
341
  description:
221
- 'Read this authenticated owner’s confirmed Gmail and Calendar connections, requestable documents and action resources. Use each returned service, identifier and label before preparing a related Hub review. A Gmail profile connection does not authorize sending.',
342
+ 'Read this authenticated owner’s confirmed Gmail and Calendar connections, requestable documents, action resources and bank tokens (`transfer.tokens`: denom, symbol, decimals, verified, balance in base units). Use each returned service, identifier and label before preparing a related Hub review. A Gmail profile connection does not authorize sending. For a token that is not verified, name its full denom and say it is unverified; its amounts are base units.',
222
343
  schema: discoverSchema,
223
344
  effect: 'read',
224
345
  handler: hubActionOptions,
@@ -231,6 +352,14 @@ export class HubActionsPlugin extends OraclePlugin {
231
352
  effect: 'write',
232
353
  handler: requestHubAction,
233
354
  },
355
+ {
356
+ name: 'request_hub_transfer',
357
+ description:
358
+ 'Prepare a bank transfer of one of the owner’s listed tokens for review in Auth Hub. Never signs or executes. Read hub_action_options first and copy the token `denom` exactly; give the amount as the user said it in that token’s units (no base-unit math, no fee: Auth Hub sets the fee). Name the full denom for anything but IXO, and say when a token is unverified.',
359
+ schema: transferSchema,
360
+ effect: 'write',
361
+ handler: requestHubTransfer,
362
+ },
234
363
  {
235
364
  name: 'hub_action_status',
236
365
  description:
@@ -0,0 +1,313 @@
1
+ /**
2
+ * Splits extracted document text into chunks the Memory Engine accepts: every
3
+ * chunk is at most `maxChunkBytes` UTF-8 bytes, cut at the largest natural
4
+ * boundary that fits (paragraph, line, sentence) and only then mid-text, never
5
+ * inside a UTF-8 sequence or surrogate pair.
6
+ */
7
+
8
+ export interface ChunkerOptions {
9
+ /** Upper bound of one chunk in UTF-8 bytes (default 12 000). */
10
+ maxChunkBytes: number;
11
+ /** Most chunks to return (default 64). */
12
+ maxChunks: number;
13
+ }
14
+
15
+ export interface DocumentChunk {
16
+ /** Zero-based position in the document. */
17
+ index: number;
18
+ /** `p. 3`, `p. 3–5` when the text carries page markers, else `part 2 of 7`. */
19
+ label: string;
20
+ content: string;
21
+ }
22
+
23
+ export interface ChunkedDocument {
24
+ chunks: DocumentChunk[];
25
+ /** More chunks were needed than `maxChunks`; only the first ones are returned. */
26
+ truncated: boolean;
27
+ /** UTF-8 size of the normalised text. */
28
+ totalBytes: number;
29
+ }
30
+
31
+ export const DEFAULT_CHUNK_BYTES = 12_000;
32
+ export const DEFAULT_MAX_CHUNKS = 64;
33
+
34
+ const MIN_CHUNK_BYTES = 4;
35
+ /** A whole line that only names a page: `--- page 3 ---`, `Page 3`, `p. 3`, `Page 3 of 10`. */
36
+ const PAGE_MARKER =
37
+ /^\s*(?:---\s*page\s+(\d+)\s*---|(?:page|p\.)\s*(\d+)(?:\s*(?:of|\/)\s*\d+)?)\s*$/i;
38
+
39
+ /** UTF-8 length of a string, counting a lone surrogate as U+FFFD (3 bytes). */
40
+ export function utf8ByteLength(text: string): number {
41
+ let bytes = 0;
42
+ for (let i = 0; i < text.length; i += 1) {
43
+ const code = text.charCodeAt(i);
44
+ if (code < 0x80) bytes += 1;
45
+ else if (code < 0x800) bytes += 2;
46
+ else if (code >= 0xd800 && code <= 0xdbff) {
47
+ const next = text.charCodeAt(i + 1);
48
+ if (next >= 0xdc00 && next <= 0xdfff) {
49
+ bytes += 4;
50
+ i += 1;
51
+ } else bytes += 3;
52
+ } else bytes += 3;
53
+ }
54
+ return bytes;
55
+ }
56
+
57
+ interface Paragraph {
58
+ text: string;
59
+ page: number | undefined;
60
+ }
61
+
62
+ interface Unit {
63
+ text: string;
64
+ /** What joins this unit to the previous one inside a chunk. */
65
+ sep: string;
66
+ page: number | undefined;
67
+ }
68
+
69
+ /** Drops trailing spaces and tabs in one backwards pass (no regex backtracking; a form feed stays). */
70
+ function trimLineEnd(line: string): string {
71
+ let end = line.length;
72
+ while (end > 0 && (line[end - 1] === ' ' || line[end - 1] === '\t')) end -= 1;
73
+ return end === line.length ? line : line.slice(0, end);
74
+ }
75
+
76
+ function normalise(text: string): string {
77
+ return text
78
+ .replace(/\r\n?/g, '\n')
79
+ .split('\n')
80
+ .map(trimLineEnd)
81
+ .join('\n')
82
+ .replace(/\n{3,}/g, '\n\n')
83
+ .trim();
84
+ }
85
+
86
+ function isMarkerOnly(text: string): boolean {
87
+ return !text.includes('\n') && PAGE_MARKER.test(text);
88
+ }
89
+
90
+ function groupText(group: readonly Unit[]): string {
91
+ return group.reduce(
92
+ (acc, u, i) => (i === 0 ? u.text : acc + u.sep + u.text),
93
+ '',
94
+ );
95
+ }
96
+
97
+ /**
98
+ * A group that only names pages (`--- page 4 ---`) says nothing: it joins the
99
+ * start of the next group, else the end of the previous one, if it fits, and
100
+ * is dropped otherwise (the page of every unit is already known).
101
+ */
102
+ function absorbMarkerGroups(groups: Unit[][], maxBytes: number): Unit[][] {
103
+ const out: Unit[][] = [];
104
+ let carry: Unit[] = [];
105
+ for (const group of groups) {
106
+ if (group.every((u) => isMarkerOnly(u.text))) {
107
+ carry = [...carry, ...group];
108
+ continue;
109
+ }
110
+ if (
111
+ carry.length > 0 &&
112
+ utf8ByteLength(groupText([...carry, ...group])) <= maxBytes
113
+ ) {
114
+ out.push([...carry, ...group]);
115
+ } else {
116
+ const last = out[out.length - 1];
117
+ if (
118
+ carry.length > 0 &&
119
+ last &&
120
+ utf8ByteLength(groupText([...last, ...carry])) <= maxBytes
121
+ ) {
122
+ last.push(...carry);
123
+ }
124
+ out.push(group);
125
+ }
126
+ carry = [];
127
+ }
128
+ const last = out[out.length - 1];
129
+ if (
130
+ carry.length > 0 &&
131
+ last &&
132
+ utf8ByteLength(groupText([...last, ...carry])) <= maxBytes
133
+ ) {
134
+ last.push(...carry);
135
+ }
136
+ return out;
137
+ }
138
+
139
+ /** Paragraphs in order, each tagged with the page it starts on (if paged). */
140
+ function splitParagraphs(text: string): {
141
+ paragraphs: Paragraph[];
142
+ paged: boolean;
143
+ } {
144
+ const lines = text.replace(/\f/g, '\n\f\n').split('\n');
145
+ const paged =
146
+ text.includes('\f') || lines.some((line) => PAGE_MARKER.test(line));
147
+ const paragraphs: Paragraph[] = [];
148
+ let page: number | undefined = paged ? 1 : undefined;
149
+ let current: string[] = [];
150
+ let currentPage = page;
151
+
152
+ const flush = (): void => {
153
+ const joined = current.join('\n').trim();
154
+ if (joined) paragraphs.push({ text: joined, page: currentPage });
155
+ current = [];
156
+ };
157
+
158
+ for (const line of lines) {
159
+ if (line === '\f') {
160
+ flush();
161
+ page = (page ?? 1) + 1;
162
+ continue;
163
+ }
164
+ if (line.trim() === '') {
165
+ flush();
166
+ continue;
167
+ }
168
+ const marker = PAGE_MARKER.exec(line);
169
+ if (marker) {
170
+ flush();
171
+ page = Number(marker[1] ?? marker[2]);
172
+ }
173
+ if (current.length === 0) currentPage = page;
174
+ current.push(line);
175
+ }
176
+ flush();
177
+ return { paragraphs, paged };
178
+ }
179
+
180
+ interface Level {
181
+ sep: string;
182
+ split: (text: string) => string[];
183
+ }
184
+
185
+ /** Boundaries tried in order when a piece is still too large. */
186
+ const LEVELS: readonly Level[] = [
187
+ {
188
+ sep: '\n',
189
+ split: (t) =>
190
+ t
191
+ .split('\n')
192
+ .map((l) => l.trim())
193
+ .filter(Boolean),
194
+ },
195
+ {
196
+ sep: ' ',
197
+ split: (t) =>
198
+ t
199
+ .split(/(?<=[.!?。!?])\s+/u)
200
+ .map((s) => s.trim())
201
+ .filter(Boolean),
202
+ },
203
+ ];
204
+
205
+ function hardSplit(text: string, maxBytes: number): string[] {
206
+ const pieces: string[] = [];
207
+ let start = 0;
208
+ let bytes = 0;
209
+ let i = 0;
210
+ while (i < text.length) {
211
+ const code = text.codePointAt(i) ?? 0;
212
+ const width = code > 0xffff ? 2 : 1;
213
+ const size = utf8ByteLength(text.slice(i, i + width));
214
+ if (bytes + size > maxBytes) {
215
+ pieces.push(text.slice(start, i));
216
+ start = i;
217
+ bytes = 0;
218
+ }
219
+ bytes += size;
220
+ i += width;
221
+ }
222
+ if (start < text.length) pieces.push(text.slice(start));
223
+ return pieces;
224
+ }
225
+
226
+ function fit(
227
+ text: string,
228
+ sep: string,
229
+ levels: readonly Level[],
230
+ maxBytes: number,
231
+ page: number | undefined,
232
+ ): Unit[] {
233
+ if (utf8ByteLength(text) <= maxBytes) return [{ text, sep, page }];
234
+ const [level, ...rest] = levels;
235
+ if (!level) {
236
+ return hardSplit(text, maxBytes).map((piece, i) => ({
237
+ text: piece,
238
+ sep: i === 0 ? sep : '',
239
+ page,
240
+ }));
241
+ }
242
+ const parts = level.split(text);
243
+ if (parts.length <= 1) return fit(text, sep, rest, maxBytes, page);
244
+ return parts.flatMap((part, i) =>
245
+ fit(part, i === 0 ? sep : level.sep, rest, maxBytes, page),
246
+ );
247
+ }
248
+
249
+ function pageLabel(first: number, last: number): string {
250
+ return first === last ? `p. ${first}` : `p. ${first}–${last}`;
251
+ }
252
+
253
+ export function chunkDocument(
254
+ text: string,
255
+ opts: Partial<ChunkerOptions> = {},
256
+ ): ChunkedDocument {
257
+ const maxChunkBytes = opts.maxChunkBytes ?? DEFAULT_CHUNK_BYTES;
258
+ const maxChunks = opts.maxChunks ?? DEFAULT_MAX_CHUNKS;
259
+ if (!Number.isInteger(maxChunkBytes) || maxChunkBytes < MIN_CHUNK_BYTES) {
260
+ throw new RangeError(
261
+ `maxChunkBytes must be an integer >= ${MIN_CHUNK_BYTES}`,
262
+ );
263
+ }
264
+ if (!Number.isInteger(maxChunks) || maxChunks < 1) {
265
+ throw new RangeError('maxChunks must be a positive integer');
266
+ }
267
+
268
+ const normalised = normalise(text);
269
+ if (!normalised) return { chunks: [], truncated: false, totalBytes: 0 };
270
+
271
+ const { paragraphs, paged } = splitParagraphs(normalised);
272
+ const units = paragraphs
273
+ .flatMap((p) => fit(p.text, '\n\n', LEVELS, maxChunkBytes, p.page))
274
+ .filter((u) => u.text.trim() !== '');
275
+
276
+ const groups: Unit[][] = [];
277
+ let group: Unit[] = [];
278
+ let groupBytes = 0;
279
+ for (const unit of units) {
280
+ const unitBytes = utf8ByteLength(unit.text);
281
+ // Separators are ASCII, so their length is their byte length.
282
+ const joined = groupBytes + unit.sep.length + unitBytes;
283
+ if (group.length > 0 && joined <= maxChunkBytes) {
284
+ group.push(unit);
285
+ groupBytes = joined;
286
+ } else {
287
+ if (group.length > 0) groups.push(group);
288
+ group = [unit];
289
+ groupBytes = unitBytes;
290
+ }
291
+ }
292
+ if (group.length > 0) groups.push(group);
293
+
294
+ const kept = absorbMarkerGroups(groups, maxChunkBytes);
295
+ const total = kept.length;
296
+ const chunks = kept.slice(0, maxChunks).map((members, index) => {
297
+ const body = groupText(members);
298
+ const pages = members
299
+ .map((u) => u.page)
300
+ .filter((p): p is number => p !== undefined);
301
+ const label =
302
+ paged && pages.length > 0
303
+ ? pageLabel(Math.min(...pages), Math.max(...pages))
304
+ : `part ${index + 1} of ${total}`;
305
+ return { index, label, content: body };
306
+ });
307
+
308
+ return {
309
+ chunks,
310
+ truncated: total > maxChunks,
311
+ totalBytes: utf8ByteLength(normalised),
312
+ };
313
+ }