diff --git a/dsh-mneme/CHANGELOG.md b/dsh-mneme/CHANGELOG.md index e62fe784..56d199af 100644 --- a/dsh-mneme/CHANGELOG.md +++ b/dsh-mneme/CHANGELOG.md @@ -2,6 +2,9 @@ ## [Unreleased] +- **情绪页白屏(beta.1 起的回归,beta.2 仍在):点开「情感」页整块面板变空白,且此后每次打开都空白**。根因是 `lib/client.js` 的 `AffectivePanel` 里一行 `formatRelativeTime(it.lastSeen)` 漏传第二个参数 `t`——该函数内部要调 `t("memory.time.now")`,缺参就抛 `t is not a function`,而这行跑在 render 里,React 整棵树卸载,日志里什么都不留。引入提交是 #619(`244da66d`),`v0.14.0-beta.1` 与 `v0.14.0-beta.2` 都带它;只有库里存在带 `lastSeen` 的情绪边时才触发,所以不是每个用户都会遇到。修法是补上 `, t`,并加一条静态断言:扫出所有 `formatRelativeTime(` 调用点,逐个做括号配对、数顶层逗号,实参不足 2 个就红。全库只有这一处漏传(`formatDate`/`formatDateShort` 本来只收一个参数,扫描器报的几处是误报)。变异自证:撤掉修复后断言变红并直接指出 `:8168 (argc=1)`。 +- **选了「off(人审队列)」却仍不断刷出待审冲突卡片,原因是「矛盾冻结」优先于档位,此前这条互斥关系只在设置项提示语里,下拉旁边看不到**。档位为 `off` 或 `shadow` 且 `conflictFreezeEnabled` 为真时,判成 conflict 的对照旧停车进 `conflict_pending`,档位选哪一档都不会减少;只有 `enforce` 且冻结关闭时才绕过队列,两者同开时冻结让位(`src/dream.js:1535` 的 `ledgerEnforce`,sleep 侧同款降级)。现在在档位下拉下方直接标一行「⚠ 冻结正在覆盖本档位」,判据与后端同源(`enumOverridden`:仅 `conflictLedgerMode`、仅 `off`/`shadow`、且读 `effective` 里的冻结值),文案中英各一条。**不改默认值、不改语义**——只是把已经在发生的事写明白,用户仍要自己关「矛盾冻结」才会停止新增。 + ## [0.14.0-beta.2] - 2026-11-11 - **模型从此看得见记忆有多旧(时间线索):注入行与三个检索工具的回执都带日期,`occurred_at` 优先、缺省回落到 `created_at`**:模型此前拿到的记忆只有标题、类型与正文——`created_at` 从头到尾没进过任何一条渲染路径(`src/inject.js` 全文零命中,`memory_get` 连 `updated_at` 都不给,唯一露出的时间戳是 `renderTimePrefix` 的「当前时间」一句,且默认关)。于是「三天前用户说要 A、昨天改口说 B」在模型眼里是两条等重的陈述,先后关系全靠正文里有没有恰好写着日期。 diff --git a/dsh-mneme/lib/client.js b/dsh-mneme/lib/client.js index 8407d1aa..5fdf7535 100644 --- a/dsh-mneme/lib/client.js +++ b/dsh-mneme/lib/client.js @@ -1063,6 +1063,9 @@ window.__ModuleLoader__.load({ "memory.features.conflictLedgerMode.off": "off(人审队列)", "memory.features.conflictLedgerMode.shadow": "shadow(只记账)", "memory.features.conflictLedgerMode.enforce": "enforce(确定性处置)", + // #661:档位选 off/shadow 但「矛盾冻结」开着时,冲突照样进人审队列—— + // 冻结优先,档位被覆盖。不写这一行,用户只能反复看到「已选 off 却有卡片」。 + "memory.features.conflictLedgerMode.frozenOverrides": "「矛盾冻结」正开着,它优先于本档位:判成 conflict 的对仍会进人审队列,档位选 off / shadow 都不会少。要让队列不再新增,先关掉「矛盾冻结」。", "memory.features.scopeEnabled": "来源标注", "memory.features.scopeEnabled.hint": "每条记忆标上来自哪个 Agent / 工作区。", "memory.features.strictScope": "严格隔离", @@ -2193,6 +2196,8 @@ window.__ModuleLoader__.load({ "memory.features.conflictLedgerMode.off": "off (review queue)", "memory.features.conflictLedgerMode.shadow": "shadow (record only)", "memory.features.conflictLedgerMode.enforce": "enforce (deterministic)", + // #661: with freeze on, the tier is overridden and conflicts still park. + "memory.features.conflictLedgerMode.frozenOverrides": "Conflict freeze is on and takes precedence over this mode: pairs judged conflict still enter the review queue, so off and shadow change nothing. Turn conflict freeze off to stop the queue from growing.", "memory.features.scopeEnabled": "Scope tagging", "memory.features.scopeEnabled.hint": "Tags each memory with its agent/workspace origin.", "memory.features.strictScope": "Strict isolation", @@ -4935,6 +4940,13 @@ window.__ModuleLoader__.load({ // 开关换成下拉。取值表来自 FEATURE_ENUM_VALUES,标签按 . // 从词典取——所以新增一个枚举档位只改常量表与词典,不动渲染代码。 // 值缺失时退回首档(与后端默认档一致),不会渲染出一个没有选中的下拉。 + // #661:冻结优先于档位这条互斥关系,此前只写在设置项提示语里,用户在 + // 档位下拉旁边看不到;选了 off 却仍刷出待审卡片时无从判断。这里在下拉 + // 下方直接标出「本档位正被冻结覆盖」,判据与后端同源(见 src/dream.js:1535 + // 的 ledgerEnforce 与 sleep 侧同款降级),不另造一套语义。 + const enumOverridden = (k) => k === "conflictLedgerMode" + && eff.conflictFreezeEnabled === true + && (eff[k] === "off" || eff[k] === "shadow"); const enumRow = (k) => { const values = FEATURE_ENUM_VALUES[k] ?? []; return h("div", { className: "mneme-featsub", key: k }, @@ -4948,7 +4960,9 @@ window.__ModuleLoader__.load({ }, values.map((v) => h("option", { key: v, value: v }, t(`memory.features.${k}.${v}`)))) ), - h("div", { className: "mneme-featsubhint" }, t(`memory.features.${k}.hint`)) + h("div", { className: "mneme-featsubhint" }, t(`memory.features.${k}.hint`)), + enumOverridden(k) && h("div", { className: "mneme-featsubhint" }, + "⚠ " + t(`memory.features.${k}.frozenOverrides`)) ); }; @@ -8165,7 +8179,7 @@ window.__ModuleLoader__.load({ if (Number.isFinite(it.evidenceCount) && it.evidenceCount > 0) { bits.push(t("memory.affective.evidence").replace("{n}", String(it.evidenceCount))); } - if (it.lastSeen) bits.push(t("memory.affective.lastSeen").replace("{time}", formatRelativeTime(it.lastSeen))); + if (it.lastSeen) bits.push(t("memory.affective.lastSeen").replace("{time}", formatRelativeTime(it.lastSeen, t))); return h("div", { className: isTrait ? "mneme-affective-row mneme-affective-row--trait" : "mneme-affective-row", key: `${it.personId}-${it.targetId}-${it.emotion}-${it.attribution}-${i}` }, h("div", { className: "mneme-affective-main" }, // 谁:服务端一直带着 person(from_name),此前前端只渲染了「对什么 · diff --git a/dsh-mneme/test/client.test.js b/dsh-mneme/test/client.test.js index 0e16d131..1b86d169 100644 --- a/dsh-mneme/test/client.test.js +++ b/dsh-mneme/test/client.test.js @@ -4593,3 +4593,90 @@ test("affective rows name the person, and every tab owns its icon (#619)", () => assert.ok(/h\(Icon, \{ name: TAB_ICONS\[s\.key\]/.test(clientSource), "页签图标必须走 TAB_ICONS 查表,不许回到嵌套三元"); }); + +// formatRelativeTime(value, t) 的 t 是必填的:它内部要 t("memory.time.now") +// 之类的文案,少传就抛 TypeError,而它跑在 render 里 —— 整个面板会白屏, +// 且不写进任何日志。v0.14.0-beta.1/2 的「情绪页一点就全白」就是这么来的 +// (AffectivePanel 里 formatRelativeTime(it.lastSeen) 漏了 t)。 +// 没有 React 渲染级测试可依赖(deps 无 react-dom),所以用静态扫描兜: +// 逐个调用点做括号配对、数顶层逗号,实参少于 2 个即为回归。 +test("every formatRelativeTime call passes the translator (a missing t blanks the panel)", () => { + const name = "formatRelativeTime"; + const re = new RegExp(`\\b${name}\\(`, "g"); + const offenders = []; + let match; + while ((match = re.exec(clientSource))) { + // 定义行本身不是调用点。 + const lineStart = clientSource.lastIndexOf("\n", match.index) + 1; + const lineText = clientSource.slice(lineStart, clientSource.indexOf("\n", match.index)); + if (lineText.includes(`function ${name}`)) continue; + + let i = match.index + match[0].length; + const argsStart = i; + let depth = 1; + while (i < clientSource.length && depth > 0) { + const ch = clientSource[i]; + if (ch === "(") depth++; + else if (ch === ")") depth--; + i++; + } + const args = clientSource.slice(argsStart, i - 1); + let argDepth = 0; + let argc = args.trim() ? 1 : 0; + for (const ch of args) { + if ("([{".includes(ch)) argDepth++; + else if (")]}".includes(ch)) argDepth--; + else if (ch === "," && argDepth === 0) argc++; + } + if (argc < 2) { + const lineNo = clientSource.slice(0, match.index).split("\n").length; + offenders.push(`:${lineNo} (argc=${argc}) ${lineText.trim().slice(0, 120)}`); + } + } + assert.deepEqual( + offenders, + [], + `formatRelativeTime 必须带上 t,否则 render 里抛 t is not a function、面板整块白屏:\n${offenders.join("\n")}` + ); +}); + +// #661:选了「off(人审队列)」却还能刷到待审冲突卡片——冻结优先于档位这条 +// 互斥关系只写在设置项提示里,用户在档位下拉旁边看不到,于是反复以为档位没生效。 +// 这里锁三件事:①判定函数存在且判据与后端同源(freeze 开 + off/shadow 才算被覆盖, +// enforce 不被标注,因为 enforce 与 freeze 同开时后端降级到 freeze,标注反而误导); +// ②该判定真的接到了 enumRow 的渲染里,不是写了没调用;③提示文案中英各一条。 +test("conflict ledger mode warns when freeze overrides the chosen tier (#661)", () => { + // ① 判定:只在 conflictLedgerMode 上成立,且必须是 freeze 开 + off/shadow + const fn = clientSource.match(/const enumOverridden = \(k\) =>([\s\S]{0,260}?);\r?\n/); + assert.ok(fn, "enumOverridden must exist — the override notice needs a single predicate"); + const body = fn[1]; + assert.match(body, /k === "conflictLedgerMode"/, + "the override notice only applies to conflictLedgerMode (other enum keys have no freeze)"); + assert.match(body, /eff\.conflictFreezeEnabled === true/, + "the notice must read the effective freeze flag, not the saved override"); + assert.match(body, /\(eff\[k\] === "off" \|\| eff\[k\] === "shadow"\)/, + "only off/shadow are overridden by freeze — enforce is degraded to freeze server-side, where this notice would misdescribe the run"); + assert.equal(/enforce" \)/.test(body), false, + "enforce must not be listed as overridden by this notice"); + + // ② 接线:判定必须真的消费在 enumRow 里(写了不调用 = 永远不显示) + const enumRowStart = clientSource.indexOf("const enumRow = (k) =>"); + assert.ok(enumRowStart > 0, "enumRow must exist"); + const enumRowEnd = clientSource.indexOf("const flagRow = (k) =>", enumRowStart); + assert.ok(enumRowEnd > enumRowStart, "enumRow must end before flagRow"); + const renderer = clientSource.slice(enumRowStart, enumRowEnd); + assert.match(renderer, /enumOverridden\(k\) && h\("div", \{ className: "mneme-featsubhint" \}/, + "enumRow must render the override notice from enumOverridden(k), not a second copy of the rule"); + assert.match(renderer, /memory\.features\.\$\{k\}\.frozenOverrides/, + "the notice text must come from the per-key dictionary entry"); + + // ③ 文案双语:恰好中英各一条(奇数次 = 漏译,>2 = 重复) + const occurrences = clientSource.split('"memory.features.conflictLedgerMode.frozenOverrides"').length - 1; + assert.equal(occurrences, 2, + `the frozenOverrides notice must exist as exactly one dictionary line per language (got ${occurrences})`); + // 文案要点:说清「冻结优先」与「要先关冻结」,否则用户仍不知道该怎么办 + const text = clientSource.match(/"memory\.features\.conflictLedgerMode\.frozenOverrides":\s*"([^"]+)"/g) ?? []; + const joined = text.join(" "); + assert.match(joined, /\u77db\u76fe\u51bb\u7ed3|conflict freeze/i, + "the notice must name the freeze switch so the user knows what to turn off"); +});