@modusensus/dsh-mneme 0.12.0 → 0.14.0-beta.1

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 (182) hide show
  1. package/README.md +51 -20
  2. package/bin/dsh-mneme-choro.mjs +9 -1
  3. package/docs/CONFIGURATION.md +47 -6
  4. package/docs/DIRECTORIES.md +114 -0
  5. package/docs/DISTILL.md +64 -0
  6. package/docs/DSH-GO.md +97 -0
  7. package/docs/ENTITIES.md +5 -3
  8. package/docs/FORGET.md +77 -0
  9. package/docs/KB.md +110 -0
  10. package/docs/SLEEP.md +28 -3
  11. package/docs/STORAGE.md +23 -0
  12. package/lib/api-standalone.js +4 -1
  13. package/lib/api.js +788 -30
  14. package/lib/attachment-text.js +122 -0
  15. package/lib/choro-command.js +9 -2
  16. package/lib/client.js +4627 -1409
  17. package/lib/config.js +166 -1
  18. package/lib/continuity.js +7 -6
  19. package/lib/diary/auto.js +190 -0
  20. package/lib/diary/compose.js +440 -0
  21. package/lib/diary/dir.js +59 -0
  22. package/lib/diary/export.js +97 -0
  23. package/lib/diary/index.js +26 -0
  24. package/lib/diary/library.js +189 -0
  25. package/lib/diary/qq-inbox.js +142 -0
  26. package/lib/diary/rollover.js +61 -0
  27. package/lib/diary/session-provider.js +122 -0
  28. package/lib/dream/decisions.js +95 -6
  29. package/lib/dream/sleep.js +760 -19
  30. package/lib/dream/triage.js +139 -0
  31. package/lib/dream.js +168 -21
  32. package/lib/dsh-go/host.js +162 -0
  33. package/lib/dsh-go/panel.js +2091 -0
  34. package/lib/dsh-go/text.js +342 -0
  35. package/lib/dsh-go/views/common.js +68 -0
  36. package/lib/dsh-go/views/entity.js +48 -0
  37. package/lib/dsh-go/views/memory.js +198 -0
  38. package/lib/dsh-go/views/root.js +30 -0
  39. package/lib/dsh-go/views/settings.js +398 -0
  40. package/lib/dsh-go/views/status.js +51 -0
  41. package/lib/embedding.js +42 -8
  42. package/lib/entities/extractor.js +36 -7
  43. package/lib/hot-memory.js +22 -11
  44. package/lib/index.js +123 -3
  45. package/lib/inject.js +203 -20
  46. package/lib/kb/index.js +309 -0
  47. package/lib/kb/routes.js +220 -0
  48. package/lib/kb/tools.js +148 -0
  49. package/lib/lang.js +98 -4
  50. package/lib/peak-hours.js +57 -2
  51. package/lib/semantic.js +347 -60
  52. package/lib/service.js +268 -69
  53. package/lib/settings.js +128 -15
  54. package/lib/store.js +585 -26
  55. package/lib/summarize.js +38 -11
  56. package/lib/tools.js +35 -4
  57. package/lib/user-settings.js +128 -32
  58. package/lib/vector-index.js +76 -6
  59. package/lib/wiki/claims.js +267 -0
  60. package/lib/wiki/cli-hint.js +90 -0
  61. package/lib/wiki/queries.js +282 -2
  62. package/lib/wiki/render.js +74 -0
  63. package/lib/wiki/t0.js +43 -6
  64. package/lib/wiki/t1-jobs.js +232 -0
  65. package/lib/wiki/t1.js +145 -32
  66. package/package.json +39 -1
  67. package/scripts/release-docs.mjs +7 -1
  68. package/scripts/release-notes.mjs +4 -1
  69. package/src/api-standalone.js +4 -1
  70. package/src/api.js +788 -30
  71. package/src/attachment-text.js +122 -0
  72. package/src/choro-command.js +9 -2
  73. package/src/config.js +166 -1
  74. package/src/continuity.js +7 -6
  75. package/src/diary/auto.js +190 -0
  76. package/src/diary/compose.js +440 -0
  77. package/src/diary/dir.js +59 -0
  78. package/src/diary/export.js +97 -0
  79. package/src/diary/index.js +26 -0
  80. package/src/diary/library.js +189 -0
  81. package/src/diary/qq-inbox.js +142 -0
  82. package/src/diary/rollover.js +61 -0
  83. package/src/diary/session-provider.js +122 -0
  84. package/src/dream/decisions.js +95 -6
  85. package/src/dream/sleep.js +760 -19
  86. package/src/dream/triage.js +139 -0
  87. package/src/dream.js +168 -21
  88. package/src/dsh-go/host.js +162 -0
  89. package/src/dsh-go/panel.js +2091 -0
  90. package/src/dsh-go/text.js +342 -0
  91. package/src/dsh-go/views/common.js +68 -0
  92. package/src/dsh-go/views/entity.js +48 -0
  93. package/src/dsh-go/views/memory.js +198 -0
  94. package/src/dsh-go/views/root.js +30 -0
  95. package/src/dsh-go/views/settings.js +398 -0
  96. package/src/dsh-go/views/status.js +51 -0
  97. package/src/embedding.js +42 -8
  98. package/src/entities/extractor.js +36 -7
  99. package/src/hot-memory.js +22 -11
  100. package/src/index.js +123 -3
  101. package/src/inject.js +203 -20
  102. package/src/kb/index.js +309 -0
  103. package/src/kb/routes.js +220 -0
  104. package/src/kb/tools.js +148 -0
  105. package/src/lang.js +98 -4
  106. package/src/peak-hours.js +57 -2
  107. package/src/semantic.js +347 -60
  108. package/src/service.js +268 -69
  109. package/src/settings.js +128 -15
  110. package/src/store.js +585 -26
  111. package/src/summarize.js +38 -11
  112. package/src/tools.js +35 -4
  113. package/src/user-settings.js +128 -32
  114. package/src/vector-index.js +76 -6
  115. package/src/wiki/claims.js +267 -0
  116. package/src/wiki/cli-hint.js +90 -0
  117. package/src/wiki/queries.js +282 -2
  118. package/src/wiki/render.js +74 -0
  119. package/src/wiki/t0.js +43 -6
  120. package/src/wiki/t1-jobs.js +232 -0
  121. package/src/wiki/t1.js +145 -32
  122. package/test/api-routes-gaps.test.js +63 -0
  123. package/test/api.test.js +385 -4
  124. package/test/attachment-text.test.js +140 -0
  125. package/test/bm25-corpus-cache.test.js +145 -0
  126. package/test/capacity-warning.test.js +262 -0
  127. package/test/client.test.js +1051 -185
  128. package/test/conflict-freeze.test.js +45 -1
  129. package/test/conflict-ledger-enforce.test.js +452 -0
  130. package/test/conflict-ledger.test.js +242 -0
  131. package/test/continuity.test.js +8 -1
  132. package/test/contrast.test.js +124 -12
  133. package/test/diary-library.test.js +187 -0
  134. package/test/diary.test.js +576 -0
  135. package/test/dream-baseline-clamp.test.js +174 -0
  136. package/test/dream-maxtokens-fallback.test.js +3 -3
  137. package/test/dream.test.js +62 -1
  138. package/test/dsh-go-host.test.js +134 -0
  139. package/test/dsh-go-panel-actions.test.js +854 -0
  140. package/test/dsh-go-panel.test.js +251 -0
  141. package/test/embedding-ready.test.js +33 -3
  142. package/test/entities.test.js +73 -0
  143. package/test/forget-corrections.test.js +70 -0
  144. package/test/graph-api.test.js +124 -0
  145. package/test/hot-memory.test.js +48 -0
  146. package/test/image-memory.test.js +171 -0
  147. package/test/inject-parent-gate.test.js +6 -3
  148. package/test/inject.test.js +59 -0
  149. package/test/issue-617-step1-query-and-fusion.test.js +361 -0
  150. package/test/kb-api-wiring.test.js +128 -0
  151. package/test/kb-collections.test.js +272 -0
  152. package/test/kb-routes.test.js +379 -0
  153. package/test/mint-source.test.js +26 -0
  154. package/test/panel-chart-scale.test.js +90 -0
  155. package/test/panel-tokens.test.js +245 -0
  156. package/test/peak-hours-observability.test.js +70 -0
  157. package/test/persona-api.test.js +3 -3
  158. package/test/persona-by-session.test.js +568 -0
  159. package/test/persona-settings.test.js +7 -7
  160. package/test/reflection-update-gate.test.js +129 -0
  161. package/test/reindex-backfill.test.js +334 -0
  162. package/test/reindex-scheduler.test.js +369 -0
  163. package/test/release-docs.test.js +20 -0
  164. package/test/release-notes.test.js +25 -0
  165. package/test/scope-candidates.test.js +249 -0
  166. package/test/security-doc.test.js +10 -0
  167. package/test/sleep-action-set.test.js +14 -0
  168. package/test/sleep-heat.test.js +49 -0
  169. package/test/sleep.test.js +635 -0
  170. package/test/standalone-api.test.js +24 -0
  171. package/test/store.test.js +20 -0
  172. package/test/summarize.test.js +26 -0
  173. package/test/user-settings-segments.test.js +528 -0
  174. package/test/vector-index.test.js +120 -6
  175. package/test/wiki-api.test.js +463 -1
  176. package/test/wiki-claims.test.js +194 -0
  177. package/test/wiki-cli-hint.test.js +118 -0
  178. package/test/wiki-provenance.test.js +202 -0
  179. package/test/wiki-queries.test.js +128 -0
  180. package/test/wiki-t0.test.js +77 -1
  181. package/test/wiki-t1-jobs.test.js +125 -0
  182. package/test/wiki-t1.test.js +226 -1
@@ -0,0 +1,251 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { readFileSync } from "node:fs";
4
+ import { dirname, join } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import { buildView } from "../src/dsh-go/views/root.js";
7
+
8
+ const HERE = dirname(fileURLToPath(import.meta.url));
9
+ const PKG_ROOT = join(HERE, "..");
10
+
11
+ // 这组守的是**视图契约**,不是像素:dsh-go 只认 JSON,越界(节点数 / 层数 / 字节)
12
+ // 是会在用户手机上直接报错的硬失败,所以每加一屏都要过一遍预算。
13
+ // 取数(src/dsh-go/host.js)与动作(src/dsh-go/panel.js)需要真宿主,端到端在
14
+ // dsh-go-bridge 那一侧验证;这里只锁得到「视图能拼出来、拼得合规」。
15
+
16
+ const LIMITS = { nodes: 120, depth: 8, bytes: 32768 };
17
+
18
+ /**
19
+ * 与 bridge 的 viewStats 同口径:节点 = 带 `type` 的组件对象(bindings / options /
20
+ * actions 里的参数对象不算节点),层数 = 组件 children 的递归深度。
21
+ */
22
+ function stats(node, depth = 1) {
23
+ if (node === null || typeof node !== "object") return { nodes: 0, depth };
24
+ if (Array.isArray(node)) {
25
+ let nodes = 0;
26
+ let deepest = depth;
27
+ for (const child of node) {
28
+ const s = stats(child, depth);
29
+ nodes += s.nodes;
30
+ deepest = Math.max(deepest, s.depth);
31
+ }
32
+ return { nodes, depth: deepest };
33
+ }
34
+ const isComponent = typeof node.type === "string";
35
+ let nodes = isComponent ? 1 : 0;
36
+ let deepest = isComponent ? depth : depth;
37
+ for (const value of Object.values(node)) {
38
+ if (value === null || typeof value !== "object") continue;
39
+ const s = stats(value, isComponent ? depth + 1 : depth);
40
+ nodes += s.nodes;
41
+ deepest = Math.max(deepest, s.depth);
42
+ }
43
+ return { nodes, depth: deepest };
44
+ }
45
+
46
+ /** 面板状态的默认形状(与 src/dsh-go/panel.js 的 state 对齐,覆盖视图读到的每个字段)。 */
47
+ function state(over = {}) {
48
+ return {
49
+ page: "list",
50
+ flagGroup: "basic",
51
+ settingsView: "settings",
52
+ sPanel: "menu",
53
+ profileTab: "profile",
54
+ q: "",
55
+ type: "all",
56
+ range: "all",
57
+ minImportance: 0,
58
+ scope: "all",
59
+ semantic: false,
60
+ viewMode: "flat",
61
+ heatSort: false,
62
+ filtersOpen: false,
63
+ offset: 0,
64
+ size: 20,
65
+ current: null,
66
+ form: {},
67
+ entityTotal: 12,
68
+ entityQuery: "",
69
+ entityName: "",
70
+ entityDepth: 1,
71
+ entityRelationCount: 0,
72
+ entityMemoryCount: 0,
73
+ flags: {},
74
+ flagOverrides: {},
75
+ pendingRestart: [],
76
+ pendingConflicts: 0,
77
+ pendingIds: new Set(),
78
+ rowsCount: 0,
79
+ monthGroups: [],
80
+ detailEntities: [],
81
+ detailEntityCount: 0,
82
+ detailConflict: false,
83
+ provenanceText: "",
84
+ conflictCount: 0,
85
+ conflictId: "",
86
+ conflictPick: "",
87
+ conflictA: null,
88
+ conflictB: null,
89
+ conflictReady: false,
90
+ wbKv: [],
91
+ wbDeposited: [],
92
+ wbArchived: [],
93
+ commands: [],
94
+ cmdCount: 0,
95
+ sCmdPick: "",
96
+ sCmdName: "",
97
+ sCmdDesc: "",
98
+ sCmdIns: "",
99
+ vector: { enabled: false, baseUrl: "", model: "" },
100
+ external: { enabled: false, port: 8790, host: "127.0.0.1" },
101
+ versionNote: "",
102
+ extractText: "",
103
+ injectPreviewText: "",
104
+ tokenCategories: [],
105
+ tokenSeries: [],
106
+ tokenKv: [],
107
+ heatSeries: [],
108
+ heatNote: "",
109
+ reclaimText: "",
110
+ reclaimConfirm: false,
111
+ notice: "",
112
+ noticeTone: "ok",
113
+ err: "",
114
+ ...over,
115
+ };
116
+ }
117
+
118
+ const SCREENS = [
119
+ ["记忆列表", state()],
120
+ ["记忆列表·月视图", state({ viewMode: "month", monthGroups: [{ label: "2026-11", rows: [] }] })],
121
+ ["记忆详情", state({ page: "detail", current: { id: "m1", archived: false } })],
122
+ ["记忆编辑", state({ page: "editor", form: { id: "m1", type: "project" } })],
123
+ ["记忆新建", state({ page: "editor", form: {} })],
124
+ ["冲突队列", state({ page: "conflict" })],
125
+ ["冲突对照", state({ page: "conflict", conflictReady: true, conflictPick: "a", conflictCount: 1 })],
126
+ ["实体", state({ entityTotal: 0 })],
127
+ ["实体详情", state({ page: "entity" })],
128
+ ["设置首页", state()],
129
+ ["状态", state({ settingsView: "status" })],
130
+ ["画像", state({ page: "profile" })],
131
+ ["人格", state({ page: "profile", profileTab: "persona" })],
132
+ ["规则", state({ page: "profile", profileTab: "rules" })],
133
+ ["工作动态", state({ sPanel: "workbench" })],
134
+ ["自定义指令", state({ sPanel: "commands", cmdCount: 1, commands: [{ id: "c1", name: "x" }], sCmdPick: "c1" })],
135
+ ["向量检索", state({ sPanel: "vector" })],
136
+ ["外部访问", state({ sPanel: "external" })],
137
+ ["关于", state({ sPanel: "help" })],
138
+ ["Agent 眼中的你", state({ sPanel: "facts" })],
139
+ ["代码库地志", state({ sPanel: "choro" })],
140
+ ];
141
+
142
+ for (const [name, fixture] of SCREENS) {
143
+ test(`dsh-go 视图在预算内:${name}`, () => {
144
+ const view = buildView(fixture);
145
+ assert.equal(view.v, 2);
146
+ const s = stats(view);
147
+ assert.ok(s.nodes <= LIMITS.nodes, `${name} 节点 ${s.nodes} > ${LIMITS.nodes}`);
148
+ assert.ok(s.depth <= LIMITS.depth, `${name} 层 ${s.depth} > ${LIMITS.depth}`);
149
+ const bytes = Buffer.byteLength(JSON.stringify(view), "utf8");
150
+ assert.ok(bytes <= LIMITS.bytes, `${name} 体积 ${bytes} > ${LIMITS.bytes}`);
151
+ });
152
+ }
153
+
154
+ test("dsh-go 顶层是三个原生页签(App 页签条每格 80dp,第四格会折行)", () => {
155
+ const view = buildView(state());
156
+ const tabs = view.children.find((node) => node.type === "tabs");
157
+ assert.ok(tabs, "顶层没有 tabs");
158
+ assert.deepEqual(
159
+ tabs.items.map((item) => item.label),
160
+ ["记忆", "实体", "设置"],
161
+ );
162
+ });
163
+
164
+ test("功能开关铺在设置页正文里,不是子页面(#dsh-go 对齐电脑端分组)", () => {
165
+ const view = buildView(state());
166
+ const flat = JSON.stringify(view);
167
+ assert.ok(flat.includes("功能开关"), "缺少功能开关标题");
168
+ assert.ok(!flat.includes("功能开关(分组)"), "标题仍带着「(分组)」");
169
+ assert.ok(flat.includes('"bind":"data.flagGroup"'), "分组选择器没有绑定");
170
+ assert.ok(/"bind":"data\.flags\./.test(flat), "没有开关绑定到 data.flags.*");
171
+ assert.ok(!flat.includes('"k":"flags"'), "还有指向 flags 子页面的入口");
172
+ });
173
+
174
+ function walk(node, out = []) {
175
+ if (node === null || typeof node !== "object") return out;
176
+ if (Array.isArray(node)) {
177
+ for (const child of node) walk(child, out);
178
+ return out;
179
+ }
180
+ out.push(node);
181
+ for (const value of Object.values(node)) walk(value, out);
182
+ return out;
183
+ }
184
+
185
+ test("设置页里不再出现自造的解释性小字(只留 mneme 原文案)", () => {
186
+ const flat = JSON.stringify(buildView(state()));
187
+ const invented = [
188
+ "38 项开关",
189
+ "写成斜杠命令,随时调用",
190
+ "跨会话记住你是谁",
191
+ "接口地址、模型、重建索引",
192
+ "版本检测与数据来源",
193
+ "轻量模式少做后台整理",
194
+ "沉淀 =",
195
+ ];
196
+ for (const phrase of invented) assert.ok(!flat.includes(phrase), `自造文案还在:${phrase}`);
197
+ assert.ok(flat.includes("控制插件在后台做哪些事"), "缺少 mneme 原文案(features.desc)");
198
+ assert.ok(flat.includes("轻量模式只保留核心能力"), "缺少 mneme 原文案(mode.desc)");
199
+ });
200
+
201
+ test("dsh-go 清单合法:一个面板、placement 只落 session.tab / features", () => {
202
+ const pkg = JSON.parse(readFileSync(join(PKG_ROOT, "package.json"), "utf8"));
203
+ const manifest = pkg.dshGo;
204
+ assert.equal(manifest.v, 1);
205
+ const panels = manifest.contributes.panels;
206
+ assert.equal(panels.length, 1);
207
+ const [panel] = panels;
208
+ assert.equal(panel.id, "mneme");
209
+ assert.ok(["session.tab", "features"].includes(panel.placement[0]));
210
+ // 面板里不出现密钥 / 令牌字样:清单是 App 侧可读的静态文件
211
+ assert.ok(!JSON.stringify(manifest).includes("token"));
212
+ });
213
+
214
+ test("记忆页与实体页的树不随数据变:首屏和拿到数据后是同一棵树", () => {
215
+ // 面板的 view() 在 data() 之前就要交出整棵树,所以按数据决定「要不要这块」
216
+ // 的地方,首屏会先按默认值定形,之后数据推来了也只是填绑定——空状态块就会
217
+ // 和有数据的列表同时挂在屏幕上。这里把两棵树直接比对,锁住结构稳定。
218
+ const empty = JSON.stringify(buildView(state({ rowsCount: 0, entityTotal: 0 })));
219
+ const filled = JSON.stringify(buildView(state({ rowsCount: 20, entityTotal: 12 })));
220
+ assert.equal(empty, filled, "视图结构仍然跟着数据走,首屏会留下残影");
221
+ assert.ok(!empty.includes('"type":"emptyState"'), "记忆 / 实体页不再用数据决定空状态块");
222
+ });
223
+
224
+ test("翻页是 App 原生的两段 segmented:一行铺满,不折行也不叠按钮", () => {
225
+ const nodes = walk(buildView(state()));
226
+ const pager = nodes.find((node) => node.type === "segmented" && node.bind === "data.pagePick");
227
+ assert.ok(pager, "缺少翻页 segmented");
228
+ assert.deepEqual(
229
+ pager.options.map((option) => option.label),
230
+ ["上一页", "下一页"],
231
+ );
232
+ assert.deepEqual(
233
+ pager.options.map((option) => option.value),
234
+ ["prev", "next"],
235
+ );
236
+ assert.equal(pager.onChange.id, "page");
237
+ // 页码文案挂在 label 上(必绘),不再单独占一行。
238
+ assert.deepEqual(pager.label, { $: "data.pageText" });
239
+
240
+ // 回归:曾经用两个 stack + chips 做左右对齐,右列在真机上渲染失败
241
+ // (App 显示「这部分无法显示」),所以翻页只能是单个节点。
242
+ assert.equal(
243
+ nodes.filter((node) => node.type === "chips" && node.bind === "data.pagePick").length,
244
+ 0,
245
+ "翻页又回到了 chips 方案",
246
+ );
247
+ const folded = nodes.filter(
248
+ (node) => node.type === "actions" && (node.items ?? []).some((item) => item.action?.id === "page"),
249
+ );
250
+ assert.equal(folded.length, 0, "翻页按钮还在 actions 里:窄屏会被折成上下两行");
251
+ });
@@ -159,10 +159,12 @@ test("reindexMissing: 逐行回填并统计 indexed/skipped,有成功才记模
159
159
  test("reindexMissing: 未启用时直接返回 0/0,不碰 store", async () => {
160
160
  const { embedder } = make({ ...FULL, enabled: false });
161
161
  const result = await embedder.reindexMissing(50);
162
- assert.deepEqual(result, { indexed: 0, skipped: 0 });
162
+ assert.deepEqual(result, { indexed: 0, failed: 0, skipped: 0 });
163
163
  });
164
164
 
165
- test("reindexMissing: 嵌入失败的行走 skipped,不算 indexed", async () => {
165
+ test("reindexMissing: 嵌入失败的行走 failed,不算 indexed、也不算 skipped", async () => {
166
+ // #604:`skipped = rows.length - indexed` 把「试过没拿到向量」和「压根没碰」
167
+ // 混成一个数。每一行都被尝试过,所以 skipped 必须是 0,失败单列。
166
168
  const writes = [];
167
169
  const embedder = createEmbedder({
168
170
  store: {
@@ -174,7 +176,35 @@ test("reindexMissing: 嵌入失败的行走 skipped,不算 indexed", async ()
174
176
  await withFetch(async () => ({ ok: false }), async () => {
175
177
  const result = await embedder.reindexMissing(50);
176
178
  assert.equal(result.indexed, 0);
177
- assert.equal(result.skipped, 1, "嵌入失败的行走 skipped,不算 indexed");
179
+ assert.equal(result.failed, 1, "试过但没拿到向量 → failed");
180
+ assert.equal(result.skipped, 0, "选择器选中了它,所以它没有被跳过");
181
+ });
182
+ });
183
+
184
+ test("reindexMissing: 一行写入抛错不中断其余行,且计入 failed", async () => {
185
+ // 注意:这里抛错的不能是 embedding 请求 —— embedTextDetailed 把网络错误吞成
186
+ // request-failed:network,永远不抛。真正会让这个 catch 跑起来的是写库失败
187
+ // (约束冲突、磁盘满),所以用 setEmbedding 抛。
188
+ const writes = [];
189
+ const embedder = createEmbedder({
190
+ store: {
191
+ setEmbedding: (id, v) => {
192
+ if (id === "m1") throw new Error("SQLITE_CONSTRAINT");
193
+ writes.push({ id, v });
194
+ },
195
+ needsEmbedding: () => [
196
+ { id: "m1", title: "t1", content: "a" },
197
+ { id: "m2", title: "t2", content: "b" }
198
+ ]
199
+ },
200
+ settings: { getVectorConfig: () => FULL },
201
+ logger: { info() {}, warn() {} }
202
+ });
203
+ await withFetch(async () => okResponse([1, 0]), async () => {
204
+ const result = await embedder.reindexMissing(50);
205
+ assert.equal(result.failed, 1, "写库失败的那行算 failed");
206
+ assert.equal(result.indexed, 1, "后面那行仍然被处理,循环没有中断");
207
+ assert.equal(writes.length, 1);
178
208
  });
179
209
  });
180
210
 
@@ -296,6 +296,79 @@ test("extractor writes entities, attrs and relations from mock LLM output", asyn
296
296
  store.close();
297
297
  });
298
298
 
299
+ test("extractor refuses to write affective_context, which belongs to the sleep phase", async () => {
300
+ // #619:affective_context 只有一个写入者——sleep 的 affective 相位。实体抽取发生
301
+ // 在写入当下,手里没有会话边界,判不了「一次场合」和「反复出现的特质」,写进去的
302
+ // 只会是一条没有 emotion/attribution/sessionCount 的半成品边,同一张表两种 schema。
303
+ // 所以抽取器把它当未知类型拒收,而不是当一等公民放行。
304
+ const store = openStore();
305
+ const memory = store.save({ type: "history", title: "答辩前一晚", content: "答辩前一晚他很焦虑", importance: 3 });
306
+ const warnings = [];
307
+ const logger = { warn: (msg) => warnings.push(String(msg)), info: () => {} };
308
+ const callLLM = jsonLLM({
309
+ entities: [
310
+ { name: "他", type: "person", attrs: [] },
311
+ { name: "答辩", type: "concept", attrs: [] }
312
+ ],
313
+ relations: [{ from: "他", to: "答辩", type: "affective_context" }]
314
+ });
315
+ const result = await extractEntities(memory, { store, config: {}, callLLM, logger });
316
+ assert.equal(result.ok, true);
317
+ assert.equal(result.relations.length, 0);
318
+ assert.deepEqual(result.rejectedRelationTypes, ["affective_context"]);
319
+ assert.equal(warnings.length, 1);
320
+ assert.match(warnings[0], /affective_context/);
321
+ assert.equal(store.getRelations(store.findEntityByName("他").id).length, 0);
322
+ store.close();
323
+ });
324
+
325
+ test("extractor rejects unknown relation types instead of silently downgrading to related_to", async () => {
326
+ // 变异自证:把 sanitizeRelationType 改回 `return "related_to"` 兜底,这条必红
327
+ // ——既会多落一条假边,rejectedRelationTypes 也会变空、warn 不再触发。
328
+ const store = openStore();
329
+ const memory = store.save({ type: "project", title: "混入未知类型", content: "A 对 B 有种奇怪的关系", importance: 3 });
330
+ const warnings = [];
331
+ const logger = { warn: (msg) => warnings.push(String(msg)), info: () => {} };
332
+ const callLLM = jsonLLM({
333
+ entities: [
334
+ { name: "A", type: "concept", attrs: [] },
335
+ { name: "B", type: "concept", attrs: [] }
336
+ ],
337
+ relations: [
338
+ { from: "A", to: "B", type: "feels_weird_about" },
339
+ { from: "A", to: "B", type: "related_to" }
340
+ ]
341
+ });
342
+ const result = await extractEntities(memory, { store, config: {}, callLLM, logger });
343
+ assert.equal(result.ok, true);
344
+ assert.deepEqual(result.rejectedRelationTypes, ["feels_weird_about"]);
345
+ assert.equal(warnings.length, 1);
346
+ assert.match(warnings[0], /feels_weird_about/);
347
+ const rels = store.getRelations(store.findEntityByName("A").id);
348
+ assert.equal(rels.length, 1, "only the legitimate relation lands");
349
+ assert.equal(rels[0].relation_type, "related_to");
350
+ store.close();
351
+ });
352
+
353
+ test("extractor drops a relation whose type is not even a string", async () => {
354
+ const store = openStore();
355
+ const memory = store.save({ type: "project", title: "空类型", content: "A 和 B 有联系", importance: 3 });
356
+ const callLLM = jsonLLM({
357
+ entities: [
358
+ { name: "A", type: "concept", attrs: [] },
359
+ { name: "B", type: "concept", attrs: [] }
360
+ ],
361
+ relations: [{ from: "A", to: "B" }]
362
+ });
363
+ const result = await extractEntities(memory, { store, config: {}, callLLM });
364
+ assert.equal(result.ok, true);
365
+ assert.equal(result.relations.length, 0);
366
+ assert.deepEqual(result.rejectedRelationTypes, ["undefined"]);
367
+ assert.deepEqual(store.getRelations(store.findEntityByName("A").id), []);
368
+ store.close();
369
+ });
370
+
371
+
299
372
  test("resolveEntity dedups: re-extracting the same name bumps mention_count", async () => {
300
373
  const store = openStore();
301
374
  const memory = store.save({ type: "preference", title: "工具", content: "用 Vite", importance: 3 });
@@ -0,0 +1,70 @@
1
+ // #532 选择性遗忘 × 事实更正的结构性回归锁。
2
+ //
3
+ // 现象(议题主楼):更正两个事实 → 只忘一个 → 保留的那个答回更正前的旧值。
4
+ // 根因是三段契约的接缝(docs/FORGET.md §1):更正由蒸馏落成新行(三元组去重不
5
+ // 命中、dream 冲突裁决阈值门控未运行)→ 旧行与新行之间没有持久失效关系 →
6
+ // memory_forget 按行抑制,合流在同一行里的其他事实连带退出可见面,旧值行成了
7
+ // 唯一幸存载体。
8
+ //
9
+ // 本文件锁两件事:
10
+ // ① 存储层合成轨迹(零 LLM):两行并存、遗忘新行、旧行原样幸存——这是现象的
11
+ // 存储层等价物。**机制修复落地时 ⑤ 的断言应当翻转**:更正行写入后旧行要么
12
+ // 被记上失效关系、要么退出活跃面;注释里写明翻转条件,防止它被当成「永远
13
+ // 该绿」的现状锁读。
14
+ // ② memory_forget 的工具描述必须带行粒度警示:模型无从得知「请求的语义粒度
15
+ // (一个事实)」与「工具的能力粒度(一行)」之间的落差,除非描述告诉它。
16
+ import test from "node:test";
17
+ import assert from "node:assert/strict";
18
+ import { createStore } from "../src/store.js";
19
+ import { createService } from "../src/service.js";
20
+ import { createTools } from "../src/tools.js";
21
+
22
+ test("#532 合成轨迹:更正落新行、遗忘新行、旧行原样幸存(机制修复落地时 ⑤ 翻转)", () => {
23
+ const store = createStore(":memory:");
24
+ const service = createService({ store, mirror: null, config: {} });
25
+ try {
26
+ // ① 原始陈述(对话 A 的蒸馏产物:颜色 + 时间合流一行)
27
+ const r1 = service.saveWithDedupe({
28
+ type: "preference", title: "纸模工具的收纳与整理",
29
+ content: "纸模工具放在雾蓝色收纳盒,每周三晚上八点整理一次", importance: 3
30
+ });
31
+ // ② 更正(对话 B:蒸馏器另起标题 → (type,title,scope) 三元组不命中 → 新行)
32
+ const r2 = service.saveWithDedupe({
33
+ type: "preference", title: "收纳盒颜色与整理时间的更正",
34
+ content: "收纳盒换成浅橙色,整理时间改为每周六上午九点", importance: 3
35
+ });
36
+ assert.notEqual(r1.memory.id, r2.memory.id, "标题不同 → 去重不命中,更正落成新行");
37
+ // ③ 结构断言:两行同时活跃,二者之间没有任何失效链接(本车道基线未含 #275
38
+ // 的 capacityStats,直接查列)
39
+ const activeCount = () => store.db.prepare("SELECT COUNT(*) AS n FROM memories WHERE forgotten = 0 AND archived = 0").get().n;
40
+ assert.equal(activeCount(), 2, "更正不淘汰旧行(dream 未运行的阈值下无失效关系)");
41
+ // ④ 「忘掉颜色」=按行遗忘新值行(模型在可见行里看到的颜色载体)
42
+ service.setForget(r2.memory.id, true);
43
+ // ⑤ 现象复现:新值行退出可见面后,旧值行仍是唯一幸存载体——这正是 #532
44
+ // 「保留的更正事实答回旧值」的存储层等价物。机制修复(写侧更正失效关系 /
45
+ // dream 触发前移,见 docs/FORGET.md §3)落地时翻转:旧行应被记上失效关系
46
+ // 或退出活跃面,断言改为不存在「幸存的旧值行」。
47
+ assert.equal(store.getById(r1.memory.id).forgotten, false, "旧行从未被任何机制波及(toRow 把列归一成 boolean)");
48
+ assert.match(store.getById(r1.memory.id).content, /每周三晚上八点/, "旧值仍在召回面");
49
+ // 被抑制的行可恢复(接口契约:行粒度遗忘不是删除)——恢复后两行回到并存
50
+ // 状态:失效关系的缺位不因遗忘/恢复而改变。
51
+ service.setForget(r2.memory.id, false);
52
+ assert.equal(activeCount(), 2);
53
+ } finally {
54
+ store.close();
55
+ }
56
+ });
57
+
58
+ test("memory_forget 的工具描述必须带行粒度警示(#532 对话 5 的模型误用直接对策)", () => {
59
+ const registered = [];
60
+ const ctx = { tools: { register(def) { registered.push(def); return () => {}; } } };
61
+ createTools(ctx, { getById: () => undefined }, {}, undefined);
62
+ const forget = registered.find((d) => d.name === "memory_forget");
63
+ assert.ok(forget, "memory_forget 必须注册");
64
+ // 锁「语义」不锁逐字:描述必须让模型知道抑制按行、合流事实连带退出、
65
+ // 请求只针对一个事实时先看行内容或拆分。防的回归是有人把描述改回
66
+ // 「只说单条抑制」,模型在「忘掉 X」请求下重新对合流行整条下手。
67
+ const desc = String(forget.description || "");
68
+ assert.match(desc, /per ROW/i, "描述必须写明抑制粒度是行");
69
+ assert.match(desc, /memory_update|inspect|say in your reply/i, "描述必须给出合流行下的替代动作");
70
+ });
@@ -207,3 +207,127 @@ test("ego 2-hop traversal work is bounded by limit, not by graph size (#409)", a
207
207
  // 250ms(≈4× 实测最坏值)只为兜住数量级级别的性能崩塌(#409)。
208
208
  assert.ok(ms < 250, `2-hop query took ${ms.toFixed(1)}ms — the work bound above is the lock; 250ms is only an outlier guard`);
209
209
  });
210
+
211
+ // #619 可见化 B:情感边的 metadata 必须原样出 API。
212
+ // 这条边上的全部信息量(情绪 / 谁对谁 / 几个场合 / 几条证据)都只存在 metadata 里,
213
+ // 上一版这里只映射 id/from/to/relation_type/memory_id/created_at/weight/source,于是
214
+ // 数据在库里有、在 store.toRelation 里也有,却在最后一跳被丢掉——面板上看起来就是
215
+ // 「这个功能没做」。所以这里钉的不是「字段存在」,而是「一个不认识的字段也能穿过去」。
216
+ test("#619 ego edges carry relation metadata through untouched", async () => {
217
+ const { routes, service } = setup();
218
+ const person = service.createEntity({ name: "桉桉", type: "person" });
219
+ const target = service.createEntity({ name: "答辩", type: "concept" });
220
+ const meta = {
221
+ emotion: "焦虑",
222
+ attribution: "occasion",
223
+ why: "答辩前一晚睡不着",
224
+ evidence: ["m-1", "m-2"],
225
+ sessionCount: 2,
226
+ model: "mock:affective-model",
227
+ runId: "run-1",
228
+ // 故意塞一个现在没人认识的字段:API 层不许按白名单裁剪,否则下一个新字段
229
+ // 又会「写进去了但看不见」,这正是这一轮要修的那类静默。
230
+ futureField: { nested: [1, 2, 3] }
231
+ };
232
+ service.saveRelation({
233
+ from_entity: person.id,
234
+ to_entity: target.id,
235
+ relation_type: "affective_context",
236
+ memory_id: "m-1",
237
+ source: "llm",
238
+ metadata: meta
239
+ });
240
+
241
+ const res = new FakeRes();
242
+ await graphRoute(routes, "/api/dsh-mneme/semantic/graph/ego")
243
+ .handler(req("/api/dsh-mneme/semantic/graph/ego?entity=桉桉&depth=1"), res);
244
+ assert.equal(res.statusCode, 200);
245
+ const edge = JSON.parse(res.body).edges.find((e) => e.relation_type === "affective_context");
246
+ assert.ok(edge, "the affective edge is on the ego graph");
247
+ assert.deepEqual(edge.metadata, meta, "metadata passes through field for field, including unknown ones");
248
+ });
249
+
250
+ // 反方向同样要钉:没有 metadata 的边给 null,不给 {}。
251
+ // 前端要靠这个区分「老边本来就没有情绪」和「有 metadata 但情绪字段丢了」——
252
+ // 给空对象会让两种情形在界面上长得一模一样。
253
+ test("#619 ego edges without metadata report null, not an empty object", async () => {
254
+ const { routes, service } = setup();
255
+ const map = buildChain(service, ["A", "B"]);
256
+ assert.ok(map.size === 2);
257
+ const res = new FakeRes();
258
+ await graphRoute(routes, "/api/dsh-mneme/semantic/graph/ego")
259
+ .handler(req("/api/dsh-mneme/semantic/graph/ego?entity=A&depth=1"), res);
260
+ assert.equal(res.statusCode, 200);
261
+ const edge = JSON.parse(res.body).edges[0];
262
+ assert.equal(edge.metadata, null, "absent metadata is null so the UI can tell it apart from a populated one");
263
+ });
264
+
265
+ // #619 可见化 C:情绪归因一览。ego 只能从某个实体往外看,回答不了「库里有
266
+ // 哪些情绪」。这条路由是全局的反向视角,三条断言各自钉一件会骗人的事。
267
+ test("#619 affective overview groups by person, target, emotion and keeps occasion apart from trait", async () => {
268
+ const { routes, service } = setup();
269
+ const person = service.createEntity({ name: "桉桉", type: "person" });
270
+ const defense = service.createEntity({ name: "答辩", type: "concept" });
271
+ const interview = service.createEntity({ name: "面试", type: "concept" });
272
+ // 同一情绪、两个对象、两个归属档:四组,不能被合掉任何一组。
273
+ service.saveRelation({
274
+ from_entity: person.id, to_entity: defense.id, relation_type: "affective_context",
275
+ memory_id: "m-1", source: "llm",
276
+ metadata: { emotion: "焦虑", attribution: "occasion", sessionCount: 1, evidence: ["m-1"] }
277
+ });
278
+ service.saveRelation({
279
+ from_entity: person.id, to_entity: interview.id, relation_type: "affective_context",
280
+ memory_id: "m-2", source: "llm",
281
+ metadata: { emotion: "焦虑", attribution: "occasion", sessionCount: 1, evidence: ["m-2"] }
282
+ });
283
+ service.saveRelation({
284
+ from_entity: person.id, to_entity: defense.id, relation_type: "affective_context",
285
+ memory_id: "m-3", source: "llm",
286
+ metadata: {
287
+ emotion: "焦虑", attribution: "trait", sessionCount: 1, traitSessionCount: 3,
288
+ promotedToTrait: true, evidence: ["m-1", "m-3", "m-4"]
289
+ }
290
+ });
291
+ // 没有情绪名的边:不进聚合,但要被计入 skippedEdges 而不是消失。
292
+ service.saveRelation({
293
+ from_entity: person.id, to_entity: defense.id, relation_type: "affective_context",
294
+ memory_id: "m-5", source: "llm", metadata: { attribution: "occasion" }
295
+ });
296
+ // 别的类型不受影响。
297
+ service.saveRelation({ from_entity: person.id, to_entity: defense.id, relation_type: "uses" });
298
+
299
+ const res = new FakeRes();
300
+ await graphRoute(routes, "/api/dsh-mneme/semantic/graph/affective")
301
+ .handler(req("/api/dsh-mneme/semantic/graph/affective"), res);
302
+ assert.equal(res.statusCode, 200);
303
+ const data = JSON.parse(res.body);
304
+ assert.equal(data.items.length, 3, "两个对象 × occasion + 一个 trait = 三组");
305
+ assert.equal(data.skippedEdges, 1, "没有情绪名的边要单独计数,不能静默消失");
306
+
307
+ const trait = data.items.find((i) => i.attribution === "trait");
308
+ assert.ok(trait, "trait 组必须存在");
309
+ assert.equal(trait.person, "桉桉");
310
+ assert.equal(trait.target, "答辩");
311
+ assert.equal(trait.traitSessionCount, 3, "trait 要能说出跨了几个独立会话");
312
+ assert.equal(trait.promotedToTrait, true);
313
+ assert.equal(data.items[0].attribution, "trait", "trait 排在 occasion 前面");
314
+
315
+ const occasions = data.items.filter((i) => i.attribution === "occasion");
316
+ assert.equal(occasions.length, 2);
317
+ assert.deepEqual(occasions.map((o) => o.target).sort(), ["答辩", "面试"],
318
+ "同一情绪对不同对象是两组,不能被合并");
319
+ });
320
+
321
+ // 读不到的时候必须是 500,不能给空列表——空列表在面板上跟「你从没记过情绪」
322
+ // 长得一模一样,而这两件事对用户的含义正好相反。
323
+ test("#619 affective overview reports 500 when the store cannot answer", async () => {
324
+ const { routes, service } = setup();
325
+ const original = service.relationsByType;
326
+ service.relationsByType = undefined;
327
+ const res = new FakeRes();
328
+ await graphRoute(routes, "/api/dsh-mneme/semantic/graph/affective")
329
+ .handler(req("/api/dsh-mneme/semantic/graph/affective"), res);
330
+ assert.equal(res.statusCode, 500, "取不到就是取不到,不能假装是空的");
331
+ assert.equal(JSON.parse(res.body).error, "unavailable");
332
+ service.relationsByType = original;
333
+ });
@@ -209,3 +209,51 @@ test("roundsFromEvents with an empty surface yields no rounds (model sees nothin
209
209
  const events = [userEvent(1, "问题"), assistantEvent(2, "回答")];
210
210
  assert.equal(roundsFromEvents(events, 5, new Set()).length, 0);
211
211
  });
212
+
213
+ // --- Issue #637:omitPendingRound —— 注入侧不回放「正在组装的这一轮」 ---
214
+
215
+ test("omitPendingRound drops the trailing in-flight round (the #637 empty-A churn source)", () => {
216
+ // 本轮刚开了头(提问已落日志、回答还没来):默认行为渲染空 A: 行——
217
+ // 宿主「文本变了就追加快照」的契机会让回答落地后这一行抖成正文、再多追一份。
218
+ const events = [userEvent(1, "第一问"), assistantEvent(2, "第一答"), userEvent(3, "第二问")];
219
+ const rounds = roundsFromEvents(events, 5, null, { omitPendingRound: true });
220
+ assert.equal(rounds.length, 1, "the in-flight round is not replayed");
221
+ assert.equal(rounds[0].query, "第一问");
222
+ assert.ok(!JSON.stringify(rounds).includes("第二问"), "the current turn's query never reaches hot memory");
223
+ });
224
+
225
+ test("omitPendingRound keeps a closed round — the block changes exactly once per turn", () => {
226
+ // 回答落地(轮闭合)是热块在一轮内唯一该变化的时刻:此前所有组装看到同一份
227
+ // 文本,此刻变成「历史轮 + 刚闭合的轮」→ 宿主恰好追一份快照。
228
+ const open = [userEvent(1, "第一问"), assistantEvent(2, "第一答"), userEvent(3, "第二问")];
229
+ const closed = [userEvent(1, "第一问"), assistantEvent(2, "第一答"), userEvent(3, "第二问"), assistantEvent(4, "第二答")];
230
+ const before = roundsFromEvents(open, 5, null, { omitPendingRound: true });
231
+ const after = roundsFromEvents(closed, 5, null, { omitPendingRound: true });
232
+ assert.equal(before.length, 1);
233
+ assert.equal(after.length, 2);
234
+ assert.deepEqual(after[1], { query: "第二问", response: "第二答" });
235
+ // 轮与轮之间(下一轮开头的组装)块不再变:第三轮的提问此刻是 pending,被省略。
236
+ const nextTurn = roundsFromEvents([...closed, userEvent(5, "第三问")], 5, null, { omitPendingRound: true });
237
+ assert.deepEqual(nextTurn, after, "the block is stable across the turn boundary — no extra snapshot");
238
+ });
239
+
240
+ test("omitPendingRound only touches the tail — mid-log abandoned rounds still render empty A", () => {
241
+ // 双发消息(第二问把第一问顶掉):第一问是已被放弃的历史提问,渲染成空 A:
242
+ // 是既有语义,不属于「正在组装的这一轮」。
243
+ const events = [userEvent(1, "被顶掉的问题"), userEvent(2, "第二轮的问题"), assistantEvent(3, "第二轮的回答"), userEvent(4, "第三问")];
244
+ const rounds = roundsFromEvents(events, 5, null, { omitPendingRound: true });
245
+ assert.deepEqual(rounds, [
246
+ { query: "被顶掉的问题", response: "" },
247
+ { query: "第二轮的问题", response: "第二轮的回答" }
248
+ ]);
249
+ });
250
+
251
+ test("omitPendingRound works with the surface projection (rewind-shadowed tail not special)", () => {
252
+ // 撤回遮蔽过的事件不参与配对,也不影响「尾部 pending」的判定:可见面上最后
253
+ // 一条 user/message 是第二问,它才是本轮。
254
+ const events = [userEvent(1, "已被撤回的问题"), assistantEvent(2, "已被撤回的回答"), userEvent(3, "第一问"), assistantEvent(4, "第一答"), userEvent(5, "第二问")];
255
+ const visible = new Set([3, 4, 5]);
256
+ const rounds = roundsFromEvents(events, 5, visible, { omitPendingRound: true });
257
+ assert.deepEqual(rounds, [{ query: "第一问", response: "第一答" }]);
258
+ });
259
+