@rei-standard/amsg-shared 0.2.0 → 0.4.0-next.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,21 +19,31 @@ 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,
26
+ assertValidDecision: () => assertValidDecision,
25
27
  base64UrlToBytes: () => base64UrlToBytes,
26
28
  buildContentPush: () => buildContentPush,
27
29
  buildErrorPush: () => buildErrorPush,
28
30
  buildReasoningPush: () => buildReasoningPush,
31
+ buildSessionContext: () => buildSessionContext,
29
32
  buildToolRequestPush: () => buildToolRequestPush,
30
33
  chunkReasoningByUtf8Bytes: () => chunkReasoningByUtf8Bytes,
31
34
  concatBytes: () => concatBytes,
35
+ extractAssistantMessage: () => extractAssistantMessage,
36
+ extractToolCallsFromDecision: () => extractToolCallsFromDecision,
32
37
  isContentPush: () => isContentPush,
33
38
  isErrorPush: () => isErrorPush,
34
39
  isReasoningPush: () => isReasoningPush,
35
40
  isToolRequestPush: () => isToolRequestPush,
36
- toUint8: () => toUint8
41
+ isValidUrl: () => isValidUrl,
42
+ normalizeVapidSubject: () => normalizeVapidSubject,
43
+ readReasoningContent: () => readReasoningContent,
44
+ stripReasoningTags: () => stripReasoningTags,
45
+ toUint8: () => toUint8,
46
+ validateAvatarUrl: () => validateAvatarUrl
37
47
  });
38
48
  module.exports = __toCommonJS(src_exports);
39
49
  var MESSAGE_KIND = Object.freeze({
@@ -264,3 +274,231 @@ function concatBytes(...chunks) {
264
274
  }
265
275
  return out;
266
276
  }
277
+ function isValidUrl(value) {
278
+ if (typeof value !== "string") return false;
279
+ try {
280
+ new URL(value);
281
+ return true;
282
+ } catch {
283
+ return false;
284
+ }
285
+ }
286
+ var AVATAR_URL_MAX_LENGTH = 2048;
287
+ function validateAvatarUrl(value) {
288
+ if (value === void 0 || value === null) return null;
289
+ if (typeof value !== "string") {
290
+ return "avatarUrl \u5FC5\u987B\u662F\u5B57\u7B26\u4E32";
291
+ }
292
+ if (/^data:/i.test(value)) {
293
+ return "\u5934\u50CF\u4E0D\u652F\u6301\u4F20\u5165 data: URI\uFF0C\u8BF7\u6539\u4E3A\u516C\u7F51\u53EF\u8BBF\u95EE\u7684 https:// \u56FE\u7247 URL";
294
+ }
295
+ if (value.length > AVATAR_URL_MAX_LENGTH) {
296
+ 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`;
297
+ }
298
+ if (!isValidUrl(value)) {
299
+ return "avatarUrl \u4E0D\u662F\u5408\u6CD5 URL";
300
+ }
301
+ return null;
302
+ }
303
+ function normalizeVapidSubject(email) {
304
+ const trimmed = String(email || "").trim();
305
+ if (!trimmed) return "";
306
+ return /^mailto:/i.test(trimmed) || /^https?:/i.test(trimmed) ? trimmed : `mailto:${trimmed}`;
307
+ }
308
+ var REASONING_TAG_RE = /<(think|thinking|thought)>([\s\S]*?)<\/\1>/i;
309
+ var REASONING_TAG_RE_G = /<(think|thinking|thought)>[\s\S]*?<\/\1>/gi;
310
+ function readReasoningContent(llmResponse) {
311
+ if (!llmResponse || typeof llmResponse !== "object") return null;
312
+ const choices = (
313
+ /** @type {{ choices?: unknown }} */
314
+ llmResponse.choices
315
+ );
316
+ if (!Array.isArray(choices) || choices.length === 0) return null;
317
+ const message = (
318
+ /** @type {{ message?: { reasoning_content?: unknown, content?: unknown } }} */
319
+ choices[0]?.message
320
+ );
321
+ const raw = message?.reasoning_content;
322
+ if (typeof raw === "string") {
323
+ const trimmed = raw.trim();
324
+ if (trimmed.length > 0) return trimmed;
325
+ }
326
+ const content = message?.content;
327
+ if (typeof content === "string") {
328
+ const match = content.match(REASONING_TAG_RE);
329
+ if (match) {
330
+ const trimmed = match[2].trim();
331
+ if (trimmed.length > 0) return trimmed;
332
+ }
333
+ }
334
+ return null;
335
+ }
336
+ function stripReasoningTags(content) {
337
+ if (typeof content !== "string" || !content.includes("<")) return content;
338
+ return content.replace(REASONING_TAG_RE_G, "").trim();
339
+ }
340
+ function buildSessionContext({
341
+ sessionId,
342
+ messages,
343
+ llmResponse,
344
+ iteration,
345
+ contactName,
346
+ avatarUrl,
347
+ charId,
348
+ metadata
349
+ }) {
350
+ const llmOutputText = readLlmOutputText(llmResponse);
351
+ const ctx = {
352
+ sessionId,
353
+ charId,
354
+ messages,
355
+ llmResponse,
356
+ llmOutputText,
357
+ iteration,
358
+ metadata: metadata && typeof metadata === "object" ? metadata : {},
359
+ contactName,
360
+ avatarUrl: avatarUrl || void 0
361
+ };
362
+ return Object.freeze(ctx);
363
+ }
364
+ function readLlmOutputText(llmResponse) {
365
+ if (!llmResponse || typeof llmResponse !== "object") return "";
366
+ const choices = (
367
+ /** @type {{ choices?: unknown }} */
368
+ llmResponse.choices
369
+ );
370
+ if (!Array.isArray(choices) || choices.length === 0) return "";
371
+ const message = (
372
+ /** @type {{ message?: { content?: unknown } }} */
373
+ choices[0]?.message
374
+ );
375
+ const content = message?.content;
376
+ return typeof content === "string" ? content : "";
377
+ }
378
+ function extractAssistantMessage(llmResponse) {
379
+ const message = llmResponse && typeof llmResponse === "object" && Array.isArray(
380
+ /** @type {{ choices?: unknown }} */
381
+ llmResponse.choices
382
+ ) && /** @type {{ choices: Array<{ message?: unknown }> }} */
383
+ llmResponse.choices[0]?.message;
384
+ if (message && typeof message === "object") {
385
+ return (
386
+ /** @type {ChatMessage} */
387
+ message
388
+ );
389
+ }
390
+ return { role: "assistant", content: "" };
391
+ }
392
+ var VALID_DECISIONS = /* @__PURE__ */ new Set(["finish", "tool-request", "continue", "skip-push"]);
393
+ function assertValidDecision(decision, options = {}) {
394
+ const inlineToolCalls = options.inlineToolCalls === true;
395
+ if (!decision || typeof decision !== "object") {
396
+ throw new TypeError(`onLLMOutput returned invalid decision: ${stringifyDecisionForError(decision)}`);
397
+ }
398
+ const tag = (
399
+ /** @type {{ decision?: unknown }} */
400
+ decision.decision
401
+ );
402
+ if (typeof tag !== "string" || !VALID_DECISIONS.has(tag)) {
403
+ throw new TypeError(`onLLMOutput returned invalid decision tag: ${stringifyDecisionForError(tag)}`);
404
+ }
405
+ const hasSingular = Object.prototype.hasOwnProperty.call(decision, "pushPayload");
406
+ const hasPlural = Object.prototype.hasOwnProperty.call(decision, "pushPayloads");
407
+ if (hasSingular) {
408
+ throw new TypeError(
409
+ hasPlural ? "pushPayload (singular) is removed in 0.8.0, use pushPayloads" : "pushPayload (singular) is removed in 0.8.0, use pushPayloads: [yourPayload]"
410
+ );
411
+ }
412
+ if (tag === "continue") {
413
+ if (!Array.isArray(
414
+ /** @type {{ nextHistory?: unknown }} */
415
+ decision.nextHistory
416
+ )) {
417
+ throw new TypeError('decision:"continue" requires a nextHistory array');
418
+ }
419
+ return;
420
+ }
421
+ if (tag === "skip-push") {
422
+ return;
423
+ }
424
+ if (tag === "tool-request" && inlineToolCalls && Object.prototype.hasOwnProperty.call(decision, "toolCalls")) {
425
+ const toolCalls = (
426
+ /** @type {{ toolCalls?: unknown }} */
427
+ decision.toolCalls
428
+ );
429
+ if (!Array.isArray(toolCalls) || toolCalls.length === 0) {
430
+ throw new TypeError('decision:"tool-request" toolCalls must be a non-empty array when set');
431
+ }
432
+ for (let i = 0; i < toolCalls.length; i++) {
433
+ const t = toolCalls[i];
434
+ if (!t || typeof t !== "object" || Array.isArray(t)) {
435
+ throw new TypeError(`toolCalls[${i}] must be a plain object, got ${stringifyDecisionForError(t)}`);
436
+ }
437
+ }
438
+ if (!hasPlural) return;
439
+ }
440
+ if (!hasPlural || !Array.isArray(
441
+ /** @type {{ pushPayloads?: unknown }} */
442
+ decision.pushPayloads
443
+ )) {
444
+ throw new TypeError(`decision:"${tag}" requires a pushPayloads array`);
445
+ }
446
+ const pushes = (
447
+ /** @type {Array<unknown>} */
448
+ decision.pushPayloads
449
+ );
450
+ if (pushes.length === 0) {
451
+ throw new TypeError("pushPayloads: [] \u2014 use decision: skip-push to skip notification entirely");
452
+ }
453
+ for (let i = 0; i < pushes.length; i++) {
454
+ const p = pushes[i];
455
+ if (!p || typeof p !== "object" || Array.isArray(p)) {
456
+ throw new TypeError(`pushPayloads[${i}] must be a plain object, got ${stringifyDecisionForError(p)}`);
457
+ }
458
+ if (Object.prototype.hasOwnProperty.call(p, "splitPattern")) {
459
+ throw new TypeError(`pushPayloads[${i}].splitPattern is removed in 0.8.0; caller is responsible for splitting`);
460
+ }
461
+ if (Object.prototype.hasOwnProperty.call(p, "messageId")) {
462
+ const id = (
463
+ /** @type {{ messageId?: unknown }} */
464
+ p.messageId
465
+ );
466
+ if (typeof id !== "string" || id === "") {
467
+ throw new TypeError(`pushPayloads[${i}].messageId must be a non-empty string when set, got ${stringifyDecisionForError(id)}`);
468
+ }
469
+ }
470
+ }
471
+ }
472
+ function extractToolCallsFromDecision(decision) {
473
+ if (!decision || typeof decision !== "object") return [];
474
+ const direct = (
475
+ /** @type {{ toolCalls?: unknown }} */
476
+ decision.toolCalls
477
+ );
478
+ if (Array.isArray(direct) && direct.length > 0) {
479
+ return direct;
480
+ }
481
+ const pushPayloads = (
482
+ /** @type {{ pushPayloads?: unknown }} */
483
+ decision.pushPayloads
484
+ );
485
+ if (!Array.isArray(pushPayloads)) return [];
486
+ const out = [];
487
+ for (const push of pushPayloads) {
488
+ if (push && typeof push === "object" && Array.isArray(
489
+ /** @type {{ toolCalls?: unknown }} */
490
+ push.toolCalls
491
+ )) {
492
+ out.push(.../** @type {{ toolCalls: unknown[] }} */
493
+ push.toolCalls);
494
+ }
495
+ }
496
+ return out;
497
+ }
498
+ function stringifyDecisionForError(value) {
499
+ try {
500
+ return JSON.stringify(value);
501
+ } catch {
502
+ return String(value);
503
+ }
504
+ }
package/dist/index.d.cts CHANGED
@@ -247,6 +247,153 @@ 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;
300
+ /**
301
+ * @typedef {Object} ChatMessage
302
+ * @property {'system' | 'user' | 'assistant' | 'tool'} role
303
+ * @property {string | unknown[] | null} [content]
304
+ * @property {Array<{ id: string, type: 'function', function: { name: string, arguments: string } }>} [tool_calls]
305
+ * @property {string} [tool_call_id]
306
+ * @property {string} [name]
307
+ */
308
+ /**
309
+ * @typedef {Object} SessionContext
310
+ * @property {string} sessionId
311
+ * @property {string} [charId]
312
+ * @property {ChatMessage[]} messages - Including the just-appended assistant turn.
313
+ * @property {unknown} llmResponse - Full LLM response (choices, usage, …).
314
+ * @property {string} llmOutputText - May be '' for pure tool-call responses.
315
+ * @property {number} iteration - 0-indexed: the round that just finished.
316
+ * @property {Record<string, unknown>} metadata
317
+ * @property {string} contactName
318
+ * @property {string} [avatarUrl]
319
+ */
320
+ /**
321
+ * Build the frozen SessionContext handed to an onLLMOutput hook.
322
+ *
323
+ * Credentials (apiKey / apiUrl / pushSubscription / vapid / masterKey) are
324
+ * intentionally NOT part of the shape: a console.log(ctx) from a hook must
325
+ * not leak keys, and a third-party hook must not be able to exfiltrate
326
+ * them. Frozen so a hook cannot mutate the live history — if it chooses
327
+ * `decision:'continue'`, the caller still owns its copy.
328
+ *
329
+ * @param {Object} args
330
+ * @param {string} args.sessionId
331
+ * @param {ChatMessage[]} args.messages
332
+ * @param {unknown} args.llmResponse
333
+ * @param {number} args.iteration
334
+ * @param {string} args.contactName
335
+ * @param {string} [args.avatarUrl]
336
+ * @param {string} [args.charId]
337
+ * @param {Record<string, unknown>} [args.metadata]
338
+ * @returns {SessionContext}
339
+ */
340
+ export function buildSessionContext({ sessionId, messages, llmResponse, iteration, contactName, avatarUrl, charId, metadata, }: {
341
+ sessionId: string;
342
+ messages: ChatMessage[];
343
+ llmResponse: unknown;
344
+ iteration: number;
345
+ contactName: string;
346
+ avatarUrl?: string;
347
+ charId?: string;
348
+ metadata?: Record<string, unknown>;
349
+ }): SessionContext;
350
+ /**
351
+ * Extract the `choices[0].message` whole object — preserving `tool_calls`
352
+ * / `reasoning_content` / `refusal` etc. — for appending to the running
353
+ * history. Falls back to a minimal placeholder when the response is
354
+ * malformed so the hook still gets a chance to react via
355
+ * `llmOutputText === ''`.
356
+ *
357
+ * Critically, we keep the entire message object (not just
358
+ * `{role, content}`): the next round may need to forward a `tool_calls`
359
+ * array to OpenAI alongside the matching tool-result messages, and
360
+ * stripping the field would make the API reject the request.
361
+ *
362
+ * @param {unknown} llmResponse
363
+ * @returns {ChatMessage}
364
+ */
365
+ export function extractAssistantMessage(llmResponse: unknown): ChatMessage;
366
+ /**
367
+ * Assert that an onLLMOutput hook returned a structurally valid decision.
368
+ * TypeScript discriminated unions don't survive into runtime, and a
369
+ * misbehaving hook can easily return `null` / `{ decision: 'idk' }` /
370
+ * `undefined` — treat any of those as a hook contract violation.
371
+ *
372
+ * Flavors:
373
+ * - default (amsg-instant): 'tool-request' must carry pushPayloads — the
374
+ * tool_request push goes to the client, which executes the tools and
375
+ * POSTs /continue.
376
+ * - `{ inlineToolCalls: true }` (amsg-server fire-time loop): the host
377
+ * executes tools in-process, so 'tool-request' may instead carry a
378
+ * non-empty `toolCalls` array directly; pushPayloads then become
379
+ * optional. pushPayloads-shaped tool-requests stay valid so a
380
+ * classifier written for instant drops in unchanged.
381
+ *
382
+ * @param {unknown} decision
383
+ * @param {{ inlineToolCalls?: boolean }} [options]
384
+ */
385
+ export function assertValidDecision(decision: unknown, options?: {
386
+ inlineToolCalls?: boolean;
387
+ }): void;
388
+ /**
389
+ * Pull the toolCalls out of a 'tool-request' decision, whichever shape it
390
+ * came in: `decision.toolCalls` directly (server flavor), or embedded in
391
+ * tool_request pushPayloads (instant classifier flavor).
392
+ *
393
+ * @param {unknown} decision
394
+ * @returns {Array<Record<string, unknown>>}
395
+ */
396
+ export function extractToolCallsFromDecision(decision: unknown): Array<Record<string, unknown>>;
250
397
  /**
251
398
  * @rei-standard/amsg-shared
252
399
  *
@@ -316,6 +463,8 @@ export const PUSH_SOURCE: Readonly<{
316
463
  INSTANT: "instant";
317
464
  SCHEDULED: "scheduled";
318
465
  }>;
466
+ /** Max accepted `avatarUrl` length, in characters. */
467
+ export const AVATAR_URL_MAX_LENGTH: 2048;
319
468
  /**
320
469
  * Fields present on every push, regardless of kind. Discriminator
321
470
  * fields (`messageKind`) and kind-specific fields live on the kind
@@ -439,24 +588,24 @@ export type ContentPush = AmsgPushCommon & {
439
588
  * out of the upstream response into its own push. Emitted **before**
440
589
  * the matching {@link ContentPush} burst when present and non-empty.
441
590
  *
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:
591
+ * Reasoning carries two optional "multi-part" axes, both *omitted* when
592
+ * the part count is 1 so the wire stays byte-for-byte compatible with
593
+ * single-shot callers. The type reserves them for forward compatibility;
594
+ * current producers emit a single ReasoningPush and set neither — oversized
595
+ * reasoning rides the generic multipart transport, not a reasoning-only
596
+ * chunk format.
445
597
  *
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.
598
+ * - `messageIndex` / `totalMessages` — a 1-based part index when a producer
599
+ * splits reasoning into multiple sentences for typing-bubble UX.
449
600
  *
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.
601
+ * - `chunkIndex` / `totalChunks` — transport-only slicing when a single
602
+ * segment exceeds the Web Push payload limit; SW would reassemble the
603
+ * original `reasoningContent` by sorting on `chunkIndex` within a
604
+ * `(sessionId, messageIndex)` bucket. See `chunkReasoningByUtf8Bytes`
605
+ * for the safe-edge splitter helper.
457
606
  *
458
- * Both axes can coexist on the same push when a sentence-split
459
- * segment is itself oversized.
607
+ * Both axes can coexist on the same push when a sentence-split segment is
608
+ * itself oversized.
460
609
  */
461
610
  export type ReasoningPush = AmsgPushCommon & {
462
611
  messageKind: "reasoning";
@@ -502,6 +651,43 @@ export type ErrorPush = AmsgPushCommon & {
502
651
  * `switch` on `messageKind` and the compiler narrows automatically.
503
652
  */
504
653
  export type AmsgPush = ContentPush | ReasoningPush | ToolRequestPush | ErrorPush;
654
+ export type ChatMessage = {
655
+ role: "system" | "user" | "assistant" | "tool";
656
+ content?: string | unknown[] | null;
657
+ tool_calls?: Array<{
658
+ id: string;
659
+ type: "function";
660
+ function: {
661
+ name: string;
662
+ arguments: string;
663
+ };
664
+ }>;
665
+ tool_call_id?: string;
666
+ name?: string;
667
+ };
668
+ export type SessionContext = {
669
+ sessionId: string;
670
+ charId?: string;
671
+ /**
672
+ * - Including the just-appended assistant turn.
673
+ */
674
+ messages: ChatMessage[];
675
+ /**
676
+ * - Full LLM response (choices, usage, …).
677
+ */
678
+ llmResponse: unknown;
679
+ /**
680
+ * - May be '' for pure tool-call responses.
681
+ */
682
+ llmOutputText: string;
683
+ /**
684
+ * - 0-indexed: the round that just finished.
685
+ */
686
+ iteration: number;
687
+ metadata: Record<string, unknown>;
688
+ contactName: string;
689
+ avatarUrl?: string;
690
+ };
505
691
  /**
506
692
  * What the push carries. Fixed enum — packages must not add values.
507
693
  */
package/dist/index.d.ts CHANGED
@@ -247,6 +247,153 @@ 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;
300
+ /**
301
+ * @typedef {Object} ChatMessage
302
+ * @property {'system' | 'user' | 'assistant' | 'tool'} role
303
+ * @property {string | unknown[] | null} [content]
304
+ * @property {Array<{ id: string, type: 'function', function: { name: string, arguments: string } }>} [tool_calls]
305
+ * @property {string} [tool_call_id]
306
+ * @property {string} [name]
307
+ */
308
+ /**
309
+ * @typedef {Object} SessionContext
310
+ * @property {string} sessionId
311
+ * @property {string} [charId]
312
+ * @property {ChatMessage[]} messages - Including the just-appended assistant turn.
313
+ * @property {unknown} llmResponse - Full LLM response (choices, usage, …).
314
+ * @property {string} llmOutputText - May be '' for pure tool-call responses.
315
+ * @property {number} iteration - 0-indexed: the round that just finished.
316
+ * @property {Record<string, unknown>} metadata
317
+ * @property {string} contactName
318
+ * @property {string} [avatarUrl]
319
+ */
320
+ /**
321
+ * Build the frozen SessionContext handed to an onLLMOutput hook.
322
+ *
323
+ * Credentials (apiKey / apiUrl / pushSubscription / vapid / masterKey) are
324
+ * intentionally NOT part of the shape: a console.log(ctx) from a hook must
325
+ * not leak keys, and a third-party hook must not be able to exfiltrate
326
+ * them. Frozen so a hook cannot mutate the live history — if it chooses
327
+ * `decision:'continue'`, the caller still owns its copy.
328
+ *
329
+ * @param {Object} args
330
+ * @param {string} args.sessionId
331
+ * @param {ChatMessage[]} args.messages
332
+ * @param {unknown} args.llmResponse
333
+ * @param {number} args.iteration
334
+ * @param {string} args.contactName
335
+ * @param {string} [args.avatarUrl]
336
+ * @param {string} [args.charId]
337
+ * @param {Record<string, unknown>} [args.metadata]
338
+ * @returns {SessionContext}
339
+ */
340
+ export function buildSessionContext({ sessionId, messages, llmResponse, iteration, contactName, avatarUrl, charId, metadata, }: {
341
+ sessionId: string;
342
+ messages: ChatMessage[];
343
+ llmResponse: unknown;
344
+ iteration: number;
345
+ contactName: string;
346
+ avatarUrl?: string;
347
+ charId?: string;
348
+ metadata?: Record<string, unknown>;
349
+ }): SessionContext;
350
+ /**
351
+ * Extract the `choices[0].message` whole object — preserving `tool_calls`
352
+ * / `reasoning_content` / `refusal` etc. — for appending to the running
353
+ * history. Falls back to a minimal placeholder when the response is
354
+ * malformed so the hook still gets a chance to react via
355
+ * `llmOutputText === ''`.
356
+ *
357
+ * Critically, we keep the entire message object (not just
358
+ * `{role, content}`): the next round may need to forward a `tool_calls`
359
+ * array to OpenAI alongside the matching tool-result messages, and
360
+ * stripping the field would make the API reject the request.
361
+ *
362
+ * @param {unknown} llmResponse
363
+ * @returns {ChatMessage}
364
+ */
365
+ export function extractAssistantMessage(llmResponse: unknown): ChatMessage;
366
+ /**
367
+ * Assert that an onLLMOutput hook returned a structurally valid decision.
368
+ * TypeScript discriminated unions don't survive into runtime, and a
369
+ * misbehaving hook can easily return `null` / `{ decision: 'idk' }` /
370
+ * `undefined` — treat any of those as a hook contract violation.
371
+ *
372
+ * Flavors:
373
+ * - default (amsg-instant): 'tool-request' must carry pushPayloads — the
374
+ * tool_request push goes to the client, which executes the tools and
375
+ * POSTs /continue.
376
+ * - `{ inlineToolCalls: true }` (amsg-server fire-time loop): the host
377
+ * executes tools in-process, so 'tool-request' may instead carry a
378
+ * non-empty `toolCalls` array directly; pushPayloads then become
379
+ * optional. pushPayloads-shaped tool-requests stay valid so a
380
+ * classifier written for instant drops in unchanged.
381
+ *
382
+ * @param {unknown} decision
383
+ * @param {{ inlineToolCalls?: boolean }} [options]
384
+ */
385
+ export function assertValidDecision(decision: unknown, options?: {
386
+ inlineToolCalls?: boolean;
387
+ }): void;
388
+ /**
389
+ * Pull the toolCalls out of a 'tool-request' decision, whichever shape it
390
+ * came in: `decision.toolCalls` directly (server flavor), or embedded in
391
+ * tool_request pushPayloads (instant classifier flavor).
392
+ *
393
+ * @param {unknown} decision
394
+ * @returns {Array<Record<string, unknown>>}
395
+ */
396
+ export function extractToolCallsFromDecision(decision: unknown): Array<Record<string, unknown>>;
250
397
  /**
251
398
  * @rei-standard/amsg-shared
252
399
  *
@@ -316,6 +463,8 @@ export const PUSH_SOURCE: Readonly<{
316
463
  INSTANT: "instant";
317
464
  SCHEDULED: "scheduled";
318
465
  }>;
466
+ /** Max accepted `avatarUrl` length, in characters. */
467
+ export const AVATAR_URL_MAX_LENGTH: 2048;
319
468
  /**
320
469
  * Fields present on every push, regardless of kind. Discriminator
321
470
  * fields (`messageKind`) and kind-specific fields live on the kind
@@ -439,24 +588,24 @@ export type ContentPush = AmsgPushCommon & {
439
588
  * out of the upstream response into its own push. Emitted **before**
440
589
  * the matching {@link ContentPush} burst when present and non-empty.
441
590
  *
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:
591
+ * Reasoning carries two optional "multi-part" axes, both *omitted* when
592
+ * the part count is 1 so the wire stays byte-for-byte compatible with
593
+ * single-shot callers. The type reserves them for forward compatibility;
594
+ * current producers emit a single ReasoningPush and set neither — oversized
595
+ * reasoning rides the generic multipart transport, not a reasoning-only
596
+ * chunk format.
445
597
  *
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.
598
+ * - `messageIndex` / `totalMessages` — a 1-based part index when a producer
599
+ * splits reasoning into multiple sentences for typing-bubble UX.
449
600
  *
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.
601
+ * - `chunkIndex` / `totalChunks` — transport-only slicing when a single
602
+ * segment exceeds the Web Push payload limit; SW would reassemble the
603
+ * original `reasoningContent` by sorting on `chunkIndex` within a
604
+ * `(sessionId, messageIndex)` bucket. See `chunkReasoningByUtf8Bytes`
605
+ * for the safe-edge splitter helper.
457
606
  *
458
- * Both axes can coexist on the same push when a sentence-split
459
- * segment is itself oversized.
607
+ * Both axes can coexist on the same push when a sentence-split segment is
608
+ * itself oversized.
460
609
  */
461
610
  export type ReasoningPush = AmsgPushCommon & {
462
611
  messageKind: "reasoning";
@@ -502,6 +651,43 @@ export type ErrorPush = AmsgPushCommon & {
502
651
  * `switch` on `messageKind` and the compiler narrows automatically.
503
652
  */
504
653
  export type AmsgPush = ContentPush | ReasoningPush | ToolRequestPush | ErrorPush;
654
+ export type ChatMessage = {
655
+ role: "system" | "user" | "assistant" | "tool";
656
+ content?: string | unknown[] | null;
657
+ tool_calls?: Array<{
658
+ id: string;
659
+ type: "function";
660
+ function: {
661
+ name: string;
662
+ arguments: string;
663
+ };
664
+ }>;
665
+ tool_call_id?: string;
666
+ name?: string;
667
+ };
668
+ export type SessionContext = {
669
+ sessionId: string;
670
+ charId?: string;
671
+ /**
672
+ * - Including the just-appended assistant turn.
673
+ */
674
+ messages: ChatMessage[];
675
+ /**
676
+ * - Full LLM response (choices, usage, …).
677
+ */
678
+ llmResponse: unknown;
679
+ /**
680
+ * - May be '' for pure tool-call responses.
681
+ */
682
+ llmOutputText: string;
683
+ /**
684
+ * - 0-indexed: the round that just finished.
685
+ */
686
+ iteration: number;
687
+ metadata: Record<string, unknown>;
688
+ contactName: string;
689
+ avatarUrl?: string;
690
+ };
505
691
  /**
506
692
  * What the push carries. Fixed enum — packages must not add values.
507
693
  */
package/dist/index.mjs CHANGED
@@ -227,20 +227,258 @@ 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
+ }
293
+ function buildSessionContext({
294
+ sessionId,
295
+ messages,
296
+ llmResponse,
297
+ iteration,
298
+ contactName,
299
+ avatarUrl,
300
+ charId,
301
+ metadata
302
+ }) {
303
+ const llmOutputText = readLlmOutputText(llmResponse);
304
+ const ctx = {
305
+ sessionId,
306
+ charId,
307
+ messages,
308
+ llmResponse,
309
+ llmOutputText,
310
+ iteration,
311
+ metadata: metadata && typeof metadata === "object" ? metadata : {},
312
+ contactName,
313
+ avatarUrl: avatarUrl || void 0
314
+ };
315
+ return Object.freeze(ctx);
316
+ }
317
+ function readLlmOutputText(llmResponse) {
318
+ if (!llmResponse || typeof llmResponse !== "object") return "";
319
+ const choices = (
320
+ /** @type {{ choices?: unknown }} */
321
+ llmResponse.choices
322
+ );
323
+ if (!Array.isArray(choices) || choices.length === 0) return "";
324
+ const message = (
325
+ /** @type {{ message?: { content?: unknown } }} */
326
+ choices[0]?.message
327
+ );
328
+ const content = message?.content;
329
+ return typeof content === "string" ? content : "";
330
+ }
331
+ function extractAssistantMessage(llmResponse) {
332
+ const message = llmResponse && typeof llmResponse === "object" && Array.isArray(
333
+ /** @type {{ choices?: unknown }} */
334
+ llmResponse.choices
335
+ ) && /** @type {{ choices: Array<{ message?: unknown }> }} */
336
+ llmResponse.choices[0]?.message;
337
+ if (message && typeof message === "object") {
338
+ return (
339
+ /** @type {ChatMessage} */
340
+ message
341
+ );
342
+ }
343
+ return { role: "assistant", content: "" };
344
+ }
345
+ var VALID_DECISIONS = /* @__PURE__ */ new Set(["finish", "tool-request", "continue", "skip-push"]);
346
+ function assertValidDecision(decision, options = {}) {
347
+ const inlineToolCalls = options.inlineToolCalls === true;
348
+ if (!decision || typeof decision !== "object") {
349
+ throw new TypeError(`onLLMOutput returned invalid decision: ${stringifyDecisionForError(decision)}`);
350
+ }
351
+ const tag = (
352
+ /** @type {{ decision?: unknown }} */
353
+ decision.decision
354
+ );
355
+ if (typeof tag !== "string" || !VALID_DECISIONS.has(tag)) {
356
+ throw new TypeError(`onLLMOutput returned invalid decision tag: ${stringifyDecisionForError(tag)}`);
357
+ }
358
+ const hasSingular = Object.prototype.hasOwnProperty.call(decision, "pushPayload");
359
+ const hasPlural = Object.prototype.hasOwnProperty.call(decision, "pushPayloads");
360
+ if (hasSingular) {
361
+ throw new TypeError(
362
+ hasPlural ? "pushPayload (singular) is removed in 0.8.0, use pushPayloads" : "pushPayload (singular) is removed in 0.8.0, use pushPayloads: [yourPayload]"
363
+ );
364
+ }
365
+ if (tag === "continue") {
366
+ if (!Array.isArray(
367
+ /** @type {{ nextHistory?: unknown }} */
368
+ decision.nextHistory
369
+ )) {
370
+ throw new TypeError('decision:"continue" requires a nextHistory array');
371
+ }
372
+ return;
373
+ }
374
+ if (tag === "skip-push") {
375
+ return;
376
+ }
377
+ if (tag === "tool-request" && inlineToolCalls && Object.prototype.hasOwnProperty.call(decision, "toolCalls")) {
378
+ const toolCalls = (
379
+ /** @type {{ toolCalls?: unknown }} */
380
+ decision.toolCalls
381
+ );
382
+ if (!Array.isArray(toolCalls) || toolCalls.length === 0) {
383
+ throw new TypeError('decision:"tool-request" toolCalls must be a non-empty array when set');
384
+ }
385
+ for (let i = 0; i < toolCalls.length; i++) {
386
+ const t = toolCalls[i];
387
+ if (!t || typeof t !== "object" || Array.isArray(t)) {
388
+ throw new TypeError(`toolCalls[${i}] must be a plain object, got ${stringifyDecisionForError(t)}`);
389
+ }
390
+ }
391
+ if (!hasPlural) return;
392
+ }
393
+ if (!hasPlural || !Array.isArray(
394
+ /** @type {{ pushPayloads?: unknown }} */
395
+ decision.pushPayloads
396
+ )) {
397
+ throw new TypeError(`decision:"${tag}" requires a pushPayloads array`);
398
+ }
399
+ const pushes = (
400
+ /** @type {Array<unknown>} */
401
+ decision.pushPayloads
402
+ );
403
+ if (pushes.length === 0) {
404
+ throw new TypeError("pushPayloads: [] \u2014 use decision: skip-push to skip notification entirely");
405
+ }
406
+ for (let i = 0; i < pushes.length; i++) {
407
+ const p = pushes[i];
408
+ if (!p || typeof p !== "object" || Array.isArray(p)) {
409
+ throw new TypeError(`pushPayloads[${i}] must be a plain object, got ${stringifyDecisionForError(p)}`);
410
+ }
411
+ if (Object.prototype.hasOwnProperty.call(p, "splitPattern")) {
412
+ throw new TypeError(`pushPayloads[${i}].splitPattern is removed in 0.8.0; caller is responsible for splitting`);
413
+ }
414
+ if (Object.prototype.hasOwnProperty.call(p, "messageId")) {
415
+ const id = (
416
+ /** @type {{ messageId?: unknown }} */
417
+ p.messageId
418
+ );
419
+ if (typeof id !== "string" || id === "") {
420
+ throw new TypeError(`pushPayloads[${i}].messageId must be a non-empty string when set, got ${stringifyDecisionForError(id)}`);
421
+ }
422
+ }
423
+ }
424
+ }
425
+ function extractToolCallsFromDecision(decision) {
426
+ if (!decision || typeof decision !== "object") return [];
427
+ const direct = (
428
+ /** @type {{ toolCalls?: unknown }} */
429
+ decision.toolCalls
430
+ );
431
+ if (Array.isArray(direct) && direct.length > 0) {
432
+ return direct;
433
+ }
434
+ const pushPayloads = (
435
+ /** @type {{ pushPayloads?: unknown }} */
436
+ decision.pushPayloads
437
+ );
438
+ if (!Array.isArray(pushPayloads)) return [];
439
+ const out = [];
440
+ for (const push of pushPayloads) {
441
+ if (push && typeof push === "object" && Array.isArray(
442
+ /** @type {{ toolCalls?: unknown }} */
443
+ push.toolCalls
444
+ )) {
445
+ out.push(.../** @type {{ toolCalls: unknown[] }} */
446
+ push.toolCalls);
447
+ }
448
+ }
449
+ return out;
450
+ }
451
+ function stringifyDecisionForError(value) {
452
+ try {
453
+ return JSON.stringify(value);
454
+ } catch {
455
+ return String(value);
456
+ }
457
+ }
230
458
  export {
459
+ AVATAR_URL_MAX_LENGTH,
231
460
  MESSAGE_KIND,
232
461
  MESSAGE_TYPE,
233
462
  PUSH_SOURCE,
463
+ assertValidDecision,
234
464
  base64UrlToBytes,
235
465
  buildContentPush,
236
466
  buildErrorPush,
237
467
  buildReasoningPush,
468
+ buildSessionContext,
238
469
  buildToolRequestPush,
239
470
  chunkReasoningByUtf8Bytes,
240
471
  concatBytes,
472
+ extractAssistantMessage,
473
+ extractToolCallsFromDecision,
241
474
  isContentPush,
242
475
  isErrorPush,
243
476
  isReasoningPush,
244
477
  isToolRequestPush,
245
- toUint8
478
+ isValidUrl,
479
+ normalizeVapidSubject,
480
+ readReasoningContent,
481
+ stripReasoningTags,
482
+ toUint8,
483
+ validateAvatarUrl
246
484
  };
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.4.0-next.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",