From d60c27cce27a26b2851b540dace02917ea99205d Mon Sep 17 00:00:00 2001
From: modusensus
Date: Mon, 12 Oct 2026 04:38:16 +0800
Subject: [PATCH 1/2] =?UTF-8?q?feat(386):=20=E9=9D=A2=E6=9D=BF=E5=8F=AF?=
=?UTF-8?q?=E6=89=8B=E5=8A=A8=E6=96=B0=E5=A2=9E=E8=AE=B0=E5=BF=86=EF=BC=8C?=
=?UTF-8?q?=E5=AD=97=E6=AE=B5=E4=B8=8E=E5=8E=BB=E9=87=8D=E5=AF=B9=E9=BD=90?=
=?UTF-8?q?=20memory=5Fsave=EF=BC=88#386=20=E7=AC=AC=205=20=E6=9D=A1?=
=?UTF-8?q?=EF=BC=89?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
面板此前只能读、改、删、导入 Markdown 镜像——想手记一条只能切回对话让模型调
memory_save,而模型看不见的东西(一次现场结论、一条口头约定)恰恰最需要手动
落库。CLI add 与外部 API POST /memories 都能建行,面板是唯一没有写入口的那条。
新增后端路由 POST /api/dsh-mneme/create,与 /update、/delete 同名法;写入走
service.saveWithDedupe,与工具/CLI/外部 API 同一条路径——去重键仍是
(type, title, scope, sensitivity),所以面板建与工具建同标题会并入同一行而不是
长出两份。响应带 action: "created" | "merged",面板据此分别提示「已新建」与
「已并入同标题的既有条目」:两者对用户是不同的事实,都报「已保存」会让人以为
自己新写了一条、实际旧行被改。
三处刻意的口径差异(代码注释里都写了理由,免得下一个人以为抄漏):
- content 要求 trim 非空,与 /update 一致而非 standalone 的宽松版:面板是人工
输入,空正文只可能是误操作;standalone 面对程序化调用,放宽是为兼容「先建壳
再补内容」的既有客户端。
- document 直接 400:行铸造口唯一(registerDocument,#230),saveWithDedupe
会 throw,这里提前给干净错误码而不是让它落成 500。
- 不接受 scope/sensitivity:面板的 scope 编辑在详情抽屉里,那条路带「放宽可见
性」二次确认;新建时就盖章会绕开那道确认。
写入准入(#254)今天对面板路径不生效——它无会话身份即放行,而面板路由恰好不带
_sessionKey——但路由仍显式处理 action === "denied" → 422 且理由随行:将来若把
会话身份接进面板,落回 toApiList([null]) 会把「被拒」伪装成「服务器出错」,两者
的处置完全不同。
面板侧:「更多操作」菜单加「新增记忆…」,弹层收类型/标题/正文/重要性/标签。类型
下拉刻意不含 document,与 memory_save 的 enum 逐字对齐(7 种);新建后既刷新列表
也选中新行——merged 时返回的 id 就是被并入那一行,两种情况都该滚进视野。
回归锁:test/api-routes-gaps.test.js 六条(鉴权 401/GET 404/六种 400 校验且库内
零写入/201 created/200 merged 且 count()===1/denied→422/落库异常 500)、
test/client.test.js 一条锁「入口真的挂在树上」(菜单项真的调 setCreateOpen(true)、
弹层真的被条件挂载、onCreated 真的刷新并选中、表单不得长出 scope/sensitivity、
类型下拉不得含 document)。变异检验两侧各七条逐条转红。
npm test:1627 tests / 1626 pass / 0 fail / 1 skipped;src↔lib 一致(57 个文件);
双 README 测试数已同步到 1627。
Refs #386(第 5 条;其余 7 条未动,不关整条 issue)
---
README.md | 6 +-
dsh-mneme/CHANGELOG.md | 4 +
dsh-mneme/README.md | 6 +-
dsh-mneme/lib/api.js | 91 ++++++++++++++-
dsh-mneme/lib/client.js | 147 ++++++++++++++++++++++-
dsh-mneme/src/api.js | 91 ++++++++++++++-
dsh-mneme/test/api-routes-gaps.test.js | 155 +++++++++++++++++++++++++
dsh-mneme/test/client.test.js | 67 +++++++++++
8 files changed, 558 insertions(+), 9 deletions(-)
diff --git a/README.md b/README.md
index 0e5c9356..72a770b7 100644
--- a/README.md
+++ b/README.md
@@ -10,7 +10,7 @@
-
+
@@ -207,7 +207,7 @@ dsh-mneme-serve # 默认 ~/.dsh/memory + 127.0.0.1:8790
```bash
cd dsh-mneme && npm install
-npm test # 1620 个测试
+npm test # 1627 个测试
npm run stress # 三轴线压测
npm run sync # src → lib 同步
```
@@ -414,7 +414,7 @@ Community projects that wire mneme into more platforms — not part of this repo
```bash
cd dsh-mneme && npm install
-npm test # 1620 tests
+npm test # 1627 tests
npm run stress # three-axis stress test
npm run sync # src → lib sync
```
diff --git a/dsh-mneme/CHANGELOG.md b/dsh-mneme/CHANGELOG.md
index 1ad9cad3..93b0ac39 100644
--- a/dsh-mneme/CHANGELOG.md
+++ b/dsh-mneme/CHANGELOG.md
@@ -2,6 +2,10 @@
## [Unreleased]
+## 🆕 新增
+
+- **面板手动新增记忆(issue #386 第 5 条)**:面板此前只能读、改、删、导入,**没有任何手写入口**——想记一条只能切到对话里让模型调 `memory_save`,而模型看不见的东西(一次现场结论、一条口头约定)恰恰最需要手动落库。「更多操作」菜单新增「新增记忆…」,弹层收类型 / 标题 / 正文 / 重要性 / 标签,字段与 `memory_save` 工具对齐。类型下拉**刻意不含 `document`**:那是 agent 注册长文档的指针行,铸造口唯一(`registerDocument`,#230),列出来只会让人点了才被拒。**表单不放 scope / sensitivity**:显式归属走详情抽屉那条路,它带「放宽可见性」二次确认;在新建表单里顺手盖章会绕开那道确认。新增后端路由 `POST /api/dsh-mneme/create`,落库走 `service.saveWithDedupe`——去重键仍是 `(type, title, scope, sensitivity)`,所以**同类型同标题会并入既有条目而不是长出第二行**,服务端回 `action: "created" | "merged"` 决定提示语说「已新建」还是「已并入」:两者对用户是不同的事实,都报「已保存」会让人以为自己新写了一条、实际旧行被改。三处校验比 standalone 的 `POST /memories` 收紧(口径向 `/update` 看齐):`content` 要 trim 非空(面板是人工输入,空正文只可能是误操作)、`document` 提前 400、scope/sensitivity 不接受。写入准入(`write-admission.js`)对面板路径不生效——它无会话身份即放行——但路由仍显式处理 `action === "denied"` → 422,免得日后面板带上会话身份时静默吞掉拒绝。回归锁六条进 `test/api-routes-gaps.test.js`(①鉴权 401 且 GET 落 404;②六种 400 校验且库内零写入;③201 created 且 `source` 为 `panel`;④200 merged 且 `count() === 1`;⑤`denied` → 422 且理由随行;⑥落库异常 500),面板侧另有一条 `test/client.test.js` 锁「入口真的挂在树上」(菜单项确实调 `setCreateOpen(true)`、弹层确实被条件挂载、`onCreated` 确实刷新列表并选中新行、表单不得长出 scope/sensitivity 输入框、类型下拉不得含 `document`)。两侧都做了变异检验:面板七条、后端七条,逐条把锁打回坏状态都转红(含「菜单项变哑」「弹层摘掉」「表单长回 scope」「打错路由」这类只有肉眼才能发现的退化)。
+
## 🐛 修复
- **面板文字对比度全面达标 WCAG AA(issue #178)**:面板有 270 条 `color` 声明,此前 **70 条**在浅色或深色主题下达不到 AA(正文 4.5:1、大字号 3:1;浅色 65 条、深色 5 条,最差的两条 `.mneme-statfoot` 只有 1.26 / 1.64)。根因是面板直接拿宿主的**标签层** token 当正文色(`label-tertiary` 50 处、`label-dimmed` 4 处):这些是给分隔线/占位符用的低对比层,不是正文层。另有三处**图形**同病(不在文字口径内,按 3:1 复核):热度条与热度点的冷档用 `label-dimmed` 压在热度轨道上只有 1.14:1、关系图连线描边 1.26:1,等于把三档热度画成同一个看不见的灰。修法不是逐条换颜色,而是在四个渲染根上定义**面板自己的语义色层**:`.mneme-x,.mneme-overlay,.mneme-triggerwrap,.mneme-topentry{…}` 六条 `--mneme-*`(`fg-dim` / `graphic-dim` / `accent` / `danger` / `warn` / `ok`),每条都用 `color-mix()` 从宿主 token 混出——`fg-dim` = `label-primary` 60% + 透明(浅 4.94 / 深 6.30),其余同法。**四个根一个都不能少**:`.mneme-overlay` 与 `.mneme-topentry` 是 `createPortal` 出来的独立子树,`.mneme-triggerwrap` 挂在宿主 sidebar footer 插槽,只挂 `.mneme-x` 的话这三个根拿不到变量、静默退回旧色。顺带修掉 **5 个幻影 token**(`--dsw-alias-state-error` 19 处、`state-warning` 14 处、`state-success` 3 处、`border` 2 处、`state-warn` 1 处):这些名字宿主从未定义过,一直靠 CSS 兜底字面量在跑,宿主改配色时面板不会跟——现全部换成真名(`state-error-primary` / `static-amber-600` / `border-l1` 等)。另需分清:`label-dimmed`(4 处)与 `label-tertiary`(50 处)**都是真实存在的宿主 token**,问题不在名字而在用法——宿主把它们定位成分隔线/占位符的低对比层,面板却拿来当正文与图形色。三处 knockout 配色一并修:`.mneme-conflict-primary` 浅色主题下白字压品牌蓝 `#4176e6` 只有 4.23:1,底色换成 `--mneme-accent`(浅 5.32 / 深 7.67);`.mneme-entrybadge` 深色主题下 `bg-layer-1` 压 `#c33` 只有 3.06:1,底色换成 `--mneme-danger`;`.mneme-btndangerconfirm` 底色同步换成 `--mneme-danger`,并把写死的 `#fff` 换成 `var(--dsw-alias-bg-layer-1)`——它旧的写死白字只在旧底色上侥幸过关,底色一变浅就会在深色主题掉到 2.67:1。回归锁 `test/contrast.test.js` 五条:①把宿主 token 的浅/深取值快照硬编码进测试(CI 上没有宿主包,读不到 asar),逐条重算全部 `color` 声明的对比度,两主题都必须过,**解析不出的颜色记失败而不是跳过**(否则改成 `var(--mneme-unknown)` 就能静默溜过);②面板引用的每个 `--dsw-*` 必须在快照表里存在(幻影名回归锁);③语义色层的选择器必须恰好是那四个根(少了根主锁照样全绿——主锁只算颜色,不知道变量有没有继承到);④图形色(`background` / `stroke`,非 `color`)单独按 3:1 复核——热度条、热度点、打卡点、关系图连线都不在主锁的视野里;⑤虚线关系边不得靠 `opacity` 区分(无 `memory_id` 的边同时命中 `.mneme-gedge` 与 `.mneme-gedge-dashed`,后者的 `opacity:.7` 把 50% 描边压到浅色 2.28,而图形锁只读单条规则、看不见这种叠加)。变异检验七条:改淡 `fg-dim`、一处正文用回宿主标签色、塞回幻影名、渲染根少挂一个、热度冷档改回宿主 `label-dimmed`、一条 `color` 改成解析不出的 `var(--mneme-unknown)`、虚线边加回 `opacity:.7`,七条锁分别转红。
diff --git a/dsh-mneme/README.md b/dsh-mneme/README.md
index 07425533..0eaa0851 100644
--- a/dsh-mneme/README.md
+++ b/dsh-mneme/README.md
@@ -5,7 +5,7 @@
[](https://www.npmjs.com/package/@modusensus/dsh-mneme)
[](LICENSE)
[](https://github.com/awesome-dsh-plugin/awesome-dsh-plugin)
-[](https://github.com/slow-stack/mneme)
+[](https://github.com/slow-stack/mneme)
[](https://github.com/slow-stack/mneme/actions)
[](https://nodejs.org)
[](https://www.npmjs.com/package/@modusensus/dsh-mneme)
@@ -613,7 +613,7 @@ src/
├── api.js # HTTP 路由(Web 面板数据通道,含 /conflicts 冲突队列)
└── index.js # 插件接线
lib/ # src 的同步分发产物(npm run sync;发布前由 root prepack 的 check-sync.js 校验一致性;唯一手写例外 lib/client.js——Web 面板 bundle,sync 不覆盖)
-test/ # 1620 个 node:test 测试(审计与三轴线压测不变量;src↔lib 一致性由 scripts/check-sync.js 发布闸门校验)
+test/ # 1627 个 node:test 测试(审计与三轴线压测不变量;src↔lib 一致性由 scripts/check-sync.js 发布闸门校验)
scripts/ # e2e-dsh.js 端到端演示 · stress-dsh.js 三轴线压测 · sync-lib.js 同步 · check-sync.js 发布闸门 · benchmark-recall.js / benchmark-embed.js / benchmark-rerank.js 基准 · sync-test-badge.mjs 测试徽章 · build-runtime-manifest.mjs 运行时清单
```
@@ -622,7 +622,7 @@ scripts/ # e2e-dsh.js 端到端演示 · stress-dsh.js 三轴线压
```bash
cd dsh-mneme
npm install # 安装 peer 依赖(以 devDependencies 形式,用于本地测试)
-npm test # 运行 1620 个测试
+npm test # 运行 1627 个测试
npm run stress # 三轴线压测:长会话检索 / 冲突仲裁 / 多 Agent 并发(离线 mock LLM)
npm run sync # 把 src/ 同步到 lib/(发布时由 prepack 钩子自动执行)
```
diff --git a/dsh-mneme/lib/api.js b/dsh-mneme/lib/api.js
index 96dfd6d2..b149ae52 100644
--- a/dsh-mneme/lib/api.js
+++ b/dsh-mneme/lib/api.js
@@ -1,6 +1,7 @@
import { URL } from "node:url";
import { timingSafeEqual, randomBytes } from "node:crypto";
import { FEATURE_FLAG_SPEC } from "./settings.js";
+import { TYPES } from "./store.js";
import { TYPE_FILE, renderMirrorText, renderFileHeader, parseHumanEdits, MIRROR_READONLY_TYPES } from "./mirror.js";
import { langOf } from "./lang.js";
import { computeHeat } from "./heat.js";
@@ -602,6 +603,94 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
}
});
+ // --- 新建一条记忆(面板写路径,与 /update、/delete 对称命名)---------------
+ // #386 第 5 条:CLI `add` 与 standalone `POST /memories` 都能建行,面板此前只
+ // 能看/改/删/导入。字段与 memory_save 工具对齐(type/title/content/importance/
+ // tags),走同一条 saveWithDedupe 路径——去重键仍是 (type, title, scope,
+ // sensitivity),所以「面板建」与「工具建」同标题会并入同一行,不会长出两份。
+ //
+ // 三处刻意的口径差异(都写在代码里,免得下一个人以为是抄漏):
+ // 1. content 要求 trim 后非空,与 /update 一致而非 standalone 的宽松版:
+ // 面板是人工输入,空正文只可能是误操作;standalone 面对的是程序化调用,
+ // 放宽是为了兼容「先建壳再补内容」的既有客户端。
+ // 2. document 直接 400:行铸造口唯一(registerDocument,#230),saveWithDedupe
+ // 会 throw,这里提前给干净错误码而不是让它落成 500。
+ // 3. 不接受 scope/sensitivity 字段——面板的 scope 编辑在详情抽屉里(带放宽
+ // 确认的安全阀,见 lib/client.js 的 MemoryDrawer),新建时就盖章 explicit
+ // 会绕过那道确认。传了也不报错,只是不生效(与 /update 的「字段出现即校验」
+ // 不同,因为这里根本没有该字段的语义)。
+ register({
+ kind: "exact",
+ path: "/api/dsh-mneme/create",
+ handler(req, res) {
+ try {
+ if (req.method !== "POST") {
+ sendJson(res, 404, { error: "not-found" });
+ return;
+ }
+ if (!requireAuth(req, res, apiToken)) return;
+ return readBody(req).then((text) => {
+ const body = parseBody(text);
+ if (!TYPES.has(body.type)) {
+ sendJson(res, 400, { error: "invalid-type" });
+ return;
+ }
+ if (body.type === "document") {
+ sendJson(res, 400, { error: "document-requires-register" });
+ return;
+ }
+ if (typeof body.title !== "string" || !body.title.trim()) {
+ sendJson(res, 400, { error: "invalid-title" });
+ return;
+ }
+ if (typeof body.content !== "string" || !body.content.trim()) {
+ sendJson(res, 400, { error: "invalid-content" });
+ return;
+ }
+ if (body.importance !== undefined
+ && (!Number.isInteger(body.importance) || body.importance < 1 || body.importance > 5)) {
+ sendJson(res, 400, { error: "invalid-importance" });
+ return;
+ }
+ if (body.tags !== undefined && (!Array.isArray(body.tags) || !body.tags.every((tag) => typeof tag === "string"))) {
+ sendJson(res, 400, { error: "invalid-tags" });
+ return;
+ }
+ try {
+ const result = service.saveWithDedupe({
+ type: body.type,
+ title: body.title.trim(),
+ content: body.content.trim(),
+ importance: body.importance,
+ tags: body.tags ?? [],
+ // 面板不是工具也不是外部程序,来源要能在详情抽屉里一眼认出是谁写的。
+ source: "panel"
+ });
+ // #254 写入准入在无会话身份时放行(write-admission.js 首行早退),面板
+ // 路由恰好不带 _sessionKey,所以今天到不了这个分支。仍然显式写出来:
+ // 将来若把会话身份接进面板,落到 toApiList([null]) 上会变成 500,把
+ // 「被拒」伪装成「服务器出错」——两者的处置完全不同。
+ if (result.action === "denied") {
+ sendJson(res, 422, { error: "write-rejected", reason: result.reason });
+ return;
+ }
+ // action 随行透出(created/merged):面板据此提示「已并入同标题的既有条目」
+ // 还是「新建成功」——两者对用户是不同的事实,不能都报「已保存」。
+ sendJson(res, result.action === "created" ? 201 : 200, {
+ ...service.toApiList([result.memory])[0],
+ action: result.action
+ });
+ } catch (error) {
+ ctx.logger?.warn?.(`[dsh-mneme] panel create failed: ${String(error)}`);
+ sendJson(res, 500, { error: "internal" });
+ }
+ });
+ } catch {
+ sendJson(res, 500, { error: "internal" });
+ }
+ }
+ });
+
// --- rules ---
register({
kind: "exact",
@@ -1429,7 +1518,7 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
});
return {
- routes: 32,
+ routes: 33,
dispose: () => {
for (const dispose of disposers) dispose();
}
diff --git a/dsh-mneme/lib/client.js b/dsh-mneme/lib/client.js
index 7988e1f4..f843219e 100644
--- a/dsh-mneme/lib/client.js
+++ b/dsh-mneme/lib/client.js
@@ -577,6 +577,20 @@ window.__ModuleLoader__.load({
"memory.explorer.imported": "已合并 {n} 条编辑",
"memory.explorer.importFailed": "导入失败",
"memory.explorer.importNoFile": "请先选择文件",
+ "memory.explorer.newMemory": "新增记忆…",
+ "memory.explorer.newTitle": "新增记忆",
+ "memory.explorer.newHint": "手动写一条记忆。同类型同标题会并入既有条目,而不是长出第二行。",
+ "memory.explorer.newType": "类型",
+ "memory.explorer.newTitlePlaceholder": "短标题,也是去重键",
+ "memory.explorer.newContentPlaceholder": "正文",
+ "memory.explorer.newTagsPlaceholder": "标签,逗号分隔(可留空)",
+ "memory.explorer.newConfirm": "新建",
+ "memory.explorer.newSaving": "保存中…",
+ "memory.explorer.newCreated": "已新建",
+ "memory.explorer.newMerged": "已并入同标题的既有条目",
+ "memory.explorer.newFailed": "新建失败",
+ "memory.explorer.newNoTitle": "请先填写标题",
+ "memory.explorer.newNoContent": "请先填写内容",
"memory.explorer.exportFailed": "导出失败",
"memory.explorer.conflictBadge": "冲突",
"memory.explorer.archivedBadge": "已归档",
@@ -1052,6 +1066,20 @@ window.__ModuleLoader__.load({
"memory.explorer.imported": "Merged {n} edits",
"memory.explorer.importFailed": "Import failed",
"memory.explorer.importNoFile": "Choose a file first",
+ "memory.explorer.newMemory": "New memory…",
+ "memory.explorer.newTitle": "New memory",
+ "memory.explorer.newHint": "Write one memory by hand. Same type + same title merges into the existing entry instead of minting a second row.",
+ "memory.explorer.newType": "Type",
+ "memory.explorer.newTitlePlaceholder": "Short title; also the dedupe key",
+ "memory.explorer.newContentPlaceholder": "Body",
+ "memory.explorer.newTagsPlaceholder": "Tags, comma separated (optional)",
+ "memory.explorer.newConfirm": "Create",
+ "memory.explorer.newSaving": "Saving…",
+ "memory.explorer.newCreated": "Created",
+ "memory.explorer.newMerged": "Merged into the existing entry with that title",
+ "memory.explorer.newFailed": "Create failed",
+ "memory.explorer.newNoTitle": "Title is required",
+ "memory.explorer.newNoContent": "Body is required",
"memory.explorer.exportFailed": "Export failed",
"memory.explorer.conflictBadge": "Conflict",
"memory.explorer.archivedBadge": "Archived",
@@ -4576,6 +4604,113 @@ window.__ModuleLoader__.load({
);
}
+ // #386 第 5 条:面板手动新增一条记忆。字段与 memory_save 工具对齐
+ //(type/title/content/importance/tags),但**不放 scope/sensitivity**:归属的
+ // 显式声明走详情抽屉那条路,它带「放宽可见性」二次确认;在新建表单里顺手盖
+ // 章会绕开那道确认。后端的去重键仍是 (type, title, scope, sensitivity),所以
+ // 同类型同标题会并入既有行——服务端回的 action 决定提示语说「已新建」还是
+ // 「已并入」,两者对用户是不同的事实,不能都报「已保存」。
+ function CreateDialog({ t, onClose, onCreated }) {
+ const [type, setType] = useState("preference");
+ const [title, setTitle] = useState("");
+ const [content, setContent] = useState("");
+ const [importance, setImportance] = useState(3);
+ const [tags, setTags] = useState("");
+ const [busy, setBusy] = useState(false);
+ const [msg, setMsg] = useState("");
+ const [err, setErr] = useState("");
+ const submit = () => {
+ // 前端先拦一次空标题/空正文:后端也会 400,但往返一趟只为报「标题不能
+ // 为空」不值当。服务端校验仍是权威——它同时管着类型白名单与去重。
+ if (!title.trim()) { setErr(t("memory.explorer.newNoTitle")); return; }
+ if (!content.trim()) { setErr(t("memory.explorer.newNoContent")); return; }
+ setBusy(true);
+ setErr("");
+ apiFetch("/api/dsh-mneme/create", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ type,
+ title: title.trim(),
+ content: content.trim(),
+ importance,
+ tags: tags.split(",").map((s) => s.trim()).filter(Boolean)
+ })
+ })
+ .then((res) => { if (!res.ok) throw new Error("http"); return res.json(); })
+ .then((d) => {
+ setMsg(d && d.action === "merged" ? t("memory.explorer.newMerged") : t("memory.explorer.newCreated"));
+ setBusy(false);
+ // 停 900ms 让提示可读,再关窗并把新建的行选中(见 onCreated)。
+ setTimeout(() => { onCreated(d); onClose(); }, 900);
+ })
+ .catch(() => { setBusy(false); setErr(t("memory.explorer.newFailed")); });
+ };
+ return h("div", { className: "mneme-impdialog", role: "region", "aria-label": t("memory.explorer.newTitle") },
+ h("div", { className: "mneme-set-title" }, t("memory.explorer.newTitle")),
+ h("div", { className: "mneme-featsubhint", style: { marginTop: 4 } }, t("memory.explorer.newHint")),
+ h("div", { className: "mneme-improw" },
+ h("span", { className: "mneme-featnumlabel" }, t("memory.explorer.newType")),
+ h("select", { className: "mneme-select", value: type, onChange: (e) => setType(e.target.value) },
+ // document 刻意不在列:它是 agent 注册长文档的指针行,铸造口唯一
+ //(registerDocument,#230),后端路由也会 400。列出来只会让人点了
+ // 才被拒。
+ ["preference", "project", "decision", "history", "rejected_solution", "pitfall", "constraint"].map((k) =>
+ h("option", { key: k, value: k }, typeLabel(t, k))))
+ ),
+ h("div", { className: "mneme-improw" },
+ h("input", {
+ type: "text",
+ className: "mneme-set-input",
+ value: title,
+ placeholder: t("memory.explorer.newTitlePlaceholder"),
+ "aria-label": t("memory.explorer.newTitlePlaceholder"),
+ onChange: (e) => { setTitle(e.target.value); setErr(""); }
+ })
+ ),
+ h("div", { className: "mneme-improw" },
+ h("textarea", {
+ className: "mneme-set-input mneme-set-input--area",
+ value: content,
+ placeholder: t("memory.explorer.newContentPlaceholder"),
+ "aria-label": t("memory.explorer.newContentPlaceholder"),
+ rows: 4,
+ onChange: (e) => { setContent(e.target.value); setErr(""); }
+ })
+ ),
+ h("div", { className: "mneme-improw" },
+ h("input", {
+ type: "text",
+ className: "mneme-set-input",
+ value: tags,
+ placeholder: t("memory.explorer.newTagsPlaceholder"),
+ "aria-label": t("memory.explorer.newTagsPlaceholder"),
+ onChange: (e) => setTags(e.target.value)
+ })
+ ),
+ h("div", { className: "mneme-improw" },
+ h("span", { className: "mneme-featnumlabel" }, t("memory.explorer.importance")),
+ h("div", { className: "mneme-staredit", role: "radiogroup", "aria-label": t("memory.explorer.detail.editImportance") },
+ [1, 2, 3, 4, 5].map((n) => h("button", {
+ key: n,
+ type: "button",
+ role: "radio",
+ "aria-checked": String(importance === n),
+ "aria-label": t("memory.explorer.detail.starLabel").replace("{n}", String(n)),
+ className: importance >= n ? "mneme-starbtn mneme-starbtn--on" : "mneme-starbtn",
+ onClick: () => setImportance(n)
+ }, h(StarGlyph, { size: 15, filled: importance >= n }))))
+ ),
+ err && h("div", { className: "mneme-featsubhint", style: { color: "var(--mneme-danger)", marginTop: 8 } }, err),
+ msg && h("div", { className: "mneme-saved", style: { marginTop: 8 } }, msg),
+ h("div", { className: "mneme-improw", style: { justifyContent: "flex-end" } },
+ h("button", { type: "button", className: "mneme-btn", disabled: busy, onClick: onClose }, t("memory.explorer.importCancel")),
+ h("button", { type: "button", className: "mneme-btn", disabled: busy, onClick: submit },
+ busy ? t("memory.explorer.newSaving") : t("memory.explorer.newConfirm"))
+ )
+ );
+ }
+
function MemoryExplorer({ t }) {
const [view, setView] = useState("memory"); // memory | entity | status | settings
const [items, setItems] = useState([]); // loaded browse pages, chrono desc
@@ -4611,6 +4746,7 @@ window.__ModuleLoader__.load({
const [pendingIds, setPendingIds] = useState(() => new Set());
const [menuOpen, setMenuOpen] = useState(false);
const [importOpen, setImportOpen] = useState(false);
+ const [createOpen, setCreateOpen] = useState(false);
const itemRefs = useRef(new Map());
const moreRef = useRef(null);
@@ -4960,9 +5096,11 @@ window.__ModuleLoader__.load({
"aria-label": t("memory.explorer.more"),
"aria-expanded": String(menuOpen),
title: t("memory.explorer.more"),
- onClick: () => { setMenuOpen(!menuOpen); setImportOpen(false); }
+ onClick: () => { setMenuOpen(!menuOpen); setImportOpen(false); setCreateOpen(false); }
}, "⋯"),
menuOpen && h("div", { className: "mneme-menu", role: "menu" },
+ h("button", { type: "button", role: "menuitem", onClick: () => { setCreateOpen(true); setMenuOpen(false); } },
+ t("memory.explorer.newMemory")),
h("button", { type: "button", role: "menuitem", onClick: () => { setMenuOpen(false); exportData("json"); } },
t("memory.explorer.exportJson")),
h("button", { type: "button", role: "menuitem", onClick: () => { setMenuOpen(false); exportData("markdown"); } },
@@ -4974,6 +5112,13 @@ window.__ModuleLoader__.load({
t,
onClose: () => setImportOpen(false),
onImported: () => setReloadKey((k) => k + 1)
+ }),
+ createOpen && h(CreateDialog, {
+ t,
+ onClose: () => setCreateOpen(false),
+ // 新建后既刷新列表也选中新行:并入既有条目时 action 是 merged,
+ // 返回的 id 就是被并入那一行——两种情况都该把它滚进视野。
+ onCreated: (d) => { setReloadKey((k) => k + 1); if (d && d.id) setSelectedId(d.id); }
})
)
),
diff --git a/dsh-mneme/src/api.js b/dsh-mneme/src/api.js
index 96dfd6d2..b149ae52 100644
--- a/dsh-mneme/src/api.js
+++ b/dsh-mneme/src/api.js
@@ -1,6 +1,7 @@
import { URL } from "node:url";
import { timingSafeEqual, randomBytes } from "node:crypto";
import { FEATURE_FLAG_SPEC } from "./settings.js";
+import { TYPES } from "./store.js";
import { TYPE_FILE, renderMirrorText, renderFileHeader, parseHumanEdits, MIRROR_READONLY_TYPES } from "./mirror.js";
import { langOf } from "./lang.js";
import { computeHeat } from "./heat.js";
@@ -602,6 +603,94 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
}
});
+ // --- 新建一条记忆(面板写路径,与 /update、/delete 对称命名)---------------
+ // #386 第 5 条:CLI `add` 与 standalone `POST /memories` 都能建行,面板此前只
+ // 能看/改/删/导入。字段与 memory_save 工具对齐(type/title/content/importance/
+ // tags),走同一条 saveWithDedupe 路径——去重键仍是 (type, title, scope,
+ // sensitivity),所以「面板建」与「工具建」同标题会并入同一行,不会长出两份。
+ //
+ // 三处刻意的口径差异(都写在代码里,免得下一个人以为是抄漏):
+ // 1. content 要求 trim 后非空,与 /update 一致而非 standalone 的宽松版:
+ // 面板是人工输入,空正文只可能是误操作;standalone 面对的是程序化调用,
+ // 放宽是为了兼容「先建壳再补内容」的既有客户端。
+ // 2. document 直接 400:行铸造口唯一(registerDocument,#230),saveWithDedupe
+ // 会 throw,这里提前给干净错误码而不是让它落成 500。
+ // 3. 不接受 scope/sensitivity 字段——面板的 scope 编辑在详情抽屉里(带放宽
+ // 确认的安全阀,见 lib/client.js 的 MemoryDrawer),新建时就盖章 explicit
+ // 会绕过那道确认。传了也不报错,只是不生效(与 /update 的「字段出现即校验」
+ // 不同,因为这里根本没有该字段的语义)。
+ register({
+ kind: "exact",
+ path: "/api/dsh-mneme/create",
+ handler(req, res) {
+ try {
+ if (req.method !== "POST") {
+ sendJson(res, 404, { error: "not-found" });
+ return;
+ }
+ if (!requireAuth(req, res, apiToken)) return;
+ return readBody(req).then((text) => {
+ const body = parseBody(text);
+ if (!TYPES.has(body.type)) {
+ sendJson(res, 400, { error: "invalid-type" });
+ return;
+ }
+ if (body.type === "document") {
+ sendJson(res, 400, { error: "document-requires-register" });
+ return;
+ }
+ if (typeof body.title !== "string" || !body.title.trim()) {
+ sendJson(res, 400, { error: "invalid-title" });
+ return;
+ }
+ if (typeof body.content !== "string" || !body.content.trim()) {
+ sendJson(res, 400, { error: "invalid-content" });
+ return;
+ }
+ if (body.importance !== undefined
+ && (!Number.isInteger(body.importance) || body.importance < 1 || body.importance > 5)) {
+ sendJson(res, 400, { error: "invalid-importance" });
+ return;
+ }
+ if (body.tags !== undefined && (!Array.isArray(body.tags) || !body.tags.every((tag) => typeof tag === "string"))) {
+ sendJson(res, 400, { error: "invalid-tags" });
+ return;
+ }
+ try {
+ const result = service.saveWithDedupe({
+ type: body.type,
+ title: body.title.trim(),
+ content: body.content.trim(),
+ importance: body.importance,
+ tags: body.tags ?? [],
+ // 面板不是工具也不是外部程序,来源要能在详情抽屉里一眼认出是谁写的。
+ source: "panel"
+ });
+ // #254 写入准入在无会话身份时放行(write-admission.js 首行早退),面板
+ // 路由恰好不带 _sessionKey,所以今天到不了这个分支。仍然显式写出来:
+ // 将来若把会话身份接进面板,落到 toApiList([null]) 上会变成 500,把
+ // 「被拒」伪装成「服务器出错」——两者的处置完全不同。
+ if (result.action === "denied") {
+ sendJson(res, 422, { error: "write-rejected", reason: result.reason });
+ return;
+ }
+ // action 随行透出(created/merged):面板据此提示「已并入同标题的既有条目」
+ // 还是「新建成功」——两者对用户是不同的事实,不能都报「已保存」。
+ sendJson(res, result.action === "created" ? 201 : 200, {
+ ...service.toApiList([result.memory])[0],
+ action: result.action
+ });
+ } catch (error) {
+ ctx.logger?.warn?.(`[dsh-mneme] panel create failed: ${String(error)}`);
+ sendJson(res, 500, { error: "internal" });
+ }
+ });
+ } catch {
+ sendJson(res, 500, { error: "internal" });
+ }
+ }
+ });
+
// --- rules ---
register({
kind: "exact",
@@ -1429,7 +1518,7 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
});
return {
- routes: 32,
+ routes: 33,
dispose: () => {
for (const dispose of disposers) dispose();
}
diff --git a/dsh-mneme/test/api-routes-gaps.test.js b/dsh-mneme/test/api-routes-gaps.test.js
index a27e73d9..527bd7e8 100644
--- a/dsh-mneme/test/api-routes-gaps.test.js
+++ b/dsh-mneme/test/api-routes-gaps.test.js
@@ -216,3 +216,158 @@ test("external-api PUT applies valid config and echoes it", async () => {
assert.equal(cfg.host, "127.0.0.1");
assert.ok(typeof cfg.token === "string");
});
+
+// ---------------------------------------------------------------------------
+// /api/dsh-mneme/create(#386 第 5 条:面板手动新增记忆)
+// ---------------------------------------------------------------------------
+// 这些用例锁的是「面板建行」与「工具建行」必须同语义:同一条 saveWithDedupe
+// 路径、同一套去重键。历史缺口是面板只有看/改/删/导入,用户想手写一条必须开
+// 终端或调外部 API——两条旁路都绕开了面板的去重提示。
+test("create is auth-gated and rejects non-POST", async () => {
+ const { routes } = setup("secret-token");
+ const route = handler(routes, "/api/dsh-mneme/create");
+ assert.ok(route, "route must be registered");
+
+ // 未带 token → 401(写路由与 /update、/delete 同口径)
+ let res = new FakeRes();
+ await route.handler(req("/api/dsh-mneme/create", "POST", { type: "project", title: "t", content: "c" }), res);
+ assert.equal(res.statusCode, 401);
+ assert.equal(JSON.parse(res.body).error, "unauthorized");
+
+ // 带 token 但 GET → 404(不是 405:与既有写路由一致,避免探测路由存在性)
+ res = new FakeRes();
+ const ok = req("/api/dsh-mneme/create", "GET");
+ ok.headers = { authorization: "Bearer secret-token" };
+ await route.handler(ok, res);
+ assert.equal(res.statusCode, 404);
+});
+
+test("create validates type/title/content and refuses document rows", async () => {
+ const { routes, service } = setup("secret-token");
+ const route = handler(routes, "/api/dsh-mneme/create");
+ const post = async (body) => {
+ const r = req("/api/dsh-mneme/create", "POST", body);
+ r.headers = { authorization: "Bearer secret-token" };
+ const res = new FakeRes();
+ await route.handler(r, res);
+ return res;
+ };
+
+ // 未知类型 → 400(TYPES 是唯一真相源,面板不得自己维护第二份白名单)
+ let res = await post({ type: "not-a-type", title: "t", content: "c" });
+ assert.equal(res.statusCode, 400);
+ assert.equal(JSON.parse(res.body).error, "invalid-type");
+
+ // document 是 registerDocument 的专属铸造口(#230)→ 干净 400,不落成 500
+ res = await post({ type: "document", title: "t", content: "c" });
+ assert.equal(res.statusCode, 400);
+ assert.equal(JSON.parse(res.body).error, "document-requires-register");
+
+ // 空白标题 / 空白正文 → 400(面板是人工输入,空正文只可能是误操作)
+ res = await post({ type: "project", title: " ", content: "c" });
+ assert.equal(res.statusCode, 400);
+ assert.equal(JSON.parse(res.body).error, "invalid-title");
+ res = await post({ type: "project", title: "t", content: " \n " });
+ assert.equal(res.statusCode, 400);
+ assert.equal(JSON.parse(res.body).error, "invalid-content");
+
+ // importance 越界 / tags 非字符串数组 → 400,宁拒绝不静默纠正
+ res = await post({ type: "project", title: "t", content: "c", importance: 9 });
+ assert.equal(res.statusCode, 400);
+ assert.equal(JSON.parse(res.body).error, "invalid-importance");
+ res = await post({ type: "project", title: "t", content: "c", tags: [1, 2] });
+ assert.equal(res.statusCode, 400);
+ assert.equal(JSON.parse(res.body).error, "invalid-tags");
+
+ // 全部被拒后一条都没落库
+ assert.equal(service.count(), 0);
+});
+
+test("create saves a memory and reports action=created with 201", async () => {
+ const { routes, service } = setup("secret-token");
+ const route = handler(routes, "/api/dsh-mneme/create");
+
+ const r = req("/api/dsh-mneme/create", "POST", {
+ type: "constraint",
+ title: "面板手写一条",
+ content: "正文",
+ importance: 4,
+ tags: ["panel"]
+ });
+ r.headers = { authorization: "Bearer secret-token" };
+ const res = new FakeRes();
+ await route.handler(r, res);
+
+ assert.equal(res.statusCode, 201);
+ const body = JSON.parse(res.body);
+ assert.equal(body.action, "created");
+ assert.equal(body.type, "constraint");
+ assert.equal(body.title, "面板手写一条");
+ assert.equal(body.importance, 4);
+ assert.deepEqual(body.tags, ["panel"]);
+ // source 必须能一眼认出是面板写的——详情抽屉把它当「来源」显示
+ assert.equal(body.source, "panel");
+ assert.ok(service.getById(body.id), "row really exists in the store");
+});
+
+test("create merges into an existing row with the same (type, title) and says so", async () => {
+ const { routes, service } = setup("secret-token");
+ const route = handler(routes, "/api/dsh-mneme/create");
+ service.saveWithDedupe({ type: "project", title: "同标题", content: "原来的正文" });
+
+ const r = req("/api/dsh-mneme/create", "POST", { type: "project", title: "同标题", content: "追加的正文" });
+ r.headers = { authorization: "Bearer secret-token" };
+ const res = new FakeRes();
+ await route.handler(r, res);
+
+ // 200 而非 201:并入了既有行,没有新行诞生。面板据此提示「已并入」而不是
+ // 「新建成功」——两者对用户是不同的事实。
+ assert.equal(res.statusCode, 200);
+ const body = JSON.parse(res.body);
+ assert.equal(body.action, "merged");
+ assert.equal(service.count(), 1, "no second row was minted");
+ assert.ok(service.getById(body.id).content.includes("追加的正文"));
+});
+
+test("create maps a denied write to 422 instead of leaking a 500", async () => {
+ // #254 写入准入今天在无会话身份时放行(write-admission.js 首行早退),面板路由
+ // 恰好不带 _sessionKey,所以这条分支当前到不了。仍然要锁:一旦有人把会话身份接
+ // 进面板,落回 toApiList([null]) 会把「被拒」伪装成「服务器出错」——用户看到的
+ // 是「新建失败」,实际是准入拒绝,两者的处置完全不同。
+ const { routes, service } = setup("secret-token");
+ const route = handler(routes, "/api/dsh-mneme/create");
+ const original = service.saveWithDedupe;
+ service.saveWithDedupe = () => ({ action: "denied", reason: "too-many-writes", memory: null });
+ try {
+ const r = req("/api/dsh-mneme/create", "POST", { type: "project", title: "t", content: "c" });
+ r.headers = { authorization: "Bearer secret-token" };
+ const res = new FakeRes();
+ await route.handler(r, res);
+ assert.equal(res.statusCode, 422);
+ const body = JSON.parse(res.body);
+ assert.equal(body.error, "write-rejected");
+ assert.equal(body.reason, "too-many-writes", "the reason must survive: it is what makes the rejection actionable");
+ } finally {
+ service.saveWithDedupe = original;
+ }
+});
+
+test("create returns 500 (not a leaked throw) when the store rejects the write", async () => {
+ const { routes, service } = setup("secret-token");
+ const route = handler(routes, "/api/dsh-mneme/create");
+ // 绕过路由校验直达 saveWithDedupe 的守卫:document 行会 throw。这里用打桩
+ // 模拟「路由认为合法、存储层仍然拒绝」的那类情况(类型白名单漂移等)。
+ const original = service.saveWithDedupe;
+ service.saveWithDedupe = () => { throw new Error("invalid memory type: whatever"); };
+ try {
+ const r = req("/api/dsh-mneme/create", "POST", { type: "project", title: "t", content: "c" });
+ r.headers = { authorization: "Bearer secret-token" };
+ const res = new FakeRes();
+ await route.handler(r, res);
+ assert.equal(res.statusCode, 500);
+ assert.equal(JSON.parse(res.body).error, "internal");
+ } finally {
+ service.saveWithDedupe = original;
+ }
+});
+
diff --git a/dsh-mneme/test/client.test.js b/dsh-mneme/test/client.test.js
index 9066b22a..bf75d8f5 100644
--- a/dsh-mneme/test/client.test.js
+++ b/dsh-mneme/test/client.test.js
@@ -1256,6 +1256,73 @@ test("conflict queue: expandable full text with decision metadata", () => {
assert.ok(occurrences >= 2, `i18n key ${key} must exist in both zh and en (got ${occurrences})`);
}
});
+
+// #386 第 5 条:面板手动新增记忆。这一块最容易悄悄退化——菜单项被删掉、弹层被
+// 从树里摘掉、或者表单又长回 scope/sensitivity 输入框(那会绕开详情抽屉的「放宽
+// 可见性」二次确认),面板表面上看不出坏,只是入口没了。所以既锁「入口存在」,
+// 也锁「入口真的挂在树上」。
+test("panel can create a memory by hand: menu entry wired to the dialog", () => {
+ // 入口在「更多操作」菜单里,且菜单项真的调用 setCreateOpen(true)——只断言文案
+ // 存在的话,把 onClick 摘掉测试照样绿。
+ assert.ok(clientSource.includes('"memory.explorer.newMemory"'), "menu entry label must exist");
+ assert.ok(/role: "menuitem", onClick: \(\) => \{ setCreateOpen\(true\); setMenuOpen\(false\); \}/.test(clientSource),
+ "the menu item must actually open the create dialog");
+ assert.ok(clientSource.includes("const [createOpen, setCreateOpen] = useState(false)"),
+ "the dialog needs its own open state");
+ // 弹层必须挂在树上并接上回调:onCreated 同时刷新列表与选中新行(并入既有条目时
+ // 返回的 id 是被并入那一行,也该滚进视野)。
+ assert.ok(/createOpen && h\(CreateDialog, \{/.test(clientSource), "the dialog must be mounted conditionally");
+ assert.ok(/onCreated: \(d\) => \{ setReloadKey\(\(k\) => k \+ 1\); if \(d && d\.id\) setSelectedId\(d\.id\); \}/.test(clientSource),
+ "creating must refresh the list and select the resulting row");
+ // 打后端的路由是 /create,不是 standalone 的 /memories:面板与工具必须走同一条
+ // saveWithDedupe 路径,否则「同标题并入」的提示在面板上会失灵。
+ assert.ok(clientSource.includes('apiFetch("/api/dsh-mneme/create"'),
+ "the dialog must post to the panel create route");
+ assert.ok(/d\.action === "merged" \? t\("memory\.explorer\.newMerged"\) : t\("memory\.explorer\.newCreated"\)/.test(clientSource),
+ "merged and created are different facts and must be reported differently");
+ // 表单字段与 memory_save 工具对齐;scope/sensitivity 刻意不放(见 CreateDialog 注释)。
+ for (const field of ["type", "title", "content", "importance", "tags"]) {
+ assert.ok(clientSource.includes(`${field}`), `form must carry ${field}`);
+ }
+ assert.equal(/CreateDialog[\s\S]{0,4000}?newScope|CreateDialog[\s\S]{0,4000}?sensitivity/.test(clientSource), false,
+ "the create form must not offer scope/sensitivity: that path bypasses the widen-confirm guard");
+ // document 不在类型下拉里:铸造口唯一(registerDocument,#230),后端也会 400。
+ const dialogStart = clientSource.indexOf("function CreateDialog");
+ const dialogEnd = clientSource.indexOf("function MemoryExplorer", dialogStart);
+ const dialogSource = clientSource.slice(dialogStart, dialogEnd);
+ assert.equal(dialogSource.includes('"document"'), false,
+ "document rows are minted only via registerDocument and must not be offered here");
+ // 类型白名单与 src/tools.js 的 memory_save enum 逐字一致(缺一个就会有人点进去才被拒)。
+ for (const k of ["preference", "project", "decision", "history", "rejected_solution", "pitfall", "constraint"]) {
+ assert.ok(dialogSource.includes(`"${k}"`), `type option ${k} must be offered`);
+ }
+ // 前端空值拦截只是省一次往返,服务端校验才是权威——但两者都得在,否则空标题会
+ // 一路发出去再弹一个 400 给用户看。
+ assert.ok(dialogSource.includes('if (!title.trim()) { setErr(t("memory.explorer.newNoTitle")); return; }'),
+ "empty title must be caught before the round trip");
+ assert.ok(dialogSource.includes('if (!content.trim()) { setErr(t("memory.explorer.newNoContent")); return; }'),
+ "empty body must be caught before the round trip");
+ // 十四个新键必须中英成对(与上面那条全局锁同口径,这里列出名字便于定位)。
+ for (const key of [
+ "memory.explorer.newMemory",
+ "memory.explorer.newTitle",
+ "memory.explorer.newHint",
+ "memory.explorer.newType",
+ "memory.explorer.newTitlePlaceholder",
+ "memory.explorer.newContentPlaceholder",
+ "memory.explorer.newTagsPlaceholder",
+ "memory.explorer.newConfirm",
+ "memory.explorer.newSaving",
+ "memory.explorer.newCreated",
+ "memory.explorer.newMerged",
+ "memory.explorer.newFailed",
+ "memory.explorer.newNoTitle",
+ "memory.explorer.newNoContent"
+ ]) {
+ const occurrences = clientSource.split(`"${key}"`).length - 1;
+ assert.ok(occurrences >= 2, `i18n key ${key} must exist in both zh and en (got ${occurrences})`);
+ }
+});
// 面板 bundle 在本文件里只被当**文本**读(上面的断言全是正则/字符串包含),
// 而仓库的 CI 里没有任何一步**解析**它:于是重复声明这类语法错误能一路绿灯进
// 主干,后果却是整个面板加载失败(__ModuleLoader__ 拿到的模块一执行就抛
From e80ee0ae609dde14c751616da626076a6cc515af Mon Sep 17 00:00:00 2001
From: modusensus
Date: Mon, 12 Oct 2026 05:01:12 +0800
Subject: [PATCH 2/2] =?UTF-8?q?fix(386):=20=E5=BB=BA=E5=AE=8C=E5=90=8E?=
=?UTF-8?q?=E6=8C=89=E9=92=AE=E4=B8=8D=E5=86=8D=E5=8F=AF=E7=82=B9=EF=BC=8C?=
=?UTF-8?q?=E5=85=8D=E5=BE=97=E5=90=8C=E6=A0=87=E9=A2=98=E4=BA=8C=E6=AC=A1?=
=?UTF-8?q?=E6=8F=90=E4=BA=A4=E6=8A=8A=E6=AD=A3=E6=96=87=E8=BF=BD=E5=8A=A0?=
=?UTF-8?q?=E4=B8=A4=E9=81=8D=EF=BC=88#408=20=E8=AF=84=E5=AE=A1=EF=BC=89?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
成功回调先 setBusy(false) 再等 900ms 关窗,这段时间「新建」按钮重新可点。
第二次同 type+title 会命中去重键落进同一行,service 走 appendContent 把正文
再追加一遍,提示还会从「已新建」翻成「已并入」——一次误触就写坏一条记忆。
现在成功路径不重置 busy,按钮全程 disabled 直到关窗;按钮文案同步改成
busy && !msg,否则写完了还写着「保存中…」。
test/client.test.js 的字段锁原先在整包上断言 clientSource.includes("title"),
恒真。改成在 CreateDialog 切片上匹配真实请求体写法,多一个字段(scope/
sensitivity)或少一个 trim 都匹配不上。
回归锁新增两条;变异检验四条全 RED(放回 setBusy(false)、文案只看 busy、
请求体多带 scope、title 不 trim)。
Refs #386
---
dsh-mneme/CHANGELOG.md | 2 +-
dsh-mneme/lib/client.js | 8 ++++++--
dsh-mneme/test/client.test.js | 29 ++++++++++++++++++++++-------
3 files changed, 29 insertions(+), 10 deletions(-)
diff --git a/dsh-mneme/CHANGELOG.md b/dsh-mneme/CHANGELOG.md
index 93b0ac39..99664f99 100644
--- a/dsh-mneme/CHANGELOG.md
+++ b/dsh-mneme/CHANGELOG.md
@@ -4,7 +4,7 @@
## 🆕 新增
-- **面板手动新增记忆(issue #386 第 5 条)**:面板此前只能读、改、删、导入,**没有任何手写入口**——想记一条只能切到对话里让模型调 `memory_save`,而模型看不见的东西(一次现场结论、一条口头约定)恰恰最需要手动落库。「更多操作」菜单新增「新增记忆…」,弹层收类型 / 标题 / 正文 / 重要性 / 标签,字段与 `memory_save` 工具对齐。类型下拉**刻意不含 `document`**:那是 agent 注册长文档的指针行,铸造口唯一(`registerDocument`,#230),列出来只会让人点了才被拒。**表单不放 scope / sensitivity**:显式归属走详情抽屉那条路,它带「放宽可见性」二次确认;在新建表单里顺手盖章会绕开那道确认。新增后端路由 `POST /api/dsh-mneme/create`,落库走 `service.saveWithDedupe`——去重键仍是 `(type, title, scope, sensitivity)`,所以**同类型同标题会并入既有条目而不是长出第二行**,服务端回 `action: "created" | "merged"` 决定提示语说「已新建」还是「已并入」:两者对用户是不同的事实,都报「已保存」会让人以为自己新写了一条、实际旧行被改。三处校验比 standalone 的 `POST /memories` 收紧(口径向 `/update` 看齐):`content` 要 trim 非空(面板是人工输入,空正文只可能是误操作)、`document` 提前 400、scope/sensitivity 不接受。写入准入(`write-admission.js`)对面板路径不生效——它无会话身份即放行——但路由仍显式处理 `action === "denied"` → 422,免得日后面板带上会话身份时静默吞掉拒绝。回归锁六条进 `test/api-routes-gaps.test.js`(①鉴权 401 且 GET 落 404;②六种 400 校验且库内零写入;③201 created 且 `source` 为 `panel`;④200 merged 且 `count() === 1`;⑤`denied` → 422 且理由随行;⑥落库异常 500),面板侧另有一条 `test/client.test.js` 锁「入口真的挂在树上」(菜单项确实调 `setCreateOpen(true)`、弹层确实被条件挂载、`onCreated` 确实刷新列表并选中新行、表单不得长出 scope/sensitivity 输入框、类型下拉不得含 `document`)。两侧都做了变异检验:面板七条、后端七条,逐条把锁打回坏状态都转红(含「菜单项变哑」「弹层摘掉」「表单长回 scope」「打错路由」这类只有肉眼才能发现的退化)。
+- **面板手动新增记忆(issue #386 第 5 条)**:面板此前只能读、改、删、导入,**没有任何手写入口**——想记一条只能切到对话里让模型调 `memory_save`,而模型看不见的东西(一次现场结论、一条口头约定)恰恰最需要手动落库。「更多操作」菜单新增「新增记忆…」,弹层收类型 / 标题 / 正文 / 重要性 / 标签,字段与 `memory_save` 工具对齐。类型下拉**刻意不含 `document`**:那是 agent 注册长文档的指针行,铸造口唯一(`registerDocument`,#230),列出来只会让人点了才被拒。**表单不放 scope / sensitivity**:显式归属走详情抽屉那条路,它带「放宽可见性」二次确认;在新建表单里顺手盖章会绕开那道确认。新增后端路由 `POST /api/dsh-mneme/create`,落库走 `service.saveWithDedupe`——去重键仍是 `(type, title, scope, sensitivity)`,所以**同类型同标题会并入既有条目而不是长出第二行**,服务端回 `action: "created" | "merged"` 决定提示语说「已新建」还是「已并入」:两者对用户是不同的事实,都报「已保存」会让人以为自己新写了一条、实际旧行被改。三处校验比 standalone 的 `POST /memories` 收紧(口径向 `/update` 看齐):`content` 要 trim 非空(面板是人工输入,空正文只可能是误操作)、`document` 提前 400、scope/sensitivity 不接受。写入准入(`write-admission.js`)对面板路径不生效——它无会话身份即放行——但路由仍显式处理 `action === "denied"` → 422,免得日后面板带上会话身份时静默吞掉拒绝。回归锁六条进 `test/api-routes-gaps.test.js`(①鉴权 401 且 GET 落 404;②六种 400 校验且库内零写入;③201 created 且 `source` 为 `panel`;④200 merged 且 `count() === 1`;⑤`denied` → 422 且理由随行;⑥落库异常 500),面板侧另有一条 `test/client.test.js` 锁「入口真的挂在树上」(菜单项确实调 `setCreateOpen(true)`、弹层确实被条件挂载、`onCreated` 确实刷新列表并选中新行、请求体逐字只有 type/title/content/importance/tags、表单不得长出 scope/sensitivity 输入框、类型下拉不得含 `document`)。评审期补的两处:**成功路径不得重置 `busy`**——提示要停 900ms 才关窗,那段时间按钮若重新可点,第二次同 type+title 会落进同一行并由 `appendContent` 把正文再追加一遍,提示还会从「已新建」翻成「已并入」,一次误触就写坏一条记忆;因此整个弹层只允许错误路径重置 `busy`,按钮文案也随之改成 `busy && !msg`,免得写完了还写着「保存中…」。另把字段锁从「整包里包含这些词」改成在 `CreateDialog` 源码切片上匹配真实请求体写法(在整包上断言 `clientSource.includes("title")` 恒真,等于没锁)。两侧都做了变异检验:面板/后端逐条把锁打回坏状态都转红(含「菜单项变哑」「弹层摘掉」「成功路径放回 setBusy(false)」「按钮文案只看 busy」「表单长回 scope」「不 trim title」「打错路由」这类只有肉眼才能发现的退化)。
## 🐛 修复
diff --git a/dsh-mneme/lib/client.js b/dsh-mneme/lib/client.js
index f843219e..a7886735 100644
--- a/dsh-mneme/lib/client.js
+++ b/dsh-mneme/lib/client.js
@@ -4640,7 +4640,11 @@ window.__ModuleLoader__.load({
.then((res) => { if (!res.ok) throw new Error("http"); return res.json(); })
.then((d) => {
setMsg(d && d.action === "merged" ? t("memory.explorer.newMerged") : t("memory.explorer.newCreated"));
- setBusy(false);
+ // 成功后**不**重置 busy。这 900ms 是留给提示的阅读时间,而「新建」按钮此刻
+ // 若重新可点,第二次同 type+title 会落进同一行,service 走 appendContent 把
+ // 正文再追加一遍,提示还会从「已新建」翻成「已并入」——一次误触就写坏一条
+ // 记忆。保持 busy 直到关窗,按钮全程 disabled(成功路径组件随即卸载,没有
+ // 需要复原的状态)。
// 停 900ms 让提示可读,再关窗并把新建的行选中(见 onCreated)。
setTimeout(() => { onCreated(d); onClose(); }, 900);
})
@@ -4706,7 +4710,7 @@ window.__ModuleLoader__.load({
h("div", { className: "mneme-improw", style: { justifyContent: "flex-end" } },
h("button", { type: "button", className: "mneme-btn", disabled: busy, onClick: onClose }, t("memory.explorer.importCancel")),
h("button", { type: "button", className: "mneme-btn", disabled: busy, onClick: submit },
- busy ? t("memory.explorer.newSaving") : t("memory.explorer.newConfirm"))
+ busy && !msg ? t("memory.explorer.newSaving") : t("memory.explorer.newConfirm"))
)
);
}
diff --git a/dsh-mneme/test/client.test.js b/dsh-mneme/test/client.test.js
index bf75d8f5..acecb2fb 100644
--- a/dsh-mneme/test/client.test.js
+++ b/dsh-mneme/test/client.test.js
@@ -1280,16 +1280,31 @@ test("panel can create a memory by hand: menu entry wired to the dialog", () =>
"the dialog must post to the panel create route");
assert.ok(/d\.action === "merged" \? t\("memory\.explorer\.newMerged"\) : t\("memory\.explorer\.newCreated"\)/.test(clientSource),
"merged and created are different facts and must be reported differently");
- // 表单字段与 memory_save 工具对齐;scope/sensitivity 刻意不放(见 CreateDialog 注释)。
- for (const field of ["type", "title", "content", "importance", "tags"]) {
- assert.ok(clientSource.includes(`${field}`), `form must carry ${field}`);
- }
- assert.equal(/CreateDialog[\s\S]{0,4000}?newScope|CreateDialog[\s\S]{0,4000}?sensitivity/.test(clientSource), false,
- "the create form must not offer scope/sensitivity: that path bypasses the widen-confirm guard");
- // document 不在类型下拉里:铸造口唯一(registerDocument,#230),后端也会 400。
+ // 弹层源码切片:下面「表单里有什么 / 没有什么」的断言一律在这段上做。在整包上做
+ // 等于没锁——这些字段名在 bundle 里到处都是,`clientSource.includes("title")` 恒真。
const dialogStart = clientSource.indexOf("function CreateDialog");
const dialogEnd = clientSource.indexOf("function MemoryExplorer", dialogStart);
+ assert.ok(dialogStart >= 0 && dialogEnd > dialogStart, "CreateDialog must be findable in the bundle");
const dialogSource = clientSource.slice(dialogStart, dialogEnd);
+ // 表单字段与 memory_save 工具对齐,且锁的是**真实请求体写法**:多一个字段(比如
+ // scope/sensitivity)这段就匹配不上,少一个 trim 也匹配不上。锁裸字段名会恒真。
+ assert.match(dialogSource,
+ /JSON\.stringify\(\{\s*type,\s*title: title\.trim\(\),\s*content: content\.trim\(\),\s*importance,\s*tags: tags\.split\(","\)\.map\(\(s\) => s\.trim\(\)\)\.filter\(Boolean\)\s*\}\)/,
+ "the create request body must carry exactly type/title/content/importance/tags");
+ // 成功路径**不得**重置 busy:提示要停 900ms 才关窗,这期间按钮若重新可点,第二次
+ // 同 type+title 会落进同一行并把正文再追加一遍(service 走 appendContent),一次
+ // 误触就写坏一条记忆。所以全弹层只允许 catch 那条路重置 busy。
+ assert.equal(dialogSource.split("setBusy(false)").length - 1, 1,
+ "only the error path may reset busy; the success path must stay disabled until the dialog closes");
+ assert.match(dialogSource, /\.catch\(\(\) => \{ setBusy\(false\); setErr\(t\("memory\.explorer\.newFailed"\)\); \}\)/,
+ "the single busy reset must be the error path");
+ // 按钮文案同理:成功后 busy 仍为 true,若只看 busy,写入早就完成了按钮还写着「保存
+ // 中…」——既骗用户又和旁边那行「已新建」自相矛盾。msg 一置上就切回正常文案。
+ assert.match(dialogSource, /busy && !msg \? t\("memory\.explorer\.newSaving"\) : t\("memory\.explorer\.newConfirm"\)/,
+ "once the write succeeded the button must stop claiming it is still saving");
+ assert.equal(/newScope|sensitivity/.test(dialogSource), false,
+ "the create form must not offer scope/sensitivity: that path bypasses the widen-confirm guard");
+ // document 不在类型下拉里:铸造口唯一(registerDocument,#230),后端也会 400。
assert.equal(dialogSource.includes('"document"'), false,
"document rows are minted only via registerDocument and must not be offered here");
// 类型白名单与 src/tools.js 的 memory_save enum 逐字一致(缺一个就会有人点进去才被拒)。