@modusensus/dsh-mneme 0.8.10 → 0.8.12

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 (45) hide show
  1. package/README.md +5 -5
  2. package/assets/icon.svg +3 -0
  3. package/lib/client.js +190 -58
  4. package/lib/config.js +47 -1
  5. package/lib/dream/decisions.js +16 -0
  6. package/lib/dream/sleep.js +3 -0
  7. package/lib/dream.js +57 -9
  8. package/lib/entities/extractor.js +4 -1
  9. package/lib/graph/anchoring.js +180 -0
  10. package/lib/index.js +5 -0
  11. package/lib/inject.js +15 -3
  12. package/lib/lang.js +10 -0
  13. package/lib/service.js +164 -23
  14. package/lib/settings.js +24 -2
  15. package/lib/store.js +155 -11
  16. package/lib/summarize.js +104 -7
  17. package/locale/en.json +6 -0
  18. package/locale/zh.json +6 -0
  19. package/package.json +12 -7
  20. package/scripts/sync-test-badge.mjs +9 -7
  21. package/scripts/test-count-sync.mjs +44 -0
  22. package/src/config.js +47 -1
  23. package/src/dream/decisions.js +16 -0
  24. package/src/dream/sleep.js +3 -0
  25. package/src/dream.js +57 -9
  26. package/src/entities/extractor.js +4 -1
  27. package/src/graph/anchoring.js +180 -0
  28. package/src/index.js +5 -0
  29. package/src/inject.js +15 -3
  30. package/src/lang.js +10 -0
  31. package/src/service.js +164 -23
  32. package/src/settings.js +24 -2
  33. package/src/store.js +155 -11
  34. package/src/summarize.js +104 -7
  35. package/test/api.test.js +7 -2
  36. package/test/baseline-persist.test.js +248 -0
  37. package/test/client.test.js +245 -45
  38. package/test/dream.test.js +75 -0
  39. package/test/graph-anchoring.test.js +185 -0
  40. package/test/graph-inject-budget.test.js +104 -0
  41. package/test/graph-passive-confirm.test.js +92 -0
  42. package/test/graph-weight-evolution.test.js +105 -0
  43. package/test/scope-inject-soft.test.js +106 -0
  44. package/test/summarize.test.js +177 -0
  45. package/test/test-count-sync.test.js +60 -0
@@ -0,0 +1,248 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { mkdtempSync, rmSync, readFileSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import { fileURLToPath } from "node:url";
7
+ import { DatabaseSync } from "node:sqlite";
8
+ import { createStore } from "../src/store.js";
9
+ import { createService } from "../src/service.js";
10
+ import { createDreamScheduler } from "../src/dream.js";
11
+
12
+ // 回归(issue #89 基线半边):阈值基线 baseline{count,chars} 只活在调度器闭包里,
13
+ // 进程重启归零。库里记忆远多于阈值时,重启后第一条写入(更新类写入同样计入
14
+ // notifyWrite)就让 overBase 成立,绕过阈值开跑一整轮巩固——0.8.11 实测四次重启
15
+ // 分别只新增 6/7/0/8 条记忆(阈值 10 条 / 5000 字符),每轮约 16 万 tokens。
16
+ // #291 只持久化了 lastRunAt(时间闸门那半边),本文件锁另一半:
17
+ // 1. lastDreamBaseline 只认「成功轮落下的完整基线」,按 run_type 隔离;
18
+ // 2. 播种后重启不再绕过阈值——同库同写入的无种子对照组仍触发,锁的正是这个差异;
19
+ // 3. 播种不妨碍阈值正常累积(计数长到 +10 仍然触发);
20
+ // 4. runDream 成功轮把基线落库并回给调度器(进程内刷新与跨重启播种同一份数字);
21
+ // 5. 老库两列由幂等迁移补上,存量行认不出基线 → 退回零基线(升级前行为)。
22
+
23
+ /** 与阈值判定同口径的活跃库规模(测试侧独立实现,避免与被测代码共用一份过滤条件)。 */
24
+ function activeSize(store) {
25
+ const rows = store.all().filter((m) => !m.archived && m.type !== "summary" && m.type !== "document");
26
+ return {
27
+ count: rows.length,
28
+ chars: rows.reduce((sum, m) => sum + m.title.length + m.content.length, 0)
29
+ };
30
+ }
31
+
32
+ function seedMemories(service, n, tag = "a") {
33
+ // 标题必须全局唯一:saveWithDedupe 的去重键是 type+title+scope,同名会被合并成
34
+ // 同一条(条数不增长),「累计到阈值」的断言就会假失败。
35
+ for (let i = 0; i < n; i++) {
36
+ service.saveWithDedupe({ type: "history", title: `${tag}-${i}`, content: "x".repeat(300), importance: 3 });
37
+ }
38
+ }
39
+
40
+ test("issue#89: store.lastDreamBaseline reads the last baseline-advancing run only", () => {
41
+ const store = createStore(":memory:");
42
+ assert.equal(store.lastDreamBaseline(), null, "empty trail → null (never ran)");
43
+ // 非 ok 轮不推进基线(既有语义)→ 不落这两列,播种端必须无视它
44
+ store.saveDreamRun({ status: "failed", snapshot_hash: "h0", input_count: 1, receipt: "r0", created_at: "2026-09-30T10:00:00.000Z", run_type: "auto" });
45
+ assert.equal(store.lastDreamBaseline("auto"), null, "failed run carries no baseline");
46
+ store.saveDreamRun({ status: "ok", snapshot_hash: "h1", input_count: 1, receipt: "r1", created_at: "2026-09-30T10:01:00.000Z", run_type: "auto", store_count: 4, store_chars: 400 });
47
+ assert.deepEqual(store.lastDreamBaseline("auto"), { count: 4, chars: 400 });
48
+ // sleep/organize 的轮次不参与 auto 的阈值判定
49
+ store.saveDreamRun({ status: "ok", snapshot_hash: "h2", input_count: 1, receipt: "r2", created_at: "2026-09-30T10:02:00.000Z", run_type: "sleep", store_count: 9, store_chars: 900 });
50
+ assert.deepEqual(store.lastDreamBaseline("auto"), { count: 4, chars: 400 }, "sleep rows are filtered out of the auto baseline");
51
+ assert.deepEqual(store.lastDreamBaseline("sleep"), { count: 9, chars: 900 });
52
+ // 半个基线(只有条数没有字符数)写入侧就该拒绝:否则阈值会按 0 字符起算
53
+ store.saveDreamRun({ status: "ok", snapshot_hash: "h3", input_count: 1, receipt: "r3", created_at: "2026-09-30T10:03:00.000Z", run_type: "auto", store_count: 7 });
54
+ assert.deepEqual(store.lastDreamBaseline("auto"), { count: 4, chars: 400 }, "half a baseline is not accepted");
55
+ store.close();
56
+ });
57
+
58
+ test("issue#89: a seeded baseline survives a restart — the first write no longer bypasses the threshold", async () => {
59
+ const store = createStore(":memory:");
60
+ const service = createService({ store, mirror: null, config: {} });
61
+ seedMemories(service, 12); // 条数与字符数都远超阈值(12 ≥ 10 条)
62
+ // 上一进程的成功轮把「当时的库规模」落进审计表,然后进程重启:
63
+ const size = activeSize(store);
64
+ store.saveDreamRun({
65
+ status: "ok", snapshot_hash: "h", input_count: 1, receipt: "r",
66
+ created_at: new Date(Date.now() - 10).toISOString(), run_type: "auto",
67
+ store_count: size.count, store_chars: size.chars
68
+ });
69
+ const opts = { onRun: async () => ({ ok: false }), thresholdCount: 10, thresholdChars: 5000, delayMs: 0, logger: { warn: () => {} } };
70
+ const seeded = createDreamScheduler({ ...opts, baselineSeed: store.lastDreamBaseline("auto") });
71
+ assert.equal(seeded.maybeSchedule(service), false, "seeded baseline blocks the first write after restart");
72
+ // 对照组:同一个库、同一次写入,没有种子就是被修掉的那个缺陷。若哪天播种被忽略,
73
+ // 上面那条断言会与这条同真同假——所以两条必须成对存在。
74
+ const unseeded = createDreamScheduler(opts);
75
+ assert.equal(unseeded.maybeSchedule(service), true, "without the seed the threshold is bypassed (the defect this test locks)");
76
+ await unseeded.dispose(); // 清掉 0ms 待跑计时器,别让对照组真开跑
77
+ store.close();
78
+ });
79
+
80
+ test("issue#89: a seeded baseline still lets the threshold accumulate", async () => {
81
+ const store = createStore(":memory:");
82
+ const service = createService({ store, mirror: null, config: {} });
83
+ seedMemories(service, 12);
84
+ const dream = createDreamScheduler({
85
+ onRun: async () => ({ ok: false }), thresholdCount: 10, thresholdChars: 5000, delayMs: 0,
86
+ baselineSeed: activeSize(store), logger: { warn: () => {} }
87
+ });
88
+ seedMemories(service, 5, "b");
89
+ assert.equal(dream.maybeSchedule(service), false, "half the threshold is not enough");
90
+ seedMemories(service, 5, "c"); // 累计 +10 条
91
+ assert.equal(dream.maybeSchedule(service), true, "the threshold still fires once the store grew by it");
92
+ await dream.dispose();
93
+ store.close();
94
+ });
95
+
96
+ test("issue#89: the run-reported baseline is what the scheduler keeps (single source of truth)", async () => {
97
+ const store = createStore(":memory:");
98
+ const service = createService({ store, mirror: null, config: {} });
99
+ seedMemories(service, 12);
100
+ const reported = { count: 50, chars: 50000 };
101
+ const dream = createDreamScheduler({
102
+ onRun: async () => ({ ok: true, baseline: reported }),
103
+ thresholdCount: 10, thresholdChars: 5000, delayMs: 0, logger: { warn: () => {} }
104
+ });
105
+ assert.equal(dream.maybeSchedule(service), true, "no seed yet → the first run is allowed");
106
+ await new Promise((r) => setTimeout(r, 10)); // 让 run 收尾并刷新基线
107
+ // 运行后再长 15 条:若调度器改用「现场计算」的基线(12),这里会触发;用 run
108
+ // 自报的 50 则不触发。这条差异就是「审计行与内存基线同一份数字」的锁。
109
+ seedMemories(service, 15, "grow");
110
+ assert.equal(dream.maybeSchedule(service), false, "the persisted-reported baseline governs, not the live store size");
111
+ await dream.dispose();
112
+ store.close();
113
+ });
114
+
115
+ test("issue#89: an ok run persists its post-run store size; a failed run persists none", async () => {
116
+ const store = createStore(":memory:");
117
+ const service = createService({ store, mirror: null, config: {} });
118
+ const a = service.saveWithDedupe({ type: "project", title: "旧1", content: "第一段内容" });
119
+ const b = service.saveWithDedupe({ type: "project", title: "旧2", content: "第二段内容" });
120
+ const dream = createDreamScheduler({ thresholdCount: 1, thresholdChars: 0, delayMs: 0, logger: { warn: () => {} } });
121
+
122
+ // 失败轮(无路由):不能留下任何基线——否则重启播种会拿到一个「没推进过」的假基线
123
+ const failed = await dream.runDream({ llm: { stream: async function* () {} }, logger: { warn: () => {} } }, service, {});
124
+ assert.equal(failed.ok, false, "no route → failed run");
125
+ assert.equal(failed.baseline, undefined, "failed run reports no baseline");
126
+ assert.equal(store.lastDreamBaseline("auto"), null, "failed run persists no baseline");
127
+
128
+ // 成功轮:落库的数字 = 本轮结束后的活跃库规模(合并已归档一方,summary 不计入)
129
+ let calls = 0;
130
+ const ctx = {
131
+ llm: {
132
+ stream: async function* () {
133
+ calls++;
134
+ const text = calls === 1
135
+ ? JSON.stringify([{ action: "merge", ids: [a.memory.id, b.memory.id], title: "合并标题", content: "合并后的内容", importance: 4, keepSource: a.memory.id }])
136
+ : "记忆库总览摘要文本";
137
+ yield { type: "text-delta", text };
138
+ yield { type: "finish", reason: { kind: "ok" } };
139
+ }
140
+ },
141
+ logger: { warn: () => {} }
142
+ };
143
+ const ok = await dream.runDream(ctx, service, { dreamProvider: "deepseek", dreamModel: "deepseek-chat" });
144
+ assert.equal(ok.ok, true, "run with decisions + summary is ok");
145
+ const expected = activeSize(store);
146
+ assert.deepEqual(ok.baseline, expected, "the run reports the post-run store size");
147
+ assert.deepEqual(store.lastDreamBaseline("auto"), expected, "the audit row carries the same numbers");
148
+ store.close();
149
+ });
150
+
151
+ test("issue#89: legacy dream_runs gains the baseline columns on open (idempotent ALTER)", () => {
152
+ const dir = mkdtempSync(join(tmpdir(), "dsh-mneme-baseline-migrate-"));
153
+ const dbPath = join(dir, "legacy.db");
154
+ try {
155
+ const legacy = new DatabaseSync(dbPath);
156
+ legacy.exec(`CREATE TABLE dream_runs (
157
+ id TEXT PRIMARY KEY, created_at TEXT NOT NULL, status TEXT NOT NULL, error TEXT,
158
+ provider TEXT, model TEXT, snapshot_hash TEXT NOT NULL, input_count INTEGER NOT NULL,
159
+ input TEXT, decisions TEXT, outcome TEXT, applied INTEGER NOT NULL DEFAULT 0,
160
+ summary_stored INTEGER NOT NULL DEFAULT 0, receipt TEXT NOT NULL,
161
+ policy_epoch INTEGER NOT NULL DEFAULT 0, run_type TEXT NOT NULL DEFAULT 'auto', skipped TEXT
162
+ );`);
163
+ legacy.prepare(
164
+ `INSERT INTO dream_runs (id, created_at, status, snapshot_hash, input_count, applied, summary_stored, receipt)
165
+ VALUES ('legacy', '2026-09-30T10:00:00.000Z', 'ok', 'h', 1, 0, 0, 'r')`
166
+ ).run();
167
+ legacy.close();
168
+
169
+ const store = createStore(dbPath);
170
+ const cols = store.db.prepare("PRAGMA table_info(dream_runs)").all().map((c) => c.name);
171
+ assert.ok(cols.includes("store_count") && cols.includes("store_chars"), "baseline columns added to legacy db");
172
+ // 存量行两列皆 NULL:反推不出「那轮结束时库有多大」,认不出就不认——退回零基线
173
+ assert.equal(store.lastDreamBaseline("auto"), null, "legacy rows carry no baseline");
174
+ store.close();
175
+
176
+ const again = createStore(dbPath); // 二次打开:ALTER 幂等,不抛 duplicate column
177
+ assert.equal(again.lastDreamBaseline("auto"), null);
178
+ again.close();
179
+ } finally {
180
+ rmSync(dir, { recursive: true, force: true });
181
+ }
182
+ });
183
+
184
+ test("issue#89: an unusable seed is treated as no baseline at all", () => {
185
+ const store = createStore(":memory:");
186
+ const service = createService({ store, mirror: null, config: {} });
187
+ seedMemories(service, 12);
188
+ const opts = { onRun: async () => ({ ok: false }), thresholdCount: 10, thresholdChars: 5000, delayMs: 0, logger: { warn: () => {} } };
189
+ // 库里的持久化值可能是老库的 NULL、或手工改坏的库:半个基线(只有条数)若被当成
190
+ // 「12 条 0 字符」,阈值会按 0 字符起算——比不认还糟。非法种子一律退回零基线。
191
+ for (const bad of [{ count: 12 }, { chars: 2000 }, { count: -1, chars: 2000 }, { count: 1.5, chars: 10 }, "nope"]) {
192
+ const sched = createDreamScheduler({ ...opts, baselineSeed: bad });
193
+ assert.equal(sched.maybeSchedule(service), true, `unusable seed ${JSON.stringify(bad)} falls back to the zero baseline`);
194
+ sched.dispose();
195
+ }
196
+ store.close();
197
+ });
198
+
199
+ test("issue#89: a failing baseline read never breaks the run nor fakes a baseline", async () => {
200
+ const store = createStore(":memory:");
201
+ const service = createService({ store, mirror: null, config: {} });
202
+ const a = service.saveWithDedupe({ type: "project", title: "旧1", content: "第一段内容" });
203
+ // finish 阶段读库失败(关库 / IO 故障):必须不反噬巩固本身(审计行照写),也不
204
+ // 写一个猜出来的基线(落 NULL → 下次重启退回零基线,而不是拿假基线抬高阈值)。
205
+ // 故障点在「总览出文本之后」才武装:runDream 前面还要读两次库(候选集与总览输入),
206
+ // 过早抛错会把整轮打成 failed,验的就不是收尾取证这条路了。
207
+ const realAll = service.all.bind(service);
208
+ let armed = false;
209
+ let calls = 0;
210
+ service.all = () => {
211
+ if (armed) throw new Error("store closed");
212
+ return realAll();
213
+ };
214
+ const ctx = {
215
+ llm: {
216
+ stream: async function* () {
217
+ if (calls++ === 0) {
218
+ yield { type: "text-delta", text: JSON.stringify([{ action: "keep", ids: [a.memory.id] }]) };
219
+ } else {
220
+ armed = true;
221
+ yield { type: "text-delta", text: "记忆库总览摘要文本" };
222
+ }
223
+ yield { type: "finish", reason: { kind: "ok" } };
224
+ }
225
+ },
226
+ logger: { warn: () => {} }
227
+ };
228
+ const dream = createDreamScheduler({ thresholdCount: 1, thresholdChars: 0, delayMs: 0, logger: { warn: () => {} } });
229
+ const ok = await dream.runDream(ctx, service, { dreamProvider: "deepseek", dreamModel: "deepseek-chat" });
230
+ assert.equal(ok.ok, true, "the run still completes when the baseline read fails");
231
+ assert.equal(ok.baseline, undefined, "no made-up baseline is reported");
232
+ assert.ok(store.listDreamRuns({ limit: 1 })[0], "the audit row is still written");
233
+ assert.equal(store.listDreamRuns({ limit: 1 })[0].store_count, undefined, "the audit row carries no baseline");
234
+ store.close();
235
+ });
236
+
237
+ // --- CodeRabbit 在 #291 上的同类要求:index.js 的 seed 是单行无分支接线,仓库没有
238
+ // 插件级挂载 harness(apply 需要完整宿主 ctx),改为源码级锁——删掉该注入行即红。
239
+ // import 用 URL 相对本文件解析,不依赖测试进程 CWD。
240
+ const indexSrc = readFileSync(fileURLToPath(new URL("../src/index.js", import.meta.url)), "utf8");
241
+
242
+ test("issue#89: index.js wires the dream baseline seed from the audit trail", () => {
243
+ assert.match(
244
+ indexSrc,
245
+ /baselineSeed:\s*store\.lastDreamBaseline\("auto"\)/,
246
+ "dream scheduler must seed its threshold baseline from dream_runs (run_type='auto') — remove this line and the restart-bypass returns"
247
+ );
248
+ });
@@ -1,6 +1,6 @@
1
1
  import test from "node:test";
2
2
  import assert from "node:assert/strict";
3
- import { readFileSync, existsSync } from "node:fs";
3
+ import { readFileSync, existsSync, readdirSync } from "node:fs";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import { dirname, join } from "node:path";
6
6
  import { spawnSync } from "node:child_process";
@@ -63,9 +63,13 @@ test("sidebar entry opens the sheet; conversation-view tab stays retired", () =>
63
63
  "no dialog surface should remain"
64
64
  );
65
65
  assert.ok(
66
- /function openLibrary\(\) \{\s*setOverlayOpen\(true\);\s*\}/.test(clientSource),
66
+ /function openLibrary\(\) \{\s*setOverlayOpen\(true\);/.test(clientSource),
67
67
  "the entry must open the overlay directly (no tab activation)"
68
68
  );
69
+ assert.ok(
70
+ clientSource.includes("conflictBadgeCount.refresh();"),
71
+ "opening the library must re-read the conflict count (badge must not show a stale number)"
72
+ );
69
73
  assert.equal(
70
74
  clientSource.includes('ctx.slots.inject("conversation.view"'),
71
75
  false,
@@ -126,16 +130,20 @@ test("trigger renders a wide row or a rail icon from the wide flag", () => {
126
130
  "the label span must render only when wide"
127
131
  );
128
132
  assert.ok(
129
- /size: wide \? 15 : 18/.test(clientSource),
130
- "the portalled icon must follow the native rail sizing convention"
133
+ /size: wide \? 16 : 18/.test(clientSource),
134
+ "the portalled icon must follow the host panel-row size (16) and the rail size (18)"
131
135
  );
132
136
  });
133
137
 
134
- // The entry lives at the top of the plugin-entry group: the real button is
135
- // portalled right after the host's New-Session row (above the entries other
136
- // plugins inject there), while the sidebar.footer.action registration remains
137
- // as the React anchor and the in-place fallback when the host markup changes.
138
- test("sidebar entry portals above the workspaces region with footer fallback", () => {
138
+ // 入口的两态形状(宿主内核 0.2 侧边栏改版后重写,见 lib/client.js 的
139
+ // SidebarTopEntry 头注释):
140
+ // · 展开:混进宿主 nav.panelList 里「插件 / 自动化任务」那一组,套用宿主
141
+ // panelRow 的实时类名 → 一颗低调面板行,不再是第二个高亮「新会话」按钮;
142
+ // · 收起:宿主把面板列表整组 display:none,只有 fixed 到标题栏的开关与
143
+ // 「新会话」还可见,所以入口必须搬回那个位置(见下一条用例)。
144
+ // 本用例锁「展开态借的是宿主面板行」,回归表现是入口退回成大按钮或落回
145
+ // 新会话行后面变成孤立项。
146
+ test("sidebar entry portals into the host panel list with footer fallback", () => {
139
147
  assert.ok(
140
148
  clientSource.includes('ctx.slots.inject("sidebar.footer.action"'),
141
149
  "the footer slot registration must stay as anchor + fallback"
@@ -149,20 +157,24 @@ test("sidebar entry portals above the workspaces region with footer fallback", (
149
157
  "the portal must anchor at the host's regionArea container"
150
158
  );
151
159
  assert.ok(
152
- clientSource.includes("insertBefore(created, target)"),
153
- "the entry must be placed through the idempotent place() helper"
160
+ clientSource.includes(`'[class*="panelList"]'`),
161
+ "the wide shape must target the host panel list (the 插件 / 自动化任务 group)"
154
162
  );
155
163
  assert.ok(
156
- clientSource.includes("if (!host) return fallback;"),
157
- "the portal entry persists across collapse (no footer jump); footer fallback only covers portal failure"
164
+ clientSource.includes(`'[class*="panelRow"]:not(.mneme-topentry-native)'`),
165
+ "the panel-row class must come from a host row, never from our own portalled button"
158
166
  );
159
167
  assert.ok(
160
- clientSource.includes('className: `${nativeCls} mneme-topentry-native`'),
161
- "the entry must reuse the host New-Session button class for native geometry alignment"
168
+ clientSource.includes("list.appendChild(created)"),
169
+ "the wide entry must be appended into that group, below the host's own entries"
162
170
  );
163
171
  assert.ok(
164
- /querySelector\('\[class\*="newSession"\]'\)/.test(clientSource),
165
- "the native class must be read from the live New-Session button, not hardcoded"
172
+ clientSource.includes("insertBefore(created, target)"),
173
+ "the entry must keep the idempotent place() helper for hosts without a panel list"
174
+ );
175
+ assert.ok(
176
+ clientSource.includes("if (!host) return fallback;"),
177
+ "the portal entry persists across collapse (no footer jump); footer fallback only covers portal failure"
166
178
  );
167
179
  assert.ok(
168
180
  clientSource.includes('"memory.view.label"'),
@@ -178,7 +190,7 @@ test("sidebar entry portals above the workspaces region with footer fallback", (
178
190
  // sidebar-entry-core (logoRow row, legacy direct-child button), (b) re-asserts
179
191
  // its slot from the existing observer, and (c) stays idempotent — the anchor
180
192
  // check short-circuits before any DOM write, so observers never ping-pong.
181
- test("entry re-asserts the New-Session-adjacent slot against ecosystem entries", () => {
193
+ test("entry re-asserts its slot against ecosystem entries", () => {
182
194
  assert.ok(
183
195
  clientSource.includes(`btn.closest('[class*="logoRow"]')`),
184
196
  "the anchor must resolve the New-Session logo row like the ecosystem core"
@@ -192,18 +204,55 @@ test("entry re-asserts the New-Session-adjacent slot against ecosystem entries",
192
204
  "placement must be idempotent so the observer never writes on steady state"
193
205
  );
194
206
  assert.ok(
195
- /place\(cur\);\s*\n\s*const cls = findNative\(cur\)/.test(clientSource),
196
- "the observer must re-place the entry on childList churn, not just re-sync the class"
207
+ /place\(cur\);\s*\n\s*sync\(cur\);/.test(clientSource),
208
+ "the observer must re-place the entry and re-read its classes on childList churn"
209
+ );
210
+ });
211
+
212
+ // 用户实测确认的遮挡 bug(v0.8.10):收起侧边栏时入口沿用宿主给「新会话」的
213
+ // left:48px,正好压在「新会话」图标上把它盖掉,左上角只剩两颗图标。
214
+ // 修法:不再共用那个 left,用一条更具体的规则推到 84px(「新会话」右侧 =
215
+ // 宿主 48px + 钮宽 28px + 间距 8px),并把宿主的面板菜单变量
216
+ // --dsh-windows-menu-start(宿主收起态自己设 84px)顺延到 120px。
217
+ // 回归表现:左上角少一颗图标,或「应用 / 编辑」与记忆图标叠在一起。
218
+ test("collapsed entry vacates the New-Session slot instead of covering it", () => {
219
+ assert.ok(
220
+ clientSource.includes("html[data-windows-titlebar] .mneme-topentry .mneme-topentry-rail{left:84px}"),
221
+ "the collapsed entry must sit right of the host's 48px New-Session slot"
222
+ );
223
+ assert.ok(
224
+ // 源码里这段 CSS 含转义引号(注入 <style> 后才还原成普通引号),断言按源码字面量写。
225
+ clientSource.includes(':has([data-plugin-entry=\\"@modusensus/dsh-mneme\\"]){--dsh-windows-menu-start:120px}'),
226
+ "the caption menu must shift right so the memory icon never overlaps 应用 / 编辑"
227
+ );
228
+ assert.ok(
229
+ clientSource.includes(":has([data-sidebar-collapsed=true]):has("),
230
+ "the menu shift must apply only while the sidebar is collapsed"
231
+ );
232
+ assert.ok(
233
+ clientSource.includes("`${cls.row} mneme-topentry-rail`.trim()"),
234
+ "the collapsed button carries the host New-Session class plus our rail offset rule"
235
+ );
236
+ });
237
+
238
+ // 宿主面板行的类名带着激活态修饰类(.xxx_panelActive),而我们的入口不是
239
+ // panel 插槽的成员、永远不该常亮;照抄整串类名会让记忆入口跟着宿主当前选中
240
+ // 的面板一起高亮。这里锁「读类名时必须剔掉激活态」。
241
+ test("panel-row highlight never leaks onto the memory entry", () => {
242
+ assert.ok(
243
+ clientSource.includes('!name.includes("panelActive")'),
244
+ "panelActive must be filtered out of the copied host row classes"
197
245
  );
198
246
  });
199
247
 
200
248
  // Three alignment/softness guarantees born from field feedback: (a) the
201
- // portalled entry keeps tracking the live New-Session class (host and skin
202
- // rewrite it asynchronously, so a mount-time snapshot goes stale), (b) it
203
- // inherits that button's width and margins along with its native centering,
204
- // (c) the toolbar dropdown escapes the transform stacking trap — the
249
+ // portalled entry re-reads the live host classes on every mutation — the host
250
+ // and skins rewrite hashed class names asynchronously, so a mount-time snapshot
251
+ // goes stale (收起态尤其依赖这条:搬迁后类名必须重新对齐宿主「新会话」按钮),
252
+ // (b) it never hardcodes a width, so whichever host class it carries decides the
253
+ // box model, (c) the toolbar dropdown escapes the transform stacking trap — the
205
254
  // container needs the z-index because transform creates the context.
206
- test("entry tracks native class, inherits native sizing, and lifts the toolbar dropdown", () => {
255
+ test("entry re-reads the live host classes and lifts the toolbar dropdown", () => {
207
256
  assert.ok(
208
257
  clientSource.includes("new MutationObserver"),
209
258
  "the entry must re-sync the copied class via MutationObserver"
@@ -334,33 +383,99 @@ test("graph toggle uses a node-graph glyph, not the share icon", () => {
334
383
  );
335
384
  });
336
385
 
337
- // #287 跨代图标守卫:primitives 在 0.1.7 把归档图标从「像素后缀」改名成「字重后缀」
338
- // (IconArchiveOutline20 → …OutlineRegular / …OutlineMedium),旧名不留别名,而
339
- // peerDependencies 仍同时覆盖 0.1.6 与 0.1.7 两代宿主。只认某一代的名字,另一代就会
340
- // 把 undefined 交给 h(),落成 slot entry 里的 React #130(该 entry 整块崩),因此必须
341
- // 探测两代名字、并在全缺时降级为不渲染图标。
342
- test("#287: archive glyph probes both naming generations and degrades to no glyph", () => {
343
- assert.ok(
344
- /primitives\.IconArchiveOutline20\s*\?\?\s*primitives\.IconArchiveOutlineRegular\s*\?\?\s*primitives\.IconArchiveOutlineMedium/.test(clientSource),
345
- "the glyph must probe the 0.1.6 pixel name before the 0.1.7 weight names"
346
- );
386
+ // #287 的成因已经不存在了,但那条教训要留着:当年的崩法是「宿主图标名换代 →
387
+ // 取到 undefined → h(undefined) 即 React #130,整个 slot entry 崩掉」。现在的解法
388
+ // 不是继续探测多代名字,而是**不再依赖宿主图标库**——插件自己画 mneme mark。
389
+ // 这条用例锁三件事:(a) 客户端里不再有对宿主 IconArchive* 名字的探测(回到探测
390
+ // 就等于把 #287 那类耦合请回来);(b) mark 走一个内联 SVG 组件、自带路径常量;
391
+ // (c) 四个入口共用同一个 helper,避免某处漏改又出现"归档盒"。
392
+ test("mneme mark is self-drawn: no host icon-name probing left (#287 root cause)", () => {
347
393
  assert.equal(
348
- /h\(IconArchiveOutline20/.test(clientSource),
394
+ /primitives\.IconArchiveOutline(20|Regular|Medium)/.test(clientSource),
349
395
  false,
350
- "the raw constant must never reach h() — on a host lacking that name it renders undefined (React #130)"
396
+ "the client must not probe host archive glyph names any more"
397
+ );
398
+ assert.ok(
399
+ /const MNEME_MARK_D = "M[^"]+"/.test(clientSource),
400
+ "the mark must carry its own path constant"
401
+ );
402
+ assert.ok(
403
+ clientSource.includes('viewBox: "0 0 16 16"') && clientSource.includes('stroke: "currentColor"'),
404
+ "the mark must follow the host icon spec (16 grid, currentColor) so it reads as first-party"
351
405
  );
352
406
  assert.ok(
353
- /const renderArchiveIcon = \(props\) => \(IconArchive \? h\(IconArchive, props\) : null\)/.test(clientSource),
354
- "the glyph must render through a null-guarded helper so a missing icon degrades to nothing"
407
+ /const renderMnemeMark = \(props\) => h\(MnemeMark, props\)/.test(clientSource),
408
+ "the mark must render through one shared helper"
355
409
  );
356
410
  for (const site of [
357
- "renderArchiveIcon({ size: 15 })", // 浮层标题栏
358
- "renderArchiveIcon({ size: wide ? 16 : 18 })", // 侧栏 trigger
359
- "renderArchiveIcon({ size: wide ? 15 : 18 })", // portal 到宿主原生侧栏的入口
360
- "renderArchiveIcon({ size })" // better-sidebar tab 图标
411
+ "renderMnemeMark({ size: 15 })", // 浮层标题栏
412
+ "renderMnemeMark({ size: wide ? 16 : 18 })", // 侧栏 trigger + portal 入口(面板行档 16 / 标题栏档 18)
413
+ "renderMnemeMark({ size })" // better-sidebar tab 图标
361
414
  ]) {
362
415
  assert.ok(clientSource.includes(site), `every render site must go through the helper: ${site}`);
363
416
  }
417
+ assert.equal(
418
+ /renderArchiveIcon/.test(clientSource),
419
+ false,
420
+ "the retired archive-glyph helper must be gone, not left as a second path"
421
+ );
422
+ });
423
+
424
+ // 插件列表那颗图标由宿主从 package.json 的 `icon` 读:必须是**包内相对路径**、
425
+ // 落在 files 白名单里、且宿主只接受 SVG/PNG/JPEG/WebP ≤256KiB。
426
+ // 宿主 `iconOf()` 的硬规则:绝对路径或带 scheme 直接报错、realpath 后不许跑出
427
+ // manifest 目录、超 256KiB 报错;缺文件/坏文件时静默回落成通用插画(等于用户
428
+ // 看到的不是自家图标)。这里把可静态检的部分全锁住。
429
+ test("plugin-list icon: declared, in-package, and shipped in files", () => {
430
+ const icon = pkg.icon;
431
+ assert.equal(typeof icon, "string", "package.json must declare an icon");
432
+ assert.equal(icon, "assets/icon.svg", "the icon must be the self-drawn mark");
433
+ assert.ok(!/^([A-Za-z]:|[A-Za-z][A-Za-z\d+.-]*:)/.test(icon), "the icon must be a relative path (host rejects absolute/URL)");
434
+ assert.ok(pkg.files.includes("assets"), "the assets dir must ship, or the published package has no icon");
435
+ const file = join(root, icon);
436
+ assert.ok(existsSync(file), `the declared icon must exist: ${icon}`);
437
+ const bytes = readFileSync(file);
438
+ assert.ok(bytes.length > 0 && bytes.length <= 256 * 1024, "the icon must be a non-empty file under the host's 256 KiB cap");
439
+ const svg = bytes.toString("utf8");
440
+ assert.ok(svg.includes("<svg") && svg.includes("viewBox"), "the icon must be an SVG with a viewBox");
441
+ // <img> 不能继承 currentColor:文件必须自带颜色,否则会渲染成黑块/不可见。
442
+ assert.ok(/stroke="#[0-9a-fA-F]{6}"/.test(svg), "the standalone file must bake its own colour (<img> cannot inherit currentColor)");
443
+ // 文件与客户端内联路径必须是同一颗:两边形状漂移是最容易悄悄发生的事。
444
+ const inline = clientSource.match(/const MNEME_MARK_D = "([^"]+)"/)[1];
445
+ assert.ok(svg.includes(inline), "assets/icon.svg must use the same path as the inline mark (no shape drift)");
446
+ });
447
+
448
+ // 插件列表的标题/描述读包内的 `locale/<语言码>.json`:宿主 readPluginMeta 先解析
449
+ // `<specifier>/locale/en.json`,再遍历同目录的每个 *.json(文件名必须过语言码正则),
450
+ // 取 meta.title / meta.description;一个都读不到就回落到 package.json 的 name——
451
+ // 也就是裸包名 "@modusensus/dsh-mneme"(用户看到的现状)。
452
+ // ⚠️ 真正的门槛是**能否被 Node 解析**:宿主用 ESM resolver 取这个文件,而 exports
453
+ // 字段会把没列出的子路径**全部挡掉**,且 optionalResourcePath 对"读不到"是静默的
454
+ // (返回 undefined 继续走回落),不报错。所以少了 "./locale/*" 这一行,文件乖乖躺在
455
+ // 包里也永远不生效——这条回归极难靠肉眼发现,必须锁死。
456
+ test("plugin-list title/description are localized through locale/*.json", () => {
457
+ assert.ok(
458
+ Object.keys(pkg.exports).includes("./locale/*"),
459
+ "exports must expose ./locale/* or the host resolver silently never reads the dictionaries"
460
+ );
461
+ assert.ok(pkg.files.includes("locale"), "the locale dir must ship in files, or the published package loses it");
462
+ const dir = join(root, "locale");
463
+ assert.ok(existsSync(dir), "locale/ must exist");
464
+ const files = readdirSync(dir).filter((name) => name.endsWith(".json"));
465
+ assert.ok(files.length >= 2, "at least an English and a Chinese dictionary must ship");
466
+ for (const name of files) {
467
+ const language = name.slice(0, -5);
468
+ assert.match(language, /^[A-Za-z]{2,8}(?:-[A-Za-z0-9]{1,8})*$/, `locale/${name} must be named after a language id`);
469
+ const parsed = JSON.parse(readFileSync(join(dir, name), "utf8"));
470
+ assert.equal(typeof parsed.meta, "object", `locale/${name} must carry a meta block`);
471
+ assert.ok(typeof parsed.meta.title === "string" && parsed.meta.title.trim() !== "", `locale/${name}: meta.title must be a non-empty string`);
472
+ assert.ok(
473
+ typeof parsed.meta.description === "string" && parsed.meta.description.trim() !== "",
474
+ `locale/${name}: meta.description must be a non-empty string`
475
+ );
476
+ }
477
+ const en = JSON.parse(readFileSync(join(dir, "en.json"), "utf8"));
478
+ assert.ok(en.meta.title !== pkg.name, "the English title must not be the bare package name (that is the bug this fixes)");
364
479
  });
365
480
 
366
481
  // 方案 A:查询收敛。状态页只做仪表盘(小页预览 + 服务端 total + 查看全部),
@@ -937,11 +1052,96 @@ test("conflict queue: actionable status card and sidebar entry badge", () => {
937
1052
  assert.ok(clientSource.includes("mneme-statuscard--actionable"), "highlight CSS must exist");
938
1053
  assert.ok((clientSource.match(/h\(ConflictBadge, \{ pending \}\)/g) || []).length === 2,
939
1054
  "badge must be wired into both entry buttons");
940
- assert.ok((clientSource.match(/= useConflictBadgeCount\(t\)/g) || []).length === 2,
941
- "both entry components must subscribe to the badge count");
1055
+ assert.ok((clientSource.match(/= useConflictBadgeCount\(\);/g) || []).length === 2,
1056
+ "both entry components must subscribe to the shared badge count");
942
1057
  assert.ok(clientSource.includes('"/api/dsh-mneme/dream-status"'), "badge must reuse the dream-status endpoint");
943
1058
  });
944
1059
 
1060
+ // 用户反馈「裁决完红点消失得很慢」的根因:裁决成功时冲突队列广播
1061
+ // mneme:conflicts-changed,但只有状态卡订阅了它,侧边栏红点漏订——红点于是只能
1062
+ // 等 60s 兜底轮询。本用例锁三类回归:(a) 红点订阅该广播并立即重取;(b) 计数走
1063
+ // 模块级单例(原先 portal 与 footer 回退各起一个 60s 轮询 → 两份请求且互不同步);
1064
+ // (c) 兜底轮询保留(后台 autoDream 自己产生/消解冲突时没人广播,靠它浮现)。
1065
+ test("sidebar badge clears on the conflicts-changed broadcast, not on the next poll", () => {
1066
+ assert.ok(
1067
+ /window\.addEventListener\("mneme:conflicts-changed", onChanged\)/.test(clientSource),
1068
+ "the badge must subscribe to the broadcast the conflict queue already emits"
1069
+ );
1070
+ assert.ok(
1071
+ clientSource.includes("const conflictBadgeCount = (() => {"),
1072
+ "the count must live in one module-level store shared by both entry components"
1073
+ );
1074
+ assert.ok(
1075
+ clientSource.includes("if (listeners.size === 1) {"),
1076
+ "exactly one poll timer per store, refcounted by its subscribers"
1077
+ );
1078
+ assert.ok(
1079
+ /setInterval\(refresh, 60_000\)/.test(clientSource),
1080
+ "the 60s fallback poll must stay: background autoDream resolves conflicts without broadcasting"
1081
+ );
1082
+ assert.ok(
1083
+ clientSource.includes("if (inflight) return inflight;"),
1084
+ "concurrent refreshes (broadcast + open + first subscriber) must collapse into one request"
1085
+ );
1086
+ });
1087
+
1088
+ // 上一条锁的是"接线在不在",这条锁"接线真的按预期跑"。把源码里那段 store 抠出来
1089
+ // 用受控的 apiFetch / setInterval 真跑一遍——用户抱怨的正是时序(慢),而
1090
+ // "存在某个字符串" 证明不了任何时序。回归表现:第二个订阅者又开一条轮询、
1091
+ // 广播后不立刻反映、并发 refresh 打两次请求、订阅者走光后定时器还在跑。
1092
+ test("badge store: one poll for both entries, immediate publish after a refresh", async () => {
1093
+ const src = clientSource.match(/const conflictBadgeCount = \(\(\) => \{[\s\S]*?\}\)\(\);/);
1094
+ assert.ok(src, "the badge store source block must be extractable verbatim");
1095
+ const calls = [];
1096
+ const resolvers = [];
1097
+ const timers = [];
1098
+ let cleared = 0;
1099
+ const makeStore = new Function("apiFetch", "setInterval", "clearInterval",
1100
+ `${src[0]}\nreturn conflictBadgeCount;`);
1101
+ const store = makeStore(
1102
+ (path) => {
1103
+ calls.push(path);
1104
+ return new Promise((resolve) => { resolvers.push(resolve); });
1105
+ },
1106
+ (fn, ms) => { timers.push({ fn, ms }); return timers.length; },
1107
+ () => { cleared += 1; }
1108
+ );
1109
+ const settle = async (payload) => {
1110
+ const batch = resolvers.splice(0, resolvers.length);
1111
+ batch.forEach((r) => r({ ok: true, json: () => Promise.resolve(payload) }));
1112
+ await new Promise((r) => setTimeout(r, 0));
1113
+ };
1114
+
1115
+ const seen = [];
1116
+ const un1 = store.subscribe((v) => seen.push(v));
1117
+ assert.equal(calls.length, 1, "the first subscriber must trigger exactly one fetch");
1118
+ assert.equal(timers.length, 1, "and exactly one timer");
1119
+ assert.equal(timers[0].ms, 60_000, "the fallback cadence stays 60s");
1120
+ const un2 = store.subscribe((v) => seen.push(v));
1121
+ assert.equal(calls.length, 1, "a second subscriber must reuse the poll, not start another");
1122
+ assert.equal(timers.length, 1, "still one timer for both entry buttons");
1123
+
1124
+ await settle({ pendingConflicts: 3 });
1125
+ assert.deepEqual(seen, [0, 0, 3, 3], "both subscribers must see the same published value");
1126
+
1127
+ // 广播路径:冲突队列裁决成功后 dispatch mneme:conflicts-changed → hook 调 refresh()
1128
+ store.refresh();
1129
+ store.refresh();
1130
+ assert.equal(calls.length, 2, "concurrent refreshes collapse into a single in-flight request");
1131
+ await settle({ pendingConflicts: 0 });
1132
+ assert.equal(store.get(), 0, "the count must drop as soon as the refreshed value lands");
1133
+ assert.equal(seen[seen.length - 1], 0, "and both subscribers must be notified");
1134
+
1135
+ store.refresh();
1136
+ await settle({ pendingConflicts: 0 });
1137
+ assert.deepEqual(seen, [0, 0, 3, 3, 0, 0], "an unchanged value must not re-render subscribers");
1138
+
1139
+ un1();
1140
+ assert.equal(cleared, 0, "the timer must survive while one subscriber remains");
1141
+ un2();
1142
+ assert.equal(cleared, 1, "the timer must stop when the last subscriber leaves");
1143
+ });
1144
+
945
1145
  // --- issue #179:注入预览(状态页卡片 + /inject-preview 端点透传的旁路快照) ---
946
1146
  test("a11y+preview: inject preview card is wired on the status tab", () => {
947
1147
  assert.ok(clientSource.includes('"/api/dsh-mneme/inject-preview"'), "card must fetch the preview endpoint");