@rei-standard/amsg-shared 0.1.0-next.0 → 0.1.0-next.2

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/dist/index.cjs CHANGED
@@ -26,6 +26,7 @@ __export(src_exports, {
26
26
  buildErrorPush: () => buildErrorPush,
27
27
  buildReasoningPush: () => buildReasoningPush,
28
28
  buildToolRequestPush: () => buildToolRequestPush,
29
+ chunkReasoningByUtf8Bytes: () => chunkReasoningByUtf8Bytes,
29
30
  isContentPush: () => isContentPush,
30
31
  isErrorPush: () => isErrorPush,
31
32
  isReasoningPush: () => isReasoningPush,
@@ -101,6 +102,10 @@ function buildReasoningPush(args) {
101
102
  if (args.contactName !== void 0) push.contactName = args.contactName;
102
103
  if (args.avatarUrl !== void 0) push.avatarUrl = args.avatarUrl;
103
104
  if (args.messageSubtype !== void 0) push.messageSubtype = args.messageSubtype;
105
+ if (args.messageIndex !== void 0) push.messageIndex = args.messageIndex;
106
+ if (args.totalMessages !== void 0) push.totalMessages = args.totalMessages;
107
+ if (args.chunkIndex !== void 0) push.chunkIndex = args.chunkIndex;
108
+ if (args.totalChunks !== void 0) push.totalChunks = args.totalChunks;
104
109
  if (args.metadata !== void 0) push.metadata = args.metadata;
105
110
  return push;
106
111
  }
@@ -168,3 +173,31 @@ function isErrorPush(value) {
168
173
  return !!value && typeof value === "object" && /** @type {{messageKind?: unknown}} */
169
174
  value.messageKind === "error";
170
175
  }
176
+ var REASONING_CHUNK_ENCODER = new TextEncoder();
177
+ var REASONING_CHUNK_DECODER = new TextDecoder("utf-8", { fatal: true });
178
+ function chunkReasoningByUtf8Bytes(text, maxBytes) {
179
+ if (typeof text !== "string") {
180
+ throw new TypeError("[amsg-shared] chunkReasoningByUtf8Bytes: text must be a string");
181
+ }
182
+ if (!Number.isInteger(maxBytes) || maxBytes < 4) {
183
+ throw new RangeError(
184
+ "[amsg-shared] chunkReasoningByUtf8Bytes: maxBytes must be an integer \u2265 4 (UTF-8 max codepoint width)"
185
+ );
186
+ }
187
+ if (text.length === 0) return [];
188
+ const bytes = REASONING_CHUNK_ENCODER.encode(text);
189
+ if (bytes.byteLength <= maxBytes) return [text];
190
+ const chunks = [];
191
+ let start = 0;
192
+ while (start < bytes.byteLength) {
193
+ let end = Math.min(start + maxBytes, bytes.byteLength);
194
+ if (end < bytes.byteLength) {
195
+ while (end > start && (bytes[end] & 192) === 128) {
196
+ end--;
197
+ }
198
+ }
199
+ chunks.push(REASONING_CHUNK_DECODER.decode(bytes.subarray(start, end)));
200
+ start = end;
201
+ }
202
+ return chunks;
203
+ }
package/dist/index.d.cts CHANGED
@@ -41,8 +41,16 @@ export function buildContentPush(args: {
41
41
  * matching `ContentPush` burst when the LLM response carried a non-
42
42
  * empty `reasoning_content`.
43
43
  *
44
- * Does NOT take `messageIndex` / `totalMessages` — reasoning is one
45
- * push per LLM round.
44
+ * Two optional multi-part axes (both omitted from wire when the part
45
+ * count is 1, so single-shot reasoning stays byte-for-byte compatible):
46
+ *
47
+ * - `messageIndex` / `totalMessages` — semantic splitter (sentence
48
+ * regex) produced multiple segments.
49
+ * - `chunkIndex` / `totalChunks` — byte splitter (UTF-8 payload-limit
50
+ * workaround) sliced a single segment across multiple pushes.
51
+ *
52
+ * Both can be set together when a sentence-split segment is itself
53
+ * oversized. See README §"Reasoning chunking".
46
54
  *
47
55
  * @param {Object} args
48
56
  * @param {MessageType} args.messageType
@@ -55,6 +63,10 @@ export function buildContentPush(args: {
55
63
  * @param {string} [args.contactName]
56
64
  * @param {string | null} [args.avatarUrl]
57
65
  * @param {string} [args.messageSubtype]
66
+ * @param {number} [args.messageIndex]
67
+ * @param {number} [args.totalMessages]
68
+ * @param {number} [args.chunkIndex]
69
+ * @param {number} [args.totalChunks]
58
70
  * @param {Object} [args.metadata]
59
71
  * @returns {ReasoningPush}
60
72
  */
@@ -69,6 +81,10 @@ export function buildReasoningPush(args: {
69
81
  contactName?: string;
70
82
  avatarUrl?: string | null;
71
83
  messageSubtype?: string;
84
+ messageIndex?: number;
85
+ totalMessages?: number;
86
+ chunkIndex?: number;
87
+ totalChunks?: number;
72
88
  metadata?: any;
73
89
  }): ReasoningPush;
74
90
  /**
@@ -162,6 +178,40 @@ export function isToolRequestPush(value: unknown): value is ToolRequestPush;
162
178
  * @returns {value is ErrorPush}
163
179
  */
164
180
  export function isErrorPush(value: unknown): value is ErrorPush;
181
+ /**
182
+ * Slice a string into UTF-8 byte chunks no larger than `maxBytes`,
183
+ * always cutting at codepoint boundaries (never inside a multi-byte
184
+ * char). Designed for the {@link ReasoningPush} byte-chunking path
185
+ * in amsg-instant — producers facing the ~3 KB Web Push payload
186
+ * limit slice oversized reasoning into N pushes with
187
+ * `chunkIndex` / `totalChunks`, the SW reassembles by concat.
188
+ *
189
+ * Algorithm: TextEncoder → Uint8Array → backward scan from each
190
+ * candidate cut index until the byte is a UTF-8 lead byte (any byte
191
+ * where `(b & 0xC0) !== 0x80`; continuation bytes are `0b10xxxxxx`).
192
+ * TextDecoder turns each slice back into a JS string.
193
+ *
194
+ * chunkReasoningByUtf8Bytes('A寿B', 4) → ['A寿', 'B'] // '寿' = 3 B,
195
+ * // cut at safe edge
196
+ *
197
+ * Constraints:
198
+ * - `maxBytes` MUST be ≥ 4 (UTF-8 codepoints can be up to 4 bytes;
199
+ * any smaller threshold has no valid cut point for a 4-byte char
200
+ * and is also operationally nonsensical). Throws `RangeError`
201
+ * otherwise.
202
+ * - Empty `text` → `[]` (caller can check `.length === 0`).
203
+ * - `text` whose total UTF-8 byte length ≤ `maxBytes` → `[text]`
204
+ * (no chunking).
205
+ * - `text` MUST be a string. Non-string throws `TypeError`.
206
+ *
207
+ * Joining the result `chunks.join('')` is guaranteed to equal the
208
+ * input `text` (no data loss, no extra whitespace).
209
+ *
210
+ * @param {string} text
211
+ * @param {number} maxBytes
212
+ * @returns {string[]}
213
+ */
214
+ export function chunkReasoningByUtf8Bytes(text: string, maxBytes: number): string[];
165
215
  /**
166
216
  * @rei-standard/amsg-shared
167
217
  *
@@ -290,10 +340,24 @@ export type ContentPush = AmsgPushCommon & {
290
340
  * out of the upstream response into its own push. Emitted **before**
291
341
  * the matching {@link ContentPush} burst when present and non-empty.
292
342
  *
293
- * Intentionally does NOT carry `messageIndex` / `totalMessages` —
294
- * reasoning is a single push per LLM round, never a split-burst.
295
- * That's why those fields are absent at the type level rather than
296
- * `optional` (which would leave callers wondering when they're set).
343
+ * Reasoning carries two orthogonal "multi-part" axes, both optional —
344
+ * they are *omitted* when the part count is 1 so the wire stays
345
+ * byte-for-byte compatible with single-shot ReasoningPush callers:
346
+ *
347
+ * - `messageIndex` / `totalMessages` — set when a semantic
348
+ * splitter (`reasoningSplitPattern` in amsg-instant) has cut the
349
+ * reasoning into multiple sentences for typing-bubble UX.
350
+ *
351
+ * - `chunkIndex` / `totalChunks` — set when a single segment was
352
+ * too large for the Web Push payload limit and the producer had
353
+ * to slice it across multiple pushes at UTF-8 byte boundaries.
354
+ * Transport-only; SW reassembles the original `reasoningContent`
355
+ * by sorting on `chunkIndex` within a `(sessionId, messageIndex)`
356
+ * bucket. See `chunkReasoningByUtf8Bytes` for the safe-edge
357
+ * splitter helper.
358
+ *
359
+ * Both axes can coexist on the same push when a sentence-split
360
+ * segment is itself oversized.
297
361
  */
298
362
  export type ReasoningPush = AmsgPushCommon & {
299
363
  messageKind: "reasoning";
@@ -301,6 +365,10 @@ export type ReasoningPush = AmsgPushCommon & {
301
365
  title?: string;
302
366
  contactName?: string;
303
367
  avatarUrl?: string | null;
368
+ messageIndex?: number;
369
+ totalMessages?: number;
370
+ chunkIndex?: number;
371
+ totalChunks?: number;
304
372
  };
305
373
  /**
306
374
  * Tool invocation request emitted by an agentic-loop hook (`decision:
package/dist/index.d.ts CHANGED
@@ -41,8 +41,16 @@ export function buildContentPush(args: {
41
41
  * matching `ContentPush` burst when the LLM response carried a non-
42
42
  * empty `reasoning_content`.
43
43
  *
44
- * Does NOT take `messageIndex` / `totalMessages` — reasoning is one
45
- * push per LLM round.
44
+ * Two optional multi-part axes (both omitted from wire when the part
45
+ * count is 1, so single-shot reasoning stays byte-for-byte compatible):
46
+ *
47
+ * - `messageIndex` / `totalMessages` — semantic splitter (sentence
48
+ * regex) produced multiple segments.
49
+ * - `chunkIndex` / `totalChunks` — byte splitter (UTF-8 payload-limit
50
+ * workaround) sliced a single segment across multiple pushes.
51
+ *
52
+ * Both can be set together when a sentence-split segment is itself
53
+ * oversized. See README §"Reasoning chunking".
46
54
  *
47
55
  * @param {Object} args
48
56
  * @param {MessageType} args.messageType
@@ -55,6 +63,10 @@ export function buildContentPush(args: {
55
63
  * @param {string} [args.contactName]
56
64
  * @param {string | null} [args.avatarUrl]
57
65
  * @param {string} [args.messageSubtype]
66
+ * @param {number} [args.messageIndex]
67
+ * @param {number} [args.totalMessages]
68
+ * @param {number} [args.chunkIndex]
69
+ * @param {number} [args.totalChunks]
58
70
  * @param {Object} [args.metadata]
59
71
  * @returns {ReasoningPush}
60
72
  */
@@ -69,6 +81,10 @@ export function buildReasoningPush(args: {
69
81
  contactName?: string;
70
82
  avatarUrl?: string | null;
71
83
  messageSubtype?: string;
84
+ messageIndex?: number;
85
+ totalMessages?: number;
86
+ chunkIndex?: number;
87
+ totalChunks?: number;
72
88
  metadata?: any;
73
89
  }): ReasoningPush;
74
90
  /**
@@ -162,6 +178,40 @@ export function isToolRequestPush(value: unknown): value is ToolRequestPush;
162
178
  * @returns {value is ErrorPush}
163
179
  */
164
180
  export function isErrorPush(value: unknown): value is ErrorPush;
181
+ /**
182
+ * Slice a string into UTF-8 byte chunks no larger than `maxBytes`,
183
+ * always cutting at codepoint boundaries (never inside a multi-byte
184
+ * char). Designed for the {@link ReasoningPush} byte-chunking path
185
+ * in amsg-instant — producers facing the ~3 KB Web Push payload
186
+ * limit slice oversized reasoning into N pushes with
187
+ * `chunkIndex` / `totalChunks`, the SW reassembles by concat.
188
+ *
189
+ * Algorithm: TextEncoder → Uint8Array → backward scan from each
190
+ * candidate cut index until the byte is a UTF-8 lead byte (any byte
191
+ * where `(b & 0xC0) !== 0x80`; continuation bytes are `0b10xxxxxx`).
192
+ * TextDecoder turns each slice back into a JS string.
193
+ *
194
+ * chunkReasoningByUtf8Bytes('A寿B', 4) → ['A寿', 'B'] // '寿' = 3 B,
195
+ * // cut at safe edge
196
+ *
197
+ * Constraints:
198
+ * - `maxBytes` MUST be ≥ 4 (UTF-8 codepoints can be up to 4 bytes;
199
+ * any smaller threshold has no valid cut point for a 4-byte char
200
+ * and is also operationally nonsensical). Throws `RangeError`
201
+ * otherwise.
202
+ * - Empty `text` → `[]` (caller can check `.length === 0`).
203
+ * - `text` whose total UTF-8 byte length ≤ `maxBytes` → `[text]`
204
+ * (no chunking).
205
+ * - `text` MUST be a string. Non-string throws `TypeError`.
206
+ *
207
+ * Joining the result `chunks.join('')` is guaranteed to equal the
208
+ * input `text` (no data loss, no extra whitespace).
209
+ *
210
+ * @param {string} text
211
+ * @param {number} maxBytes
212
+ * @returns {string[]}
213
+ */
214
+ export function chunkReasoningByUtf8Bytes(text: string, maxBytes: number): string[];
165
215
  /**
166
216
  * @rei-standard/amsg-shared
167
217
  *
@@ -290,10 +340,24 @@ export type ContentPush = AmsgPushCommon & {
290
340
  * out of the upstream response into its own push. Emitted **before**
291
341
  * the matching {@link ContentPush} burst when present and non-empty.
292
342
  *
293
- * Intentionally does NOT carry `messageIndex` / `totalMessages` —
294
- * reasoning is a single push per LLM round, never a split-burst.
295
- * That's why those fields are absent at the type level rather than
296
- * `optional` (which would leave callers wondering when they're set).
343
+ * Reasoning carries two orthogonal "multi-part" axes, both optional —
344
+ * they are *omitted* when the part count is 1 so the wire stays
345
+ * byte-for-byte compatible with single-shot ReasoningPush callers:
346
+ *
347
+ * - `messageIndex` / `totalMessages` — set when a semantic
348
+ * splitter (`reasoningSplitPattern` in amsg-instant) has cut the
349
+ * reasoning into multiple sentences for typing-bubble UX.
350
+ *
351
+ * - `chunkIndex` / `totalChunks` — set when a single segment was
352
+ * too large for the Web Push payload limit and the producer had
353
+ * to slice it across multiple pushes at UTF-8 byte boundaries.
354
+ * Transport-only; SW reassembles the original `reasoningContent`
355
+ * by sorting on `chunkIndex` within a `(sessionId, messageIndex)`
356
+ * bucket. See `chunkReasoningByUtf8Bytes` for the safe-edge
357
+ * splitter helper.
358
+ *
359
+ * Both axes can coexist on the same push when a sentence-split
360
+ * segment is itself oversized.
297
361
  */
298
362
  export type ReasoningPush = AmsgPushCommon & {
299
363
  messageKind: "reasoning";
@@ -301,6 +365,10 @@ export type ReasoningPush = AmsgPushCommon & {
301
365
  title?: string;
302
366
  contactName?: string;
303
367
  avatarUrl?: string | null;
368
+ messageIndex?: number;
369
+ totalMessages?: number;
370
+ chunkIndex?: number;
371
+ totalChunks?: number;
304
372
  };
305
373
  /**
306
374
  * Tool invocation request emitted by an agentic-loop hook (`decision:
package/dist/index.mjs CHANGED
@@ -68,6 +68,10 @@ function buildReasoningPush(args) {
68
68
  if (args.contactName !== void 0) push.contactName = args.contactName;
69
69
  if (args.avatarUrl !== void 0) push.avatarUrl = args.avatarUrl;
70
70
  if (args.messageSubtype !== void 0) push.messageSubtype = args.messageSubtype;
71
+ if (args.messageIndex !== void 0) push.messageIndex = args.messageIndex;
72
+ if (args.totalMessages !== void 0) push.totalMessages = args.totalMessages;
73
+ if (args.chunkIndex !== void 0) push.chunkIndex = args.chunkIndex;
74
+ if (args.totalChunks !== void 0) push.totalChunks = args.totalChunks;
71
75
  if (args.metadata !== void 0) push.metadata = args.metadata;
72
76
  return push;
73
77
  }
@@ -135,6 +139,34 @@ function isErrorPush(value) {
135
139
  return !!value && typeof value === "object" && /** @type {{messageKind?: unknown}} */
136
140
  value.messageKind === "error";
137
141
  }
142
+ var REASONING_CHUNK_ENCODER = new TextEncoder();
143
+ var REASONING_CHUNK_DECODER = new TextDecoder("utf-8", { fatal: true });
144
+ function chunkReasoningByUtf8Bytes(text, maxBytes) {
145
+ if (typeof text !== "string") {
146
+ throw new TypeError("[amsg-shared] chunkReasoningByUtf8Bytes: text must be a string");
147
+ }
148
+ if (!Number.isInteger(maxBytes) || maxBytes < 4) {
149
+ throw new RangeError(
150
+ "[amsg-shared] chunkReasoningByUtf8Bytes: maxBytes must be an integer \u2265 4 (UTF-8 max codepoint width)"
151
+ );
152
+ }
153
+ if (text.length === 0) return [];
154
+ const bytes = REASONING_CHUNK_ENCODER.encode(text);
155
+ if (bytes.byteLength <= maxBytes) return [text];
156
+ const chunks = [];
157
+ let start = 0;
158
+ while (start < bytes.byteLength) {
159
+ let end = Math.min(start + maxBytes, bytes.byteLength);
160
+ if (end < bytes.byteLength) {
161
+ while (end > start && (bytes[end] & 192) === 128) {
162
+ end--;
163
+ }
164
+ }
165
+ chunks.push(REASONING_CHUNK_DECODER.decode(bytes.subarray(start, end)));
166
+ start = end;
167
+ }
168
+ return chunks;
169
+ }
138
170
  export {
139
171
  MESSAGE_KIND,
140
172
  MESSAGE_TYPE,
@@ -143,6 +175,7 @@ export {
143
175
  buildErrorPush,
144
176
  buildReasoningPush,
145
177
  buildToolRequestPush,
178
+ chunkReasoningByUtf8Bytes,
146
179
  isContentPush,
147
180
  isErrorPush,
148
181
  isReasoningPush,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rei-standard/amsg-shared",
3
- "version": "0.1.0-next.0",
3
+ "version": "0.1.0-next.2",
4
4
  "description": "ReiStandard Active Messaging shared types and push builders — the lowest layer (no deps on other amsg packages)",
5
5
  "repository": {
6
6
  "type": "git",