@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 +171 -0
- package/dist/index.d.cts +141 -0
- package/dist/index.d.ts +141 -0
- package/dist/index.mjs +171 -0
- package/package.json +1 -1
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