@rei-standard/amsg-shared 0.3.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/dist/index.cjs CHANGED
@@ -23,13 +23,17 @@ __export(src_exports, {
23
23
  MESSAGE_KIND: () => MESSAGE_KIND,
24
24
  MESSAGE_TYPE: () => MESSAGE_TYPE,
25
25
  PUSH_SOURCE: () => PUSH_SOURCE,
26
+ assertValidDecision: () => assertValidDecision,
26
27
  base64UrlToBytes: () => base64UrlToBytes,
27
28
  buildContentPush: () => buildContentPush,
28
29
  buildErrorPush: () => buildErrorPush,
29
30
  buildReasoningPush: () => buildReasoningPush,
31
+ buildSessionContext: () => buildSessionContext,
30
32
  buildToolRequestPush: () => buildToolRequestPush,
31
33
  chunkReasoningByUtf8Bytes: () => chunkReasoningByUtf8Bytes,
32
34
  concatBytes: () => concatBytes,
35
+ extractAssistantMessage: () => extractAssistantMessage,
36
+ extractToolCallsFromDecision: () => extractToolCallsFromDecision,
33
37
  isContentPush: () => isContentPush,
34
38
  isErrorPush: () => isErrorPush,
35
39
  isReasoningPush: () => isReasoningPush,
@@ -333,3 +337,168 @@ function stripReasoningTags(content) {
333
337
  if (typeof content !== "string" || !content.includes("<")) return content;
334
338
  return content.replace(REASONING_TAG_RE_G, "").trim();
335
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
@@ -297,6 +297,103 @@ export function readReasoningContent(llmResponse: unknown): string | null;
297
297
  * @returns {string}
298
298
  */
299
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>>;
300
397
  /**
301
398
  * @rei-standard/amsg-shared
302
399
  *
@@ -554,6 +651,43 @@ export type ErrorPush = AmsgPushCommon & {
554
651
  * `switch` on `messageKind` and the compiler narrows automatically.
555
652
  */
556
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
+ };
557
691
  /**
558
692
  * What the push carries. Fixed enum — packages must not add values.
559
693
  */
package/dist/index.d.ts CHANGED
@@ -297,6 +297,103 @@ export function readReasoningContent(llmResponse: unknown): string | null;
297
297
  * @returns {string}
298
298
  */
299
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>>;
300
397
  /**
301
398
  * @rei-standard/amsg-shared
302
399
  *
@@ -554,6 +651,43 @@ export type ErrorPush = AmsgPushCommon & {
554
651
  * `switch` on `messageKind` and the compiler narrows automatically.
555
652
  */
556
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
+ };
557
691
  /**
558
692
  * What the push carries. Fixed enum — packages must not add values.
559
693
  */
package/dist/index.mjs CHANGED
@@ -290,18 +290,187 @@ function stripReasoningTags(content) {
290
290
  if (typeof content !== "string" || !content.includes("<")) return content;
291
291
  return content.replace(REASONING_TAG_RE_G, "").trim();
292
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
+ }
293
458
  export {
294
459
  AVATAR_URL_MAX_LENGTH,
295
460
  MESSAGE_KIND,
296
461
  MESSAGE_TYPE,
297
462
  PUSH_SOURCE,
463
+ assertValidDecision,
298
464
  base64UrlToBytes,
299
465
  buildContentPush,
300
466
  buildErrorPush,
301
467
  buildReasoningPush,
468
+ buildSessionContext,
302
469
  buildToolRequestPush,
303
470
  chunkReasoningByUtf8Bytes,
304
471
  concatBytes,
472
+ extractAssistantMessage,
473
+ extractToolCallsFromDecision,
305
474
  isContentPush,
306
475
  isErrorPush,
307
476
  isReasoningPush,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rei-standard/amsg-shared",
3
- "version": "0.3.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",