@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.
@@ -1,4 +1,4 @@
1
- import { validateAvatarUrl, toUint8, concatBytes, base64UrlToBytes, readReasoningContent, stripReasoningTags, buildReasoningPush, buildContentPush, normalizeVapidSubject } from '@rei-standard/amsg-shared';
1
+ import { validateAvatarUrl, toUint8, concatBytes, base64UrlToBytes, chunkReasoningByUtf8Bytes, extractAssistantMessage, buildSessionContext, assertValidDecision, extractToolCallsFromDecision, readReasoningContent, stripReasoningTags, buildReasoningPush, buildContentPush, normalizeVapidSubject } from '@rei-standard/amsg-shared';
2
2
 
3
3
  /**
4
4
  * Validation utility library (SDK version)
@@ -421,7 +421,7 @@ const TEXT_ENCODER = new TextEncoder();
421
421
  const TEXT_DECODER = new TextDecoder('utf-8', { fatal: false });
422
422
 
423
423
  /** UTF-8 encode a string into a Uint8Array. */
424
- function utf8(str) {
424
+ function utf8$1(str) {
425
425
  return TEXT_ENCODER.encode(String(str));
426
426
  }
427
427
 
@@ -471,7 +471,7 @@ function hexToBytes(hex) {
471
471
 
472
472
  /** Encode a JSON-serializable value as base64url (UTF-8 JSON). */
473
473
  function jsonToBase64Url(value) {
474
- return bytesToBase64Url(utf8(JSON.stringify(value)));
474
+ return bytesToBase64Url(utf8$1(JSON.stringify(value)));
475
475
  }
476
476
 
477
477
  /** `crypto.randomUUID()`. The Node adapter polyfills `globalThis.crypto`. */
@@ -558,7 +558,7 @@ async function aesGcmOpen(hexKey, iv, ciphertext, authTag) {
558
558
  * @returns {Promise<string>} 64-char hex key.
559
559
  */
560
560
  async function deriveUserEncryptionKey(userId, masterKey) {
561
- const digest = await globalThis.crypto.subtle.digest('SHA-256', utf8(masterKey + userId));
561
+ const digest = await globalThis.crypto.subtle.digest('SHA-256', utf8$1(masterKey + userId));
562
562
  return bytesToHex(new Uint8Array(digest)).slice(0, 64);
563
563
  }
564
564
 
@@ -590,7 +590,7 @@ async function decryptPayload(encryptedPayload, encryptionKey) {
590
590
  async function encryptPayload(payload, encryptionKey) {
591
591
  const plaintext = typeof payload === 'string' ? payload : JSON.stringify(payload);
592
592
  const iv = randomBytes(12);
593
- const { ciphertext, authTag } = await aesGcmSeal(encryptionKey, iv, utf8(plaintext));
593
+ const { ciphertext, authTag } = await aesGcmSeal(encryptionKey, iv, utf8$1(plaintext));
594
594
 
595
595
  return {
596
596
  iv: bytesToBase64(iv),
@@ -608,7 +608,7 @@ async function encryptPayload(payload, encryptionKey) {
608
608
  */
609
609
  async function encryptForStorage(text, encryptionKey) {
610
610
  const iv = randomBytes(16);
611
- const { ciphertext, authTag } = await aesGcmSeal(encryptionKey, iv, utf8(text));
611
+ const { ciphertext, authTag } = await aesGcmSeal(encryptionKey, iv, utf8$1(text));
612
612
  return `${bytesToHex(iv)}:${bytesToHex(authTag)}:${bytesToHex(ciphertext)}`;
613
613
  }
614
614
 
@@ -699,6 +699,613 @@ function isUniqueViolation(error) {
699
699
  return message.includes('duplicate key') || message.includes('unique constraint');
700
700
  }
701
701
 
702
+ /**
703
+ * OpenAI-compatible LLM call for the server-side fire chain.
704
+ *
705
+ * Extracted from message-processor.js so the agentic fire loop
706
+ * (lib/agentic-fire.js) can call the LLM without a circular import.
707
+ * The legacy single-shot path (processSingleMessage) and the multi-round
708
+ * loop share this one function.
709
+ */
710
+
711
+ /**
712
+ * Call an OpenAI-compatible API.
713
+ *
714
+ * Returns the full response object alongside the extracted (trimmed)
715
+ * `content` string. Callers that only need the text can ignore
716
+ * `response`; callers that want `reasoning_content` / `tool_calls`
717
+ * read from `response.choices[0].message`.
718
+ *
719
+ * @param {Object} payload
720
+ * @param {{ requireContent?: boolean, timeoutMs?: number }} [options]
721
+ * requireContent defaults to true (legacy single-shot behavior:
722
+ * throw when the response carries no content). Tool rounds legitimately
723
+ * return no content (pure tool_calls), so the agentic loop passes
724
+ * `{ requireContent: false }` — same precedent as amsg-instant's
725
+ * callLlmRaw.
726
+ * timeoutMs defaults to 300000 (the legacy per-call ceiling). The
727
+ * agentic loop passes its remaining wall-time budget so a hung LLM
728
+ * request cannot outlive totalTimeoutMs.
729
+ * @returns {Promise<{ response: unknown, content: string }>}
730
+ */
731
+ async function callLlm(payload, options = {}) {
732
+ const requireContent = options.requireContent !== false;
733
+ const timeoutMs = typeof options.timeoutMs === 'number' && Number.isFinite(options.timeoutMs) && options.timeoutMs > 0
734
+ ? options.timeoutMs
735
+ : 300000;
736
+ const normalizedApiUrl = normalizeAiApiUrl(payload.apiUrl);
737
+ const requestBody = buildAiRequestBody(payload);
738
+
739
+ const aiResponse = await fetch(normalizedApiUrl, {
740
+ method: 'POST',
741
+ headers: {
742
+ 'Content-Type': 'application/json',
743
+ 'Authorization': `Bearer ${payload.apiKey}`
744
+ },
745
+ body: JSON.stringify(requestBody),
746
+ signal: AbortSignal.timeout(timeoutMs)
747
+ });
748
+
749
+ if (!aiResponse.ok) {
750
+ if (aiResponse.status === 405) {
751
+ throw new Error(
752
+ `AI API error: 405 Method Not Allowed. ` +
753
+ `apiUrl must point to a full chat endpoint (for example: /chat/completions). ` +
754
+ `Received: ${normalizedApiUrl}`
755
+ );
756
+ }
757
+
758
+ throw new Error(
759
+ `AI API error: ${aiResponse.status} ${aiResponse.statusText || 'Unknown Error'}. ` +
760
+ `Request URL: ${normalizedApiUrl}`
761
+ );
762
+ }
763
+
764
+ const aiData = await aiResponse.json();
765
+ const rawContent = aiData?.choices?.[0]?.message?.content;
766
+ if (requireContent && (typeof rawContent !== 'string' || !rawContent.trim())) {
767
+ throw new Error('AI API error: response missing choices[0].message.content');
768
+ }
769
+
770
+ return { response: aiData, content: typeof rawContent === 'string' ? rawContent.trim() : '' };
771
+ }
772
+
773
+ /**
774
+ * Build OpenAI-compatible request body.
775
+ *
776
+ * `max_tokens` is optional:
777
+ * - include it only when payload.maxTokens is provided
778
+ * - omit it when payload.maxTokens is undefined / null
779
+ *
780
+ * @param {Object} payload
781
+ * @returns {Object}
782
+ */
783
+ function buildAiRequestBody(payload) {
784
+ // messages mode (added in v2.2.0): forward the caller's OpenAI-style array
785
+ // verbatim — same contract as @rei-standard/amsg-instant 0.5.0+. No auto
786
+ // role injection, no concatenation back to a single user message. Lets
787
+ // the upstream app preserve system / multi-turn context byte-for-byte
788
+ // across the schedule-message path.
789
+ const llmMessages = Array.isArray(payload.messages) && payload.messages.length > 0
790
+ ? payload.messages
791
+ : [{ role: 'user', content: payload.completePrompt }];
792
+
793
+ const requestBody = {
794
+ model: payload.primaryModel,
795
+ messages: llmMessages,
796
+ };
797
+
798
+ // Match the instant package's behavior: only inject default temperature
799
+ // for the legacy completePrompt path; messages mode forwards whatever the
800
+ // upstream app set (or nothing) so behavior matches their main chat path.
801
+ if (payload.temperature !== undefined && payload.temperature !== null) {
802
+ requestBody.temperature = payload.temperature;
803
+ } else if (!Array.isArray(payload.messages)) {
804
+ requestBody.temperature = 0.8;
805
+ }
806
+
807
+ if (payload.maxTokens === undefined || payload.maxTokens === null) {
808
+ return requestBody;
809
+ }
810
+
811
+ if (!Number.isInteger(payload.maxTokens) || payload.maxTokens <= 0) {
812
+ throw new Error('Invalid maxTokens: maxTokens must be a positive integer when provided.');
813
+ }
814
+
815
+ requestBody.max_tokens = payload.maxTokens;
816
+ return requestBody;
817
+ }
818
+
819
+ /**
820
+ * Normalize AI API URL for OpenAI-compatible chat endpoints.
821
+ *
822
+ * **Keep in sync** with `@rei-standard/amsg-instant`'s
823
+ * `src/message-processor.js` `normalizeAiApiUrl` — same rules, same
824
+ * tests. The two packages share this logic but each carry their own copy
825
+ * to avoid an architectural dependency (server should not depend on the
826
+ * stateless worker package).
827
+ *
828
+ * @param {string} apiUrl
829
+ * @returns {string}
830
+ */
831
+ function normalizeAiApiUrl(apiUrl) {
832
+ if (typeof apiUrl !== 'string' || !apiUrl.trim()) {
833
+ throw new Error(
834
+ 'Invalid apiUrl: apiUrl is required. ' +
835
+ 'Please provide a chat endpoint URL ' +
836
+ '(for example: https://api.openai.com or https://api.openai.com/v1/chat/completions).'
837
+ );
838
+ }
839
+
840
+ const trimmedApiUrl = apiUrl.trim();
841
+ let parsedUrl;
842
+
843
+ try {
844
+ parsedUrl = new URL(trimmedApiUrl);
845
+ } catch {
846
+ throw new Error(
847
+ `Invalid apiUrl: "${apiUrl}". Please provide a valid absolute URL.`
848
+ );
849
+ }
850
+
851
+ let path = parsedUrl.pathname.replace(/\/+$/, '') || '/';
852
+
853
+ if (/\/chat\/completions$/.test(path)) ; else if (path === '/') {
854
+ // Bare host → assume OpenAI shape.
855
+ path = '/v1/chat/completions';
856
+ } else if (/\/v\d+$/.test(path)) {
857
+ // Path ends in `/v1`, `/v2`, … — caller already versioned the URL.
858
+ // Append only `/chat/completions`; never re-add `/v1`.
859
+ path = `${path}/chat/completions`;
860
+ }
861
+ // Any other custom path is left untouched on purpose.
862
+
863
+ parsedUrl.pathname = path;
864
+ return parsedUrl.toString();
865
+ }
866
+
867
+ /**
868
+ * client_state 大值透明分块(单用户/D1 专用;handlers/client-state.js 与
869
+ * lib/agentic-fire.js 的 readState 共用)。
870
+ *
871
+ * 存储格式(库内部实现细节,不进公开契约):
872
+ * - 单条 value ≤ STATE_CHUNK_SLICE_BYTES(200KB)→ 历史单行路径,存储字节级不变。
873
+ * - 超过 → 服务端切片跨行:原 (namespace, key) 行的 value 写成纯文本 marker
874
+ * (`\u001famsg-chunked\u001fv1\u001f<块数>`),切片本体逐片
875
+ * encryptForStorage 后存进保留 namespace(`\u001famsg-chunks\u001f<原ns>`),
876
+ * key 为 `<原key>\u001f<序号>`。写入方与读取方(客户端 / hook 作者)完全无感。
877
+ * - marker 以 \u001f (Unit Separator) 开头;encryptForStorage 输出是
878
+ * `hex:hex:hex`,永远不以控制字符开头,两种行值不会混淆。
879
+ *
880
+ * 读取完整性:块必须齐全,且每块 updated_at 与根行一致(同一次写入的印记)。
881
+ * 不满足(写到一半断了 / 新旧写交错)→ 该 key 视为不存在,读方走自己的兜底,
882
+ * 不抛错、不吐半截数据。
883
+ *
884
+ * 保留字符:namespace / key 里的 C0 控制字符(\u0000-\u001f)为库内部保留,
885
+ * handler 对用户输入逐条拒绝。内部分隔符选 \u001f 而不是 NUL,因为 SQLite 的
886
+ * LIKE 在 \u0000 处截断 pattern,前缀清理会失效。
887
+ */
888
+
889
+
890
+ // 每个切片行的 plaintext 上限 = 历史单条上限,沿用已验证的行大小。
891
+ const STATE_CHUNK_SLICE_BYTES = 200 * 1024;
892
+ // 单条 value 总上限的默认值(工厂配置 maxStateValueBytes 可调)。
893
+ const DEFAULT_MAX_STATE_VALUE_BYTES = 5 * 1024 * 1024;
894
+ // 用户输入里的保留字符(namespace / key 逐条拒绝)。
895
+ const INTERNAL_STATE_CHAR_RE = /[\u0000-\u001f]/;
896
+
897
+ const SEP = '\u001f';
898
+ const CHUNK_NS_PREFIX = `${SEP}amsg-chunks${SEP}`;
899
+ const ROOT_MARKER_PREFIX = `${SEP}amsg-chunked${SEP}v1${SEP}`;
900
+
901
+ /** 某个用户 namespace 的切片行所在的保留 namespace。 */
902
+ function chunkNamespaceFor(namespace) {
903
+ return CHUNK_NS_PREFIX + namespace;
904
+ }
905
+
906
+ /** 第 index 片的存储 key。 */
907
+ function chunkKeyFor(key, index) {
908
+ return `${key}${SEP}${index}`;
909
+ }
910
+
911
+ /** 清理某 key 全部切片行用的 key 前缀。 */
912
+ function chunkKeyPrefixFor(key) {
913
+ return `${key}${SEP}`;
914
+ }
915
+
916
+ /** 分块根行的 value(纯文本 marker,不加密——不含用户数据)。 */
917
+ function buildChunkedRootValue(chunkCount) {
918
+ return `${ROOT_MARKER_PREFIX}${chunkCount}`;
919
+ }
920
+
921
+ /**
922
+ * 严格解析根行 marker。不是 marker(普通密文 / 任意文本 / 计数非正整数)
923
+ * → null,调用方按普通单行处理。
924
+ *
925
+ * @param {unknown} value
926
+ * @returns {number | null}
927
+ */
928
+ function parseChunkedRootCount(value) {
929
+ if (typeof value !== 'string' || !value.startsWith(ROOT_MARKER_PREFIX)) return null;
930
+ const raw = value.slice(ROOT_MARKER_PREFIX.length);
931
+ if (!/^[1-9][0-9]*$/.test(raw)) return null;
932
+ return Number(raw);
933
+ }
934
+
935
+ /**
936
+ * 把超限 value 切成 ≤ STATE_CHUNK_SLICE_BYTES 的切片。切点在码点边界
937
+ * (chunkReasoningByUtf8Bytes 保证多字节字符 / emoji 代理对不被劈开,
938
+ * join('') === 原文)。
939
+ *
940
+ * @param {string} value
941
+ * @returns {string[]}
942
+ */
943
+ function splitStateValue(value) {
944
+ return chunkReasoningByUtf8Bytes(value, STATE_CHUNK_SLICE_BYTES);
945
+ }
946
+
947
+ /**
948
+ * 把一个 namespace 的存储行解析成逻辑条目(GET /client-state 与 readState
949
+ * 共用)。普通行解密直读;分块根行按 marker 拼回。切片行查询是惰性的:
950
+ * 整个 namespace 没有分块根行时一次都不发。
951
+ *
952
+ * @param {Array<{ namespace: string, key: string, value: string, updated_at: number }>} rows
953
+ * 用户 namespace 的存储行(getClientState 返回值)。
954
+ * @param {() => Promise<Array<{ key: string, value: string, updated_at: number }>>} fetchChunkRows
955
+ * 取该 namespace 对应保留 namespace 的全部切片行(最多调用一次)。
956
+ * @param {(value: string) => Promise<string>} decryptValue
957
+ * @returns {Promise<Array<{ namespace: string, key: string, value: string, updatedAt: number }>>}
958
+ */
959
+ async function resolveClientStateEntries(rows, fetchChunkRows, decryptValue) {
960
+ let chunkMap = null;
961
+ const loadChunks = async () => {
962
+ if (chunkMap === null) {
963
+ const chunkRows = await fetchChunkRows();
964
+ chunkMap = new Map(chunkRows.map((row) => [row.key, row]));
965
+ }
966
+ return chunkMap;
967
+ };
968
+
969
+ const entries = [];
970
+ for (const row of rows) {
971
+ const count = parseChunkedRootCount(row.value);
972
+ if (count === null) {
973
+ entries.push({
974
+ namespace: row.namespace,
975
+ key: row.key,
976
+ value: await decryptValue(row.value),
977
+ updatedAt: row.updated_at,
978
+ });
979
+ continue;
980
+ }
981
+
982
+ const map = await loadChunks();
983
+ const chunkRows = [];
984
+ let intact = true;
985
+ for (let i = 0; i < count; i++) {
986
+ const chunk = map.get(chunkKeyFor(row.key, i));
987
+ if (!chunk || chunk.updated_at !== row.updated_at) {
988
+ intact = false;
989
+ break;
990
+ }
991
+ chunkRows.push(chunk);
992
+ }
993
+ if (!intact) continue; // 写到一半断了 → 该 key 视为不存在
994
+
995
+ const parts = await Promise.all(chunkRows.map((chunk) => decryptValue(chunk.value)));
996
+ entries.push({
997
+ namespace: row.namespace,
998
+ key: row.key,
999
+ value: parts.join(''),
1000
+ updatedAt: row.updated_at,
1001
+ });
1002
+ }
1003
+ return entries;
1004
+ }
1005
+
1006
+ /**
1007
+ * Server-side agentic fire loop.
1008
+ *
1009
+ * When the host configures fire-time hooks, an LLM task stops replaying
1010
+ * the completePrompt frozen at schedule time. At fire time instead:
1011
+ *
1012
+ * onBeforeFire(fireCtx) → fresh messages (may read client_state)
1013
+ * → callLlm → onLLMOutput(sessionCtx) → decision
1014
+ * ├─ 'finish' → push decision.pushPayloads, done
1015
+ * ├─ 'skip-push' → record, done (task counts as delivered)
1016
+ * ├─ 'continue' → replace history, next round
1017
+ * └─ 'tool-request' → executeToolCalls IN the worker — the client
1018
+ * is offline at fire time, so unlike
1019
+ * amsg-instant nothing is pushed back for the
1020
+ * client to execute — then append the
1021
+ * assistant turn + tool results, next round
1022
+ *
1023
+ * The decision contract is shared with @rei-standard/amsg-instant
1024
+ * (assertValidDecision / buildSessionContext live in
1025
+ * @rei-standard/amsg-shared), so a classifier written for instant's
1026
+ * onLLMOutput drops in unchanged: 'tool-request' may carry `toolCalls`
1027
+ * directly, or tool_request pushPayloads that embed them — both work.
1028
+ *
1029
+ * Credential hiding: hook ctx objects never contain apiKey /
1030
+ * pushSubscription / vapid / masterKey (same rationale as instant's
1031
+ * SessionContext — a console.log(ctx) in a hook must not leak keys).
1032
+ *
1033
+ * scratch:每次 fire 新建一个普通对象,onBeforeFire 的 fireCtx 与同一次
1034
+ * fire 每轮的 sessionCtx 都持有同一个引用,hook 之间借它传工具上下文,
1035
+ * 不用再自己维护 Map<sessionId, state>。fire 结束(finish / skip-push /
1036
+ * 抛错 / 轮数超限)后随调用栈丢弃;库不读不写、不落库、不打日志。
1037
+ *
1038
+ * Budget guards, both factory-level (ctx.maxToolIterations /
1039
+ * ctx.totalTimeoutMs) and per-fire (onBeforeFire may return
1040
+ * { messages, maxToolIterations?, totalTimeoutMs? } to override for one
1041
+ * task — e.g. a task the host knows runs slow tools): maxToolIterations
1042
+ * caps LLM rounds; totalTimeoutMs is a wall-time ceiling checked before
1043
+ * each round so a cron tick can never hang forever. Both exhaustions
1044
+ * surface as ordinary task failures → the existing retry / mark-failed
1045
+ * semantics apply.
1046
+ */
1047
+
1048
+
1049
+ const DEFAULT_MAX_TOOL_ITERATIONS = 5;
1050
+ const DEFAULT_TOTAL_TIMEOUT_MS = 240_000;
1051
+
1052
+ // Same pacing as the legacy path / amsg-instant.
1053
+ const SLEEP_BETWEEN_MESSAGES_MS$1 = 1500;
1054
+
1055
+ // Task-payload fields that must never reach hook code.
1056
+ const CREDENTIAL_PAYLOAD_KEYS = new Set(['apiKey', 'pushSubscription']);
1057
+
1058
+ const defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
1059
+
1060
+ /**
1061
+ * Does this task need the LLM at fire time? Fixed text never does, so it
1062
+ * always stays on the legacy path regardless of hooks.
1063
+ *
1064
+ * @param {Object} decryptedPayload
1065
+ * @returns {boolean}
1066
+ */
1067
+ function taskNeedsLlm(decryptedPayload) {
1068
+ const type = decryptedPayload.messageType;
1069
+ if (type === 'prompted' || type === 'auto') return true;
1070
+ if (type === 'instant') {
1071
+ return !!(decryptedPayload.apiUrl && decryptedPayload.apiKey && decryptedPayload.primaryModel);
1072
+ }
1073
+ return false;
1074
+ }
1075
+
1076
+ /** Frozen, credential-free view of the task for hook authors. */
1077
+ function buildHookTask(task, decryptedPayload) {
1078
+ const safe = {};
1079
+ for (const [key, value] of Object.entries(decryptedPayload)) {
1080
+ if (!CREDENTIAL_PAYLOAD_KEYS.has(key)) safe[key] = value;
1081
+ }
1082
+ return Object.freeze({
1083
+ ...safe,
1084
+ id: task.id ?? null,
1085
+ uuid: task.uuid ?? null,
1086
+ nextSendAt: task.next_send_at ?? null,
1087
+ retryCount: task.retry_count ?? 0,
1088
+ });
1089
+ }
1090
+
1091
+ function normalizeBeforeFireResult(result) {
1092
+ if (Array.isArray(result)) {
1093
+ return { messages: result };
1094
+ }
1095
+ if (result && typeof result === 'object' && Array.isArray(result.messages)) {
1096
+ return {
1097
+ messages: result.messages,
1098
+ maxToolIterations: result.maxToolIterations,
1099
+ totalTimeoutMs: result.totalTimeoutMs,
1100
+ };
1101
+ }
1102
+ throw new TypeError(
1103
+ 'AGENTIC_BAD_BEFORE_FIRE: onBeforeFire must return ChatMessage[] | { messages, maxToolIterations?, totalTimeoutMs? } | null'
1104
+ );
1105
+ }
1106
+
1107
+ function firstPositiveInt(values, fallback) {
1108
+ for (const v of values) {
1109
+ if (Number.isInteger(v) && v > 0) return v;
1110
+ }
1111
+ return fallback;
1112
+ }
1113
+
1114
+ function firstPositiveNumber(values, fallback) {
1115
+ for (const v of values) {
1116
+ if (typeof v === 'number' && Number.isFinite(v) && v > 0) return v;
1117
+ }
1118
+ return fallback;
1119
+ }
1120
+
1121
+ /**
1122
+ * Try the hook-driven fire path for one task.
1123
+ *
1124
+ * @param {Object} args
1125
+ * @param {import('../adapters/interface.js').TaskRow} args.task
1126
+ * @param {Object} args.decryptedPayload - decrypted task payload (has credentials; they stop here)
1127
+ * @param {string} args.userKey - per-user storage key (for readState decryption)
1128
+ * @param {Object} args.ctx - processor ctx ({ db, webpush, vapid, hooks, maxToolIterations, totalTimeoutMs })
1129
+ * @returns {Promise<{ handled: false } | { handled: true, result: { success: true, messagesSent: number, status: 'finished'|'skipped', iterations: number } }>}
1130
+ * `handled: false` → caller falls back to the legacy frozen-prompt path.
1131
+ * Failures (timeout / loop exceeded / config errors) throw — the caller's
1132
+ * existing error handling turns them into task retry/failure.
1133
+ */
1134
+ async function runAgenticFire({ task, decryptedPayload, userKey, ctx }) {
1135
+ const hooks = ctx.hooks;
1136
+ if (typeof hooks.onLLMOutput !== 'function') {
1137
+ throw new Error('AGENTIC_CONFIG_ERROR: hooks.onBeforeFire requires hooks.onLLMOutput to classify LLM rounds');
1138
+ }
1139
+
1140
+ // Injectable seams for tests only (fake clock / no real 1500ms pacing).
1141
+ const nowFn = typeof ctx._agenticNow === 'function' ? ctx._agenticNow : Date.now;
1142
+ const sleep = typeof ctx._agenticSleep === 'function' ? ctx._agenticSleep : defaultSleep;
1143
+
1144
+ const readState = async (namespace) => {
1145
+ if (typeof namespace !== 'string' || !namespace.trim()) {
1146
+ throw new TypeError('readState(namespace) requires a non-empty string');
1147
+ }
1148
+ if (!ctx.db || typeof ctx.db.getClientState !== 'function') return [];
1149
+ const rows = await ctx.db.getClientState(task.user_id, namespace);
1150
+ // 分块存储的值在这里拼回原文(见 state-chunks.js);块不齐全的 key 视为
1151
+ // 不存在 —— hook 作者拿到的与客户端写入的一致,永远不会是半截数据。
1152
+ return resolveClientStateEntries(
1153
+ rows,
1154
+ () => ctx.db.getClientState(task.user_id, chunkNamespaceFor(namespace)),
1155
+ (value) => decryptFromStorage(value, userKey)
1156
+ );
1157
+ };
1158
+
1159
+ // 单次 fire 的宿主便签:onBeforeFire 的 fireCtx 和同一次 fire 每轮的
1160
+ // sessionCtx(onLLMOutput / executeToolCalls)拿到同一个对象引用,fire 结束
1161
+ // (finish / skip-push / 抛错 / 轮数超限)随调用栈丢弃。库自己不读不写、
1162
+ // 不落库、不打日志、不跨 fire 共享 —— 重试产生的新 fire 拿到的是新对象。
1163
+ const scratch = {};
1164
+
1165
+ const fireCtx = Object.freeze({
1166
+ task: buildHookTask(task, decryptedPayload),
1167
+ userId: task.user_id,
1168
+ readState,
1169
+ now: new Date(nowFn()),
1170
+ scratch,
1171
+ });
1172
+
1173
+ const before = await hooks.onBeforeFire(fireCtx);
1174
+ if (before == null) return { handled: false };
1175
+
1176
+ const normalized = normalizeBeforeFireResult(before);
1177
+ const maxToolIterations = firstPositiveInt(
1178
+ [normalized.maxToolIterations, ctx.maxToolIterations],
1179
+ DEFAULT_MAX_TOOL_ITERATIONS
1180
+ );
1181
+ const totalTimeoutMs = firstPositiveNumber(
1182
+ [normalized.totalTimeoutMs, ctx.totalTimeoutMs],
1183
+ DEFAULT_TOTAL_TIMEOUT_MS
1184
+ );
1185
+ const deadline = nowFn() + totalTimeoutMs;
1186
+
1187
+ // Same sessionId scheme as the legacy path: pinned to the task id so a
1188
+ // retried task reuses the same session and clients can group/dedupe.
1189
+ const sessionId = task.id != null ? `sess_task_${task.id}` : `sess_${randomUUID()}`;
1190
+ let messages = normalized.messages.slice();
1191
+
1192
+ for (let iteration = 0; iteration < maxToolIterations; iteration++) {
1193
+ if (nowFn() >= deadline) {
1194
+ throw new Error(`AGENTIC_TOTAL_TIMEOUT: fire chain exceeded ${totalTimeoutMs}ms after ${iteration} LLM round(s)`);
1195
+ }
1196
+
1197
+ // Shrink each round's fetch timeout to the remaining wall-time budget
1198
+ // (capped at the legacy 300s single-call ceiling) — a hung LLM request
1199
+ // must not outlive totalTimeoutMs waiting for its own 300s abort.
1200
+ const roundTimeoutMs = Math.max(1, Math.min(300_000, deadline - nowFn()));
1201
+ const { response: llmResponse } = await callLlm(
1202
+ { ...decryptedPayload, messages },
1203
+ { requireContent: false, timeoutMs: roundTimeoutMs }
1204
+ );
1205
+
1206
+ const assistantMessage = extractAssistantMessage(llmResponse);
1207
+ messages = [...messages, assistantMessage];
1208
+
1209
+ const sessionCtx = buildSessionContext({
1210
+ sessionId,
1211
+ messages,
1212
+ llmResponse,
1213
+ iteration,
1214
+ contactName: decryptedPayload.contactName,
1215
+ avatarUrl: decryptedPayload.avatarUrl || undefined,
1216
+ charId: decryptedPayload.charId,
1217
+ metadata: decryptedPayload.metadata,
1218
+ scratch,
1219
+ });
1220
+
1221
+ const decision = await hooks.onLLMOutput(sessionCtx);
1222
+ assertValidDecision(decision, { inlineToolCalls: true });
1223
+
1224
+ if (decision.decision === 'continue') {
1225
+ messages = decision.nextHistory.slice();
1226
+ continue;
1227
+ }
1228
+
1229
+ if (decision.decision === 'skip-push') {
1230
+ return { handled: true, result: { success: true, messagesSent: 0, status: 'skipped', iterations: iteration + 1 } };
1231
+ }
1232
+
1233
+ if (decision.decision === 'finish') {
1234
+ const messagesSent = await sendHookPushPayloads(decision.pushPayloads, decryptedPayload, ctx, sessionId, task, sleep);
1235
+ return { handled: true, result: { success: true, messagesSent, status: 'finished', iterations: iteration + 1 } };
1236
+ }
1237
+
1238
+ // 'tool-request' — execute right here in the worker.
1239
+ const toolCalls = extractToolCallsFromDecision(decision);
1240
+ if (toolCalls.length === 0) {
1241
+ throw new Error('AGENTIC_EMPTY_TOOL_REQUEST: tool-request decision carried no toolCalls (neither decision.toolCalls nor pushPayloads[].toolCalls)');
1242
+ }
1243
+ if (typeof hooks.executeToolCalls !== 'function') {
1244
+ throw new Error('AGENTIC_CONFIG_ERROR: onLLMOutput returned tool-request but hooks.executeToolCalls is not configured');
1245
+ }
1246
+ if (iteration === maxToolIterations - 1) {
1247
+ // No LLM round left to consume the results — executing tools now
1248
+ // would only burn external calls. Fall straight to the exceeded error.
1249
+ break;
1250
+ }
1251
+
1252
+ let toolResults;
1253
+ try {
1254
+ toolResults = await hooks.executeToolCalls(toolCalls, sessionCtx);
1255
+ if (!Array.isArray(toolResults)) {
1256
+ throw new TypeError('executeToolCalls must resolve to an array of { tool_call_id, role: "tool", content }');
1257
+ }
1258
+ } catch (error) {
1259
+ // Feed the failure back as tool results and let the LLM talk its way
1260
+ // out, instead of failing the whole fire.
1261
+ toolResults = toolCalls.map((toolCall) => ({
1262
+ tool_call_id: toolCall && typeof toolCall === 'object' && typeof toolCall.id === 'string' ? toolCall.id : '',
1263
+ role: 'tool',
1264
+ content: `Tool execution failed: ${error?.message ?? String(error)}`,
1265
+ }));
1266
+ }
1267
+
1268
+ // Text-protocol classifiers synthesize toolCalls the raw assistant
1269
+ // message doesn't carry; stamp them on so the appended role:'tool'
1270
+ // results stay valid for OpenAI-compatible APIs.
1271
+ const assistantWithTools = Array.isArray(assistantMessage.tool_calls) && assistantMessage.tool_calls.length > 0
1272
+ ? assistantMessage
1273
+ : { ...assistantMessage, tool_calls: toolCalls };
1274
+ messages = [...messages.slice(0, -1), assistantWithTools, ...toolResults];
1275
+ }
1276
+
1277
+ throw new Error(`AGENTIC_LOOP_EXCEEDED: no finish/skip-push decision within ${maxToolIterations} LLM round(s)`);
1278
+ }
1279
+
1280
+ /**
1281
+ * Deliver the hook's pushPayloads sequentially. Mirrors instant's
1282
+ * sendPushesSequentially: force-overwrite messageIndex/totalMessages,
1283
+ * stamp missing ids, pace with the same 1500ms spacing. Ids are
1284
+ * deterministic per (task, index) so a retried task reuses the same ids
1285
+ * and clients can dedupe.
1286
+ */
1287
+ async function sendHookPushPayloads(pushPayloads, decryptedPayload, ctx, sessionId, task, sleep) {
1288
+ if (!ctx.vapid || !ctx.vapid.email || !ctx.vapid.publicKey || !ctx.vapid.privateKey) {
1289
+ throw new Error('VAPID configuration missing - push notifications cannot be sent');
1290
+ }
1291
+ const pushSubscription = decryptedPayload.pushSubscription;
1292
+ const total = pushPayloads.length;
1293
+ const messageIdBase = task.id != null ? `msg_task_${task.id}` : `msg_${randomUUID()}`;
1294
+
1295
+ for (let i = 0; i < total; i++) {
1296
+ const push = { ...pushPayloads[i] };
1297
+ if (typeof push.messageId !== 'string' || !push.messageId) push.messageId = `${messageIdBase}_hook_${i}`;
1298
+ if (typeof push.sessionId !== 'string' || !push.sessionId) push.sessionId = sessionId;
1299
+ if (typeof push.timestamp !== 'string' || !push.timestamp) push.timestamp = new Date().toISOString();
1300
+ push.messageIndex = i + 1;
1301
+ push.totalMessages = total;
1302
+
1303
+ await ctx.webpush.sendNotification(pushSubscription, JSON.stringify(push));
1304
+ if (i < total - 1) await sleep(SLEEP_BETWEEN_MESSAGES_MS$1);
1305
+ }
1306
+ return total;
1307
+ }
1308
+
702
1309
  /**
703
1310
  * Message Processor (SDK version)
704
1311
  * ReiStandard amsg-server v2.4.0
@@ -799,6 +1406,16 @@ async function processSingleMessage(task, ctx, providedMasterKey) {
799
1406
  const userKey = await deriveUserEncryptionKey(task.user_id, masterKey);
800
1407
  const decryptedPayload = JSON.parse(await decryptFromStorage(task.encrypted_payload, userKey));
801
1408
 
1409
+ // Fire-time hooks: when the host configured onBeforeFire and the task
1410
+ // needs the LLM, offer the agentic path first. onBeforeFire → null
1411
+ // falls straight through to the frozen-prompt chain below, and
1412
+ // deployments without hooks never enter this branch — legacy behavior
1413
+ // is byte-identical.
1414
+ if (ctx.hooks && typeof ctx.hooks.onBeforeFire === 'function' && taskNeedsLlm(decryptedPayload)) {
1415
+ const agentic = await runAgenticFire({ task, decryptedPayload, userKey, ctx });
1416
+ if (agentic.handled) return agentic.result;
1417
+ }
1418
+
802
1419
  let messageContent;
803
1420
  /** @type {unknown} */
804
1421
  let llmResponse = null;
@@ -810,7 +1427,7 @@ async function processSingleMessage(task, ctx, providedMasterKey) {
810
1427
  const hasPrompt = !!decryptedPayload.completePrompt
811
1428
  || (Array.isArray(decryptedPayload.messages) && decryptedPayload.messages.length > 0);
812
1429
  if (hasPrompt && decryptedPayload.apiUrl && decryptedPayload.apiKey && decryptedPayload.primaryModel) {
813
- const aiResult = await _callAI(decryptedPayload);
1430
+ const aiResult = await callLlm(decryptedPayload);
814
1431
  messageContent = aiResult.content;
815
1432
  llmResponse = aiResult.response;
816
1433
  } else if (decryptedPayload.userMessage) {
@@ -820,7 +1437,7 @@ async function processSingleMessage(task, ctx, providedMasterKey) {
820
1437
  }
821
1438
 
822
1439
  } else if (decryptedPayload.messageType === 'prompted' || decryptedPayload.messageType === 'auto') {
823
- const aiResult = await _callAI(decryptedPayload);
1440
+ const aiResult = await callLlm(decryptedPayload);
824
1441
  messageContent = aiResult.content;
825
1442
  llmResponse = aiResult.response;
826
1443
  } else {
@@ -913,245 +1530,101 @@ async function processSingleMessage(task, ctx, providedMasterKey) {
913
1530
  if (i < messages.length - 1) {
914
1531
  await new Promise(resolve => setTimeout(resolve, SLEEP_BETWEEN_MESSAGES_MS));
915
1532
  }
916
- }
917
-
918
- return { success: true, messagesSent: messages.length };
919
-
920
- } catch (error) {
921
- return { success: false, messagesSent: 0, error: error.message };
922
- }
923
- }
924
-
925
- /**
926
- * Process a single message identified by UUID (used for instant type).
927
- *
928
- * @param {string} uuid
929
- * @param {ProcessorContext} ctx
930
- * @param {number} [maxRetries=2]
931
- * @param {string} [userId]
932
- * @param {string} [providedMasterKey]
933
- * @returns {Promise<{ success: boolean, messagesSent?: number, retriesUsed?: number, error?: Object }>}
934
- */
935
- async function processMessagesByUuid(uuid, ctx, maxRetries = 2, userId, providedMasterKey) {
936
- let retryCount = 0;
937
- const masterKey = providedMasterKey || ctx.masterKey;
938
-
939
- if (!masterKey) {
940
- return {
941
- success: false,
942
- error: { code: 'TENANT_MASTER_KEY_MISSING', message: '租户主密钥不存在或配置异常' }
943
- };
944
- }
945
-
946
- while (retryCount <= maxRetries) {
947
- let task;
948
- try {
949
- task = userId
950
- ? await ctx.db.getTaskByUuid(uuid, userId)
951
- : await ctx.db.getTaskByUuidOnly(uuid);
952
- } catch (error) {
953
- if (retryCount < maxRetries) {
954
- retryCount++;
955
- await new Promise(resolve => setTimeout(resolve, 1000 * retryCount));
956
- continue;
957
- }
958
-
959
- return {
960
- success: false,
961
- error: { code: 'INTERNAL_ERROR', message: error.message, retriesAttempted: retryCount }
962
- };
963
- }
964
-
965
- if (!task) {
966
- return { success: false, error: { code: 'TASK_NOT_FOUND', message: '任务不存在或已处理' } };
967
- }
968
-
969
- const result = await processSingleMessage(task, ctx, masterKey);
970
-
971
- if (!result.success) {
972
- if (retryCount < maxRetries) {
973
- retryCount++;
974
- await new Promise(resolve => setTimeout(resolve, 1000 * retryCount));
975
- continue;
976
- }
977
-
978
- try {
979
- await ctx.db.updateTaskById(task.id, { status: 'failed', retry_count: retryCount });
980
- } catch (_updateError) {
981
- // best-effort status update; keep original processing error as primary signal
982
- }
983
-
984
- return {
985
- success: false,
986
- error: { code: 'PROCESSING_ERROR', message: result.error, retriesAttempted: retryCount }
987
- };
988
- }
989
-
990
- try {
991
- await ctx.db.deleteTaskById(task.id);
992
- } catch (error) {
993
- try {
994
- await ctx.db.updateTaskById(task.id, { status: 'sent', retry_count: 0 });
995
- } catch (_markSentError) {
996
- // best effort: avoid re-sending if storage mutation partially fails
997
- }
998
-
999
- return {
1000
- success: false,
1001
- error: {
1002
- code: 'POST_SEND_CLEANUP_FAILED',
1003
- message: '消息已发送,但任务清理失败',
1004
- details: { error: error.message }
1005
- }
1006
- };
1007
- }
1008
-
1009
- return { success: true, messagesSent: result.messagesSent, retriesUsed: retryCount };
1010
- }
1011
- }
1012
-
1013
- /**
1014
- * Call an OpenAI-compatible API.
1015
- *
1016
- * Returns the full response object alongside the extracted (trimmed)
1017
- * `content` string. Callers that only need the text can ignore
1018
- * `response`; callers that want `reasoning_content` / `tool_calls`
1019
- * read from `response.choices[0].message`.
1020
- *
1021
- * @private
1022
- * @param {Object} payload
1023
- * @returns {Promise<{ response: unknown, content: string }>}
1024
- */
1025
- async function _callAI(payload) {
1026
- const normalizedApiUrl = normalizeAiApiUrl(payload.apiUrl);
1027
- const requestBody = buildAiRequestBody(payload);
1028
-
1029
- const aiResponse = await fetch(normalizedApiUrl, {
1030
- method: 'POST',
1031
- headers: {
1032
- 'Content-Type': 'application/json',
1033
- 'Authorization': `Bearer ${payload.apiKey}`
1034
- },
1035
- body: JSON.stringify(requestBody),
1036
- signal: AbortSignal.timeout(300000)
1037
- });
1038
-
1039
- if (!aiResponse.ok) {
1040
- if (aiResponse.status === 405) {
1041
- throw new Error(
1042
- `AI API error: 405 Method Not Allowed. ` +
1043
- `apiUrl must point to a full chat endpoint (for example: /chat/completions). ` +
1044
- `Received: ${normalizedApiUrl}`
1045
- );
1046
- }
1047
-
1048
- throw new Error(
1049
- `AI API error: ${aiResponse.status} ${aiResponse.statusText || 'Unknown Error'}. ` +
1050
- `Request URL: ${normalizedApiUrl}`
1051
- );
1052
- }
1053
-
1054
- const aiData = await aiResponse.json();
1055
- const content = aiData?.choices?.[0]?.message?.content;
1056
- if (typeof content !== 'string' || !content.trim()) {
1057
- throw new Error('AI API error: response missing choices[0].message.content');
1058
- }
1059
-
1060
- return { response: aiData, content: content.trim() };
1061
- }
1062
-
1063
- /**
1064
- * Build OpenAI-compatible request body.
1065
- *
1066
- * `max_tokens` is optional:
1067
- * - include it only when payload.maxTokens is provided
1068
- * - omit it when payload.maxTokens is undefined / null
1069
- *
1070
- * @param {Object} payload
1071
- * @returns {Object}
1072
- */
1073
- function buildAiRequestBody(payload) {
1074
- // messages mode (added in v2.2.0): forward the caller's OpenAI-style array
1075
- // verbatim — same contract as @rei-standard/amsg-instant 0.5.0+. No auto
1076
- // role injection, no concatenation back to a single user message. Lets
1077
- // the upstream app preserve system / multi-turn context byte-for-byte
1078
- // across the schedule-message path.
1079
- const llmMessages = Array.isArray(payload.messages) && payload.messages.length > 0
1080
- ? payload.messages
1081
- : [{ role: 'user', content: payload.completePrompt }];
1082
-
1083
- const requestBody = {
1084
- model: payload.primaryModel,
1085
- messages: llmMessages,
1086
- };
1087
-
1088
- // Match the instant package's behavior: only inject default temperature
1089
- // for the legacy completePrompt path; messages mode forwards whatever the
1090
- // upstream app set (or nothing) so behavior matches their main chat path.
1091
- if (payload.temperature !== undefined && payload.temperature !== null) {
1092
- requestBody.temperature = payload.temperature;
1093
- } else if (!Array.isArray(payload.messages)) {
1094
- requestBody.temperature = 0.8;
1095
- }
1096
-
1097
- if (payload.maxTokens === undefined || payload.maxTokens === null) {
1098
- return requestBody;
1099
- }
1533
+ }
1100
1534
 
1101
- if (!Number.isInteger(payload.maxTokens) || payload.maxTokens <= 0) {
1102
- throw new Error('Invalid maxTokens: maxTokens must be a positive integer when provided.');
1103
- }
1535
+ return { success: true, messagesSent: messages.length };
1104
1536
 
1105
- requestBody.max_tokens = payload.maxTokens;
1106
- return requestBody;
1537
+ } catch (error) {
1538
+ return { success: false, messagesSent: 0, error: error.message };
1539
+ }
1107
1540
  }
1108
1541
 
1109
1542
  /**
1110
- * Normalize AI API URL for OpenAI-compatible chat endpoints.
1111
- *
1112
- * **Keep in sync** with `@rei-standard/amsg-instant`'s
1113
- * `src/message-processor.js` `normalizeAiApiUrl` — same rules, same
1114
- * tests. The two packages share this logic but each carry their own copy
1115
- * to avoid an architectural dependency (server should not depend on the
1116
- * stateless worker package).
1543
+ * Process a single message identified by UUID (used for instant type).
1117
1544
  *
1118
- * @param {string} apiUrl
1119
- * @returns {string}
1545
+ * @param {string} uuid
1546
+ * @param {ProcessorContext} ctx
1547
+ * @param {number} [maxRetries=2]
1548
+ * @param {string} [userId]
1549
+ * @param {string} [providedMasterKey]
1550
+ * @returns {Promise<{ success: boolean, messagesSent?: number, retriesUsed?: number, error?: Object }>}
1120
1551
  */
1121
- function normalizeAiApiUrl(apiUrl) {
1122
- if (typeof apiUrl !== 'string' || !apiUrl.trim()) {
1123
- throw new Error(
1124
- 'Invalid apiUrl: apiUrl is required. ' +
1125
- 'Please provide a chat endpoint URL ' +
1126
- '(for example: https://api.openai.com or https://api.openai.com/v1/chat/completions).'
1127
- );
1552
+ async function processMessagesByUuid(uuid, ctx, maxRetries = 2, userId, providedMasterKey) {
1553
+ let retryCount = 0;
1554
+ const masterKey = providedMasterKey || ctx.masterKey;
1555
+
1556
+ if (!masterKey) {
1557
+ return {
1558
+ success: false,
1559
+ error: { code: 'TENANT_MASTER_KEY_MISSING', message: '租户主密钥不存在或配置异常' }
1560
+ };
1128
1561
  }
1129
1562
 
1130
- const trimmedApiUrl = apiUrl.trim();
1131
- let parsedUrl;
1563
+ while (retryCount <= maxRetries) {
1564
+ let task;
1565
+ try {
1566
+ task = userId
1567
+ ? await ctx.db.getTaskByUuid(uuid, userId)
1568
+ : await ctx.db.getTaskByUuidOnly(uuid);
1569
+ } catch (error) {
1570
+ if (retryCount < maxRetries) {
1571
+ retryCount++;
1572
+ await new Promise(resolve => setTimeout(resolve, 1000 * retryCount));
1573
+ continue;
1574
+ }
1132
1575
 
1133
- try {
1134
- parsedUrl = new URL(trimmedApiUrl);
1135
- } catch {
1136
- throw new Error(
1137
- `Invalid apiUrl: "${apiUrl}". Please provide a valid absolute URL.`
1138
- );
1139
- }
1576
+ return {
1577
+ success: false,
1578
+ error: { code: 'INTERNAL_ERROR', message: error.message, retriesAttempted: retryCount }
1579
+ };
1580
+ }
1140
1581
 
1141
- let path = parsedUrl.pathname.replace(/\/+$/, '') || '/';
1582
+ if (!task) {
1583
+ return { success: false, error: { code: 'TASK_NOT_FOUND', message: '任务不存在或已处理' } };
1584
+ }
1142
1585
 
1143
- if (/\/chat\/completions$/.test(path)) ; else if (path === '/') {
1144
- // Bare host → assume OpenAI shape.
1145
- path = '/v1/chat/completions';
1146
- } else if (/\/v\d+$/.test(path)) {
1147
- // Path ends in `/v1`, `/v2`, … — caller already versioned the URL.
1148
- // Append only `/chat/completions`; never re-add `/v1`.
1149
- path = `${path}/chat/completions`;
1150
- }
1151
- // Any other custom path is left untouched on purpose.
1586
+ const result = await processSingleMessage(task, ctx, masterKey);
1152
1587
 
1153
- parsedUrl.pathname = path;
1154
- return parsedUrl.toString();
1588
+ if (!result.success) {
1589
+ if (retryCount < maxRetries) {
1590
+ retryCount++;
1591
+ await new Promise(resolve => setTimeout(resolve, 1000 * retryCount));
1592
+ continue;
1593
+ }
1594
+
1595
+ try {
1596
+ await ctx.db.updateTaskById(task.id, { status: 'failed', retry_count: retryCount });
1597
+ } catch (_updateError) {
1598
+ // best-effort status update; keep original processing error as primary signal
1599
+ }
1600
+
1601
+ return {
1602
+ success: false,
1603
+ error: { code: 'PROCESSING_ERROR', message: result.error, retriesAttempted: retryCount }
1604
+ };
1605
+ }
1606
+
1607
+ try {
1608
+ await ctx.db.deleteTaskById(task.id);
1609
+ } catch (error) {
1610
+ try {
1611
+ await ctx.db.updateTaskById(task.id, { status: 'sent', retry_count: 0 });
1612
+ } catch (_markSentError) {
1613
+ // best effort: avoid re-sending if storage mutation partially fails
1614
+ }
1615
+
1616
+ return {
1617
+ success: false,
1618
+ error: {
1619
+ code: 'POST_SEND_CLEANUP_FAILED',
1620
+ message: '消息已发送,但任务清理失败',
1621
+ details: { error: error.message }
1622
+ }
1623
+ };
1624
+ }
1625
+
1626
+ return { success: true, messagesSent: result.messagesSent, retriesUsed: retryCount };
1627
+ }
1155
1628
  }
1156
1629
 
1157
1630
  /**
@@ -1924,6 +2397,24 @@ const SQLITE_INDEXES = [
1924
2397
  }
1925
2398
  ];
1926
2399
 
2400
+ // client_state: cloud mirror of client-side state for the single-user
2401
+ // deployment. One live copy per (user, namespace, key) — not per-task
2402
+ // snapshots. The client is the only writer (batch upsert, last-write-wins
2403
+ // on updated_at); fire-time hooks are the reader. `value` holds
2404
+ // encryptForStorage ciphertext. `updated_at` is a caller-supplied epoch-ms
2405
+ // INTEGER (unlike scheduled_messages' ISO TEXT) so conflict resolution
2406
+ // compares without parsing. Single-user/SQLite only — no Postgres mirror.
2407
+ const CLIENT_STATE_TABLE_SQL = `
2408
+ CREATE TABLE IF NOT EXISTS client_state (
2409
+ user_id TEXT NOT NULL,
2410
+ namespace TEXT NOT NULL,
2411
+ key TEXT NOT NULL,
2412
+ value TEXT NOT NULL,
2413
+ updated_at INTEGER NOT NULL,
2414
+ PRIMARY KEY (user_id, namespace, key)
2415
+ )
2416
+ `;
2417
+
1927
2418
  /**
1928
2419
  * Cloudflare D1 (SQLite) Database Adapter.
1929
2420
  *
@@ -1943,6 +2434,11 @@ const UPDATABLE_COLUMNS = new Set([
1943
2434
  'next_send_at', 'status', 'retry_count', 'created_at', 'updated_at'
1944
2435
  ]);
1945
2436
 
2437
+ // LIKE 前缀转义:用户 key 里的 % _ \ 不能变成通配符/转义符。
2438
+ function escapeLikePrefix(prefix) {
2439
+ return prefix.replace(/[\\%_]/g, (ch) => `\\${ch}`);
2440
+ }
2441
+
1946
2442
  class D1Adapter {
1947
2443
  /** @param {{ prepare: (sql: string) => any }} db - Cloudflare D1 binding */
1948
2444
  constructor(db) {
@@ -1966,6 +2462,7 @@ class D1Adapter {
1966
2462
 
1967
2463
  async initSchema() {
1968
2464
  await this._db.prepare(SQLITE_TABLE_SQL).run();
2465
+ await this._db.prepare(CLIENT_STATE_TABLE_SQL).run();
1969
2466
 
1970
2467
  const indexResults = [];
1971
2468
  for (const index of SQLITE_INDEXES) {
@@ -1997,6 +2494,7 @@ class D1Adapter {
1997
2494
 
1998
2495
  async dropSchema() {
1999
2496
  await this._db.prepare('DROP TABLE IF EXISTS scheduled_messages').run();
2497
+ await this._db.prepare('DROP TABLE IF EXISTS client_state').run();
2000
2498
  }
2001
2499
 
2002
2500
  async createTask(params) {
@@ -2145,6 +2643,98 @@ class D1Adapter {
2145
2643
  ).bind(uuid, userId).first();
2146
2644
  return row ? row.status : null;
2147
2645
  }
2646
+
2647
+ // ── client_state (single-user cloud state mirror) ──────────────────────
2648
+
2649
+ /**
2650
+ * Batch upsert. Last-write-wins per (namespace, key): an entry older
2651
+ * than the stored row (updatedAt strictly lower) is skipped; equal or
2652
+ * newer overwrites. Values arrive pre-encrypted (the handler encrypts).
2653
+ *
2654
+ * `cleanups` 是分块存储的清理项(见 lib/state-chunks.js):在同一 batch 里
2655
+ * 先于 upsert 执行,按 (namespace, key 前缀) 删掉旧写入留下的切片行;
2656
+ * `updated_at <= ?` 条件保证陈旧批次删不动更新写入的行。
2657
+ *
2658
+ * Uses D1's batch() — one network round trip for the whole set (implicit
2659
+ * transaction). The client calls this endpoint inside its few-seconds
2660
+ * background window, so N sequential round trips could eat the whole
2661
+ * window. Bindings without batch() (e.g. the sqlite test shim, custom
2662
+ * adapters) fall back to a sequential loop.
2663
+ *
2664
+ * @param {string} userId
2665
+ * @param {Array<{ namespace: string, key: string, value: string, updatedAt: number }>} entries
2666
+ * @param {Array<{ namespace: string, keyPrefix: string, updatedAt: number }>} [cleanups]
2667
+ * @returns {Promise<{ upserted: number, skipped: number, outcomes: boolean[] }>}
2668
+ * `outcomes[i]` 对应 entries[i] 是否真的写入(changes > 0)。
2669
+ */
2670
+ async upsertClientState(userId, entries, cleanups = []) {
2671
+ const UPSERT_SQL =
2672
+ `INSERT INTO client_state (user_id, namespace, key, value, updated_at)
2673
+ VALUES (?, ?, ?, ?, ?)
2674
+ ON CONFLICT (user_id, namespace, key) DO UPDATE SET
2675
+ value = excluded.value,
2676
+ updated_at = excluded.updated_at
2677
+ WHERE excluded.updated_at >= client_state.updated_at`;
2678
+ const CLEANUP_SQL =
2679
+ `DELETE FROM client_state
2680
+ WHERE user_id = ? AND namespace = ? AND key LIKE ? ESCAPE '\\' AND updated_at <= ?`;
2681
+
2682
+ const buildStatements = () => [
2683
+ ...cleanups.map((c) =>
2684
+ this._db.prepare(CLEANUP_SQL).bind(userId, c.namespace, `${escapeLikePrefix(c.keyPrefix)}%`, c.updatedAt)
2685
+ ),
2686
+ ...entries.map((entry) =>
2687
+ this._db.prepare(UPSERT_SQL).bind(userId, entry.namespace, entry.key, entry.value, entry.updatedAt)
2688
+ ),
2689
+ ];
2690
+
2691
+ let results;
2692
+ if (typeof this._db.batch === 'function') {
2693
+ results = await this._db.batch(buildStatements());
2694
+ } else {
2695
+ results = [];
2696
+ for (const stmt of buildStatements()) {
2697
+ results.push(await stmt.run());
2698
+ }
2699
+ }
2700
+
2701
+ // cleanup 语句不计数:upserted/skipped/outcomes 只看 entries 对应的语句。
2702
+ const outcomes = results.slice(cleanups.length).map((res) => res.meta.changes > 0);
2703
+ let upserted = 0;
2704
+ let skipped = 0;
2705
+ for (const wrote of outcomes) {
2706
+ if (wrote) upserted++; else skipped++;
2707
+ }
2708
+ return { upserted, skipped, outcomes };
2709
+ }
2710
+
2711
+ /**
2712
+ * All entries of one namespace (values still encrypted).
2713
+ *
2714
+ * @param {string} userId
2715
+ * @param {string} namespace
2716
+ * @returns {Promise<Array<{ namespace: string, key: string, value: string, updated_at: number }>>}
2717
+ */
2718
+ async getClientState(userId, namespace) {
2719
+ const res = await this._db.prepare(
2720
+ `SELECT namespace, key, value, updated_at
2721
+ FROM client_state
2722
+ WHERE user_id = ? AND namespace = ?
2723
+ ORDER BY key ASC`
2724
+ ).bind(userId, namespace).all();
2725
+ return res.results || [];
2726
+ }
2727
+
2728
+ /**
2729
+ * Wipe every entry of this user.
2730
+ *
2731
+ * @param {string} userId
2732
+ * @returns {Promise<number>} rows deleted
2733
+ */
2734
+ async clearClientState(userId) {
2735
+ const res = await this._db.prepare('DELETE FROM client_state WHERE user_id = ?').bind(userId).run();
2736
+ return res.meta.changes || 0;
2737
+ }
2148
2738
  }
2149
2739
 
2150
2740
  /**
@@ -2319,6 +2909,310 @@ function createVapidPublicKeyHandler(ctx) {
2319
2909
  return { GET };
2320
2910
  }
2321
2911
 
2912
+ /**
2913
+ * Handler: client-state
2914
+ *
2915
+ * Cloud mirror of client-side state for the single-user deployment. The
2916
+ * client batch-syncs entries up (PUT) whenever convenient — e.g. in the
2917
+ * few-seconds window before iOS backgrounds the page — and fire-time
2918
+ * hooks read them back via ctx.readState(namespace). One live copy per
2919
+ * (user, namespace, key); the client is the only writer.
2920
+ *
2921
+ * PUT /client-state batch upsert, last-write-wins on updatedAt
2922
+ * GET /client-state?namespace=<ns> one namespace's entries (decrypted, response re-encrypted)
2923
+ * DELETE /client-state wipe every entry of this user
2924
+ *
2925
+ * 单条 value 超过 200KB 时由服务端透明分块(见 lib/state-chunks.js):写入时
2926
+ * 切片跨行存储,GET / readState 返回拼好的原值,客户端与 hook 作者无感。
2927
+ * 单条总上限默认 5MB,工厂配置 maxStateValueBytes 可调。批内某条超限/非法
2928
+ * 只拒它自己:有拒绝时响应带 data.rejected 逐条给原因,全部成功时响应形状
2929
+ * 与单值时代完全一致。
2930
+ *
2931
+ * Auth & crypto follow the existing endpoints exactly: X-Client-Token is
2932
+ * all-or-nothing via resolveTenant, PUT bodies must be encrypted
2933
+ * (X-Payload-Encrypted / X-Encryption-Version), values are stored as
2934
+ * encryptForStorage ciphertext under the per-user key, and GET responses
2935
+ * ride the existing encrypted-response envelope.
2936
+ */
2937
+
2938
+ // "a few dozen entries in one background-window request" is the design
2939
+ // load; 200 bounds a single request with generous headroom.
2940
+ const MAX_STATE_ENTRIES_PER_REQUEST = 200;
2941
+ const MAX_NAMESPACE_CHARS = 128;
2942
+ const MAX_KEY_CHARS = 256;
2943
+
2944
+ const utf8 = new TextEncoder();
2945
+
2946
+ function err(status, code, message, details) {
2947
+ const error = details === undefined ? { code, message } : { code, message, details };
2948
+ return { status, body: { success: false, error } };
2949
+ }
2950
+
2951
+ function requireUserId(headers) {
2952
+ const userId = getHeader(headers, 'x-user-id');
2953
+ if (!userId) return { error: err(400, 'USER_ID_REQUIRED', '缺少用户标识符') };
2954
+ if (!isValidUUIDv4(userId)) return { error: err(400, 'INVALID_USER_ID_FORMAT', 'X-User-Id 必须是 UUID v4 格式') };
2955
+ return { userId };
2956
+ }
2957
+
2958
+ function rejectEntry(entry, index, code, message, extra) {
2959
+ const rejection = { index, code, message, ...(extra || {}) };
2960
+ if (entry && typeof entry === 'object') {
2961
+ if (typeof entry.namespace === 'string') rejection.namespace = entry.namespace;
2962
+ if (typeof entry.key === 'string') rejection.key = entry.key;
2963
+ }
2964
+ return rejection;
2965
+ }
2966
+
2967
+ // 逐条校验:返回 null(合法)或拒绝对象(进 data.rejected,只拒这一条)。
2968
+ function validateEntry(entry, index, maxValueBytes) {
2969
+ if (!isPlainObject(entry)) {
2970
+ return rejectEntry(entry, index, 'INVALID_STATE_ENTRY', `entries[${index}] 必须是对象`);
2971
+ }
2972
+ if (typeof entry.namespace !== 'string' || !entry.namespace.trim() || entry.namespace.length > MAX_NAMESPACE_CHARS) {
2973
+ return rejectEntry(entry, index, 'INVALID_STATE_NAMESPACE', `entries[${index}].namespace 必须是 1-${MAX_NAMESPACE_CHARS} 字符的字符串`);
2974
+ }
2975
+ if (INTERNAL_STATE_CHAR_RE.test(entry.namespace)) {
2976
+ return rejectEntry(entry, index, 'INVALID_STATE_NAMESPACE', `entries[${index}].namespace 不能包含控制字符(\\u0000-\\u001f 为库内部保留)`);
2977
+ }
2978
+ if (typeof entry.key !== 'string' || !entry.key.trim() || entry.key.length > MAX_KEY_CHARS) {
2979
+ return rejectEntry(entry, index, 'INVALID_STATE_KEY', `entries[${index}].key 必须是 1-${MAX_KEY_CHARS} 字符的字符串`);
2980
+ }
2981
+ if (INTERNAL_STATE_CHAR_RE.test(entry.key)) {
2982
+ return rejectEntry(entry, index, 'INVALID_STATE_KEY', `entries[${index}].key 不能包含控制字符(\\u0000-\\u001f 为库内部保留)`);
2983
+ }
2984
+ if (typeof entry.value !== 'string') {
2985
+ return rejectEntry(entry, index, 'INVALID_STATE_VALUE', `entries[${index}].value 必须是字符串(宿主自行序列化)`);
2986
+ }
2987
+ const bytes = utf8.encode(entry.value).length;
2988
+ if (bytes > maxValueBytes) {
2989
+ return rejectEntry(entry, index, 'STATE_VALUE_TOO_LARGE', `entries[${index}].value 超过单条总上限`, { bytes, maxBytes: maxValueBytes });
2990
+ }
2991
+ if (!Number.isInteger(entry.updatedAt) || entry.updatedAt <= 0) {
2992
+ return rejectEntry(entry, index, 'INVALID_STATE_UPDATED_AT', `entries[${index}].updatedAt 必须是正整数(epoch 毫秒)`);
2993
+ }
2994
+ return null;
2995
+ }
2996
+
2997
+ function createClientStateHandler(ctx) {
2998
+ async function PUT(headers, body) {
2999
+ const tenantResult = await ctx.tenantManager.resolveTenant(headers);
3000
+ if (!tenantResult.ok) return tenantResult.error;
3001
+ const { db, masterKey } = tenantResult.context;
3002
+
3003
+ if (getHeader(headers, 'x-payload-encrypted') !== 'true') {
3004
+ return err(400, 'ENCRYPTION_REQUIRED', '请求体必须加密');
3005
+ }
3006
+ const gate = requireUserId(headers);
3007
+ if (gate.error) return gate.error;
3008
+ const { userId } = gate;
3009
+ if (getHeader(headers, 'x-encryption-version') !== '1') {
3010
+ return err(400, 'UNSUPPORTED_ENCRYPTION_VERSION', '加密版本不支持');
3011
+ }
3012
+
3013
+ const parsedBody = parseEncryptedBody(body);
3014
+ if (!parsedBody.ok) return { status: 400, body: { success: false, error: parsedBody.error } };
3015
+
3016
+ const userKey = await deriveUserEncryptionKey(userId, masterKey);
3017
+ let payload;
3018
+ try {
3019
+ payload = await decryptPayload(parsedBody.data, userKey);
3020
+ } catch (error) {
3021
+ if (error instanceof SyntaxError) {
3022
+ return err(400, 'INVALID_PAYLOAD_FORMAT', '解密后的数据不是有效 JSON');
3023
+ }
3024
+ return err(400, 'DECRYPTION_FAILED', '请求体解密失败');
3025
+ }
3026
+ if (!isPlainObject(payload)) return err(400, 'INVALID_PAYLOAD_FORMAT', '解密后的数据必须是 JSON 对象');
3027
+
3028
+ const entries = payload.entries;
3029
+ if (!Array.isArray(entries) || entries.length === 0) {
3030
+ return err(400, 'INVALID_STATE_ENTRIES', 'entries 必须是非空数组');
3031
+ }
3032
+ if (entries.length > MAX_STATE_ENTRIES_PER_REQUEST) {
3033
+ return err(400, 'TOO_MANY_STATE_ENTRIES', `单次最多 ${MAX_STATE_ENTRIES_PER_REQUEST} 条`, { count: entries.length });
3034
+ }
3035
+ const maxValueBytes = Number.isInteger(ctx.maxStateValueBytes) && ctx.maxStateValueBytes > 0
3036
+ ? ctx.maxStateValueBytes
3037
+ : DEFAULT_MAX_STATE_VALUE_BYTES;
3038
+
3039
+ // 逐条校验:坏条目只拒它自己(进 rejected),好条目照常入库。
3040
+ const accepted = [];
3041
+ const rejected = [];
3042
+ for (let i = 0; i < entries.length; i++) {
3043
+ const rejection = validateEntry(entries[i], i, maxValueBytes);
3044
+ if (rejection) rejected.push(rejection); else accepted.push(entries[i]);
3045
+ }
3046
+
3047
+ if (typeof db.upsertClientState !== 'function') {
3048
+ return err(501, 'CLIENT_STATE_NOT_SUPPORTED', '当前数据库适配器不支持 client_state');
3049
+ }
3050
+
3051
+ // 展开成物理行:小值 1 行(历史路径,字节级不变),大值 = 根 marker 行 +
3052
+ // 保留 namespace 里的 N 个加密切片行。每条 accepted 条目都配一条 cleanup
3053
+ // (同一 batch 里先删后写),把这个 key 旧写入留下的切片清干净 —— 覆盖写
3054
+ // 变小 / 缩块都不留尾巴;陈旧批次的 cleanup 因 updated_at 条件删不动新行。
3055
+ const physicalRows = [];
3056
+ const cleanups = [];
3057
+ const rootRowIndexes = [];
3058
+ for (const entry of accepted) {
3059
+ cleanups.push({
3060
+ namespace: chunkNamespaceFor(entry.namespace),
3061
+ keyPrefix: chunkKeyPrefixFor(entry.key),
3062
+ updatedAt: entry.updatedAt,
3063
+ });
3064
+ rootRowIndexes.push(physicalRows.length);
3065
+ if (utf8.encode(entry.value).length <= STATE_CHUNK_SLICE_BYTES) {
3066
+ physicalRows.push({
3067
+ namespace: entry.namespace,
3068
+ key: entry.key,
3069
+ value: await encryptForStorage(entry.value, userKey),
3070
+ updatedAt: entry.updatedAt,
3071
+ });
3072
+ } else {
3073
+ const slices = splitStateValue(entry.value);
3074
+ physicalRows.push({
3075
+ namespace: entry.namespace,
3076
+ key: entry.key,
3077
+ value: buildChunkedRootValue(slices.length),
3078
+ updatedAt: entry.updatedAt,
3079
+ });
3080
+ const encryptedSlices = await Promise.all(slices.map((slice) => encryptForStorage(slice, userKey)));
3081
+ for (let c = 0; c < encryptedSlices.length; c++) {
3082
+ physicalRows.push({
3083
+ namespace: chunkNamespaceFor(entry.namespace),
3084
+ key: chunkKeyFor(entry.key, c),
3085
+ value: encryptedSlices[c],
3086
+ updatedAt: entry.updatedAt,
3087
+ });
3088
+ }
3089
+ }
3090
+ }
3091
+
3092
+ let upserted = 0;
3093
+ let skipped = 0;
3094
+ if (physicalRows.length > 0) {
3095
+ const result = await db.upsertClientState(userId, physicalRows, cleanups);
3096
+ if (Array.isArray(result.outcomes) && result.outcomes.length === physicalRows.length) {
3097
+ // 逻辑计数:一条 entry 的 upserted/skipped 看它的根行(切片行不计数)。
3098
+ for (const rootIndex of rootRowIndexes) {
3099
+ if (result.outcomes[rootIndex]) upserted++; else skipped++;
3100
+ }
3101
+ } else {
3102
+ // 自定义 adapter 只回老形状 { upserted, skipped } 时按物理行计数兜底。
3103
+ upserted = result.upserted;
3104
+ skipped = result.skipped;
3105
+ }
3106
+ }
3107
+
3108
+ const data = { upserted, skipped };
3109
+ if (rejected.length > 0) data.rejected = rejected;
3110
+ return { status: 200, body: { success: true, data } };
3111
+ }
3112
+
3113
+ async function GET(url, headers) {
3114
+ const tenantResult = await ctx.tenantManager.resolveTenant(headers);
3115
+ if (!tenantResult.ok) return tenantResult.error;
3116
+ const { db, masterKey } = tenantResult.context;
3117
+
3118
+ const gate = requireUserId(headers);
3119
+ if (gate.error) return gate.error;
3120
+ const { userId } = gate;
3121
+
3122
+ const namespace = new URL(url, 'https://dummy').searchParams.get('namespace') || '';
3123
+ if (!namespace.trim()) return err(400, 'NAMESPACE_REQUIRED', '必须提供 namespace 查询参数');
3124
+ if (INTERNAL_STATE_CHAR_RE.test(namespace)) {
3125
+ return err(400, 'INVALID_STATE_NAMESPACE', 'namespace 不能包含控制字符(\\u0000-\\u001f 为库内部保留)');
3126
+ }
3127
+
3128
+ if (typeof db.getClientState !== 'function') {
3129
+ return err(501, 'CLIENT_STATE_NOT_SUPPORTED', '当前数据库适配器不支持 client_state');
3130
+ }
3131
+
3132
+ const userKey = await deriveUserEncryptionKey(userId, masterKey);
3133
+ const rows = await db.getClientState(userId, namespace);
3134
+ // 分块存储的值在这里拼回原文;块不齐全的 key 视为不存在(不抛错)。
3135
+ const decrypted = await resolveClientStateEntries(
3136
+ rows,
3137
+ () => db.getClientState(userId, chunkNamespaceFor(namespace)),
3138
+ (value) => decryptFromStorage(value, userKey)
3139
+ );
3140
+
3141
+ const encryptedResponse = await encryptPayload({ namespace, entries: decrypted }, userKey);
3142
+ return { status: 200, body: { success: true, encrypted: true, version: 1, data: encryptedResponse } };
3143
+ }
3144
+
3145
+ async function DELETE(url, headers) {
3146
+ const tenantResult = await ctx.tenantManager.resolveTenant(headers);
3147
+ if (!tenantResult.ok) return tenantResult.error;
3148
+ const { db } = tenantResult.context;
3149
+
3150
+ const gate = requireUserId(headers);
3151
+ if (gate.error) return gate.error;
3152
+ const { userId } = gate;
3153
+
3154
+ if (typeof db.clearClientState !== 'function') {
3155
+ return err(501, 'CLIENT_STATE_NOT_SUPPORTED', '当前数据库适配器不支持 client_state');
3156
+ }
3157
+
3158
+ const deleted = await db.clearClientState(userId);
3159
+ return { status: 200, body: { success: true, data: { deleted } } };
3160
+ }
3161
+
3162
+ return { PUT, GET, DELETE };
3163
+ }
3164
+
3165
+ /**
3166
+ * 构建期注入的包版本。tsup 用 define 把 __AMSG_SERVER_VERSION__ 替换成
3167
+ * package.json 的 version(见 tsup.config.js),发布产物里是真实版本号;
3168
+ * 直接跑 src(node --test / 本地调试)没有这个替换,typeof 守卫落到
3169
+ * '0.0.0-dev'。
3170
+ */
3171
+ /* global __AMSG_SERVER_VERSION__ */
3172
+ const SERVER_VERSION =
3173
+ typeof __AMSG_SERVER_VERSION__ !== 'undefined' ? __AMSG_SERVER_VERSION__ : '0.0.0-dev';
3174
+
3175
+ /**
3176
+ * Handler: capabilities
3177
+ *
3178
+ * GET /capabilities → { success, serverVersion, features }。前端用它做特性
3179
+ * 探测:worker 部署版本落后时,新链路只是「探测不到」(而不是静默失效),
3180
+ * 设置页可以据此提示重新部署 worker。老部署没有这个路由 → 404,客户端 SDK
3181
+ * 的 getCapabilities() 把 404 归一成 null。
3182
+ *
3183
+ * features 表达「这份代码支持什么」,随版本静态演进追加;不反映部署配置——
3184
+ * 例如 'agentic-hooks' 表示该版本认识 fire-time hooks,宿主配没配 hooks 不
3185
+ * 影响它出现。
3186
+ *
3187
+ * 鉴权与 /vapid-public-key 同待遇:走 resolveTenant,配置 serverToken 后同样
3188
+ * 要求 X-Client-Token。
3189
+ */
3190
+
3191
+
3192
+ const SERVER_FEATURES = Object.freeze([
3193
+ 'client-state',
3194
+ 'client-state-chunking',
3195
+ 'client-state-partial-failure',
3196
+ 'agentic-hooks',
3197
+ 'agentic-scratch',
3198
+ 'vapid-public-key',
3199
+ ]);
3200
+
3201
+ function createCapabilitiesHandler(ctx) {
3202
+ async function GET(url, headers) {
3203
+ const effectiveHeaders = headers || url || {};
3204
+ const tenantResult = await ctx.tenantManager.resolveTenant(effectiveHeaders);
3205
+ if (!tenantResult.ok) {
3206
+ return tenantResult.error;
3207
+ }
3208
+ return {
3209
+ status: 200,
3210
+ body: { success: true, serverVersion: SERVER_VERSION, features: [...SERVER_FEATURES] },
3211
+ };
3212
+ }
3213
+ return { GET };
3214
+ }
3215
+
2322
3216
  /**
2323
3217
  * Single-user ReiStandard server assembly.
2324
3218
  *
@@ -2334,6 +3228,12 @@ function createVapidPublicKeyHandler(ctx) {
2334
3228
  * @param {string} [config.serverToken] - optional shared secret (X-Client-Token)
2335
3229
  * @param {{ email?: string, publicKey?: string, privateKey?: string }} [config.vapid]
2336
3230
  * @param {{ sendNotification: function }} [config.webpush] - web-push-compatible sender
3231
+ * @param {Object} [config.hooks] - optional fire-time hooks (see lib/agentic-fire.js):
3232
+ * { onBeforeFire, onLLMOutput, executeToolCalls }. When omitted, AI tasks
3233
+ * replay the schedule-time frozen prompt (legacy behavior, unchanged).
3234
+ * @param {number} [config.maxToolIterations] - factory default LLM-round cap for the agentic loop (default 5).
3235
+ * @param {number} [config.totalTimeoutMs] - factory default wall-time ceiling for the agentic loop (default 240000).
3236
+ * @param {number} [config.maxStateValueBytes] - client_state 单条 value 的总上限(默认 5MB)。超过 200KB 的值由服务端透明分块存储(见 lib/state-chunks.js)。
2337
3237
  * @returns {{ handlers: Object, ctx: Object }}
2338
3238
  */
2339
3239
 
@@ -2356,7 +3256,14 @@ function createSingleUserServer(config) {
2356
3256
  privateKey: vapid.privateKey || ''
2357
3257
  },
2358
3258
  webpush: config.webpush || null,
2359
- tenantManager
3259
+ tenantManager,
3260
+ // Fire-time hooks (optional): the in-server instant path fires through
3261
+ // processMessagesByUuid with this ctx, so instant-type tasks can take
3262
+ // the agentic path too.
3263
+ hooks: config.hooks || null,
3264
+ maxToolIterations: config.maxToolIterations,
3265
+ totalTimeoutMs: config.totalTimeoutMs,
3266
+ maxStateValueBytes: config.maxStateValueBytes
2360
3267
  };
2361
3268
 
2362
3269
  return {
@@ -2368,7 +3275,9 @@ function createSingleUserServer(config) {
2368
3275
  updateMessage: createUpdateMessageHandler(ctx),
2369
3276
  cancelMessage: createCancelMessageHandler(ctx),
2370
3277
  messages: createMessagesHandler(ctx),
2371
- vapidPublicKey: createVapidPublicKeyHandler(ctx)
3278
+ vapidPublicKey: createVapidPublicKeyHandler(ctx),
3279
+ clientState: createClientStateHandler(ctx),
3280
+ capabilities: createCapabilitiesHandler(ctx)
2372
3281
  }
2373
3282
  };
2374
3283
  }
@@ -2389,9 +3298,9 @@ function createSingleUserServer(config) {
2389
3298
 
2390
3299
 
2391
3300
  // RFC 8291 fixed labels (each followed by a NUL byte per HKDF "info" framing).
2392
- const KEY_INFO_PREFIX = utf8('WebPush: info\0');
2393
- const CEK_INFO = utf8('Content-Encoding: aes128gcm\0');
2394
- const NONCE_INFO = utf8('Content-Encoding: nonce\0');
3301
+ const KEY_INFO_PREFIX = utf8$1('WebPush: info\0');
3302
+ const CEK_INFO = utf8$1('Content-Encoding: aes128gcm\0');
3303
+ const NONCE_INFO = utf8$1('Content-Encoding: nonce\0');
2395
3304
 
2396
3305
  const VAPID_DEFAULT_TTL = 60; // seconds — short, matches single-shot instant.
2397
3306
  const VAPID_TOKEN_LIFETIME = 12 * 3600; // 12h — comfortably under the 24h RFC 8292 cap.
@@ -2433,7 +3342,7 @@ async function sendWebPush({ subscription, payload, vapid, ttl, fetch: fetchImpl
2433
3342
  }
2434
3343
 
2435
3344
  const encryptedBody = await encryptPushPayload({
2436
- plaintext: utf8(payload),
3345
+ plaintext: utf8$1(payload),
2437
3346
  uaPublicKey: base64UrlToBytes(subscriptionKeys.p256dh),
2438
3347
  authSecret: base64UrlToBytes(subscriptionKeys.auth),
2439
3348
  });
@@ -2606,7 +3515,7 @@ async function buildVapidJwt({ audience, subject, publicKey, privateKey }) {
2606
3515
  sub: subject,
2607
3516
  });
2608
3517
 
2609
- const signingInput = utf8(`${header}.${payload}`);
3518
+ const signingInput = utf8$1(`${header}.${payload}`);
2610
3519
 
2611
3520
  const pubBytes = base64UrlToBytes(publicKey);
2612
3521
  const privBytes = base64UrlToBytes(privateKey);
@@ -2717,10 +3626,20 @@ function createWebCryptoWebPush(vapid = {}, { ttl = SCHEDULED_DEFAULT_TTL } = {}
2717
3626
  * DELETE /cancel-message → delete
2718
3627
  * GET /vapid-public-key → this worker's VAPID public key (for the frontend's
2719
3628
  * Web Push subscription); 503 if VAPID_PUBLIC_KEY unset
3629
+ * GET /capabilities → { serverVersion, features }(特性探测;老部署无此路由 → 404)
3630
+ * PUT /client-state → batch upsert client state (last-write-wins on updatedAt)
3631
+ * GET /client-state → read one namespace's entries (?namespace=<ns>)
3632
+ * DELETE /client-state → wipe this user's client state
2720
3633
  *
2721
3634
  * CORS is opt-in: pass `cors: { origin }` in the config (a fixed origin, '*', or
2722
3635
  * an (origin) => allowedOrigin function) to answer OPTIONS preflights and echo
2723
3636
  * Access-Control-* on responses. With no `cors` the Worker stays same-origin.
3637
+ *
3638
+ * Fire-time hooks are opt-in too: pass `hooks: { onBeforeFire, onLLMOutput,
3639
+ * executeToolCalls }` (+ optional `maxToolIterations` / `totalTimeoutMs`) in the
3640
+ * config to let scheduled AI tasks assemble their prompt and run a server-side
3641
+ * tool loop at fire time. Omit them and AI tasks replay the schedule-time frozen
3642
+ * prompt exactly as before. See lib/agentic-fire.js.
2724
3643
  */
2725
3644
 
2726
3645
 
@@ -2815,6 +3734,14 @@ function createSingleUserCloudflareWorker(buildConfig) {
2815
3734
  result = await server.handlers.cancelMessage.DELETE(url, headers);
2816
3735
  } else if (method === 'GET' && pathname.endsWith('/vapid-public-key')) {
2817
3736
  result = await server.handlers.vapidPublicKey.GET(url, headers);
3737
+ } else if (method === 'GET' && pathname.endsWith('/capabilities')) {
3738
+ result = await server.handlers.capabilities.GET(url, headers);
3739
+ } else if (method === 'PUT' && pathname.endsWith('/client-state')) {
3740
+ result = await server.handlers.clientState.PUT(headers, await request.text());
3741
+ } else if (method === 'GET' && pathname.endsWith('/client-state')) {
3742
+ result = await server.handlers.clientState.GET(url, headers);
3743
+ } else if (method === 'DELETE' && pathname.endsWith('/client-state')) {
3744
+ result = await server.handlers.clientState.DELETE(url, headers);
2818
3745
  } else {
2819
3746
  result = { status: 404, body: { success: false, error: { code: 'NOT_FOUND', message: 'Unknown route' } } };
2820
3747
  }
@@ -2836,7 +3763,17 @@ function createSingleUserCloudflareWorker(buildConfig) {
2836
3763
  // Swallow tick failures: pending tasks stay pending, so the next cron tick
2837
3764
  // retries them. Logging keeps the failure visible in the tail log.
2838
3765
  try {
2839
- await runScheduledTick({ db: cfg.db, masterKey: cfg.masterKey, vapid, webpush: cfg.webpush });
3766
+ await runScheduledTick({
3767
+ db: cfg.db,
3768
+ masterKey: cfg.masterKey,
3769
+ vapid,
3770
+ webpush: cfg.webpush,
3771
+ // Fire-time hooks (optional; see lib/agentic-fire.js). runScheduledTick
3772
+ // spreads its ctx into processSingleMessage, so these ride along.
3773
+ hooks: cfg.hooks || null,
3774
+ maxToolIterations: cfg.maxToolIterations,
3775
+ totalTimeoutMs: cfg.totalTimeoutMs
3776
+ });
2840
3777
  } catch (error) {
2841
3778
  console.error('[amsg single-user] scheduled(): tick failed:', error && error.message);
2842
3779
  }