@rei-standard/amsg-server 2.6.0-next.3 → 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, extractAssistantMessage, buildSessionContext, assertValidDecision, extractToolCallsFromDecision, 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)
@@ -864,6 +864,145 @@ function normalizeAiApiUrl(apiUrl) {
864
864
  return parsedUrl.toString();
865
865
  }
866
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
+
867
1006
  /**
868
1007
  * Server-side agentic fire loop.
869
1008
  *
@@ -891,6 +1030,11 @@ function normalizeAiApiUrl(apiUrl) {
891
1030
  * pushSubscription / vapid / masterKey (same rationale as instant's
892
1031
  * SessionContext — a console.log(ctx) in a hook must not leak keys).
893
1032
  *
1033
+ * scratch:每次 fire 新建一个普通对象,onBeforeFire 的 fireCtx 与同一次
1034
+ * fire 每轮的 sessionCtx 都持有同一个引用,hook 之间借它传工具上下文,
1035
+ * 不用再自己维护 Map<sessionId, state>。fire 结束(finish / skip-push /
1036
+ * 抛错 / 轮数超限)后随调用栈丢弃;库不读不写、不落库、不打日志。
1037
+ *
894
1038
  * Budget guards, both factory-level (ctx.maxToolIterations /
895
1039
  * ctx.totalTimeoutMs) and per-fire (onBeforeFire may return
896
1040
  * { messages, maxToolIterations?, totalTimeoutMs? } to override for one
@@ -1003,19 +1147,27 @@ async function runAgenticFire({ task, decryptedPayload, userKey, ctx }) {
1003
1147
  }
1004
1148
  if (!ctx.db || typeof ctx.db.getClientState !== 'function') return [];
1005
1149
  const rows = await ctx.db.getClientState(task.user_id, namespace);
1006
- return Promise.all(rows.map(async (row) => ({
1007
- namespace: row.namespace,
1008
- key: row.key,
1009
- value: await decryptFromStorage(row.value, userKey),
1010
- updatedAt: row.updated_at,
1011
- })));
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
+ );
1012
1157
  };
1013
1158
 
1159
+ // 单次 fire 的宿主便签:onBeforeFire 的 fireCtx 和同一次 fire 每轮的
1160
+ // sessionCtx(onLLMOutput / executeToolCalls)拿到同一个对象引用,fire 结束
1161
+ // (finish / skip-push / 抛错 / 轮数超限)随调用栈丢弃。库自己不读不写、
1162
+ // 不落库、不打日志、不跨 fire 共享 —— 重试产生的新 fire 拿到的是新对象。
1163
+ const scratch = {};
1164
+
1014
1165
  const fireCtx = Object.freeze({
1015
1166
  task: buildHookTask(task, decryptedPayload),
1016
1167
  userId: task.user_id,
1017
1168
  readState,
1018
1169
  now: new Date(nowFn()),
1170
+ scratch,
1019
1171
  });
1020
1172
 
1021
1173
  const before = await hooks.onBeforeFire(fireCtx);
@@ -1063,6 +1215,7 @@ async function runAgenticFire({ task, decryptedPayload, userKey, ctx }) {
1063
1215
  avatarUrl: decryptedPayload.avatarUrl || undefined,
1064
1216
  charId: decryptedPayload.charId,
1065
1217
  metadata: decryptedPayload.metadata,
1218
+ scratch,
1066
1219
  });
1067
1220
 
1068
1221
  const decision = await hooks.onLLMOutput(sessionCtx);
@@ -2281,6 +2434,11 @@ const UPDATABLE_COLUMNS = new Set([
2281
2434
  'next_send_at', 'status', 'retry_count', 'created_at', 'updated_at'
2282
2435
  ]);
2283
2436
 
2437
+ // LIKE 前缀转义:用户 key 里的 % _ \ 不能变成通配符/转义符。
2438
+ function escapeLikePrefix(prefix) {
2439
+ return prefix.replace(/[\\%_]/g, (ch) => `\\${ch}`);
2440
+ }
2441
+
2284
2442
  class D1Adapter {
2285
2443
  /** @param {{ prepare: (sql: string) => any }} db - Cloudflare D1 binding */
2286
2444
  constructor(db) {
@@ -2493,6 +2651,10 @@ class D1Adapter {
2493
2651
  * than the stored row (updatedAt strictly lower) is skipped; equal or
2494
2652
  * newer overwrites. Values arrive pre-encrypted (the handler encrypts).
2495
2653
  *
2654
+ * `cleanups` 是分块存储的清理项(见 lib/state-chunks.js):在同一 batch 里
2655
+ * 先于 upsert 执行,按 (namespace, key 前缀) 删掉旧写入留下的切片行;
2656
+ * `updated_at <= ?` 条件保证陈旧批次删不动更新写入的行。
2657
+ *
2496
2658
  * Uses D1's batch() — one network round trip for the whole set (implicit
2497
2659
  * transaction). The client calls this endpoint inside its few-seconds
2498
2660
  * background window, so N sequential round trips could eat the whole
@@ -2501,9 +2663,11 @@ class D1Adapter {
2501
2663
  *
2502
2664
  * @param {string} userId
2503
2665
  * @param {Array<{ namespace: string, key: string, value: string, updatedAt: number }>} entries
2504
- * @returns {Promise<{ upserted: number, skipped: number }>}
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)。
2505
2669
  */
2506
- async upsertClientState(userId, entries) {
2670
+ async upsertClientState(userId, entries, cleanups = []) {
2507
2671
  const UPSERT_SQL =
2508
2672
  `INSERT INTO client_state (user_id, namespace, key, value, updated_at)
2509
2673
  VALUES (?, ?, ?, ?, ?)
@@ -2511,28 +2675,37 @@ class D1Adapter {
2511
2675
  value = excluded.value,
2512
2676
  updated_at = excluded.updated_at
2513
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
+ ];
2514
2690
 
2515
2691
  let results;
2516
2692
  if (typeof this._db.batch === 'function') {
2517
- const statements = entries.map((entry) =>
2518
- this._db.prepare(UPSERT_SQL).bind(userId, entry.namespace, entry.key, entry.value, entry.updatedAt)
2519
- );
2520
- results = await this._db.batch(statements);
2693
+ results = await this._db.batch(buildStatements());
2521
2694
  } else {
2522
2695
  results = [];
2523
- for (const entry of entries) {
2524
- results.push(
2525
- await this._db.prepare(UPSERT_SQL).bind(userId, entry.namespace, entry.key, entry.value, entry.updatedAt).run()
2526
- );
2696
+ for (const stmt of buildStatements()) {
2697
+ results.push(await stmt.run());
2527
2698
  }
2528
2699
  }
2529
2700
 
2701
+ // cleanup 语句不计数:upserted/skipped/outcomes 只看 entries 对应的语句。
2702
+ const outcomes = results.slice(cleanups.length).map((res) => res.meta.changes > 0);
2530
2703
  let upserted = 0;
2531
2704
  let skipped = 0;
2532
- for (const res of results) {
2533
- if (res.meta.changes > 0) upserted++; else skipped++;
2705
+ for (const wrote of outcomes) {
2706
+ if (wrote) upserted++; else skipped++;
2534
2707
  }
2535
- return { upserted, skipped };
2708
+ return { upserted, skipped, outcomes };
2536
2709
  }
2537
2710
 
2538
2711
  /**
@@ -2749,6 +2922,12 @@ function createVapidPublicKeyHandler(ctx) {
2749
2922
  * GET /client-state?namespace=<ns> one namespace's entries (decrypted, response re-encrypted)
2750
2923
  * DELETE /client-state wipe every entry of this user
2751
2924
  *
2925
+ * 单条 value 超过 200KB 时由服务端透明分块(见 lib/state-chunks.js):写入时
2926
+ * 切片跨行存储,GET / readState 返回拼好的原值,客户端与 hook 作者无感。
2927
+ * 单条总上限默认 5MB,工厂配置 maxStateValueBytes 可调。批内某条超限/非法
2928
+ * 只拒它自己:有拒绝时响应带 data.rejected 逐条给原因,全部成功时响应形状
2929
+ * 与单值时代完全一致。
2930
+ *
2752
2931
  * Auth & crypto follow the existing endpoints exactly: X-Client-Token is
2753
2932
  * all-or-nothing via resolveTenant, PUT bodies must be encrypted
2754
2933
  * (X-Payload-Encrypted / X-Encryption-Version), values are stored as
@@ -2756,11 +2935,6 @@ function createVapidPublicKeyHandler(ctx) {
2756
2935
  * ride the existing encrypted-response envelope.
2757
2936
  */
2758
2937
 
2759
-
2760
- // One value may hold a serialized state chunk, but must stay well under
2761
- // D1's per-row limits — reject early with a clear error instead of an
2762
- // opaque DB failure.
2763
- const MAX_STATE_VALUE_BYTES = 200 * 1024;
2764
2938
  // "a few dozen entries in one background-window request" is the design
2765
2939
  // load; 200 bounds a single request with generous headroom.
2766
2940
  const MAX_STATE_ENTRIES_PER_REQUEST = 200;
@@ -2781,23 +2955,41 @@ function requireUserId(headers) {
2781
2955
  return { userId };
2782
2956
  }
2783
2957
 
2784
- function validateEntry(entry, index) {
2785
- if (!isPlainObject(entry)) return err(400, 'INVALID_STATE_ENTRY', `entries[${index}] 必须是对象`);
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
+ }
2786
2972
  if (typeof entry.namespace !== 'string' || !entry.namespace.trim() || entry.namespace.length > MAX_NAMESPACE_CHARS) {
2787
- return err(400, 'INVALID_STATE_NAMESPACE', `entries[${index}].namespace 必须是 1-${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 为库内部保留)`);
2788
2977
  }
2789
2978
  if (typeof entry.key !== 'string' || !entry.key.trim() || entry.key.length > MAX_KEY_CHARS) {
2790
- return err(400, 'INVALID_STATE_KEY', `entries[${index}].key 必须是 1-${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 为库内部保留)`);
2791
2983
  }
2792
2984
  if (typeof entry.value !== 'string') {
2793
- return err(400, 'INVALID_STATE_VALUE', `entries[${index}].value 必须是字符串(宿主自行序列化)`);
2985
+ return rejectEntry(entry, index, 'INVALID_STATE_VALUE', `entries[${index}].value 必须是字符串(宿主自行序列化)`);
2794
2986
  }
2795
2987
  const bytes = utf8.encode(entry.value).length;
2796
- if (bytes > MAX_STATE_VALUE_BYTES) {
2797
- return err(413, 'STATE_VALUE_TOO_LARGE', `entries[${index}].value 超过单条上限`, { index, key: entry.key, bytes, maxBytes: MAX_STATE_VALUE_BYTES });
2988
+ if (bytes > maxValueBytes) {
2989
+ return rejectEntry(entry, index, 'STATE_VALUE_TOO_LARGE', `entries[${index}].value 超过单条总上限`, { bytes, maxBytes: maxValueBytes });
2798
2990
  }
2799
2991
  if (!Number.isInteger(entry.updatedAt) || entry.updatedAt <= 0) {
2800
- return err(400, 'INVALID_STATE_UPDATED_AT', `entries[${index}].updatedAt 必须是正整数(epoch 毫秒)`);
2992
+ return rejectEntry(entry, index, 'INVALID_STATE_UPDATED_AT', `entries[${index}].updatedAt 必须是正整数(epoch 毫秒)`);
2801
2993
  }
2802
2994
  return null;
2803
2995
  }
@@ -2840,24 +3032,82 @@ function createClientStateHandler(ctx) {
2840
3032
  if (entries.length > MAX_STATE_ENTRIES_PER_REQUEST) {
2841
3033
  return err(400, 'TOO_MANY_STATE_ENTRIES', `单次最多 ${MAX_STATE_ENTRIES_PER_REQUEST} 条`, { count: entries.length });
2842
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 = [];
2843
3042
  for (let i = 0; i < entries.length; i++) {
2844
- const invalid = validateEntry(entries[i], i);
2845
- if (invalid) return invalid;
3043
+ const rejection = validateEntry(entries[i], i, maxValueBytes);
3044
+ if (rejection) rejected.push(rejection); else accepted.push(entries[i]);
2846
3045
  }
2847
3046
 
2848
3047
  if (typeof db.upsertClientState !== 'function') {
2849
3048
  return err(501, 'CLIENT_STATE_NOT_SUPPORTED', '当前数据库适配器不支持 client_state');
2850
3049
  }
2851
3050
 
2852
- const encryptedEntries = await Promise.all(entries.map(async (entry) => ({
2853
- namespace: entry.namespace,
2854
- key: entry.key,
2855
- value: await encryptForStorage(entry.value, userKey),
2856
- updatedAt: entry.updatedAt,
2857
- })));
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
+ }
2858
3107
 
2859
- const { upserted, skipped } = await db.upsertClientState(userId, encryptedEntries);
2860
- return { status: 200, body: { success: true, data: { upserted, skipped } } };
3108
+ const data = { upserted, skipped };
3109
+ if (rejected.length > 0) data.rejected = rejected;
3110
+ return { status: 200, body: { success: true, data } };
2861
3111
  }
2862
3112
 
2863
3113
  async function GET(url, headers) {
@@ -2871,6 +3121,9 @@ function createClientStateHandler(ctx) {
2871
3121
 
2872
3122
  const namespace = new URL(url, 'https://dummy').searchParams.get('namespace') || '';
2873
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
+ }
2874
3127
 
2875
3128
  if (typeof db.getClientState !== 'function') {
2876
3129
  return err(501, 'CLIENT_STATE_NOT_SUPPORTED', '当前数据库适配器不支持 client_state');
@@ -2878,12 +3131,12 @@ function createClientStateHandler(ctx) {
2878
3131
 
2879
3132
  const userKey = await deriveUserEncryptionKey(userId, masterKey);
2880
3133
  const rows = await db.getClientState(userId, namespace);
2881
- const decrypted = await Promise.all(rows.map(async (row) => ({
2882
- namespace: row.namespace,
2883
- key: row.key,
2884
- value: await decryptFromStorage(row.value, userKey),
2885
- updatedAt: row.updated_at,
2886
- })));
3134
+ // 分块存储的值在这里拼回原文;块不齐全的 key 视为不存在(不抛错)。
3135
+ const decrypted = await resolveClientStateEntries(
3136
+ rows,
3137
+ () => db.getClientState(userId, chunkNamespaceFor(namespace)),
3138
+ (value) => decryptFromStorage(value, userKey)
3139
+ );
2887
3140
 
2888
3141
  const encryptedResponse = await encryptPayload({ namespace, entries: decrypted }, userKey);
2889
3142
  return { status: 200, body: { success: true, encrypted: true, version: 1, data: encryptedResponse } };
@@ -2909,6 +3162,57 @@ function createClientStateHandler(ctx) {
2909
3162
  return { PUT, GET, DELETE };
2910
3163
  }
2911
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
+
2912
3216
  /**
2913
3217
  * Single-user ReiStandard server assembly.
2914
3218
  *
@@ -2929,6 +3233,7 @@ function createClientStateHandler(ctx) {
2929
3233
  * replay the schedule-time frozen prompt (legacy behavior, unchanged).
2930
3234
  * @param {number} [config.maxToolIterations] - factory default LLM-round cap for the agentic loop (default 5).
2931
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)。
2932
3237
  * @returns {{ handlers: Object, ctx: Object }}
2933
3238
  */
2934
3239
 
@@ -2957,7 +3262,8 @@ function createSingleUserServer(config) {
2957
3262
  // the agentic path too.
2958
3263
  hooks: config.hooks || null,
2959
3264
  maxToolIterations: config.maxToolIterations,
2960
- totalTimeoutMs: config.totalTimeoutMs
3265
+ totalTimeoutMs: config.totalTimeoutMs,
3266
+ maxStateValueBytes: config.maxStateValueBytes
2961
3267
  };
2962
3268
 
2963
3269
  return {
@@ -2970,7 +3276,8 @@ function createSingleUserServer(config) {
2970
3276
  cancelMessage: createCancelMessageHandler(ctx),
2971
3277
  messages: createMessagesHandler(ctx),
2972
3278
  vapidPublicKey: createVapidPublicKeyHandler(ctx),
2973
- clientState: createClientStateHandler(ctx)
3279
+ clientState: createClientStateHandler(ctx),
3280
+ capabilities: createCapabilitiesHandler(ctx)
2974
3281
  }
2975
3282
  };
2976
3283
  }
@@ -3319,6 +3626,7 @@ function createWebCryptoWebPush(vapid = {}, { ttl = SCHEDULED_DEFAULT_TTL } = {}
3319
3626
  * DELETE /cancel-message → delete
3320
3627
  * GET /vapid-public-key → this worker's VAPID public key (for the frontend's
3321
3628
  * Web Push subscription); 503 if VAPID_PUBLIC_KEY unset
3629
+ * GET /capabilities → { serverVersion, features }(特性探测;老部署无此路由 → 404)
3322
3630
  * PUT /client-state → batch upsert client state (last-write-wins on updatedAt)
3323
3631
  * GET /client-state → read one namespace's entries (?namespace=<ns>)
3324
3632
  * DELETE /client-state → wipe this user's client state
@@ -3426,6 +3734,8 @@ function createSingleUserCloudflareWorker(buildConfig) {
3426
3734
  result = await server.handlers.cancelMessage.DELETE(url, headers);
3427
3735
  } else if (method === 'GET' && pathname.endsWith('/vapid-public-key')) {
3428
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);
3429
3739
  } else if (method === 'PUT' && pathname.endsWith('/client-state')) {
3430
3740
  result = await server.handlers.clientState.PUT(headers, await request.text());
3431
3741
  } else if (method === 'GET' && pathname.endsWith('/client-state')) {
@@ -8,7 +8,7 @@
8
8
 
9
9
 
10
10
 
11
- var _chunkL7WMM33Qcjs = require('./chunk-L7WMM33Q.cjs');
11
+ var _chunkREX5MCEZcjs = require('./chunk-REX5MCEZ.cjs');
12
12
 
13
13
 
14
14
 
@@ -19,4 +19,4 @@ var _chunkL7WMM33Qcjs = require('./chunk-L7WMM33Q.cjs');
19
19
 
20
20
 
21
21
 
22
- exports.createD1Adapter = _chunkL7WMM33Qcjs.createD1Adapter; exports.createSingleUserCloudflareWorker = _chunkL7WMM33Qcjs.createSingleUserCloudflareWorker; exports.createSingleUserServer = _chunkL7WMM33Qcjs.createSingleUserServer; exports.createWebCryptoWebPush = _chunkL7WMM33Qcjs.createWebCryptoWebPush; exports.decryptFromStorage = _chunkL7WMM33Qcjs.decryptFromStorage; exports.decryptPayload = _chunkL7WMM33Qcjs.decryptPayload; exports.deriveUserEncryptionKey = _chunkL7WMM33Qcjs.deriveUserEncryptionKey; exports.encryptForStorage = _chunkL7WMM33Qcjs.encryptForStorage; exports.runScheduledTick = _chunkL7WMM33Qcjs.runScheduledTick;
22
+ exports.createD1Adapter = _chunkREX5MCEZcjs.createD1Adapter; exports.createSingleUserCloudflareWorker = _chunkREX5MCEZcjs.createSingleUserCloudflareWorker; exports.createSingleUserServer = _chunkREX5MCEZcjs.createSingleUserServer; exports.createWebCryptoWebPush = _chunkREX5MCEZcjs.createWebCryptoWebPush; exports.decryptFromStorage = _chunkREX5MCEZcjs.decryptFromStorage; exports.decryptPayload = _chunkREX5MCEZcjs.decryptPayload; exports.deriveUserEncryptionKey = _chunkREX5MCEZcjs.deriveUserEncryptionKey; exports.encryptForStorage = _chunkREX5MCEZcjs.encryptForStorage; exports.runScheduledTick = _chunkREX5MCEZcjs.runScheduledTick;
@@ -1,2 +1,2 @@
1
- export { f as createD1Adapter, h as createSingleUserCloudflareWorker, j as createSingleUserServer, k as createWebCryptoWebPush, l as decryptFromStorage, m as decryptPayload, n as deriveUserEncryptionKey, o as encryptForStorage, r as runScheduledTick } from './cloudflare-DlLTdCp0.cjs';
1
+ export { f as createD1Adapter, h as createSingleUserCloudflareWorker, j as createSingleUserServer, k as createWebCryptoWebPush, l as decryptFromStorage, m as decryptPayload, n as deriveUserEncryptionKey, o as encryptForStorage, r as runScheduledTick } from './cloudflare-sVIPkSsf.cjs';
2
2
  import '@rei-standard/amsg-shared';
@@ -1,2 +1,2 @@
1
- export { f as createD1Adapter, h as createSingleUserCloudflareWorker, j as createSingleUserServer, k as createWebCryptoWebPush, l as decryptFromStorage, m as decryptPayload, n as deriveUserEncryptionKey, o as encryptForStorage, r as runScheduledTick } from './cloudflare-DlLTdCp0.js';
1
+ export { f as createD1Adapter, h as createSingleUserCloudflareWorker, j as createSingleUserServer, k as createWebCryptoWebPush, l as decryptFromStorage, m as decryptPayload, n as deriveUserEncryptionKey, o as encryptForStorage, r as runScheduledTick } from './cloudflare-sVIPkSsf.js';
2
2
  import '@rei-standard/amsg-shared';
@@ -8,7 +8,7 @@ import {
8
8
  deriveUserEncryptionKey,
9
9
  encryptForStorage,
10
10
  runScheduledTick
11
- } from "./chunk-PPPWETND.mjs";
11
+ } from "./chunk-OVGQHTSE.mjs";
12
12
  export {
13
13
  createD1Adapter,
14
14
  createSingleUserCloudflareWorker,