@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.
- package/dist/{chunk-PVNLB6TF.mjs → chunk-OVGQHTSE.mjs} +701 -89
- package/dist/{chunk-GN5QTMCL.cjs → chunk-REX5MCEZ.cjs} +701 -89
- package/dist/{cloudflare-B-E51VjP.d.cts → cloudflare-sVIPkSsf.d.cts} +1179 -242
- package/dist/{cloudflare-B-E51VjP.d.ts → cloudflare-sVIPkSsf.d.ts} +1179 -242
- package/dist/cloudflare.cjs +2 -2
- package/dist/cloudflare.d.cts +1 -1
- package/dist/cloudflare.d.ts +1 -1
- package/dist/cloudflare.mjs +1 -1
- package/dist/index.cjs +11 -11
- package/dist/index.d.cts +2 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.mjs +1 -1
- package/package.json +2 -2
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
1106
|
-
|
|
1537
|
+
} catch (error) {
|
|
1538
|
+
return { success: false, messagesSent: 0, error: error.message };
|
|
1539
|
+
}
|
|
1107
1540
|
}
|
|
1108
1541
|
|
|
1109
1542
|
/**
|
|
1110
|
-
*
|
|
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}
|
|
1119
|
-
* @
|
|
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
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
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
|
-
|
|
1131
|
-
|
|
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
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
);
|
|
1139
|
-
}
|
|
1576
|
+
return {
|
|
1577
|
+
success: false,
|
|
1578
|
+
error: { code: 'INTERNAL_ERROR', message: error.message, retriesAttempted: retryCount }
|
|
1579
|
+
};
|
|
1580
|
+
}
|
|
1140
1581
|
|
|
1141
|
-
|
|
1582
|
+
if (!task) {
|
|
1583
|
+
return { success: false, error: { code: 'TASK_NOT_FOUND', message: '任务不存在或已处理' } };
|
|
1584
|
+
}
|
|
1142
1585
|
|
|
1143
|
-
|
|
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
|
-
|
|
1154
|
-
|
|
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({
|
|
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
|
}
|