@modusensus/dsh-mneme 0.7.14 → 0.7.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.
@@ -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),
@@ -870,7 +870,7 @@ test("Bug8: runDream records llm_audit_logs rows for consolidation and summary",
870
870
  assert.deepEqual(summarize.related_memory_ids, [], "summary audit has no related ids");
871
871
  for (const row of rows) {
872
872
  assert.equal(row.status, "success");
873
- assert.equal(row.model_id, "mock:stress-model");
873
+ assert.equal(row.model_id, "deepseek:deepseek-chat", "config-first route (Issue #25): dreamProvider/dreamModel wins");
874
874
  assert.ok(Number.isInteger(row.duration_ms) && row.duration_ms >= 0, "duration recorded");
875
875
  assert.equal(row.input_tokens, 0);
876
876
  assert.equal(row.output_tokens, 0);
@@ -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();
@@ -50,10 +50,11 @@ test("autoDream writes llm_audit_logs rows for consolidation and summary", async
50
50
  assert.equal(summarize.trigger_source, "autoDream");
51
51
  assert.equal(consolidate.status, "success");
52
52
  assert.equal(summarize.status, "success");
53
- // mockCtx resolves the route from agentDefaultModel (mock:stress-model), not
54
- // the config fallback — assert the actually-used route.
55
- assert.equal(consolidate.model_id, "mock:stress-model");
56
- assert.equal(summarize.model_id, "mock:stress-model");
53
+ // config-first (Issue #25): dreamSetup sets dreamProvider/dreamModel, so it
54
+ // wins over mockCtx's agentDefaultModel (mock:stress-model) — assert the
55
+ // actually-used config route.
56
+ assert.equal(consolidate.model_id, "deepseek:deepseek-chat");
57
+ assert.equal(summarize.model_id, "deepseek:deepseek-chat");
57
58
  assert.ok(Array.isArray(consolidate.related_memory_ids) && consolidate.related_memory_ids.length === 2,
58
59
  "consolidation audit records the snapshot ids");
59
60
  assert.ok(consolidate.total_tokens >= 0 && summarize.total_tokens >= 0);
@@ -110,6 +111,35 @@ test("autoDream throwing LLM is audited as status=error", async () => {
110
111
  store.close();
111
112
  });
112
113
 
114
+ test("autoDream parse failure is audited as status=error, not fake success", async () => {
115
+ const { store, service, config } = dreamSetup();
116
+ service.saveWithDedupe({ type: "project", title: "主题", content: "内容" });
117
+ // Stream completes fine but returns no JSON array — the run fails, and the
118
+ // audit must NOT claim success (the old bug: llm_audit said dream_consolidate
119
+ // success while the run recorded failed).
120
+ const warnings = [];
121
+ const ctx = {
122
+ logger: { warn: (m) => warnings.push(m) },
123
+ llm: {
124
+ async *stream() {
125
+ yield { type: "text-delta", index: 0, text: "抱歉,我无法解析成 JSON。" };
126
+ yield { type: "finish", reason: { kind: "stop" } };
127
+ }
128
+ }
129
+ };
130
+ const dream = createDreamScheduler({ thresholdCount: 1, thresholdChars: 0, delayMs: 0 });
131
+ const result = await dream.runDream(ctx, service, config);
132
+ assert.equal(result.ok, false);
133
+ assert.equal(result.error, "no json array in llm output");
134
+ const rows = service.listLlmAudits();
135
+ assert.equal(rows.length, 1, "failed consolidation still audited");
136
+ assert.equal(rows[0].operation_type, "dream_consolidate");
137
+ assert.equal(rows[0].status, "error", "stream succeeded but output unusable → audit error");
138
+ assert.match(rows[0].error_message, /no json array in llm output/);
139
+ assert.ok(warnings.some((m) => m.includes("head: 抱歉,我无法解析成")), "raw output head logged for diagnosis");
140
+ store.close();
141
+ });
142
+
113
143
  test("autoDream audit is skipped when llmAudit.enabled === false", async () => {
114
144
  const { store, service, config } = dreamSetup({ llmAudit: { enabled: false } });
115
145
  service.saveWithDedupe({ type: "project", title: "主题", content: "内容" });
@@ -115,8 +115,14 @@ test("peer-C: 多进程并发原子递增——8 进程×10 次 incrementGenerat
115
115
  }
116
116
  } finally {
117
117
  // Windows:8 个 SQLite 子进程退出后 -shm/-wal 句柄延迟释放,删目录偶发 EPERM;
118
- // maxRetries rm 自动重试(Node 文档:EBUSY/EPERM 等会重试),不影响并发逻辑。
119
- rmSync(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 });
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
+ }
120
126
  }
121
127
  });
122
128
 
@@ -107,6 +107,70 @@ test("issue#9: dream forwards dreamReasoningEffort on both LLM calls", async ()
107
107
  store.close();
108
108
  });
109
109
 
110
+ test("issue#25: dreamProvider/dreamModel config wins over the agentDefaultModel route", async () => {
111
+ const store = createStore(":memory:");
112
+ const service = createService({ store, mirror: null, config: {} });
113
+ const dream = createDreamScheduler({ onRun: () => Promise.resolve({ ok: true, skipped: true }) });
114
+ const { memory: a } = service.saveWithDedupe({ type: "project", title: "插件", content: "旧", importance: 3 });
115
+ const { memory: b } = service.saveWithDedupe({ type: "project", title: "插件2", content: "新细节", importance: 4 });
116
+ const captured = [];
117
+ const ctx = dreamCtx({
118
+ captured,
119
+ onConsolidation: () => JSON.stringify([
120
+ { action: "merge", ids: [a.id, b.id], keepSource: b.id, title: "插件总览", content: "合并内容", importance: 4 }
121
+ ])
122
+ });
123
+ // dreamCtx's agentDefaultModel resolves (mock:mock-model), but the explicit
124
+ // config route must win — otherwise dreamProvider/dreamModel is dead code in
125
+ // a standard DSH install and the dream can never be moved off a thinking model.
126
+ const result = await dream.runDream(ctx, service, { dreamProvider: "volcano", dreamModel: "deepseek-v3" });
127
+ assert.equal(result.ok, true);
128
+ assert.ok(captured.length >= 2, "consolidation + summary both hit the LLM");
129
+ for (const options of captured) {
130
+ assert.equal(options.provider, "volcano");
131
+ assert.equal(options.model, "deepseek-v3", "config route wins over agentDefaultModel (mock:mock-model)");
132
+ }
133
+ store.close();
134
+ });
135
+
136
+ test("issue#9: rejected reasoningEffort retries once without it and still consolidates", async () => {
137
+ const store = createStore(":memory:");
138
+ const service = createService({ store, mirror: null, config: {} });
139
+ const dream = createDreamScheduler({ onRun: () => Promise.resolve({ ok: true, skipped: true }) });
140
+ const { memory: a } = service.saveWithDedupe({ type: "project", title: "插件", content: "旧", importance: 3 });
141
+ const { memory: b } = service.saveWithDedupe({ type: "project", title: "插件2", content: "新细节", importance: 4 });
142
+ const calls = [];
143
+ const ctx = {
144
+ logger: { warn: () => {} },
145
+ agentDefaultModel: { currentSelection: () => ({ provider: "mock", model: "mock-model" }) },
146
+ llm: {
147
+ async *stream(options) {
148
+ calls.push(options);
149
+ const userText = options.messages.find((m) => m.role === "user")?.content?.[0]?.text ?? "";
150
+ if (userText.startsWith("id=")) {
151
+ // First attempt forwards reasoningEffort: the provider rejects it.
152
+ if (options.reasoningEffort) {
153
+ throw new Error("UNSUPPORTED_REASONING_EFFORT: DeepSeek does not support reasoning effort \"low\"");
154
+ }
155
+ yield { type: "text-delta", index: 0, text: JSON.stringify([
156
+ { action: "merge", ids: [a.id, b.id], keepSource: b.id, title: "合并标题", content: "合并内容", importance: 4 }
157
+ ]) };
158
+ } else {
159
+ yield { type: "text-delta", index: 0, text: "记忆库总览:用户偏好中文。" };
160
+ }
161
+ yield { type: "finish", reason: { kind: "stop" } };
162
+ }
163
+ }
164
+ };
165
+ const result = await dream.runDream(ctx, service, { dreamReasoningEffort: "low" });
166
+ assert.equal(result.ok, true, "run survives the effort rejection via the fallback retry");
167
+ assert.ok(result.applied > 0, "consolidation still lands changes");
168
+ assert.equal(calls.length, 3, "consolidation tried (rejected) + retried without effort + summary");
169
+ assert.equal(calls[0].reasoningEffort, "low", "first consolidation attempt forwards the effort");
170
+ assert.equal("reasoningEffort" in calls[1], false, "retry omits the rejected effort field");
171
+ store.close();
172
+ });
173
+
110
174
  // ---------------------------------------------------------------- sleep passthrough
111
175
 
112
176
  function sleepSetup() {
@@ -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
+ });