From d8412aec5e36ef57fa4a2dc02bc1d21597005550 Mon Sep 17 00:00:00 2001 From: modusensus Date: Mon, 2 Nov 2026 12:29:15 +0800 Subject: [PATCH] =?UTF-8?q?fix(panel):=20=E3=80=8C=E6=9C=80=E8=BF=91?= =?UTF-8?q?=E5=B7=A9=E5=9B=BA=E3=80=8D=E4=B8=8D=E5=86=8D=E6=98=BE=E7=A4=BA?= =?UTF-8?q?=20organize=20=E6=95=B4=E7=90=86=E5=9B=9E=E6=89=A7=EF=BC=8C?= =?UTF-8?q?=E6=94=B9=E6=8C=89=20run=5Ftype=20=E5=8C=BA=E5=88=86=E5=8F=A3?= =?UTF-8?q?=E5=BE=84=EF=BC=88#495=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit organize 上线后与巩固共用 dream_runs,状态卡直接取 runs[0]。一次 agent 主动 整理会写两行(dryRun 报告 + apply 回执),两行都排在真巩固之前,于是标题写着 「最近巩固」的卡显示的是整理的时间与状态。 走 issue 给的标注路线而不是过滤: - 后端 /dream-status 新增顶层 lastConsolidation(excludeRunType: "organize" 后 取一条),runs 仍是全类型 5 条——审计面是证据,不因显示面积省略; - 卡面取 lastConsolidation;只有 organize 时照实说「尚未运行」,并加脚注点名 那次整理的时间; - KPI 带下方「上一轮跳过什么」同源改读 lastConsolidation(organize 回执也带 skipped,但它的跳过不是巩固决策)。 listDreamRuns 的 excludeRunType 是**下推**条件而不是调用方后置过滤:过滤放在 limit 之后就晚了,5 个名额已被 organize 占掉,再筛只能得到空。取值走 DREAM_RUN_TYPES 白名单,未知值按不过滤处理,外部字符串不进 SQL 形状。 不加配置开关:这是显示口径的更正,不是可选功能。 回归测试 +5(api ×2、client ×1、store ×1;另原有用例补一行断言); 变异自证四例。 --- README.md | 6 ++-- dsh-mneme/CHANGELOG.md | 2 ++ dsh-mneme/README.md | 6 ++-- dsh-mneme/lib/api.js | 14 +++++++-- dsh-mneme/lib/client.js | 35 +++++++++++++++++++---- dsh-mneme/lib/store.js | 15 ++++++++-- dsh-mneme/src/api.js | 14 +++++++-- dsh-mneme/src/store.js | 15 ++++++++-- dsh-mneme/test/api.test.js | 54 +++++++++++++++++++++++++++++++++++ dsh-mneme/test/client.test.js | 33 +++++++++++++++++++++ dsh-mneme/test/store.test.js | 27 ++++++++++++++++++ 11 files changed, 199 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index fd74a305..143a06d8 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ license CI node - tests + tests coverage Awesome

@@ -270,7 +270,7 @@ dsh-mneme-serve # 默认 ~/.dsh/memory + 127.0.0.1:8790 ```bash cd dsh-mneme && npm install -npm test # 2623 个测试 +npm test # 2638 个测试 npm run stress # 三轴线压测 npm run sync # src → lib 同步 ``` @@ -550,7 +550,7 @@ Community projects that wire mneme into more platforms — not part of this repo ```bash cd dsh-mneme && npm install -npm test # 2623 tests +npm test # 2638 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 c4fa262c..381424f0 100644 --- a/dsh-mneme/CHANGELOG.md +++ b/dsh-mneme/CHANGELOG.md @@ -34,6 +34,8 @@ ## 🎨 面板 +- **面板「最近巩固」卡显示的可能是一次「整理」回执:organize 上线后它与巩固共用 `dream_runs`,而状态卡直接取 `runs[0]`(#495,报告由 @modusensus 提供)**:`/dream-status` 的最近 5 条 run 不分 `run_type`,一次 agent 主动整理会写两行(dryRun 报告 + apply 回执,后者 `outcome.dry_run_id` 指回报告),两行都排在真巩固之前——于是这张标题写着「最近巩固」的卡显示的是整理的时间与状态。issue 给了两条路,选了**标注**:`runs` 仍是全类型 5 条(审计面是证据,不因显示面积省略),另加顶层 `lastConsolidation`(`excludeRunType: "organize"` 后取一条)供卡面取数;跑过但只有 organize 时卡面照实说「尚未运行」,并加一句脚注点名那次整理的时间,不把它抹掉也不拿它冒充巩固。**为什么不是面板侧过滤 `runs`**:一次整理写两行,5 个名额被挤空时 `runs` 里可能一条巩固都没有,那时候「过滤后有什么」本身就是空的;而后端多给一个键,旧面板读不懂也不受影响。**同源修正**:KPI 带下方那块「上一轮跳过什么」原先也读 `lastRun`,而 organize 回执同样带 `skipped`——它的跳过不是巩固决策(例如「与既有记忆重复」),混进去会让人以为巩固那一整类动作被拦;改读 `lastConsolidation`。`TokenFlowSection` 与 `DreamStatusCards` 两处都对新键缺席的旧服务端留了退路(退回 `lastRun`,但退回时仍拒绝把 organize 行当巩固)。**不加配置开关**:这是显示口径的更正,不是可选功能。回归测试 +5(`test/api.test.js`:混排时取到真巩固轮且两路共用同一份投影 / 只有 organize 时 `lastConsolidation` 为 null;`test/client.test.js`:卡面读新键且退回分支排除 organize / KPI 带改读巩固轮;`test/store.test.js`:`excludeRunType` 是**下推**条件——在 `LIMIT` 之前生效,白名单外的值不改变查询)。变异自证四例(去掉 `excludeRunType` 两条红、`excluded` 恒为 null 时 store 用例红、卡面退回 `state.lastRun` 红、KPI 退回 `d.lastRun` 红)。 + - **481–519px 是一段两不管的中间带:480 主断点之上、两栏又排不下,卡片横向溢出 2–11px 被外层 `overflow:hidden` 直接切掉(承接 #542,实测发现)**:#542 把 `minmax(252px,1fr)` 换成 `minmax(min(252px,100%),1fr)`,但那两条**只写在 `@container (max-width:480px)` 里**,而基础规则 `lib/client.js` 的 `.mneme-cards` 仍是裸 `minmax(252px,1fr)`、`.mneme-attrgrid` 仍是裸 `minmax(200px,1fr)`。于是 481px 一开始断点失效、左栏回到 216px,卡片被要求至少 252px——`Edge 156` 实测:记忆页 481/490/500/510 各有 1–2 条 `SCROLLX`(`.mneme-cards` 300 > 可用 265),实体页 detail 只剩 257–276px 而属性网格仍要 200px。**兜底必须落在基础规则上**:两处 `min()` 下限上移到基础规则(它们的正确作用域本来就是「所有宽度」,481–519px 只是其中最窄的一段),480 档里的重复声明一并删掉。修完 320–900px 全档 24 个页面 ovf=0,卡片宽 320→300 / 481→217 / 520→256 / 900→287,属性网格 481→209 / 900→628。**一处刻意的取舍**:我一度给基础规则加 `max-height:min(440px,52vh)` 想把 481–519px 那段 440px 高的图谱压下来,实测确实压到 353px——但 `52vh` 在常见窗口高度(≈680px)下对**桌面也生效**,等于顺手改了大屏观感,已回退。基础规则不带 `max-height`,限高仍只留在 480 档(`min(320px,52vh)`);**代价是 481–519px 中间带的图谱仍是 440px 高,这个中间带高度问题刻意不修**。**回归测试改锁位置**:原来那两条 `body.includes("minmax(min(252px,100%),1fr)")` 锚在 480 档 body 上,现在改成作用在「剥掉所有嵌套块之后的顶层规则」——新增一个 `topLevel` 切片器(遇到 `@` 就按括号配对跳过后整块丢弃),并断言切片长度 > 10000 防止切片器自己坏掉导致假绿;锁两件事:两处基础 `minmax(min(…,100%),1fr)`。变异自证(把基础 minmax 退回裸 252 → 红;恢复后 85/85)。全量 `npm test` 2609 / 2608 pass / 0 fail / 1 skipped。**已知未覆盖**:仍无真机(`adb devices` 为空,需要设备接上并重启宿主让插件重新探测 adb),宽度结论来自 Edge headless + 真实 CSS 的静态测量,交互(拖拽、滚动链、软键盘)未经真机验证。 - **面板此前根本没有移动端断点:320–430px 的手机上,记忆页与实体页的两栏骨架把主区挤成一条缝,导航项还会压到「⋯」按钮底下**:整份 `lib/client.js` 只有 8 条 `@container` 规则、0 条 `@media`,而且这 8 条全是「把数字改小」式收窄——记忆页左栏 216px + 卡片最小 252px + 沟槽 48px = 面板至少 517px 才放得下,实体页 rail 224px + 属性网格 200px + 48px = 至少 473px,而目标上沿只有 430px。**收窄数字永远追不上,必须动骨架**:新增 `@container (max-width: 480px)` 主断点,把 `.mneme-xmain` 与 `.mneme-ent` 从 `flex-direction:row` 翻成 `column`,左栏 / rail 改成满宽 + 限高可滚(`max-height:min(42vh,300px)` / `min(40vh,280px)` + `overscroll-behavior:contain`,滚到底不漏给页面),卡片与属性网格的 `minmax(252px,1fr)` 改成 `minmax(min(252px,100%),1fr)`(**`min()` 是防溢出的关键:320px 下 `minmax(252px,1fr)` 仍会把轨道撑到 252px 之外**),抽屉从 `width:min(432px,52%)` 改 `100%`(52% 相对的是 `.mneme-xmain`,320 下只剩 166px;抽屉自带 ✕,满宽不丢关闭入口),顶部导航改左对齐 + 右侧留 52px 给 `position:absolute` 的 `⋯`,并新增 `@container (max-width: 360px)` 兜底档(隐藏 14px 图标、导航横向可滚、卡片单列)。**三处刻意的取舍**:① **`@container` 里一个颜色都不写**——`test/contrast.test.js` 的提取器用 `/^([^{]+)\{([^}]*)\}$/` 只匹配单层花括号,`@container` / `@media` 的双层块整串被跳过,在里面写 `color` 就绕过了 #178 的 AA 锁;新增的回归测试会把这条纪律变成闸门(遍历所有双层块,出现 `color` / `fill` / `stroke` / `background` 即失败)。② **overlay 外框只能走 `@media`**:`.mneme-overlay` 不是 `.mneme-x` 的后代(`container-type:inline-size` 在 `.mneme-x` 上),容器查询够不到它,所以它的 `calc(100vw - 16px)` / `calc(100dvh - 16px)` 放在 `@media (max-width:640px)` 里。③ **触屏删除钮只加常显、不动配色**:`.mneme-set-del{opacity:0}` 靠 `:hover` / `:focus-within` 恢复,触屏永远没有 hover——改法是 `@media (hover:none){.mneme-set-del{opacity:1}}`,参照同文件 `.mneme-persona-del` 的常显范式;#457(内联 `opacity:1` 覆写)与 #459(连带点亮规则区真删除钮)两次回滚的教训就是**别顺手把配色也改了**。顺带给 `.mneme-set-del` / `.mneme-footbtn` 补 `min-height:24px`(WCAG 2.5.8 AA 的触控下限)。图谱另修三症:内联 `height:440` 被 `max-height:min(320px,52vh)` 压住(CSS 2.1 §10.7:`max-height` 优先于 `height`)、`touch-action:none` 改 `pan-y` 让页面能纵向滚、`.mneme-glabel` 从 11px 提到 14px 抵消 SVG 缩放(380 用户单位压到 288px 时缩放比 0.758,11px 视觉只剩 8.3px)。弹层固定宽同样越界:`.mneme-sharedialog{width:340px}` 在 320 屏上是硬矛盾,三条都改 `min(原宽,calc(100cqw - 32px))`。**回归测试 +1**(`test/client.test.js` 的 `mobile: the narrow-container breakpoints reflow the two-column skeletons instead of shrinking numbers`):锁两档断点存在、锁骨架翻转与 `minmax(min(...))`、锁抽屉满宽与弹层 `cqw` 上界、锁 overlay 走 `@media`、**锁双层块内不得出现颜色声明**、锁 `hover:none` 的常显。变异自证三例(把 `@container` 换成 `@media`、在 `.mneme-ent` 里插 `color:red`、把 `minmax(min(252px,100%),1fr)` 改回 `minmax(252px,1fr)`)逐条变红,恢复后 90/90 全绿。**已知未覆盖**:实际视觉未在真机或 Playwright 上验证过(本机无 playwright),全部结论是 CSS 级推演;`100vh` 未换成 `dvh` 的位置仍有 3 处(`2108` / `2493` / `2596`),`safe-area-inset-*` 全文件 0 处——这三项留作后续。 diff --git a/dsh-mneme/README.md b/dsh-mneme/README.md index 398616f8..e6821cd5 100644 --- a/dsh-mneme/README.md +++ b/dsh-mneme/README.md @@ -5,7 +5,7 @@ [![npm version](https://img.shields.io/npm/v/@modusensus/dsh-mneme?color=blue&label=npm)](https://www.npmjs.com/package/@modusensus/dsh-mneme) [![license](https://img.shields.io/badge/license-MIT-green)](LICENSE) [![Awesome](https://awesome-dsh-plugin.com/badge.svg)](https://github.com/awesome-dsh-plugin/awesome-dsh-plugin) -[![tests](https://img.shields.io/badge/tests-2623%20passed-success)](https://github.com/slow-stack/mneme) +[![tests](https://img.shields.io/badge/tests-2638%20passed-success)](https://github.com/slow-stack/mneme) [![CI](https://img.shields.io/github/actions/workflow/status/slow-stack/mneme/ci.yml)](https://github.com/slow-stack/mneme/actions) [![node](https://img.shields.io/badge/node-22%2B-blue)](https://nodejs.org) [![npm downloads](https://img.shields.io/npm/d18m/@modusensus/dsh-mneme.svg?color=blue&label=downloads)](https://www.npmjs.com/package/@modusensus/dsh-mneme) @@ -719,7 +719,7 @@ src/ ├── api.js # HTTP 路由(Web 面板数据通道,含 /conflicts 冲突队列) └── index.js # 插件接线 lib/ # src 的同步分发产物(npm run sync,prepack 钩子在发布时自动跑;一致性由 scripts/check-sync.js 在 CI 与 release.yml 校验——它只查不同步;唯一手写例外 lib/client.js——Web 面板 bundle,sync 不覆盖) -test/ # 2623 个 node:test 测试(审计与三轴线压测不变量;src↔lib 一致性由 scripts/check-sync.js 发布闸门校验) +test/ # 2638 个 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 运行时清单 ``` @@ -728,7 +728,7 @@ scripts/ # e2e-dsh.js 端到端演示 · stress-dsh.js 三轴线压 ```bash cd dsh-mneme npm install # 安装 peer 依赖(以 devDependencies 形式,用于本地测试) -npm test # 运行 2623 个测试 +npm test # 运行 2638 个测试 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 d3a75f1f..ae15bb95 100644 --- a/dsh-mneme/lib/api.js +++ b/dsh-mneme/lib/api.js @@ -1769,7 +1769,7 @@ export function createApi(ctx, service, settings, commands, embedder, semantic = policy: "open", handler(req, res) { try { - const runs = (service.listDreamRuns?.({ limit: 5 }) ?? []).map((r) => ({ + const toRun = (r) => ({ created_at: r.created_at, status: r.status, provider: r.provider ?? null, @@ -1787,7 +1787,16 @@ export function createApi(ctx, service, settings, commands, embedder, semantic = // 被发现。这里只回聚合(原因 + 动作 + 条数),明细仍留在库里。 skippedCount: Array.isArray(r.skipped) ? r.skipped.length : 0, skippedReasons: summarizeSkipped(r.skipped) - })); + }); + // Issue #495:organize 上线后 runs 会混进 run_type="organize",而面板那张 + // 卡的标题是「最近巩固」——一次 agent 主动整理就会把它顶成整理回执。 + // 两条路里选了「标注」而不是过滤:runs 仍是全类型 5 条(审计面是证据, + // 不因显示面积省略),另给一个只含 auto/sleep 的 lastConsolidation 供那张 + // 卡取数;面板再用 lastRun 是否为 organize 决定要不要标一句脚注。 + // 这里不能只靠 runs[0] 的 run_type 过滤——一次整理写两行(dryRun 报告 + + // apply 回执),挤空 5 个名额时 runs 里可能一条巩固都没有。 + const runs = (service.listDreamRuns?.({ limit: 5 }) ?? []).map(toRun); + const lastConsolidation = service.listDreamRuns?.({ limit: 1, excludeRunType: "organize" })?.[0]; const pendingConflicts = service.countConflictPending?.() ?? 0; const ids = new Set(); for (const row of service.listConflictPending?.({ limit: 200, includeResolved: false }) ?? []) { @@ -1798,6 +1807,7 @@ export function createApi(ctx, service, settings, commands, embedder, semantic = } sendJson(res, 200, { lastRun: runs[0] ?? null, + lastConsolidation: lastConsolidation ? toRun(lastConsolidation) : null, runs, pendingConflicts, pendingMemoryIds: [...ids].slice(0, 200) diff --git a/dsh-mneme/lib/client.js b/dsh-mneme/lib/client.js index 3bbd4dba..1d18dd57 100644 --- a/dsh-mneme/lib/client.js +++ b/dsh-mneme/lib/client.js @@ -899,6 +899,7 @@ window.__ModuleLoader__.load({ "memory.explorer.detail.starLabel": "设为 {n} 星", "memory.status.dream": "最近巩固", "memory.status.dreamNever": "尚未运行", + "memory.status.dreamOrganizeOnly": "最近一次后台运行是 agent 主动整理({time}),不计入巩固", "memory.status.conflicts": "待确认冲突", "memory.status.conflictsHint": "冻结的矛盾记忆,等待人工确认", "memory.status.skippedTitle": "本轮跳过 {n} 条决定", @@ -1661,6 +1662,7 @@ window.__ModuleLoader__.load({ "memory.explorer.detail.starLabel": "Set importance to {n} stars", "memory.status.dream": "Last consolidation", "memory.status.dreamNever": "Not yet run", + "memory.status.dreamOrganizeOnly": "The last background run was an agent-initiated organize ({time}), not a consolidation", "memory.status.conflicts": "Pending conflicts", "memory.status.conflictsHint": "Frozen contradictory memories awaiting confirmation", "memory.status.skippedTitle": "{n} decisions skipped this run", @@ -5556,11 +5558,16 @@ window.__ModuleLoader__.load({ .then((res) => { if (!res.ok) throw new Error("http"); return res.json(); }) .then((d) => { if (cancelled) return; + // #495:这块 KPI 说的是「上一轮巩固跳过了什么」,所以要的是**巩固轮**。 + // organize 回执同样带 skipped,但它的跳过不是巩固决策(例如「与既有 + // 记忆重复」),混进来会让人以为巩固那一整类动作被拦。后端 lastConsolidation + // 只含 auto/sleep;旧服务端没有该键时退回 lastRun,保持原行为。 + const last = (d && (d.lastConsolidation || d.lastRun)) || null; setConf({ loading: false, error: false, pending: Number((d && d.pendingConflicts) ?? 0), - skipped: Array.isArray(d && d.lastRun && d.lastRun.skippedReasons) ? d.lastRun.skippedReasons : [] + skipped: Array.isArray(last && last.skippedReasons) ? last.skippedReasons : [] }); }) .catch(() => { if (!cancelled) setConf({ loading: false, error: true, pending: 0, skipped: [] }); }); @@ -5811,7 +5818,7 @@ window.__ModuleLoader__.load({ // showConflicts=false 用于 2026-10 改造后的降级折叠区:待确认冲突已升入 // KPI 带,这里不再重复渲染冲突卡,只留最近运行卡。 function DreamStatusCards({ t, onGotoQueue, showConflicts = true }) { - const [state, setState] = useState({ loading: true, error: false, lastRun: null, pending: 0 }); + const [state, setState] = useState({ loading: true, error: false, lastRun: null, lastConsolidation: null, pending: 0 }); useEffect(() => { let cancelled = false; const loadOnce = () => { @@ -5823,10 +5830,11 @@ window.__ModuleLoader__.load({ loading: false, error: false, lastRun: (d && d.lastRun) || null, + lastConsolidation: (d && d.lastConsolidation) || null, pending: Number((d && d.pendingConflicts) ?? 0) }); }) - .catch(() => { if (!cancelled) setState({ loading: false, error: true, lastRun: null, pending: 0 }); }); + .catch(() => { if (!cancelled) setState({ loading: false, error: true, lastRun: null, lastConsolidation: null, pending: 0 }); }); }; loadOnce(); // #295 评审:裁决完成后队列会广播 mneme:conflicts-changed,这里重取 @@ -5834,11 +5842,26 @@ window.__ModuleLoader__.load({ window.addEventListener("mneme:conflicts-changed", loadOnce); return () => { cancelled = true; window.removeEventListener("mneme:conflicts-changed", loadOnce); }; }, []); - const run = state.lastRun; + // #495:这张卡的标题是「最近巩固」,取数就该是巩固轮,而不是 runs[0]。 + // organize 与巩固共用 dream_runs,一次 agent 主动整理会写两行(dryRun 报告 + + // apply 回执),把它顶到首位后这张卡就变成「整理回执」了。 + // 卡面数字只认巩固轮:lastConsolidation 由后端排除 organize 后取一条。旧服务端 + // 没有这个键时退回 lastRun,但退回的那条若是 organize 就仍然不算「最近巩固」。 + const organizedOnly = state.lastRun && state.lastRun.run_type === "organize" ? state.lastRun : null; + const run = state.lastConsolidation + || (state.lastRun && !organizedOnly ? state.lastRun : null); const num = run ? formatRelativeTime(run.created_at, t) : t("memory.status.dreamNever"); + // issue 给的两条路里选「标注」:最近一次后台运行是 organize 时,用脚注点名它, + // 而不是把它从审计面抹掉。它没有挤掉巩固数(卡面仍是巩固轮),只是补一句说明 + // ——否则读者会以为整理就是那次巩固。 + const organizeFoot = organizedOnly && !run + ? t("memory.status.dreamOrganizeOnly").replace("{time}", formatRelativeTime(organizedOnly.created_at, t)) + : null; const cap = run ? `${run.status || "—"}${run.model ? ` · ${run.model}` : ""}` - : t("memory.settings.mode.offList"); + // 只有 organize 跑过时不能沿用 offList 那句「已关闭:巩固…」——autoDream 可能 + // 开着,只是还没轮到。这时只留脚注说明最近那一条是什么。 + : organizeFoot ? null : t("memory.settings.mode.offList"); // #178:pending>0 时冲突卡可激活(role=button + 可聚焦 + 回车/空格), // 激活直达队列——StatusPanel 把 onGotoQueue 传进来(一步 prop,无线程)。 const pending = state.pending > 0; @@ -5846,7 +5869,7 @@ window.__ModuleLoader__.load({ // #521 的跳过明细不在这里渲染:它已提到 TokenFlowSection 的 KPI 带下方, // 首屏可见。这张卡在折叠区里,展开后再重复一遍只会让人以为有两批跳过。 return h(react.Fragment, null, - h(StatusCard, { t, title: t("memory.status.dream"), loading: state.loading, error: state.error, num, cap }), + h(StatusCard, { t, title: t("memory.status.dream"), loading: state.loading, error: state.error, num, cap, foot: organizeFoot }), showConflicts ? h("div", { role: pending ? "button" : undefined, diff --git a/dsh-mneme/lib/store.js b/dsh-mneme/lib/store.js index 608573ba..059ef4df 100644 --- a/dsh-mneme/lib/store.js +++ b/dsh-mneme/lib/store.js @@ -461,6 +461,10 @@ export const TYPES = new Set(["preference", "project", "decision", "history", "s // 截断上限(continuity.js)是两件事。 const MAX_CONTINUITY_PENDING = 200; +// dream_runs.run_type 的取值域(schema 注释同款)。既是白名单也是下推条件的护栏: +// listDreamRuns 的 excludeRunType 会拼进 SQL 的 != 比较,非白名单值一律忽略。 +const DREAM_RUN_TYPES = ["auto", "sleep", "organize"]; + // Epistemic status: what kind of evidence a memory rests on. Defaults to // 'subjective' so legacy rows (and rows without any signal) stay compatible. const EPISTEMIC_STATUSES = new Set(["observation", "subjective", "inferred"]); @@ -2246,11 +2250,16 @@ export function createStore(path) { return toDreamRun(row); } - function listDreamRuns({ limit = 50, offset = 0 } = {}) { + // #495:excludeRunType 是可选下推条件,默认不过滤——审计面(面板的最近运行、 + // 回放脚本、各测试的 `listDreamRuns()` 无参调用)本来就该看全量,过滤只在 + // 「最近一次真·巩固」这种需要排除 agent 主动整理的场合由调用方显式指定。 + // 值走白名单:这个参数会拼进 SQL 的 != 条件,不接受任意字符串。 + function listDreamRuns({ limit = 50, offset = 0, excludeRunType = null } = {}) { const { limit: lim, offset: off } = sanitizePage(limit, offset, 50); + const excluded = DREAM_RUN_TYPES.includes(excludeRunType) ? excludeRunType : null; const rows = db.prepare( - "SELECT * FROM dream_runs ORDER BY created_at DESC, id LIMIT ? OFFSET ?" - ).all(lim, off); + "SELECT * FROM dream_runs WHERE (? IS NULL OR run_type != ?) ORDER BY created_at DESC, id LIMIT ? OFFSET ?" + ).all(excluded, excluded, lim, off); return rows.map(toDreamRun); } diff --git a/dsh-mneme/src/api.js b/dsh-mneme/src/api.js index d3a75f1f..ae15bb95 100644 --- a/dsh-mneme/src/api.js +++ b/dsh-mneme/src/api.js @@ -1769,7 +1769,7 @@ export function createApi(ctx, service, settings, commands, embedder, semantic = policy: "open", handler(req, res) { try { - const runs = (service.listDreamRuns?.({ limit: 5 }) ?? []).map((r) => ({ + const toRun = (r) => ({ created_at: r.created_at, status: r.status, provider: r.provider ?? null, @@ -1787,7 +1787,16 @@ export function createApi(ctx, service, settings, commands, embedder, semantic = // 被发现。这里只回聚合(原因 + 动作 + 条数),明细仍留在库里。 skippedCount: Array.isArray(r.skipped) ? r.skipped.length : 0, skippedReasons: summarizeSkipped(r.skipped) - })); + }); + // Issue #495:organize 上线后 runs 会混进 run_type="organize",而面板那张 + // 卡的标题是「最近巩固」——一次 agent 主动整理就会把它顶成整理回执。 + // 两条路里选了「标注」而不是过滤:runs 仍是全类型 5 条(审计面是证据, + // 不因显示面积省略),另给一个只含 auto/sleep 的 lastConsolidation 供那张 + // 卡取数;面板再用 lastRun 是否为 organize 决定要不要标一句脚注。 + // 这里不能只靠 runs[0] 的 run_type 过滤——一次整理写两行(dryRun 报告 + + // apply 回执),挤空 5 个名额时 runs 里可能一条巩固都没有。 + const runs = (service.listDreamRuns?.({ limit: 5 }) ?? []).map(toRun); + const lastConsolidation = service.listDreamRuns?.({ limit: 1, excludeRunType: "organize" })?.[0]; const pendingConflicts = service.countConflictPending?.() ?? 0; const ids = new Set(); for (const row of service.listConflictPending?.({ limit: 200, includeResolved: false }) ?? []) { @@ -1798,6 +1807,7 @@ export function createApi(ctx, service, settings, commands, embedder, semantic = } sendJson(res, 200, { lastRun: runs[0] ?? null, + lastConsolidation: lastConsolidation ? toRun(lastConsolidation) : null, runs, pendingConflicts, pendingMemoryIds: [...ids].slice(0, 200) diff --git a/dsh-mneme/src/store.js b/dsh-mneme/src/store.js index 608573ba..059ef4df 100644 --- a/dsh-mneme/src/store.js +++ b/dsh-mneme/src/store.js @@ -461,6 +461,10 @@ export const TYPES = new Set(["preference", "project", "decision", "history", "s // 截断上限(continuity.js)是两件事。 const MAX_CONTINUITY_PENDING = 200; +// dream_runs.run_type 的取值域(schema 注释同款)。既是白名单也是下推条件的护栏: +// listDreamRuns 的 excludeRunType 会拼进 SQL 的 != 比较,非白名单值一律忽略。 +const DREAM_RUN_TYPES = ["auto", "sleep", "organize"]; + // Epistemic status: what kind of evidence a memory rests on. Defaults to // 'subjective' so legacy rows (and rows without any signal) stay compatible. const EPISTEMIC_STATUSES = new Set(["observation", "subjective", "inferred"]); @@ -2246,11 +2250,16 @@ export function createStore(path) { return toDreamRun(row); } - function listDreamRuns({ limit = 50, offset = 0 } = {}) { + // #495:excludeRunType 是可选下推条件,默认不过滤——审计面(面板的最近运行、 + // 回放脚本、各测试的 `listDreamRuns()` 无参调用)本来就该看全量,过滤只在 + // 「最近一次真·巩固」这种需要排除 agent 主动整理的场合由调用方显式指定。 + // 值走白名单:这个参数会拼进 SQL 的 != 条件,不接受任意字符串。 + function listDreamRuns({ limit = 50, offset = 0, excludeRunType = null } = {}) { const { limit: lim, offset: off } = sanitizePage(limit, offset, 50); + const excluded = DREAM_RUN_TYPES.includes(excludeRunType) ? excludeRunType : null; const rows = db.prepare( - "SELECT * FROM dream_runs ORDER BY created_at DESC, id LIMIT ? OFFSET ?" - ).all(lim, off); + "SELECT * FROM dream_runs WHERE (? IS NULL OR run_type != ?) ORDER BY created_at DESC, id LIMIT ? OFFSET ?" + ).all(excluded, excluded, lim, off); return rows.map(toDreamRun); } diff --git a/dsh-mneme/test/api.test.js b/dsh-mneme/test/api.test.js index 97c9f525..8e0060ee 100644 --- a/dsh-mneme/test/api.test.js +++ b/dsh-mneme/test/api.test.js @@ -1428,6 +1428,7 @@ test("GET /api/dsh-mneme/dream-status returns runs and pending conflict ids", as assert.equal(data.runs.length, 2); assert.equal(data.runs[0].created_at, "2026-01-02T00:00:00.000Z", "created_at DESC"); assert.deepEqual(data.lastRun, data.runs[0]); + assert.deepEqual(data.lastConsolidation, data.runs[0], "no organize row => same as lastRun"); // #521:skippedCount / skippedReasons 是本轮新增的两个字段(面板按归一化原因 // 显示跳过明细)。这行锁的是 dream-status 的响应形状,加字段必须同步改这里。 assert.deepEqual(Object.keys(data.lastRun).sort(), ["created_at", "demotion", "error", "model", "provider", "run_type", "skippedCount", "skippedReasons", "status"]); @@ -1447,6 +1448,59 @@ test("GET /api/dsh-mneme/dream-status returns runs and pending conflict ids", as assert.deepEqual(afterData.pendingMemoryIds, []); }); +// Issue #495:organize 与巩固共用 dream_runs,一次 agent 主动整理写两行(dryRun +// 报告 + apply 回执)。面板那张卡的标题是「最近巩固」,若继续取 runs[0],一次整理 +// 就会把它顶成整理回执——测的是「卡面数字取的是巩固轮」,不是「runs 里没有 organize」。 +test("GET /api/dsh-mneme/dream-status keeps organize rows out of lastConsolidation (issue #495)", async () => { + const { routes, service } = setup(); + service.saveDreamRun({ created_at: "2026-01-01T00:00:00.000Z", status: "ok", snapshot_hash: "h1", input_count: 2, receipt: "r1" }); + // 一次整理的两行:报告在前、回执在后,都晚于那次巩固。 + service.saveDreamRun({ + created_at: "2026-01-02T00:00:00.000Z", status: "ok", run_type: "organize", + snapshot_hash: "h2", input_count: 0, receipt: "r2", + outcome: { dryRun: true, byId: {} } + }); + service.saveDreamRun({ + created_at: "2026-01-02T00:00:01.000Z", status: "ok", run_type: "organize", + snapshot_hash: "h3", input_count: 0, receipt: "r3", + outcome: { dry_run_id: "dry-1", byId: {} } + }); + + const route = routes.find((r) => r.path === "/api/dsh-mneme/dream-status"); + const res = new FakeRes(); + await route.handler(req("/api/dsh-mneme/dream-status"), res); + const data = JSON.parse(res.body); + + // 审计面不缩:runs 仍是全类型 5 条,organize 照旧排在最前。 + assert.equal(data.runs.length, 3, "runs keeps every run type — the audit surface is not filtered"); + assert.equal(data.runs[0].run_type, "organize"); + assert.equal(data.lastRun.run_type, "organize", "lastRun still means the newest row of any type"); + // 卡面取数:跳过 organize,回到那次真巩固。 + assert.ok(data.lastConsolidation, "lastConsolidation must not be empty when a consolidation exists"); + assert.equal(data.lastConsolidation.run_type, "auto"); + assert.equal(data.lastConsolidation.created_at, "2026-01-01T00:00:00.000Z"); + // 两条路径共用同一份投影,字段形状必须一致(否则面板得写两套渲染)。 + assert.deepEqual(Object.keys(data.lastConsolidation).sort(), Object.keys(data.lastRun).sort()); +}); + +// 只有 organize 跑过时:宁可留空让面板说「尚未运行」并加脚注,也不能拿整理回执 +// 冒充巩固——那是这条 issue 要治的那个错。 +test("dream-status: organize-only history leaves lastConsolidation null, never the organize row (issue #495)", async () => { + const { routes, service } = setup(); + service.saveDreamRun({ + created_at: "2026-01-02T00:00:00.000Z", status: "ok", run_type: "organize", + snapshot_hash: "h1", input_count: 0, receipt: "r1", + outcome: { dry_run_id: "dry-1", byId: {} } + }); + const route = routes.find((r) => r.path === "/api/dsh-mneme/dream-status"); + const res = new FakeRes(); + await route.handler(req("/api/dsh-mneme/dream-status"), res); + const data = JSON.parse(res.body); + assert.equal(data.lastRun.run_type, "organize"); + assert.equal(data.lastConsolidation, null, "an organize receipt must never be reported as the last consolidation"); + assert.equal(data.runs.length, 1); +}); + // v0.8.0 冲突集中处理:GET /conflicts 队列联表 + POST /conflicts/resolve 人工确认。 test("GET /api/dsh-mneme/conflicts joins both sides of unresolved conflicts", async () => { const { routes, service } = setup(); diff --git a/dsh-mneme/test/client.test.js b/dsh-mneme/test/client.test.js index 081490f8..9b8dc605 100644 --- a/dsh-mneme/test/client.test.js +++ b/dsh-mneme/test/client.test.js @@ -2312,6 +2312,39 @@ test("skipped reasons are visible without expanding the collapsed detail section assert.ok(!dreamCards.includes("skippedReasons"), "the collapsed card no longer needs its own copy of the skip buckets"); }); + +// #495:organize 与巩固共用 dream_runs,一次 agent 主动整理写两行。这张卡的标题是 +// 「最近巩固」,若面板继续拿 lastRun 取数,一次整理就把卡面变成整理回执——正是 +// issue 报的那个现象。锁三件事:① 卡面读 lastConsolidation;② 退回 lastRun 时必须 +// 先排除 organize(旧服务端没有新键,不能拿 organize 行冒充巩固);③ KPI 带那块 +// 「上一轮跳过什么」同样改读巩固轮,organize 的 skipped 不是巩固决策。 +test("dream status card reads the consolidation run, not whatever ran last (issue #495)", () => { + const dreamCards = clientSource.slice( + clientSource.indexOf("function DreamStatusCards("), + clientSource.indexOf("function InjectStatusCard(") + ); + assert.ok(dreamCards.includes("state.lastConsolidation"), + "the card must consume lastConsolidation from /dream-status"); + assert.ok(dreamCards.includes('state.lastRun.run_type === "organize"'), + "falling back to lastRun must still refuse an organize row — an old server may omit the new key"); + assert.ok(/const run = state\.lastConsolidation\s*\n?\s*\|\| \(state\.lastRun && !organizedOnly/.test(dreamCards) + || dreamCards.includes("|| (state.lastRun && !organizedOnly"), + "the fallback branch must skip the row it just identified as organize"); + // 脚注:最近一次后台运行确实是 organize 时点名它,而不是静默把它当巩固。 + assert.ok(dreamCards.includes("memory.status.dreamOrganizeOnly"), + "an organize-only history must be named in a footnote, not shown as a consolidation"); + // ① 的取数还必须晚于本卡自己那次 fetch,不能只是把键名写进注释里。 + const fetchIdx = dreamCards.indexOf('apiFetch("/api/dsh-mneme/dream-status")'); + const useIdx = dreamCards.indexOf("state.lastConsolidation"); + assert.ok(fetchIdx >= 0 && useIdx > fetchIdx, "the key must be read from the /dream-status payload"); + + const tokenFlow = clientSource.slice( + clientSource.indexOf("function TokenFlowSection("), + clientSource.indexOf("function DreamStatusCards(") + ); + assert.ok(tokenFlow.includes("d.lastConsolidation || d.lastRun"), + "the skip-reason KPI belongs to consolidation; organize skipped buckets would read as blocked consolidation"); +}); // 设置页目录(#518):用户要的是「左侧一个分类,直接跳到某一节」。这里锁五件事: // ① 目录由 tocGroups 一张表生成,五项分组标题全部走 groupHead(key) 渲染——两处各写 // 一遍清单是这类索引最典型的腐烂方式,而腐烂后的目录会把人领到不存在的锚点; diff --git a/dsh-mneme/test/store.test.js b/dsh-mneme/test/store.test.js index 4f63a1a1..494b7a95 100644 --- a/dsh-mneme/test/store.test.js +++ b/dsh-mneme/test/store.test.js @@ -527,3 +527,30 @@ test("Issue #128: needsEmbedding skips archived and forgotten rows", () => { assert.deepEqual(ids, [active.id], "only the active row is a backfill candidate"); store.close(); }); + +// Issue #495:listDreamRuns 的 excludeRunType 是**下推**条件而不是调用方后置过滤—— +// 后置过滤在 limit 生效之后就晚了:5 个名额已经被 organize 占掉,再筛只能得到空。 +// 三条一起钉:默认不过滤(审计面要看全量)、指定值真的排除、白名单之外的值不生效 +// (这个参数拼进 SQL 的 != 条件,任意外来字符串不能改变查询形状)。 +test("Issue #495: listDreamRuns excludes a run type by pushdown, and only accepts known types", () => { + const store = openMemory(); + store.saveDreamRun({ created_at: "2026-01-01T00:00:00.000Z", status: "ok", snapshot_hash: "h", input_count: 0, run_type: "auto", receipt: "r1" }); + store.saveDreamRun({ created_at: "2026-01-02T00:00:00.000Z", status: "ok", snapshot_hash: "h", input_count: 0, run_type: "organize", receipt: "r2" }); + store.saveDreamRun({ created_at: "2026-01-03T00:00:00.000Z", status: "ok", snapshot_hash: "h", input_count: 0, run_type: "organize", receipt: "r3" }); + + assert.equal(store.listDreamRuns().length, 3, "no filter by default — the audit surface keeps every run type"); + assert.equal(store.listDreamRuns({ limit: 1 })[0].run_type, "organize", "limit applies to the full set"); + + const onlyConsolidation = store.listDreamRuns({ limit: 1, excludeRunType: "organize" }); + assert.equal(onlyConsolidation.length, 1); + assert.equal(onlyConsolidation[0].run_type, "auto", "the pushdown must apply before LIMIT, not after"); + + // 白名单之外的值按「不过滤」处理,不静默返回空集。诚实说:这条断言钉得住 + // 「未知值 ⇒ 不过滤」这个可观测契约,钉不住白名单本身——把 includes 判断换成裸绑定, + // 四条断言仍然全绿(不存在的 run_type 本来也筛不掉任何行)。白名单的价值是纵深: + // 值来自调用方时只允许已知的三个字面量进 SQL 的 != 条件,查询形状不随外部字符串改变。 + assert.equal(store.listDreamRuns({ excludeRunType: "auto' OR 1=1 --" }).length, 3, + "a hostile value is bound as a parameter and filters nothing, never widens the query"); + assert.equal(store.listDreamRuns({ excludeRunType: "nope" }).length, 3); + store.close(); +});