@rei-standard/amsg-shared 0.1.0 → 0.3.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.
package/README.md CHANGED
@@ -1,7 +1,7 @@
1
1
  # @rei-standard/amsg-shared
2
2
 
3
- Lowest layer of the ReiStandard Active Messaging ecosystem. Defines
4
- the **three-axis push contract** that `amsg-instant`, `amsg-server`,
3
+ Lowest layer of the ReiStandard Active Messaging stack. Defines
4
+ the **push schema** that `amsg-instant`, `amsg-server`,
5
5
  `amsg-sw`, and `amsg-client` all conform to.
6
6
 
7
7
  Zero runtime deps. Does **not** depend on any other amsg package —
@@ -9,9 +9,9 @@ every other amsg sub-package depends on this one, never the reverse.
9
9
 
10
10
  ---
11
11
 
12
- ## Three axes
12
+ ## Push schema
13
13
 
14
- A single push is described by three orthogonal axes:
14
+ A single push is described by three independent dimensions:
15
15
 
16
16
  | Axis | Field | Values | Defined by |
17
17
  |----------------|-------------------|-------------------------------------------------------|--------------------|
@@ -22,7 +22,7 @@ A single push is described by three orthogonal axes:
22
22
  `messageType` answers **how this push was produced** (one-shot
23
23
  `instant` worker, scheduled `fixed` ping, AI-`prompted` reply, fully
24
24
  `auto`-generated cadence). `messageKind` answers **what it carries**.
25
- The two are intentionally orthogonal: any `messageType` can carry any
25
+ The two are intentionally independent: any `messageType` can carry any
26
26
  `messageKind`.
27
27
 
28
28
  There is also `source: 'instant' | 'scheduled'` — the **routing
@@ -48,6 +48,30 @@ origin** (`'instant'` for `amsg-instant`, `'scheduled'` for any
48
48
 
49
49
  ---
50
50
 
51
+ ## Notification directive
52
+
53
+ `ContentPush` and `ToolRequestPush` can carry an optional
54
+ `notification` object. It is a producer-side hint consumed by
55
+ `@rei-standard/amsg-sw` before rendering a system notification.
56
+
57
+ | Field | Type | Notes |
58
+ |----------------------|-------------------------------------------|-------|
59
+ | `show` | `'auto' \| 'always' \| 'when-hidden' \| false` | Display policy. `auto` follows SW defaults. |
60
+ | `title` | `string?` | Notification title override. |
61
+ | `body` | `string?` | Notification body override. |
62
+ | `icon` | `string?` | Notification icon URL. |
63
+ | `badge` | `string?` | Notification badge URL. |
64
+ | `tag` | `string?` | Notification grouping tag. |
65
+ | `renotify` | `boolean?` | Re-alert when a matching `tag` replaces an existing notification. |
66
+ | `requireInteraction` | `boolean?` | Keep the notification visible until the user dismisses it. |
67
+ | `silent` | `boolean?` | Suppress notification sound and vibration. |
68
+ | `data` | `Record<string, unknown>?` | Custom data passed to the notification. |
69
+
70
+ Unknown fields are preserved for forward compatibility, but the known
71
+ fields above are validated by the builders when present.
72
+
73
+ ---
74
+
51
75
  ## Per-kind fields
52
76
 
53
77
  ### `ContentPush` — final user-facing content
package/dist/index.cjs CHANGED
@@ -19,6 +19,7 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
19
19
  // src/index.js
20
20
  var src_exports = {};
21
21
  __export(src_exports, {
22
+ AVATAR_URL_MAX_LENGTH: () => AVATAR_URL_MAX_LENGTH,
22
23
  MESSAGE_KIND: () => MESSAGE_KIND,
23
24
  MESSAGE_TYPE: () => MESSAGE_TYPE,
24
25
  PUSH_SOURCE: () => PUSH_SOURCE,
@@ -33,7 +34,12 @@ __export(src_exports, {
33
34
  isErrorPush: () => isErrorPush,
34
35
  isReasoningPush: () => isReasoningPush,
35
36
  isToolRequestPush: () => isToolRequestPush,
36
- toUint8: () => toUint8
37
+ isValidUrl: () => isValidUrl,
38
+ normalizeVapidSubject: () => normalizeVapidSubject,
39
+ readReasoningContent: () => readReasoningContent,
40
+ stripReasoningTags: () => stripReasoningTags,
41
+ toUint8: () => toUint8,
42
+ validateAvatarUrl: () => validateAvatarUrl
37
43
  });
38
44
  module.exports = __toCommonJS(src_exports);
39
45
  var MESSAGE_KIND = Object.freeze({
@@ -159,7 +165,7 @@ function validateNotificationArg(kind, value) {
159
165
  throw new Error(`[amsg-shared] ${kind}: 'notification.${f}' must be a string when present`);
160
166
  }
161
167
  }
162
- for (const f of ["renotify", "requireInteraction"]) {
168
+ for (const f of ["renotify", "requireInteraction", "silent"]) {
163
169
  if (n[f] !== void 0 && typeof n[f] !== "boolean") {
164
170
  throw new Error(`[amsg-shared] ${kind}: 'notification.${f}' must be a boolean when present`);
165
171
  }
@@ -264,3 +270,66 @@ function concatBytes(...chunks) {
264
270
  }
265
271
  return out;
266
272
  }
273
+ function isValidUrl(value) {
274
+ if (typeof value !== "string") return false;
275
+ try {
276
+ new URL(value);
277
+ return true;
278
+ } catch {
279
+ return false;
280
+ }
281
+ }
282
+ var AVATAR_URL_MAX_LENGTH = 2048;
283
+ function validateAvatarUrl(value) {
284
+ if (value === void 0 || value === null) return null;
285
+ if (typeof value !== "string") {
286
+ return "avatarUrl \u5FC5\u987B\u662F\u5B57\u7B26\u4E32";
287
+ }
288
+ if (/^data:/i.test(value)) {
289
+ return "\u5934\u50CF\u4E0D\u652F\u6301\u4F20\u5165 data: URI\uFF0C\u8BF7\u6539\u4E3A\u516C\u7F51\u53EF\u8BBF\u95EE\u7684 https:// \u56FE\u7247 URL";
290
+ }
291
+ if (value.length > AVATAR_URL_MAX_LENGTH) {
292
+ return `\u5934\u50CF URL \u957F\u5EA6 ${value.length} \u5B57\u7B26\u8D85\u8FC7 ${AVATAR_URL_MAX_LENGTH} \u4E0A\u9650\uFF0C\u8BF7\u6539\u4E3A\u66F4\u77ED\u7684\u56FE\u7247 URL`;
293
+ }
294
+ if (!isValidUrl(value)) {
295
+ return "avatarUrl \u4E0D\u662F\u5408\u6CD5 URL";
296
+ }
297
+ return null;
298
+ }
299
+ function normalizeVapidSubject(email) {
300
+ const trimmed = String(email || "").trim();
301
+ if (!trimmed) return "";
302
+ return /^mailto:/i.test(trimmed) || /^https?:/i.test(trimmed) ? trimmed : `mailto:${trimmed}`;
303
+ }
304
+ var REASONING_TAG_RE = /<(think|thinking|thought)>([\s\S]*?)<\/\1>/i;
305
+ var REASONING_TAG_RE_G = /<(think|thinking|thought)>[\s\S]*?<\/\1>/gi;
306
+ function readReasoningContent(llmResponse) {
307
+ if (!llmResponse || typeof llmResponse !== "object") return null;
308
+ const choices = (
309
+ /** @type {{ choices?: unknown }} */
310
+ llmResponse.choices
311
+ );
312
+ if (!Array.isArray(choices) || choices.length === 0) return null;
313
+ const message = (
314
+ /** @type {{ message?: { reasoning_content?: unknown, content?: unknown } }} */
315
+ choices[0]?.message
316
+ );
317
+ const raw = message?.reasoning_content;
318
+ if (typeof raw === "string") {
319
+ const trimmed = raw.trim();
320
+ if (trimmed.length > 0) return trimmed;
321
+ }
322
+ const content = message?.content;
323
+ if (typeof content === "string") {
324
+ const match = content.match(REASONING_TAG_RE);
325
+ if (match) {
326
+ const trimmed = match[2].trim();
327
+ if (trimmed.length > 0) return trimmed;
328
+ }
329
+ }
330
+ return null;
331
+ }
332
+ function stripReasoningTags(content) {
333
+ if (typeof content !== "string" || !content.includes("<")) return content;
334
+ return content.replace(REASONING_TAG_RE_G, "").trim();
335
+ }
package/dist/index.d.cts CHANGED
@@ -200,10 +200,10 @@ export function isErrorPush(value: unknown): value is ErrorPush;
200
200
  /**
201
201
  * Slice a string into UTF-8 byte chunks no larger than `maxBytes`,
202
202
  * always cutting at codepoint boundaries (never inside a multi-byte
203
- * char). Designed for the {@link ReasoningPush} byte-chunking path
204
- * in amsg-instant — producers facing the ~3 KB Web Push payload
205
- * limit slice oversized reasoning into N pushes with
206
- * `chunkIndex` / `totalChunks`, the SW reassembles by concat.
203
+ * char). This is a generic byte-safe string helper retained for
204
+ * callers that need deterministic UTF-8 chunking around small Web Push
205
+ * payload budgets; current amsg-instant oversized payload delivery uses
206
+ * BlobStore / generic multipart instead of reasoning-only wire fields.
207
207
  *
208
208
  * Algorithm: TextEncoder → Uint8Array → backward scan from each
209
209
  * candidate cut index until the byte is a UTF-8 lead byte (any byte
@@ -247,6 +247,56 @@ export function base64UrlToBytes(input: string): Uint8Array;
247
247
  * @returns {Uint8Array}
248
248
  */
249
249
  export function concatBytes(...chunks: (Uint8Array | ArrayBuffer | ArrayBufferView)[]): Uint8Array;
250
+ /**
251
+ * True when `value` parses as an absolute URL.
252
+ * @param {unknown} value
253
+ * @returns {boolean}
254
+ */
255
+ export function isValidUrl(value: unknown): boolean;
256
+ /**
257
+ * Validate the optional `avatarUrl` field. Rejects `data:` URIs (typically
258
+ * base64-encoded inline images) and anything longer than
259
+ * {@link AVATAR_URL_MAX_LENGTH} chars — both the dominant trigger for
260
+ * downstream 413 / Web Push 4 KB payload errors — plus anything that doesn't
261
+ * parse as a URL. Returns an error message string, or null when valid.
262
+ *
263
+ * Pure: callers decide how to act on a non-null result (amsg-server /
264
+ * amsg-instant / amsg-client soft-strip + console.warn; see standards §6.2).
265
+ *
266
+ * @param {unknown} value
267
+ * @returns {string | null}
268
+ */
269
+ export function validateAvatarUrl(value: unknown): string | null;
270
+ /**
271
+ * Normalize a VAPID `sub` (subject) claim. Web Push (RFC 8292) accepts a
272
+ * `mailto:` address or an `http(s):` URL; a bare contact like
273
+ * `you@example.com` is prefixed with `mailto:`. An already-prefixed
274
+ * `mailto:` / `http(s):` value is returned untouched. Empty / blank → `''`.
275
+ *
276
+ * @param {unknown} email
277
+ * @returns {string}
278
+ */
279
+ export function normalizeVapidSubject(email: unknown): string;
280
+ /**
281
+ * Read `choices[0].message.reasoning_content` as a non-empty trimmed string,
282
+ * or null when absent / empty. Falls back to the first `<think>` span inside
283
+ * `message.content` when a provider inlines reasoning there. Many providers
284
+ * return an empty string instead of omitting the field — treated the same as
285
+ * missing so callers don't emit an empty ReasoningPush.
286
+ *
287
+ * @param {unknown} llmResponse
288
+ * @returns {string | null}
289
+ */
290
+ export function readReasoningContent(llmResponse: unknown): string | null;
291
+ /**
292
+ * Drop any `<think>` / `<thinking>` / `<thought>` spans from a user-facing
293
+ * content string, so private chain-of-thought leaking through `message.content`
294
+ * does not also ship inside the ContentPush burst.
295
+ *
296
+ * @param {string} content
297
+ * @returns {string}
298
+ */
299
+ export function stripReasoningTags(content: string): string;
250
300
  /**
251
301
  * @rei-standard/amsg-shared
252
302
  *
@@ -316,6 +366,8 @@ export const PUSH_SOURCE: Readonly<{
316
366
  INSTANT: "instant";
317
367
  SCHEDULED: "scheduled";
318
368
  }>;
369
+ /** Max accepted `avatarUrl` length, in characters. */
370
+ export const AVATAR_URL_MAX_LENGTH: 2048;
319
371
  /**
320
372
  * Fields present on every push, regardless of kind. Discriminator
321
373
  * fields (`messageKind`) and kind-specific fields live on the kind
@@ -361,18 +413,21 @@ export type AmsgPushCommon = {
361
413
  };
362
414
  /**
363
415
  * SW-rendering directive. Mirrors the fields that `amsg-sw`'s
364
- * `createNotificationFromPayload` consumes (`notification.{title,body,icon,badge,tag,renotify,requireInteraction,data}`)
365
- * — typing all fields so callers don't lose IDE checking and slip back into the untyped-spread footgun.
416
+ * `createNotificationFromPayload` consumes (`notification.{show,title,body,icon,badge,tag,renotify,requireInteraction,silent,data}`)
417
+ * so producers get builder validation for the fields the SW actually reads.
366
418
  *
367
- * Routing in SW (kept here so producers don't have to cross-check):
419
+ * Routing in SW:
368
420
  * - By default (`show: "auto"` or omitted), `messageKind: 'content'` (and legacy un-kinded payloads)
369
421
  * will display a system notification. `reasoning` / `tool_request` / `error` will dispatch silently.
370
422
  * - `show: "always"`, `"when-hidden"`, or `false` overrides this default.
371
- * - When rendering, `notification.*` is consulted, with per-field fallback to
372
- * the top-level `title` / `avatarUrl` / `messageId` and finally
373
- * to the SW's `defaultIcon` / `defaultBadge` options. Everything
374
- * else (`tag`, `renotify`, `requireInteraction`) has no top-level
375
- * fallback — set them under `notification` or accept the SW default.
423
+ * - When rendering, `notification.*` is consulted first, with per-field
424
+ * fallback to the matching top-level payload fields (`title`,
425
+ * `body`/`message`, `icon`/`avatarUrl`, `badge`, `tag`/`messageId`,
426
+ * `renotify`, `requireInteraction`, `silent`, `data`), and finally to
427
+ * the SW's `defaultIcon` / `defaultBadge` options (boolean knobs
428
+ * default to `false` at the SW). Prefer setting overrides under
429
+ * `notification` for explicitness; top-level fallback exists so that
430
+ * legacy un-namespaced payloads keep working byte-for-byte.
376
431
  */
377
432
  export type NotificationDirective = {
378
433
  /**
@@ -380,35 +435,39 @@ export type NotificationDirective = {
380
435
  */
381
436
  show?: "auto" | "always" | "when-hidden" | false;
382
437
  /**
383
- * - Notification title override.
438
+ * - Notification title override (falls back to top-level `title`, then `来自 {contactName}`).
384
439
  */
385
440
  title?: string;
386
441
  /**
387
- * - Notification body override.
442
+ * - Notification body override (falls back to top-level `body`, then `message`).
388
443
  */
389
444
  body?: string;
390
445
  /**
391
- * - Icon URL override (falls back to top-level `avatarUrl` then SW `defaultIcon`).
446
+ * - Icon URL override (falls back to top-level `icon`/`avatarUrl`, then SW `defaultIcon`).
392
447
  */
393
448
  icon?: string;
394
449
  /**
395
- * - Badge URL override (falls back to SW `defaultBadge`).
450
+ * - Badge URL override (falls back to top-level `badge`, then SW `defaultBadge`).
396
451
  */
397
452
  badge?: string;
398
453
  /**
399
- * - Notification grouping tag; matching tag replaces the prior notification.
454
+ * - Notification grouping tag; matching tag replaces the prior notification (falls back to top-level `tag`, then `messageId`, then a generated unique tag).
400
455
  */
401
456
  tag?: string;
402
457
  /**
403
- * - When tag matches, still vibrate/sound. Default false at SW.
458
+ * - When tag matches, still vibrate/sound (falls back to top-level `renotify`, default false at SW).
404
459
  */
405
460
  renotify?: boolean;
406
461
  /**
407
- * - Notification stays until user dismisses. Default false at SW.
462
+ * - Notification stays until user dismisses (falls back to top-level `requireInteraction`, default false at SW).
408
463
  */
409
464
  requireInteraction?: boolean;
410
465
  /**
411
- * - Custom payload data to attach to the notification.
466
+ * - Suppress sound and vibration (falls back to top-level `silent`, default false at SW).
467
+ */
468
+ silent?: boolean;
469
+ /**
470
+ * - Custom payload data to attach to the notification (falls back to top-level `data`).
412
471
  */
413
472
  data?: Record<string, unknown>;
414
473
  };
@@ -432,24 +491,24 @@ export type ContentPush = AmsgPushCommon & {
432
491
  * out of the upstream response into its own push. Emitted **before**
433
492
  * the matching {@link ContentPush} burst when present and non-empty.
434
493
  *
435
- * Reasoning carries two orthogonal "multi-part" axes, both optional —
436
- * they are *omitted* when the part count is 1 so the wire stays
437
- * byte-for-byte compatible with single-shot ReasoningPush callers:
494
+ * Reasoning carries two optional "multi-part" axes, both *omitted* when
495
+ * the part count is 1 so the wire stays byte-for-byte compatible with
496
+ * single-shot callers. The type reserves them for forward compatibility;
497
+ * current producers emit a single ReasoningPush and set neither — oversized
498
+ * reasoning rides the generic multipart transport, not a reasoning-only
499
+ * chunk format.
438
500
  *
439
- * - `messageIndex` / `totalMessages` — set when a semantic
440
- * splitter (`reasoningSplitPattern` in amsg-instant) has cut the
441
- * reasoning into multiple sentences for typing-bubble UX.
501
+ * - `messageIndex` / `totalMessages` — a 1-based part index when a producer
502
+ * splits reasoning into multiple sentences for typing-bubble UX.
442
503
  *
443
- * - `chunkIndex` / `totalChunks` — set when a single segment was
444
- * too large for the Web Push payload limit and the producer had
445
- * to slice it across multiple pushes at UTF-8 byte boundaries.
446
- * Transport-only; SW reassembles the original `reasoningContent`
447
- * by sorting on `chunkIndex` within a `(sessionId, messageIndex)`
448
- * bucket. See `chunkReasoningByUtf8Bytes` for the safe-edge
449
- * splitter helper.
504
+ * - `chunkIndex` / `totalChunks` — transport-only slicing when a single
505
+ * segment exceeds the Web Push payload limit; SW would reassemble the
506
+ * original `reasoningContent` by sorting on `chunkIndex` within a
507
+ * `(sessionId, messageIndex)` bucket. See `chunkReasoningByUtf8Bytes`
508
+ * for the safe-edge splitter helper.
450
509
  *
451
- * Both axes can coexist on the same push when a sentence-split
452
- * segment is itself oversized.
510
+ * Both axes can coexist on the same push when a sentence-split segment is
511
+ * itself oversized.
453
512
  */
454
513
  export type ReasoningPush = AmsgPushCommon & {
455
514
  messageKind: "reasoning";
package/dist/index.d.ts CHANGED
@@ -200,10 +200,10 @@ export function isErrorPush(value: unknown): value is ErrorPush;
200
200
  /**
201
201
  * Slice a string into UTF-8 byte chunks no larger than `maxBytes`,
202
202
  * always cutting at codepoint boundaries (never inside a multi-byte
203
- * char). Designed for the {@link ReasoningPush} byte-chunking path
204
- * in amsg-instant — producers facing the ~3 KB Web Push payload
205
- * limit slice oversized reasoning into N pushes with
206
- * `chunkIndex` / `totalChunks`, the SW reassembles by concat.
203
+ * char). This is a generic byte-safe string helper retained for
204
+ * callers that need deterministic UTF-8 chunking around small Web Push
205
+ * payload budgets; current amsg-instant oversized payload delivery uses
206
+ * BlobStore / generic multipart instead of reasoning-only wire fields.
207
207
  *
208
208
  * Algorithm: TextEncoder → Uint8Array → backward scan from each
209
209
  * candidate cut index until the byte is a UTF-8 lead byte (any byte
@@ -247,6 +247,56 @@ export function base64UrlToBytes(input: string): Uint8Array;
247
247
  * @returns {Uint8Array}
248
248
  */
249
249
  export function concatBytes(...chunks: (Uint8Array | ArrayBuffer | ArrayBufferView)[]): Uint8Array;
250
+ /**
251
+ * True when `value` parses as an absolute URL.
252
+ * @param {unknown} value
253
+ * @returns {boolean}
254
+ */
255
+ export function isValidUrl(value: unknown): boolean;
256
+ /**
257
+ * Validate the optional `avatarUrl` field. Rejects `data:` URIs (typically
258
+ * base64-encoded inline images) and anything longer than
259
+ * {@link AVATAR_URL_MAX_LENGTH} chars — both the dominant trigger for
260
+ * downstream 413 / Web Push 4 KB payload errors — plus anything that doesn't
261
+ * parse as a URL. Returns an error message string, or null when valid.
262
+ *
263
+ * Pure: callers decide how to act on a non-null result (amsg-server /
264
+ * amsg-instant / amsg-client soft-strip + console.warn; see standards §6.2).
265
+ *
266
+ * @param {unknown} value
267
+ * @returns {string | null}
268
+ */
269
+ export function validateAvatarUrl(value: unknown): string | null;
270
+ /**
271
+ * Normalize a VAPID `sub` (subject) claim. Web Push (RFC 8292) accepts a
272
+ * `mailto:` address or an `http(s):` URL; a bare contact like
273
+ * `you@example.com` is prefixed with `mailto:`. An already-prefixed
274
+ * `mailto:` / `http(s):` value is returned untouched. Empty / blank → `''`.
275
+ *
276
+ * @param {unknown} email
277
+ * @returns {string}
278
+ */
279
+ export function normalizeVapidSubject(email: unknown): string;
280
+ /**
281
+ * Read `choices[0].message.reasoning_content` as a non-empty trimmed string,
282
+ * or null when absent / empty. Falls back to the first `<think>` span inside
283
+ * `message.content` when a provider inlines reasoning there. Many providers
284
+ * return an empty string instead of omitting the field — treated the same as
285
+ * missing so callers don't emit an empty ReasoningPush.
286
+ *
287
+ * @param {unknown} llmResponse
288
+ * @returns {string | null}
289
+ */
290
+ export function readReasoningContent(llmResponse: unknown): string | null;
291
+ /**
292
+ * Drop any `<think>` / `<thinking>` / `<thought>` spans from a user-facing
293
+ * content string, so private chain-of-thought leaking through `message.content`
294
+ * does not also ship inside the ContentPush burst.
295
+ *
296
+ * @param {string} content
297
+ * @returns {string}
298
+ */
299
+ export function stripReasoningTags(content: string): string;
250
300
  /**
251
301
  * @rei-standard/amsg-shared
252
302
  *
@@ -316,6 +366,8 @@ export const PUSH_SOURCE: Readonly<{
316
366
  INSTANT: "instant";
317
367
  SCHEDULED: "scheduled";
318
368
  }>;
369
+ /** Max accepted `avatarUrl` length, in characters. */
370
+ export const AVATAR_URL_MAX_LENGTH: 2048;
319
371
  /**
320
372
  * Fields present on every push, regardless of kind. Discriminator
321
373
  * fields (`messageKind`) and kind-specific fields live on the kind
@@ -361,18 +413,21 @@ export type AmsgPushCommon = {
361
413
  };
362
414
  /**
363
415
  * SW-rendering directive. Mirrors the fields that `amsg-sw`'s
364
- * `createNotificationFromPayload` consumes (`notification.{title,body,icon,badge,tag,renotify,requireInteraction,data}`)
365
- * — typing all fields so callers don't lose IDE checking and slip back into the untyped-spread footgun.
416
+ * `createNotificationFromPayload` consumes (`notification.{show,title,body,icon,badge,tag,renotify,requireInteraction,silent,data}`)
417
+ * so producers get builder validation for the fields the SW actually reads.
366
418
  *
367
- * Routing in SW (kept here so producers don't have to cross-check):
419
+ * Routing in SW:
368
420
  * - By default (`show: "auto"` or omitted), `messageKind: 'content'` (and legacy un-kinded payloads)
369
421
  * will display a system notification. `reasoning` / `tool_request` / `error` will dispatch silently.
370
422
  * - `show: "always"`, `"when-hidden"`, or `false` overrides this default.
371
- * - When rendering, `notification.*` is consulted, with per-field fallback to
372
- * the top-level `title` / `avatarUrl` / `messageId` and finally
373
- * to the SW's `defaultIcon` / `defaultBadge` options. Everything
374
- * else (`tag`, `renotify`, `requireInteraction`) has no top-level
375
- * fallback — set them under `notification` or accept the SW default.
423
+ * - When rendering, `notification.*` is consulted first, with per-field
424
+ * fallback to the matching top-level payload fields (`title`,
425
+ * `body`/`message`, `icon`/`avatarUrl`, `badge`, `tag`/`messageId`,
426
+ * `renotify`, `requireInteraction`, `silent`, `data`), and finally to
427
+ * the SW's `defaultIcon` / `defaultBadge` options (boolean knobs
428
+ * default to `false` at the SW). Prefer setting overrides under
429
+ * `notification` for explicitness; top-level fallback exists so that
430
+ * legacy un-namespaced payloads keep working byte-for-byte.
376
431
  */
377
432
  export type NotificationDirective = {
378
433
  /**
@@ -380,35 +435,39 @@ export type NotificationDirective = {
380
435
  */
381
436
  show?: "auto" | "always" | "when-hidden" | false;
382
437
  /**
383
- * - Notification title override.
438
+ * - Notification title override (falls back to top-level `title`, then `来自 {contactName}`).
384
439
  */
385
440
  title?: string;
386
441
  /**
387
- * - Notification body override.
442
+ * - Notification body override (falls back to top-level `body`, then `message`).
388
443
  */
389
444
  body?: string;
390
445
  /**
391
- * - Icon URL override (falls back to top-level `avatarUrl` then SW `defaultIcon`).
446
+ * - Icon URL override (falls back to top-level `icon`/`avatarUrl`, then SW `defaultIcon`).
392
447
  */
393
448
  icon?: string;
394
449
  /**
395
- * - Badge URL override (falls back to SW `defaultBadge`).
450
+ * - Badge URL override (falls back to top-level `badge`, then SW `defaultBadge`).
396
451
  */
397
452
  badge?: string;
398
453
  /**
399
- * - Notification grouping tag; matching tag replaces the prior notification.
454
+ * - Notification grouping tag; matching tag replaces the prior notification (falls back to top-level `tag`, then `messageId`, then a generated unique tag).
400
455
  */
401
456
  tag?: string;
402
457
  /**
403
- * - When tag matches, still vibrate/sound. Default false at SW.
458
+ * - When tag matches, still vibrate/sound (falls back to top-level `renotify`, default false at SW).
404
459
  */
405
460
  renotify?: boolean;
406
461
  /**
407
- * - Notification stays until user dismisses. Default false at SW.
462
+ * - Notification stays until user dismisses (falls back to top-level `requireInteraction`, default false at SW).
408
463
  */
409
464
  requireInteraction?: boolean;
410
465
  /**
411
- * - Custom payload data to attach to the notification.
466
+ * - Suppress sound and vibration (falls back to top-level `silent`, default false at SW).
467
+ */
468
+ silent?: boolean;
469
+ /**
470
+ * - Custom payload data to attach to the notification (falls back to top-level `data`).
412
471
  */
413
472
  data?: Record<string, unknown>;
414
473
  };
@@ -432,24 +491,24 @@ export type ContentPush = AmsgPushCommon & {
432
491
  * out of the upstream response into its own push. Emitted **before**
433
492
  * the matching {@link ContentPush} burst when present and non-empty.
434
493
  *
435
- * Reasoning carries two orthogonal "multi-part" axes, both optional —
436
- * they are *omitted* when the part count is 1 so the wire stays
437
- * byte-for-byte compatible with single-shot ReasoningPush callers:
494
+ * Reasoning carries two optional "multi-part" axes, both *omitted* when
495
+ * the part count is 1 so the wire stays byte-for-byte compatible with
496
+ * single-shot callers. The type reserves them for forward compatibility;
497
+ * current producers emit a single ReasoningPush and set neither — oversized
498
+ * reasoning rides the generic multipart transport, not a reasoning-only
499
+ * chunk format.
438
500
  *
439
- * - `messageIndex` / `totalMessages` — set when a semantic
440
- * splitter (`reasoningSplitPattern` in amsg-instant) has cut the
441
- * reasoning into multiple sentences for typing-bubble UX.
501
+ * - `messageIndex` / `totalMessages` — a 1-based part index when a producer
502
+ * splits reasoning into multiple sentences for typing-bubble UX.
442
503
  *
443
- * - `chunkIndex` / `totalChunks` — set when a single segment was
444
- * too large for the Web Push payload limit and the producer had
445
- * to slice it across multiple pushes at UTF-8 byte boundaries.
446
- * Transport-only; SW reassembles the original `reasoningContent`
447
- * by sorting on `chunkIndex` within a `(sessionId, messageIndex)`
448
- * bucket. See `chunkReasoningByUtf8Bytes` for the safe-edge
449
- * splitter helper.
504
+ * - `chunkIndex` / `totalChunks` — transport-only slicing when a single
505
+ * segment exceeds the Web Push payload limit; SW would reassemble the
506
+ * original `reasoningContent` by sorting on `chunkIndex` within a
507
+ * `(sessionId, messageIndex)` bucket. See `chunkReasoningByUtf8Bytes`
508
+ * for the safe-edge splitter helper.
450
509
  *
451
- * Both axes can coexist on the same push when a sentence-split
452
- * segment is itself oversized.
510
+ * Both axes can coexist on the same push when a sentence-split segment is
511
+ * itself oversized.
453
512
  */
454
513
  export type ReasoningPush = AmsgPushCommon & {
455
514
  messageKind: "reasoning";
package/dist/index.mjs CHANGED
@@ -122,7 +122,7 @@ function validateNotificationArg(kind, value) {
122
122
  throw new Error(`[amsg-shared] ${kind}: 'notification.${f}' must be a string when present`);
123
123
  }
124
124
  }
125
- for (const f of ["renotify", "requireInteraction"]) {
125
+ for (const f of ["renotify", "requireInteraction", "silent"]) {
126
126
  if (n[f] !== void 0 && typeof n[f] !== "boolean") {
127
127
  throw new Error(`[amsg-shared] ${kind}: 'notification.${f}' must be a boolean when present`);
128
128
  }
@@ -227,7 +227,71 @@ function concatBytes(...chunks) {
227
227
  }
228
228
  return out;
229
229
  }
230
+ function isValidUrl(value) {
231
+ if (typeof value !== "string") return false;
232
+ try {
233
+ new URL(value);
234
+ return true;
235
+ } catch {
236
+ return false;
237
+ }
238
+ }
239
+ var AVATAR_URL_MAX_LENGTH = 2048;
240
+ function validateAvatarUrl(value) {
241
+ if (value === void 0 || value === null) return null;
242
+ if (typeof value !== "string") {
243
+ return "avatarUrl \u5FC5\u987B\u662F\u5B57\u7B26\u4E32";
244
+ }
245
+ if (/^data:/i.test(value)) {
246
+ return "\u5934\u50CF\u4E0D\u652F\u6301\u4F20\u5165 data: URI\uFF0C\u8BF7\u6539\u4E3A\u516C\u7F51\u53EF\u8BBF\u95EE\u7684 https:// \u56FE\u7247 URL";
247
+ }
248
+ if (value.length > AVATAR_URL_MAX_LENGTH) {
249
+ return `\u5934\u50CF URL \u957F\u5EA6 ${value.length} \u5B57\u7B26\u8D85\u8FC7 ${AVATAR_URL_MAX_LENGTH} \u4E0A\u9650\uFF0C\u8BF7\u6539\u4E3A\u66F4\u77ED\u7684\u56FE\u7247 URL`;
250
+ }
251
+ if (!isValidUrl(value)) {
252
+ return "avatarUrl \u4E0D\u662F\u5408\u6CD5 URL";
253
+ }
254
+ return null;
255
+ }
256
+ function normalizeVapidSubject(email) {
257
+ const trimmed = String(email || "").trim();
258
+ if (!trimmed) return "";
259
+ return /^mailto:/i.test(trimmed) || /^https?:/i.test(trimmed) ? trimmed : `mailto:${trimmed}`;
260
+ }
261
+ var REASONING_TAG_RE = /<(think|thinking|thought)>([\s\S]*?)<\/\1>/i;
262
+ var REASONING_TAG_RE_G = /<(think|thinking|thought)>[\s\S]*?<\/\1>/gi;
263
+ function readReasoningContent(llmResponse) {
264
+ if (!llmResponse || typeof llmResponse !== "object") return null;
265
+ const choices = (
266
+ /** @type {{ choices?: unknown }} */
267
+ llmResponse.choices
268
+ );
269
+ if (!Array.isArray(choices) || choices.length === 0) return null;
270
+ const message = (
271
+ /** @type {{ message?: { reasoning_content?: unknown, content?: unknown } }} */
272
+ choices[0]?.message
273
+ );
274
+ const raw = message?.reasoning_content;
275
+ if (typeof raw === "string") {
276
+ const trimmed = raw.trim();
277
+ if (trimmed.length > 0) return trimmed;
278
+ }
279
+ const content = message?.content;
280
+ if (typeof content === "string") {
281
+ const match = content.match(REASONING_TAG_RE);
282
+ if (match) {
283
+ const trimmed = match[2].trim();
284
+ if (trimmed.length > 0) return trimmed;
285
+ }
286
+ }
287
+ return null;
288
+ }
289
+ function stripReasoningTags(content) {
290
+ if (typeof content !== "string" || !content.includes("<")) return content;
291
+ return content.replace(REASONING_TAG_RE_G, "").trim();
292
+ }
230
293
  export {
294
+ AVATAR_URL_MAX_LENGTH,
231
295
  MESSAGE_KIND,
232
296
  MESSAGE_TYPE,
233
297
  PUSH_SOURCE,
@@ -242,5 +306,10 @@ export {
242
306
  isErrorPush,
243
307
  isReasoningPush,
244
308
  isToolRequestPush,
245
- toUint8
309
+ isValidUrl,
310
+ normalizeVapidSubject,
311
+ readReasoningContent,
312
+ stripReasoningTags,
313
+ toUint8,
314
+ validateAvatarUrl
246
315
  };
package/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "@rei-standard/amsg-shared",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
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",
7
- "url": "https://github.com/Tosd0/ReiStandard",
7
+ "url": "git+https://github.com/Tosd0/ReiStandard.git",
8
8
  "directory": "packages/rei-standard-amsg/shared"
9
9
  },
10
10
  "license": "MIT",