@modusensus/dsh-mneme 0.7.14 → 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.
package/README.en.md CHANGED
@@ -7,7 +7,7 @@ English | [简体中文](README.md)
7
7
  [![npm version](https://img.shields.io/npm/v/@modusensus/dsh-mneme?color=blue&label=npm)](https://www.npmjs.com/package/@modusensus/dsh-mneme)
8
8
  [![license](https://img.shields.io/badge/license-MIT-green)](LICENSE)
9
9
  [![Awesome](https://awesome-dsh-plugin.com/badge.svg)](https://github.com/awesome-dsh-plugin/awesome-dsh-plugin)
10
- [![tests](https://img.shields.io/badge/tests-450%20passed-success)](https://github.com/modusensus/dsh-mneme)
10
+ [![tests](https://img.shields.io/badge/tests-645%20passed-success)](https://github.com/modusensus/dsh-mneme)
11
11
 
12
12
  > A cross-session memory plugin for DeepSeek Harness: it lets the Agent remember you, remember your projects, and organize memories automatically. **Mneme** (Μνήμη) — named after Mnemosyne, the Greek goddess of memory who presides over memory and dreams, just as autoDream consolidates memories in the background.
13
13
 
@@ -405,7 +405,7 @@ src/
405
405
  lib/
406
406
  ├── client.js # Web 面板(手写 ModuleLoader bundle)
407
407
  └── *.js # src 的同步分发产物
408
- test/ # 450 个 node:test 测试(含审计与三轴线压测不变量)
408
+ test/ # 645 个 node:test 测试(含审计与三轴线压测不变量)
409
409
  scripts/ # e2e-dsh.js 端到端演示 · stress-dsh.js 三轴线压测 · sync-lib.js 同步
410
410
  ```
411
411
 
package/README.md CHANGED
@@ -5,8 +5,8 @@
5
5
  [![npm version](https://img.shields.io/npm/v/@modusensus/dsh-mneme?color=blue&label=npm)](https://www.npmjs.com/package/@modusensus/dsh-mneme)
6
6
  [![license](https://img.shields.io/badge/license-MIT-green)](LICENSE)
7
7
  [![Awesome](https://awesome-dsh-plugin.com/badge.svg)](https://github.com/awesome-dsh-plugin/awesome-dsh-plugin)
8
- [![tests](https://img.shields.io/badge/tests-815%20passed-success)](https://github.com/modusensus/dsh-mneme)
9
- [![CI](https://img.shields.io/github/actions/workflow/status/modusensus/dsh-mneme/test.yml)](https://github.com/modusensus/dsh-mneme/actions)
8
+ [![tests](https://img.shields.io/badge/tests-645%20passed-success)](https://github.com/modusensus/dsh-mneme)
9
+ [![CI](https://img.shields.io/github/actions/workflow/status/modusensus/dsh-mneme/ci.yml)](https://github.com/modusensus/dsh-mneme/actions)
10
10
  [![node](https://img.shields.io/badge/node-24%2B-blue)](https://nodejs.org)
11
11
  [![npm downloads](https://img.shields.io/npm/dm/@modusensus/dsh-mneme?color=blue&label=downloads)](https://www.npmjs.com/package/@modusensus/dsh-mneme)
12
12
  [![coverage](https://img.shields.io/codecov/c/github/modusensus/dsh-mneme/main)](https://codecov.io/gh/modusensus/dsh-mneme)
@@ -241,6 +241,7 @@ v0.3.0 起新增**记忆基因**层:从记忆里抽取**命名实体**、**带
241
241
 
242
242
  | 版本 | 亮点 |
243
243
  |------|------|
244
+ | **v0.7.15** | 桌面端适配:记忆库面板重设计(撤对话 tab → 居中非全屏 sheet、卡片网格/时间线双视图、右侧详情抽屉、编辑/归档/关联实体)+ 侧边栏入口上移工作区上方(借用宿主原生类名对齐、收起态零位移)+ 功能开关 30 键上 UI(features API,巩固模型与 embedding 提供方可配)+ 状态页工作台(巩固卡/工作动态流/沉淀记忆/归档恢复)+ 导入导出(镜像同构 md 黄金闭环)+ Token 面板默认遮蔽;645 测试全绿 |
244
245
  | **v0.7.14** | 安全修复(CWE-200):蒸馏不再把助手 `reasoning` 私有推理块送进蒸馏上下文(只采公开 `text`,防止记忆沉淀私有思考链);617 测试全绿 |
245
246
  | **v0.7.13** | 编码记忆蒸馏(`codingRetrospect` 默认关):完整转录(用户+助手回答+工具调用/结果+代码执行)提炼原子记忆,新增 `rejected_solution`/`pitfall`/`constraint` 三类型,编码任务 `codingBoostFactor` 加权(cap 5)+ 智能调速器(蒸馏全局串行队列 + 429 指数退避自动重试)+ 原子记忆语义保留(宁可拆多条不合并丢细节,`distillMaxChars` 默认 24000)+ 修复 v0.7.12 CI 回归(恢复 c8 覆盖率);616 测试全绿 |
246
247
  | **v0.7.12** | 独立外部 API(插件自带 HTTP 服务 `127.0.0.1:8790` Bearer 鉴权,生态集成)+ 零依赖 CLI `dsh-mneme`(status/list/search/get/add/delete/config)+ 轻量模式 lightMode(简化预设、面板一键切换)+ 设置面板新卡片(运行模式/外部访问 API);612 测试全绿 |
@@ -543,7 +544,7 @@ src/
543
544
  ├── client.js # Web 面板 bundle(ModuleLoader 自注册;v0.7.6 起 src 正源)
544
545
  └── index.js # 插件接线
545
546
  lib/ # src 的同步分发产物(npm run sync;发布前由 root prepack 的 check-sync.js 校验一致性,无手写例外)
546
- test/ # 815 个 node:test 测试(含 lib-smoke.test.js:lib 产物冒烟 + src↔lib 一致性断言;审计与三轴线压测不变量)
547
+ test/ # 645 个 node:test 测试(审计与三轴线压测不变量;src↔lib 一致性由 scripts/check-sync.js 发布闸门校验)
547
548
  scripts/ # e2e-dsh.js 端到端演示 · stress-dsh.js 三轴线压测 · sync-lib.js 同步 · check-sync.js 发布闸门 · benchmark-recall.js 召回基准
548
549
  ```
549
550
 
@@ -552,7 +553,7 @@ scripts/ # e2e-dsh.js 端到端演示 · stress-dsh.js 三轴线压
552
553
  ```bash
553
554
  cd dsh-mneme
554
555
  npm install # 安装 peer 依赖(以 devDependencies 形式,用于本地测试)
555
- npm test # 运行 815 个测试
556
+ npm test # 运行 645 个测试
556
557
  npm run stress # 三轴线压测:长会话检索 / 冲突仲裁 / 多 Agent 并发(离线 mock LLM)
557
558
  npm run sync # 把 src/ 同步到 lib/(发布时由 prepack 钩子自动执行)
558
559
  ```
package/lib/api.js CHANGED
@@ -1,11 +1,35 @@
1
1
  import { URL } from "node:url";
2
+ import { readFileSync } from "node:fs";
2
3
  import { timingSafeEqual, randomBytes } from "node:crypto";
4
+ import { FEATURE_FLAG_SPEC } from "./settings.js";
5
+ import { TYPE_FILE, renderMirrorText, parseHumanEdits } from "./mirror.js";
3
6
 
4
- function sendJson(res, status, payload) {
5
- res.writeHead(status, { "Content-Type": "application/json; charset=utf-8" });
7
+ // headers:少数端点(/export 附件下载)需要追加 Content-Disposition 等响应头。
8
+ function sendJson(res, status, payload, headers = {}) {
9
+ res.writeHead(status, { "Content-Type": "application/json; charset=utf-8", ...headers });
6
10
  res.end(JSON.stringify(payload));
7
11
  }
8
12
 
13
+ // 附件下载响应(导出端点):Content-Type 与文件名(含日期后缀)由调用方给定。
14
+ function sendAttachment(res, status, contentType, filename, body) {
15
+ res.writeHead(status, {
16
+ "Content-Type": contentType,
17
+ "Content-Disposition": `attachment; filename="${filename}"`
18
+ });
19
+ res.end(body);
20
+ }
21
+
22
+ // 导出 JSON 的 version 字段:读插件根的 package.json(src/ 与 lib/ 都在根下
23
+ // 一层,相对 import.meta.url 解析一致)。读取失败(打包/受限环境)降级为
24
+ // "unknown",导出本身仍然可用。
25
+ const PACKAGE_VERSION = (() => {
26
+ try {
27
+ return JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version ?? "unknown";
28
+ } catch {
29
+ return "unknown";
30
+ }
31
+ })();
32
+
9
33
  // Defaults for the standalone external API — keep in step with the schema
10
34
  // defaults in config.js (externalApiPort / externalApiHost).
11
35
  const EXTERNAL_API_DEFAULTS = { enabled: false, port: 8790, host: "127.0.0.1" };
@@ -70,9 +94,43 @@ function parseBody(text) {
70
94
  }
71
95
  }
72
96
 
73
- export function createApi(ctx, service, settings, commands, embedder, semantic = null, apiToken = "") {
97
+ export function createApi(ctx, service, settings, commands, embedder, semantic = null, apiToken = "", config = null) {
74
98
  const disposers = [];
75
99
 
100
+ // feature flags 快照(GET/PUT 共用):overrides 是持久化的用户显式覆盖;
101
+ // effective 是启动配置在白名单键上被 overrides 覆盖后的最终值。config 缺席
102
+ // (旧调用方直连、未传 cfg)时只报被覆盖的键,不把不存在的默认值编造给前端。
103
+ const flagKeys = [
104
+ ...FEATURE_FLAG_SPEC.booleans,
105
+ ...Object.keys(FEATURE_FLAG_SPEC.ints),
106
+ ...FEATURE_FLAG_SPEC.strings,
107
+ ...FEATURE_FLAG_SPEC.urls,
108
+ ...Object.keys(FEATURE_FLAG_SPEC.enums)
109
+ ];
110
+ // 嵌套键在 kv 里按点号平铺("memoryQualityFilter.enabled"),运行时 cfg 里
111
+ // 是嵌套对象,effective 从对象子字段取值;其余键照旧从 cfg 顶层取。
112
+ const NESTED_FLAG_PATHS = {
113
+ "memoryQualityFilter.enabled": ["memoryQualityFilter", "enabled"],
114
+ "llmAudit.enabled": ["llmAudit", "enabled"]
115
+ };
116
+ function configFlagValue(key) {
117
+ const path = NESTED_FLAG_PATHS[key];
118
+ if (path) return config?.[path[0]]?.[path[1]];
119
+ return config?.[key];
120
+ }
121
+ function featureSnapshot() {
122
+ const overrides = settings.getFeatureFlags();
123
+ const effective = {};
124
+ for (const key of flagKeys) {
125
+ if (overrides[key] !== undefined) effective[key] = overrides[key];
126
+ else {
127
+ const value = configFlagValue(key);
128
+ if (value !== undefined) effective[key] = value;
129
+ }
130
+ }
131
+ return { overrides, effective };
132
+ }
133
+
76
134
  // Ensure the service has an embedder when the API layer was handed one
77
135
  // (tests wire the embedder through the API instead of index.js). Without
78
136
  // this, /api/dsh-mneme/search would silently degrade to keyword-only.
@@ -112,10 +170,26 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
112
170
  ? Number(minRaw)
113
171
  : undefined;
114
172
  const source = url.searchParams.get("source") || undefined;
115
- const items = service.toApiList(service.list({ type, limit, offset, order, minImportance, source }));
173
+ // updatedFrom/updatedTo:updated_at 闭区间过滤(ISO 日期或完整时间戳)。
174
+ // 边界归一化在 store 的 updatedAtBounds(list/count 共用同一纯函数)
175
+ // 完成,非法值在那里被忽略——这里原样透传即可。
176
+ const updatedFrom = url.searchParams.get("updatedFrom") ?? undefined;
177
+ const updatedTo = url.searchParams.get("updatedTo") ?? undefined;
178
+ // archived=only:只看归档(状态页的归档列表用)。归档行不进默认列表,
179
+ // 所以这是独立的视图开关,而不是 includeArchived 的混看模式。
180
+ const onlyArchived = url.searchParams.get("archived") === "only";
181
+ const rows = service.list({ type, limit, offset, order, minImportance, source, updatedFrom, updatedTo, onlyArchived });
182
+ // 面板行在 wire DTO 之上补 archived/quality_score——模型工具的输出
183
+ // schema 严格复用 toApiList,扩展只发生在 HTTP 层。
184
+ const items = service.toApiList(rows).map((m, i) => ({
185
+ ...m,
186
+ archived: rows[i].archived === true || rows[i].archived === 1,
187
+ quality_score: rows[i].quality_score ?? null
188
+ }));
116
189
  // Total honors the same filters as the rows, or the pager's
117
- // has-more math breaks whenever minImportance/source is active.
118
- sendJson(res, 200, { items, total: service.count(type, { minImportance, source }) });
190
+ // has-more math breaks whenever minImportance/source/updated-at
191
+ // bounds are active.
192
+ sendJson(res, 200, { items, total: service.count(type, { minImportance, source, updatedFrom, updatedTo, onlyArchived }) });
119
193
  } catch {
120
194
  sendJson(res, 500, { error: "internal" });
121
195
  }
@@ -220,6 +294,90 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
220
294
  }
221
295
  });
222
296
 
297
+ // --- 编辑/归档一条记忆(面板写路径,与 /delete 对称命名)-------------------
298
+ // POST body {id, title?, content?, importance?, tags?, archived?}。字段校验
299
+ // 只做类型/范围检查:字段出现(!== undefined)就必须合法,宁 400 不静默纠正
300
+ // ——静默丢字段会让面板误以为保存成功。写入走 service 的正规更新路径:
301
+ // updated_at 由 store 的单调时钟推进;content 被改写时旧版本按 human_override
302
+ // 入档(与镜像人工编辑回灌 mergeHumanEdits 的语义对齐,FIFO 上限 20);
303
+ // archived 走 setArchived。两条路径都会触发镜像重渲染(afterSync)。
304
+ register({
305
+ kind: "exact",
306
+ path: "/api/dsh-mneme/update",
307
+ handler(req, res) {
308
+ try {
309
+ if (req.method !== "POST") {
310
+ sendJson(res, 404, { error: "not-found" });
311
+ return;
312
+ }
313
+ if (!requireAuth(req, res, apiToken)) return;
314
+ return readBody(req).then((text) => {
315
+ const body = parseBody(text);
316
+ const id = typeof body.id === "string" ? body.id.trim() : "";
317
+ if (!id) {
318
+ sendJson(res, 400, { error: "missing-id" });
319
+ return;
320
+ }
321
+ const patch = {};
322
+ if (body.title !== undefined) {
323
+ if (typeof body.title !== "string" || !body.title.trim()) {
324
+ sendJson(res, 400, { error: "invalid-title" });
325
+ return;
326
+ }
327
+ patch.title = body.title.trim();
328
+ }
329
+ if (body.content !== undefined) {
330
+ if (typeof body.content !== "string" || !body.content.trim()) {
331
+ sendJson(res, 400, { error: "invalid-content" });
332
+ return;
333
+ }
334
+ patch.content = body.content.trim();
335
+ }
336
+ if (body.importance !== undefined) {
337
+ if (!Number.isInteger(body.importance) || body.importance < 1 || body.importance > 5) {
338
+ sendJson(res, 400, { error: "invalid-importance" });
339
+ return;
340
+ }
341
+ patch.importance = body.importance;
342
+ }
343
+ if (body.tags !== undefined) {
344
+ if (!Array.isArray(body.tags) || !body.tags.every((t) => typeof t === "string")) {
345
+ sendJson(res, 400, { error: "invalid-tags" });
346
+ return;
347
+ }
348
+ patch.tags = body.tags;
349
+ }
350
+ if (body.archived !== undefined && typeof body.archived !== "boolean") {
351
+ sendJson(res, 400, { error: "invalid-archived" });
352
+ return;
353
+ }
354
+ if (Object.keys(patch).length === 0 && body.archived === undefined) {
355
+ sendJson(res, 400, { error: "no-fields" });
356
+ return;
357
+ }
358
+ const existing = service.getById(id);
359
+ if (!existing) {
360
+ sendJson(res, 404, { error: "not-found" });
361
+ return;
362
+ }
363
+ // 内容被改写时旧版本先入档(human_override),人工修正不静默销毁旧值。
364
+ if (patch.content !== undefined && patch.content !== existing.content) {
365
+ const history = Array.isArray(existing.content_history) ? existing.content_history : [];
366
+ patch.content_history = [
367
+ { content: existing.content ?? "", source: "human_override", updated_at: new Date().toISOString() },
368
+ ...history
369
+ ].slice(0, 20);
370
+ }
371
+ if (Object.keys(patch).length) service.update(id, patch);
372
+ if (body.archived !== undefined) service.setArchived(id, body.archived);
373
+ sendJson(res, 200, { memory: service.getById(id) });
374
+ });
375
+ } catch {
376
+ sendJson(res, 500, { error: "internal" });
377
+ }
378
+ }
379
+ });
380
+
223
381
  // --- rules ---
224
382
  register({
225
383
  kind: "exact",
@@ -501,6 +659,144 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
501
659
  }
502
660
  });
503
661
 
662
+ // --- 一条记忆的关联实体(记忆详情侧栏)-------------------------------------
663
+ // 只读:entity_attrs.memory_id 反查实体(store 一条 JOIN 完成,按提及去重)。
664
+ // 与目录 /entities 不同,这里以记忆为锚点,回答"这条记忆提到了谁"。记忆不
665
+ // 存在或无关联一律返回空数组——详情侧栏不需要区分这两种情况。
666
+ register({
667
+ kind: "exact",
668
+ path: "/api/dsh-mneme/memories/entities",
669
+ handler(req, res) {
670
+ try {
671
+ const url = new URL(req.url, "http://localhost");
672
+ const memoryId = (url.searchParams.get("memoryId") ?? "").trim();
673
+ if (!memoryId) {
674
+ sendJson(res, 400, { error: "missing-memory-id" });
675
+ return;
676
+ }
677
+ sendJson(res, 200, { entities: service.entitiesForMemory?.(memoryId) ?? [] });
678
+ } catch {
679
+ sendJson(res, 500, { error: "internal" });
680
+ }
681
+ }
682
+ });
683
+
684
+ // --- 导出(面板备份/迁移)---------------------------------------------------
685
+ // 只读。json:全字段行(含 archived/forgotten,布尔化),updated_at DESC;
686
+ // markdown:按类型分节,块格式与磁盘镜像完全同构(renderMirrorText 与
687
+ // mirror.sync 共用同一条渲染路径),因此导出文本可以被 /import 原样吃回。
688
+ // 全量一次性取回(store.all 按 updated_at DESC)——导出是一次性备份动作,
689
+ // 不需要流式。
690
+ register({
691
+ kind: "exact",
692
+ path: "/api/dsh-mneme/export",
693
+ handler(req, res) {
694
+ try {
695
+ const url = new URL(req.url, "http://localhost");
696
+ const format = url.searchParams.get("format") ?? "json";
697
+ if (format !== "json" && format !== "markdown") {
698
+ sendJson(res, 400, { error: "invalid-format" });
699
+ return;
700
+ }
701
+ const rows = service.all?.() ?? [];
702
+ const stamp = new Date().toISOString().slice(0, 10).replace(/-/g, "");
703
+ if (format === "json") {
704
+ sendJson(res, 200, {
705
+ exported_at: new Date().toISOString(),
706
+ version: PACKAGE_VERSION,
707
+ count: rows.length,
708
+ memories: rows
709
+ }, { "Content-Disposition": `attachment; filename="dsh-mneme-export-${stamp}.json"` });
710
+ return;
711
+ }
712
+ const byType = {};
713
+ for (const row of rows) {
714
+ if (TYPE_FILE[row.type]) (byType[row.type] ??= []).push(row);
715
+ }
716
+ const sections = [];
717
+ for (const type of Object.keys(TYPE_FILE)) {
718
+ if (byType[type]?.length) sections.push(renderMirrorText(type, byType[type]));
719
+ }
720
+ sendAttachment(res, 200, "text/markdown; charset=utf-8", `dsh-mneme-export-${stamp}.md`, sections.join("\n"));
721
+ } catch {
722
+ sendJson(res, 500, { error: "internal" });
723
+ }
724
+ }
725
+ });
726
+
727
+ // --- 导入(Markdown 镜像回填)----------------------------------------------
728
+ // 写路径(requireAuth)。body {type, markdown}:type 是镜像 TYPE_FILE 键
729
+ // (preference/project/decision/history/summary),markdown 是与镜像文件同构
730
+ // 的文本。解析复用 readHumanEdits 的纯函数核心 parseHumanEdits(同一实现,
731
+ // 行为一致是硬约束),合并走 mergeHumanEdits(只吃 title/content;digest 命
732
+ // 中或无差异的条目在 service 侧自动跳过)。解析出 0 条不算错误。
733
+ register({
734
+ kind: "exact",
735
+ path: "/api/dsh-mneme/import",
736
+ handler(req, res) {
737
+ try {
738
+ if (req.method !== "POST") {
739
+ sendJson(res, 404, { error: "not-found" });
740
+ return;
741
+ }
742
+ if (!requireAuth(req, res, apiToken)) return;
743
+ return readBody(req).then((text) => {
744
+ const body = parseBody(text);
745
+ if (typeof body.type !== "string" || !Object.hasOwn(TYPE_FILE, body.type)) {
746
+ sendJson(res, 400, { error: "invalid-type" });
747
+ return;
748
+ }
749
+ if (typeof body.markdown !== "string" || !body.markdown.trim()) {
750
+ sendJson(res, 400, { error: "invalid-markdown" });
751
+ return;
752
+ }
753
+ const edits = parseHumanEdits(body.markdown);
754
+ service.mergeHumanEdits(body.type, edits);
755
+ sendJson(res, 200, { merged: edits.length, type: body.type });
756
+ });
757
+ } catch {
758
+ sendJson(res, 500, { error: "internal" });
759
+ }
760
+ }
761
+ });
762
+
763
+ // --- 巩固状态(dream 面板)--------------------------------------------------
764
+ // 只读:最近巩固运行(最多 5 条,created_at DESC)+ 未解决冲突队列。
765
+ // pendingMemoryIds 从未解决冲突行提取涉及的记忆 id,去重后上限 200,避免积
766
+ // 压很大时响应失控。listDreamRuns/listConflictPending 走 service 现成的审计
767
+ // 只读通道(bookkeeping 语义,不触发写钩子)。
768
+ register({
769
+ kind: "exact",
770
+ path: "/api/dsh-mneme/dream-status",
771
+ handler(req, res) {
772
+ try {
773
+ const runs = (service.listDreamRuns?.({ limit: 5 }) ?? []).map((r) => ({
774
+ created_at: r.created_at,
775
+ status: r.status,
776
+ provider: r.provider ?? null,
777
+ model: r.model ?? null,
778
+ error: r.error ?? null
779
+ }));
780
+ const pendingConflicts = service.countConflictPending?.() ?? 0;
781
+ const ids = new Set();
782
+ for (const row of service.listConflictPending?.({ limit: 200, includeResolved: false }) ?? []) {
783
+ if (ids.size >= 200) break;
784
+ if (row.memory_a) ids.add(row.memory_a);
785
+ if (ids.size >= 200) break;
786
+ if (row.memory_b) ids.add(row.memory_b);
787
+ }
788
+ sendJson(res, 200, {
789
+ lastRun: runs[0] ?? null,
790
+ runs,
791
+ pendingConflicts,
792
+ pendingMemoryIds: [...ids].slice(0, 200)
793
+ });
794
+ } catch {
795
+ sendJson(res, 500, { error: "internal" });
796
+ }
797
+ }
798
+ });
799
+
504
800
  // --- health: mirror sync state (F-NEW-03 / v0.3.6) ---
505
801
  // Auth-gated; only returns a sanitized error code (never raw last_error which
506
802
  // may leak paths/token-like strings/internal hosts). On state read failure it
@@ -624,6 +920,41 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
624
920
  }
625
921
  });
626
922
 
923
+ // --- feature flags(功能开关:面板逐项开关后端能力)-------------------------
924
+ // 读路由保持开放(与 mode/list 一致),前端无需 token 即可渲染开关状态;PUT
925
+ // 与其他设置写一致走 requireAuth。空/非对象 patch 直接 400:它不携带任何
926
+ // 意图,静默成功只会掩盖前端 bug。校验失败(未知键/类型/越界)的 400 把键
927
+ // 名放进 error,前端能直接定位写坏的开关。生效节奏与 panel_mode 相同:
928
+ // 持久化后下次启动合并进 cfg,本次启动的运行时行为不变。
929
+ register({
930
+ kind: "exact",
931
+ path: "/api/dsh-mneme/features",
932
+ handler(req, res) {
933
+ try {
934
+ if (req.method === "PUT" || req.method === "POST") {
935
+ if (!requireAuth(req, res, apiToken)) return;
936
+ return readBody(req).then((text) => {
937
+ const body = parseBody(text);
938
+ if (typeof body !== "object" || body === null || Array.isArray(body) || Object.keys(body).length === 0) {
939
+ sendJson(res, 400, { error: "invalid-patch" });
940
+ return;
941
+ }
942
+ try {
943
+ settings.setFeatureFlags(body);
944
+ } catch (error) {
945
+ sendJson(res, 400, { error: error.message });
946
+ return;
947
+ }
948
+ sendJson(res, 200, featureSnapshot());
949
+ });
950
+ }
951
+ sendJson(res, 200, featureSnapshot());
952
+ } catch {
953
+ sendJson(res, 500, { error: "internal" });
954
+ }
955
+ }
956
+ });
957
+
627
958
  // --- custom commands ---
628
959
  register({
629
960
  kind: "exact",
@@ -662,7 +993,7 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
662
993
  });
663
994
 
664
995
  return {
665
- routes: 17,
996
+ routes: 23,
666
997
  dispose: () => {
667
998
  for (const dispose of disposers) dispose();
668
999
  }