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

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,
@@ -61,6 +62,7 @@ function buildContentPush(args) {
61
62
  if (typeof args.message !== "string") {
62
63
  throw new Error("[amsg-shared] ContentPush: 'message' must be a string");
63
64
  }
65
+ validateNotificationArg("ContentPush", args.notification);
64
66
  const push = {
65
67
  messageKind: "content",
66
68
  messageType: args.messageType,
@@ -78,6 +80,7 @@ function buildContentPush(args) {
78
80
  if (args.totalMessages !== void 0) push.totalMessages = args.totalMessages;
79
81
  if (args.taskId !== void 0) push.taskId = args.taskId;
80
82
  if (args.metadata !== void 0) push.metadata = args.metadata;
83
+ if (args.notification !== void 0) push.notification = args.notification;
81
84
  return push;
82
85
  }
83
86
  function buildReasoningPush(args) {
@@ -101,6 +104,10 @@ function buildReasoningPush(args) {
101
104
  if (args.contactName !== void 0) push.contactName = args.contactName;
102
105
  if (args.avatarUrl !== void 0) push.avatarUrl = args.avatarUrl;
103
106
  if (args.messageSubtype !== void 0) push.messageSubtype = args.messageSubtype;
107
+ if (args.messageIndex !== void 0) push.messageIndex = args.messageIndex;
108
+ if (args.totalMessages !== void 0) push.totalMessages = args.totalMessages;
109
+ if (args.chunkIndex !== void 0) push.chunkIndex = args.chunkIndex;
110
+ if (args.totalChunks !== void 0) push.totalChunks = args.totalChunks;
104
111
  if (args.metadata !== void 0) push.metadata = args.metadata;
105
112
  return push;
106
113
  }
@@ -112,6 +119,7 @@ function buildToolRequestPush(args) {
112
119
  if (!Array.isArray(args.toolCalls) || args.toolCalls.length === 0) {
113
120
  throw new Error("[amsg-shared] ToolRequestPush: 'toolCalls' must be a non-empty array");
114
121
  }
122
+ validateNotificationArg("ToolRequestPush", args.notification);
115
123
  const push = {
116
124
  messageKind: "tool_request",
117
125
  messageType: args.messageType,
@@ -126,8 +134,29 @@ function buildToolRequestPush(args) {
126
134
  if (args.message !== void 0) push.message = args.message;
127
135
  if (args.messageSubtype !== void 0) push.messageSubtype = args.messageSubtype;
128
136
  if (args.metadata !== void 0) push.metadata = args.metadata;
137
+ if (args.notification !== void 0) push.notification = args.notification;
129
138
  return push;
130
139
  }
140
+ function validateNotificationArg(kind, value) {
141
+ if (value === void 0) return;
142
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
143
+ throw new Error(`[amsg-shared] ${kind}: 'notification' must be a plain object`);
144
+ }
145
+ const n = (
146
+ /** @type {Record<string, unknown>} */
147
+ value
148
+ );
149
+ for (const f of ["title", "body", "icon", "badge", "tag"]) {
150
+ if (n[f] !== void 0 && typeof n[f] !== "string") {
151
+ throw new Error(`[amsg-shared] ${kind}: 'notification.${f}' must be a string when present`);
152
+ }
153
+ }
154
+ for (const f of ["renotify", "requireInteraction"]) {
155
+ if (n[f] !== void 0 && typeof n[f] !== "boolean") {
156
+ throw new Error(`[amsg-shared] ${kind}: 'notification.${f}' must be a boolean when present`);
157
+ }
158
+ }
159
+ }
131
160
  function buildErrorPush(args) {
132
161
  requireField("ErrorPush", "messageType", args.messageType);
133
162
  requireField("ErrorPush", "source", args.source);
@@ -168,3 +197,31 @@ function isErrorPush(value) {
168
197
  return !!value && typeof value === "object" && /** @type {{messageKind?: unknown}} */
169
198
  value.messageKind === "error";
170
199
  }
200
+ var REASONING_CHUNK_ENCODER = new TextEncoder();
201
+ var REASONING_CHUNK_DECODER = new TextDecoder("utf-8", { fatal: true });
202
+ function chunkReasoningByUtf8Bytes(text, maxBytes) {
203
+ if (typeof text !== "string") {
204
+ throw new TypeError("[amsg-shared] chunkReasoningByUtf8Bytes: text must be a string");
205
+ }
206
+ if (!Number.isInteger(maxBytes) || maxBytes < 4) {
207
+ throw new RangeError(
208
+ "[amsg-shared] chunkReasoningByUtf8Bytes: maxBytes must be an integer \u2265 4 (UTF-8 max codepoint width)"
209
+ );
210
+ }
211
+ if (text.length === 0) return [];
212
+ const bytes = REASONING_CHUNK_ENCODER.encode(text);
213
+ if (bytes.byteLength <= maxBytes) return [text];
214
+ const chunks = [];
215
+ let start = 0;
216
+ while (start < bytes.byteLength) {
217
+ let end = Math.min(start + maxBytes, bytes.byteLength);
218
+ if (end < bytes.byteLength) {
219
+ while (end > start && (bytes[end] & 192) === 128) {
220
+ end--;
221
+ }
222
+ }
223
+ chunks.push(REASONING_CHUNK_DECODER.decode(bytes.subarray(start, end)));
224
+ start = end;
225
+ }
226
+ return chunks;
227
+ }
package/dist/index.d.cts CHANGED
@@ -18,6 +18,12 @@
18
18
  * @param {number} [args.totalMessages]
19
19
  * @param {string | null} [args.taskId]
20
20
  * @param {Object} [args.metadata]
21
+ * @param {NotificationDirective} [args.notification]
22
+ * - SW-side `showNotification` overrides for content
23
+ * (and for ToolRequestPush prefix chunks that get
24
+ * demoted to `content` during sentence-split). All
25
+ * fields optional; see {@link NotificationDirective}
26
+ * for the SW fallback chain.
21
27
  * @returns {ContentPush}
22
28
  */
23
29
  export function buildContentPush(args: {
@@ -35,14 +41,23 @@ export function buildContentPush(args: {
35
41
  totalMessages?: number;
36
42
  taskId?: string | null;
37
43
  metadata?: any;
44
+ notification?: NotificationDirective;
38
45
  }): ContentPush;
39
46
  /**
40
47
  * Build a {@link ReasoningPush}. Producers emit this **before** any
41
48
  * matching `ContentPush` burst when the LLM response carried a non-
42
49
  * empty `reasoning_content`.
43
50
  *
44
- * Does NOT take `messageIndex` / `totalMessages` — reasoning is one
45
- * push per LLM round.
51
+ * Two optional multi-part axes (both omitted from wire when the part
52
+ * count is 1, so single-shot reasoning stays byte-for-byte compatible):
53
+ *
54
+ * - `messageIndex` / `totalMessages` — semantic splitter (sentence
55
+ * regex) produced multiple segments.
56
+ * - `chunkIndex` / `totalChunks` — byte splitter (UTF-8 payload-limit
57
+ * workaround) sliced a single segment across multiple pushes.
58
+ *
59
+ * Both can be set together when a sentence-split segment is itself
60
+ * oversized. See README §"Reasoning chunking".
46
61
  *
47
62
  * @param {Object} args
48
63
  * @param {MessageType} args.messageType
@@ -55,6 +70,10 @@ export function buildContentPush(args: {
55
70
  * @param {string} [args.contactName]
56
71
  * @param {string | null} [args.avatarUrl]
57
72
  * @param {string} [args.messageSubtype]
73
+ * @param {number} [args.messageIndex]
74
+ * @param {number} [args.totalMessages]
75
+ * @param {number} [args.chunkIndex]
76
+ * @param {number} [args.totalChunks]
58
77
  * @param {Object} [args.metadata]
59
78
  * @returns {ReasoningPush}
60
79
  */
@@ -69,6 +88,10 @@ export function buildReasoningPush(args: {
69
88
  contactName?: string;
70
89
  avatarUrl?: string | null;
71
90
  messageSubtype?: string;
91
+ messageIndex?: number;
92
+ totalMessages?: number;
93
+ chunkIndex?: number;
94
+ totalChunks?: number;
72
95
  metadata?: any;
73
96
  }): ReasoningPush;
74
97
  /**
@@ -88,6 +111,15 @@ export function buildReasoningPush(args: {
88
111
  * @param {string} [args.message]
89
112
  * @param {string} [args.messageSubtype]
90
113
  * @param {Object} [args.metadata]
114
+ * @param {NotificationDirective} [args.notification]
115
+ * - SW notification overrides. Used after the
116
+ * splitter demotes prefix chunks to `content`
117
+ * (where `messageKind: 'content'` triggers
118
+ * `showNotification`). On the un-demoted last
119
+ * chunk (`messageKind: 'tool_request'`) the
120
+ * SW dispatches silently and the field is
121
+ * ignored — typed here purely so the demoted
122
+ * chunks inherit it via the splitter's spread.
91
123
  * @returns {ToolRequestPush}
92
124
  */
93
125
  export function buildToolRequestPush(args: {
@@ -102,6 +134,7 @@ export function buildToolRequestPush(args: {
102
134
  message?: string;
103
135
  messageSubtype?: string;
104
136
  metadata?: any;
137
+ notification?: NotificationDirective;
105
138
  }): ToolRequestPush;
106
139
  /**
107
140
  * Build an {@link ErrorPush}. Replaces the legacy
@@ -162,6 +195,40 @@ export function isToolRequestPush(value: unknown): value is ToolRequestPush;
162
195
  * @returns {value is ErrorPush}
163
196
  */
164
197
  export function isErrorPush(value: unknown): value is ErrorPush;
198
+ /**
199
+ * Slice a string into UTF-8 byte chunks no larger than `maxBytes`,
200
+ * always cutting at codepoint boundaries (never inside a multi-byte
201
+ * char). Designed for the {@link ReasoningPush} byte-chunking path
202
+ * in amsg-instant — producers facing the ~3 KB Web Push payload
203
+ * limit slice oversized reasoning into N pushes with
204
+ * `chunkIndex` / `totalChunks`, the SW reassembles by concat.
205
+ *
206
+ * Algorithm: TextEncoder → Uint8Array → backward scan from each
207
+ * candidate cut index until the byte is a UTF-8 lead byte (any byte
208
+ * where `(b & 0xC0) !== 0x80`; continuation bytes are `0b10xxxxxx`).
209
+ * TextDecoder turns each slice back into a JS string.
210
+ *
211
+ * chunkReasoningByUtf8Bytes('A寿B', 4) → ['A寿', 'B'] // '寿' = 3 B,
212
+ * // cut at safe edge
213
+ *
214
+ * Constraints:
215
+ * - `maxBytes` MUST be ≥ 4 (UTF-8 codepoints can be up to 4 bytes;
216
+ * any smaller threshold has no valid cut point for a 4-byte char
217
+ * and is also operationally nonsensical). Throws `RangeError`
218
+ * otherwise.
219
+ * - Empty `text` → `[]` (caller can check `.length === 0`).
220
+ * - `text` whose total UTF-8 byte length ≤ `maxBytes` → `[text]`
221
+ * (no chunking).
222
+ * - `text` MUST be a string. Non-string throws `TypeError`.
223
+ *
224
+ * Joining the result `chunks.join('')` is guaranteed to equal the
225
+ * input `text` (no data loss, no extra whitespace).
226
+ *
227
+ * @param {string} text
228
+ * @param {number} maxBytes
229
+ * @returns {string[]}
230
+ */
231
+ export function chunkReasoningByUtf8Bytes(text: string, maxBytes: number): string[];
165
232
  /**
166
233
  * @rei-standard/amsg-shared
167
234
  *
@@ -270,6 +337,58 @@ export type AmsgPushCommon = {
270
337
  */
271
338
  metadata?: any;
272
339
  };
340
+ /**
341
+ * SW-rendering directive carried on `ContentPush` / `ToolRequestPush`.
342
+ * Mirrors the seven fields that `amsg-sw`'s `createNotificationFromPayload`
343
+ * actually consumes (`notification.{title,body,icon,badge,tag,renotify,requireInteraction}`)
344
+ * — typing all seven (rather than just `title` / `body`) so callers
345
+ * don't lose IDE checking on the other five and slip back into the
346
+ * untyped-spread footgun this typedef was added to close.
347
+ *
348
+ * Routing in SW (kept here so producers don't have to cross-check):
349
+ * - `messageKind: 'content'` (and legacy un-kinded payloads) →
350
+ * `notification.*` is consulted, with per-field fallback to
351
+ * the top-level `title` / `avatarUrl` / `messageId` and finally
352
+ * to the SW's `defaultIcon` / `defaultBadge` options. Everything
353
+ * else (`tag`, `renotify`, `requireInteraction`) has no top-level
354
+ * fallback — set them under `notification` or accept the SW
355
+ * default (`messageId`-derived tag, no renotify, no requireInteraction).
356
+ * - `messageKind: 'reasoning'` / `'tool_request'` / `'error'` →
357
+ * dispatched silently to controlled clients. `notification` is
358
+ * ignored. (It's still typed on `ToolRequestPush` because the
359
+ * splitter demotes prefix chunks to `messageKind: 'content'`, at
360
+ * which point the field starts mattering.)
361
+ */
362
+ export type NotificationDirective = {
363
+ /**
364
+ * - Notification title override.
365
+ */
366
+ title?: string;
367
+ /**
368
+ * - Notification body override.
369
+ */
370
+ body?: string;
371
+ /**
372
+ * - Icon URL override (falls back to top-level `avatarUrl` then SW `defaultIcon`).
373
+ */
374
+ icon?: string;
375
+ /**
376
+ * - Badge URL override (falls back to SW `defaultBadge`).
377
+ */
378
+ badge?: string;
379
+ /**
380
+ * - Notification grouping tag; matching tag replaces the prior notification.
381
+ */
382
+ tag?: string;
383
+ /**
384
+ * - When tag matches, still vibrate/sound. Default false at SW.
385
+ */
386
+ renotify?: boolean;
387
+ /**
388
+ * - Notification stays until user dismisses. Default false at SW.
389
+ */
390
+ requireInteraction?: boolean;
391
+ };
273
392
  /**
274
393
  * Final user-facing content. Sentence-split bursts of N use
275
394
  * `messageIndex` (1-based) + `totalMessages` so the client can
@@ -284,16 +403,31 @@ export type ContentPush = AmsgPushCommon & {
284
403
  messageIndex?: number;
285
404
  totalMessages?: number;
286
405
  taskId?: string | null;
406
+ notification?: NotificationDirective;
287
407
  };
288
408
  /**
289
409
  * LLM "meta-thinking" — `choices[0].message.reasoning_content` lifted
290
410
  * out of the upstream response into its own push. Emitted **before**
291
411
  * the matching {@link ContentPush} burst when present and non-empty.
292
412
  *
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).
413
+ * Reasoning carries two orthogonal "multi-part" axes, both optional —
414
+ * they are *omitted* when the part count is 1 so the wire stays
415
+ * byte-for-byte compatible with single-shot ReasoningPush callers:
416
+ *
417
+ * - `messageIndex` / `totalMessages` — set when a semantic
418
+ * splitter (`reasoningSplitPattern` in amsg-instant) has cut the
419
+ * reasoning into multiple sentences for typing-bubble UX.
420
+ *
421
+ * - `chunkIndex` / `totalChunks` — set when a single segment was
422
+ * too large for the Web Push payload limit and the producer had
423
+ * to slice it across multiple pushes at UTF-8 byte boundaries.
424
+ * Transport-only; SW reassembles the original `reasoningContent`
425
+ * by sorting on `chunkIndex` within a `(sessionId, messageIndex)`
426
+ * bucket. See `chunkReasoningByUtf8Bytes` for the safe-edge
427
+ * splitter helper.
428
+ *
429
+ * Both axes can coexist on the same push when a sentence-split
430
+ * segment is itself oversized.
297
431
  */
298
432
  export type ReasoningPush = AmsgPushCommon & {
299
433
  messageKind: "reasoning";
@@ -301,6 +435,10 @@ export type ReasoningPush = AmsgPushCommon & {
301
435
  title?: string;
302
436
  contactName?: string;
303
437
  avatarUrl?: string | null;
438
+ messageIndex?: number;
439
+ totalMessages?: number;
440
+ chunkIndex?: number;
441
+ totalChunks?: number;
304
442
  };
305
443
  /**
306
444
  * Tool invocation request emitted by an agentic-loop hook (`decision:
@@ -317,6 +455,7 @@ export type ToolRequestPush = AmsgPushCommon & {
317
455
  title?: string;
318
456
  contactName?: string;
319
457
  message?: string;
458
+ notification?: NotificationDirective;
320
459
  };
321
460
  /**
322
461
  * Producer-level error. Replaces the legacy
package/dist/index.d.ts CHANGED
@@ -18,6 +18,12 @@
18
18
  * @param {number} [args.totalMessages]
19
19
  * @param {string | null} [args.taskId]
20
20
  * @param {Object} [args.metadata]
21
+ * @param {NotificationDirective} [args.notification]
22
+ * - SW-side `showNotification` overrides for content
23
+ * (and for ToolRequestPush prefix chunks that get
24
+ * demoted to `content` during sentence-split). All
25
+ * fields optional; see {@link NotificationDirective}
26
+ * for the SW fallback chain.
21
27
  * @returns {ContentPush}
22
28
  */
23
29
  export function buildContentPush(args: {
@@ -35,14 +41,23 @@ export function buildContentPush(args: {
35
41
  totalMessages?: number;
36
42
  taskId?: string | null;
37
43
  metadata?: any;
44
+ notification?: NotificationDirective;
38
45
  }): ContentPush;
39
46
  /**
40
47
  * Build a {@link ReasoningPush}. Producers emit this **before** any
41
48
  * matching `ContentPush` burst when the LLM response carried a non-
42
49
  * empty `reasoning_content`.
43
50
  *
44
- * Does NOT take `messageIndex` / `totalMessages` — reasoning is one
45
- * push per LLM round.
51
+ * Two optional multi-part axes (both omitted from wire when the part
52
+ * count is 1, so single-shot reasoning stays byte-for-byte compatible):
53
+ *
54
+ * - `messageIndex` / `totalMessages` — semantic splitter (sentence
55
+ * regex) produced multiple segments.
56
+ * - `chunkIndex` / `totalChunks` — byte splitter (UTF-8 payload-limit
57
+ * workaround) sliced a single segment across multiple pushes.
58
+ *
59
+ * Both can be set together when a sentence-split segment is itself
60
+ * oversized. See README §"Reasoning chunking".
46
61
  *
47
62
  * @param {Object} args
48
63
  * @param {MessageType} args.messageType
@@ -55,6 +70,10 @@ export function buildContentPush(args: {
55
70
  * @param {string} [args.contactName]
56
71
  * @param {string | null} [args.avatarUrl]
57
72
  * @param {string} [args.messageSubtype]
73
+ * @param {number} [args.messageIndex]
74
+ * @param {number} [args.totalMessages]
75
+ * @param {number} [args.chunkIndex]
76
+ * @param {number} [args.totalChunks]
58
77
  * @param {Object} [args.metadata]
59
78
  * @returns {ReasoningPush}
60
79
  */
@@ -69,6 +88,10 @@ export function buildReasoningPush(args: {
69
88
  contactName?: string;
70
89
  avatarUrl?: string | null;
71
90
  messageSubtype?: string;
91
+ messageIndex?: number;
92
+ totalMessages?: number;
93
+ chunkIndex?: number;
94
+ totalChunks?: number;
72
95
  metadata?: any;
73
96
  }): ReasoningPush;
74
97
  /**
@@ -88,6 +111,15 @@ export function buildReasoningPush(args: {
88
111
  * @param {string} [args.message]
89
112
  * @param {string} [args.messageSubtype]
90
113
  * @param {Object} [args.metadata]
114
+ * @param {NotificationDirective} [args.notification]
115
+ * - SW notification overrides. Used after the
116
+ * splitter demotes prefix chunks to `content`
117
+ * (where `messageKind: 'content'` triggers
118
+ * `showNotification`). On the un-demoted last
119
+ * chunk (`messageKind: 'tool_request'`) the
120
+ * SW dispatches silently and the field is
121
+ * ignored — typed here purely so the demoted
122
+ * chunks inherit it via the splitter's spread.
91
123
  * @returns {ToolRequestPush}
92
124
  */
93
125
  export function buildToolRequestPush(args: {
@@ -102,6 +134,7 @@ export function buildToolRequestPush(args: {
102
134
  message?: string;
103
135
  messageSubtype?: string;
104
136
  metadata?: any;
137
+ notification?: NotificationDirective;
105
138
  }): ToolRequestPush;
106
139
  /**
107
140
  * Build an {@link ErrorPush}. Replaces the legacy
@@ -162,6 +195,40 @@ export function isToolRequestPush(value: unknown): value is ToolRequestPush;
162
195
  * @returns {value is ErrorPush}
163
196
  */
164
197
  export function isErrorPush(value: unknown): value is ErrorPush;
198
+ /**
199
+ * Slice a string into UTF-8 byte chunks no larger than `maxBytes`,
200
+ * always cutting at codepoint boundaries (never inside a multi-byte
201
+ * char). Designed for the {@link ReasoningPush} byte-chunking path
202
+ * in amsg-instant — producers facing the ~3 KB Web Push payload
203
+ * limit slice oversized reasoning into N pushes with
204
+ * `chunkIndex` / `totalChunks`, the SW reassembles by concat.
205
+ *
206
+ * Algorithm: TextEncoder → Uint8Array → backward scan from each
207
+ * candidate cut index until the byte is a UTF-8 lead byte (any byte
208
+ * where `(b & 0xC0) !== 0x80`; continuation bytes are `0b10xxxxxx`).
209
+ * TextDecoder turns each slice back into a JS string.
210
+ *
211
+ * chunkReasoningByUtf8Bytes('A寿B', 4) → ['A寿', 'B'] // '寿' = 3 B,
212
+ * // cut at safe edge
213
+ *
214
+ * Constraints:
215
+ * - `maxBytes` MUST be ≥ 4 (UTF-8 codepoints can be up to 4 bytes;
216
+ * any smaller threshold has no valid cut point for a 4-byte char
217
+ * and is also operationally nonsensical). Throws `RangeError`
218
+ * otherwise.
219
+ * - Empty `text` → `[]` (caller can check `.length === 0`).
220
+ * - `text` whose total UTF-8 byte length ≤ `maxBytes` → `[text]`
221
+ * (no chunking).
222
+ * - `text` MUST be a string. Non-string throws `TypeError`.
223
+ *
224
+ * Joining the result `chunks.join('')` is guaranteed to equal the
225
+ * input `text` (no data loss, no extra whitespace).
226
+ *
227
+ * @param {string} text
228
+ * @param {number} maxBytes
229
+ * @returns {string[]}
230
+ */
231
+ export function chunkReasoningByUtf8Bytes(text: string, maxBytes: number): string[];
165
232
  /**
166
233
  * @rei-standard/amsg-shared
167
234
  *
@@ -270,6 +337,58 @@ export type AmsgPushCommon = {
270
337
  */
271
338
  metadata?: any;
272
339
  };
340
+ /**
341
+ * SW-rendering directive carried on `ContentPush` / `ToolRequestPush`.
342
+ * Mirrors the seven fields that `amsg-sw`'s `createNotificationFromPayload`
343
+ * actually consumes (`notification.{title,body,icon,badge,tag,renotify,requireInteraction}`)
344
+ * — typing all seven (rather than just `title` / `body`) so callers
345
+ * don't lose IDE checking on the other five and slip back into the
346
+ * untyped-spread footgun this typedef was added to close.
347
+ *
348
+ * Routing in SW (kept here so producers don't have to cross-check):
349
+ * - `messageKind: 'content'` (and legacy un-kinded payloads) →
350
+ * `notification.*` is consulted, with per-field fallback to
351
+ * the top-level `title` / `avatarUrl` / `messageId` and finally
352
+ * to the SW's `defaultIcon` / `defaultBadge` options. Everything
353
+ * else (`tag`, `renotify`, `requireInteraction`) has no top-level
354
+ * fallback — set them under `notification` or accept the SW
355
+ * default (`messageId`-derived tag, no renotify, no requireInteraction).
356
+ * - `messageKind: 'reasoning'` / `'tool_request'` / `'error'` →
357
+ * dispatched silently to controlled clients. `notification` is
358
+ * ignored. (It's still typed on `ToolRequestPush` because the
359
+ * splitter demotes prefix chunks to `messageKind: 'content'`, at
360
+ * which point the field starts mattering.)
361
+ */
362
+ export type NotificationDirective = {
363
+ /**
364
+ * - Notification title override.
365
+ */
366
+ title?: string;
367
+ /**
368
+ * - Notification body override.
369
+ */
370
+ body?: string;
371
+ /**
372
+ * - Icon URL override (falls back to top-level `avatarUrl` then SW `defaultIcon`).
373
+ */
374
+ icon?: string;
375
+ /**
376
+ * - Badge URL override (falls back to SW `defaultBadge`).
377
+ */
378
+ badge?: string;
379
+ /**
380
+ * - Notification grouping tag; matching tag replaces the prior notification.
381
+ */
382
+ tag?: string;
383
+ /**
384
+ * - When tag matches, still vibrate/sound. Default false at SW.
385
+ */
386
+ renotify?: boolean;
387
+ /**
388
+ * - Notification stays until user dismisses. Default false at SW.
389
+ */
390
+ requireInteraction?: boolean;
391
+ };
273
392
  /**
274
393
  * Final user-facing content. Sentence-split bursts of N use
275
394
  * `messageIndex` (1-based) + `totalMessages` so the client can
@@ -284,16 +403,31 @@ export type ContentPush = AmsgPushCommon & {
284
403
  messageIndex?: number;
285
404
  totalMessages?: number;
286
405
  taskId?: string | null;
406
+ notification?: NotificationDirective;
287
407
  };
288
408
  /**
289
409
  * LLM "meta-thinking" — `choices[0].message.reasoning_content` lifted
290
410
  * out of the upstream response into its own push. Emitted **before**
291
411
  * the matching {@link ContentPush} burst when present and non-empty.
292
412
  *
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).
413
+ * Reasoning carries two orthogonal "multi-part" axes, both optional —
414
+ * they are *omitted* when the part count is 1 so the wire stays
415
+ * byte-for-byte compatible with single-shot ReasoningPush callers:
416
+ *
417
+ * - `messageIndex` / `totalMessages` — set when a semantic
418
+ * splitter (`reasoningSplitPattern` in amsg-instant) has cut the
419
+ * reasoning into multiple sentences for typing-bubble UX.
420
+ *
421
+ * - `chunkIndex` / `totalChunks` — set when a single segment was
422
+ * too large for the Web Push payload limit and the producer had
423
+ * to slice it across multiple pushes at UTF-8 byte boundaries.
424
+ * Transport-only; SW reassembles the original `reasoningContent`
425
+ * by sorting on `chunkIndex` within a `(sessionId, messageIndex)`
426
+ * bucket. See `chunkReasoningByUtf8Bytes` for the safe-edge
427
+ * splitter helper.
428
+ *
429
+ * Both axes can coexist on the same push when a sentence-split
430
+ * segment is itself oversized.
297
431
  */
298
432
  export type ReasoningPush = AmsgPushCommon & {
299
433
  messageKind: "reasoning";
@@ -301,6 +435,10 @@ export type ReasoningPush = AmsgPushCommon & {
301
435
  title?: string;
302
436
  contactName?: string;
303
437
  avatarUrl?: string | null;
438
+ messageIndex?: number;
439
+ totalMessages?: number;
440
+ chunkIndex?: number;
441
+ totalChunks?: number;
304
442
  };
305
443
  /**
306
444
  * Tool invocation request emitted by an agentic-loop hook (`decision:
@@ -317,6 +455,7 @@ export type ToolRequestPush = AmsgPushCommon & {
317
455
  title?: string;
318
456
  contactName?: string;
319
457
  message?: string;
458
+ notification?: NotificationDirective;
320
459
  };
321
460
  /**
322
461
  * Producer-level error. Replaces the legacy
package/dist/index.mjs CHANGED
@@ -28,6 +28,7 @@ function buildContentPush(args) {
28
28
  if (typeof args.message !== "string") {
29
29
  throw new Error("[amsg-shared] ContentPush: 'message' must be a string");
30
30
  }
31
+ validateNotificationArg("ContentPush", args.notification);
31
32
  const push = {
32
33
  messageKind: "content",
33
34
  messageType: args.messageType,
@@ -45,6 +46,7 @@ function buildContentPush(args) {
45
46
  if (args.totalMessages !== void 0) push.totalMessages = args.totalMessages;
46
47
  if (args.taskId !== void 0) push.taskId = args.taskId;
47
48
  if (args.metadata !== void 0) push.metadata = args.metadata;
49
+ if (args.notification !== void 0) push.notification = args.notification;
48
50
  return push;
49
51
  }
50
52
  function buildReasoningPush(args) {
@@ -68,6 +70,10 @@ function buildReasoningPush(args) {
68
70
  if (args.contactName !== void 0) push.contactName = args.contactName;
69
71
  if (args.avatarUrl !== void 0) push.avatarUrl = args.avatarUrl;
70
72
  if (args.messageSubtype !== void 0) push.messageSubtype = args.messageSubtype;
73
+ if (args.messageIndex !== void 0) push.messageIndex = args.messageIndex;
74
+ if (args.totalMessages !== void 0) push.totalMessages = args.totalMessages;
75
+ if (args.chunkIndex !== void 0) push.chunkIndex = args.chunkIndex;
76
+ if (args.totalChunks !== void 0) push.totalChunks = args.totalChunks;
71
77
  if (args.metadata !== void 0) push.metadata = args.metadata;
72
78
  return push;
73
79
  }
@@ -79,6 +85,7 @@ function buildToolRequestPush(args) {
79
85
  if (!Array.isArray(args.toolCalls) || args.toolCalls.length === 0) {
80
86
  throw new Error("[amsg-shared] ToolRequestPush: 'toolCalls' must be a non-empty array");
81
87
  }
88
+ validateNotificationArg("ToolRequestPush", args.notification);
82
89
  const push = {
83
90
  messageKind: "tool_request",
84
91
  messageType: args.messageType,
@@ -93,8 +100,29 @@ function buildToolRequestPush(args) {
93
100
  if (args.message !== void 0) push.message = args.message;
94
101
  if (args.messageSubtype !== void 0) push.messageSubtype = args.messageSubtype;
95
102
  if (args.metadata !== void 0) push.metadata = args.metadata;
103
+ if (args.notification !== void 0) push.notification = args.notification;
96
104
  return push;
97
105
  }
106
+ function validateNotificationArg(kind, value) {
107
+ if (value === void 0) return;
108
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
109
+ throw new Error(`[amsg-shared] ${kind}: 'notification' must be a plain object`);
110
+ }
111
+ const n = (
112
+ /** @type {Record<string, unknown>} */
113
+ value
114
+ );
115
+ for (const f of ["title", "body", "icon", "badge", "tag"]) {
116
+ if (n[f] !== void 0 && typeof n[f] !== "string") {
117
+ throw new Error(`[amsg-shared] ${kind}: 'notification.${f}' must be a string when present`);
118
+ }
119
+ }
120
+ for (const f of ["renotify", "requireInteraction"]) {
121
+ if (n[f] !== void 0 && typeof n[f] !== "boolean") {
122
+ throw new Error(`[amsg-shared] ${kind}: 'notification.${f}' must be a boolean when present`);
123
+ }
124
+ }
125
+ }
98
126
  function buildErrorPush(args) {
99
127
  requireField("ErrorPush", "messageType", args.messageType);
100
128
  requireField("ErrorPush", "source", args.source);
@@ -135,6 +163,34 @@ function isErrorPush(value) {
135
163
  return !!value && typeof value === "object" && /** @type {{messageKind?: unknown}} */
136
164
  value.messageKind === "error";
137
165
  }
166
+ var REASONING_CHUNK_ENCODER = new TextEncoder();
167
+ var REASONING_CHUNK_DECODER = new TextDecoder("utf-8", { fatal: true });
168
+ function chunkReasoningByUtf8Bytes(text, maxBytes) {
169
+ if (typeof text !== "string") {
170
+ throw new TypeError("[amsg-shared] chunkReasoningByUtf8Bytes: text must be a string");
171
+ }
172
+ if (!Number.isInteger(maxBytes) || maxBytes < 4) {
173
+ throw new RangeError(
174
+ "[amsg-shared] chunkReasoningByUtf8Bytes: maxBytes must be an integer \u2265 4 (UTF-8 max codepoint width)"
175
+ );
176
+ }
177
+ if (text.length === 0) return [];
178
+ const bytes = REASONING_CHUNK_ENCODER.encode(text);
179
+ if (bytes.byteLength <= maxBytes) return [text];
180
+ const chunks = [];
181
+ let start = 0;
182
+ while (start < bytes.byteLength) {
183
+ let end = Math.min(start + maxBytes, bytes.byteLength);
184
+ if (end < bytes.byteLength) {
185
+ while (end > start && (bytes[end] & 192) === 128) {
186
+ end--;
187
+ }
188
+ }
189
+ chunks.push(REASONING_CHUNK_DECODER.decode(bytes.subarray(start, end)));
190
+ start = end;
191
+ }
192
+ return chunks;
193
+ }
138
194
  export {
139
195
  MESSAGE_KIND,
140
196
  MESSAGE_TYPE,
@@ -143,6 +199,7 @@ export {
143
199
  buildErrorPush,
144
200
  buildReasoningPush,
145
201
  buildToolRequestPush,
202
+ chunkReasoningByUtf8Bytes,
146
203
  isContentPush,
147
204
  isErrorPush,
148
205
  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.3",
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",