@nightpainters/dsh-ling 1.5.2 → 1.6.0

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/lib/client.js CHANGED
@@ -27,6 +27,67 @@
27
27
 
28
28
  var API = '/api/dsh-ling';
29
29
 
30
+ // ---------------- 导入切批(纯函数,无副作用) ----------------
31
+ // S9(2026-09-29 导入批失败 4MiB):服务端 readBody() 按【字节】封顶 4MiB
32
+ // (lib/host/api.js:139),而旧客户端按【条数】切批(batch = 200)⇒ 两端判据不同单位:
33
+ // 186 条 / 4.89MB 被打成整批一发 ⇒ 超限 ⇒ 连接被 reset ⇒ 界面错报「批失败(网关)」。
34
+ // 现改为【条数 × 字节】双限:字节上限留 1MiB 余量给数组封套与 JSON 转义。
35
+ // ⚠️ 这两个函数必须留在 factory 作用域(不能塞进 bGo.onclick):V1 用例经
36
+ // tests/vm-light.mjs 的 contextify 加载本文件后,从 exports.__test__ 取名直调。
37
+ var BATCH_MAX_BYTES = 3 * 1024 * 1024; // 服务端 4 MiB;留 1 MiB 余量给封套与转义
38
+ var BATCH_MAX_ITEMS = 200; // 保留旧条数上限(小条目时行为与旧版一致)
39
+ // 精确 UTF-8 字节数。⚠️ 不许用 s.length 冒充(CJK 3 字节、emoji 代理对 4 字节),
40
+ // 也不许依赖 TextEncoder/TextDecoder —— 本文件要能在测试沙箱
41
+ // (tests/vm-light.mjs 的白名单式 sandbox,无 TextEncoder)里跑。
42
+ function utf8Len(s) {
43
+ var t = s === null || s === undefined ? '' : String(s);
44
+ var n = 0;
45
+ for (var i = 0; i < t.length; i++) {
46
+ var c = t.charCodeAt(i);
47
+ if (c < 0x80) n += 1;
48
+ else if (c < 0x800) n += 2;
49
+ else if (c >= 0xD800 && c <= 0xDBFF && i + 1 < t.length) {
50
+ var d = t.charCodeAt(i + 1);
51
+ if (d >= 0xDC00 && d <= 0xDFFF) { n += 4; i += 1; } // 代理对(emoji 等)
52
+ else n += 3; // 落单高代理 ⇒ U+FFFD(3 字节)
53
+ } else n += 3; // BMP 3 字节 / 落单低代理 ⇒ U+FFFD
54
+ }
55
+ return n;
56
+ }
57
+ // 字节 ⇒ 人读口径(C2 进度文案用)
58
+ function fmtBytes(n) {
59
+ var b = Number(n) || 0;
60
+ if (b < 1024 * 1024) return (b / 1024).toFixed(1) + ' KB';
61
+ return (b / 1024 / 1024).toFixed(1) + ' MB';
62
+ }
63
+ /** 服务端**回执里的**单次上限 ⇒ 人话(B / 2026-09-29:413 文案要说清"上限是多少")。
64
+ * ⚠️ 数字**只许来自回执**(`r.limit`):客户端不镜像服务端的那个常量(C5 / C-1 的同一条命门 ——
65
+ * 两处各写一个"4 MB",将来服务端改上限时必漂移);回执没带就如实说"没给",绝不猜一个数顶上。 */
66
+ function fmtLimitMB(limit) {
67
+ var n = Number(limit);
68
+ if (!(n > 0)) return '上限未随回执返回';
69
+ return (Math.round(n / 1024 / 1024 * 10) / 10) + ' MB';
70
+ }
71
+ // 从 from 起切一批 ⇒ { list, next, bytes }。bytes 口径 = JSON.stringify(每条)的
72
+ // UTF-8 字节 + 1(数组逗号),外加 2(方括号)—— 与契约一致(偏保守:末条逗号也计入)。
73
+ // 前进性硬保证:首条即使自己就超 BATCH_MAX_BYTES 也单独成批,绝不返回空 list
74
+ // (空批 ⇒ 调用方 idx 不前进 ⇒ 死循环)。
75
+ function takeChunk(items, from) {
76
+ var list = [];
77
+ var bytes = 2; // 数组封套 []
78
+ var i = from;
79
+ while (i < items.length && list.length < BATCH_MAX_ITEMS) {
80
+ var n = 0;
81
+ try { n = utf8Len(JSON.stringify(items[i])); } catch (e) { n = 0; } // 循环引用等:交给服务端拒
82
+ var cost = n + 1; // 数组逗号
83
+ if (list.length && bytes + cost > BATCH_MAX_BYTES) break;
84
+ list.push(items[i]);
85
+ bytes += cost;
86
+ i += 1;
87
+ }
88
+ return { list: list, next: i, bytes: bytes };
89
+ }
90
+
30
91
  // ---------------- 第三人称代词(跟随 persona.pronoun) ----------------
31
92
  // 界面文案里写死的「她」全部经 she() 出口 —— 用户改成「他 / TA / 自定义」后文案随之改变;
32
93
  // **只用于文案**,绝不用于用户原话或记忆数据(那会把数据改错)。
@@ -101,13 +162,49 @@
101
162
  return parts.length ? '?' + parts.join('&') : '';
102
163
  }
103
164
 
165
+ // ── 手术门 · 本机通行票据(2026-09-30 深夜;规格 §1.2 的客户端那一半)──────────────
166
+ // 宿主每启动轮换一枚 32 字节票据;**只有本机直连**拿得到(带代理头的请求会被拒)。
167
+ // 这一半只做"取一次 + 缓存 + 随手术请求带上",**不动任何弹窗与文案结构**(Phase 1 的边界)。
168
+ // 取不到(经反代打开的页面 / 旧宿主)= 空串 ⇒ 手术请求照旧发,由宿主那边给出明确拒绝。
169
+ var surgeryTicket = '';
170
+ var surgeryTicketTried = false;
171
+ function fetchSurgeryTicket() {
172
+ surgeryTicketTried = true; // 无论成败都只试一次(失败不重试,免得把拒绝变成刷屏)
173
+ return fetch(API + '/surgery/ticket', { method: 'POST', headers: { 'content-type': 'application/json' } })
174
+ .then(function (r) { return r.json().catch(function () { return null; }); })
175
+ .then(function (j) {
176
+ if (j && j.ok && typeof j.ticket === 'string' && j.ticket) { surgeryTicket = j.ticket; return surgeryTicket; }
177
+ return ''; // 远端 / 未定型:没有票,照常走后面的拒绝路径
178
+ })
179
+ .catch(function () { return ''; });
180
+ }
181
+
104
182
  function api(path, opts) {
105
183
  opts = opts || {};
184
+ var headers = { 'content-type': 'application/json' };
185
+ if (surgeryTicket) headers['x-dsh-ling-surgery'] = surgeryTicket;
106
186
  return fetch(API + path, {
107
187
  method: opts.method || 'GET',
108
- headers: { 'content-type': 'application/json' },
188
+ headers: headers,
109
189
  body: opts.body ? JSON.stringify(opts.body) : undefined,
110
- }).then(function (res) { return res.json().catch(function () { return null; }); });
190
+ }).then(function (res) {
191
+ return res.json().catch(function () { return null; });
192
+ }).then(function (j) {
193
+ // 宿主说"票据不对/没票"⇒ 现场取一次,原样重发一次(只重发一次,不做循环)。
194
+ // 票据每启动轮换 ⇒ 页面放久了会遇到这一支,那时用户不必手动刷新。
195
+ if (j && j.ok === false && j.surgery === 'surgery-ticket' && !surgeryTicketTried) {
196
+ return fetchSurgeryTicket().then(function (t) {
197
+ if (!t) return j;
198
+ var h2 = { 'content-type': 'application/json', 'x-dsh-ling-surgery': t };
199
+ return fetch(API + path, {
200
+ method: opts.method || 'GET',
201
+ headers: h2,
202
+ body: opts.body ? JSON.stringify(opts.body) : undefined,
203
+ }).then(function (r2) { return r2.json().catch(function () { return null; }); });
204
+ });
205
+ }
206
+ return j;
207
+ });
111
208
  }
112
209
 
113
210
  // ---------------- tiny store ----------------
@@ -314,7 +411,7 @@
314
411
  // 缺陷③ 修复(2026-09-27 真机目视"小字略显拥挤,大字行距是对的"):上一轮我把小字**并进正文那一档**换来统一,
315
412
  // 层级就没了 ⇒ 小字挤。正解 = 行距写**绝对 px**(取旧块的计算行距 20.8px):绝对行距在子元素上继承的是
316
413
  // **计算值**,不随各自字号相乘 ⇒ 字号可以分三档,行距仍恒等 —— "统一"与"层级"同时成立。
317
- // (判据:_probe/check-e3-static.cjs 二c,逐档算实际行距并要求取值唯一、且等于旧块的计算值。)
414
+ // (判据:已由一次性静态探针逐档核算实际行距,要求取值唯一、且等于旧块的计算值。)
318
415
  '.ling-e3{margin:8px 0 2px;padding:8px 10px;border:1px solid var(--lg-border-soft);border-radius:10px;' +
319
416
  'background:var(--lg-rowbg);font-size:13px;line-height:20.8px;}' + // 正文档 13px(同旧块)+ **绝对**行距 = 旧块的计算行距
320
417
  '.ling-e3 .e3-h{font-weight:700;opacity:.95;margin-bottom:3px;}' + // 标题:正文档(继承 13px)
@@ -333,7 +430,7 @@
333
430
 
334
431
  function dark() { return document.documentElement.getAttribute('data-ds-theme') === 'dark' ? '1' : '0'; }
335
432
 
336
- /** 悬停即时小气泡(2026-09-27,用户反馈"太罗嗦 + 窗口叠加"):
433
+ /** 悬停即时小气泡(2026-09-27,尝生反馈"太罗嗦 + 窗口叠加"):
337
434
  * 原生 `title` 由**浏览器**绘制 —— 既不受样式控制,也**不会自动消失**;而悬停 1.5s 会开菜单,
338
435
  * 于是浏览器 tooltip 和菜单叠成两层。故完整提醒改走自绘:悬停瞬间出现、`TIP_MS` 后自动移除,
339
436
  * 且菜单一开就先收掉它 ⇒ 两者永不同屏。与 `toast` 同规格(都是 dsh-ling-menu 容器 + 定时移除),
@@ -390,14 +487,23 @@
390
487
  }
391
488
 
392
489
  /** `ms` 可选:不传时沿用原样 3.2s(既有调用全部不传)。需要长一点的是**覆盖/归档**这类
393
- * "数据已经变了"的回执 —— 3.2s 读不完一句带归档目录的话(审计 E10)。 */
394
- function toast(msg, ms) {
490
+ * "数据已经变了"的回执 —— 3.2s 读不完一句带归档目录的话(审计 E10)。
491
+ *
492
+ * `wrap` 可选(B / 2026-09-29):`.dsh-ling-menu .mi` 是 `white-space:nowrap`(菜单项必须单行),
493
+ * 而**说得清一件事的长文案**在 nowrap 下会顶破 70vw 的盒子、文字溢到屏幕外 —— 那时"文案改好了"
494
+ * 反而读不到。故长文案的调用显式传 `wrap = true`:换成换行渲染 + 更宽的盒子 + 更长的寿命。
495
+ * 不传 = 与既有全部调用逐字同形(短文案本来就只占一行,样式分支根本不进)。 */
496
+ function toast(msg, ms, wrap) {
395
497
  var t = el('div', 'dsh-ling-menu');
396
- t.style.cssText = 'top:16px;left:50%;transform:translateX(-50%);cursor:default;min-width:auto;max-width:70vw;text-align:center;';
498
+ t.style.cssText = 'top:16px;left:50%;transform:translateX(-50%);cursor:default;min-width:auto;max-width:70vw;text-align:center;'
499
+ + (wrap ? 'width:620px;max-width:86vw;' : '');
397
500
  // 后端回执里的 `message` 允许带 `**强调**` 标记(如「**覆盖了上一版主脉记忆**」),
398
501
  // 而 toast 是纯文本容器 ⇒ 显示层去掉标记,别把星号糊给主人。
399
502
  // (非字符串原样交给 el(),不改变任何既有调用的行为:全文件现有 toast 文案里没有 `**`。)
400
- t.appendChild(el('div', 'mi', (typeof msg === 'string') ? msg.replace(/\*\*/g, '') : msg));
503
+ var mi = el('div', 'mi', (typeof msg === 'string') ? msg.replace(/\*\*/g, '') : msg);
504
+ // pre-line:只认 `\n` 换行,不动空格/缩进 —— 文案里手写的分行原样落地
505
+ if (wrap) mi.style.cssText = 'white-space:pre-line;line-height:1.65;text-align:left;align-items:flex-start;';
506
+ t.appendChild(mi);
401
507
  document.body.appendChild(t);
402
508
  setTimeout(function () { t.remove(); }, Number(ms) > 0 ? Number(ms) : 3200);
403
509
  }
@@ -689,6 +795,13 @@
689
795
  // 老后端没有这个字段 ⇒ undefined ⇒ 原样跳过,store 里也就没有 audit(各处一律按缺失处理)。
690
796
  // ⚠️ 刻意**不**把它加进 pollSig:writes/lost 每拍都在变,加进去等于把轮询永久钉在快节拍上。
691
797
  if (r.audit !== undefined) patch.audit = r.audit;
798
+ // E2 可见化(2026-10-01):原文表里出现**未知来源**时,后端在 /state 顶上给一句人话
799
+ // (`rawUnknownSources`,形状见 lib/host/api.js 的 rawUnknownSourcesOf)。
800
+ // 与 audit 同一条路搬进 store ⇒ 面板不必重开也看得见。
801
+ // ⚠️ 与 audit 的 `!== undefined` **不同**:这里显式归零(`|| null`)。audit 是新后端恒有、
802
+ // 老后端恒无;而这个字段**干净时就该消失**,留一个过期的"有未知来源"比不显示更坏。
803
+ // ⚠️ 刻意**不**进 pollSig(同 audit 的理由:它极少变,进去只会把轮询钉在快节拍上)。
804
+ patch.rawUnknownSources = r.rawUnknownSources || null;
692
805
  store.set(patch);
693
806
  }
694
807
 
@@ -911,7 +1024,7 @@
911
1024
  return r || {};
912
1025
  }).catch(function () { toast('切换失败(网关不可达)'); return {}; });
913
1026
  }
914
- // ⚠️ 这里**故意不问一句** —— 用户 2026-09-27 裁决(原话):
1027
+ // ⚠️ 这里**故意不问一句** —— 尝生 2026-09-27 裁决(原话):
915
1028
  // 「创建新会话时只能通过调全局默认来调新会话的模式,所以它的使用比想象的频繁一些」
916
1029
  // ⇒ 全局默认不是危险开关,而是**开一个不同模式的新会话**的必经入口,属高频正常动作;
917
1030
  // 事前弹确认 = 给日常添堵,且那个按钮按下本身就是低成本可撤销动作。
@@ -968,7 +1081,7 @@
968
1081
  wrap.appendChild(h);
969
1082
  var kv = el('div', 'kv', '加载中…');
970
1083
  wrap.appendChild(kv);
971
- // E3(2026-09-27 用户拍板):「那一行」只读块。**必须另起一个节点** —— 上面 `kv` 整块是纯
1084
+ // E3(2026-09-27 尝生拍板):「那一行」只读块。**必须另起一个节点** —— 上面 `kv` 整块是纯
972
1085
  // textContent(一段文字),而「看全文」要一个真能展开/收起的子节点(快照原文放 <pre>,默认收起)。
973
1086
  // 这一块对模型上下文**零占用**:读的全是 `/state` 面板档(`l0Preview=1`)字段,纯读,不进 prompt。
974
1087
  var e3box = el('div', 'ling-e3');
@@ -1110,7 +1223,7 @@
1110
1223
  }
1111
1224
  // 全局视图的 L2 三行**必然**缺席:后端 lib/host/api.js 在 global 分支写死 snapshot: null,
1112
1225
  // 而且整个返回里不带 idleRefresh 字段 ⇒ 上面两段一个字符都进不来。
1113
- // 不写这一行,"按设计没有"与"坏了"在界面上长得一模一样(用户 2026-09-27 反馈)。
1226
+ // 不写这一行,"按设计没有"与"坏了"在界面上长得一模一样(尝生 2026-09-27 反馈)。
1114
1227
  // 放在 L2 本该出现的位置(待应用更新 → 这里 → 人格),两个视图的行序才对得齐。
1115
1228
  if (global && !r.snapshot && !r.idleRefresh) {
1116
1229
  lines.push('(全局视图无会话快照 —— 快照定稿/空闲刷新是按会话统计的,进入某个会话才显示)' + SNAP_TAG);
@@ -1142,6 +1255,15 @@
1142
1255
  // 上面四行印的是 store 那一份(实时);store 里没有(老后端 / 首拍还没回来)才退回打开时那份 ⇒ 标快照。
1143
1256
  if (!auditLive) lines[lines.length - 1] += SNAP_TAG;
1144
1257
  }
1258
+ // E2 可见化(2026-10-01):原文表里出现了**我没见过的来源**时,如实说一句人话。
1259
+ // 后端(api.js rawUnknownSourcesOf)把 kv 里那条留痕翻成**一整句话**放在 `text` 里,
1260
+ // 这里**原样**印出来(前面加一个 ⚠)—— **不在这里拆字段拼第二遍**:两处各拼一遍必然漂,
1261
+ // 而"读数怎么算"的规矩是单一来源(见 guard.test.mjs 的 E2 护栏)。
1262
+ // ⚠️ 没有告警时后端**整个字段都不给** ⇒ 这里一个字符都不渲染(与上面 audit 同一条规矩:
1263
+ // 绝不拿"未知/正常"冒充读数)。老后端同理:字段缺失 = 静默。
1264
+ // ⚠️ 只加这一行:不动任何既有行、不动徽标/布局/文案风格(主人原话"告诉前台到底发生啥了就行")。
1265
+ var ru = cur.rawUnknownSources || r.rawUnknownSources;
1266
+ if (ru && ru.text) lines.push('⚠ ' + ru.text + (cur.rawUnknownSources ? '' : SNAP_TAG));
1145
1267
  // §M:以下一整块(连面板标题里那个自称)**只有打开那一刻那一份** —— 用一行分隔说明覆盖到面板末尾,
1146
1268
  // 比逐行贴标签干净(标题在上面的 h3 里,同属这份应答)。
1147
1269
  lines = lines.concat([
@@ -1353,7 +1475,8 @@
1353
1475
  var sealedNow = false; // 服务端定型状态
1354
1476
  var unlocked = false; // 本次会话承诺已验证(仅存于内存)
1355
1477
  var unlockPhrase = '';
1356
- var sealPhrase = ''; // 定型承诺句明文(服务端回显用;锁只造庄重,不保密)
1478
+ var sealPhrase = ''; // 定型承诺句明文(**服务端只对本机直连回显**;经反代/远端打开时为空)
1479
+ var sealHidden = false; // 服务端明说"这句只在本机显示"⇒ 解锁框走优雅退化文案,而不是报错/空白
1357
1480
 
1358
1481
  function row(label, node) {
1359
1482
  var r = el('div', 'row');
@@ -1788,7 +1911,9 @@
1788
1911
  sealBar.appendChild(bRelock);
1789
1912
  sealBar.appendChild(bUnseal);
1790
1913
  } else {
1791
- st.textContent = she('🌱 她已自由生长中 —— 若需对她做手术,亲手敲出当初的承诺句即可开启(≥10 字;输入框禁粘贴;界面会回显原句)。');
1914
+ // §6.1(a):"界面会回显原句"只在**本机**成立 ⇒ 远端(经反代打开)时补一句说清,而不是让主人以为界面坏了
1915
+ st.textContent = she('🌱 她已自由生长中 —— 若需对她做手术,亲手敲出当初的承诺句即可开启(≥10 字;输入框禁粘贴)。')
1916
+ + (sealHidden ? '承诺句只在本机显示,人格手术也请在本机做。' : '');
1792
1917
  var bUnlock = el('button', null, '🔓 对人格做手术…');
1793
1918
  bUnlock.onclick = doUnlock;
1794
1919
  sealBar.appendChild(st);
@@ -1805,12 +1930,16 @@
1805
1930
  }
1806
1931
  function doUnlock() {
1807
1932
  var hintPhrase = sealPhrase;
1933
+ // §6.1(a) 优雅退化:远端页面拿不到回显 ⇒ 说清"这是渠道问题"并给出可执行的路,
1934
+ // 绝不因此报错或留空(旧文案会把它误说成"旧档案没记录明文"—— 那是另一回事)。
1808
1935
  lingAsk({
1809
1936
  title: '对人格做手术(仪式)',
1810
1937
  body: (hintPhrase
1811
1938
  ? '你定型时写下的承诺句是:\n「' + hintPhrase + '」\n\n手术前请郑重地亲手敲一遍以确认(输入框禁粘贴,逐字敲出)。'
1812
- : '档案已定型,但旧档案未记录明文承诺。输入一句 ≥10 字的新承诺,它将被采纳为定型承诺句并开启本次手术。\n\n若想重新立誓,也请在下面输入你的新承诺。'),
1813
- placeholder: hintPhrase ? '手动敲出上方承诺句…' : '写下新的定型承诺句…',
1939
+ : (sealHidden
1940
+ ? '承诺句只在本机显示 —— 这个页面是经反代/远端打开的,所以看不到提示句。\n\n人格手术也请回到运行器灵的这台电脑上做(远端一律不给)。若你人就在本机,用本机地址(如 127.0.0.1:3080)打开面板即可看到提示句。'
1941
+ : '档案已定型,但旧档案未记录明文承诺。输入一句 ≥10 字的新承诺,它将被采纳为定型承诺句并开启本次手术。\n\n若想重新立誓,也请在下面输入你的新承诺。')),
1942
+ placeholder: hintPhrase ? '手动敲出上方承诺句…' : (sealHidden ? '(远端页面看不到提示句 —— 请回本机操作)' : '写下新的定型承诺句…'),
1814
1943
  minLen: 10, noPaste: true, okLabel: '郑重解锁',
1815
1944
  }).then(function (v) {
1816
1945
  if (!v) return;
@@ -2010,7 +2139,8 @@
2010
2139
  }
2011
2140
  var wasSealed = sealedNow;
2012
2141
  sealedNow = p.sealed === true;
2013
- sealPhrase = p.sealPhrase || '';
2142
+ sealPhrase = p.sealPhrase || ''; // 本机直连才有值;远端为空(见下一行的标注)
2143
+ sealHidden = p.phraseHidden === true; // 服务端明说"这句只在本机显示"(经代理的读面)
2014
2144
  if (!keepSession || (sealedNow && !wasSealed)) { unlocked = false; unlockPhrase = ''; }
2015
2145
  blCount();
2016
2146
  applyLockUI();
@@ -2304,9 +2434,39 @@
2304
2434
  (r.personaApplied ? ' · 人格已覆盖' : ''));
2305
2435
  } else if (r && r.reason === 'sealed') {
2306
2436
  toast('导入失败:人格已定型 — 请先到「人格中心」做手术(输入承诺句)后再覆盖导入;仅导入记忆不受影响');
2307
- } else toast('导入失败:' + JSON.stringify(r || {}).slice(0, 120));
2437
+ } else if (r && r.reason === 'too-large') {
2438
+ // B(2026-09-29,主人原话「文案改成人话,说明清楚发生啥了就行」):旧代码走的是最后那条
2439
+ // 兜底 —— `JSON.stringify(r).slice(0,120)` 把回执原文(含 `"reason":"too-large"` 这种
2440
+ // 机器话)直接糊到 toast 上,看得见字段、看不出"发生了什么、上限是多少、我该干什么"。
2441
+ // 这条文案必须说清三件事,且三件事**各有出处、都不许猜**:
2442
+ // ① 整包太大、**不是包坏了** —— 判据是服务端回的 `reason === 'too-large'`:
2443
+ // 它是**体量**闸门(readBody 的 4 MiB 上限),不是 JSON 解析失败/包结构坏;
2444
+ // ② 上限是多少 —— 判据是回执的 `limit`,用 fmtLimitMB 换算;**绝不写死**(见该函数注释);
2445
+ // ③ 怎么办 —— 换成**不含原文**的包重导:界面「⬇ 导出记忆包」走的 /export 默认
2446
+ // `includeRaw` 不为 1(`exportMemory()` 不带该参数)⇒ 那条路**本来就不含原文**、
2447
+ // 体积小得多,是主人自己能走通的一条路;并如实说明"整包一次发不完"是**当前通道的
2448
+ // 结构限制**(记忆包导入是整包一 POST,没有分批 —— 分批是 1.6 的活,不在这里顺手做)。
2449
+ // ⚠️ 体量报的是**这份文件多大**(f.size,缩进过的 JSON),与 limit(压缩后的请求体口径)不同尺,
2450
+ // 所以只说"这份包 ≈X / 整包超了上限"这个方向性事实,不假装两个数能直接相减。
2451
+ var sizeHint = (f && f.size > 0) ? '(≈' + fmtBytes(f.size) + ')' : '';
2452
+ toast('导入失败:这份包' + sizeHint + '整包超过了服务端单次上限 ' + fmtLimitMB(r.limit) + ' —— 不是包坏了。\n'
2453
+ + '· 「导入记忆包」是把整份文件**一次**发出去,带对话原文的包很容易超过它(当前通道的结构限制:整包一次发不完)。\n'
2454
+ + '· 怎么办:改用「⬇ 导出记忆包」重新导出一份**不含原文**的包(那个按钮默认就不含原文,体积小得多),再导入一次即可 —— 记忆/建议/队列都在,只是不含对话原文。',
2455
+ 12000, true);
2456
+ } else {
2457
+ // 其余失败:优先用服务端给的 `message`(它是一句中文),只有连 message 都没有时
2458
+ // 才退回 JSON 摘要 —— 兜底可以有,但不许当主路(旧代码就是无条件 JSON)。
2459
+ var whyImp = r && (r.message || r.error || r.reason);
2460
+ toast('导入失败:' + (whyImp ? String(whyImp) : JSON.stringify(r || {}).slice(0, 120)));
2461
+ }
2308
2462
  if (st.tab === 'overview') renderOverview();
2309
- }).catch(function () { toast('导入失败(网关不可达)'); });
2463
+ }).catch(function () {
2464
+ // 拿不到回执 ⇒ **不猜**成确定结论(网关真不可达 / 体量大到连接被直接断开,浏览器这一侧
2465
+ // 长得一模一样)⇒ 只说"无从判断" + 一条已知可能 + 一条主人能自己走的路。
2466
+ toast('导入失败(网关不可达):没拿到回执,具体原因无从判断。\n'
2467
+ + '已知的一种可能:这份包太大 —— 数据量大到超出服务端的排空余量时,连接会被直接断开,连说明情况的回执都送不出来。\n'
2468
+ + '可以先改用「⬇ 导出记忆包」重导一份不含原文的小包,再试一次。', 12000, true);
2469
+ });
2310
2470
  } catch (e) {
2311
2471
  toast('文件不是有效的记忆包 JSON');
2312
2472
  }
@@ -2750,7 +2910,7 @@
2750
2910
 
2751
2911
  // ---------------- 记忆树 · 复盘(D9-b,2026-09-19) ----------------
2752
2912
  // 树形结构 + 复盘驱动的操作:并脉(归类) / 连边 / 调权重 / 矛盾裁定。
2753
- // 「一键复盘」按用户定的设计约束(§10.1)把开场材料一次备齐 —— 复盘的门槛必须接近 0,
2913
+ // 「一键复盘」按尝生定的设计约束(§10.1)把开场材料一次备齐 —— 复盘的门槛必须接近 0,
2754
2914
  // 否则它会像"整理房间"一样被无限推迟。树的任何操作都**不改写记忆内容**。
2755
2915
  var TREE_KIND = { trunk: '主干', branch: '枝', vein: '主脉' };
2756
2916
  var TREE_COLOR = { trunk: 'var(--lg-primary)', branch: 'var(--lg-cyan)', vein: '#c58b2f' };
@@ -2813,7 +2973,7 @@
2813
2973
  };
2814
2974
  }
2815
2975
  row.appendChild(tri);
2816
- // 显示层(用户 2026-09-21 定):前端不写运算层级名;主干用一句话表示"还在生长"。
2976
+ // 显示层(尝生 2026-09-21 定):前端不写运算层级名;主干用一句话表示"还在生长"。
2817
2977
  var isTrunk = n.id === ((st.treeData && st.treeData.trunk) || 'trunk');
2818
2978
  var badge = el('span', null, isTrunk ? '🌱 你们的故事正在生长' : (TREE_KIND[n.kind] || n.kind));
2819
2979
  badge.style.cssText = 'font-size:11px;padding:1px 6px;border-radius:6px;flex:none;'
@@ -2827,7 +2987,7 @@
2827
2987
  + (n.weightScale && Number(n.weightScale) !== 1 ? ' · ×' + n.weightScale : ''));
2828
2988
  meta.style.cssText = 'flex:none;opacity:.72;';
2829
2989
  row.appendChild(meta);
2830
- // ---- 连边小标记(用户 2026-09-25) ----
2990
+ // ---- 连边小标记(尝生 2026-09-25) ----
2831
2991
  // 与侧栏记忆树同款:行内只留一个 ⇄N,不在这里铺开"连到哪里" ——
2832
2992
  // 选中本行后,下方"横向连边"区块才展开它的详情。
2833
2993
  var myLinks = (st.linkIndex && st.linkIndex[n.id]) || [];
@@ -2913,7 +3073,7 @@
2913
3073
 
2914
3074
  // ---------------- 一键生成记忆树(autotree,2026-09-20) ----------------
2915
3075
  // 三阶段:① 机械分段(零模型调用) ② 器灵批量命名 ③ 主人改名字后应用。
2916
- // 用户的设计约束:模型只做"命名"这一件语义活;分段是机械活,不烧 token,也不该由模型做。
3076
+ // 尝生的设计约束:模型只做"命名"这一件语义活;分段是机械活,不烧 token,也不该由模型做。
2917
3077
  function loadAutoPreview() {
2918
3078
  if (st.autoBusy) return;
2919
3079
  st.autoBusy = true;
@@ -3046,7 +3206,7 @@
3046
3206
  inp.oninput = function () { st.autoNames[b.id] = inp.value; };
3047
3207
  row.appendChild(inp);
3048
3208
  // 「看内容」:展开这一簇实际包含哪些标题 —— 只给一个名字(如"零散记录")时
3049
- // 主人无从判断该不该采纳(用户实测反馈)。内容来自 /tree/autobuild/preview 的 sample。
3209
+ // 主人无从判断该不该采纳(尝生实测反馈)。内容来自 /tree/autobuild/preview 的 sample。
3050
3210
  var bPeek = el('button', null, st.autoExpand[b.id] ? '▾ 收起' : '▸ 看内容');
3051
3211
  bPeek.type = 'button';
3052
3212
  bPeek.style.cssText = 'flex:none;font-size:11px;padding:1px 7px;';
@@ -3079,7 +3239,7 @@
3079
3239
  var links = td.links || [];
3080
3240
  var conf = st.conflicts || [];
3081
3241
  var pend = conf.filter(function (c) { return c.status === 'pending'; });
3082
- // ---- 连边索引(用户 2026-09-25):节点 id → 挂在它身上的连边 ----
3242
+ // ---- 连边索引(尝生 2026-09-25):节点 id → 挂在它身上的连边 ----
3083
3243
  // 用途两个:① nodeRow 行内画小标记(⇄N),让"这条枝有横向连边"平时可见;
3084
3244
  // ② 下方"横向连边"区块改为**只显示选中项**那几条 —— 原先是无条件铺开全部,
3085
3245
  // 连边一多这块就越来越臃肿,把真正该看的矛盾区挤下去。
@@ -3092,11 +3252,11 @@
3092
3252
  });
3093
3253
 
3094
3254
  var tools = el('div', 'btnrow');
3095
- // 左对齐(用户 2026-09-25):`.btnrow` 的全局 CSS 是 justify-content:flex-end(对"保存/取消"
3255
+ // 左对齐(尝生 2026-09-25):`.btnrow` 的全局 CSS 是 justify-content:flex-end(对"保存/取消"
3096
3256
  // 这类按钮行合适),但树工具条挂在面板左上,右对齐会让它飘到面板另一头,与下方树列表脱节。
3097
3257
  // 就地覆盖,不动全局 class(它被另外 15 处按钮行复用)。
3098
3258
  tools.style.cssText = 'margin:0 0 4px;flex-wrap:wrap;justify-content:flex-start;';
3099
- // 记忆中心已改为**只读视图**(2026-09-22 用户裁决):扫重复 / 模型判定 / 归并零散枝 /
3259
+ // 记忆中心已改为**只读视图**(2026-09-22 尝生裁决):扫重复 / 模型判定 / 归并零散枝 /
3100
3260
  // 解锁编辑等**复盘操作**全部移到右侧「器灵 · 复盘」栏 —— 这里只留「看」与一个总入口。
3101
3261
  var bRefresh = el('button', null, '刷新');
3102
3262
  var bReview = el('button', null, '🌱 一键复盘');
@@ -3117,7 +3277,7 @@
3117
3277
  treeHead.style.cssText = 'margin:2px 0 4px;font-weight:700;opacity:.9;';
3118
3278
  body.appendChild(treeHead);
3119
3279
  var treeBox = el('div', null);
3120
- // 选中/重绘时保持滚动位置(否则每次重建 DOM 都会跳回顶部 —— 用户 2026-09-22 反馈)
3280
+ // 选中/重绘时保持滚动位置(否则每次重建 DOM 都会跳回顶部 —— 尝生 2026-09-22 反馈)
3121
3281
  treeBox.onscroll = function () { st.treeScroll = treeBox.scrollTop; };
3122
3282
  treeBox.style.cssText = 'border:1px solid var(--lg-border);border-radius:11px;padding:6px 8px;'
3123
3283
  + 'max-height:34vh;overflow:auto;';
@@ -3142,7 +3302,7 @@
3142
3302
  : '🔒 树平时只读 —— 进入复盘模式后可修改。(先点一条枝 / 主脉选中它)'));
3143
3303
  body.appendChild(ops);
3144
3304
 
3145
- // ---- 横向连边(用户 2026-09-25 改):只显示**选中项**的连边 ----
3305
+ // ---- 横向连边(尝生 2026-09-25 改):只显示**选中项**的连边 ----
3146
3306
  // 原先是无条件铺开全部连边 ⇒ 连边一多这块就越来越臃肿,把真正该看的矛盾区挤到下面。
3147
3307
  // 现在:行内只留 ⇄N 小标记(nodeRow 里画),选中哪条才展开它的详情;
3148
3308
  // 未选中时只给一行"共 N 条"的提示,可发现性不丢。
@@ -3224,7 +3384,7 @@
3224
3384
  + (c.winner ? ' · 以 ' + (c.winner === 'a' ? 'A' : 'B') + ' 为准' : ''));
3225
3385
  badge.style.cssText = 'opacity:.75;margin-bottom:4px;';
3226
3386
  card.appendChild(badge);
3227
- // 2026-09-21:每侧标题可点开看**摘要全文** —— 只给标题时人判不出"到底哪矛盾"(用户实测反馈)。
3387
+ // 2026-09-21:每侧标题可点开看**摘要全文** —— 只给标题时人判不出"到底哪矛盾"(尝生实测反馈)。
3228
3388
  function sideLine(tag, title, summary, convId) {
3229
3389
  var wrap = el('div', null);
3230
3390
  var head = el('div', null, tag + ':' + (title || convId || '?'));
@@ -3782,7 +3942,7 @@
3782
3942
  var doc = splitDoc(String(reader.result));
3783
3943
  if (doc.note) resB.textContent = doc.note + '\n';
3784
3944
  pendingItems = doc.items;
3785
- resB.textContent = (resB.textContent || '') + '解析到会话 ' + doc.items.length + ' 条 —— 点击下方「确认导入」分批写入(200/批)。';
3945
+ resB.textContent = (resB.textContent || '') + '解析到会话 ' + doc.items.length + ' 条 —— 点击下方「确认导入」分批写入(按体量切批,单批 ≤' + (BATCH_MAX_BYTES / 1024 / 1024) + ' MB)。';
3786
3946
  bGo.textContent = '确认导入(' + doc.items.length + ' 条)';
3787
3947
  bGo.disabled = false;
3788
3948
  } catch (e) {
@@ -3799,9 +3959,13 @@
3799
3959
  var old = bGo.textContent;
3800
3960
  var items = pendingItems;
3801
3961
  pendingItems = null;
3802
- var totals = { newRows: 0, refreshed: 0, upgraded: 0, folded: 0, removedImport: 0, accepted: 0, rejected: 0, degraded: 0, degradedReasons: {}, errors: 0 };
3962
+ // tooBig = 单条自身超限被跳过的条数(C-1):**已知且可解释**,与 errors(意外)分开记,
3963
+ // 但必须在 finish() 汇总里如实显示 —— 不显示就是静默丢数据。
3964
+ // foldSkippedRaw(A,2026-09-30)= 折叠时"因为目标会话**已有原文**而**没有替换**的轮数":
3965
+ // 同一档"已知且可解释的跳过",同样必须说出来(finish() 里有一行专门交代它)。
3966
+ var totals = { newRows: 0, refreshed: 0, upgraded: 0, folded: 0, removedImport: 0, foldSkippedRaw: 0, accepted: 0, rejected: 0, degraded: 0, degradedReasons: {}, errors: 0, tooBig: 0 };
3803
3967
  var rejPreview = [];
3804
- var batch = 200;
3968
+ var sentBytes = 0; // 已送字节(累计;C2 双口径进度用)
3805
3969
  // 一次导入 = 一条账:runId 贯穿全部批,at = 导入开始时刻(服务端固定后不再变动)
3806
3970
  var runId = 'imp-' + Date.now() + '-' + Math.random().toString(36).slice(2, 8);
3807
3971
  var startedAt = Date.now();
@@ -3812,14 +3976,33 @@
3812
3976
  body: { runId: runId, at: startedAt, file: currentFileName, errors: 1 },
3813
3977
  }).catch(function () {});
3814
3978
  }
3979
+ // #10(2026-09-29):被跳过的"单条过大"要进**持久账本**(「接入历史 → 导入记录」)。
3980
+ // ⚠️ 语义:它记的是"**跳过**"、不是"失败" —— 所以这一笔**不带 `errors`**
3981
+ // (端点里 `errors` 的旧语义是 `max(1, ...)`:带上它就等于把"已知跳过"混成"失败批";
3982
+ // 而当初正是因为那条通路只认 errors,C-1 只能**故意不报** ⇒ 账本上完全看不出有东西被跳过)。
3983
+ // 名字与界面点名**同源**(同一个三级退路 title → name →「未命名」),最多 5 条(端点还会再截一次)。
3984
+ // 收尾时发**一次**(不是每条一发);失败不重试、不影响界面 —— 与 reportError 同款"尽力而为"。
3985
+ var skippedNames = [];
3986
+ function reportTooBig() {
3987
+ if (!totals.tooBig) return;
3988
+ api('/import/log', {
3989
+ method: 'POST',
3990
+ body: {
3991
+ runId: runId, at: startedAt, file: currentFileName,
3992
+ tooBig: totals.tooBig, tooBigNames: skippedNames.slice(0, 5),
3993
+ },
3994
+ }).catch(function () {});
3995
+ }
3815
3996
  function next() {
3816
- var chunk = items.slice(idx, idx + batch);
3817
- if (!chunk.length) return finish();
3818
- idx += batch;
3819
- resB.textContent = '导入中…' + Math.min(idx, items.length) + '/' + items.length;
3997
+ var chunk = takeChunk(items, idx); // C1:条数 × 字节双限(字节口径见 takeChunk 注释)
3998
+ if (!chunk.list.length) return finish();
3999
+ var chunkStart = idx; // C4:本批起始下标(0-based)⇒ 拒绝序号 = chunkStart + rj.i + 1
4000
+ idx = chunk.next;
4001
+ sentBytes += chunk.bytes;
4002
+ resB.textContent = '导入中…' + Math.min(idx, items.length) + '/' + items.length + ' 条 · 已送 ' + fmtBytes(sentBytes);
3820
4003
  api('/import/file/batch', {
3821
4004
  method: 'POST',
3822
- body: { items: chunk, file: currentFileName, runId: runId, at: startedAt },
4005
+ body: { items: chunk.list, file: currentFileName, runId: runId, at: startedAt },
3823
4006
  }).then(function (r) {
3824
4007
  if (r && r.ok) {
3825
4008
  totals.accepted += r.accepted || 0;
@@ -3828,6 +4011,8 @@
3828
4011
  totals.upgraded += r.upgraded || 0;
3829
4012
  totals.folded += r.folded || 0;
3830
4013
  totals.removedImport += r.removedImport || 0;
4014
+ // A(2026-09-30):折叠时"目标已有原文 ⇒ 这一轮没替换"的计数,照 upgraded/folded 同一写法累加
4015
+ totals.foldSkippedRaw += r.foldSkippedRaw || 0;
3831
4016
  totals.degraded += r.degraded || 0;
3832
4017
  if (r.degradedReasons) {
3833
4018
  Object.keys(r.degradedReasons).forEach(function (k) {
@@ -3837,14 +4022,46 @@
3837
4022
  if (r.rejected && r.rejected.length) {
3838
4023
  totals.rejected += r.rejected.length;
3839
4024
  r.rejected.slice(0, 3).forEach(function (rj) {
3840
- var at = idx - batch + rj.i + 1;
4025
+ var at = chunkStart + rj.i + 1; // C4:以【本批起始下标】为准(batch 已不存在)
3841
4026
  if (rejPreview.length < 5) rejPreview.push('第 ' + at + ' 条:' + rj.reason);
3842
4027
  });
3843
4028
  }
3844
4029
  } else {
3845
- totals.errors += 1;
3846
- reportError();
3847
- if (rejPreview.length < 5) rejPreview.push('批失败:' + ((r && (r.message || r.error || r.reason)) || '未知'));
4030
+ // C5:超限是可自愈的确定性失败,给可执行的人话(而不是让它烂成「(网关)」)。
4031
+ // 上限数字**取自服务端回执**(413 体里带 limit),不在这边再写死一个 ——
4032
+ // 两个地方各写一个"4 MB",将来改上限时必漂移;`4` 只作回执缺 limit 时的兜底。
4033
+ var limMb = Number(r && r.limit) > 0 ? Math.round(Number(r.limit) / 1024 / 1024) : 4;
4034
+ // C-1(2026-09-29):同一个 413 其实有两种,必须分开说 ——
4035
+ // ① 本批**只有 1 条** ⇒ 是【这一条自己】太大:takeChunk 已保证超限条单独成批,
4036
+ // 所以"再切细一点"物理上不存在(单条不可再分)。它是**已知且可解释**的,
4037
+ // 不是意外 ⇒ 记 totals.tooBig,不混进 errors;并且要点名报到哪一条、多大,
4038
+ // 再说清"这条跳过、其余照常导入"(后面照常 next(),绝不因一条太大中止整次导入)。
4039
+ // ② 本批**多于 1 条** ⇒ 是切批没生效(真异常)⇒ 沿用旧文案,计入 errors。
4040
+ // 判据只用**服务端回执**(reason==='too-large' + limit)与本批条数;
4041
+ // 客户端**不镜像**服务端的字节上限(将来放宽上限不用改这里)。
4042
+ if (r && r.reason === 'too-large' && chunk.list.length === 1) {
4043
+ var one = chunk.list[0] || {};
4044
+ var oneTitle = String(one.title || one.name || '').trim() || '未命名';
4045
+ var oneBytes = chunk.bytes; // 退路:本批自报字节(含封套近似)
4046
+ try { oneBytes = utf8Len(JSON.stringify(one)); } catch (e) { /* 循环引用等:用退路 */ }
4047
+ totals.tooBig += 1;
4048
+ if (skippedNames.length < 5) skippedNames.push(oneTitle); // #10:账本点名用(最多 5 条)
4049
+ if (rejPreview.length < 5) {
4050
+ rejPreview.push('单条过大跳过:「' + oneTitle + '」这一条 ' + fmtBytes(oneBytes) + '(' + oneBytes + ' B)'
4051
+ + ' —— 它自己就在单次上限 ' + limMb + ' MB 之上,导入通道装不下(不是文件坏了,是这条太长,拆文件也拆不开它);'
4052
+ + '这条已跳过,其余会话照常导入');
4053
+ }
4054
+ } else {
4055
+ totals.errors += 1;
4056
+ reportError();
4057
+ if (rejPreview.length < 5) {
4058
+ if (r && r.reason === 'too-large') {
4059
+ rejPreview.push('批失败:这一批太大(服务端单次上限 ' + limMb + ' MB),请把导出文件拆小后再导');
4060
+ } else {
4061
+ rejPreview.push('批失败:' + ((r && (r.message || r.error || r.reason)) || '未知'));
4062
+ }
4063
+ }
4064
+ }
3848
4065
  }
3849
4066
  next();
3850
4067
  }).catch(function () {
@@ -3859,7 +4076,12 @@
3859
4076
  bGo.textContent = old;
3860
4077
  // S5(2026-09-17 导入诚实化):「有壳无肉」时绝不说"✓ 导入完成"。
3861
4078
  // 旧文案把"对话解析不出来"说成"降级为轻量",用户以为导入成功了,其实正文一条没进。
4079
+ // C-1(2026-09-29)按同一口径补一档:**有会话因单条过长被跳过**时,同样绝不说"✓ 导入完成"。
3862
4080
  var shell = totals.degraded || 0;
4081
+ var skipped = totals.tooBig || 0;
4082
+ // A(2026-09-30):折叠时"因为目标会话已有原文而**没有替换**的轮数"。
4083
+ // 它不是失败、也没有丢东西(保住的是对方原有的真原文),但**必须说出来** —— 静默不可接受。
4084
+ var notReplaced = totals.foldSkippedRaw || 0;
3863
4085
  var why = Object.keys(totals.degradedReasons).map(function (k) {
3864
4086
  return k + ' ×' + totals.degradedReasons[k];
3865
4087
  }).join(';');
@@ -3869,19 +4091,37 @@
3869
4091
  shell + ' 条只有标题、没有对话正文');
3870
4092
  lines.push(' 原因:' + (why || '文件里的对话格式没能认出来') +
3871
4093
  ' —— 这些记忆暂时只有壳,深摘也补不出内容。');
4094
+ } else if (skipped) {
4095
+ lines.push('⚠ 导入不完全:写入新记忆 ' + totals.newRows + ' 条 · 刷新已有 ' + totals.refreshed + ' 条,另有 ' +
4096
+ skipped + ' 条会话单条太长、没能通过导入通道(已跳过,见下)');
4097
+ lines.push(' 这不是文件坏了:是那几条会话**自己**就超过了单次上限 —— 拆小导出文件也拆不开单条。');
3872
4098
  } else {
3873
4099
  lines.push('✓ 导入完成:写入新记忆 ' + totals.newRows + ' 条 · 刷新已有 ' + totals.refreshed + ' 条' +
3874
4100
  (totals.upgraded ? ' · 补全对话原文 ' + totals.upgraded + ' 条' : '') +
3875
4101
  (totals.folded ? ' · 并入网页端历史 ' + totals.folded + ' 条(含对话原文)' : '') +
3876
4102
  (totals.removedImport ? ' · 清理重复副本 ' + totals.removedImport + ' 条' : ''));
3877
4103
  }
3878
- lines.push(' 校验通过 ' + totals.accepted + ' · 拒绝 ' + totals.rejected + (totals.errors ? ' · 批错误 ' + totals.errors : ''));
4104
+ lines.push(' 校验通过 ' + totals.accepted + ' · 拒绝 ' + totals.rejected + (totals.errors ? ' · 批错误 ' + totals.errors : '') + (skipped ? ' · 单条过大跳过 ' + skipped : ''));
4105
+ // A(2026-09-30):折叠时"没替换"的那几轮必须有一行人话(静默不可接受)。
4106
+ // 措辞要点:① 说清**为什么**(该会话在网页端旧库里已经有原文);② 说清**不是失败、也没丢东西**
4107
+ // (保住的是原有的真原文,是**更安全**的那一边);③ 与 `⚠` 那两档分开 —— 那两档是真的不完全,这档不是。
4108
+ if (notReplaced) {
4109
+ lines.push(' ⓘ 另有 ' + notReplaced + ' 轮原文没有替换:这些会话在网页端旧库里**已经有原文**了 —— '
4110
+ + '保留原有的那份、不覆盖它(不是失败,也没有丢东西)。');
4111
+ }
3879
4112
  rejPreview.forEach(function (x) { lines.push(' ⚠ ' + x); });
3880
- if (!totals.errors && totals.accepted && !shell) {
3881
- lines.push(she('她开始读这些日子了 —— 深摘要会在后台按热度慢慢补齐。'));
4113
+ // 收尾句只在"全好"时打(S5/C-1 的诚实化口径)。
4114
+ // A(2026-09-30):有"没替换"的轮次同样不算全好 —— 那几轮的正文确实没进这一侧的库,
4115
+ // 不能一边少进一边说"她开始读这些日子了"。⚠️ 这一层**写成内层判断**而不是往上面那行
4116
+ // 守卫里再加一个 `&&`:那一行的字面形态被 `tests/client-batch.test.mjs:219` 的 T7 源码护栏
4117
+ // 逐字盯着(`!totals.errors && !skipped && totals.accepted && !shell`),往里塞条件会让那条
4118
+ // 既有断言变红 —— 而它守的正是同一个口径(有跳过就不打"全都好"的收尾句)。
4119
+ if (!totals.errors && !skipped && totals.accepted && !shell) {
4120
+ if (!notReplaced) lines.push(she('她开始读这些日子了 —— 深摘要会在后台按热度慢慢补齐。'));
3882
4121
  }
3883
4122
  resB.textContent = lines.join('\n');
3884
4123
  renderImportLog();
4124
+ reportTooBig(); // #10:有跳过就记一笔账(不带 errors;见 reportTooBig 上方的注释)
3885
4125
  }
3886
4126
  next();
3887
4127
  };
@@ -4280,7 +4520,7 @@
4280
4520
  var label = rail
4281
4521
  ? (auditBad ? '⚠' : (mode === 'work' ? '⚙' : '☾'))
4282
4522
  : ((auditBad ? '⚠ ' : '') + (mode === 'work' ? '⚙ 工作' : '☾ 生活'));
4283
- // ⚠️ 原生 `title` 只留**一行**(2026-09-27 用户反馈:原来四行太罗嗦,且浏览器 tooltip 不会自动
4523
+ // ⚠️ 原生 `title` 只留**一行**(2026-09-27 尝生反馈:原来四行太罗嗦,且浏览器 tooltip 不会自动
4284
4524
  // 消失,悬停 1.5s 菜单一开就和它叠成两层)。完整提醒改走自绘气泡 `showTip` ——
4285
4525
  // 悬停即现、3s 自隐、菜单一开就收,见 `tipText`。
4286
4526
  var title = (isGlobal
@@ -4310,7 +4550,7 @@
4310
4550
  if (tipText) showTip(btnRef && btnRef.current, tipText);
4311
4551
  hoverTimer.current = setTimeout(function () {
4312
4552
  hoverTimer.current = null;
4313
- hideTip(); // 菜单与气泡永不同屏(用户反馈的"窗口叠加"就是这两层)
4553
+ hideTip(); // 菜单与气泡永不同屏(尝生反馈的"窗口叠加"就是这两层)
4314
4554
  var node = btnRef && btnRef.current;
4315
4555
  openMenuNear(node ? node.getBoundingClientRect() : null, store);
4316
4556
  }, HOVER_MS);
@@ -4386,7 +4626,7 @@
4386
4626
  return out.join('\n');
4387
4627
  }
4388
4628
 
4389
- /** 复盘解锁:跨面板共享的真值 —— 记忆中心与右侧复盘栏读同一个变量(用户 2026-09-21)。
4629
+ /** 复盘解锁:跨面板共享的真值 —— 记忆中心与右侧复盘栏读同一个变量(尝生 2026-09-21)。
4390
4630
  * 「复盘模式」不是一个 UI 开关,而是"左会话 + 右侧栏"这个状态;树编辑解锁只是它的附属。*/
4391
4631
  var lingUnlocked = false;
4392
4632
  /** 解锁状态订阅者(2026-09-22 审计 C#3):侧栏是 React 组件,useState 只在**首次渲染**取值,
@@ -4420,7 +4660,7 @@ var lingCenterReload = null; // 侧栏改完树后通知记忆中心重载(两
4420
4660
  /**
4421
4661
  * 复制文本(clipboard 不可用时回退 execCommand)。
4422
4662
  * silent=true 时不再单独 toast「已复制」—— 调用方自己会报一句完整的结果,
4423
- * 否则会出现两个 toast 叠加(2026-09-22 用户实测:『已复制复盘开场白 —— 已复制 …』)。
4663
+ * 否则会出现两个 toast 叠加(2026-09-22 尝生实测:『已复制复盘开场白 —— 已复制 …』)。
4424
4664
  */
4425
4665
  function reviewCopy(text, silent) {
4426
4666
  // 审计 C#13:改成**回报是否成功**(Promise) —— clipboard 是异步的,只有 `.then` 才知道结果;
@@ -4463,7 +4703,7 @@ var lingCenterReload = null; // 侧栏改完树后通知记忆中心重载(两
4463
4703
  var LOG_FETCH_LIMIT = 100;
4464
4704
 
4465
4705
  /** 「最近改动」溢出笔数(纯函数 —— 抽出来是为了能在假 DOM 上真跑,
4466
- * 见 `_probe\check-panel-M1-runtime.cjs`)。
4706
+ * 已由一次性运行时探针在假 DOM 上实跑核对过)。
4467
4707
  *
4468
4708
  * 主路:用**服务端真计数**。`/branch-log/pending` 的 `pending` 就是
4469
4709
  * `COUNT(id > 水位 AND action <> 'snapshot')`(host/memory.js:1257-1263),再把列表里
@@ -4474,7 +4714,7 @@ var lingCenterReload = null; // 侧栏改完树后通知记忆中心重载(两
4474
4714
  * (`ORDER BY id DESC`),而 branch_log 是**全量留痕** —— 2026-09-27 只读实测已有 **404 行**
4475
4715
  * ⇒ 页面恒满,`data.log.length - 6` 恒等于 `limit - 6`(limit=100 ⇒ 恒 94),与主人看到的
4476
4716
  * 改动笔数无关。那正是 1.5.1 在注入面修掉的"两个写死上限的差值"
4477
- * (`plans\BACKLOG-1.5.2.md` §A;host/memory.js:1016-1029 明写"不需要把 limit 调大去凑数")。
4717
+ * (作者的开发台账 §A,该台账不随包公开;host/memory.js:1016-1029 明写"不需要把 limit 调大去凑数")。
4478
4718
  *
4479
4719
  * 退路(真计数问不到时 `pend` 为 null):退回页面口径;页面满(`log.length >= limit`)
4480
4720
  * 说明可能还有更多行没取到 ⇒ 只说「N 笔**以上**」,不冒充确切数。
@@ -4533,7 +4773,7 @@ var lingCenterReload = null; // 侧栏改完树后通知记忆中心重载(两
4533
4773
  if (i >= 0) lingUnlockSubs.splice(i, 1);
4534
4774
  };
4535
4775
  }, []);
4536
- // 选中态统一(2026-09-22 用户):单选与多选共用 s8 的集合,selId 由它派生。
4776
+ // 选中态统一(2026-09-22 尝生):单选与多选共用 s8 的集合,selId 由它派生。
4537
4777
  // 原先 selId(蓝) 与 dragIds(橙) 是两套状态,表现为"一蓝一橙"且拖动只有橙的会动。
4538
4778
  var refName = React.useRef(null);
4539
4779
  var refRep = React.useRef(null);
@@ -4548,7 +4788,7 @@ var lingCenterReload = null; // 侧栏改完树后通知记忆中心重载(两
4548
4788
  var setSnapshots = s5[1];
4549
4789
  var refSnap = React.useRef(null);
4550
4790
  // R2 会话级展开:哪些枝展开了、该枝的成员缓存(懒加载)
4551
- // C(2026-09-25 用户):此前 openId 是**单个字符串** ⇒ 打开一个就把上一个关掉(独占式)。
4791
+ // C(2026-09-25 尝生):此前 openId 是**单个字符串** ⇒ 打开一个就把上一个关掉(独占式)。
4552
4792
  // 复盘的场景恰恰是"同时对照几条枝",独占等于逼人来回点。改用集合(每节点独立),
4553
4793
  // 可同时展开多个;Shift+C 一键全折叠(见下方 keydown effect)。
4554
4794
  var s6 = React.useState({});
@@ -4575,7 +4815,7 @@ var lingCenterReload = null; // 侧栏改完树后通知记忆中心重载(两
4575
4815
  // 否则落库前的撤回信息里记不到这一项 ⇒ Ctrl+Z 报「已撤回:恢复了 0 项的位置」。
4576
4816
  // 用 ref 而不是 state:拖拽期间不需要重渲染,drop 时读最新值即可。
4577
4817
  var refDragFrom = React.useRef('');
4578
- // 滚动位置自保(2026-09-22 用户:点主脉级会跳回顶部)
4818
+ // 滚动位置自保(2026-09-22 尝生:点主脉级会跳回顶部)
4579
4819
  // 点选节点会触发整块重渲染,内容高度变化时浏览器会把视口弹回顶部;
4580
4820
  // 故实时记录 scrollTop,并在每次渲染后恢复 —— 与记忆中心 st.treeScroll 同思路。
4581
4821
  var boxRef = React.useRef(null);
@@ -4586,7 +4826,7 @@ var lingCenterReload = null; // 侧栏改完树后通知记忆中心重载(两
4586
4826
  });
4587
4827
  // 编辑区只在"恰好选中一个"时生效(多选时显示提示)
4588
4828
  var selId = (dragIds.length === 1) ? dragIds[0] : '';
4589
- // 复盘守门(2026-09-22 用户:「互相复盘不应该被我绕过,动记忆树不能是随意的」):
4829
+ // 复盘守门(2026-09-22 尝生:「互相复盘不应该被我绕过,动记忆树不能是随意的」):
4590
4830
  // 记录"自器灵上次开口以来累积了多少笔结构改动还没交代"。未同步时:
4591
4831
  // ① 面板顶部红字提示 ② **拦住"退出复盘模式"** —— 改完树必须让器灵有机会看到。
4592
4832
  var s10 = React.useState(0);
@@ -4605,7 +4845,7 @@ var lingCenterReload = null; // 侧栏改完树后通知记忆中心重载(两
4605
4845
  var veinSugs = s11[0];
4606
4846
  var setVeinSugs = s11[1];
4607
4847
  // 审计 E10(1.5.1 红蓝对抗):「这条主脉**已经**有主脉记忆了」是**采纳前**就该知道的事。
4608
- // 后端对同主脉的第二次采纳做的是"先把旧版归档、再覆盖"(用户 2026-09-25 裁决),可界面
4848
+ // 后端对同主脉的第二次采纳做的是"先把旧版归档、再覆盖"(尝生 2026-09-25 裁决),可界面
4609
4849
  // 此前对"将要覆盖"零提示 ⇒ 唯一的发现途径是事后去翻「最近改动」。
4610
4850
  // 判据不靠后端加字段:`conv_overview.source='vein'` 的 `conv_id` **就是主脉 id**
4611
4851
  //(memory.js:1821 写入时 `conv_id: veinId`),所以查一次 `?source=vein` 就知道
@@ -4621,7 +4861,8 @@ var lingCenterReload = null; // 侧栏改完树后通知记忆中心重载(两
4621
4861
  api('/conflicts').catch(function () { return null; }),
4622
4862
  api('/branch-log?limit=' + LOG_FETCH_LIMIT).catch(function () { return null; }),
4623
4863
  api('/tree/snapshots?limit=30').catch(function () { return null; }),
4624
- api('/branch-log/pending').catch(function () { return null; }),
4864
+ // 1.5.3(主会话校正:本批未发布,不叫 1.5.4):该端点进了服务端 `POST_ONLY`(它会一次性补种水位 = 真写),GET 现在回 405。
4865
+ api('/branch-log/pending', { method: 'POST', body: {} }).catch(function () { return null; }),
4625
4866
  api('/vein/suggestions?status=new&limit=100').catch(function () { return null; }),
4626
4867
  // E10:主脉记忆清单(只读,limit 上限就是 500,见 memory.js:1951)
4627
4868
  api('/memories?source=vein&limit=500').catch(function () { return null; }),
@@ -4654,7 +4895,7 @@ var lingCenterReload = null; // 侧栏改完树后通知记忆中心重载(两
4654
4895
  // 侧栏还写着"有 N 笔没说"。加轻量轮询让它跟上(8s 一次小请求,值不变时 React 不重渲染)。
4655
4896
  React.useEffect(function () {
4656
4897
  var t = setInterval(function () {
4657
- api('/branch-log/pending').then(function (r) {
4898
+ api('/branch-log/pending', { method: 'POST', body: {} }).then(function (r) {
4658
4899
  if (!r || !r.ok) return;
4659
4900
  var p = Number(r.pending || 0);
4660
4901
  var w = Number(r.watermark || 0);
@@ -4668,7 +4909,7 @@ var lingCenterReload = null; // 侧栏改完树后通知记忆中心重载(两
4668
4909
  }, 8000);
4669
4910
  return function () { clearInterval(t); };
4670
4911
  }, []);
4671
- // Shift+C:一键折叠所有已展开的枝(2026-09-25 用户定,暂定键位)。
4912
+ // Shift+C:一键折叠所有已展开的枝(2026-09-25 尝生定,暂定键位)。
4672
4913
  // ⚠️ 位置纪律:必须落在**本函数所有 early return 之前**(下方有 `if (!data) return …`)。
4673
4914
  // React 要求每次渲染的 hook 数量与顺序恒定;把 useEffect 放在 early return 之后会出现
4674
4915
  // "首次渲染 data 为空 → 提前 return(少一个 hook) → 数据到达后重渲染多一个 hook"
@@ -4965,7 +5206,7 @@ var lingCenterReload = null; // 侧栏改完树后通知记忆中心重载(两
4965
5206
  userSelect: 'none', WebkitUserSelect: 'none',
4966
5207
  // 选中色只有一种(琥珀) —— 单选与 Ctrl 多选视觉一致,避免"一蓝一橙"
4967
5208
  background: on ? 'rgba(197,139,47,.22)' : 'transparent',
4968
- // 拖动悬停目标:青色虚线框,松手前就知道会落到哪(2026-09-22 用户)
5209
+ // 拖动悬停目标:青色虚线框,松手前就知道会落到哪(2026-09-22 尝生)
4969
5210
  outline: (dropTarget === x.n.id) ? '2px dashed #3fb9a6'
4970
5211
  : (on ? '1px solid rgba(197,139,47,.5)' : 'none'),
4971
5212
  outlineOffset: '-2px',
@@ -4980,7 +5221,7 @@ var lingCenterReload = null; // 侧栏改完树后通知记忆中心重载(两
4980
5221
  },
4981
5222
  style: { display: 'inline-block', width: '12px', opacity: hasKids ? 0.75 : 0.25 },
4982
5223
  }, hasKids ? (isOpen ? '▾' : '▸') : '·'),
4983
- // A(2026-09-25 用户):主脉与枝此前在列表里长得一模一样,只能靠"点开会显示 [主脉]"
5224
+ // A(2026-09-25 尝生):主脉与枝此前在列表里长得一模一样,只能靠"点开会显示 [主脉]"
4984
5225
  // 分辨 —— 而「零散记录」(枝) 与「零散会话」(主脉) 这类名字只差一个字,必然选错。
4985
5226
  // 现在行首给标记:🌱 主干 / ◆ 主脉 / · 枝。
4986
5227
  (x.n.kind === 'trunk' ? '🌱 ' : (x.n.kind === 'vein' ? '◆ ' : '· ')) + (x.n.name || x.n.id)
@@ -5049,7 +5290,7 @@ var lingCenterReload = null; // 侧栏改完树后通知记忆中心重载(两
5049
5290
  paddingLeft: ((x.d + 1) * 12 + 12) + 'px', fontSize: '11px', opacity: 0.85,
5050
5291
  cursor: 'grab', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis',
5051
5292
  userSelect: 'none', WebkitUserSelect: 'none', borderRadius: '5px',
5052
- // 会话级也要有选中可视化(2026-09-22 用户:会话能拖但看不出选没选中)
5293
+ // 会话级也要有选中可视化(2026-09-22 尝生:会话能拖但看不出选没选中)
5053
5294
  background: con ? 'rgba(197,139,47,.22)' : 'transparent',
5054
5295
  outline: (dropTarget === cid) ? '2px dashed #3fb9a6'
5055
5296
  : (con ? '1px solid rgba(197,139,47,.5)' : 'none'),
@@ -5224,7 +5465,7 @@ var lingCenterReload = null; // 侧栏改完树后通知记忆中心重载(两
5224
5465
  .catch(function (e) { toast('✗ 提炼失败:' + ((e && e.message) || '未知')); });
5225
5466
  };
5226
5467
  var resolveSug = function (id, action, text) {
5227
- // 用户 2026-09-25 裁决(乙):**提炼不锁、采纳要解锁**。
5468
+ // 尝生 2026-09-25 裁决(乙):**提炼不锁、采纳要解锁**。
5228
5469
  // 「提炼」只往候选表写一行草稿,不碰记忆 ⇒ 不锁(锁它会抬启动成本);
5229
5470
  // 「采纳」会写 conv_overview(source='vein') + assignConv 改树归属 ⇒ 与改名/并脉同级,故要走同一道门。
5230
5471
  // 「驳回」不写任何东西 ⇒ 不锁。
@@ -5264,7 +5505,7 @@ var lingCenterReload = null; // 侧栏改完树后通知记忆中心重载(两
5264
5505
  h('button', { key: 'b', type: 'button', onClick: function () { doDistill(selX.id); } }, '✦ 提炼主脉'),
5265
5506
  ]));
5266
5507
  } else if (selX && selX.kind === 'branch') {
5267
- // B(2026-09-25 用户):枝上也要有提炼入口 —— 但提炼的素材是"主脉及其子枝",
5508
+ // B(2026-09-25 尝生):枝上也要有提炼入口 —— 但提炼的素材是"主脉及其子枝",
5268
5509
  // 对枝本身提炼没有意义。所以这里先沿 parentId 往上找它所属的主脉,
5269
5510
  // 按钮直接对**那条主脉**做提炼,并在文案里说清对象,省掉"自己去列表里找主脉"这一跳。
5270
5511
  var vAnc = null;
@@ -5290,7 +5531,7 @@ var lingCenterReload = null; // 侧栏改完树后通知记忆中心重载(两
5290
5531
  '✦ 提炼 —— 这条枝还没挂在主脉下;先并脉(挂到某条主脉),再对它提炼。')]));
5291
5532
  }
5292
5533
  // ---- R7 主脉提炼审核队列(2026-09-23) ----
5293
- // 2026-09-25 用户:「提炼结果放在『主脉提炼』那一条底下」—— 审核队列紧跟提炼入口,
5534
+ // 2026-09-25 尝生:「提炼结果放在『主脉提炼』那一条底下」—— 审核队列紧跟提炼入口,
5294
5535
  // 点完提炼往下看就是候选,不必滚到页面别处去找。
5295
5536
  // 设计稿 §5:提炼结果先入队、带派生来源;采纳后才写进主脉记忆,且可"改后采纳"(文本框里改完再点采纳)。
5296
5537
  if (veinSugs.length) {
@@ -5485,7 +5726,7 @@ var lingCenterReload = null; // 侧栏改完树后通知记忆中心重载(两
5485
5726
  });
5486
5727
  }
5487
5728
  // ---- 最近改动 ----
5488
- // 2026-09-25 用户:「『最近改动』块挪到『树的保存/恢复』底下」—— 它记的是"谁在什么时候
5729
+ // 2026-09-25 尝生:「『最近改动』块挪到『树的保存/恢复』底下」—— 它记的是"谁在什么时候
5489
5730
  // 动了树",与上面那排快照是同一件事的两面(一份是存档,一份是流水),放一起才顺。
5490
5731
  if (data.log.length) {
5491
5732
  kids.push(h('div', { key: 'lh', style: { fontWeight: 700, margin: '9px 0 2px' } }, '最近改动'));
@@ -5526,12 +5767,12 @@ var lingCenterReload = null; // 侧栏改完树后通知记忆中心重载(两
5526
5767
  style: unlocked ? { borderColor: '#c58b2f', color: '#c58b2f' } : {},
5527
5768
  onClick: function () {
5528
5769
  var next = !unlocked;
5529
- // 复盘守门(2026-09-22 用户:「互相复盘不应该被我绕过」):退出前必须先把
5770
+ // 复盘守门(2026-09-22 尝生:「互相复盘不应该被我绕过」):退出前必须先把
5530
5771
  // 未同步的改动交代给器灵 —— 不能"改完几十处直接走人",那等于假复盘。
5531
5772
  // 2026-09-23 修:显示值可能滞后(器灵开口才推进水位,而"发言"不触发本面板重载),
5532
5773
  // 所以退出前**实时查一次**服务端 —— 避免"明明已经同步了却拦着不让退"。
5533
5774
  if (!next) {
5534
- api('/branch-log/pending').then(function (r) {
5775
+ api('/branch-log/pending', { method: 'POST', body: {} }).then(function (r) {
5535
5776
  var p = (r && r.ok) ? Number(r.pending || 0) : pendingChanges;
5536
5777
  setPendingChanges(p);
5537
5778
  if (p > 0) {
@@ -5552,7 +5793,7 @@ var lingCenterReload = null; // 侧栏改完树后通知记忆中心重载(两
5552
5793
  }
5553
5794
  setUnlocked(next);
5554
5795
  setLingUnlocked(next, true);
5555
- // 进入复盘模式 = 解锁 + 备好开场白(2026-09-22 用户:行为与总控板「一键进入复盘」对齐)
5796
+ // 进入复盘模式 = 解锁 + 备好开场白(2026-09-22 尝生:行为与总控板「一键进入复盘」对齐)
5556
5797
  // 审计 C#17:原来这里把 reviewCopy 当同步函数 —— 它返回 Promise,所以 try/catch
5557
5798
  // 根本抓不到复制失败,而且**不等结果**就先报「已复制」。改成等结果再说文案。
5558
5799
  if (next) {
@@ -5859,7 +6100,7 @@ var lingCenterReload = null; // 侧栏改完树后通知记忆中心重载(两
5859
6100
  // ⚠️ 只等 sidebarRightTabs —— 不要把 sidebarRight 也写进来:
5860
6101
  // 它是 per-session 绑定的控制器,在该注入作用域里可能永不满足,
5861
6102
  // 回调就永远不执行 ⇒ 面板类型注册不上 ⇒ openTab 抛
5862
- // 'sidebarRight: no tab type is registered as "ling-review"'(用户实测)。
6103
+ // 'sidebarRight: no tab type is registered as "ling-review"'(尝生实测)。
5863
6104
  // 打开面板时用 lingCtx.get('sidebarRight') 动态取即可(那时必然已挂载)。
5864
6105
  // 🔴 根因(2026-09-21 定位):服务必须从**回调参数**上取(scoped),
5865
6106
  // 不能用闭包里的 ctx —— cordis 的属性访问在未 inject 时抛
@@ -5908,6 +6149,15 @@ var lingCenterReload = null; // 侧栏改完树后通知记忆中心重载(两
5908
6149
  console.warn('[dsh-ling] client apply failed', e);
5909
6150
  }
5910
6151
  };
6152
+ // ---- 测试钩子(仅供 tests/client-batch.test.mjs 直调真实实现)----
6153
+ // 只挂纯函数与常量,无任何副作用;生产路径不读这里。
6154
+ exports.__test__ = {
6155
+ takeChunk: takeChunk,
6156
+ utf8Len: utf8Len,
6157
+ fmtBytes: fmtBytes,
6158
+ BATCH_MAX_BYTES: BATCH_MAX_BYTES,
6159
+ BATCH_MAX_ITEMS: BATCH_MAX_ITEMS,
6160
+ };
5911
6161
  return module.exports;
5912
6162
  },
5913
6163
  });