@rei-standard/amsg-shared 0.2.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
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({
@@ -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
@@ -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
@@ -439,24 +491,24 @@ export type ContentPush = AmsgPushCommon & {
439
491
  * out of the upstream response into its own push. Emitted **before**
440
492
  * the matching {@link ContentPush} burst when present and non-empty.
441
493
  *
442
- * Reasoning carries two orthogonal "multi-part" axes, both optional —
443
- * they are *omitted* when the part count is 1 so the wire stays
444
- * 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.
445
500
  *
446
- * - `messageIndex` / `totalMessages` — set when a semantic
447
- * splitter (`reasoningSplitPattern` in amsg-instant) has cut the
448
- * 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.
449
503
  *
450
- * - `chunkIndex` / `totalChunks` — set when a single segment was
451
- * too large for the Web Push payload limit and the producer had
452
- * to slice it across multiple pushes at UTF-8 byte boundaries.
453
- * Transport-only; SW reassembles the original `reasoningContent`
454
- * by sorting on `chunkIndex` within a `(sessionId, messageIndex)`
455
- * bucket. See `chunkReasoningByUtf8Bytes` for the safe-edge
456
- * 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.
457
509
  *
458
- * Both axes can coexist on the same push when a sentence-split
459
- * segment is itself oversized.
510
+ * Both axes can coexist on the same push when a sentence-split segment is
511
+ * itself oversized.
460
512
  */
461
513
  export type ReasoningPush = AmsgPushCommon & {
462
514
  messageKind: "reasoning";
package/dist/index.d.ts CHANGED
@@ -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
@@ -439,24 +491,24 @@ export type ContentPush = AmsgPushCommon & {
439
491
  * out of the upstream response into its own push. Emitted **before**
440
492
  * the matching {@link ContentPush} burst when present and non-empty.
441
493
  *
442
- * Reasoning carries two orthogonal "multi-part" axes, both optional —
443
- * they are *omitted* when the part count is 1 so the wire stays
444
- * 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.
445
500
  *
446
- * - `messageIndex` / `totalMessages` — set when a semantic
447
- * splitter (`reasoningSplitPattern` in amsg-instant) has cut the
448
- * 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.
449
503
  *
450
- * - `chunkIndex` / `totalChunks` — set when a single segment was
451
- * too large for the Web Push payload limit and the producer had
452
- * to slice it across multiple pushes at UTF-8 byte boundaries.
453
- * Transport-only; SW reassembles the original `reasoningContent`
454
- * by sorting on `chunkIndex` within a `(sessionId, messageIndex)`
455
- * bucket. See `chunkReasoningByUtf8Bytes` for the safe-edge
456
- * 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.
457
509
  *
458
- * Both axes can coexist on the same push when a sentence-split
459
- * segment is itself oversized.
510
+ * Both axes can coexist on the same push when a sentence-split segment is
511
+ * itself oversized.
460
512
  */
461
513
  export type ReasoningPush = AmsgPushCommon & {
462
514
  messageKind: "reasoning";
package/dist/index.mjs CHANGED
@@ -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,6 +1,6 @@
1
1
  {
2
2
  "name": "@rei-standard/amsg-shared",
3
- "version": "0.2.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",