@rei-standard/amsg-server 2.6.0-next.2 → 2.6.0-next.4

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.
@@ -106,10 +106,10 @@ function validateScheduleMessagePayload(payload) {
106
106
  };
107
107
  }
108
108
  if (hasMessages) {
109
- const err = validateLlmMessagesArray(payload.messages);
110
- if (err) {
109
+ const err2 = validateLlmMessagesArray(payload.messages);
110
+ if (err2) {
111
111
  return {
112
- error: { code: "INVALID_PARAMETERS", message: err, details: { invalidFields: ["messages"] } },
112
+ error: { code: "INVALID_PARAMETERS", message: err2, details: { invalidFields: ["messages"] } },
113
113
  hasCompletePrompt: false,
114
114
  hasMessages: true
115
115
  };
@@ -399,8 +399,347 @@ function isUniqueViolation(error) {
399
399
 
400
400
 
401
401
 
402
- var DEFAULT_SPLIT_REGEX = /([。!?!?]+)/;
402
+
403
+ // src/server/lib/llm.js
404
+ async function callLlm(payload, options = {}) {
405
+ const requireContent = options.requireContent !== false;
406
+ const timeoutMs = typeof options.timeoutMs === "number" && Number.isFinite(options.timeoutMs) && options.timeoutMs > 0 ? options.timeoutMs : 3e5;
407
+ const normalizedApiUrl = normalizeAiApiUrl(payload.apiUrl);
408
+ const requestBody = buildAiRequestBody(payload);
409
+ const aiResponse = await fetch(normalizedApiUrl, {
410
+ method: "POST",
411
+ headers: {
412
+ "Content-Type": "application/json",
413
+ "Authorization": `Bearer ${payload.apiKey}`
414
+ },
415
+ body: JSON.stringify(requestBody),
416
+ signal: AbortSignal.timeout(timeoutMs)
417
+ });
418
+ if (!aiResponse.ok) {
419
+ if (aiResponse.status === 405) {
420
+ throw new Error(
421
+ `AI API error: 405 Method Not Allowed. apiUrl must point to a full chat endpoint (for example: /chat/completions). Received: ${normalizedApiUrl}`
422
+ );
423
+ }
424
+ throw new Error(
425
+ `AI API error: ${aiResponse.status} ${aiResponse.statusText || "Unknown Error"}. Request URL: ${normalizedApiUrl}`
426
+ );
427
+ }
428
+ const aiData = await aiResponse.json();
429
+ const rawContent = _optionalChain([aiData, 'optionalAccess', _2 => _2.choices, 'optionalAccess', _3 => _3[0], 'optionalAccess', _4 => _4.message, 'optionalAccess', _5 => _5.content]);
430
+ if (requireContent && (typeof rawContent !== "string" || !rawContent.trim())) {
431
+ throw new Error("AI API error: response missing choices[0].message.content");
432
+ }
433
+ return { response: aiData, content: typeof rawContent === "string" ? rawContent.trim() : "" };
434
+ }
435
+ function buildAiRequestBody(payload) {
436
+ const llmMessages = Array.isArray(payload.messages) && payload.messages.length > 0 ? payload.messages : [{ role: "user", content: payload.completePrompt }];
437
+ const requestBody = {
438
+ model: payload.primaryModel,
439
+ messages: llmMessages
440
+ };
441
+ if (payload.temperature !== void 0 && payload.temperature !== null) {
442
+ requestBody.temperature = payload.temperature;
443
+ } else if (!Array.isArray(payload.messages)) {
444
+ requestBody.temperature = 0.8;
445
+ }
446
+ if (payload.maxTokens === void 0 || payload.maxTokens === null) {
447
+ return requestBody;
448
+ }
449
+ if (!Number.isInteger(payload.maxTokens) || payload.maxTokens <= 0) {
450
+ throw new Error("Invalid maxTokens: maxTokens must be a positive integer when provided.");
451
+ }
452
+ requestBody.max_tokens = payload.maxTokens;
453
+ return requestBody;
454
+ }
455
+ function normalizeAiApiUrl(apiUrl) {
456
+ if (typeof apiUrl !== "string" || !apiUrl.trim()) {
457
+ throw new Error(
458
+ "Invalid apiUrl: apiUrl is required. Please provide a chat endpoint URL (for example: https://api.openai.com or https://api.openai.com/v1/chat/completions)."
459
+ );
460
+ }
461
+ const trimmedApiUrl = apiUrl.trim();
462
+ let parsedUrl;
463
+ try {
464
+ parsedUrl = new URL(trimmedApiUrl);
465
+ } catch (e3) {
466
+ throw new Error(
467
+ `Invalid apiUrl: "${apiUrl}". Please provide a valid absolute URL.`
468
+ );
469
+ }
470
+ let path = parsedUrl.pathname.replace(/\/+$/, "") || "/";
471
+ if (/\/chat\/completions$/.test(path)) {
472
+ } else if (path === "/") {
473
+ path = "/v1/chat/completions";
474
+ } else if (/\/v\d+$/.test(path)) {
475
+ path = `${path}/chat/completions`;
476
+ }
477
+ parsedUrl.pathname = path;
478
+ return parsedUrl.toString();
479
+ }
480
+
481
+ // src/server/lib/agentic-fire.js
482
+
483
+
484
+
485
+
486
+
487
+
488
+
489
+ // src/server/lib/state-chunks.js
490
+
491
+ var STATE_CHUNK_SLICE_BYTES = 200 * 1024;
492
+ var DEFAULT_MAX_STATE_VALUE_BYTES = 5 * 1024 * 1024;
493
+ var INTERNAL_STATE_CHAR_RE = /[\u0000-\u001f]/;
494
+ var SEP = "";
495
+ var CHUNK_NS_PREFIX = `${SEP}amsg-chunks${SEP}`;
496
+ var ROOT_MARKER_PREFIX = `${SEP}amsg-chunked${SEP}v1${SEP}`;
497
+ function chunkNamespaceFor(namespace) {
498
+ return CHUNK_NS_PREFIX + namespace;
499
+ }
500
+ function chunkKeyFor(key, index) {
501
+ return `${key}${SEP}${index}`;
502
+ }
503
+ function chunkKeyPrefixFor(key) {
504
+ return `${key}${SEP}`;
505
+ }
506
+ function buildChunkedRootValue(chunkCount) {
507
+ return `${ROOT_MARKER_PREFIX}${chunkCount}`;
508
+ }
509
+ function parseChunkedRootCount(value) {
510
+ if (typeof value !== "string" || !value.startsWith(ROOT_MARKER_PREFIX)) return null;
511
+ const raw = value.slice(ROOT_MARKER_PREFIX.length);
512
+ if (!/^[1-9][0-9]*$/.test(raw)) return null;
513
+ return Number(raw);
514
+ }
515
+ function splitStateValue(value) {
516
+ return _amsgshared.chunkReasoningByUtf8Bytes.call(void 0, value, STATE_CHUNK_SLICE_BYTES);
517
+ }
518
+ async function resolveClientStateEntries(rows, fetchChunkRows, decryptValue) {
519
+ let chunkMap = null;
520
+ const loadChunks = async () => {
521
+ if (chunkMap === null) {
522
+ const chunkRows = await fetchChunkRows();
523
+ chunkMap = new Map(chunkRows.map((row) => [row.key, row]));
524
+ }
525
+ return chunkMap;
526
+ };
527
+ const entries = [];
528
+ for (const row of rows) {
529
+ const count = parseChunkedRootCount(row.value);
530
+ if (count === null) {
531
+ entries.push({
532
+ namespace: row.namespace,
533
+ key: row.key,
534
+ value: await decryptValue(row.value),
535
+ updatedAt: row.updated_at
536
+ });
537
+ continue;
538
+ }
539
+ const map = await loadChunks();
540
+ const chunkRows = [];
541
+ let intact = true;
542
+ for (let i = 0; i < count; i++) {
543
+ const chunk = map.get(chunkKeyFor(row.key, i));
544
+ if (!chunk || chunk.updated_at !== row.updated_at) {
545
+ intact = false;
546
+ break;
547
+ }
548
+ chunkRows.push(chunk);
549
+ }
550
+ if (!intact) continue;
551
+ const parts = await Promise.all(chunkRows.map((chunk) => decryptValue(chunk.value)));
552
+ entries.push({
553
+ namespace: row.namespace,
554
+ key: row.key,
555
+ value: parts.join(""),
556
+ updatedAt: row.updated_at
557
+ });
558
+ }
559
+ return entries;
560
+ }
561
+
562
+ // src/server/lib/agentic-fire.js
563
+ var DEFAULT_MAX_TOOL_ITERATIONS = 5;
564
+ var DEFAULT_TOTAL_TIMEOUT_MS = 24e4;
403
565
  var SLEEP_BETWEEN_MESSAGES_MS = 1500;
566
+ var CREDENTIAL_PAYLOAD_KEYS = /* @__PURE__ */ new Set(["apiKey", "pushSubscription"]);
567
+ var defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
568
+ function taskNeedsLlm(decryptedPayload) {
569
+ const type = decryptedPayload.messageType;
570
+ if (type === "prompted" || type === "auto") return true;
571
+ if (type === "instant") {
572
+ return !!(decryptedPayload.apiUrl && decryptedPayload.apiKey && decryptedPayload.primaryModel);
573
+ }
574
+ return false;
575
+ }
576
+ function buildHookTask(task, decryptedPayload) {
577
+ const safe = {};
578
+ for (const [key, value] of Object.entries(decryptedPayload)) {
579
+ if (!CREDENTIAL_PAYLOAD_KEYS.has(key)) safe[key] = value;
580
+ }
581
+ return Object.freeze({
582
+ ...safe,
583
+ id: _nullishCoalesce(task.id, () => ( null)),
584
+ uuid: _nullishCoalesce(task.uuid, () => ( null)),
585
+ nextSendAt: _nullishCoalesce(task.next_send_at, () => ( null)),
586
+ retryCount: _nullishCoalesce(task.retry_count, () => ( 0))
587
+ });
588
+ }
589
+ function normalizeBeforeFireResult(result) {
590
+ if (Array.isArray(result)) {
591
+ return { messages: result };
592
+ }
593
+ if (result && typeof result === "object" && Array.isArray(result.messages)) {
594
+ return {
595
+ messages: result.messages,
596
+ maxToolIterations: result.maxToolIterations,
597
+ totalTimeoutMs: result.totalTimeoutMs
598
+ };
599
+ }
600
+ throw new TypeError(
601
+ "AGENTIC_BAD_BEFORE_FIRE: onBeforeFire must return ChatMessage[] | { messages, maxToolIterations?, totalTimeoutMs? } | null"
602
+ );
603
+ }
604
+ function firstPositiveInt(values, fallback) {
605
+ for (const v of values) {
606
+ if (Number.isInteger(v) && v > 0) return v;
607
+ }
608
+ return fallback;
609
+ }
610
+ function firstPositiveNumber(values, fallback) {
611
+ for (const v of values) {
612
+ if (typeof v === "number" && Number.isFinite(v) && v > 0) return v;
613
+ }
614
+ return fallback;
615
+ }
616
+ async function runAgenticFire({ task, decryptedPayload, userKey, ctx }) {
617
+ const hooks = ctx.hooks;
618
+ if (typeof hooks.onLLMOutput !== "function") {
619
+ throw new Error("AGENTIC_CONFIG_ERROR: hooks.onBeforeFire requires hooks.onLLMOutput to classify LLM rounds");
620
+ }
621
+ const nowFn = typeof ctx._agenticNow === "function" ? ctx._agenticNow : Date.now;
622
+ const sleep = typeof ctx._agenticSleep === "function" ? ctx._agenticSleep : defaultSleep;
623
+ const readState = async (namespace) => {
624
+ if (typeof namespace !== "string" || !namespace.trim()) {
625
+ throw new TypeError("readState(namespace) requires a non-empty string");
626
+ }
627
+ if (!ctx.db || typeof ctx.db.getClientState !== "function") return [];
628
+ const rows = await ctx.db.getClientState(task.user_id, namespace);
629
+ return resolveClientStateEntries(
630
+ rows,
631
+ () => ctx.db.getClientState(task.user_id, chunkNamespaceFor(namespace)),
632
+ (value) => decryptFromStorage(value, userKey)
633
+ );
634
+ };
635
+ const scratch = {};
636
+ const fireCtx = Object.freeze({
637
+ task: buildHookTask(task, decryptedPayload),
638
+ userId: task.user_id,
639
+ readState,
640
+ now: new Date(nowFn()),
641
+ scratch
642
+ });
643
+ const before = await hooks.onBeforeFire(fireCtx);
644
+ if (before == null) return { handled: false };
645
+ const normalized = normalizeBeforeFireResult(before);
646
+ const maxToolIterations = firstPositiveInt(
647
+ [normalized.maxToolIterations, ctx.maxToolIterations],
648
+ DEFAULT_MAX_TOOL_ITERATIONS
649
+ );
650
+ const totalTimeoutMs = firstPositiveNumber(
651
+ [normalized.totalTimeoutMs, ctx.totalTimeoutMs],
652
+ DEFAULT_TOTAL_TIMEOUT_MS
653
+ );
654
+ const deadline = nowFn() + totalTimeoutMs;
655
+ const sessionId = task.id != null ? `sess_task_${task.id}` : `sess_${randomUUID()}`;
656
+ let messages = normalized.messages.slice();
657
+ for (let iteration = 0; iteration < maxToolIterations; iteration++) {
658
+ if (nowFn() >= deadline) {
659
+ throw new Error(`AGENTIC_TOTAL_TIMEOUT: fire chain exceeded ${totalTimeoutMs}ms after ${iteration} LLM round(s)`);
660
+ }
661
+ const roundTimeoutMs = Math.max(1, Math.min(3e5, deadline - nowFn()));
662
+ const { response: llmResponse } = await callLlm(
663
+ { ...decryptedPayload, messages },
664
+ { requireContent: false, timeoutMs: roundTimeoutMs }
665
+ );
666
+ const assistantMessage = _amsgshared.extractAssistantMessage.call(void 0, llmResponse);
667
+ messages = [...messages, assistantMessage];
668
+ const sessionCtx = _amsgshared.buildSessionContext.call(void 0, {
669
+ sessionId,
670
+ messages,
671
+ llmResponse,
672
+ iteration,
673
+ contactName: decryptedPayload.contactName,
674
+ avatarUrl: decryptedPayload.avatarUrl || void 0,
675
+ charId: decryptedPayload.charId,
676
+ metadata: decryptedPayload.metadata,
677
+ scratch
678
+ });
679
+ const decision = await hooks.onLLMOutput(sessionCtx);
680
+ _amsgshared.assertValidDecision.call(void 0, decision, { inlineToolCalls: true });
681
+ if (decision.decision === "continue") {
682
+ messages = decision.nextHistory.slice();
683
+ continue;
684
+ }
685
+ if (decision.decision === "skip-push") {
686
+ return { handled: true, result: { success: true, messagesSent: 0, status: "skipped", iterations: iteration + 1 } };
687
+ }
688
+ if (decision.decision === "finish") {
689
+ const messagesSent = await sendHookPushPayloads(decision.pushPayloads, decryptedPayload, ctx, sessionId, task, sleep);
690
+ return { handled: true, result: { success: true, messagesSent, status: "finished", iterations: iteration + 1 } };
691
+ }
692
+ const toolCalls = _amsgshared.extractToolCallsFromDecision.call(void 0, decision);
693
+ if (toolCalls.length === 0) {
694
+ throw new Error("AGENTIC_EMPTY_TOOL_REQUEST: tool-request decision carried no toolCalls (neither decision.toolCalls nor pushPayloads[].toolCalls)");
695
+ }
696
+ if (typeof hooks.executeToolCalls !== "function") {
697
+ throw new Error("AGENTIC_CONFIG_ERROR: onLLMOutput returned tool-request but hooks.executeToolCalls is not configured");
698
+ }
699
+ if (iteration === maxToolIterations - 1) {
700
+ break;
701
+ }
702
+ let toolResults;
703
+ try {
704
+ toolResults = await hooks.executeToolCalls(toolCalls, sessionCtx);
705
+ if (!Array.isArray(toolResults)) {
706
+ throw new TypeError('executeToolCalls must resolve to an array of { tool_call_id, role: "tool", content }');
707
+ }
708
+ } catch (error) {
709
+ toolResults = toolCalls.map((toolCall) => ({
710
+ tool_call_id: toolCall && typeof toolCall === "object" && typeof toolCall.id === "string" ? toolCall.id : "",
711
+ role: "tool",
712
+ content: `Tool execution failed: ${_nullishCoalesce(_optionalChain([error, 'optionalAccess', _6 => _6.message]), () => ( String(error)))}`
713
+ }));
714
+ }
715
+ const assistantWithTools = Array.isArray(assistantMessage.tool_calls) && assistantMessage.tool_calls.length > 0 ? assistantMessage : { ...assistantMessage, tool_calls: toolCalls };
716
+ messages = [...messages.slice(0, -1), assistantWithTools, ...toolResults];
717
+ }
718
+ throw new Error(`AGENTIC_LOOP_EXCEEDED: no finish/skip-push decision within ${maxToolIterations} LLM round(s)`);
719
+ }
720
+ async function sendHookPushPayloads(pushPayloads, decryptedPayload, ctx, sessionId, task, sleep) {
721
+ if (!ctx.vapid || !ctx.vapid.email || !ctx.vapid.publicKey || !ctx.vapid.privateKey) {
722
+ throw new Error("VAPID configuration missing - push notifications cannot be sent");
723
+ }
724
+ const pushSubscription = decryptedPayload.pushSubscription;
725
+ const total = pushPayloads.length;
726
+ const messageIdBase = task.id != null ? `msg_task_${task.id}` : `msg_${randomUUID()}`;
727
+ for (let i = 0; i < total; i++) {
728
+ const push = { ...pushPayloads[i] };
729
+ if (typeof push.messageId !== "string" || !push.messageId) push.messageId = `${messageIdBase}_hook_${i}`;
730
+ if (typeof push.sessionId !== "string" || !push.sessionId) push.sessionId = sessionId;
731
+ if (typeof push.timestamp !== "string" || !push.timestamp) push.timestamp = (/* @__PURE__ */ new Date()).toISOString();
732
+ push.messageIndex = i + 1;
733
+ push.totalMessages = total;
734
+ await ctx.webpush.sendNotification(pushSubscription, JSON.stringify(push));
735
+ if (i < total - 1) await sleep(SLEEP_BETWEEN_MESSAGES_MS);
736
+ }
737
+ return total;
738
+ }
739
+
740
+ // src/server/lib/message-processor.js
741
+ var DEFAULT_SPLIT_REGEX = /([。!?!?]+)/;
742
+ var SLEEP_BETWEEN_MESSAGES_MS2 = 1500;
404
743
  function splitOnceByRegex(chunk, regex) {
405
744
  const out = chunk.split(regex).reduce((acc, part, i, arr) => {
406
745
  if (i % 2 === 0 && part.trim()) {
@@ -428,6 +767,10 @@ async function processSingleMessage(task, ctx, providedMasterKey) {
428
767
  }
429
768
  const userKey = await deriveUserEncryptionKey(task.user_id, masterKey);
430
769
  const decryptedPayload = JSON.parse(await decryptFromStorage(task.encrypted_payload, userKey));
770
+ if (ctx.hooks && typeof ctx.hooks.onBeforeFire === "function" && taskNeedsLlm(decryptedPayload)) {
771
+ const agentic = await runAgenticFire({ task, decryptedPayload, userKey, ctx });
772
+ if (agentic.handled) return agentic.result;
773
+ }
431
774
  let messageContent;
432
775
  let llmResponse = null;
433
776
  if (decryptedPayload.messageType === "fixed") {
@@ -435,7 +778,7 @@ async function processSingleMessage(task, ctx, providedMasterKey) {
435
778
  } else if (decryptedPayload.messageType === "instant") {
436
779
  const hasPrompt = !!decryptedPayload.completePrompt || Array.isArray(decryptedPayload.messages) && decryptedPayload.messages.length > 0;
437
780
  if (hasPrompt && decryptedPayload.apiUrl && decryptedPayload.apiKey && decryptedPayload.primaryModel) {
438
- const aiResult = await _callAI(decryptedPayload);
781
+ const aiResult = await callLlm(decryptedPayload);
439
782
  messageContent = aiResult.content;
440
783
  llmResponse = aiResult.response;
441
784
  } else if (decryptedPayload.userMessage) {
@@ -444,7 +787,7 @@ async function processSingleMessage(task, ctx, providedMasterKey) {
444
787
  throw new Error("Invalid instant message: no content source available");
445
788
  }
446
789
  } else if (decryptedPayload.messageType === "prompted" || decryptedPayload.messageType === "auto") {
447
- const aiResult = await _callAI(decryptedPayload);
790
+ const aiResult = await callLlm(decryptedPayload);
448
791
  messageContent = aiResult.content;
449
792
  llmResponse = aiResult.response;
450
793
  } else {
@@ -480,7 +823,7 @@ async function processSingleMessage(task, ctx, providedMasterKey) {
480
823
  metadata
481
824
  });
482
825
  await ctx.webpush.sendNotification(pushSubscription, JSON.stringify(reasoningPush));
483
- await new Promise((resolve) => setTimeout(resolve, SLEEP_BETWEEN_MESSAGES_MS));
826
+ await new Promise((resolve) => setTimeout(resolve, SLEEP_BETWEEN_MESSAGES_MS2));
484
827
  }
485
828
  for (let i = 0; i < messages.length; i++) {
486
829
  const contentPush = _amsgshared.buildContentPush.call(void 0, {
@@ -501,7 +844,7 @@ async function processSingleMessage(task, ctx, providedMasterKey) {
501
844
  });
502
845
  await ctx.webpush.sendNotification(pushSubscription, JSON.stringify(contentPush));
503
846
  if (i < messages.length - 1) {
504
- await new Promise((resolve) => setTimeout(resolve, SLEEP_BETWEEN_MESSAGES_MS));
847
+ await new Promise((resolve) => setTimeout(resolve, SLEEP_BETWEEN_MESSAGES_MS2));
505
848
  }
506
849
  }
507
850
  return { success: true, messagesSent: messages.length };
@@ -571,80 +914,6 @@ async function processMessagesByUuid(uuid, ctx, maxRetries = 2, userId, provided
571
914
  return { success: true, messagesSent: result.messagesSent, retriesUsed: retryCount };
572
915
  }
573
916
  }
574
- async function _callAI(payload) {
575
- const normalizedApiUrl = normalizeAiApiUrl(payload.apiUrl);
576
- const requestBody = buildAiRequestBody(payload);
577
- const aiResponse = await fetch(normalizedApiUrl, {
578
- method: "POST",
579
- headers: {
580
- "Content-Type": "application/json",
581
- "Authorization": `Bearer ${payload.apiKey}`
582
- },
583
- body: JSON.stringify(requestBody),
584
- signal: AbortSignal.timeout(3e5)
585
- });
586
- if (!aiResponse.ok) {
587
- if (aiResponse.status === 405) {
588
- throw new Error(
589
- `AI API error: 405 Method Not Allowed. apiUrl must point to a full chat endpoint (for example: /chat/completions). Received: ${normalizedApiUrl}`
590
- );
591
- }
592
- throw new Error(
593
- `AI API error: ${aiResponse.status} ${aiResponse.statusText || "Unknown Error"}. Request URL: ${normalizedApiUrl}`
594
- );
595
- }
596
- const aiData = await aiResponse.json();
597
- const content = _optionalChain([aiData, 'optionalAccess', _2 => _2.choices, 'optionalAccess', _3 => _3[0], 'optionalAccess', _4 => _4.message, 'optionalAccess', _5 => _5.content]);
598
- if (typeof content !== "string" || !content.trim()) {
599
- throw new Error("AI API error: response missing choices[0].message.content");
600
- }
601
- return { response: aiData, content: content.trim() };
602
- }
603
- function buildAiRequestBody(payload) {
604
- const llmMessages = Array.isArray(payload.messages) && payload.messages.length > 0 ? payload.messages : [{ role: "user", content: payload.completePrompt }];
605
- const requestBody = {
606
- model: payload.primaryModel,
607
- messages: llmMessages
608
- };
609
- if (payload.temperature !== void 0 && payload.temperature !== null) {
610
- requestBody.temperature = payload.temperature;
611
- } else if (!Array.isArray(payload.messages)) {
612
- requestBody.temperature = 0.8;
613
- }
614
- if (payload.maxTokens === void 0 || payload.maxTokens === null) {
615
- return requestBody;
616
- }
617
- if (!Number.isInteger(payload.maxTokens) || payload.maxTokens <= 0) {
618
- throw new Error("Invalid maxTokens: maxTokens must be a positive integer when provided.");
619
- }
620
- requestBody.max_tokens = payload.maxTokens;
621
- return requestBody;
622
- }
623
- function normalizeAiApiUrl(apiUrl) {
624
- if (typeof apiUrl !== "string" || !apiUrl.trim()) {
625
- throw new Error(
626
- "Invalid apiUrl: apiUrl is required. Please provide a chat endpoint URL (for example: https://api.openai.com or https://api.openai.com/v1/chat/completions)."
627
- );
628
- }
629
- const trimmedApiUrl = apiUrl.trim();
630
- let parsedUrl;
631
- try {
632
- parsedUrl = new URL(trimmedApiUrl);
633
- } catch (e3) {
634
- throw new Error(
635
- `Invalid apiUrl: "${apiUrl}". Please provide a valid absolute URL.`
636
- );
637
- }
638
- let path = parsedUrl.pathname.replace(/\/+$/, "") || "/";
639
- if (/\/chat\/completions$/.test(path)) {
640
- } else if (path === "/") {
641
- path = "/v1/chat/completions";
642
- } else if (/\/v\d+$/.test(path)) {
643
- path = `${path}/chat/completions`;
644
- }
645
- parsedUrl.pathname = path;
646
- return parsedUrl.toString();
647
- }
648
917
 
649
918
  // src/server/handlers/schedule-message.js
650
919
  function createScheduleMessageHandler(ctx) {
@@ -1224,6 +1493,16 @@ var SQLITE_INDEXES = [
1224
1493
  critical: true
1225
1494
  }
1226
1495
  ];
1496
+ var CLIENT_STATE_TABLE_SQL = `
1497
+ CREATE TABLE IF NOT EXISTS client_state (
1498
+ user_id TEXT NOT NULL,
1499
+ namespace TEXT NOT NULL,
1500
+ key TEXT NOT NULL,
1501
+ value TEXT NOT NULL,
1502
+ updated_at INTEGER NOT NULL,
1503
+ PRIMARY KEY (user_id, namespace, key)
1504
+ )
1505
+ `;
1227
1506
 
1228
1507
  // src/server/adapters/d1.js
1229
1508
  var UPDATABLE_COLUMNS = /* @__PURE__ */ new Set([
@@ -1237,6 +1516,9 @@ var UPDATABLE_COLUMNS = /* @__PURE__ */ new Set([
1237
1516
  "created_at",
1238
1517
  "updated_at"
1239
1518
  ]);
1519
+ function escapeLikePrefix(prefix) {
1520
+ return prefix.replace(/[\\%_]/g, (ch) => `\\${ch}`);
1521
+ }
1240
1522
  var D1Adapter = class {
1241
1523
  /** @param {{ prepare: (sql: string) => any }} db - Cloudflare D1 binding */
1242
1524
  constructor(db) {
@@ -1256,6 +1538,7 @@ var D1Adapter = class {
1256
1538
  }
1257
1539
  async initSchema() {
1258
1540
  await this._db.prepare(SQLITE_TABLE_SQL).run();
1541
+ await this._db.prepare(CLIENT_STATE_TABLE_SQL).run();
1259
1542
  const indexResults = [];
1260
1543
  for (const index of SQLITE_INDEXES) {
1261
1544
  try {
@@ -1282,6 +1565,7 @@ var D1Adapter = class {
1282
1565
  }
1283
1566
  async dropSchema() {
1284
1567
  await this._db.prepare("DROP TABLE IF EXISTS scheduled_messages").run();
1568
+ await this._db.prepare("DROP TABLE IF EXISTS client_state").run();
1285
1569
  }
1286
1570
  async createTask(params) {
1287
1571
  const now = this._now();
@@ -1410,6 +1694,89 @@ var D1Adapter = class {
1410
1694
  ).bind(uuid, userId).first();
1411
1695
  return row ? row.status : null;
1412
1696
  }
1697
+ // ── client_state (single-user cloud state mirror) ──────────────────────
1698
+ /**
1699
+ * Batch upsert. Last-write-wins per (namespace, key): an entry older
1700
+ * than the stored row (updatedAt strictly lower) is skipped; equal or
1701
+ * newer overwrites. Values arrive pre-encrypted (the handler encrypts).
1702
+ *
1703
+ * `cleanups` 是分块存储的清理项(见 lib/state-chunks.js):在同一 batch 里
1704
+ * 先于 upsert 执行,按 (namespace, key 前缀) 删掉旧写入留下的切片行;
1705
+ * `updated_at <= ?` 条件保证陈旧批次删不动更新写入的行。
1706
+ *
1707
+ * Uses D1's batch() — one network round trip for the whole set (implicit
1708
+ * transaction). The client calls this endpoint inside its few-seconds
1709
+ * background window, so N sequential round trips could eat the whole
1710
+ * window. Bindings without batch() (e.g. the sqlite test shim, custom
1711
+ * adapters) fall back to a sequential loop.
1712
+ *
1713
+ * @param {string} userId
1714
+ * @param {Array<{ namespace: string, key: string, value: string, updatedAt: number }>} entries
1715
+ * @param {Array<{ namespace: string, keyPrefix: string, updatedAt: number }>} [cleanups]
1716
+ * @returns {Promise<{ upserted: number, skipped: number, outcomes: boolean[] }>}
1717
+ * `outcomes[i]` 对应 entries[i] 是否真的写入(changes > 0)。
1718
+ */
1719
+ async upsertClientState(userId, entries, cleanups = []) {
1720
+ const UPSERT_SQL = `INSERT INTO client_state (user_id, namespace, key, value, updated_at)
1721
+ VALUES (?, ?, ?, ?, ?)
1722
+ ON CONFLICT (user_id, namespace, key) DO UPDATE SET
1723
+ value = excluded.value,
1724
+ updated_at = excluded.updated_at
1725
+ WHERE excluded.updated_at >= client_state.updated_at`;
1726
+ const CLEANUP_SQL = `DELETE FROM client_state
1727
+ WHERE user_id = ? AND namespace = ? AND key LIKE ? ESCAPE '\\' AND updated_at <= ?`;
1728
+ const buildStatements = () => [
1729
+ ...cleanups.map(
1730
+ (c) => this._db.prepare(CLEANUP_SQL).bind(userId, c.namespace, `${escapeLikePrefix(c.keyPrefix)}%`, c.updatedAt)
1731
+ ),
1732
+ ...entries.map(
1733
+ (entry) => this._db.prepare(UPSERT_SQL).bind(userId, entry.namespace, entry.key, entry.value, entry.updatedAt)
1734
+ )
1735
+ ];
1736
+ let results;
1737
+ if (typeof this._db.batch === "function") {
1738
+ results = await this._db.batch(buildStatements());
1739
+ } else {
1740
+ results = [];
1741
+ for (const stmt of buildStatements()) {
1742
+ results.push(await stmt.run());
1743
+ }
1744
+ }
1745
+ const outcomes = results.slice(cleanups.length).map((res) => res.meta.changes > 0);
1746
+ let upserted = 0;
1747
+ let skipped = 0;
1748
+ for (const wrote of outcomes) {
1749
+ if (wrote) upserted++;
1750
+ else skipped++;
1751
+ }
1752
+ return { upserted, skipped, outcomes };
1753
+ }
1754
+ /**
1755
+ * All entries of one namespace (values still encrypted).
1756
+ *
1757
+ * @param {string} userId
1758
+ * @param {string} namespace
1759
+ * @returns {Promise<Array<{ namespace: string, key: string, value: string, updated_at: number }>>}
1760
+ */
1761
+ async getClientState(userId, namespace) {
1762
+ const res = await this._db.prepare(
1763
+ `SELECT namespace, key, value, updated_at
1764
+ FROM client_state
1765
+ WHERE user_id = ? AND namespace = ?
1766
+ ORDER BY key ASC`
1767
+ ).bind(userId, namespace).all();
1768
+ return res.results || [];
1769
+ }
1770
+ /**
1771
+ * Wipe every entry of this user.
1772
+ *
1773
+ * @param {string} userId
1774
+ * @returns {Promise<number>} rows deleted
1775
+ */
1776
+ async clearClientState(userId) {
1777
+ const res = await this._db.prepare("DELETE FROM client_state WHERE user_id = ?").bind(userId).run();
1778
+ return res.meta.changes || 0;
1779
+ }
1413
1780
  };
1414
1781
  function createD1Adapter(db) {
1415
1782
  if (!db || typeof db.prepare !== "function") {
@@ -1523,6 +1890,224 @@ function createVapidPublicKeyHandler(ctx) {
1523
1890
  return { GET };
1524
1891
  }
1525
1892
 
1893
+ // src/server/handlers/client-state.js
1894
+ var MAX_STATE_ENTRIES_PER_REQUEST = 200;
1895
+ var MAX_NAMESPACE_CHARS = 128;
1896
+ var MAX_KEY_CHARS = 256;
1897
+ var utf82 = new TextEncoder();
1898
+ function err(status, code, message, details) {
1899
+ const error = details === void 0 ? { code, message } : { code, message, details };
1900
+ return { status, body: { success: false, error } };
1901
+ }
1902
+ function requireUserId(headers) {
1903
+ const userId = getHeader(headers, "x-user-id");
1904
+ if (!userId) return { error: err(400, "USER_ID_REQUIRED", "\u7F3A\u5C11\u7528\u6237\u6807\u8BC6\u7B26") };
1905
+ if (!isValidUUIDv4(userId)) return { error: err(400, "INVALID_USER_ID_FORMAT", "X-User-Id \u5FC5\u987B\u662F UUID v4 \u683C\u5F0F") };
1906
+ return { userId };
1907
+ }
1908
+ function rejectEntry(entry, index, code, message, extra) {
1909
+ const rejection = { index, code, message, ...extra || {} };
1910
+ if (entry && typeof entry === "object") {
1911
+ if (typeof entry.namespace === "string") rejection.namespace = entry.namespace;
1912
+ if (typeof entry.key === "string") rejection.key = entry.key;
1913
+ }
1914
+ return rejection;
1915
+ }
1916
+ function validateEntry(entry, index, maxValueBytes) {
1917
+ if (!isPlainObject(entry)) {
1918
+ return rejectEntry(entry, index, "INVALID_STATE_ENTRY", `entries[${index}] \u5FC5\u987B\u662F\u5BF9\u8C61`);
1919
+ }
1920
+ if (typeof entry.namespace !== "string" || !entry.namespace.trim() || entry.namespace.length > MAX_NAMESPACE_CHARS) {
1921
+ return rejectEntry(entry, index, "INVALID_STATE_NAMESPACE", `entries[${index}].namespace \u5FC5\u987B\u662F 1-${MAX_NAMESPACE_CHARS} \u5B57\u7B26\u7684\u5B57\u7B26\u4E32`);
1922
+ }
1923
+ if (INTERNAL_STATE_CHAR_RE.test(entry.namespace)) {
1924
+ return rejectEntry(entry, index, "INVALID_STATE_NAMESPACE", `entries[${index}].namespace \u4E0D\u80FD\u5305\u542B\u63A7\u5236\u5B57\u7B26\uFF08\\u0000-\\u001f \u4E3A\u5E93\u5185\u90E8\u4FDD\u7559\uFF09`);
1925
+ }
1926
+ if (typeof entry.key !== "string" || !entry.key.trim() || entry.key.length > MAX_KEY_CHARS) {
1927
+ return rejectEntry(entry, index, "INVALID_STATE_KEY", `entries[${index}].key \u5FC5\u987B\u662F 1-${MAX_KEY_CHARS} \u5B57\u7B26\u7684\u5B57\u7B26\u4E32`);
1928
+ }
1929
+ if (INTERNAL_STATE_CHAR_RE.test(entry.key)) {
1930
+ return rejectEntry(entry, index, "INVALID_STATE_KEY", `entries[${index}].key \u4E0D\u80FD\u5305\u542B\u63A7\u5236\u5B57\u7B26\uFF08\\u0000-\\u001f \u4E3A\u5E93\u5185\u90E8\u4FDD\u7559\uFF09`);
1931
+ }
1932
+ if (typeof entry.value !== "string") {
1933
+ return rejectEntry(entry, index, "INVALID_STATE_VALUE", `entries[${index}].value \u5FC5\u987B\u662F\u5B57\u7B26\u4E32\uFF08\u5BBF\u4E3B\u81EA\u884C\u5E8F\u5217\u5316\uFF09`);
1934
+ }
1935
+ const bytes = utf82.encode(entry.value).length;
1936
+ if (bytes > maxValueBytes) {
1937
+ return rejectEntry(entry, index, "STATE_VALUE_TOO_LARGE", `entries[${index}].value \u8D85\u8FC7\u5355\u6761\u603B\u4E0A\u9650`, { bytes, maxBytes: maxValueBytes });
1938
+ }
1939
+ if (!Number.isInteger(entry.updatedAt) || entry.updatedAt <= 0) {
1940
+ return rejectEntry(entry, index, "INVALID_STATE_UPDATED_AT", `entries[${index}].updatedAt \u5FC5\u987B\u662F\u6B63\u6574\u6570\uFF08epoch \u6BEB\u79D2\uFF09`);
1941
+ }
1942
+ return null;
1943
+ }
1944
+ function createClientStateHandler(ctx) {
1945
+ async function PUT(headers, body) {
1946
+ const tenantResult = await ctx.tenantManager.resolveTenant(headers);
1947
+ if (!tenantResult.ok) return tenantResult.error;
1948
+ const { db, masterKey } = tenantResult.context;
1949
+ if (getHeader(headers, "x-payload-encrypted") !== "true") {
1950
+ return err(400, "ENCRYPTION_REQUIRED", "\u8BF7\u6C42\u4F53\u5FC5\u987B\u52A0\u5BC6");
1951
+ }
1952
+ const gate = requireUserId(headers);
1953
+ if (gate.error) return gate.error;
1954
+ const { userId } = gate;
1955
+ if (getHeader(headers, "x-encryption-version") !== "1") {
1956
+ return err(400, "UNSUPPORTED_ENCRYPTION_VERSION", "\u52A0\u5BC6\u7248\u672C\u4E0D\u652F\u6301");
1957
+ }
1958
+ const parsedBody = parseEncryptedBody(body);
1959
+ if (!parsedBody.ok) return { status: 400, body: { success: false, error: parsedBody.error } };
1960
+ const userKey = await deriveUserEncryptionKey(userId, masterKey);
1961
+ let payload;
1962
+ try {
1963
+ payload = await decryptPayload(parsedBody.data, userKey);
1964
+ } catch (error) {
1965
+ if (error instanceof SyntaxError) {
1966
+ return err(400, "INVALID_PAYLOAD_FORMAT", "\u89E3\u5BC6\u540E\u7684\u6570\u636E\u4E0D\u662F\u6709\u6548 JSON");
1967
+ }
1968
+ return err(400, "DECRYPTION_FAILED", "\u8BF7\u6C42\u4F53\u89E3\u5BC6\u5931\u8D25");
1969
+ }
1970
+ if (!isPlainObject(payload)) return err(400, "INVALID_PAYLOAD_FORMAT", "\u89E3\u5BC6\u540E\u7684\u6570\u636E\u5FC5\u987B\u662F JSON \u5BF9\u8C61");
1971
+ const entries = payload.entries;
1972
+ if (!Array.isArray(entries) || entries.length === 0) {
1973
+ return err(400, "INVALID_STATE_ENTRIES", "entries \u5FC5\u987B\u662F\u975E\u7A7A\u6570\u7EC4");
1974
+ }
1975
+ if (entries.length > MAX_STATE_ENTRIES_PER_REQUEST) {
1976
+ return err(400, "TOO_MANY_STATE_ENTRIES", `\u5355\u6B21\u6700\u591A ${MAX_STATE_ENTRIES_PER_REQUEST} \u6761`, { count: entries.length });
1977
+ }
1978
+ const maxValueBytes = Number.isInteger(ctx.maxStateValueBytes) && ctx.maxStateValueBytes > 0 ? ctx.maxStateValueBytes : DEFAULT_MAX_STATE_VALUE_BYTES;
1979
+ const accepted = [];
1980
+ const rejected = [];
1981
+ for (let i = 0; i < entries.length; i++) {
1982
+ const rejection = validateEntry(entries[i], i, maxValueBytes);
1983
+ if (rejection) rejected.push(rejection);
1984
+ else accepted.push(entries[i]);
1985
+ }
1986
+ if (typeof db.upsertClientState !== "function") {
1987
+ return err(501, "CLIENT_STATE_NOT_SUPPORTED", "\u5F53\u524D\u6570\u636E\u5E93\u9002\u914D\u5668\u4E0D\u652F\u6301 client_state");
1988
+ }
1989
+ const physicalRows = [];
1990
+ const cleanups = [];
1991
+ const rootRowIndexes = [];
1992
+ for (const entry of accepted) {
1993
+ cleanups.push({
1994
+ namespace: chunkNamespaceFor(entry.namespace),
1995
+ keyPrefix: chunkKeyPrefixFor(entry.key),
1996
+ updatedAt: entry.updatedAt
1997
+ });
1998
+ rootRowIndexes.push(physicalRows.length);
1999
+ if (utf82.encode(entry.value).length <= STATE_CHUNK_SLICE_BYTES) {
2000
+ physicalRows.push({
2001
+ namespace: entry.namespace,
2002
+ key: entry.key,
2003
+ value: await encryptForStorage(entry.value, userKey),
2004
+ updatedAt: entry.updatedAt
2005
+ });
2006
+ } else {
2007
+ const slices = splitStateValue(entry.value);
2008
+ physicalRows.push({
2009
+ namespace: entry.namespace,
2010
+ key: entry.key,
2011
+ value: buildChunkedRootValue(slices.length),
2012
+ updatedAt: entry.updatedAt
2013
+ });
2014
+ const encryptedSlices = await Promise.all(slices.map((slice) => encryptForStorage(slice, userKey)));
2015
+ for (let c = 0; c < encryptedSlices.length; c++) {
2016
+ physicalRows.push({
2017
+ namespace: chunkNamespaceFor(entry.namespace),
2018
+ key: chunkKeyFor(entry.key, c),
2019
+ value: encryptedSlices[c],
2020
+ updatedAt: entry.updatedAt
2021
+ });
2022
+ }
2023
+ }
2024
+ }
2025
+ let upserted = 0;
2026
+ let skipped = 0;
2027
+ if (physicalRows.length > 0) {
2028
+ const result = await db.upsertClientState(userId, physicalRows, cleanups);
2029
+ if (Array.isArray(result.outcomes) && result.outcomes.length === physicalRows.length) {
2030
+ for (const rootIndex of rootRowIndexes) {
2031
+ if (result.outcomes[rootIndex]) upserted++;
2032
+ else skipped++;
2033
+ }
2034
+ } else {
2035
+ upserted = result.upserted;
2036
+ skipped = result.skipped;
2037
+ }
2038
+ }
2039
+ const data = { upserted, skipped };
2040
+ if (rejected.length > 0) data.rejected = rejected;
2041
+ return { status: 200, body: { success: true, data } };
2042
+ }
2043
+ async function GET(url, headers) {
2044
+ const tenantResult = await ctx.tenantManager.resolveTenant(headers);
2045
+ if (!tenantResult.ok) return tenantResult.error;
2046
+ const { db, masterKey } = tenantResult.context;
2047
+ const gate = requireUserId(headers);
2048
+ if (gate.error) return gate.error;
2049
+ const { userId } = gate;
2050
+ const namespace = new URL(url, "https://dummy").searchParams.get("namespace") || "";
2051
+ if (!namespace.trim()) return err(400, "NAMESPACE_REQUIRED", "\u5FC5\u987B\u63D0\u4F9B namespace \u67E5\u8BE2\u53C2\u6570");
2052
+ if (INTERNAL_STATE_CHAR_RE.test(namespace)) {
2053
+ return err(400, "INVALID_STATE_NAMESPACE", "namespace \u4E0D\u80FD\u5305\u542B\u63A7\u5236\u5B57\u7B26\uFF08\\u0000-\\u001f \u4E3A\u5E93\u5185\u90E8\u4FDD\u7559\uFF09");
2054
+ }
2055
+ if (typeof db.getClientState !== "function") {
2056
+ return err(501, "CLIENT_STATE_NOT_SUPPORTED", "\u5F53\u524D\u6570\u636E\u5E93\u9002\u914D\u5668\u4E0D\u652F\u6301 client_state");
2057
+ }
2058
+ const userKey = await deriveUserEncryptionKey(userId, masterKey);
2059
+ const rows = await db.getClientState(userId, namespace);
2060
+ const decrypted = await resolveClientStateEntries(
2061
+ rows,
2062
+ () => db.getClientState(userId, chunkNamespaceFor(namespace)),
2063
+ (value) => decryptFromStorage(value, userKey)
2064
+ );
2065
+ const encryptedResponse = await encryptPayload({ namespace, entries: decrypted }, userKey);
2066
+ return { status: 200, body: { success: true, encrypted: true, version: 1, data: encryptedResponse } };
2067
+ }
2068
+ async function DELETE(url, headers) {
2069
+ const tenantResult = await ctx.tenantManager.resolveTenant(headers);
2070
+ if (!tenantResult.ok) return tenantResult.error;
2071
+ const { db } = tenantResult.context;
2072
+ const gate = requireUserId(headers);
2073
+ if (gate.error) return gate.error;
2074
+ const { userId } = gate;
2075
+ if (typeof db.clearClientState !== "function") {
2076
+ return err(501, "CLIENT_STATE_NOT_SUPPORTED", "\u5F53\u524D\u6570\u636E\u5E93\u9002\u914D\u5668\u4E0D\u652F\u6301 client_state");
2077
+ }
2078
+ const deleted = await db.clearClientState(userId);
2079
+ return { status: 200, body: { success: true, data: { deleted } } };
2080
+ }
2081
+ return { PUT, GET, DELETE };
2082
+ }
2083
+
2084
+ // src/server/lib/version.js
2085
+ var SERVER_VERSION = true ? "2.6.0-next.4" : "0.0.0-dev";
2086
+
2087
+ // src/server/handlers/capabilities.js
2088
+ var SERVER_FEATURES = Object.freeze([
2089
+ "client-state",
2090
+ "client-state-chunking",
2091
+ "client-state-partial-failure",
2092
+ "agentic-hooks",
2093
+ "agentic-scratch",
2094
+ "vapid-public-key"
2095
+ ]);
2096
+ function createCapabilitiesHandler(ctx) {
2097
+ async function GET(url, headers) {
2098
+ const effectiveHeaders = headers || url || {};
2099
+ const tenantResult = await ctx.tenantManager.resolveTenant(effectiveHeaders);
2100
+ if (!tenantResult.ok) {
2101
+ return tenantResult.error;
2102
+ }
2103
+ return {
2104
+ status: 200,
2105
+ body: { success: true, serverVersion: SERVER_VERSION, features: [...SERVER_FEATURES] }
2106
+ };
2107
+ }
2108
+ return { GET };
2109
+ }
2110
+
1526
2111
  // src/server/single-user.js
1527
2112
  function createSingleUserServer(config) {
1528
2113
  if (!config || !config.db) throw new Error("[amsg-server single-user] config.db is required");
@@ -1540,7 +2125,14 @@ function createSingleUserServer(config) {
1540
2125
  privateKey: vapid.privateKey || ""
1541
2126
  },
1542
2127
  webpush: config.webpush || null,
1543
- tenantManager
2128
+ tenantManager,
2129
+ // Fire-time hooks (optional): the in-server instant path fires through
2130
+ // processMessagesByUuid with this ctx, so instant-type tasks can take
2131
+ // the agentic path too.
2132
+ hooks: config.hooks || null,
2133
+ maxToolIterations: config.maxToolIterations,
2134
+ totalTimeoutMs: config.totalTimeoutMs,
2135
+ maxStateValueBytes: config.maxStateValueBytes
1544
2136
  };
1545
2137
  return {
1546
2138
  ctx,
@@ -1551,7 +2143,9 @@ function createSingleUserServer(config) {
1551
2143
  updateMessage: createUpdateMessageHandler(ctx),
1552
2144
  cancelMessage: createCancelMessageHandler(ctx),
1553
2145
  messages: createMessagesHandler(ctx),
1554
- vapidPublicKey: createVapidPublicKeyHandler(ctx)
2146
+ vapidPublicKey: createVapidPublicKeyHandler(ctx),
2147
+ clientState: createClientStateHandler(ctx),
2148
+ capabilities: createCapabilitiesHandler(ctx)
1555
2149
  }
1556
2150
  };
1557
2151
  }
@@ -1605,12 +2199,12 @@ async function sendWebPush({ subscription, payload, vapid, ttl, fetch: fetchImpl
1605
2199
  });
1606
2200
  if (!res.ok) {
1607
2201
  const text = await safeReadText(res);
1608
- const err = new Error(
2202
+ const err2 = new Error(
1609
2203
  `Web Push delivery failed: ${res.status} ${res.statusText || ""}${text ? ` \u2014 ${text}` : ""}`
1610
2204
  );
1611
- err.code = "PUSH_SEND_FAILED";
1612
- err.statusCode = res.status;
1613
- throw err;
2205
+ err2.code = "PUSH_SEND_FAILED";
2206
+ err2.statusCode = res.status;
2207
+ throw err2;
1614
2208
  }
1615
2209
  return {
1616
2210
  statusCode: res.status,
@@ -1817,6 +2411,14 @@ function createSingleUserCloudflareWorker(buildConfig) {
1817
2411
  result = await server.handlers.cancelMessage.DELETE(url, headers);
1818
2412
  } else if (method === "GET" && pathname.endsWith("/vapid-public-key")) {
1819
2413
  result = await server.handlers.vapidPublicKey.GET(url, headers);
2414
+ } else if (method === "GET" && pathname.endsWith("/capabilities")) {
2415
+ result = await server.handlers.capabilities.GET(url, headers);
2416
+ } else if (method === "PUT" && pathname.endsWith("/client-state")) {
2417
+ result = await server.handlers.clientState.PUT(headers, await request.text());
2418
+ } else if (method === "GET" && pathname.endsWith("/client-state")) {
2419
+ result = await server.handlers.clientState.GET(url, headers);
2420
+ } else if (method === "DELETE" && pathname.endsWith("/client-state")) {
2421
+ result = await server.handlers.clientState.DELETE(url, headers);
1820
2422
  } else {
1821
2423
  result = { status: 404, body: { success: false, error: { code: "NOT_FOUND", message: "Unknown route" } } };
1822
2424
  }
@@ -1834,7 +2436,17 @@ function createSingleUserCloudflareWorker(buildConfig) {
1834
2436
  return;
1835
2437
  }
1836
2438
  try {
1837
- await runScheduledTick({ db: cfg.db, masterKey: cfg.masterKey, vapid, webpush: cfg.webpush });
2439
+ await runScheduledTick({
2440
+ db: cfg.db,
2441
+ masterKey: cfg.masterKey,
2442
+ vapid,
2443
+ webpush: cfg.webpush,
2444
+ // Fire-time hooks (optional; see lib/agentic-fire.js). runScheduledTick
2445
+ // spreads its ctx into processSingleMessage, so these ride along.
2446
+ hooks: cfg.hooks || null,
2447
+ maxToolIterations: cfg.maxToolIterations,
2448
+ totalTimeoutMs: cfg.totalTimeoutMs
2449
+ });
1838
2450
  } catch (error) {
1839
2451
  console.error("[amsg single-user] scheduled(): tick failed:", error && error.message);
1840
2452
  }