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

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,270 @@ 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
+ var DEFAULT_MAX_TOOL_ITERATIONS = 5;
489
+ var DEFAULT_TOTAL_TIMEOUT_MS = 24e4;
403
490
  var SLEEP_BETWEEN_MESSAGES_MS = 1500;
491
+ var CREDENTIAL_PAYLOAD_KEYS = /* @__PURE__ */ new Set(["apiKey", "pushSubscription"]);
492
+ var defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
493
+ function taskNeedsLlm(decryptedPayload) {
494
+ const type = decryptedPayload.messageType;
495
+ if (type === "prompted" || type === "auto") return true;
496
+ if (type === "instant") {
497
+ return !!(decryptedPayload.apiUrl && decryptedPayload.apiKey && decryptedPayload.primaryModel);
498
+ }
499
+ return false;
500
+ }
501
+ function buildHookTask(task, decryptedPayload) {
502
+ const safe = {};
503
+ for (const [key, value] of Object.entries(decryptedPayload)) {
504
+ if (!CREDENTIAL_PAYLOAD_KEYS.has(key)) safe[key] = value;
505
+ }
506
+ return Object.freeze({
507
+ ...safe,
508
+ id: _nullishCoalesce(task.id, () => ( null)),
509
+ uuid: _nullishCoalesce(task.uuid, () => ( null)),
510
+ nextSendAt: _nullishCoalesce(task.next_send_at, () => ( null)),
511
+ retryCount: _nullishCoalesce(task.retry_count, () => ( 0))
512
+ });
513
+ }
514
+ function normalizeBeforeFireResult(result) {
515
+ if (Array.isArray(result)) {
516
+ return { messages: result };
517
+ }
518
+ if (result && typeof result === "object" && Array.isArray(result.messages)) {
519
+ return {
520
+ messages: result.messages,
521
+ maxToolIterations: result.maxToolIterations,
522
+ totalTimeoutMs: result.totalTimeoutMs
523
+ };
524
+ }
525
+ throw new TypeError(
526
+ "AGENTIC_BAD_BEFORE_FIRE: onBeforeFire must return ChatMessage[] | { messages, maxToolIterations?, totalTimeoutMs? } | null"
527
+ );
528
+ }
529
+ function firstPositiveInt(values, fallback) {
530
+ for (const v of values) {
531
+ if (Number.isInteger(v) && v > 0) return v;
532
+ }
533
+ return fallback;
534
+ }
535
+ function firstPositiveNumber(values, fallback) {
536
+ for (const v of values) {
537
+ if (typeof v === "number" && Number.isFinite(v) && v > 0) return v;
538
+ }
539
+ return fallback;
540
+ }
541
+ async function runAgenticFire({ task, decryptedPayload, userKey, ctx }) {
542
+ const hooks = ctx.hooks;
543
+ if (typeof hooks.onLLMOutput !== "function") {
544
+ throw new Error("AGENTIC_CONFIG_ERROR: hooks.onBeforeFire requires hooks.onLLMOutput to classify LLM rounds");
545
+ }
546
+ const nowFn = typeof ctx._agenticNow === "function" ? ctx._agenticNow : Date.now;
547
+ const sleep = typeof ctx._agenticSleep === "function" ? ctx._agenticSleep : defaultSleep;
548
+ const readState = async (namespace) => {
549
+ if (typeof namespace !== "string" || !namespace.trim()) {
550
+ throw new TypeError("readState(namespace) requires a non-empty string");
551
+ }
552
+ if (!ctx.db || typeof ctx.db.getClientState !== "function") return [];
553
+ const rows = await ctx.db.getClientState(task.user_id, namespace);
554
+ return Promise.all(rows.map(async (row) => ({
555
+ namespace: row.namespace,
556
+ key: row.key,
557
+ value: await decryptFromStorage(row.value, userKey),
558
+ updatedAt: row.updated_at
559
+ })));
560
+ };
561
+ const fireCtx = Object.freeze({
562
+ task: buildHookTask(task, decryptedPayload),
563
+ userId: task.user_id,
564
+ readState,
565
+ now: new Date(nowFn())
566
+ });
567
+ const before = await hooks.onBeforeFire(fireCtx);
568
+ if (before == null) return { handled: false };
569
+ const normalized = normalizeBeforeFireResult(before);
570
+ const maxToolIterations = firstPositiveInt(
571
+ [normalized.maxToolIterations, ctx.maxToolIterations],
572
+ DEFAULT_MAX_TOOL_ITERATIONS
573
+ );
574
+ const totalTimeoutMs = firstPositiveNumber(
575
+ [normalized.totalTimeoutMs, ctx.totalTimeoutMs],
576
+ DEFAULT_TOTAL_TIMEOUT_MS
577
+ );
578
+ const deadline = nowFn() + totalTimeoutMs;
579
+ const sessionId = task.id != null ? `sess_task_${task.id}` : `sess_${randomUUID()}`;
580
+ let messages = normalized.messages.slice();
581
+ for (let iteration = 0; iteration < maxToolIterations; iteration++) {
582
+ if (nowFn() >= deadline) {
583
+ throw new Error(`AGENTIC_TOTAL_TIMEOUT: fire chain exceeded ${totalTimeoutMs}ms after ${iteration} LLM round(s)`);
584
+ }
585
+ const roundTimeoutMs = Math.max(1, Math.min(3e5, deadline - nowFn()));
586
+ const { response: llmResponse } = await callLlm(
587
+ { ...decryptedPayload, messages },
588
+ { requireContent: false, timeoutMs: roundTimeoutMs }
589
+ );
590
+ const assistantMessage = _amsgshared.extractAssistantMessage.call(void 0, llmResponse);
591
+ messages = [...messages, assistantMessage];
592
+ const sessionCtx = _amsgshared.buildSessionContext.call(void 0, {
593
+ sessionId,
594
+ messages,
595
+ llmResponse,
596
+ iteration,
597
+ contactName: decryptedPayload.contactName,
598
+ avatarUrl: decryptedPayload.avatarUrl || void 0,
599
+ charId: decryptedPayload.charId,
600
+ metadata: decryptedPayload.metadata
601
+ });
602
+ const decision = await hooks.onLLMOutput(sessionCtx);
603
+ _amsgshared.assertValidDecision.call(void 0, decision, { inlineToolCalls: true });
604
+ if (decision.decision === "continue") {
605
+ messages = decision.nextHistory.slice();
606
+ continue;
607
+ }
608
+ if (decision.decision === "skip-push") {
609
+ return { handled: true, result: { success: true, messagesSent: 0, status: "skipped", iterations: iteration + 1 } };
610
+ }
611
+ if (decision.decision === "finish") {
612
+ const messagesSent = await sendHookPushPayloads(decision.pushPayloads, decryptedPayload, ctx, sessionId, task, sleep);
613
+ return { handled: true, result: { success: true, messagesSent, status: "finished", iterations: iteration + 1 } };
614
+ }
615
+ const toolCalls = _amsgshared.extractToolCallsFromDecision.call(void 0, decision);
616
+ if (toolCalls.length === 0) {
617
+ throw new Error("AGENTIC_EMPTY_TOOL_REQUEST: tool-request decision carried no toolCalls (neither decision.toolCalls nor pushPayloads[].toolCalls)");
618
+ }
619
+ if (typeof hooks.executeToolCalls !== "function") {
620
+ throw new Error("AGENTIC_CONFIG_ERROR: onLLMOutput returned tool-request but hooks.executeToolCalls is not configured");
621
+ }
622
+ if (iteration === maxToolIterations - 1) {
623
+ break;
624
+ }
625
+ let toolResults;
626
+ try {
627
+ toolResults = await hooks.executeToolCalls(toolCalls, sessionCtx);
628
+ if (!Array.isArray(toolResults)) {
629
+ throw new TypeError('executeToolCalls must resolve to an array of { tool_call_id, role: "tool", content }');
630
+ }
631
+ } catch (error) {
632
+ toolResults = toolCalls.map((toolCall) => ({
633
+ tool_call_id: toolCall && typeof toolCall === "object" && typeof toolCall.id === "string" ? toolCall.id : "",
634
+ role: "tool",
635
+ content: `Tool execution failed: ${_nullishCoalesce(_optionalChain([error, 'optionalAccess', _6 => _6.message]), () => ( String(error)))}`
636
+ }));
637
+ }
638
+ const assistantWithTools = Array.isArray(assistantMessage.tool_calls) && assistantMessage.tool_calls.length > 0 ? assistantMessage : { ...assistantMessage, tool_calls: toolCalls };
639
+ messages = [...messages.slice(0, -1), assistantWithTools, ...toolResults];
640
+ }
641
+ throw new Error(`AGENTIC_LOOP_EXCEEDED: no finish/skip-push decision within ${maxToolIterations} LLM round(s)`);
642
+ }
643
+ async function sendHookPushPayloads(pushPayloads, decryptedPayload, ctx, sessionId, task, sleep) {
644
+ if (!ctx.vapid || !ctx.vapid.email || !ctx.vapid.publicKey || !ctx.vapid.privateKey) {
645
+ throw new Error("VAPID configuration missing - push notifications cannot be sent");
646
+ }
647
+ const pushSubscription = decryptedPayload.pushSubscription;
648
+ const total = pushPayloads.length;
649
+ const messageIdBase = task.id != null ? `msg_task_${task.id}` : `msg_${randomUUID()}`;
650
+ for (let i = 0; i < total; i++) {
651
+ const push = { ...pushPayloads[i] };
652
+ if (typeof push.messageId !== "string" || !push.messageId) push.messageId = `${messageIdBase}_hook_${i}`;
653
+ if (typeof push.sessionId !== "string" || !push.sessionId) push.sessionId = sessionId;
654
+ if (typeof push.timestamp !== "string" || !push.timestamp) push.timestamp = (/* @__PURE__ */ new Date()).toISOString();
655
+ push.messageIndex = i + 1;
656
+ push.totalMessages = total;
657
+ await ctx.webpush.sendNotification(pushSubscription, JSON.stringify(push));
658
+ if (i < total - 1) await sleep(SLEEP_BETWEEN_MESSAGES_MS);
659
+ }
660
+ return total;
661
+ }
662
+
663
+ // src/server/lib/message-processor.js
664
+ var DEFAULT_SPLIT_REGEX = /([。!?!?]+)/;
665
+ var SLEEP_BETWEEN_MESSAGES_MS2 = 1500;
404
666
  function splitOnceByRegex(chunk, regex) {
405
667
  const out = chunk.split(regex).reduce((acc, part, i, arr) => {
406
668
  if (i % 2 === 0 && part.trim()) {
@@ -428,6 +690,10 @@ async function processSingleMessage(task, ctx, providedMasterKey) {
428
690
  }
429
691
  const userKey = await deriveUserEncryptionKey(task.user_id, masterKey);
430
692
  const decryptedPayload = JSON.parse(await decryptFromStorage(task.encrypted_payload, userKey));
693
+ if (ctx.hooks && typeof ctx.hooks.onBeforeFire === "function" && taskNeedsLlm(decryptedPayload)) {
694
+ const agentic = await runAgenticFire({ task, decryptedPayload, userKey, ctx });
695
+ if (agentic.handled) return agentic.result;
696
+ }
431
697
  let messageContent;
432
698
  let llmResponse = null;
433
699
  if (decryptedPayload.messageType === "fixed") {
@@ -435,7 +701,7 @@ async function processSingleMessage(task, ctx, providedMasterKey) {
435
701
  } else if (decryptedPayload.messageType === "instant") {
436
702
  const hasPrompt = !!decryptedPayload.completePrompt || Array.isArray(decryptedPayload.messages) && decryptedPayload.messages.length > 0;
437
703
  if (hasPrompt && decryptedPayload.apiUrl && decryptedPayload.apiKey && decryptedPayload.primaryModel) {
438
- const aiResult = await _callAI(decryptedPayload);
704
+ const aiResult = await callLlm(decryptedPayload);
439
705
  messageContent = aiResult.content;
440
706
  llmResponse = aiResult.response;
441
707
  } else if (decryptedPayload.userMessage) {
@@ -444,7 +710,7 @@ async function processSingleMessage(task, ctx, providedMasterKey) {
444
710
  throw new Error("Invalid instant message: no content source available");
445
711
  }
446
712
  } else if (decryptedPayload.messageType === "prompted" || decryptedPayload.messageType === "auto") {
447
- const aiResult = await _callAI(decryptedPayload);
713
+ const aiResult = await callLlm(decryptedPayload);
448
714
  messageContent = aiResult.content;
449
715
  llmResponse = aiResult.response;
450
716
  } else {
@@ -480,7 +746,7 @@ async function processSingleMessage(task, ctx, providedMasterKey) {
480
746
  metadata
481
747
  });
482
748
  await ctx.webpush.sendNotification(pushSubscription, JSON.stringify(reasoningPush));
483
- await new Promise((resolve) => setTimeout(resolve, SLEEP_BETWEEN_MESSAGES_MS));
749
+ await new Promise((resolve) => setTimeout(resolve, SLEEP_BETWEEN_MESSAGES_MS2));
484
750
  }
485
751
  for (let i = 0; i < messages.length; i++) {
486
752
  const contentPush = _amsgshared.buildContentPush.call(void 0, {
@@ -501,7 +767,7 @@ async function processSingleMessage(task, ctx, providedMasterKey) {
501
767
  });
502
768
  await ctx.webpush.sendNotification(pushSubscription, JSON.stringify(contentPush));
503
769
  if (i < messages.length - 1) {
504
- await new Promise((resolve) => setTimeout(resolve, SLEEP_BETWEEN_MESSAGES_MS));
770
+ await new Promise((resolve) => setTimeout(resolve, SLEEP_BETWEEN_MESSAGES_MS2));
505
771
  }
506
772
  }
507
773
  return { success: true, messagesSent: messages.length };
@@ -571,80 +837,6 @@ async function processMessagesByUuid(uuid, ctx, maxRetries = 2, userId, provided
571
837
  return { success: true, messagesSent: result.messagesSent, retriesUsed: retryCount };
572
838
  }
573
839
  }
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
840
 
649
841
  // src/server/handlers/schedule-message.js
650
842
  function createScheduleMessageHandler(ctx) {
@@ -1224,6 +1416,16 @@ var SQLITE_INDEXES = [
1224
1416
  critical: true
1225
1417
  }
1226
1418
  ];
1419
+ var CLIENT_STATE_TABLE_SQL = `
1420
+ CREATE TABLE IF NOT EXISTS client_state (
1421
+ user_id TEXT NOT NULL,
1422
+ namespace TEXT NOT NULL,
1423
+ key TEXT NOT NULL,
1424
+ value TEXT NOT NULL,
1425
+ updated_at INTEGER NOT NULL,
1426
+ PRIMARY KEY (user_id, namespace, key)
1427
+ )
1428
+ `;
1227
1429
 
1228
1430
  // src/server/adapters/d1.js
1229
1431
  var UPDATABLE_COLUMNS = /* @__PURE__ */ new Set([
@@ -1256,6 +1458,7 @@ var D1Adapter = class {
1256
1458
  }
1257
1459
  async initSchema() {
1258
1460
  await this._db.prepare(SQLITE_TABLE_SQL).run();
1461
+ await this._db.prepare(CLIENT_STATE_TABLE_SQL).run();
1259
1462
  const indexResults = [];
1260
1463
  for (const index of SQLITE_INDEXES) {
1261
1464
  try {
@@ -1282,6 +1485,7 @@ var D1Adapter = class {
1282
1485
  }
1283
1486
  async dropSchema() {
1284
1487
  await this._db.prepare("DROP TABLE IF EXISTS scheduled_messages").run();
1488
+ await this._db.prepare("DROP TABLE IF EXISTS client_state").run();
1285
1489
  }
1286
1490
  async createTask(params) {
1287
1491
  const now = this._now();
@@ -1410,6 +1614,77 @@ var D1Adapter = class {
1410
1614
  ).bind(uuid, userId).first();
1411
1615
  return row ? row.status : null;
1412
1616
  }
1617
+ // ── client_state (single-user cloud state mirror) ──────────────────────
1618
+ /**
1619
+ * Batch upsert. Last-write-wins per (namespace, key): an entry older
1620
+ * than the stored row (updatedAt strictly lower) is skipped; equal or
1621
+ * newer overwrites. Values arrive pre-encrypted (the handler encrypts).
1622
+ *
1623
+ * Uses D1's batch() — one network round trip for the whole set (implicit
1624
+ * transaction). The client calls this endpoint inside its few-seconds
1625
+ * background window, so N sequential round trips could eat the whole
1626
+ * window. Bindings without batch() (e.g. the sqlite test shim, custom
1627
+ * adapters) fall back to a sequential loop.
1628
+ *
1629
+ * @param {string} userId
1630
+ * @param {Array<{ namespace: string, key: string, value: string, updatedAt: number }>} entries
1631
+ * @returns {Promise<{ upserted: number, skipped: number }>}
1632
+ */
1633
+ async upsertClientState(userId, entries) {
1634
+ const UPSERT_SQL = `INSERT INTO client_state (user_id, namespace, key, value, updated_at)
1635
+ VALUES (?, ?, ?, ?, ?)
1636
+ ON CONFLICT (user_id, namespace, key) DO UPDATE SET
1637
+ value = excluded.value,
1638
+ updated_at = excluded.updated_at
1639
+ WHERE excluded.updated_at >= client_state.updated_at`;
1640
+ let results;
1641
+ if (typeof this._db.batch === "function") {
1642
+ const statements = entries.map(
1643
+ (entry) => this._db.prepare(UPSERT_SQL).bind(userId, entry.namespace, entry.key, entry.value, entry.updatedAt)
1644
+ );
1645
+ results = await this._db.batch(statements);
1646
+ } else {
1647
+ results = [];
1648
+ for (const entry of entries) {
1649
+ results.push(
1650
+ await this._db.prepare(UPSERT_SQL).bind(userId, entry.namespace, entry.key, entry.value, entry.updatedAt).run()
1651
+ );
1652
+ }
1653
+ }
1654
+ let upserted = 0;
1655
+ let skipped = 0;
1656
+ for (const res of results) {
1657
+ if (res.meta.changes > 0) upserted++;
1658
+ else skipped++;
1659
+ }
1660
+ return { upserted, skipped };
1661
+ }
1662
+ /**
1663
+ * All entries of one namespace (values still encrypted).
1664
+ *
1665
+ * @param {string} userId
1666
+ * @param {string} namespace
1667
+ * @returns {Promise<Array<{ namespace: string, key: string, value: string, updated_at: number }>>}
1668
+ */
1669
+ async getClientState(userId, namespace) {
1670
+ const res = await this._db.prepare(
1671
+ `SELECT namespace, key, value, updated_at
1672
+ FROM client_state
1673
+ WHERE user_id = ? AND namespace = ?
1674
+ ORDER BY key ASC`
1675
+ ).bind(userId, namespace).all();
1676
+ return res.results || [];
1677
+ }
1678
+ /**
1679
+ * Wipe every entry of this user.
1680
+ *
1681
+ * @param {string} userId
1682
+ * @returns {Promise<number>} rows deleted
1683
+ */
1684
+ async clearClientState(userId) {
1685
+ const res = await this._db.prepare("DELETE FROM client_state WHERE user_id = ?").bind(userId).run();
1686
+ return res.meta.changes || 0;
1687
+ }
1413
1688
  };
1414
1689
  function createD1Adapter(db) {
1415
1690
  if (!db || typeof db.prepare !== "function") {
@@ -1523,6 +1798,131 @@ function createVapidPublicKeyHandler(ctx) {
1523
1798
  return { GET };
1524
1799
  }
1525
1800
 
1801
+ // src/server/handlers/client-state.js
1802
+ var MAX_STATE_VALUE_BYTES = 200 * 1024;
1803
+ var MAX_STATE_ENTRIES_PER_REQUEST = 200;
1804
+ var MAX_NAMESPACE_CHARS = 128;
1805
+ var MAX_KEY_CHARS = 256;
1806
+ var utf82 = new TextEncoder();
1807
+ function err(status, code, message, details) {
1808
+ const error = details === void 0 ? { code, message } : { code, message, details };
1809
+ return { status, body: { success: false, error } };
1810
+ }
1811
+ function requireUserId(headers) {
1812
+ const userId = getHeader(headers, "x-user-id");
1813
+ if (!userId) return { error: err(400, "USER_ID_REQUIRED", "\u7F3A\u5C11\u7528\u6237\u6807\u8BC6\u7B26") };
1814
+ if (!isValidUUIDv4(userId)) return { error: err(400, "INVALID_USER_ID_FORMAT", "X-User-Id \u5FC5\u987B\u662F UUID v4 \u683C\u5F0F") };
1815
+ return { userId };
1816
+ }
1817
+ function validateEntry(entry, index) {
1818
+ if (!isPlainObject(entry)) return err(400, "INVALID_STATE_ENTRY", `entries[${index}] \u5FC5\u987B\u662F\u5BF9\u8C61`);
1819
+ if (typeof entry.namespace !== "string" || !entry.namespace.trim() || entry.namespace.length > MAX_NAMESPACE_CHARS) {
1820
+ return err(400, "INVALID_STATE_NAMESPACE", `entries[${index}].namespace \u5FC5\u987B\u662F 1-${MAX_NAMESPACE_CHARS} \u5B57\u7B26\u7684\u5B57\u7B26\u4E32`);
1821
+ }
1822
+ if (typeof entry.key !== "string" || !entry.key.trim() || entry.key.length > MAX_KEY_CHARS) {
1823
+ return err(400, "INVALID_STATE_KEY", `entries[${index}].key \u5FC5\u987B\u662F 1-${MAX_KEY_CHARS} \u5B57\u7B26\u7684\u5B57\u7B26\u4E32`);
1824
+ }
1825
+ if (typeof entry.value !== "string") {
1826
+ return err(400, "INVALID_STATE_VALUE", `entries[${index}].value \u5FC5\u987B\u662F\u5B57\u7B26\u4E32\uFF08\u5BBF\u4E3B\u81EA\u884C\u5E8F\u5217\u5316\uFF09`);
1827
+ }
1828
+ const bytes = utf82.encode(entry.value).length;
1829
+ if (bytes > MAX_STATE_VALUE_BYTES) {
1830
+ return err(413, "STATE_VALUE_TOO_LARGE", `entries[${index}].value \u8D85\u8FC7\u5355\u6761\u4E0A\u9650`, { index, key: entry.key, bytes, maxBytes: MAX_STATE_VALUE_BYTES });
1831
+ }
1832
+ if (!Number.isInteger(entry.updatedAt) || entry.updatedAt <= 0) {
1833
+ return err(400, "INVALID_STATE_UPDATED_AT", `entries[${index}].updatedAt \u5FC5\u987B\u662F\u6B63\u6574\u6570\uFF08epoch \u6BEB\u79D2\uFF09`);
1834
+ }
1835
+ return null;
1836
+ }
1837
+ function createClientStateHandler(ctx) {
1838
+ async function PUT(headers, body) {
1839
+ const tenantResult = await ctx.tenantManager.resolveTenant(headers);
1840
+ if (!tenantResult.ok) return tenantResult.error;
1841
+ const { db, masterKey } = tenantResult.context;
1842
+ if (getHeader(headers, "x-payload-encrypted") !== "true") {
1843
+ return err(400, "ENCRYPTION_REQUIRED", "\u8BF7\u6C42\u4F53\u5FC5\u987B\u52A0\u5BC6");
1844
+ }
1845
+ const gate = requireUserId(headers);
1846
+ if (gate.error) return gate.error;
1847
+ const { userId } = gate;
1848
+ if (getHeader(headers, "x-encryption-version") !== "1") {
1849
+ return err(400, "UNSUPPORTED_ENCRYPTION_VERSION", "\u52A0\u5BC6\u7248\u672C\u4E0D\u652F\u6301");
1850
+ }
1851
+ const parsedBody = parseEncryptedBody(body);
1852
+ if (!parsedBody.ok) return { status: 400, body: { success: false, error: parsedBody.error } };
1853
+ const userKey = await deriveUserEncryptionKey(userId, masterKey);
1854
+ let payload;
1855
+ try {
1856
+ payload = await decryptPayload(parsedBody.data, userKey);
1857
+ } catch (error) {
1858
+ if (error instanceof SyntaxError) {
1859
+ return err(400, "INVALID_PAYLOAD_FORMAT", "\u89E3\u5BC6\u540E\u7684\u6570\u636E\u4E0D\u662F\u6709\u6548 JSON");
1860
+ }
1861
+ return err(400, "DECRYPTION_FAILED", "\u8BF7\u6C42\u4F53\u89E3\u5BC6\u5931\u8D25");
1862
+ }
1863
+ if (!isPlainObject(payload)) return err(400, "INVALID_PAYLOAD_FORMAT", "\u89E3\u5BC6\u540E\u7684\u6570\u636E\u5FC5\u987B\u662F JSON \u5BF9\u8C61");
1864
+ const entries = payload.entries;
1865
+ if (!Array.isArray(entries) || entries.length === 0) {
1866
+ return err(400, "INVALID_STATE_ENTRIES", "entries \u5FC5\u987B\u662F\u975E\u7A7A\u6570\u7EC4");
1867
+ }
1868
+ if (entries.length > MAX_STATE_ENTRIES_PER_REQUEST) {
1869
+ return err(400, "TOO_MANY_STATE_ENTRIES", `\u5355\u6B21\u6700\u591A ${MAX_STATE_ENTRIES_PER_REQUEST} \u6761`, { count: entries.length });
1870
+ }
1871
+ for (let i = 0; i < entries.length; i++) {
1872
+ const invalid = validateEntry(entries[i], i);
1873
+ if (invalid) return invalid;
1874
+ }
1875
+ if (typeof db.upsertClientState !== "function") {
1876
+ return err(501, "CLIENT_STATE_NOT_SUPPORTED", "\u5F53\u524D\u6570\u636E\u5E93\u9002\u914D\u5668\u4E0D\u652F\u6301 client_state");
1877
+ }
1878
+ const encryptedEntries = await Promise.all(entries.map(async (entry) => ({
1879
+ namespace: entry.namespace,
1880
+ key: entry.key,
1881
+ value: await encryptForStorage(entry.value, userKey),
1882
+ updatedAt: entry.updatedAt
1883
+ })));
1884
+ const { upserted, skipped } = await db.upsertClientState(userId, encryptedEntries);
1885
+ return { status: 200, body: { success: true, data: { upserted, skipped } } };
1886
+ }
1887
+ async function GET(url, headers) {
1888
+ const tenantResult = await ctx.tenantManager.resolveTenant(headers);
1889
+ if (!tenantResult.ok) return tenantResult.error;
1890
+ const { db, masterKey } = tenantResult.context;
1891
+ const gate = requireUserId(headers);
1892
+ if (gate.error) return gate.error;
1893
+ const { userId } = gate;
1894
+ const namespace = new URL(url, "https://dummy").searchParams.get("namespace") || "";
1895
+ if (!namespace.trim()) return err(400, "NAMESPACE_REQUIRED", "\u5FC5\u987B\u63D0\u4F9B namespace \u67E5\u8BE2\u53C2\u6570");
1896
+ if (typeof db.getClientState !== "function") {
1897
+ return err(501, "CLIENT_STATE_NOT_SUPPORTED", "\u5F53\u524D\u6570\u636E\u5E93\u9002\u914D\u5668\u4E0D\u652F\u6301 client_state");
1898
+ }
1899
+ const userKey = await deriveUserEncryptionKey(userId, masterKey);
1900
+ const rows = await db.getClientState(userId, namespace);
1901
+ const decrypted = await Promise.all(rows.map(async (row) => ({
1902
+ namespace: row.namespace,
1903
+ key: row.key,
1904
+ value: await decryptFromStorage(row.value, userKey),
1905
+ updatedAt: row.updated_at
1906
+ })));
1907
+ const encryptedResponse = await encryptPayload({ namespace, entries: decrypted }, userKey);
1908
+ return { status: 200, body: { success: true, encrypted: true, version: 1, data: encryptedResponse } };
1909
+ }
1910
+ async function DELETE(url, headers) {
1911
+ const tenantResult = await ctx.tenantManager.resolveTenant(headers);
1912
+ if (!tenantResult.ok) return tenantResult.error;
1913
+ const { db } = tenantResult.context;
1914
+ const gate = requireUserId(headers);
1915
+ if (gate.error) return gate.error;
1916
+ const { userId } = gate;
1917
+ if (typeof db.clearClientState !== "function") {
1918
+ return err(501, "CLIENT_STATE_NOT_SUPPORTED", "\u5F53\u524D\u6570\u636E\u5E93\u9002\u914D\u5668\u4E0D\u652F\u6301 client_state");
1919
+ }
1920
+ const deleted = await db.clearClientState(userId);
1921
+ return { status: 200, body: { success: true, data: { deleted } } };
1922
+ }
1923
+ return { PUT, GET, DELETE };
1924
+ }
1925
+
1526
1926
  // src/server/single-user.js
1527
1927
  function createSingleUserServer(config) {
1528
1928
  if (!config || !config.db) throw new Error("[amsg-server single-user] config.db is required");
@@ -1540,7 +1940,13 @@ function createSingleUserServer(config) {
1540
1940
  privateKey: vapid.privateKey || ""
1541
1941
  },
1542
1942
  webpush: config.webpush || null,
1543
- tenantManager
1943
+ tenantManager,
1944
+ // Fire-time hooks (optional): the in-server instant path fires through
1945
+ // processMessagesByUuid with this ctx, so instant-type tasks can take
1946
+ // the agentic path too.
1947
+ hooks: config.hooks || null,
1948
+ maxToolIterations: config.maxToolIterations,
1949
+ totalTimeoutMs: config.totalTimeoutMs
1544
1950
  };
1545
1951
  return {
1546
1952
  ctx,
@@ -1551,7 +1957,8 @@ function createSingleUserServer(config) {
1551
1957
  updateMessage: createUpdateMessageHandler(ctx),
1552
1958
  cancelMessage: createCancelMessageHandler(ctx),
1553
1959
  messages: createMessagesHandler(ctx),
1554
- vapidPublicKey: createVapidPublicKeyHandler(ctx)
1960
+ vapidPublicKey: createVapidPublicKeyHandler(ctx),
1961
+ clientState: createClientStateHandler(ctx)
1555
1962
  }
1556
1963
  };
1557
1964
  }
@@ -1605,12 +2012,12 @@ async function sendWebPush({ subscription, payload, vapid, ttl, fetch: fetchImpl
1605
2012
  });
1606
2013
  if (!res.ok) {
1607
2014
  const text = await safeReadText(res);
1608
- const err = new Error(
2015
+ const err2 = new Error(
1609
2016
  `Web Push delivery failed: ${res.status} ${res.statusText || ""}${text ? ` \u2014 ${text}` : ""}`
1610
2017
  );
1611
- err.code = "PUSH_SEND_FAILED";
1612
- err.statusCode = res.status;
1613
- throw err;
2018
+ err2.code = "PUSH_SEND_FAILED";
2019
+ err2.statusCode = res.status;
2020
+ throw err2;
1614
2021
  }
1615
2022
  return {
1616
2023
  statusCode: res.status,
@@ -1817,6 +2224,12 @@ function createSingleUserCloudflareWorker(buildConfig) {
1817
2224
  result = await server.handlers.cancelMessage.DELETE(url, headers);
1818
2225
  } else if (method === "GET" && pathname.endsWith("/vapid-public-key")) {
1819
2226
  result = await server.handlers.vapidPublicKey.GET(url, headers);
2227
+ } else if (method === "PUT" && pathname.endsWith("/client-state")) {
2228
+ result = await server.handlers.clientState.PUT(headers, await request.text());
2229
+ } else if (method === "GET" && pathname.endsWith("/client-state")) {
2230
+ result = await server.handlers.clientState.GET(url, headers);
2231
+ } else if (method === "DELETE" && pathname.endsWith("/client-state")) {
2232
+ result = await server.handlers.clientState.DELETE(url, headers);
1820
2233
  } else {
1821
2234
  result = { status: 404, body: { success: false, error: { code: "NOT_FOUND", message: "Unknown route" } } };
1822
2235
  }
@@ -1834,7 +2247,17 @@ function createSingleUserCloudflareWorker(buildConfig) {
1834
2247
  return;
1835
2248
  }
1836
2249
  try {
1837
- await runScheduledTick({ db: cfg.db, masterKey: cfg.masterKey, vapid, webpush: cfg.webpush });
2250
+ await runScheduledTick({
2251
+ db: cfg.db,
2252
+ masterKey: cfg.masterKey,
2253
+ vapid,
2254
+ webpush: cfg.webpush,
2255
+ // Fire-time hooks (optional; see lib/agentic-fire.js). runScheduledTick
2256
+ // spreads its ctx into processSingleMessage, so these ride along.
2257
+ hooks: cfg.hooks || null,
2258
+ maxToolIterations: cfg.maxToolIterations,
2259
+ totalTimeoutMs: cfg.totalTimeoutMs
2260
+ });
1838
2261
  } catch (error) {
1839
2262
  console.error("[amsg single-user] scheduled(): tick failed:", error && error.message);
1840
2263
  }