@rei-standard/amsg-shared 0.3.0 → 0.4.0-next.1

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,170 @@ 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
+ scratch
350
+ }) {
351
+ const llmOutputText = readLlmOutputText(llmResponse);
352
+ const ctx = {
353
+ sessionId,
354
+ charId,
355
+ messages,
356
+ llmResponse,
357
+ llmOutputText,
358
+ iteration,
359
+ metadata: metadata && typeof metadata === "object" ? metadata : {},
360
+ contactName,
361
+ avatarUrl: avatarUrl || void 0
362
+ };
363
+ if (scratch !== void 0) ctx.scratch = scratch;
364
+ return Object.freeze(ctx);
365
+ }
366
+ function readLlmOutputText(llmResponse) {
367
+ if (!llmResponse || typeof llmResponse !== "object") return "";
368
+ const choices = (
369
+ /** @type {{ choices?: unknown }} */
370
+ llmResponse.choices
371
+ );
372
+ if (!Array.isArray(choices) || choices.length === 0) return "";
373
+ const message = (
374
+ /** @type {{ message?: { content?: unknown } }} */
375
+ choices[0]?.message
376
+ );
377
+ const content = message?.content;
378
+ return typeof content === "string" ? content : "";
379
+ }
380
+ function extractAssistantMessage(llmResponse) {
381
+ const message = llmResponse && typeof llmResponse === "object" && Array.isArray(
382
+ /** @type {{ choices?: unknown }} */
383
+ llmResponse.choices
384
+ ) && /** @type {{ choices: Array<{ message?: unknown }> }} */
385
+ llmResponse.choices[0]?.message;
386
+ if (message && typeof message === "object") {
387
+ return (
388
+ /** @type {ChatMessage} */
389
+ message
390
+ );
391
+ }
392
+ return { role: "assistant", content: "" };
393
+ }
394
+ var VALID_DECISIONS = /* @__PURE__ */ new Set(["finish", "tool-request", "continue", "skip-push"]);
395
+ function assertValidDecision(decision, options = {}) {
396
+ const inlineToolCalls = options.inlineToolCalls === true;
397
+ if (!decision || typeof decision !== "object") {
398
+ throw new TypeError(`onLLMOutput returned invalid decision: ${stringifyDecisionForError(decision)}`);
399
+ }
400
+ const tag = (
401
+ /** @type {{ decision?: unknown }} */
402
+ decision.decision
403
+ );
404
+ if (typeof tag !== "string" || !VALID_DECISIONS.has(tag)) {
405
+ throw new TypeError(`onLLMOutput returned invalid decision tag: ${stringifyDecisionForError(tag)}`);
406
+ }
407
+ const hasSingular = Object.prototype.hasOwnProperty.call(decision, "pushPayload");
408
+ const hasPlural = Object.prototype.hasOwnProperty.call(decision, "pushPayloads");
409
+ if (hasSingular) {
410
+ throw new TypeError(
411
+ hasPlural ? "pushPayload (singular) is removed in 0.8.0, use pushPayloads" : "pushPayload (singular) is removed in 0.8.0, use pushPayloads: [yourPayload]"
412
+ );
413
+ }
414
+ if (tag === "continue") {
415
+ if (!Array.isArray(
416
+ /** @type {{ nextHistory?: unknown }} */
417
+ decision.nextHistory
418
+ )) {
419
+ throw new TypeError('decision:"continue" requires a nextHistory array');
420
+ }
421
+ return;
422
+ }
423
+ if (tag === "skip-push") {
424
+ return;
425
+ }
426
+ if (tag === "tool-request" && inlineToolCalls && Object.prototype.hasOwnProperty.call(decision, "toolCalls")) {
427
+ const toolCalls = (
428
+ /** @type {{ toolCalls?: unknown }} */
429
+ decision.toolCalls
430
+ );
431
+ if (!Array.isArray(toolCalls) || toolCalls.length === 0) {
432
+ throw new TypeError('decision:"tool-request" toolCalls must be a non-empty array when set');
433
+ }
434
+ for (let i = 0; i < toolCalls.length; i++) {
435
+ const t = toolCalls[i];
436
+ if (!t || typeof t !== "object" || Array.isArray(t)) {
437
+ throw new TypeError(`toolCalls[${i}] must be a plain object, got ${stringifyDecisionForError(t)}`);
438
+ }
439
+ }
440
+ if (!hasPlural) return;
441
+ }
442
+ if (!hasPlural || !Array.isArray(
443
+ /** @type {{ pushPayloads?: unknown }} */
444
+ decision.pushPayloads
445
+ )) {
446
+ throw new TypeError(`decision:"${tag}" requires a pushPayloads array`);
447
+ }
448
+ const pushes = (
449
+ /** @type {Array<unknown>} */
450
+ decision.pushPayloads
451
+ );
452
+ if (pushes.length === 0) {
453
+ throw new TypeError("pushPayloads: [] \u2014 use decision: skip-push to skip notification entirely");
454
+ }
455
+ for (let i = 0; i < pushes.length; i++) {
456
+ const p = pushes[i];
457
+ if (!p || typeof p !== "object" || Array.isArray(p)) {
458
+ throw new TypeError(`pushPayloads[${i}] must be a plain object, got ${stringifyDecisionForError(p)}`);
459
+ }
460
+ if (Object.prototype.hasOwnProperty.call(p, "splitPattern")) {
461
+ throw new TypeError(`pushPayloads[${i}].splitPattern is removed in 0.8.0; caller is responsible for splitting`);
462
+ }
463
+ if (Object.prototype.hasOwnProperty.call(p, "messageId")) {
464
+ const id = (
465
+ /** @type {{ messageId?: unknown }} */
466
+ p.messageId
467
+ );
468
+ if (typeof id !== "string" || id === "") {
469
+ throw new TypeError(`pushPayloads[${i}].messageId must be a non-empty string when set, got ${stringifyDecisionForError(id)}`);
470
+ }
471
+ }
472
+ }
473
+ }
474
+ function extractToolCallsFromDecision(decision) {
475
+ if (!decision || typeof decision !== "object") return [];
476
+ const direct = (
477
+ /** @type {{ toolCalls?: unknown }} */
478
+ decision.toolCalls
479
+ );
480
+ if (Array.isArray(direct) && direct.length > 0) {
481
+ return direct;
482
+ }
483
+ const pushPayloads = (
484
+ /** @type {{ pushPayloads?: unknown }} */
485
+ decision.pushPayloads
486
+ );
487
+ if (!Array.isArray(pushPayloads)) return [];
488
+ const out = [];
489
+ for (const push of pushPayloads) {
490
+ if (push && typeof push === "object" && Array.isArray(
491
+ /** @type {{ toolCalls?: unknown }} */
492
+ push.toolCalls
493
+ )) {
494
+ out.push(.../** @type {{ toolCalls: unknown[] }} */
495
+ push.toolCalls);
496
+ }
497
+ }
498
+ return out;
499
+ }
500
+ function stringifyDecisionForError(value) {
501
+ try {
502
+ return JSON.stringify(value);
503
+ } catch {
504
+ return String(value);
505
+ }
506
+ }
package/dist/index.d.cts CHANGED
@@ -297,6 +297,106 @@ 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
+ * @property {Record<string, unknown>} [scratch] - Per-fire host scratch object. Producers that run several hooks within one fire (amsg-server's fire-time loop) pass the same mutable object to every hook of that fire, so hooks can hand context to each other without a module-level Map. The library never reads, writes, logs, or persists it, and never shares it across fires. Absent when the producer does not supply one (amsg-instant).
320
+ */
321
+ /**
322
+ * Build the frozen SessionContext handed to an onLLMOutput hook.
323
+ *
324
+ * Credentials (apiKey / apiUrl / pushSubscription / vapid / masterKey) are
325
+ * intentionally NOT part of the shape: a console.log(ctx) from a hook must
326
+ * not leak keys, and a third-party hook must not be able to exfiltrate
327
+ * them. Frozen so a hook cannot mutate the live history — if it chooses
328
+ * `decision:'continue'`, the caller still owns its copy.
329
+ *
330
+ * @param {Object} args
331
+ * @param {string} args.sessionId
332
+ * @param {ChatMessage[]} args.messages
333
+ * @param {unknown} args.llmResponse
334
+ * @param {number} args.iteration
335
+ * @param {string} args.contactName
336
+ * @param {string} [args.avatarUrl]
337
+ * @param {string} [args.charId]
338
+ * @param {Record<string, unknown>} [args.metadata]
339
+ * @param {Record<string, unknown>} [args.scratch]
340
+ * @returns {SessionContext}
341
+ */
342
+ export function buildSessionContext({ sessionId, messages, llmResponse, iteration, contactName, avatarUrl, charId, metadata, scratch, }: {
343
+ sessionId: string;
344
+ messages: ChatMessage[];
345
+ llmResponse: unknown;
346
+ iteration: number;
347
+ contactName: string;
348
+ avatarUrl?: string;
349
+ charId?: string;
350
+ metadata?: Record<string, unknown>;
351
+ scratch?: Record<string, unknown>;
352
+ }): SessionContext;
353
+ /**
354
+ * Extract the `choices[0].message` whole object — preserving `tool_calls`
355
+ * / `reasoning_content` / `refusal` etc. — for appending to the running
356
+ * history. Falls back to a minimal placeholder when the response is
357
+ * malformed so the hook still gets a chance to react via
358
+ * `llmOutputText === ''`.
359
+ *
360
+ * Critically, we keep the entire message object (not just
361
+ * `{role, content}`): the next round may need to forward a `tool_calls`
362
+ * array to OpenAI alongside the matching tool-result messages, and
363
+ * stripping the field would make the API reject the request.
364
+ *
365
+ * @param {unknown} llmResponse
366
+ * @returns {ChatMessage}
367
+ */
368
+ export function extractAssistantMessage(llmResponse: unknown): ChatMessage;
369
+ /**
370
+ * Assert that an onLLMOutput hook returned a structurally valid decision.
371
+ * TypeScript discriminated unions don't survive into runtime, and a
372
+ * misbehaving hook can easily return `null` / `{ decision: 'idk' }` /
373
+ * `undefined` — treat any of those as a hook contract violation.
374
+ *
375
+ * Flavors:
376
+ * - default (amsg-instant): 'tool-request' must carry pushPayloads — the
377
+ * tool_request push goes to the client, which executes the tools and
378
+ * POSTs /continue.
379
+ * - `{ inlineToolCalls: true }` (amsg-server fire-time loop): the host
380
+ * executes tools in-process, so 'tool-request' may instead carry a
381
+ * non-empty `toolCalls` array directly; pushPayloads then become
382
+ * optional. pushPayloads-shaped tool-requests stay valid so a
383
+ * classifier written for instant drops in unchanged.
384
+ *
385
+ * @param {unknown} decision
386
+ * @param {{ inlineToolCalls?: boolean }} [options]
387
+ */
388
+ export function assertValidDecision(decision: unknown, options?: {
389
+ inlineToolCalls?: boolean;
390
+ }): void;
391
+ /**
392
+ * Pull the toolCalls out of a 'tool-request' decision, whichever shape it
393
+ * came in: `decision.toolCalls` directly (server flavor), or embedded in
394
+ * tool_request pushPayloads (instant classifier flavor).
395
+ *
396
+ * @param {unknown} decision
397
+ * @returns {Array<Record<string, unknown>>}
398
+ */
399
+ export function extractToolCallsFromDecision(decision: unknown): Array<Record<string, unknown>>;
300
400
  /**
301
401
  * @rei-standard/amsg-shared
302
402
  *
@@ -554,6 +654,47 @@ export type ErrorPush = AmsgPushCommon & {
554
654
  * `switch` on `messageKind` and the compiler narrows automatically.
555
655
  */
556
656
  export type AmsgPush = ContentPush | ReasoningPush | ToolRequestPush | ErrorPush;
657
+ export type ChatMessage = {
658
+ role: "system" | "user" | "assistant" | "tool";
659
+ content?: string | unknown[] | null;
660
+ tool_calls?: Array<{
661
+ id: string;
662
+ type: "function";
663
+ function: {
664
+ name: string;
665
+ arguments: string;
666
+ };
667
+ }>;
668
+ tool_call_id?: string;
669
+ name?: string;
670
+ };
671
+ export type SessionContext = {
672
+ sessionId: string;
673
+ charId?: string;
674
+ /**
675
+ * - Including the just-appended assistant turn.
676
+ */
677
+ messages: ChatMessage[];
678
+ /**
679
+ * - Full LLM response (choices, usage, …).
680
+ */
681
+ llmResponse: unknown;
682
+ /**
683
+ * - May be '' for pure tool-call responses.
684
+ */
685
+ llmOutputText: string;
686
+ /**
687
+ * - 0-indexed: the round that just finished.
688
+ */
689
+ iteration: number;
690
+ metadata: Record<string, unknown>;
691
+ contactName: string;
692
+ avatarUrl?: string;
693
+ /**
694
+ * - Per-fire host scratch object. Producers that run several hooks within one fire (amsg-server's fire-time loop) pass the same mutable object to every hook of that fire, so hooks can hand context to each other without a module-level Map. The library never reads, writes, logs, or persists it, and never shares it across fires. Absent when the producer does not supply one (amsg-instant).
695
+ */
696
+ scratch?: Record<string, unknown>;
697
+ };
557
698
  /**
558
699
  * What the push carries. Fixed enum — packages must not add values.
559
700
  */
package/dist/index.d.ts CHANGED
@@ -297,6 +297,106 @@ 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
+ * @property {Record<string, unknown>} [scratch] - Per-fire host scratch object. Producers that run several hooks within one fire (amsg-server's fire-time loop) pass the same mutable object to every hook of that fire, so hooks can hand context to each other without a module-level Map. The library never reads, writes, logs, or persists it, and never shares it across fires. Absent when the producer does not supply one (amsg-instant).
320
+ */
321
+ /**
322
+ * Build the frozen SessionContext handed to an onLLMOutput hook.
323
+ *
324
+ * Credentials (apiKey / apiUrl / pushSubscription / vapid / masterKey) are
325
+ * intentionally NOT part of the shape: a console.log(ctx) from a hook must
326
+ * not leak keys, and a third-party hook must not be able to exfiltrate
327
+ * them. Frozen so a hook cannot mutate the live history — if it chooses
328
+ * `decision:'continue'`, the caller still owns its copy.
329
+ *
330
+ * @param {Object} args
331
+ * @param {string} args.sessionId
332
+ * @param {ChatMessage[]} args.messages
333
+ * @param {unknown} args.llmResponse
334
+ * @param {number} args.iteration
335
+ * @param {string} args.contactName
336
+ * @param {string} [args.avatarUrl]
337
+ * @param {string} [args.charId]
338
+ * @param {Record<string, unknown>} [args.metadata]
339
+ * @param {Record<string, unknown>} [args.scratch]
340
+ * @returns {SessionContext}
341
+ */
342
+ export function buildSessionContext({ sessionId, messages, llmResponse, iteration, contactName, avatarUrl, charId, metadata, scratch, }: {
343
+ sessionId: string;
344
+ messages: ChatMessage[];
345
+ llmResponse: unknown;
346
+ iteration: number;
347
+ contactName: string;
348
+ avatarUrl?: string;
349
+ charId?: string;
350
+ metadata?: Record<string, unknown>;
351
+ scratch?: Record<string, unknown>;
352
+ }): SessionContext;
353
+ /**
354
+ * Extract the `choices[0].message` whole object — preserving `tool_calls`
355
+ * / `reasoning_content` / `refusal` etc. — for appending to the running
356
+ * history. Falls back to a minimal placeholder when the response is
357
+ * malformed so the hook still gets a chance to react via
358
+ * `llmOutputText === ''`.
359
+ *
360
+ * Critically, we keep the entire message object (not just
361
+ * `{role, content}`): the next round may need to forward a `tool_calls`
362
+ * array to OpenAI alongside the matching tool-result messages, and
363
+ * stripping the field would make the API reject the request.
364
+ *
365
+ * @param {unknown} llmResponse
366
+ * @returns {ChatMessage}
367
+ */
368
+ export function extractAssistantMessage(llmResponse: unknown): ChatMessage;
369
+ /**
370
+ * Assert that an onLLMOutput hook returned a structurally valid decision.
371
+ * TypeScript discriminated unions don't survive into runtime, and a
372
+ * misbehaving hook can easily return `null` / `{ decision: 'idk' }` /
373
+ * `undefined` — treat any of those as a hook contract violation.
374
+ *
375
+ * Flavors:
376
+ * - default (amsg-instant): 'tool-request' must carry pushPayloads — the
377
+ * tool_request push goes to the client, which executes the tools and
378
+ * POSTs /continue.
379
+ * - `{ inlineToolCalls: true }` (amsg-server fire-time loop): the host
380
+ * executes tools in-process, so 'tool-request' may instead carry a
381
+ * non-empty `toolCalls` array directly; pushPayloads then become
382
+ * optional. pushPayloads-shaped tool-requests stay valid so a
383
+ * classifier written for instant drops in unchanged.
384
+ *
385
+ * @param {unknown} decision
386
+ * @param {{ inlineToolCalls?: boolean }} [options]
387
+ */
388
+ export function assertValidDecision(decision: unknown, options?: {
389
+ inlineToolCalls?: boolean;
390
+ }): void;
391
+ /**
392
+ * Pull the toolCalls out of a 'tool-request' decision, whichever shape it
393
+ * came in: `decision.toolCalls` directly (server flavor), or embedded in
394
+ * tool_request pushPayloads (instant classifier flavor).
395
+ *
396
+ * @param {unknown} decision
397
+ * @returns {Array<Record<string, unknown>>}
398
+ */
399
+ export function extractToolCallsFromDecision(decision: unknown): Array<Record<string, unknown>>;
300
400
  /**
301
401
  * @rei-standard/amsg-shared
302
402
  *
@@ -554,6 +654,47 @@ export type ErrorPush = AmsgPushCommon & {
554
654
  * `switch` on `messageKind` and the compiler narrows automatically.
555
655
  */
556
656
  export type AmsgPush = ContentPush | ReasoningPush | ToolRequestPush | ErrorPush;
657
+ export type ChatMessage = {
658
+ role: "system" | "user" | "assistant" | "tool";
659
+ content?: string | unknown[] | null;
660
+ tool_calls?: Array<{
661
+ id: string;
662
+ type: "function";
663
+ function: {
664
+ name: string;
665
+ arguments: string;
666
+ };
667
+ }>;
668
+ tool_call_id?: string;
669
+ name?: string;
670
+ };
671
+ export type SessionContext = {
672
+ sessionId: string;
673
+ charId?: string;
674
+ /**
675
+ * - Including the just-appended assistant turn.
676
+ */
677
+ messages: ChatMessage[];
678
+ /**
679
+ * - Full LLM response (choices, usage, …).
680
+ */
681
+ llmResponse: unknown;
682
+ /**
683
+ * - May be '' for pure tool-call responses.
684
+ */
685
+ llmOutputText: string;
686
+ /**
687
+ * - 0-indexed: the round that just finished.
688
+ */
689
+ iteration: number;
690
+ metadata: Record<string, unknown>;
691
+ contactName: string;
692
+ avatarUrl?: string;
693
+ /**
694
+ * - Per-fire host scratch object. Producers that run several hooks within one fire (amsg-server's fire-time loop) pass the same mutable object to every hook of that fire, so hooks can hand context to each other without a module-level Map. The library never reads, writes, logs, or persists it, and never shares it across fires. Absent when the producer does not supply one (amsg-instant).
695
+ */
696
+ scratch?: Record<string, unknown>;
697
+ };
557
698
  /**
558
699
  * What the push carries. Fixed enum — packages must not add values.
559
700
  */
package/dist/index.mjs CHANGED
@@ -290,18 +290,189 @@ 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
+ scratch
303
+ }) {
304
+ const llmOutputText = readLlmOutputText(llmResponse);
305
+ const ctx = {
306
+ sessionId,
307
+ charId,
308
+ messages,
309
+ llmResponse,
310
+ llmOutputText,
311
+ iteration,
312
+ metadata: metadata && typeof metadata === "object" ? metadata : {},
313
+ contactName,
314
+ avatarUrl: avatarUrl || void 0
315
+ };
316
+ if (scratch !== void 0) ctx.scratch = scratch;
317
+ return Object.freeze(ctx);
318
+ }
319
+ function readLlmOutputText(llmResponse) {
320
+ if (!llmResponse || typeof llmResponse !== "object") return "";
321
+ const choices = (
322
+ /** @type {{ choices?: unknown }} */
323
+ llmResponse.choices
324
+ );
325
+ if (!Array.isArray(choices) || choices.length === 0) return "";
326
+ const message = (
327
+ /** @type {{ message?: { content?: unknown } }} */
328
+ choices[0]?.message
329
+ );
330
+ const content = message?.content;
331
+ return typeof content === "string" ? content : "";
332
+ }
333
+ function extractAssistantMessage(llmResponse) {
334
+ const message = llmResponse && typeof llmResponse === "object" && Array.isArray(
335
+ /** @type {{ choices?: unknown }} */
336
+ llmResponse.choices
337
+ ) && /** @type {{ choices: Array<{ message?: unknown }> }} */
338
+ llmResponse.choices[0]?.message;
339
+ if (message && typeof message === "object") {
340
+ return (
341
+ /** @type {ChatMessage} */
342
+ message
343
+ );
344
+ }
345
+ return { role: "assistant", content: "" };
346
+ }
347
+ var VALID_DECISIONS = /* @__PURE__ */ new Set(["finish", "tool-request", "continue", "skip-push"]);
348
+ function assertValidDecision(decision, options = {}) {
349
+ const inlineToolCalls = options.inlineToolCalls === true;
350
+ if (!decision || typeof decision !== "object") {
351
+ throw new TypeError(`onLLMOutput returned invalid decision: ${stringifyDecisionForError(decision)}`);
352
+ }
353
+ const tag = (
354
+ /** @type {{ decision?: unknown }} */
355
+ decision.decision
356
+ );
357
+ if (typeof tag !== "string" || !VALID_DECISIONS.has(tag)) {
358
+ throw new TypeError(`onLLMOutput returned invalid decision tag: ${stringifyDecisionForError(tag)}`);
359
+ }
360
+ const hasSingular = Object.prototype.hasOwnProperty.call(decision, "pushPayload");
361
+ const hasPlural = Object.prototype.hasOwnProperty.call(decision, "pushPayloads");
362
+ if (hasSingular) {
363
+ throw new TypeError(
364
+ hasPlural ? "pushPayload (singular) is removed in 0.8.0, use pushPayloads" : "pushPayload (singular) is removed in 0.8.0, use pushPayloads: [yourPayload]"
365
+ );
366
+ }
367
+ if (tag === "continue") {
368
+ if (!Array.isArray(
369
+ /** @type {{ nextHistory?: unknown }} */
370
+ decision.nextHistory
371
+ )) {
372
+ throw new TypeError('decision:"continue" requires a nextHistory array');
373
+ }
374
+ return;
375
+ }
376
+ if (tag === "skip-push") {
377
+ return;
378
+ }
379
+ if (tag === "tool-request" && inlineToolCalls && Object.prototype.hasOwnProperty.call(decision, "toolCalls")) {
380
+ const toolCalls = (
381
+ /** @type {{ toolCalls?: unknown }} */
382
+ decision.toolCalls
383
+ );
384
+ if (!Array.isArray(toolCalls) || toolCalls.length === 0) {
385
+ throw new TypeError('decision:"tool-request" toolCalls must be a non-empty array when set');
386
+ }
387
+ for (let i = 0; i < toolCalls.length; i++) {
388
+ const t = toolCalls[i];
389
+ if (!t || typeof t !== "object" || Array.isArray(t)) {
390
+ throw new TypeError(`toolCalls[${i}] must be a plain object, got ${stringifyDecisionForError(t)}`);
391
+ }
392
+ }
393
+ if (!hasPlural) return;
394
+ }
395
+ if (!hasPlural || !Array.isArray(
396
+ /** @type {{ pushPayloads?: unknown }} */
397
+ decision.pushPayloads
398
+ )) {
399
+ throw new TypeError(`decision:"${tag}" requires a pushPayloads array`);
400
+ }
401
+ const pushes = (
402
+ /** @type {Array<unknown>} */
403
+ decision.pushPayloads
404
+ );
405
+ if (pushes.length === 0) {
406
+ throw new TypeError("pushPayloads: [] \u2014 use decision: skip-push to skip notification entirely");
407
+ }
408
+ for (let i = 0; i < pushes.length; i++) {
409
+ const p = pushes[i];
410
+ if (!p || typeof p !== "object" || Array.isArray(p)) {
411
+ throw new TypeError(`pushPayloads[${i}] must be a plain object, got ${stringifyDecisionForError(p)}`);
412
+ }
413
+ if (Object.prototype.hasOwnProperty.call(p, "splitPattern")) {
414
+ throw new TypeError(`pushPayloads[${i}].splitPattern is removed in 0.8.0; caller is responsible for splitting`);
415
+ }
416
+ if (Object.prototype.hasOwnProperty.call(p, "messageId")) {
417
+ const id = (
418
+ /** @type {{ messageId?: unknown }} */
419
+ p.messageId
420
+ );
421
+ if (typeof id !== "string" || id === "") {
422
+ throw new TypeError(`pushPayloads[${i}].messageId must be a non-empty string when set, got ${stringifyDecisionForError(id)}`);
423
+ }
424
+ }
425
+ }
426
+ }
427
+ function extractToolCallsFromDecision(decision) {
428
+ if (!decision || typeof decision !== "object") return [];
429
+ const direct = (
430
+ /** @type {{ toolCalls?: unknown }} */
431
+ decision.toolCalls
432
+ );
433
+ if (Array.isArray(direct) && direct.length > 0) {
434
+ return direct;
435
+ }
436
+ const pushPayloads = (
437
+ /** @type {{ pushPayloads?: unknown }} */
438
+ decision.pushPayloads
439
+ );
440
+ if (!Array.isArray(pushPayloads)) return [];
441
+ const out = [];
442
+ for (const push of pushPayloads) {
443
+ if (push && typeof push === "object" && Array.isArray(
444
+ /** @type {{ toolCalls?: unknown }} */
445
+ push.toolCalls
446
+ )) {
447
+ out.push(.../** @type {{ toolCalls: unknown[] }} */
448
+ push.toolCalls);
449
+ }
450
+ }
451
+ return out;
452
+ }
453
+ function stringifyDecisionForError(value) {
454
+ try {
455
+ return JSON.stringify(value);
456
+ } catch {
457
+ return String(value);
458
+ }
459
+ }
293
460
  export {
294
461
  AVATAR_URL_MAX_LENGTH,
295
462
  MESSAGE_KIND,
296
463
  MESSAGE_TYPE,
297
464
  PUSH_SOURCE,
465
+ assertValidDecision,
298
466
  base64UrlToBytes,
299
467
  buildContentPush,
300
468
  buildErrorPush,
301
469
  buildReasoningPush,
470
+ buildSessionContext,
302
471
  buildToolRequestPush,
303
472
  chunkReasoningByUtf8Bytes,
304
473
  concatBytes,
474
+ extractAssistantMessage,
475
+ extractToolCallsFromDecision,
305
476
  isContentPush,
306
477
  isErrorPush,
307
478
  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.1",
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",