@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 +57 -0
- package/dist/index.d.cts +145 -6
- package/dist/index.d.ts +145 -6
- package/dist/index.mjs +57 -0
- package/package.json +1 -1
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
|
-
*
|
|
45
|
-
*
|
|
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
|
-
*
|
|
294
|
-
*
|
|
295
|
-
*
|
|
296
|
-
*
|
|
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
|
-
*
|
|
45
|
-
*
|
|
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
|
-
*
|
|
294
|
-
*
|
|
295
|
-
*
|
|
296
|
-
*
|
|
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