@modusensus/dsh-mneme 0.12.0 → 0.13.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.
Files changed (154) hide show
  1. package/README.md +48 -19
  2. package/docs/CONFIGURATION.md +43 -4
  3. package/docs/DIRECTORIES.md +114 -0
  4. package/docs/DISTILL.md +64 -0
  5. package/docs/DSH-GO.md +97 -0
  6. package/docs/ENTITIES.md +5 -3
  7. package/docs/FORGET.md +77 -0
  8. package/docs/KB.md +110 -0
  9. package/docs/SLEEP.md +28 -3
  10. package/docs/STORAGE.md +23 -0
  11. package/lib/api-standalone.js +4 -1
  12. package/lib/api.js +581 -29
  13. package/lib/attachment-text.js +122 -0
  14. package/lib/choro-command.js +9 -2
  15. package/lib/client.js +3537 -1205
  16. package/lib/config.js +139 -1
  17. package/lib/continuity.js +7 -6
  18. package/lib/diary/auto.js +190 -0
  19. package/lib/diary/compose.js +440 -0
  20. package/lib/diary/dir.js +59 -0
  21. package/lib/diary/export.js +97 -0
  22. package/lib/diary/index.js +26 -0
  23. package/lib/diary/library.js +189 -0
  24. package/lib/diary/qq-inbox.js +142 -0
  25. package/lib/diary/rollover.js +61 -0
  26. package/lib/diary/session-provider.js +122 -0
  27. package/lib/dream/decisions.js +56 -5
  28. package/lib/dream/sleep.js +616 -15
  29. package/lib/dream.js +45 -13
  30. package/lib/dsh-go/host.js +162 -0
  31. package/lib/dsh-go/panel.js +2091 -0
  32. package/lib/dsh-go/text.js +342 -0
  33. package/lib/dsh-go/views/common.js +68 -0
  34. package/lib/dsh-go/views/entity.js +48 -0
  35. package/lib/dsh-go/views/memory.js +198 -0
  36. package/lib/dsh-go/views/root.js +30 -0
  37. package/lib/dsh-go/views/settings.js +398 -0
  38. package/lib/dsh-go/views/status.js +51 -0
  39. package/lib/embedding.js +42 -8
  40. package/lib/entities/extractor.js +36 -7
  41. package/lib/hot-memory.js +8 -9
  42. package/lib/index.js +92 -2
  43. package/lib/inject.js +189 -18
  44. package/lib/kb/index.js +309 -0
  45. package/lib/kb/routes.js +220 -0
  46. package/lib/kb/tools.js +148 -0
  47. package/lib/lang.js +87 -4
  48. package/lib/peak-hours.js +57 -2
  49. package/lib/semantic.js +347 -60
  50. package/lib/service.js +193 -66
  51. package/lib/settings.js +112 -14
  52. package/lib/store.js +347 -21
  53. package/lib/summarize.js +38 -11
  54. package/lib/tools.js +35 -4
  55. package/lib/user-settings.js +128 -32
  56. package/lib/vector-index.js +76 -6
  57. package/lib/wiki/cli-hint.js +90 -0
  58. package/lib/wiki/queries.js +116 -2
  59. package/lib/wiki/t1-jobs.js +232 -0
  60. package/lib/wiki/t1.js +7 -1
  61. package/package.json +25 -1
  62. package/src/api-standalone.js +4 -1
  63. package/src/api.js +581 -29
  64. package/src/attachment-text.js +122 -0
  65. package/src/choro-command.js +9 -2
  66. package/src/config.js +139 -1
  67. package/src/continuity.js +7 -6
  68. package/src/diary/auto.js +190 -0
  69. package/src/diary/compose.js +440 -0
  70. package/src/diary/dir.js +59 -0
  71. package/src/diary/export.js +97 -0
  72. package/src/diary/index.js +26 -0
  73. package/src/diary/library.js +189 -0
  74. package/src/diary/qq-inbox.js +142 -0
  75. package/src/diary/rollover.js +61 -0
  76. package/src/diary/session-provider.js +122 -0
  77. package/src/dream/decisions.js +56 -5
  78. package/src/dream/sleep.js +616 -15
  79. package/src/dream.js +45 -13
  80. package/src/dsh-go/host.js +162 -0
  81. package/src/dsh-go/panel.js +2091 -0
  82. package/src/dsh-go/text.js +342 -0
  83. package/src/dsh-go/views/common.js +68 -0
  84. package/src/dsh-go/views/entity.js +48 -0
  85. package/src/dsh-go/views/memory.js +198 -0
  86. package/src/dsh-go/views/root.js +30 -0
  87. package/src/dsh-go/views/settings.js +398 -0
  88. package/src/dsh-go/views/status.js +51 -0
  89. package/src/embedding.js +42 -8
  90. package/src/entities/extractor.js +36 -7
  91. package/src/hot-memory.js +8 -9
  92. package/src/index.js +92 -2
  93. package/src/inject.js +189 -18
  94. package/src/kb/index.js +309 -0
  95. package/src/kb/routes.js +220 -0
  96. package/src/kb/tools.js +148 -0
  97. package/src/lang.js +87 -4
  98. package/src/peak-hours.js +57 -2
  99. package/src/semantic.js +347 -60
  100. package/src/service.js +193 -66
  101. package/src/settings.js +112 -14
  102. package/src/store.js +347 -21
  103. package/src/summarize.js +38 -11
  104. package/src/tools.js +35 -4
  105. package/src/user-settings.js +128 -32
  106. package/src/vector-index.js +76 -6
  107. package/src/wiki/cli-hint.js +90 -0
  108. package/src/wiki/queries.js +116 -2
  109. package/src/wiki/t1-jobs.js +232 -0
  110. package/src/wiki/t1.js +7 -1
  111. package/test/api-routes-gaps.test.js +63 -0
  112. package/test/api.test.js +375 -4
  113. package/test/attachment-text.test.js +140 -0
  114. package/test/bm25-corpus-cache.test.js +145 -0
  115. package/test/capacity-warning.test.js +262 -0
  116. package/test/client.test.js +779 -154
  117. package/test/conflict-freeze.test.js +45 -1
  118. package/test/continuity.test.js +8 -1
  119. package/test/diary-library.test.js +187 -0
  120. package/test/diary.test.js +576 -0
  121. package/test/dream-maxtokens-fallback.test.js +3 -3
  122. package/test/dream.test.js +62 -1
  123. package/test/dsh-go-host.test.js +134 -0
  124. package/test/dsh-go-panel-actions.test.js +854 -0
  125. package/test/dsh-go-panel.test.js +251 -0
  126. package/test/embedding-ready.test.js +33 -3
  127. package/test/entities.test.js +73 -0
  128. package/test/forget-corrections.test.js +70 -0
  129. package/test/image-memory.test.js +171 -0
  130. package/test/issue-617-step1-query-and-fusion.test.js +361 -0
  131. package/test/kb-api-wiring.test.js +128 -0
  132. package/test/kb-collections.test.js +272 -0
  133. package/test/kb-routes.test.js +379 -0
  134. package/test/mint-source.test.js +26 -0
  135. package/test/peak-hours-observability.test.js +70 -0
  136. package/test/persona-api.test.js +3 -3
  137. package/test/persona-by-session.test.js +568 -0
  138. package/test/persona-settings.test.js +7 -7
  139. package/test/reflection-update-gate.test.js +129 -0
  140. package/test/reindex-backfill.test.js +334 -0
  141. package/test/reindex-scheduler.test.js +369 -0
  142. package/test/scope-candidates.test.js +249 -0
  143. package/test/sleep-action-set.test.js +14 -0
  144. package/test/sleep-heat.test.js +49 -0
  145. package/test/sleep.test.js +598 -0
  146. package/test/standalone-api.test.js +24 -0
  147. package/test/store.test.js +20 -0
  148. package/test/summarize.test.js +26 -0
  149. package/test/user-settings-segments.test.js +528 -0
  150. package/test/vector-index.test.js +120 -6
  151. package/test/wiki-api.test.js +355 -0
  152. package/test/wiki-cli-hint.test.js +118 -0
  153. package/test/wiki-queries.test.js +7 -0
  154. package/test/wiki-t1-jobs.test.js +125 -0
@@ -108,6 +108,26 @@ test("count excludes forgotten by default, includeForgotten opts in", () => {
108
108
  store.close();
109
109
  });
110
110
 
111
+ test("countByType: same filters, no type clause — the distribution sums to count(undefined) (#597)", () => {
112
+ const store = openMemory();
113
+ const a = store.save({ type: "project", title: "t1", content: "c" });
114
+ store.save({ type: "project", title: "t2", content: "c" });
115
+ store.save({ type: "preference", title: "p", content: "c", importance: 5 });
116
+ store.setForget(a.id, true);
117
+ const counts = store.countByType();
118
+ assert.equal(counts.project, 1, "forgotten rows excluded (same口径 as count)");
119
+ assert.equal(counts.preference, 1);
120
+ assert.equal(
121
+ Object.keys(counts).reduce((n, k) => n + counts[k], 0),
122
+ store.count(),
123
+ "distribution sums to count(undefined)"
124
+ );
125
+ // 筛选同口径:带 minImportance 的分布合计 = 带同筛选的 count
126
+ const filtered = store.countByType({ minImportance: 5 });
127
+ assert.deepEqual(filtered, { preference: 1 }, "distribution honors the same filters as count");
128
+ store.close();
129
+ });
130
+
111
131
  test("search matches title, content and tags", () => {
112
132
  const store = openMemory();
113
133
  store.save({ type: "project", title: "记忆插件", content: "c1", tags: [] });
@@ -386,6 +386,32 @@ test("#239-4 高峰时段解析:合法 / 多段 / 跨零点 / 星期前缀 /
386
386
  }
387
387
  });
388
388
 
389
+ // #424:中文输入法带出来的全角等价符号曾让整串解析成 null——null 的语义是
390
+ // 「未配置 = 不限制」,错峰闸门于是静默失效(用户写的全角逗号把省钱功能整个
391
+ // 关掉,且没有任何提示)。本用例锁两件事:
392
+ // ① 全角逗号/冒号/数字/波浪号(含 U+301C)与全角星期前缀都归一成半角照常解析,
393
+ // 判定与补跑(isInPeakWindow / nextOffPeakAt)走同一份解析、自动受益;
394
+ // ② 归一化只做等价映射不救错:值本身越界的写法(24:00)无论半角全角仍然非法,
395
+ // 防的是「归一化顺手放宽了数值边界」这类静默语义漂移。
396
+ test("#424 全角等价符号:归一后照常解析,不改变越界判定", () => {
397
+ assert.deepEqual(parsePeakSpec("00:00-03:00,04:00-06:00"), {
398
+ days: null,
399
+ windows: [{ start: 0, end: 180 }, { start: 240, end: 360 }]
400
+ }, "全角逗号切段");
401
+ assert.deepEqual(parsePeakSpec("00:00-03:00").windows, [{ start: 0, end: 180 }], "全角冒号");
402
+ assert.deepEqual(parsePeakSpec("00:00-03:00").windows, [{ start: 0, end: 180 }], "全角数字");
403
+ assert.deepEqual(parsePeakSpec("00:00~03:00").windows, [{ start: 0, end: 180 }], "全角波浪号当连接号");
404
+ assert.deepEqual(parsePeakSpec("00:00〜03:00").windows, [{ start: 0, end: 180 }], "U+301C 波浪号的另一半");
405
+ assert.deepEqual(parsePeakSpec("sat,sun 23:00-06:00").days, [6, 7], "整串全角:星期前缀一起归一");
406
+ // 判定与补跑与解析共用同一入口,全角串在两侧行为一致
407
+ const at = (h, m) => new Date(2026, 10, 10, h, m, 0);
408
+ assert.equal(isInPeakWindow(at(1, 0), "00:00~03:00"), true, "判定侧同一份解析");
409
+ assert.equal(nextOffPeakAt(at(1, 0), "00:00~03:00").getHours(), 3, "补跑时刻同一份解析");
410
+ // 归一化不救错:#424 的举例串本身还踩 24:00 越界,跨零点的正写是 04:00-00:00
411
+ assert.equal(parsePeakSpec("00:00-03:00,04:00-24:00"), null, "24:00 越界不因归一化变合法");
412
+ assert.equal(parsePeakSpec("24:00-06:00"), null, "全角形式不改变越界判定");
413
+ });
414
+
389
415
  test("#239-4 高峰判定与补跑时刻(跨零点、右开区间)", () => {
390
416
  const at = (h, m) => new Date(2026, 8, 19, h, m, 30);
391
417
  assert.equal(isInPeakWindow(at(10, 0), "09:00-18:00"), true);
@@ -0,0 +1,528 @@
1
+ // #600 第 1 期:渲染函数产出段边界(segments),供面板按段读、按段改、按段提问。
2
+ //
3
+ // 这一期是**纯加法**,产品承诺只有两条,都是「做错了界面上看不出来」的那类:
4
+ // ① `text` 逐字节不变——注入是每帧的,多一个空格都是全库用户的行为变化;
5
+ // ② `segments` 是 `text` 的唯一真源——面板拿段拼回去必须等于注入那一串,
6
+ // 否则「按段改」改的和模型看到的不是同一份(正是 #497 那类两份实现漂掉)。
7
+ //
8
+ // 第 ① 条不靠「我看着一样」:本文件里留了一份改动前的渲染实现(legacyRender),
9
+ // 逐配置对比新旧输出。老实现的代码是历史,故意不跟着新实现重构——它就是 golden。
10
+ import test from "node:test";
11
+ import assert from "node:assert/strict";
12
+ import { STR } from "../src/lang.js";
13
+ import { renderUserSettingsBlock, escapePromptVars, resolvePersonaCard, resolveFactsCard } from "../src/user-settings.js";
14
+
15
+ // 事实段预算(与 src/user-settings.js 同值,golden 需要它复现截断)
16
+ const MAX_LINES = 40;
17
+ const MAX_CHARS = 1200;
18
+
19
+ function makeSettings({ profile = "", rules = [], persona = null, byAgent = {} } = {}) {
20
+ return {
21
+ getProfile: () => profile,
22
+ getRules: () => rules,
23
+ getPersona: () => ({ default: persona ?? { enabled: false }, byAgent })
24
+ };
25
+ }
26
+
27
+ /**
28
+ * 冻结副本:origin/main 041e8f3 的 resolvePersonaCard 逐字抄。
29
+ *
30
+ * golden 不能 import 生产函数——那样谁改坏生产,golden 跟着一起坏,两边同错
31
+ * 仍会全绿(自证陷阱,Ephor 2026-11-07 指出)。所以这里留一份副本。
32
+ * 代价是副本会随生产演进而过期,由文件末尾那条「漂移告警」用例负责显式失败,
33
+ * 而不是让 golden 静默变成生产函数的镜像。
34
+ */
35
+ function legacyResolvePersonaCard(settings, agentId, { strict = false } = {}) {
36
+ const store = settings.getPersona();
37
+ const id = typeof agentId === "string" && agentId.trim() ? agentId.trim() : "";
38
+ const card = id ? store.byAgent[id] : null;
39
+ if (card && card.enabled === true) return { card, source: "byAgent" };
40
+ if (id && strict && Object.keys(store.byAgent).length > 0) return { card: {}, source: "none" };
41
+ return { card: store.default, source: "default" };
42
+ }
43
+
44
+ /** 冻结副本:origin/main 041e8f3 的 resolveFactsCard 逐字抄(同上,不许 import 生产)。 */
45
+ function legacyResolveFactsCard(factsStore, agentId, { strict = false } = {}) {
46
+ const id = typeof agentId === "string" && agentId.trim() ? agentId.trim() : "";
47
+ const byAgent = factsStore?.byAgent ?? {};
48
+ const card = id ? (byAgent[id] ?? null) : null;
49
+ if (card && card.length) return { facts: card, source: "byAgent" };
50
+ if (id && strict && Object.keys(byAgent).length > 0) return { facts: [], source: "none" };
51
+ return { facts: factsStore?.default ?? [], source: "default" };
52
+ }
53
+
54
+ /**
55
+ * 改动前的实现(origin/main 041e8f3 的 src/user-settings.js:88-134 原样)。
56
+ * 只依赖 lang.js 与 escapePromptVars——这两个本期都没改。
57
+ * 两个 resolve 用上面的冻结副本,不用生产函数(见副本注释)。
58
+ */
59
+ function legacyRender({ settings, config, agentId = null, factsProvider = null }) {
60
+ const language = config?.language === "en" ? "en" : "zh";
61
+ const profile = settings.getProfile().trim();
62
+ const rules = settings.getRules();
63
+ const enabled = config?.personaEnabled === true;
64
+ const resolved = enabled
65
+ ? legacyResolvePersonaCard(settings, agentId, { strict: config?.personaStrictAddressing === true })
66
+ : { card: {}, source: "default" };
67
+ const persona = resolved.card;
68
+ const personaLines = persona?.enabled === true
69
+ ? [
70
+ ...(typeof persona.name === "string" && persona.name.trim() ? [STR.personaNameLine[language](persona.name.trim())] : []),
71
+ ...(typeof persona.personality === "string" && persona.personality.trim() ? [STR.personaPersonalityLine[language](persona.personality.trim())] : []),
72
+ ...(typeof persona.replyStyle === "string" && persona.replyStyle.trim() ? [STR.personaReplyStyleLine[language](persona.replyStyle.trim())] : [])
73
+ ]
74
+ : [];
75
+ // 事实解析用冻结副本。曾有一版是内联重写的,抄错了「先查卡命中还是先判 strict」的
76
+ // 顺序(Ephor 2026-11-07 复核指出,实测 3/5 情形与生产不一致);改成直接调生产
77
+ // 函数又落进自证陷阱。冻结副本是第三条路:顺序与 origin/main 一致,且不随生产漂。
78
+ const factsResolved = config?.profileFactsEnabled === true && typeof factsProvider === "function"
79
+ ? legacyResolveFactsCard(factsProvider(), agentId, { strict: config?.personaStrictAddressing === true })
80
+ : { facts: [], source: "none" };
81
+ const factLines = [];
82
+ if (factsResolved.facts.length) {
83
+ let chars = 0;
84
+ let shown = 0;
85
+ for (const fact of factsResolved.facts) {
86
+ const line = STR.profileFactLine[language](String(fact));
87
+ if (shown >= MAX_LINES || chars + line.length > MAX_CHARS) break;
88
+ factLines.push(line);
89
+ chars += line.length;
90
+ shown += 1;
91
+ }
92
+ const remaining = factsResolved.facts.length - shown;
93
+ if (remaining > 0) factLines.push(STR.profileFactsTruncated[language](remaining));
94
+ if (factLines.length) factLines.unshift(STR.profileFactsHeader[language](factsResolved.facts.length));
95
+ }
96
+ const id = typeof agentId === "string" && agentId.trim() ? agentId.trim() : null;
97
+ if (!profile && !rules.length && !personaLines.length && !factLines.length) {
98
+ return { text: "", source: resolved.source, agent: id, configGate: enabled };
99
+ }
100
+ const lines = [STR.userSettingsHeader[language]];
101
+ lines.push(...personaLines);
102
+ if (profile) lines.push(STR.profileLine[language](profile));
103
+ lines.push(...factLines);
104
+ for (const rule of rules) lines.push(STR.ruleLine[language](rule));
105
+ return { text: escapePromptVars(config, lines.join("\n")), source: resolved.source, agent: id, configGate: enabled };
106
+ }
107
+
108
+ // ---- ① text 逐字节不变 -------------------------------------------------------
109
+
110
+ const CASES = [
111
+ ["空库全空", makeSettings(), {}],
112
+ ["只有画像", makeSettings({ profile: "用户是规划背景" }), {}],
113
+ ["只有规则", makeSettings({ rules: ["不提及用户名", "先结论后展开"] }), {}],
114
+ ["画像+规则", makeSettings({ profile: "用户是规划背景", rules: ["不提及用户名"] }), {}],
115
+ [
116
+ "人格三行齐(default 卡)",
117
+ // name 里刻意放 {{x}}:人格段若被提前转义,转义是幂等的,下面的
118
+ // 「转义后等于 text」断言抓不到(CodeRabbit 2026-11-07 指出)。
119
+ makeSettings({ profile: "画像原文", rules: ["规则一"], persona: { enabled: true, name: "Amarre {{x}}", personality: "轻松幽默", replyStyle: "先结论" } }),
120
+ { personaEnabled: true }
121
+ ],
122
+ [
123
+ "人格卡只填 name(另两行不出现)",
124
+ makeSettings({ profile: "画像", rules: [], persona: { enabled: true, name: "Amarre" } }),
125
+ { personaEnabled: true }
126
+ ],
127
+ [
128
+ "人格卡 enabled:false(整段不渲染)",
129
+ makeSettings({ profile: "画像", rules: [], persona: { enabled: false, name: "Amarre" } }),
130
+ { personaEnabled: true }
131
+ ],
132
+ [
133
+ "byAgent 命中",
134
+ makeSettings({ profile: "画像", rules: [], byAgent: { zephyr: { enabled: true, name: "泽菲尔", personality: "冷危" } } }),
135
+ { personaEnabled: true, agentId: "zephyr" }
136
+ ],
137
+ [
138
+ "strict + 未命中(source=none)",
139
+ makeSettings({ profile: "画像", rules: [], byAgent: { zephyr: { enabled: true, name: "泽菲尔" } } }),
140
+ { personaEnabled: true, personaStrictAddressing: true, agentId: "ghost" }
141
+ ],
142
+ [
143
+ "总闸关着(人格行整段不出现,画像照出)",
144
+ makeSettings({ profile: "画像", rules: ["规则一"], persona: { enabled: true, name: "Amarre" } }),
145
+ { personaEnabled: false }
146
+ ],
147
+ ["英文 locale", makeSettings({ profile: "profile", rules: ["rule one"] }), { language: "en" }],
148
+ ["转义关掉", makeSettings({ profile: "画像 {{x}}", rules: ["规则 {{y}}"] }), { escapePromptVariables: false }],
149
+ ["转义默认开(裸 {{ 要插反斜杠)", makeSettings({ profile: "画像 {{x}}", rules: ["规则 }}y{{"] }), {}],
150
+ [
151
+ "事实段(未截断)",
152
+ makeSettings({ profile: "画像", rules: ["规则一"] }),
153
+ { profileFactsEnabled: true, factsProvider: () => ({ default: ["事实甲", "事实乙"], byAgent: {} }) }
154
+ ],
155
+ [
156
+ "事实段(超 40 行帽,截断)",
157
+ makeSettings({ profile: "画像", rules: [] }),
158
+ { profileFactsEnabled: true, factsProvider: () => ({ default: Array.from({ length: 50 }, (_, i) => `事实${i}`), byAgent: {} }) }
159
+ ],
160
+ [
161
+ "事实段(超 1200 字符帽,截断)",
162
+ makeSettings({ profile: "画像", rules: [] }),
163
+ { profileFactsEnabled: true, factsProvider: () => ({ default: Array.from({ length: 30 }, () => "字".repeat(100)), byAgent: {} }) }
164
+ ],
165
+ [
166
+ "事实段开关关着(与无 provider 同源)",
167
+ makeSettings({ profile: "画像", rules: [] }),
168
+ { profileFactsEnabled: false, factsProvider: () => ({ default: ["事实甲"], byAgent: {} }) }
169
+ ],
170
+ [
171
+ "事实段 strict 未命中(整段不渲染)",
172
+ makeSettings({ profile: "画像", rules: [] }),
173
+ { profileFactsEnabled: true, personaStrictAddressing: true, agentId: "ghost", factsProvider: () => ({ default: ["默认卡"], byAgent: { botA: ["botA 卡"] } }) }
174
+ ],
175
+ [
176
+ "事实段 byAgent 命中",
177
+ makeSettings({ profile: "画像", rules: [] }),
178
+ { profileFactsEnabled: true, agentId: "botA", factsProvider: () => ({ default: ["默认卡"], byAgent: { botA: ["botA 卡"] } }) }
179
+ ],
180
+ [
181
+ "事实里的 {{ }} 要转义",
182
+ makeSettings({ profile: "画像", rules: [] }),
183
+ { profileFactsEnabled: true, factsProvider: () => ({ default: ["模板 {{x}} 事实"], byAgent: {} }) }
184
+ ],
185
+ // 下面三条覆盖 resolveFactsCard 的四条出口:golden 曾把事实解析内联重写,
186
+ // 抄错了「先查本卡命中还是先判 strict」的顺序,而这 20 个用例当时全绿——说明
187
+ // 分歧路径没被覆盖。Ephor 2026-11-07 复核指出后补齐(实测内联版 3/5 分歧)。
188
+ [
189
+ "事实段 strict 开着但本卡命中(命中优先于 strict,不许判 none)",
190
+ makeSettings({ profile: "画像", rules: [] }),
191
+ { profileFactsEnabled: true, personaStrictAddressing: true, agentId: "botA", factsProvider: () => ({ default: ["默认卡"], byAgent: { botA: ["botA 卡"] } }) }
192
+ ],
193
+ [
194
+ "事实段 strict 关着且本卡未命中(落 default,不许判 byAgent 空)",
195
+ makeSettings({ profile: "画像", rules: [] }),
196
+ { profileFactsEnabled: true, agentId: "ghost", factsProvider: () => ({ default: ["默认卡"], byAgent: { botA: ["botA 卡"] } }) }
197
+ ],
198
+ [
199
+ "事实段 strict 开着但 byAgent 整体为空(#587 例外,仍落 default)",
200
+ makeSettings({ profile: "画像", rules: [] }),
201
+ { profileFactsEnabled: true, personaStrictAddressing: true, agentId: "botA", factsProvider: () => ({ default: ["默认卡"], byAgent: {} }) }
202
+ ]
203
+ ];
204
+
205
+ test("text 逐字节不变:新实现与改动前的实现,23 种配置下输出完全一致", () => {
206
+ // 逐个比对并把**所有**分歧收集完再断言:循环里直接 assert 会在第一条分歧处
207
+ // 抛出,后面几条根本没跑到(Ephor 2026-11-07 复核时暴露出这一点——把 golden
208
+ // 换回内联版只报 1 条,实际有 3 条分歧路径)。
209
+ const bad = [];
210
+ for (const [name, settings, extra] of CASES) {
211
+ const { agentId = null, ...config } = extra;
212
+ const args = { settings, config, agentId, factsProvider: config.factsProvider };
213
+ const before = legacyRender(args).text;
214
+ const after = renderUserSettingsBlock(args).text;
215
+ if (after !== before) bad.push(`「${name}」\n 改动前: ${JSON.stringify(before)}\n 改动后: ${JSON.stringify(after)}`);
216
+ }
217
+ assert.equal(bad.length, 0, `${bad.length} 种配置的注入文本变了:\n${bad.join("\n")}`);
218
+ });
219
+
220
+ // ---- ② segments 是 text 的唯一真源 ------------------------------------------
221
+
222
+ test("segments 拼回去等于未转义的整串,转义后等于 text(同源,不是第二份实现)", () => {
223
+ for (const [name, settings, extra] of CASES) {
224
+ const { agentId = null, ...config } = extra;
225
+ const out = renderUserSettingsBlock({ settings, config, agentId, factsProvider: config.factsProvider });
226
+ const joined = out.segments.map((s) => s.text).join("\n");
227
+ // 段必须是**未转义**的原文:只比「转义后等于 text」抓不到「某段被提前转义」——
228
+ // escapePromptVars 幂等(/[{}]{2,}/g 只在连续花括号前插反斜杠,已插过的串不再匹配),
229
+ // 提前转义一遍再整体转义一遍,结果与只转义一遍逐字节相同。
230
+ // 所以另取一份独立基线:同一套配置把转义关掉跑 golden,段拼装必须等于它。
231
+ const rawBefore = legacyRender({
232
+ settings,
233
+ config: { ...config, escapePromptVariables: false },
234
+ agentId,
235
+ factsProvider: config.factsProvider
236
+ }).text;
237
+ assert.equal(joined, rawBefore, `配置「${name}」:段拼装必须等于未转义原文(某段被提前转义就会在这里红)`);
238
+ assert.equal(escapePromptVars(config, joined), out.text, `配置「${name}」:转义后的段拼装必须等于 text`);
239
+ if (config.escapePromptVariables === false) {
240
+ assert.equal(joined, out.text, `配置「${name}」:转义关掉时段拼装与 text 直接相等`);
241
+ }
242
+ }
243
+ });
244
+
245
+ test("段顺序:表头恒第一 → 人格 → 画像 → 事实 → 规则(设计稿⑥的顺序,改它就是改提示词权重)", () => {
246
+ const settings = makeSettings({
247
+ profile: "画像原文",
248
+ rules: ["规则一", "规则二"],
249
+ persona: { enabled: true, name: "Amarre", personality: "轻松幽默", replyStyle: "先结论" }
250
+ });
251
+ const out = renderUserSettingsBlock({
252
+ settings,
253
+ config: { language: "zh", personaEnabled: true, profileFactsEnabled: true },
254
+ agentId: null,
255
+ factsProvider: () => ({ default: ["事实甲"], byAgent: {} })
256
+ });
257
+ assert.deepEqual(
258
+ out.segments.map((s) => s.kind),
259
+ ["header", "personaName", "personaPersonality", "personaReplyStyle", "profile", "factsHeader", "fact", "rule", "rule"]
260
+ );
261
+ });
262
+
263
+ test("人格段带 field,面板据此走字段级写回(不是整卡覆盖)", () => {
264
+ const settings = makeSettings({ persona: { enabled: true, name: "Amarre", personality: "轻松幽默", replyStyle: "先结论" } });
265
+ const out = renderUserSettingsBlock({ settings, config: { language: "zh", personaEnabled: true }, agentId: null });
266
+ const fields = out.segments.filter((s) => s.kind.startsWith("persona")).map((s) => s.field);
267
+ assert.deepEqual(fields, ["name", "personality", "replyStyle"]);
268
+ });
269
+
270
+ test("只填了 name 的人格卡:另外两个字段既不出段也不出空行", () => {
271
+ const settings = makeSettings({ persona: { enabled: true, name: "Amarre", personality: " ", replyStyle: "" } });
272
+ const out = renderUserSettingsBlock({ settings, config: { language: "zh", personaEnabled: true }, agentId: null });
273
+ assert.deepEqual(out.segments.filter((s) => s.kind.startsWith("persona")).map((s) => s.field), ["name"]);
274
+ });
275
+
276
+ // ---- ③ 事实段的 shown/total ------------------------------------------------
277
+
278
+ test("事实未截断:factsHeader 的 shown === total,且没有 factsTruncated 段", () => {
279
+ const out = renderUserSettingsBlock({
280
+ settings: makeSettings({ profile: "画像" }),
281
+ config: { language: "zh", profileFactsEnabled: true },
282
+ agentId: null,
283
+ factsProvider: () => ({ default: ["甲", "乙", "丙"], byAgent: {} })
284
+ });
285
+ const header = out.segments.find((s) => s.kind === "factsHeader");
286
+ assert.equal(header.shown, 3);
287
+ assert.equal(header.total, 3);
288
+ assert.equal(out.segments.filter((s) => s.kind === "fact").length, 3);
289
+ assert.equal(out.segments.some((s) => s.kind === "factsTruncated"), false, "没截断就不许有尾注段");
290
+ });
291
+
292
+ test("事实超行帽:factsHeader.total 是总条数,shown 是实际注入数,尾注 remaining 等于差值", () => {
293
+ const facts = Array.from({ length: 50 }, (_, i) => `事实${i}`);
294
+ const out = renderUserSettingsBlock({
295
+ settings: makeSettings({ profile: "画像" }),
296
+ config: { language: "zh", profileFactsEnabled: true },
297
+ agentId: null,
298
+ factsProvider: () => ({ default: facts, byAgent: {} })
299
+ });
300
+ const header = out.segments.find((s) => s.kind === "factsHeader");
301
+ const truncated = out.segments.find((s) => s.kind === "factsTruncated");
302
+ assert.equal(header.total, 50, "表头报总数,不是已注入数");
303
+ assert.equal(header.shown, 40, "行帽 40");
304
+ assert.equal(truncated.remaining, 10, "尾注如实报被丢弃的条数");
305
+ assert.equal(header.shown + truncated.remaining, header.total, "shown + remaining 必须配平");
306
+ assert.equal(out.segments.filter((s) => s.kind === "fact").length, 40);
307
+ });
308
+
309
+ test("事实超字符帽:shown 由字符预算决定,remaining 仍配平(两顶帽子任一先到都算)", () => {
310
+ const facts = Array.from({ length: 30 }, () => "字".repeat(100));
311
+ const out = renderUserSettingsBlock({
312
+ settings: makeSettings({ profile: "画像" }),
313
+ config: { language: "zh", profileFactsEnabled: true },
314
+ agentId: null,
315
+ factsProvider: () => ({ default: facts, byAgent: {} })
316
+ });
317
+ const header = out.segments.find((s) => s.kind === "factsHeader");
318
+ const truncated = out.segments.find((s) => s.kind === "factsTruncated");
319
+ assert.ok(header.shown > 0 && header.shown < 30, `字符帽先到时 shown 应落在中间,实际 ${header.shown}`);
320
+ assert.equal(header.shown + truncated.remaining, header.total);
321
+ });
322
+
323
+ test("事实段的 fact.index 是注入序号,从 0 连续递增(面板据此定位到行)", () => {
324
+ const out = renderUserSettingsBlock({
325
+ settings: makeSettings({ profile: "画像" }),
326
+ config: { language: "zh", profileFactsEnabled: true },
327
+ agentId: null,
328
+ factsProvider: () => ({ default: ["甲", "乙", "丙"], byAgent: {} })
329
+ });
330
+ assert.deepEqual(out.segments.filter((s) => s.kind === "fact").map((s) => s.index), [0, 1, 2]);
331
+ });
332
+
333
+ // ---- ④ 早退 ----------------------------------------------------------------
334
+
335
+ test("全空:segments 是空数组(不是 undefined,也不是一个只有表头的段)", () => {
336
+ const out = renderUserSettingsBlock({ settings: makeSettings(), config: {}, agentId: null });
337
+ assert.equal(out.text, "");
338
+ assert.deepEqual(out.segments, []);
339
+ });
340
+
341
+ test("只有人格段开着、画像规则事实全空:不算早退,segments 从表头开始", () => {
342
+ const out = renderUserSettingsBlock({
343
+ settings: makeSettings({ persona: { enabled: true, name: "Amarre" } }),
344
+ config: { language: "zh", personaEnabled: true },
345
+ agentId: null
346
+ });
347
+ assert.deepEqual(out.segments.map((s) => s.kind), ["header", "personaName"]);
348
+ });
349
+
350
+ // ---- ⑤ 段里不许有未转义的整串 ----------------------------------------------
351
+
352
+ test("段文本本身保持原样(未转义),转义只发生在 text 这一层", () => {
353
+ const settings = makeSettings({ profile: "画像 {{x}}" });
354
+ const out = renderUserSettingsBlock({ settings, config: { language: "zh" }, agentId: null });
355
+ const profileSeg = out.segments.find((s) => s.kind === "profile");
356
+ assert.ok(profileSeg.text.includes("{{x}}"), "段里是原文,面板要显示给人看");
357
+ assert.ok(!out.text.includes("{{x}}"), "text 里必须已转义");
358
+ });
359
+
360
+ // ---- ⑥ value:可回写的原始值(Ephor 2026-11-07 复核提出的第二期坑) ----------
361
+ //
362
+ // `text` 是带模板前缀的整行(`- 用户画像:xxx` / ` - xxx` / `- 规则:xxx`),
363
+ // 面板若直接拿它当新值回写,写一次叠一层前缀。可回写的段因此另带 `value`=
364
+ // 原始值(无前缀、未转义);表头、事实表头、截断尾注没有原始值,不带这个键。
365
+
366
+ test("画像段带 value=原始画像,text 仍是带前缀的整行", () => {
367
+ const out = renderUserSettingsBlock({ settings: makeSettings({ profile: "用户是规划背景" }), config: { language: "zh" }, agentId: null });
368
+ const seg = out.segments.find((s) => s.kind === "profile");
369
+ assert.equal(seg.value, "用户是规划背景", "value 必须是原始值,不带 `- 用户画像:` 前缀");
370
+ assert.equal(seg.text, STR.profileLine.zh("用户是规划背景"), "text 仍是整行");
371
+ assert.ok(seg.text !== seg.value, "两者不该相同,否则这条用例没测到前缀");
372
+ });
373
+
374
+ test("人格三段的 value 分别等于 name / personality / replyStyle 的原始值", () => {
375
+ const out = renderUserSettingsBlock({
376
+ settings: makeSettings({ persona: { enabled: true, name: "Amarre", personality: "懒洋洋", replyStyle: "先结论" } }),
377
+ config: { language: "zh", personaEnabled: true },
378
+ agentId: null
379
+ });
380
+ const byKind = Object.fromEntries(out.segments.filter((s) => s.kind.startsWith("persona")).map((s) => [s.kind, s]));
381
+ assert.equal(byKind.personaName.value, "Amarre");
382
+ assert.equal(byKind.personaPersonality.value, "懒洋洋");
383
+ assert.equal(byKind.personaReplyStyle.value, "先结论");
384
+ assert.equal(byKind.personaName.field, "name", "field 仍在,value 不取代它");
385
+ });
386
+
387
+ test("事实段的 value 是原始事实串,不带 ` - ` 前缀", () => {
388
+ const out = renderUserSettingsBlock({
389
+ settings: makeSettings({ profile: "画像" }),
390
+ config: { language: "zh", profileFactsEnabled: true },
391
+ agentId: null,
392
+ factsProvider: () => ({ default: ["甲事实", "乙事实"], byAgent: {} })
393
+ });
394
+ const facts = out.segments.filter((s) => s.kind === "fact");
395
+ assert.deepEqual(facts.map((s) => s.value), ["甲事实", "乙事实"]);
396
+ assert.equal(facts[0].text, STR.profileFactLine.zh("甲事实"));
397
+ assert.ok(facts[0].text !== facts[0].value);
398
+ });
399
+
400
+ test("规则段的 value 是原始规则串", () => {
401
+ const out = renderUserSettingsBlock({ settings: makeSettings({ profile: "画像", rules: ["提交时不得有AI署名"] }), config: { language: "zh" }, agentId: null });
402
+ const seg = out.segments.find((s) => s.kind === "rule");
403
+ assert.equal(seg.value, "提交时不得有AI署名");
404
+ assert.equal(seg.text, STR.ruleLine.zh("提交时不得有AI署名"));
405
+ });
406
+
407
+ test("没有原始值的段不带 value 键(不是 undefined、不是空串)", () => {
408
+ const out = renderUserSettingsBlock({
409
+ settings: makeSettings({ profile: "画像", rules: ["规则一"] }),
410
+ config: { language: "zh", profileFactsEnabled: true },
411
+ agentId: null,
412
+ factsProvider: () => ({ default: Array.from({ length: 50 }, (_, i) => `事实${i}`), byAgent: {} })
413
+ });
414
+ for (const kind of ["header", "factsHeader", "factsTruncated"]) {
415
+ const seg = out.segments.find((s) => s.kind === kind);
416
+ assert.ok(seg, `用例前提:应当存在 ${kind} 段`);
417
+ assert.ok(!("value" in seg), `${kind} 段没有原始值,不该带 value 键`);
418
+ }
419
+ });
420
+
421
+ test("回写幂等:拿 value 当新值再渲染,文本不变;拿 text 当新值会叠一层前缀", () => {
422
+ const config = { language: "zh" };
423
+ const original = renderUserSettingsBlock({ settings: makeSettings({ profile: "用户是规划背景" }), config, agentId: null });
424
+ const profileValue = original.segments.find((s) => s.kind === "profile").value;
425
+ const profileText = original.segments.find((s) => s.kind === "profile").text;
426
+
427
+ const fromValue = renderUserSettingsBlock({ settings: makeSettings({ profile: profileValue }), config, agentId: null });
428
+ assert.equal(fromValue.text, original.text, "用 value 回写必须幂等");
429
+
430
+ // 用 text 当新值回写:新值里已经含 `- 用户画像:` 前缀,再渲染时模板又加一层,
431
+ // 于是注入文本里出现两遍前缀。`value` 就是为挡这个坑存在的。
432
+ const fromText = renderUserSettingsBlock({ settings: makeSettings({ profile: profileText }), config, agentId: null });
433
+ const doubledLine = fromText.segments.find((s) => s.kind === "profile").text;
434
+ assert.notEqual(fromText.text, original.text, "用 text 回写必须改变注入文本,否则这条用例没测到");
435
+ assert.ok(
436
+ doubledLine.startsWith("- 用户画像:- 用户画像:"),
437
+ `用 text 回写应当叠成两层前缀,实际是:${doubledLine}`
438
+ );
439
+ assert.equal(
440
+ fromText.segments.find((s) => s.kind === "profile").value,
441
+ profileText,
442
+ "value 只如实回显存进去的东西(它就是原文),叠层发生在渲染这一步"
443
+ );
444
+ });
445
+
446
+ test("value 也是未转义的原文(转义只在 text 这一层)", () => {
447
+ const out = renderUserSettingsBlock({ settings: makeSettings({ profile: "画像 {{x}}" }), config: { language: "zh" }, agentId: null });
448
+ const seg = out.segments.find((s) => s.kind === "profile");
449
+ assert.equal(seg.value, "画像 {{x}}", "value 要能直接回写进 settings,不能带反斜杠");
450
+ assert.ok(!out.text.includes("{{x}}"), "text 里仍必须已转义");
451
+ });
452
+
453
+ test("人格字段带前后空格时 value 已 trim(回写进去的不能是带空格的原文)", () => {
454
+ const out = renderUserSettingsBlock({
455
+ settings: makeSettings({ persona: { enabled: true, name: " Amarre ", personality: " 懒洋洋 ", replyStyle: " 先结论 " } }),
456
+ config: { language: "zh", personaEnabled: true },
457
+ agentId: null
458
+ });
459
+ const byKind = Object.fromEntries(out.segments.filter((s) => s.kind.startsWith("persona")).map((s) => [s.kind, s]));
460
+ assert.equal(byKind.personaName.value, "Amarre", "value 要与 text 用的是同一个 trim 后的值");
461
+ assert.equal(byKind.personaPersonality.value, "懒洋洋");
462
+ assert.equal(byKind.personaReplyStyle.value, "先结论");
463
+ assert.ok(!byKind.personaName.text.includes(" "), "text 里也不该有那两处空格");
464
+ });
465
+
466
+ // ---- ④ golden 的冻结副本与生产是否还一致(漂移告警,不是等价断言)---------------
467
+ //
468
+ // golden 里那两份 resolve 副本是冻结的:生产怎么演进它们都不动,所以「text 逐字节
469
+ // 不变」这条结论始终对着一个历史基准成立。代价是副本会过期——如果生产改了判据而
470
+ // 副本没跟着改,golden 比的就不再是「改动前 vs 改动后」,而是「旧判据 vs 新判据」,
471
+ // 那批用例会开始报一些看不懂的分歧。
472
+ //
473
+ // 这条用例就是把那个时刻显式化:副本与生产一旦分歧就红,红的时候**别急着改副本**,
474
+ // 先判断生产这次改动是不是有意的——
475
+ // · 有意改判据 → 同时更新 legacyRender 的期望,并在 CHANGELOG 记清「基准已换代」;
476
+ // · 无意改坏 → 生产回退。
477
+ // 两种情况都不该由这条用例替你决定,它只负责让静默漂移变成一次可见的失败。
478
+ test("golden 的 resolve 冻结副本与生产实现一致(漂移告警)", () => {
479
+ const personaCases = [
480
+ ["无名会话", {}, null, false],
481
+ ["无名会话 + strict", {}, null, true],
482
+ ["default 卡", { default: { enabled: true, name: "A" } }, null, false],
483
+ ["default 卡 + strict", { default: { enabled: true, name: "A" } }, null, true],
484
+ ["本卡命中", { default: { enabled: true, name: "A" }, byAgent: { botA: { enabled: true, name: "B" } } }, "botA", false],
485
+ ["本卡命中 + strict", { default: { enabled: true, name: "A" }, byAgent: { botA: { enabled: true, name: "B" } } }, "botA", true],
486
+ ["本卡存在但关着", { default: { enabled: true, name: "A" }, byAgent: { botA: { enabled: false } } }, "botA", false],
487
+ ["本卡存在但关着 + strict", { default: { enabled: true, name: "A" }, byAgent: { botA: { enabled: false } } }, "botA", true],
488
+ ["建了别家的卡(未命中)", { default: { enabled: true, name: "A" }, byAgent: { botB: { enabled: true } } }, "botA", false],
489
+ ["建了别家的卡(未命中)+ strict", { default: { enabled: true, name: "A" }, byAgent: { botB: { enabled: true } } }, "botA", true],
490
+ ["byAgent 有键但空数组", { default: { enabled: true }, byAgent: { botA: [] } }, "botA", true],
491
+ ["agentId 带空格", { default: { enabled: true, name: "A" }, byAgent: { botA: { enabled: true, name: "B" } } }, " botA ", true],
492
+ ["agentId 空串", { default: { enabled: true, name: "A" } }, "", true]
493
+ ];
494
+ const factsCases = [
495
+ ["无名会话", { default: ["d"], byAgent: {} }, null, false],
496
+ ["无名会话 + strict", { default: ["d"], byAgent: {} }, null, true],
497
+ ["本卡命中", { default: ["d"], byAgent: { botA: ["a"] } }, "botA", false],
498
+ ["本卡命中 + strict", { default: ["d"], byAgent: { botA: ["a"] } }, "botA", true],
499
+ ["本卡空数组(未命中)", { default: ["d"], byAgent: { botA: [] } }, "botA", false],
500
+ ["本卡空数组(未命中)+ strict", { default: ["d"], byAgent: { botA: [] } }, "botA", true],
501
+ ["建了别家的卡 + strict", { default: ["d"], byAgent: { botB: ["b"] } }, "botA", true],
502
+ ["无 default 键", { byAgent: { botB: ["b"] } }, "botA", true],
503
+ ["undefined 存储", undefined, "botA", true]
504
+ ];
505
+
506
+ const drift = [];
507
+ for (const [name, store, agentId, strict] of personaCases) {
508
+ const legacy = legacyResolvePersonaCard({ getPersona: () => store }, agentId, { strict });
509
+ const real = resolvePersonaCard({ getPersona: () => store }, agentId, { strict });
510
+ if (JSON.stringify(legacy) !== JSON.stringify(real)) {
511
+ drift.push(`人格 / ${name}:golden=${JSON.stringify(legacy)} 生产=${JSON.stringify(real)}`);
512
+ }
513
+ }
514
+ for (const [name, store, agentId, strict] of factsCases) {
515
+ const legacy = legacyResolveFactsCard(store, agentId, { strict });
516
+ const real = resolveFactsCard(store, agentId, { strict });
517
+ if (JSON.stringify(legacy) !== JSON.stringify(real)) {
518
+ drift.push(`事实 / ${name}:golden=${JSON.stringify(legacy)} 生产=${JSON.stringify(real)}`);
519
+ }
520
+ }
521
+ assert.equal(
522
+ drift.length,
523
+ 0,
524
+ `生产判据变了,golden 的冻结副本已过期(${drift.length} 处)。先判断这次改动是否有意:\n` +
525
+ `有意 → 更新副本并记 CHANGELOG「基准已换代」;无意 → 回退生产。\n` +
526
+ drift.join("\n")
527
+ );
528
+ });