@modusensus/dsh-mneme 0.8.15 → 0.8.16
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/README.md +8 -3
- package/bin/cli.mjs +4 -2
- package/docs/CONFIGURATION.md +14 -2
- package/docs/DISTILL.md +162 -0
- package/docs/STORAGE.md +11 -2
- package/lib/api-standalone.js +22 -39
- package/lib/api.js +171 -3
- package/lib/client.js +1065 -156
- package/lib/config.js +68 -1
- package/lib/dream/decisions.js +20 -0
- package/lib/dream.js +2 -0
- package/lib/hot-memory.js +62 -0
- package/lib/index.js +24 -7
- package/lib/inject.js +27 -63
- package/lib/maintenance.js +25 -5
- package/lib/recall-stats.js +22 -7
- package/lib/reclaim-http.js +150 -0
- package/lib/safe-slice.js +18 -0
- package/lib/service.js +27 -2
- package/lib/settings.js +13 -2
- package/lib/store.js +196 -7
- package/lib/summarize.js +208 -60
- package/package.json +1 -1
- package/src/api-standalone.js +22 -39
- package/src/api.js +171 -3
- package/src/config.js +68 -1
- package/src/dream/decisions.js +20 -0
- package/src/dream.js +2 -0
- package/src/hot-memory.js +62 -0
- package/src/index.js +24 -7
- package/src/inject.js +27 -63
- package/src/maintenance.js +25 -5
- package/src/recall-stats.js +22 -7
- package/src/reclaim-http.js +150 -0
- package/src/safe-slice.js +18 -0
- package/src/service.js +27 -2
- package/src/settings.js +13 -2
- package/src/store.js +196 -7
- package/src/summarize.js +208 -60
- package/test/api-routes-gaps.test.js +681 -0
- package/test/api.test.js +25 -2
- package/test/client.test.js +354 -2
- package/test/contrast.test.js +314 -0
- package/test/dream-merge-guardrail.test.js +219 -0
- package/test/hot-memory.test.js +67 -1
- package/test/inject-recall.test.js +39 -0
- package/test/inject.test.js +23 -0
- package/test/llm-audit.test.js +98 -0
- package/test/maintenance.test.js +112 -2
- package/test/pre-inject-gate.test.js +10 -0
- package/test/recall-stats.test.js +89 -4
- package/test/reclaim-http.test.js +209 -0
- package/test/service.test.js +7 -2
- package/test/settings.test.js +8 -0
- package/test/store.test.js +108 -0
- package/test/summarize.test.js +174 -0
package/test/api.test.js
CHANGED
|
@@ -676,8 +676,11 @@ test("GET /api/dsh-mneme/features returns empty overrides and effective config d
|
|
|
676
676
|
// issue #339 新增 dreamMergeGuard、
|
|
677
677
|
// issue #218/E5 新增 injectHeatEnabled、
|
|
678
678
|
// issue #164 A2 新增 sensitiveScanEnabled(顶层扁平键,走 configFlagValue 默认分支)、
|
|
679
|
-
// issue #380 新增 preInjectGate.enabled/preInjectGate.enforce
|
|
680
|
-
|
|
679
|
+
// issue #380 新增 preInjectGate.enabled/preInjectGate.enforce(注入前判定两级开关)、
|
|
680
|
+
// #135 成本审计派生新增 dreamMaxMergePerRun(单轮 merge 决策上限))
|
|
681
|
+
// issue #399 新增 distillRecords.enabled/distillRecords.retentionDays(蒸馏原文留存 +
|
|
682
|
+
// 溯源:开关与保留期都在嵌套对象里,两个都进 NESTED_FLAG_PATHS))
|
|
683
|
+
assert.equal(Object.keys(data.effective).length, 59 + 3 + 2 + 1 + 2 + 2 + 2 + 1 + 1 + 1 + 2 + 1 + 2 + 1 + 3 + 2 + 2 + 1 + 1 + 1 + 1 + 2 + 1 + 2);
|
|
681
684
|
assert.equal(data.effective.dreamSkipInvalid, true);
|
|
682
685
|
assert.equal(data.effective.allowCrossTypeMerge, false);
|
|
683
686
|
assert.equal(data.effective.dreamMergeGuard, true);
|
|
@@ -688,6 +691,7 @@ test("GET /api/dsh-mneme/features returns empty overrides and effective config d
|
|
|
688
691
|
assert.equal(data.effective.dreamMinIntervalMinutes, 0);
|
|
689
692
|
assert.equal(data.effective.autoDreamFailureBackoff, false);
|
|
690
693
|
assert.equal(data.effective.dreamMaxTokens, 131072);
|
|
694
|
+
assert.equal(data.effective.dreamMaxMergePerRun, 40);
|
|
691
695
|
assert.equal(data.effective.sleepProvider, "");
|
|
692
696
|
assert.equal(data.effective.sleepModel, "");
|
|
693
697
|
assert.equal(data.effective.autoInject, true);
|
|
@@ -706,6 +710,10 @@ test("GET /api/dsh-mneme/features returns empty overrides and effective config d
|
|
|
706
710
|
assert.equal(data.effective["writeAdmission.enforce"], false);
|
|
707
711
|
// #164 A2:密钥/PII 判据自己的闸,同样默认关(#332 的行为逐字节保留)。
|
|
708
712
|
assert.equal(data.effective.sensitiveScanEnabled, false);
|
|
713
|
+
// #399:蒸馏原文留存默认关(这是一份对话原文副本,不该由升级默认打开),
|
|
714
|
+
// 保留期默认 90 天(与 llmAudit.retentionDays 同款)。
|
|
715
|
+
assert.equal(data.effective["distillRecords.enabled"], false);
|
|
716
|
+
assert.equal(data.effective["distillRecords.retentionDays"], 90);
|
|
709
717
|
// 新增字符串 / URL / 枚举键
|
|
710
718
|
assert.equal(data.effective.localEmbedModel, "Xenova/bge-small-zh-v1.5");
|
|
711
719
|
assert.equal(data.effective.ollamaBaseUrl, "http://localhost:11434");
|
|
@@ -763,6 +771,10 @@ test("PUT /api/dsh-mneme/features round-trips nested, string, url and enum keys"
|
|
|
763
771
|
// #380:同款点号键往返——白名单与 NESTED_FLAG_PATHS 成对缺一即静默缺键。
|
|
764
772
|
"preInjectGate.enabled": true,
|
|
765
773
|
"preInjectGate.enforce": true,
|
|
774
|
+
// #399:布尔与整数范围两类点号键都往返一次(retentionDays 是 int,同样必须进
|
|
775
|
+
// NESTED_FLAG_PATHS——否则 configFlagValue 落到顶层同名键上,effective 静默缺键)。
|
|
776
|
+
"distillRecords.enabled": true,
|
|
777
|
+
"distillRecords.retentionDays": 30,
|
|
766
778
|
// #164 A2:顶层扁平键(不是对象子字段),与上面两个点号键走同一条往返;
|
|
767
779
|
// 它的默认值断言在计数锁那一条里,这里钉的是「PUT 存进去、effective 读得回」。
|
|
768
780
|
sensitiveScanEnabled: true,
|
|
@@ -782,6 +794,8 @@ test("PUT /api/dsh-mneme/features round-trips nested, string, url and enum keys"
|
|
|
782
794
|
"writeAdmission.enforce": true,
|
|
783
795
|
"preInjectGate.enabled": true,
|
|
784
796
|
"preInjectGate.enforce": true,
|
|
797
|
+
"distillRecords.enabled": true,
|
|
798
|
+
"distillRecords.retentionDays": 30,
|
|
785
799
|
sensitiveScanEnabled: true,
|
|
786
800
|
embedProvider: "local",
|
|
787
801
|
ollamaBaseUrl: "http://127.0.0.1:11434",
|
|
@@ -796,6 +810,8 @@ test("PUT /api/dsh-mneme/features round-trips nested, string, url and enum keys"
|
|
|
796
810
|
// #380:往返回来的覆盖值压过 bundle 配置默认
|
|
797
811
|
assert.equal(data.effective["preInjectGate.enabled"], true);
|
|
798
812
|
assert.equal(data.effective["preInjectGate.enforce"], true);
|
|
813
|
+
assert.equal(data.effective["distillRecords.enabled"], true);
|
|
814
|
+
assert.equal(data.effective["distillRecords.retentionDays"], 30, "整数点号键也要读得回来");
|
|
799
815
|
assert.equal(data.effective.sensitiveScanEnabled, true);
|
|
800
816
|
assert.equal(data.effective.embedProvider, "local");
|
|
801
817
|
assert.equal(data.effective.ollamaBaseUrl, "http://127.0.0.1:11434");
|
|
@@ -1162,6 +1178,13 @@ test("GET /api/dsh-mneme/conflicts joins both sides of unresolved conflicts", as
|
|
|
1162
1178
|
);
|
|
1163
1179
|
assert.equal(data.items[0].memory_a.archived, false);
|
|
1164
1180
|
assert.equal(data.items[0].memory_b.archived, false);
|
|
1181
|
+
// 裁决依据(冲突窗可读性报障):两侧必须带新旧时间戳,否则面板只能显示
|
|
1182
|
+
// A 方/B 方两个标签,用户没有客观依据判断该留哪条。
|
|
1183
|
+
for (const key of ["memory_a", "memory_b"]) {
|
|
1184
|
+
assert.equal(typeof data.items[0][key].created_at, "string", `${key} must carry created_at`);
|
|
1185
|
+
assert.equal(typeof data.items[0][key].updated_at, "string", `${key} must carry updated_at`);
|
|
1186
|
+
assert.ok(!Number.isNaN(Date.parse(data.items[0][key].updated_at)), `${key}.updated_at must parse`);
|
|
1187
|
+
}
|
|
1165
1188
|
// 幽灵侧容忍缺失
|
|
1166
1189
|
service.saveConflictPending({ memory_a: a.id, memory_b: "ghost-id", reason: "对方已删" });
|
|
1167
1190
|
const res2 = new FakeRes();
|
package/test/client.test.js
CHANGED
|
@@ -600,9 +600,12 @@ test("explorer chrome aligns with the host design system", () => {
|
|
|
600
600
|
/\.mneme-vtab\.mneme-active::after/.test(clientSource),
|
|
601
601
|
"active sub-tabs use the host underline treatment"
|
|
602
602
|
);
|
|
603
|
+
// #178:这里原本直接断言宿主品牌蓝。宿主蓝在浅色画布上只有 4.23:1,对 13px
|
|
604
|
+
// 正文不足 AA,所以面板改用自己的语义色层 --mneme-accent(同一条宿主 token
|
|
605
|
+
// 往 label-primary 方向混 15%),换肤仍跟随宿主。意图不变:激活态是品牌蓝。
|
|
603
606
|
assert.ok(
|
|
604
|
-
clientSource.includes(".mneme-vtab.mneme-active{color:var(--
|
|
605
|
-
"active sub-tab text must turn the host brand blue"
|
|
607
|
+
clientSource.includes(".mneme-vtab.mneme-active{color:var(--mneme-accent)}"),
|
|
608
|
+
"active sub-tab text must turn the host brand blue (via the panel accent layer)"
|
|
606
609
|
);
|
|
607
610
|
assert.equal(
|
|
608
611
|
/\.mneme-xmain\{[^}]*border:1px/.test(clientSource),
|
|
@@ -664,6 +667,48 @@ test("heat badges render from the /list projection and self-hide when off", () =
|
|
|
664
667
|
);
|
|
665
668
|
});
|
|
666
669
|
|
|
670
|
+
// Token 去向三屏(#412):前端只拿 by_day 的稀疏行渲染两种时间尺度,三个坑
|
|
671
|
+
// 都在这一层,且都是「不报错但图错了」的静默失效,所以锁形状而非锁行为:
|
|
672
|
+
// ① 柱状图必须补连续 7 天——by_day 是 GROUP BY 的稀疏结果,直接映射会缺列;
|
|
673
|
+
// ② 热力图的 56 格必须来自独立的 days=56 请求——复用 days=7 的响应会让 56 格
|
|
674
|
+
// 里 48 格恒 0(图看着正常,其实只有一周有数据);
|
|
675
|
+
// ③ 开合不能挂在 details 上——details 的 onClick 会连折叠区子卡片的点击一起
|
|
676
|
+
// 吃掉(preventDefault 拦掉冒泡的激活事件),必须挂 summary。
|
|
677
|
+
test("token flow: 7-day bars pad to a continuous week, heatmap uses its own 56-day window", () => {
|
|
678
|
+
// ① 连续 7 天:右端对齐今天 UTC,缺日填 0
|
|
679
|
+
assert.ok(
|
|
680
|
+
/Array\.from\(\{ length: 7 \}/.test(clientSource),
|
|
681
|
+
"the 7-day bars must be generated as a fixed 7-slot array, not mapped straight off by_day"
|
|
682
|
+
);
|
|
683
|
+
assert.ok(
|
|
684
|
+
/rawDayMap\.get\(key\) \?\? 0/.test(clientSource),
|
|
685
|
+
"missing days must fill 0 so the x-axis stays a continuous week"
|
|
686
|
+
);
|
|
687
|
+
// stats 为 null(未到)与 {}(已知为空)必须分开:前者不画柱,后者补零
|
|
688
|
+
assert.ok(
|
|
689
|
+
/const rawDays = stats && Array\.isArray\(stats\.by_day\) \? stats\.by_day : null;/.test(clientSource),
|
|
690
|
+
"an unloaded response must stay null, else the loading state paints 7 zero bars"
|
|
691
|
+
);
|
|
692
|
+
// ② 热力图独立 56 天窗口
|
|
693
|
+
assert.ok(
|
|
694
|
+
clientSource.includes('llm-audit/stats?days=56'),
|
|
695
|
+
"the heatmap must issue its own days=56 request"
|
|
696
|
+
);
|
|
697
|
+
assert.ok(
|
|
698
|
+
/heatStats && Array\.isArray\(heatStats\.by_day\)/.test(clientSource),
|
|
699
|
+
"the heatmap must read heatStats.by_day, not stats.by_day"
|
|
700
|
+
);
|
|
701
|
+
// ③ 开合挂 summary,不挂 details
|
|
702
|
+
assert.ok(
|
|
703
|
+
!/className: "mneme-legacy",\s*\n\s*open: legacyOpen,\s*\n\s*onClick/.test(clientSource),
|
|
704
|
+
"the click handler must not sit on <details>: it swallows clicks from child cards"
|
|
705
|
+
);
|
|
706
|
+
assert.ok(
|
|
707
|
+
/h\("summary", \{ onClick: \(e\) => \{ e\.preventDefault\(\); setLegacyOpen\(!legacyOpen\); \} \}/.test(clientSource),
|
|
708
|
+
"the toggle must be bound on <summary>, the native disclosure handle"
|
|
709
|
+
);
|
|
710
|
+
});
|
|
711
|
+
|
|
667
712
|
// 巩固/睡眠模型路由 UI:下拉数据来自宿主侧已注册适配器(/llm-providers,
|
|
668
713
|
// 云端插件侧端点),「测试连通性」走 POST /test-model 真实最小调用。旧后端
|
|
669
714
|
// 端点 404 时必须回退纯文本输入——前端自门控,不挡旧版本。
|
|
@@ -1217,6 +1262,313 @@ test("mcp card: mount snippet is copyable and token-keyed to the external API",
|
|
|
1217
1262
|
assert.ok(occurrences >= 2, `i18n key ${key} must exist in both zh and en (got ${occurrences})`);
|
|
1218
1263
|
}
|
|
1219
1264
|
});
|
|
1265
|
+
// --- #400 冲突裁决窗可读性(报障原话:窗口太小又不能放大,完全对比不了内容,只好
|
|
1266
|
+
// 两个都删或胡乱选一个)---
|
|
1267
|
+
// 锁四件事:展开切换存在且默认收起(折叠态仍走 140 字预览,既有布局不回归);
|
|
1268
|
+
// 展开态渲染未截断全文并去掉 diff 的行数钳制;判读依据行(类型/重要度/字数/
|
|
1269
|
+
// 新旧 + 单侧复制)在位;窄容器降级单列。缺任何一条,报障就复发。
|
|
1270
|
+
test("conflict queue: expandable full text with decision metadata", () => {
|
|
1271
|
+
assert.ok(clientSource.includes('"memory.status.conflictQueue.expand"'), "collapsed state must offer 展开全文");
|
|
1272
|
+
assert.ok(clientSource.includes('"memory.status.conflictQueue.collapse"'), "expanded state must offer 收起全文");
|
|
1273
|
+
assert.ok(clientSource.includes('"aria-expanded": open'), "the toggle must expose aria-expanded");
|
|
1274
|
+
assert.ok(/open \? \(s\.content \|\| ""\) : \(s\.content \|\| ""\)\.slice\(0, 140\)/.test(clientSource),
|
|
1275
|
+
"collapsed keeps the 140-char preview while expanded must render untruncated content");
|
|
1276
|
+
assert.ok(clientSource.includes("mneme-conflict-diff--full"), "expanded diff must drop the 4-line clamp");
|
|
1277
|
+
// 展开态高度上限必须与视口无关:宿主可能把面板放进零高视口(隐藏 webview /
|
|
1278
|
+
// 后台页),那时 vh 会算成 0px,「展开全文」直接看不见字——肉眼验收实测踩过。
|
|
1279
|
+
assert.ok(clientSource.includes("max-height:28em"), "expanded height cap must be viewport-independent");
|
|
1280
|
+
assert.equal(/\.mneme-conflict-(diff--full|text)\{[^}]*\d+vh/.test(clientSource), false,
|
|
1281
|
+
"conflict expand rules must not use vh");
|
|
1282
|
+
assert.ok(clientSource.includes('"memory.status.conflictQueue.chars"'), "each side must show its length");
|
|
1283
|
+
assert.ok(clientSource.includes('"memory.status.conflictQueue.updated"'), "each side must show which one is newer");
|
|
1284
|
+
assert.ok(clientSource.includes("copySide"), "each side must be copyable for external comparison");
|
|
1285
|
+
assert.ok(clientSource.includes("@container (max-width:560px){.mneme-conflict-pair{grid-template-columns:1fr}}"),
|
|
1286
|
+
"a narrow panel must fall back to one column instead of two unreadable halves");
|
|
1287
|
+
for (const key of [
|
|
1288
|
+
"memory.status.conflictQueue.expand",
|
|
1289
|
+
"memory.status.conflictQueue.collapse",
|
|
1290
|
+
"memory.status.conflictQueue.copySide",
|
|
1291
|
+
"memory.status.conflictQueue.copiedSide",
|
|
1292
|
+
"memory.status.conflictQueue.importance",
|
|
1293
|
+
"memory.status.conflictQueue.chars",
|
|
1294
|
+
"memory.status.conflictQueue.updated",
|
|
1295
|
+
"memory.status.conflictQueue.created"
|
|
1296
|
+
]) {
|
|
1297
|
+
const occurrences = clientSource.split(`"${key}"`).length - 1;
|
|
1298
|
+
assert.ok(occurrences >= 2, `i18n key ${key} must exist in both zh and en (got ${occurrences})`);
|
|
1299
|
+
}
|
|
1300
|
+
});
|
|
1301
|
+
|
|
1302
|
+
// #386 第 5 条:面板手动新增记忆。这一块最容易悄悄退化——菜单项被删掉、弹层被
|
|
1303
|
+
// 从树里摘掉、或者表单又长回 scope/sensitivity 输入框(那会绕开详情抽屉的「放宽
|
|
1304
|
+
// 可见性」二次确认),面板表面上看不出坏,只是入口没了。所以既锁「入口存在」,
|
|
1305
|
+
// 也锁「入口真的挂在树上」。
|
|
1306
|
+
test("panel can create a memory by hand: menu entry wired to the dialog", () => {
|
|
1307
|
+
// 入口在「更多操作」菜单里,且菜单项真的调用 setCreateOpen(true)——只断言文案
|
|
1308
|
+
// 存在的话,把 onClick 摘掉测试照样绿。
|
|
1309
|
+
assert.ok(clientSource.includes('"memory.explorer.newMemory"'), "menu entry label must exist");
|
|
1310
|
+
assert.ok(/role: "menuitem", onClick: \(\) => \{ setCreateOpen\(true\); setMenuOpen\(false\); \}/.test(clientSource),
|
|
1311
|
+
"the menu item must actually open the create dialog");
|
|
1312
|
+
assert.ok(clientSource.includes("const [createOpen, setCreateOpen] = useState(false)"),
|
|
1313
|
+
"the dialog needs its own open state");
|
|
1314
|
+
// 弹层必须挂在树上并接上回调:onCreated 同时刷新列表与选中新行(并入既有条目时
|
|
1315
|
+
// 返回的 id 是被并入那一行,也该滚进视野)。
|
|
1316
|
+
assert.ok(/createOpen && h\(CreateDialog, \{/.test(clientSource), "the dialog must be mounted conditionally");
|
|
1317
|
+
assert.ok(/onCreated: \(d\) => \{ setReloadKey\(\(k\) => k \+ 1\); if \(d && d\.id\) setSelectedId\(d\.id\); \}/.test(clientSource),
|
|
1318
|
+
"creating must refresh the list and select the resulting row");
|
|
1319
|
+
// 打后端的路由是 /create,不是 standalone 的 /memories:面板与工具必须走同一条
|
|
1320
|
+
// saveWithDedupe 路径,否则「同标题并入」的提示在面板上会失灵。
|
|
1321
|
+
assert.ok(clientSource.includes('apiFetch("/api/dsh-mneme/create"'),
|
|
1322
|
+
"the dialog must post to the panel create route");
|
|
1323
|
+
assert.ok(/d\.action === "merged" \? t\("memory\.explorer\.newMerged"\) : t\("memory\.explorer\.newCreated"\)/.test(clientSource),
|
|
1324
|
+
"merged and created are different facts and must be reported differently");
|
|
1325
|
+
// 弹层源码切片:下面「表单里有什么 / 没有什么」的断言一律在这段上做。在整包上做
|
|
1326
|
+
// 等于没锁——这些字段名在 bundle 里到处都是,`clientSource.includes("title")` 恒真。
|
|
1327
|
+
const dialogStart = clientSource.indexOf("function CreateDialog");
|
|
1328
|
+
const dialogEnd = clientSource.indexOf("function MemoryExplorer", dialogStart);
|
|
1329
|
+
assert.ok(dialogStart >= 0 && dialogEnd > dialogStart, "CreateDialog must be findable in the bundle");
|
|
1330
|
+
const dialogSource = clientSource.slice(dialogStart, dialogEnd);
|
|
1331
|
+
// 表单字段与 memory_save 工具对齐,且锁的是**真实请求体写法**:多一个字段(比如
|
|
1332
|
+
// scope/sensitivity)这段就匹配不上,少一个 trim 也匹配不上。锁裸字段名会恒真。
|
|
1333
|
+
assert.match(dialogSource,
|
|
1334
|
+
/JSON\.stringify\(\{\s*type,\s*title: title\.trim\(\),\s*content: content\.trim\(\),\s*importance,\s*tags: tags\.split\(","\)\.map\(\(s\) => s\.trim\(\)\)\.filter\(Boolean\)\s*\}\)/,
|
|
1335
|
+
"the create request body must carry exactly type/title/content/importance/tags");
|
|
1336
|
+
// 成功路径**不得**重置 busy:提示要停 900ms 才关窗,这期间按钮若重新可点,第二次
|
|
1337
|
+
// 同 type+title 会落进同一行并把正文再追加一遍(service 走 appendContent),一次
|
|
1338
|
+
// 误触就写坏一条记忆。所以全弹层只允许 catch 那条路重置 busy。
|
|
1339
|
+
assert.equal(dialogSource.split("setBusy(false)").length - 1, 1,
|
|
1340
|
+
"only the error path may reset busy; the success path must stay disabled until the dialog closes");
|
|
1341
|
+
assert.match(dialogSource, /\.catch\(\(\) => \{ setBusy\(false\); setErr\(t\("memory\.explorer\.newFailed"\)\); \}\)/,
|
|
1342
|
+
"the single busy reset must be the error path");
|
|
1343
|
+
// 按钮文案同理:成功后 busy 仍为 true,若只看 busy,写入早就完成了按钮还写着「保存
|
|
1344
|
+
// 中…」——既骗用户又和旁边那行「已新建」自相矛盾。msg 一置上就切回正常文案。
|
|
1345
|
+
assert.match(dialogSource, /busy && !msg \? t\("memory\.explorer\.newSaving"\) : t\("memory\.explorer\.newConfirm"\)/,
|
|
1346
|
+
"once the write succeeded the button must stop claiming it is still saving");
|
|
1347
|
+
assert.equal(/newScope|sensitivity/.test(dialogSource), false,
|
|
1348
|
+
"the create form must not offer scope/sensitivity: that path bypasses the widen-confirm guard");
|
|
1349
|
+
// document 不在类型下拉里:铸造口唯一(registerDocument,#230),后端也会 400。
|
|
1350
|
+
assert.equal(dialogSource.includes('"document"'), false,
|
|
1351
|
+
"document rows are minted only via registerDocument and must not be offered here");
|
|
1352
|
+
// 类型白名单与 src/tools.js 的 memory_save enum 逐字一致(缺一个就会有人点进去才被拒)。
|
|
1353
|
+
for (const k of ["preference", "project", "decision", "history", "rejected_solution", "pitfall", "constraint"]) {
|
|
1354
|
+
assert.ok(dialogSource.includes(`"${k}"`), `type option ${k} must be offered`);
|
|
1355
|
+
}
|
|
1356
|
+
// 前端空值拦截只是省一次往返,服务端校验才是权威——但两者都得在,否则空标题会
|
|
1357
|
+
// 一路发出去再弹一个 400 给用户看。
|
|
1358
|
+
assert.ok(dialogSource.includes('if (!title.trim()) { setErr(t("memory.explorer.newNoTitle")); return; }'),
|
|
1359
|
+
"empty title must be caught before the round trip");
|
|
1360
|
+
assert.ok(dialogSource.includes('if (!content.trim()) { setErr(t("memory.explorer.newNoContent")); return; }'),
|
|
1361
|
+
"empty body must be caught before the round trip");
|
|
1362
|
+
// 十四个新键必须中英成对(与上面那条全局锁同口径,这里列出名字便于定位)。
|
|
1363
|
+
for (const key of [
|
|
1364
|
+
"memory.explorer.newMemory",
|
|
1365
|
+
"memory.explorer.newTitle",
|
|
1366
|
+
"memory.explorer.newHint",
|
|
1367
|
+
"memory.explorer.newType",
|
|
1368
|
+
"memory.explorer.newTitlePlaceholder",
|
|
1369
|
+
"memory.explorer.newContentPlaceholder",
|
|
1370
|
+
"memory.explorer.newTagsPlaceholder",
|
|
1371
|
+
"memory.explorer.newConfirm",
|
|
1372
|
+
"memory.explorer.newSaving",
|
|
1373
|
+
"memory.explorer.newCreated",
|
|
1374
|
+
"memory.explorer.newMerged",
|
|
1375
|
+
"memory.explorer.newFailed",
|
|
1376
|
+
"memory.explorer.newNoTitle",
|
|
1377
|
+
"memory.explorer.newNoContent"
|
|
1378
|
+
]) {
|
|
1379
|
+
const occurrences = clientSource.split(`"${key}"`).length - 1;
|
|
1380
|
+
assert.ok(occurrences >= 2, `i18n key ${key} must exist in both zh and en (got ${occurrences})`);
|
|
1381
|
+
}
|
|
1382
|
+
});
|
|
1383
|
+
// --- issue #386 第 4 条:存储回收卡(dry-run 预估 + 两步确认执行)---
|
|
1384
|
+
// 这是「不可逆的内容丢弃」在面板上的唯一入口,所以锁的不是「有没有这张卡」,而是三条
|
|
1385
|
+
// 不变量:执行必须两步、POST 必须逐字带确认门、VACUUM 失败不得渲染成干净的成功。
|
|
1386
|
+
// 所有断言都做在**卡片切片**上:路由串/dryRun/confirm 这些字面量在整包里到处都是,
|
|
1387
|
+
// 在 clientSource 上做 includes 恒真、等于没锁(#408 评审踩过的坑)。
|
|
1388
|
+
const maintCardSource = () => {
|
|
1389
|
+
const start = clientSource.indexOf("function MaintenanceCard");
|
|
1390
|
+
const end = clientSource.indexOf("function MirrorHealthCard", start);
|
|
1391
|
+
assert.ok(start >= 0 && end > start, "MaintenanceCard must be findable in the bundle (切片锚点变了?)");
|
|
1392
|
+
return clientSource.slice(start, end);
|
|
1393
|
+
};
|
|
1394
|
+
|
|
1395
|
+
test("maintenance card: i18n keys are bilingual and the section is really rendered", () => {
|
|
1396
|
+
for (const key of [
|
|
1397
|
+
"memory.status.sec.maintenance",
|
|
1398
|
+
"memory.status.maint",
|
|
1399
|
+
"memory.status.maint.freelist",
|
|
1400
|
+
"memory.status.maint.plannedInputs",
|
|
1401
|
+
"memory.status.maint.plannedEmbeddings",
|
|
1402
|
+
"memory.status.maint.window",
|
|
1403
|
+
"memory.status.maint.windowUnit",
|
|
1404
|
+
"memory.status.maint.windowLabel",
|
|
1405
|
+
"memory.status.maint.reestimate",
|
|
1406
|
+
"memory.status.maint.reestimating",
|
|
1407
|
+
"memory.status.maint.stale",
|
|
1408
|
+
"memory.status.maint.vacuum",
|
|
1409
|
+
"memory.status.maint.vacuumHint",
|
|
1410
|
+
"memory.status.maint.execute",
|
|
1411
|
+
"memory.status.maint.confirm",
|
|
1412
|
+
"memory.status.maint.running",
|
|
1413
|
+
"memory.status.maint.cancel",
|
|
1414
|
+
"memory.status.maint.confirmHint",
|
|
1415
|
+
"memory.status.maint.badWindow",
|
|
1416
|
+
"memory.status.maint.failed",
|
|
1417
|
+
"memory.status.maint.done",
|
|
1418
|
+
"memory.status.maint.size",
|
|
1419
|
+
"memory.status.maint.reclaimed",
|
|
1420
|
+
"memory.status.maint.clearedInputs",
|
|
1421
|
+
"memory.status.maint.clearedEmbeddings",
|
|
1422
|
+
"memory.status.maint.receipt",
|
|
1423
|
+
"memory.status.maint.receiptNone",
|
|
1424
|
+
"memory.status.maint.duration",
|
|
1425
|
+
"memory.status.maint.vacuumRan",
|
|
1426
|
+
"memory.status.maint.vacuumOff",
|
|
1427
|
+
"memory.status.maint.vacuumFailed",
|
|
1428
|
+
"memory.status.maint.vacuumFailedHint"
|
|
1429
|
+
]) {
|
|
1430
|
+
// 数的是**字典行**(`"key": "` 顶格成行),不是裸字符串出现次数。裸数会把调用点
|
|
1431
|
+
// 一起算进来:`t("...maint.done")` 在卡片里本来就有一次,删掉一整行英文译文之后
|
|
1432
|
+
// 「字典 zh 行 + 调用点」照样凑够 2——锁恒真,就是这么漏的(本条第一版被 M1 变异
|
|
1433
|
+
// 抓出来了)。行首锚定后,少一侧译文就只剩 1。
|
|
1434
|
+
const entries = clientSource.match(new RegExp(`^\\s*"${key.replace(/\./g, "\\.")}"\\s*:\\s*"`, "gm"));
|
|
1435
|
+
const count = entries ? entries.length : 0;
|
|
1436
|
+
assert.equal(count, 2, `i18n key ${key} must appear as exactly one dictionary line per language (got ${count})`);
|
|
1437
|
+
}
|
|
1438
|
+
// 分区键只在字典里躺着不算加分区:必须真的渲染出「维护」这段扫读锚点。
|
|
1439
|
+
assert.ok(clientSource.includes('h("div", { className: "mneme-statushead" }, t("memory.status.sec.maintenance"))'),
|
|
1440
|
+
"the maintenance section head must render from the dictionary key");
|
|
1441
|
+
});
|
|
1442
|
+
|
|
1443
|
+
test("maintenance card: the reclaim route is the in-process one, not the standalone data plane", () => {
|
|
1444
|
+
const src = maintCardSource();
|
|
1445
|
+
// 走 /api/dsh-mneme/... 这条 in-process 路由:它才有 requireAuth + 与 CLI 同一条
|
|
1446
|
+
// reclaim 实现。写成 standalone 数据面的路径会绕开面板身份的鉴权口径。
|
|
1447
|
+
assert.equal(src.split('"/api/dsh-mneme/maintenance/reclaim"').length - 1, 1,
|
|
1448
|
+
"the card must name the in-process reclaim route exactly once (via its path constant)");
|
|
1449
|
+
// 预估走 GET 且把窗口作为查询参数带上;粘在按钮上——键入即发会让数字在半截输入时跳。
|
|
1450
|
+
assert.ok(src.includes("`${RECLAIM_PATH}?olderThanDays=${days}`"),
|
|
1451
|
+
"re-estimating must GET the route with the current window as a query parameter");
|
|
1452
|
+
assert.equal(src.split("onClick: estimate").length - 1, 1,
|
|
1453
|
+
"only the re-estimate button may trigger an estimate (no request per keystroke)");
|
|
1454
|
+
assert.ok(src.includes('onChange: (e) => setDays(e.target.value)'),
|
|
1455
|
+
"typing in the window field must only update local state");
|
|
1456
|
+
});
|
|
1457
|
+
|
|
1458
|
+
test("maintenance card: execution carries the verbatim dryRun:false + confirm:true gate", () => {
|
|
1459
|
+
const src = maintCardSource();
|
|
1460
|
+
// 逐字锁请求体:多一个字段、少一个空格、把 Number(days) 换成裸 days 都匹配不上。
|
|
1461
|
+
// 锁裸字段名(dryRun/confirm)会恒真——它们在这段源码里各自还有别的出现位置。
|
|
1462
|
+
assert.match(src,
|
|
1463
|
+
/JSON\.stringify\(\{ dryRun: false, confirm: true, olderThanDays: Number\(days\), vacuum \}\)/,
|
|
1464
|
+
"the executing body must be exactly dryRun:false + confirm:true + olderThanDays + vacuum");
|
|
1465
|
+
// 全卡只有一处能改库:确认门之外的任何一次 POST 都是「第一次点就真清了」。
|
|
1466
|
+
assert.equal(src.split('method: "POST"').length - 1, 1, "the card must issue exactly one POST");
|
|
1467
|
+
assert.equal(src.split("dryRun: false").length - 1, 1, "exactly one mutating body may exist");
|
|
1468
|
+
// 窗口非法时不得发请求:0 在服务端是「清掉全部快照」,畸形输入绝不能静默落进最坏解释。
|
|
1469
|
+
assert.ok(src.includes('if (!/^\\d+$/.test(days)) { setError(t("memory.status.maint.badWindow")); setArmed(false); return; }'),
|
|
1470
|
+
"a non-numeric window must be rejected locally before any round trip");
|
|
1471
|
+
// 上界同样在本地拦:/^\d+$/ 放得过 1e20 与 400 位数字(后者化成 Infinity),后端
|
|
1472
|
+
// 会回 400,但那时用户只看到一句「回收失败」,分不清是窗口不合法还是网络问题。
|
|
1473
|
+
assert.match(src, /const MAX_WINDOW_DAYS = 3650;/,
|
|
1474
|
+
"the card must carry the same upper bound as the backend");
|
|
1475
|
+
assert.ok(src.includes('if (Number(days) > MAX_WINDOW_DAYS) { setError(t("memory.status.maint.badWindow")); setArmed(false); return; }'),
|
|
1476
|
+
"an out-of-range window must be rejected locally, before any round trip");
|
|
1477
|
+
});
|
|
1478
|
+
|
|
1479
|
+
test("maintenance card: 'no receipt' does not claim a single cause", () => {
|
|
1480
|
+
// writeReceipt 返回 null 有两种成因:审计被关(config.llmAudit.enabled === false),
|
|
1481
|
+
// 或回执写库失败(saveLlmAudit 抛错,只 warn)。旧文案「无(审计未启用)」把第二种
|
|
1482
|
+
// 情况说成第一种——而这行出现在不可逆操作的结果区,用户会照它去对账。
|
|
1483
|
+
for (const wording of [
|
|
1484
|
+
'"memory.status.maint.receiptNone": "未记录(审计未启用或回执写入失败)"',
|
|
1485
|
+
'"memory.status.maint.receiptNone": "Not recorded (audit disabled or receipt write failed)"'
|
|
1486
|
+
]) {
|
|
1487
|
+
assert.ok(clientSource.includes(wording),
|
|
1488
|
+
`the missing-receipt wording must name both causes: ${wording}`);
|
|
1489
|
+
}
|
|
1490
|
+
});
|
|
1491
|
+
|
|
1492
|
+
test("maintenance card: no window.confirm — the host webview cannot be relied on for it", () => {
|
|
1493
|
+
const src = maintCardSource();
|
|
1494
|
+
assert.equal(/window\.confirm/.test(src), false,
|
|
1495
|
+
"window.confirm is not reliably available in the host webview; the in-card confirm state is the gate");
|
|
1496
|
+
assert.equal(/(^|[^.\w])confirm\s*\(/.test(src), false,
|
|
1497
|
+
"no blocking confirm() call may exist in the card (the confirm state is a rendered branch)");
|
|
1498
|
+
assert.ok(src.includes("mneme-btndangerconfirm"),
|
|
1499
|
+
"the armed state must use the repo's existing danger-confirm styling");
|
|
1500
|
+
});
|
|
1501
|
+
|
|
1502
|
+
test("maintenance card: mounted in the status view under the maintenance section", () => {
|
|
1503
|
+
// 组件存在但没挂上树 = 用户永远看不到,正是这条 issue 报的缺口。
|
|
1504
|
+
const panelStart = clientSource.indexOf("function StatusPanel");
|
|
1505
|
+
const panelEnd = clientSource.indexOf("function ", clientSource.indexOf("h(WorkbenchSection", panelStart));
|
|
1506
|
+
assert.ok(panelStart >= 0 && panelEnd > panelStart, "StatusPanel must be findable in the bundle");
|
|
1507
|
+
const panel = clientSource.slice(panelStart, panelEnd);
|
|
1508
|
+
assert.ok(panel.includes("h(MaintenanceCard, { t })"),
|
|
1509
|
+
"the status panel must render the maintenance card");
|
|
1510
|
+
assert.ok(panel.includes('t("memory.status.sec.maintenance")'),
|
|
1511
|
+
"the card must sit under the maintenance section head, not loose in the grid");
|
|
1512
|
+
});
|
|
1513
|
+
|
|
1514
|
+
test("maintenance card: success state exists only behind the confirm gate", () => {
|
|
1515
|
+
const src = maintCardSource();
|
|
1516
|
+
// 第一次点只能「武装」,不得直连执行:默认态是红描边钮(mneme-btndanger),只有武装
|
|
1517
|
+
// 后的实心红确认钮(mneme-btndangerconfirm)才允许调用 execute。
|
|
1518
|
+
assert.match(src, /mneme-btndangerconfirm"[\s\S]{0,200}?onClick: execute/,
|
|
1519
|
+
"only the armed danger-confirm button may fire the mutating call");
|
|
1520
|
+
assert.match(src, /mneme-btndanger"[\s\S]{0,200}?onClick: \(\) => setArmed\(true\)/,
|
|
1521
|
+
"the first click must only arm the confirm state, never execute");
|
|
1522
|
+
assert.equal(src.split("onClick: execute").length - 1, 1,
|
|
1523
|
+
"the executing click must have exactly one entry point in the card");
|
|
1524
|
+
// 非 2xx 必须先 return,成功态才能写在它之后——否则失败也会渲染成成功。
|
|
1525
|
+
const guard = src.indexOf("if (!r.ok) {");
|
|
1526
|
+
const success = src.indexOf("setResult(r.body)");
|
|
1527
|
+
assert.ok(guard >= 0 && success > guard,
|
|
1528
|
+
"a non-2xx response must return before the success report is written");
|
|
1529
|
+
assert.equal(src.split("setResult(r.body)").length - 1, 1,
|
|
1530
|
+
"the success report must be written exactly once, from the confirmed call");
|
|
1531
|
+
// 窗口改过却没重新估算时,确认态里摆出来的会是上一次的数字——对不可逆动作来说这是最坏
|
|
1532
|
+
// 的一种错位,所以那种状态下执行钮必须是禁用的。
|
|
1533
|
+
assert.ok(src.includes("const stale = !report || String(report.olderThanDays) !== days;"),
|
|
1534
|
+
"an estimate that no longer matches the input must be detectable");
|
|
1535
|
+
assert.match(src, /disabled: running \|\| loading \|\| estimating \|\| stale/,
|
|
1536
|
+
"the run button must stay disabled while the estimate is stale");
|
|
1537
|
+
});
|
|
1538
|
+
|
|
1539
|
+
// VACUUM 失败 ≠ 成功(契约 §5 的硬要求)。清理的两个 UPDATE 各自提交且不可逆,VACUUM
|
|
1540
|
+
// 只负责把文件缩回去;把半成功渲染成成功,用户会以为白清了一次。这条锁住那个三元分支,
|
|
1541
|
+
// 防止未来被「简化」成只看 dryRun/receipt。
|
|
1542
|
+
test("maintenance card: a failed VACUUM is reported, never rendered as a clean success", () => {
|
|
1543
|
+
const src = maintCardSource();
|
|
1544
|
+
assert.ok(src.includes("const vacuumError = rv.error == null ? null : String(rv.error);"),
|
|
1545
|
+
"vacuum.error is string|null and must be read as the failure signal it is");
|
|
1546
|
+
assert.match(src, /vacuumError \? t\("memory\.status\.maint\.vacuumFailed"\) : t\("memory\.status\.maint\.done"\)/,
|
|
1547
|
+
"a non-null vacuum.error must replace the success headline");
|
|
1548
|
+
assert.ok(src.includes('t("memory.status.maint.vacuumFailedHint")'),
|
|
1549
|
+
"the failed state must state that the reclaim already happened, is irreversible, and that a rerun is idempotent");
|
|
1550
|
+
// receipt 也要露面:不可逆动作没有回执号,出事时无法对账。锁的是**取值表达式**而不是
|
|
1551
|
+
// 行标签——`t("...maint.receipt")` 只是左侧那行文案,把右侧渲染删空它照样在,锁标签
|
|
1552
|
+
// 等于没锁(M12 变异验证过)。
|
|
1553
|
+
assert.match(src, /result\.receipt == null \? t\("memory\.status\.maint\.receiptNone"\) : String\(result\.receipt\)/,
|
|
1554
|
+
"the receipt value must be rendered (id when present, an explicit 'not recorded' when absent)");
|
|
1555
|
+
});
|
|
1556
|
+
|
|
1557
|
+
test("maintenance card: VACUUM defaults to off and a failed run is reported verbatim", () => {
|
|
1558
|
+
const src = maintCardSource();
|
|
1559
|
+
// VACUUM 会短暂独占数据库(别的进程此时写入会 SQLITE_BUSY),它是**可选**动作,
|
|
1560
|
+
// 默认必须关着——默认开等于每次回收都替用户独占一次库。契约 §5 的硬要求。
|
|
1561
|
+
assert.ok(src.includes("const [vacuum, setVacuum] = useState(false);"),
|
|
1562
|
+
"VACUUM must default to off");
|
|
1563
|
+
// 非 2xx 必须把服务端给的 error 串如实带出来:只说「失败」,现场就分不清是 400 的
|
|
1564
|
+
// confirm-required 还是 500 的 internal。
|
|
1565
|
+
assert.ok(src.includes('setResultError(`${t("memory.status.maint.failed")} · ${(r.body && r.body.error) || `HTTP ${r.status}`}`);'),
|
|
1566
|
+
"a non-2xx response must surface the server's own error string, not a bare 'failed'");
|
|
1567
|
+
// 执行成功后必须复位确认态:否则下一次点击落在已武装的分支上,一步就把不可逆动作
|
|
1568
|
+
// 放出去,两步确认名存实亡。
|
|
1569
|
+
assert.match(src, /setResult\(r\.body\);\s*setArmed\(false\);/,
|
|
1570
|
+
"the confirm state must be reset on the success path");
|
|
1571
|
+
});
|
|
1220
1572
|
// 面板 bundle 在本文件里只被当**文本**读(上面的断言全是正则/字符串包含),
|
|
1221
1573
|
// 而仓库的 CI 里没有任何一步**解析**它:于是重复声明这类语法错误能一路绿灯进
|
|
1222
1574
|
// 主干,后果却是整个面板加载失败(__ModuleLoader__ 拿到的模块一执行就抛
|