@modusensus/dsh-mneme 0.7.13 → 0.7.15

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.
@@ -41,71 +41,71 @@ test("memory entry registers into the sidebar foot slot", () => {
41
41
  );
42
42
  });
43
43
 
44
- // The drawer era is over as the PRIMARY surface: the sidebar entry must
45
- // activate the main-area memory library tab first. The one sanctioned
46
- // exception is the hero screen the host hides the whole tab ring while a
47
- // session is blank, so activation legitimately fails there and the same
48
- // click falls back to a full-viewport overlay (same MemoryExplorer, not the
49
- // old 420px side drawer). A dialog role stays banned either way.
50
- test("tab-first activation with hero-screen overlay fallback", () => {
44
+ // The tab era is over: the in-conversation memory tab fought the floating
45
+ // composer and squeezed the session layout, so the library no longer
46
+ // registers as a conversation view. The sidebar entry opens the centered
47
+ // sheet directly from any state, including the new-chat hero screen. A
48
+ // dialog role stays banned either way.
49
+ test("sidebar entry opens the sheet; conversation-view tab stays retired", () => {
51
50
  assert.equal(
52
51
  clientSource.includes("role: \"dialog\""),
53
52
  false,
54
53
  "no dialog surface should remain"
55
54
  );
56
55
  assert.ok(
57
- clientSource.includes("activateExplorerTab(t(\"memory.view.label\"))"),
58
- "the sidebar trigger must activate the memory library tab by its label"
56
+ /function openLibrary\(\) \{\s*setOverlayOpen\(true\);\s*\}/.test(clientSource),
57
+ "the entry must open the overlay directly (no tab activation)"
59
58
  );
60
- assert.ok(
61
- clientSource.includes("activateExplorerTab(t(\"memory.view.label\")).then((ok) => { if (!ok) setOpen(true); })"),
62
- "a failed tab activation must open the fallback overlay"
63
- );
64
- assert.ok(
65
- /if \(candidates\.length === 0\) \{ resolve\(false\); return; \}/.test(clientSource),
66
- "activation must resolve false when the tab ring is absent (hero screen)"
59
+ assert.equal(
60
+ clientSource.includes('ctx.slots.inject("conversation.view"'),
61
+ false,
62
+ "the conversation.view tab must stay retired"
67
63
  );
68
- assert.ok(
69
- /aria-selected.*true/.test(clientSource),
70
- "activation is only confirmed once the host marks the tab selected"
64
+ assert.equal(
65
+ clientSource.includes("activateExplorerTab"),
66
+ false,
67
+ "the tab-click activation machinery must be gone"
71
68
  );
72
- // Same-labelled tabs from other plugins must never be activated by mistake:
73
- // the click is only trusted once OUR explorer view actually rendered, and
74
- // hidden tab panes are excluded before any click happens.
69
+ });
70
+
71
+ // The sheet is deliberately NOT fullscreen: a dimmed backdrop plus a rounded
72
+ // panel capped at 1180×880 keeps the conversation visible behind it. It
73
+ // reuses the full MemoryExplorer and closes on Esc, the close button, or a
74
+ // backdrop click.
75
+ test("memory library opens as a centered sheet with backdrop", () => {
75
76
  assert.ok(
76
- /querySelector\("\.mneme-x"\) !== null/.test(clientSource),
77
- "activation must verify the memory explorer rendered, not just the tab state"
77
+ clientSource.includes(".mneme-backdrop{position:fixed;inset:0"),
78
+ "a dimmed backdrop must sit behind the sheet"
78
79
  );
79
80
  assert.ok(
80
- /offsetParent === null/.test(clientSource),
81
- "hidden tab panes (settings dialogs etc.) must be excluded from activation"
81
+ /\.mneme-overlay\{position:fixed;z-index:1000;left:50%;top:50%;transform:translate\(-50%,-50%\)/.test(clientSource),
82
+ "the sheet must be centered, not viewport-filling"
82
83
  );
83
- });
84
-
85
- // The fallback overlay reuses the full MemoryExplorer (three columns, graph,
86
- // settings) at viewport size — not the old side drawer — and closes on Esc
87
- // or the close button.
88
- test("hero fallback overlay is a full-viewport MemoryExplorer with a close affordance", () => {
89
84
  assert.ok(
90
- clientSource.includes('.mneme-overlay{position:fixed;inset:0'),
91
- "the overlay must cover the full viewport"
85
+ clientSource.includes("width:min(1240px,calc(100vw - 88px))"),
86
+ "the sheet must not occupy the full width and keeps friendly margins"
92
87
  );
93
88
  assert.ok(
94
89
  clientSource.includes('h(MemoryExplorer, { t })'),
95
- "the overlay renders the same MemoryExplorer component as the tab"
90
+ "the sheet renders the same MemoryExplorer component"
96
91
  );
97
92
  assert.ok(
98
93
  clientSource.includes('e.key === "Escape"'),
99
- "Esc must close the overlay"
94
+ "Esc must close the sheet"
95
+ );
96
+ assert.ok(
97
+ clientSource.includes('className: "mneme-backdrop"'),
98
+ "the backdrop is a clickable close surface"
100
99
  );
101
100
  assert.ok(
102
101
  clientSource.includes('"memory.overlay.close"'),
103
- "the overlay ships a localized close label in both dictionaries"
102
+ "the close affordance keeps its localized label"
104
103
  );
105
104
  });
106
105
 
107
- // The sidebar hands each footer action only its column state: a wide row
108
- // (icon + label) when expanded, a bare rail icon when collapsed.
106
+ // The sidebar hands each entry only its column state: a wide row (icon +
107
+ // label) when expanded, a bare rail icon when collapsed — for both the
108
+ // portalled top button and the footer fallback.
109
109
  test("trigger renders a wide row or a rail icon from the wide flag", () => {
110
110
  assert.ok(
111
111
  /wide \? "mneme-trigger" : "mneme-trigger mneme-rail"/.test(clientSource),
@@ -115,23 +115,48 @@ test("trigger renders a wide row or a rail icon from the wide flag", () => {
115
115
  /wide && h\("span", \{ className: "mneme-trigger-label" \}/.test(clientSource),
116
116
  "the label span must render only when wide"
117
117
  );
118
+ assert.ok(
119
+ /size: wide \? 15 : 18/.test(clientSource),
120
+ "the portalled icon must follow the native rail sizing convention"
121
+ );
118
122
  });
119
123
 
120
- // The full-width memory browser lives in the conversation view ring beside
121
- // Chat / Trajectory, so the client must inject into `conversation.view` with
122
- // a stable entry id (the active view is persisted by that id).
123
- test("explorer registers into the conversation view ring", () => {
124
+ // The entry lives ABOVE the workspaces region: the real button is portalled
125
+ // just before the host's regionArea container (below New Session, above the
126
+ // workspace list), while the sidebar.footer.action registration remains as
127
+ // the React anchor and the in-place fallback when the host markup changes.
128
+ test("sidebar entry portals above the workspaces region with footer fallback", () => {
124
129
  assert.ok(
125
- clientSource.includes('ctx.slots.inject("conversation.view"'),
126
- "client must inject into conversation.view"
130
+ clientSource.includes('ctx.slots.inject("sidebar.footer.action"'),
131
+ "the footer slot registration must stay as anchor + fallback"
132
+ );
133
+ assert.ok(
134
+ clientSource.includes("SidebarTopEntry"),
135
+ "the portalled top entry must exist"
136
+ );
137
+ assert.ok(
138
+ clientSource.includes(`'[class*="regionArea"]'`),
139
+ "the portal must anchor at the host's regionArea container"
127
140
  );
128
141
  assert.ok(
129
- clientSource.includes('id: "dsh-mneme-memory"'),
130
- "the view entry needs a stable, unique id"
142
+ clientSource.includes("insertBefore(created, region)"),
143
+ "the entry must sit immediately above the workspaces region"
144
+ );
145
+ assert.ok(
146
+ clientSource.includes("if (!host) return fallback;"),
147
+ "the portal entry persists across collapse (no footer jump); footer fallback only covers portal failure"
148
+ );
149
+ assert.ok(
150
+ clientSource.includes('className: `${nativeCls} mneme-topentry-native`'.replace("${nativeCls} mneme-topentry-native", "${nativeCls} mneme-topentry-native")),
151
+ "the entry must reuse the host New-Session button class for native geometry alignment"
152
+ );
153
+ assert.ok(
154
+ /querySelector\('\[class\*="newSession"\]'\)/.test(clientSource),
155
+ "the native class must be read from the live New-Session button, not hardcoded"
131
156
  );
132
157
  assert.ok(
133
158
  clientSource.includes('"memory.view.label"'),
134
- "the tab label must come from the memory.view.label dictionary key"
159
+ "the sheet aria-label must come from the memory.view.label dictionary key"
135
160
  );
136
161
  });
137
162
 
@@ -199,16 +224,21 @@ test("entity: search grammar offers a graph jump", () => {
199
224
  );
200
225
  });
201
226
 
202
- // The explorer is a three-pane layout: types with counts, a month→day time
203
- // tree, and a detail pane rendering the untruncated content.
227
+ // The explorer is a card-grid / timeline browse with a right-hand detail
228
+ // drawer: types with counts, a month→day time tree (timeline mode), and the
229
+ // drawer rendering the untruncated content.
204
230
  test("explorer lays out types, timeline, and full-text detail", () => {
205
231
  assert.ok(
206
232
  clientSource.includes('className: "mneme-xmain"'),
207
233
  "the three-column grid must be present"
208
234
  );
209
235
  assert.ok(
210
- clientSource.includes('className: "mneme-xdcontent"'),
211
- "the detail pane must render the full content"
236
+ clientSource.includes('className: "mneme-dcontent"'),
237
+ "the detail drawer must render the full content"
238
+ );
239
+ assert.ok(
240
+ clientSource.includes('className: "mneme-cards"'),
241
+ "the card-grid view must exist alongside the timeline"
212
242
  );
213
243
  assert.ok(
214
244
  /toLocaleDateString\(undefined, \{ year: "numeric", month: "long" \}\)/.test(clientSource),
@@ -5,6 +5,21 @@
5
5
  import { createStore } from "../../src/store.js";
6
6
 
7
7
  const [, , dbPath, iterations] = process.argv;
8
- const store = createStore(dbPath);
9
- for (let i = 0; i < Number(iterations); i++) store.incrementGeneration();
8
+
9
+ // Windows CI 8 进程并发写同一 generation 行时,busy_timeout(5s) 到期仍偶发
10
+ // SQLITE_BUSY("database is locked")。锁是瞬时资源竞争,线性重试即可收敛;
11
+ // incrementGeneration 是单条原子 UPDATE(失败即未执行),重试不会重复计数。
12
+ async function withBusyRetry(fn) {
13
+ for (let attempt = 0; ; attempt++) {
14
+ try {
15
+ return fn();
16
+ } catch (err) {
17
+ if (!/database is locked|SQLITE_BUSY/i.test(String(err?.message ?? err)) || attempt >= 40) throw err;
18
+ await new Promise((r) => setTimeout(r, 250));
19
+ }
20
+ }
21
+ }
22
+
23
+ const store = await withBusyRetry(() => createStore(dbPath));
24
+ for (let i = 0; i < Number(iterations); i++) await withBusyRetry(() => store.incrementGeneration());
10
25
  store.close();
@@ -114,7 +114,15 @@ test("peer-C: 多进程并发原子递增——8 进程×10 次 incrementGenerat
114
114
  store.close();
115
115
  }
116
116
  } finally {
117
- rmSync(dir, { recursive: true, force: true });
117
+ // Windows:8 SQLite 子进程退出后 -shm/-wal 句柄延迟释放,删目录偶发 EPERM;
118
+ // rm 带 maxRetries 自动重试(30×100ms 共 3s),仍失败则放行——清理只是收尾,
119
+ // 并发断言正确性由上方 generation 断言保证,残留临时目录交给 OS/CI 回收,
120
+ // 绝不让句柄释放慢把 flaky 标红。
121
+ try {
122
+ rmSync(dir, { recursive: true, force: true, maxRetries: 30, retryDelay: 100 });
123
+ } catch (err) {
124
+ if (!["EPERM", "EBUSY", "EACCES"].includes(err.code)) throw err;
125
+ }
118
126
  }
119
127
  });
120
128
 
@@ -123,3 +123,139 @@ test("external api settings persist token and merge partial writes", () => {
123
123
  assert.deepEqual(settings.getExternalApi(), { enabled: true, port: 9000, host: "0.0.0.0", token: "tok-2" });
124
124
  store.close();
125
125
  });
126
+
127
+ // --- feature flags ---
128
+
129
+ test("feature flags default to an empty object", () => {
130
+ const { store, settings } = setup();
131
+ assert.deepEqual(settings.getFeatureFlags(), {});
132
+ store.close();
133
+ });
134
+
135
+ test("setFeatureFlags rejects unknown keys and persists nothing", () => {
136
+ const { store, settings } = setup();
137
+ settings.setFeatureFlags({ autoDream: false });
138
+ assert.throws(() => settings.setFeatureFlags({ noSuchFlag: true }), TypeError);
139
+ assert.throws(() => settings.setFeatureFlags({ noSuchFlag: true }), /noSuchFlag/);
140
+ // 被拒的 patch 不落库,已存的合法值原样保留
141
+ assert.deepEqual(settings.getFeatureFlags(), { autoDream: false });
142
+ store.close();
143
+ });
144
+
145
+ test("setFeatureFlags enforces boolean types and integer ranges", () => {
146
+ const { store, settings } = setup();
147
+ assert.throws(() => settings.setFeatureFlags({ autoInject: "yes" }), /autoInject/);
148
+ assert.throws(() => settings.setFeatureFlags({ distillMaxChars: 500 }), /distillMaxChars/);
149
+ assert.throws(() => settings.setFeatureFlags({ distillRateLimitRetries: 1.5 }), /distillRateLimitRetries/);
150
+ assert.throws(() => settings.setFeatureFlags({ codingBoostFactor: 9 }), /codingBoostFactor/);
151
+ assert.deepEqual(settings.getFeatureFlags(), {}, "failed writes persist nothing");
152
+ store.close();
153
+ });
154
+
155
+ test("setFeatureFlags merges partial patches and persists across instances", () => {
156
+ const { store, settings } = setup();
157
+ settings.setFeatureFlags({ autoDream: false, distillMaxChars: 48000 });
158
+ settings.setFeatureFlags({ rerankEnabled: true });
159
+ // 同一 kv 上的新实例读回合并结果,证明已真正持久化
160
+ const settings2 = createSettings(store.db);
161
+ assert.deepEqual(settings2.getFeatureFlags(), { autoDream: false, distillMaxChars: 48000, rerankEnabled: true });
162
+ store.close();
163
+ });
164
+
165
+ test("getFeatureFlags drops unknown and corrupted keys from the kv", () => {
166
+ const { store, settings } = setup();
167
+ settings.setFeatureFlags({ autoInject: false });
168
+ // 模拟旧版本/手改残留:绕过校验直接写脏 kv
169
+ store.db.prepare("UPDATE user_settings SET value = ? WHERE key = ?").run(
170
+ JSON.stringify({ autoInject: false, legacyFlag: true, autoDream: "yes", distillMaxChars: 999999 }),
171
+ "feature_flags"
172
+ );
173
+ assert.deepEqual(settings.getFeatureFlags(), { autoInject: false });
174
+ // 整个 kv 损坏(非 JSON)→ 读回空对象而不是抛错
175
+ store.db.prepare("UPDATE user_settings SET value = ? WHERE key = ?").run("not-json", "feature_flags");
176
+ assert.deepEqual(settings.getFeatureFlags(), {});
177
+ store.close();
178
+ });
179
+
180
+ test("feature flags accept the new boolean, string, url and enum keys", () => {
181
+ const { store, settings } = setup();
182
+ settings.setFeatureFlags({
183
+ "memoryQualityFilter.enabled": false,
184
+ "llmAudit.enabled": false,
185
+ bm25SearchEnabled: false,
186
+ conflictFreezeEnabled: true,
187
+ trustEpistemicWeighting: true,
188
+ reflectionFailureTracking: false,
189
+ dreamProvider: " siliconflow ",
190
+ dreamModel: "deepseek-chat",
191
+ localEmbedModel: "Xenova/bge-small-zh-v1.5",
192
+ ollamaModel: "nomic-embed-text",
193
+ ollamaBaseUrl: "http://127.0.0.1:11434/",
194
+ embedProvider: "ollama"
195
+ });
196
+ const flags = settings.getFeatureFlags();
197
+ assert.equal(flags["memoryQualityFilter.enabled"], false, "nested keys stored flat");
198
+ assert.equal(flags["llmAudit.enabled"], false);
199
+ assert.equal(flags.bm25SearchEnabled, false);
200
+ assert.equal(flags.conflictFreezeEnabled, true);
201
+ assert.equal(flags.trustEpistemicWeighting, true);
202
+ assert.equal(flags.reflectionFailureTracking, false);
203
+ assert.equal(flags.dreamProvider, "siliconflow", "string values are trimmed");
204
+ assert.equal(flags.dreamModel, "deepseek-chat");
205
+ assert.equal(flags.ollamaBaseUrl, "http://127.0.0.1:11434/");
206
+ assert.equal(flags.embedProvider, "ollama");
207
+ // 同一 kv 上的新实例读回合并结果:点号键按平铺键持久化
208
+ const settings2 = createSettings(store.db);
209
+ assert.equal(settings2.getFeatureFlags()["memoryQualityFilter.enabled"], false);
210
+ assert.equal(settings2.getFeatureFlags().embedProvider, "ollama");
211
+ store.close();
212
+ });
213
+
214
+ test("nested feature flag keys are validated as booleans and rejected otherwise", () => {
215
+ const { store, settings } = setup();
216
+ assert.throws(() => settings.setFeatureFlags({ "memoryQualityFilter.enabled": "yes" }), /memoryQualityFilter\.enabled/);
217
+ assert.throws(() => settings.setFeatureFlags({ "llmAudit.enabled": 1 }), /llmAudit\.enabled/);
218
+ assert.deepEqual(settings.getFeatureFlags(), {}, "failed writes persist nothing");
219
+ store.close();
220
+ });
221
+
222
+ test("string feature flags: empty is legal, over-length and enum violations rejected", () => {
223
+ const { store, settings } = setup();
224
+ // 空字符串合法 = 跟随主对话模型(面板显示 placeholder)
225
+ settings.setFeatureFlags({ dreamProvider: "", dreamModel: " " });
226
+ assert.equal(settings.getFeatureFlags().dreamProvider, "");
227
+ assert.equal(settings.getFeatureFlags().dreamModel, "", "whitespace-only trims to empty");
228
+ assert.throws(() => settings.setFeatureFlags({ dreamModel: "x".repeat(201) }), /dreamModel/);
229
+ assert.throws(() => settings.setFeatureFlags({ localEmbedModel: 42 }), /localEmbedModel/);
230
+ assert.throws(() => settings.setFeatureFlags({ embedProvider: "bogus" }), /embedProvider/);
231
+ assert.throws(() => settings.setFeatureFlags({ embedProvider: "OpenAI" }), /embedProvider/, "enum is case-sensitive");
232
+ assert.deepEqual(settings.getFeatureFlags(), { dreamProvider: "", dreamModel: "" }, "failed writes persist nothing");
233
+ store.close();
234
+ });
235
+
236
+ test("ollamaBaseUrl accepts empty and http(s) URLs, rejects other protocols", () => {
237
+ const { store, settings } = setup();
238
+ settings.setFeatureFlags({ ollamaBaseUrl: "" });
239
+ assert.equal(settings.getFeatureFlags().ollamaBaseUrl, "", "empty = follow default");
240
+ settings.setFeatureFlags({ ollamaBaseUrl: " https://embed.example.com/v1 " });
241
+ assert.equal(settings.getFeatureFlags().ollamaBaseUrl, "https://embed.example.com/v1");
242
+ assert.throws(() => settings.setFeatureFlags({ ollamaBaseUrl: "ftp://localhost:11434" }), /ollamaBaseUrl/);
243
+ assert.throws(() => settings.setFeatureFlags({ ollamaBaseUrl: "file:///etc/passwd" }), /ollamaBaseUrl/);
244
+ assert.throws(() => settings.setFeatureFlags({ ollamaBaseUrl: "javascript:alert(1)" }), /ollamaBaseUrl/);
245
+ assert.throws(() => settings.setFeatureFlags({ ollamaBaseUrl: "localhost:11434" }), /ollamaBaseUrl/, "protocol is required");
246
+ assert.throws(() => settings.setFeatureFlags({ ollamaBaseUrl: "not a url" }), /ollamaBaseUrl/);
247
+ assert.equal(settings.getFeatureFlags().ollamaBaseUrl, "https://embed.example.com/v1", "rejected writes persist nothing");
248
+ store.close();
249
+ });
250
+
251
+ test("getFeatureFlags drops corrupted string, url and enum keys from the kv", () => {
252
+ const { store, settings } = setup();
253
+ settings.setFeatureFlags({ dreamModel: "m", ollamaBaseUrl: "http://localhost:11434", embedProvider: "local" });
254
+ // 模拟旧版本/手改残留:字符串键混入非字符串、URL 键混入非法协议、枚举越界
255
+ store.db.prepare("UPDATE user_settings SET value = ? WHERE key = ?").run(
256
+ JSON.stringify({ dreamModel: 42, ollamaBaseUrl: "ftp://bad", embedProvider: "bogus", autoInject: false }),
257
+ "feature_flags"
258
+ );
259
+ assert.deepEqual(settings.getFeatureFlags(), { autoInject: false });
260
+ store.close();
261
+ });
@@ -293,6 +293,34 @@ test("distills full transcript (tool calls, results, code output) with atomic-me
293
293
  assert.ok(calls[0].messages[0].content[0].text.includes("原子记忆"));
294
294
  });
295
295
 
296
+ test("distill excludes private assistant reasoning blocks from the transcript", async () => {
297
+ const { events, calls } = setup();
298
+ const handler = events.find((e) => e.name === "session/event").fn;
299
+ const session = {
300
+ id: "s12",
301
+ requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
302
+ events: [
303
+ userMessage("解释一下这段代码"),
304
+ {
305
+ type: "assistant/message",
306
+ data: {
307
+ message: {
308
+ content: [
309
+ { type: "reasoning", text: "私有推理:内部权衡过程不该进记忆" },
310
+ { type: "text", text: "这段代码有死循环,第 3 行 while 条件恒真。" }
311
+ ]
312
+ }
313
+ }
314
+ },
315
+ { seq: 3, type: "turn/end" }
316
+ ]
317
+ };
318
+ await handler(session, { seq: 3, type: "turn/end" });
319
+ const transcript = JSON.stringify(calls[0].messages);
320
+ assert.ok(transcript.includes("有死循环,第 3 行"), "public assistant text still distills");
321
+ assert.ok(!transcript.includes("私有推理"), "private reasoning must never enter the distill context");
322
+ });
323
+
296
324
  test("codingRetrospect stores coding memory types in the coding memory type set", async () => {
297
325
  const { events, store, calls } = setup({ codingRetrospect: true }, {
298
326
  stream() {