@modusensus/dsh-mneme 0.9.1 → 0.9.2

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.md CHANGED
@@ -5,7 +5,7 @@
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-2173%20passed-success)](https://github.com/slow-stack/mneme)
8
+ [![tests](https://img.shields.io/badge/tests-2230%20passed-success)](https://github.com/slow-stack/mneme)
9
9
  [![CI](https://img.shields.io/github/actions/workflow/status/slow-stack/mneme/ci.yml)](https://github.com/slow-stack/mneme/actions)
10
10
  [![node](https://img.shields.io/badge/node-22%2B-blue)](https://nodejs.org)
11
11
  [![npm downloads](https://img.shields.io/npm/d18m/@modusensus/dsh-mneme.svg?color=blue&label=downloads)](https://www.npmjs.com/package/@modusensus/dsh-mneme)
@@ -164,7 +164,7 @@ dsh web
164
164
 
165
165
  ### 语义增强(Semantic)🧠
166
166
 
167
- v0.2 起新增**完全离线的语义记忆引擎**(本地模型 + 精排 + 聚类):
167
+ v0.2 起新增**可以完全离线运行的语义记忆引擎**(本地模型 + 精排 + 聚类):
168
168
 
169
169
  - **本地 Embedding**:三后端可选——ONNX(`Xenova/bge-small-zh-v1.5`,离线)/ Ollama / OpenAI 兼容,失败自动逐级降级,最差回退关键词搜索
170
170
  - **Rerank 精排**:`Xenova/bge-reranker-base` 对召回候选交叉编码精排,提升 Top-K 准确率
@@ -273,6 +273,7 @@ v0.3.0 起新增**记忆基因**层:从记忆里抽取**命名实体**、**带
273
273
  |------|------|
274
274
  | **v0.9.0**(2026-10-17) | M.O.(Modus Operandi · 手法)beta:工作流本体住 `<项目>/.mneme/pipelines/`、候选分层与升格判据、面板清单与图同屏(`actionPipelineEnabled` 默认关);设置面板折叠线挪到「新手之外」并按人话重写 22 个条目名(#427);安全与供应链加固(39 条路由声明式鉴权、OSV 有漏洞即失败、发布带 provenance) |
275
275
  | **v0.9.1**(2026-10-18) | M.O. 从「看得见」跑到「跑得出账」:执行窗口由 `/mo-run start` / `/mo-run stop` 显式划开、运行账落 `<项目>/.mneme/pipelines/<id>.runs.jsonl`(账本坏了与从没跑过严格分开)、归属只出候选、观察旁挂与脱节检出、步骤 `assert` / `optional`、R1/R2 提议;沉淀链从真实会话日志挖 draft 工作流(判据恒 `null`,答案只能由人补);人格定义页 v1 → v1.2(按 agent 寻址多卡,默认关);面板、向量与检查更新的一圈修复(#448 / #450 / #460);Release 正文改分层渲染 |
276
+ | **v0.9.2**(2026-10-19) | 让「看不见的失败」显形:功能开关的第三态「待重启」把「面板显示已改 ≠ 运行时已改」摆到界面上(#481);作用域级可逆闸门让一路记忆能被单独按下与松开、不必改配置重启(#471,默认关);巩固用的 `dreamMaxTokens` 默认值撞服务商上限、八天 180 次全失败,面板现在能改、超上限还会自动降级重试(#478);CLI 把 `--` 开头的值当新旗标的真实缺陷(一次 1/4096 概率的 CI 红揪出来的)、人格卡删除 × 永远不可见、自动蒸馏写入的行 100% 拿不到向量(#477) |
276
277
  | **v0.8.x** | 作用域隔离(agent / workspace 双维 + opt-in 硬过滤)、冲突队列人工裁决、生态化(stdio MCP server / 图召回轴 / 冷启动 / 注入截断) |
277
278
 
278
279
  ## 📦 安装
@@ -597,7 +598,7 @@ dsh-mneme-serve --embed off # 纯关键词 + BM
597
598
 
598
599
  ### MCP Server(任意 MCP 客户端接入)
599
600
 
600
- > Claude Code / Codex / Hermes / OpenCode / OpenClaw 等各客户端的最小挂载配置速查表见[根 README](../README.md#用在其他-ai-工具里mcp);本节是完整配置与安全说明。规划中的独立分发包 `mneme-memory` 落地后,挂载命令将保持兼容(详见仓库 Discussions #300)。
601
+ > Claude Code / Codex / Hermes / OpenCode / OpenClaw 等各客户端的最小挂载配置速查表见[根 README](../README.md#用在其他-ai-工具里mcp);本节是完整配置与安全说明。独立分发包 [`mneme-memory`](https://www.npmjs.com/package/mneme-memory)(bin `mneme-mcp`)已发布,与插件内置的 `dsh-mneme-mcp` 并存、挂载命令互相兼容。
601
602
 
602
603
  插件自带 stdio MCP server(`bin/dsh-mneme-mcp.mjs`,零依赖,随 npm 包发布,bin 名 `dsh-mneme-mcp`)。任何支持 Model Context Protocol 的客户端(Claude Code、Cursor 等)挂载后即可获得与 DSH 内一致的记忆工具六件套:`memory_save` / `memory_search` / `memory_list` / `memory_get` / `memory_update` / `memory_delete`——工具名、参数与去重合并、重要性等语义与 DSH 内工具对齐(测试锁漂移)。
603
604
 
@@ -674,7 +675,7 @@ src/
674
675
  ├── api.js # HTTP 路由(Web 面板数据通道,含 /conflicts 冲突队列)
675
676
  └── index.js # 插件接线
676
677
  lib/ # src 的同步分发产物(npm run sync,prepack 钩子在发布时自动跑;一致性由 scripts/check-sync.js 在 CI 与 release.yml 校验——它只查不同步;唯一手写例外 lib/client.js——Web 面板 bundle,sync 不覆盖)
677
- test/ # 2173 个 node:test 测试(审计与三轴线压测不变量;src↔lib 一致性由 scripts/check-sync.js 发布闸门校验)
678
+ test/ # 2230 个 node:test 测试(审计与三轴线压测不变量;src↔lib 一致性由 scripts/check-sync.js 发布闸门校验)
678
679
  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 运行时清单
679
680
  ```
680
681
 
@@ -683,7 +684,7 @@ scripts/ # e2e-dsh.js 端到端演示 · stress-dsh.js 三轴线压
683
684
  ```bash
684
685
  cd dsh-mneme
685
686
  npm install # 安装 peer 依赖(以 devDependencies 形式,用于本地测试)
686
- npm test # 运行 2173 个测试
687
+ npm test # 运行 2230 个测试
687
688
  npm run stress # 三轴线压测:长会话检索 / 冲突仲裁 / 多 Agent 并发(离线 mock LLM)
688
689
  npm run sync # 把 src/ 同步到 lib/(发布时由 prepack 钩子自动执行)
689
690
  ```
@@ -728,7 +729,7 @@ dsh-mneme 的机制设计与下列公开研究相互印证(以下引用均逐
728
729
  - **何时该忘 / scope 门控**:[PersistBench](https://arxiv.org/abs/2602.01146)(arXiv:2602.01146);呈现层缓解见 [Structured Memory](https://arxiv.org/abs/2608.08300)(arXiv:2608.08300)——scope 语义域门控的问题出处与零契约变更改法。
729
730
  - **领域全景**:[Write–Manage–Read 综述](https://arxiv.org/abs/2603.07670)(arXiv:2603.07670)——「写入 / 管理 / 读取」三段式与插件整体形状互证。
730
731
  - **数月尺度的可靠性工程**:[Memory as Infrastructure](https://arxiv.org/abs/2609.05510)(arXiv:2609.05510)——与 sha256 审计链、fail-safe 降级同属「记忆即基础设施」路线。
731
- - **相邻系统(对照)**:[Zep](https://arxiv.org/abs/2501.13956)(时序知识图谱)、[Mem0](https://arxiv.org/abs/2504.19413)(生产级抽取式记忆)、[Hindsight](https://arxiv.org/abs/2512.12818)(retain / recall / reflect 三操作)走「写入时 LLM 抽取 + 向量/图检索」路线;dsh-mneme 的差异点在于:巩固由 LLM 参与但产物全程留痕可回放,冲突冻结待人工复核,默认离线。
732
+ - **相邻系统(对照)**:[Zep](https://arxiv.org/abs/2501.13956)(时序知识图谱)、[Mem0](https://arxiv.org/abs/2504.19413)(生产级抽取式记忆)、[Hindsight](https://arxiv.org/abs/2512.12818)(retain / recall / reflect 三操作)走「写入时 LLM 抽取 + 向量/图检索」路线;dsh-mneme 的差异点在于:巩固由 LLM 参与但产物全程留痕可回放,冲突冻结待人工复核,记忆只落本机。
732
733
 
733
734
  ## 📜 License
734
735
 
package/bin/cli.mjs CHANGED
@@ -102,6 +102,40 @@ const USAGE = `${CLI_NAME} —— DSH 记忆插件(@modusensus/dsh-mneme)外
102
102
 
103
103
  /* ---------------------------------- 参数解析 ---------------------------------- */
104
104
 
105
+ // `--flag value` 形态下,「下一个词以 -- 开头就别当值」这条规则有一个致命反例:
106
+ // 值本身就可能是 `--` 开头的字符串。外部 API 的 token 由
107
+ // `crypto.randomBytes(24).toString("base64url")` 生成(src/api-standalone.js),
108
+ // base64url 的字母表含 `-`,所以约 1/4096 的 token 头两位恰好都是 `-`:
109
+ //
110
+ // dsh-mneme reclaim --url http://127.0.0.1:55423 --token --UYoOHxr6PtE2-bfqW...
111
+ //
112
+ // 旧规则把 `--UYoOHxr6PtE2-bfqW...` 当成一个新旗标(键名 `uyoohxr6pte2-bfqw...`),
113
+ // 于是 token 解析成 `true`,flagValue 视作「没给」→ 退回环境变量/配置文件 → 全空
114
+ // → 报「未配置访问 token」。而 CLI 生成 token 的另一半同样会撞:`--token=--abc`
115
+ // 走等号分支没事,`--token --abc` 就废。这不是测试的随机性,是真实用户的随机性。
116
+ //
117
+ // 修法:区分「已知旗标」与「未知词」。下一个词以 `--` 开头时,只有它确实是一个
118
+ // 已注册的旗标才当作旗标,否则当作当前 flag 的值。于是 `--limit --json` 仍按
119
+ // 旧语义把 --json 当旗标,而 `--token --UYo...` 正确取到 token。
120
+ // 已知旗标表必须与下面 flagValue/flagBool 的实际用法保持同步;漏登记的代价是
121
+ // 该旗标被当成值吞掉,所以 test/cli-flag-value.test.js 用文本锁对齐两者。
122
+ const KNOWN_FLAGS = new Set([
123
+ // 取值的旗标
124
+ 'url', 'token', 'type', 'title', 'content', 'importance', 'tags', 'source',
125
+ 'limit', 'offset', 'mode', 'topk', 'top-k', 'min-importance', 'minimportance',
126
+ 'older-than', 'olderthandays',
127
+ // 布尔旗标
128
+ 'apply', 'vacuum', 'json', 'help', 'version'
129
+ ]);
130
+
131
+ /** `--foo=bar` / `--foo` 取出小写键名;非旗标词返回 null。 */
132
+ function flagKeyOf(word) {
133
+ if (typeof word !== 'string' || !word.startsWith('--') || word === '--') return null;
134
+ const key = word.slice(2);
135
+ const eq = key.indexOf('=');
136
+ return (eq >= 0 ? key.slice(0, eq) : key).toLowerCase();
137
+ }
138
+
105
139
  function parseArgv(argv) {
106
140
  const positionals = [];
107
141
  const flags = {};
@@ -118,10 +152,14 @@ function parseArgv(argv) {
118
152
  if (eq >= 0) {
119
153
  value = key.slice(eq + 1);
120
154
  key = key.slice(0, eq);
121
- } else if (i + 1 < argv.length && !argv[i + 1].startsWith('--') && argv[i + 1] !== '--') {
122
- value = argv[++i];
123
155
  } else {
124
- value = true;
156
+ const next = argv[i + 1];
157
+ const nextIsFlag = next !== undefined && flagKeyOf(next) !== null && KNOWN_FLAGS.has(flagKeyOf(next));
158
+ if (next !== undefined && next !== '--' && !nextIsFlag) {
159
+ value = argv[++i];
160
+ } else {
161
+ value = true;
162
+ }
125
163
  }
126
164
  flags[key.toLowerCase()] = value;
127
165
  } else {
@@ -691,7 +729,23 @@ async function main(argv) {
691
729
  }
692
730
  }
693
731
 
694
- main(process.argv.slice(2)).catch((err) => {
695
- console.error(err?.stack ?? String(err));
696
- process.exit(1);
697
- });
732
+ // 直接运行才进 main(import 只取 parseArgv 做单测时必须安静):npm 全局安装走
733
+ // node_modules/.bin 的符号链接,argv[1] 是链接路径而非真身,所以要 realpath 两侧
734
+ // 再比,不能用字符串相等。
735
+ const invokedDirectly = (() => {
736
+ if (process.argv[1] == null) return false;
737
+ try {
738
+ return fs.realpathSync(process.argv[1]) === fs.realpathSync(fileURLToPath(import.meta.url));
739
+ } catch {
740
+ return false;
741
+ }
742
+ })();
743
+
744
+ if (invokedDirectly) {
745
+ main(process.argv.slice(2)).catch((err) => {
746
+ console.error(err?.stack ?? String(err));
747
+ process.exit(1);
748
+ });
749
+ }
750
+
751
+ export { parseArgv, KNOWN_FLAGS, flagValue, flagBool, flagKeyOf };
@@ -28,6 +28,29 @@
28
28
  | `scopeEnabled` | `false` | 存储层总开关:**两条写入路径**(工具调用 `memory_save`/`memory_update`,以及机器提炼·会话蒸馏)都自动标注 agent_scope / workspace_scope(标注只贴**新建行**:并入既有行时不重贴,既有行的归属属于它原来被提炼出来的语境),去重键扩展;检索与注入排序按当前会话作用域加权(命中 ×1.25、他 scope ×0.5 保留可见,issue #339 补齐注入通道) | opt-in;关 = 写入不标注、行为与 A1 前逐字节一致 |
29
29
  | `strictScope` | `false` | 硬过滤模式(A3):他 scope 完全不可见、未标注恒可见(fail-closed);硬过滤先于软加权执行 | 依赖 `scopeEnabled` 打开才有意义;关 = A2 软隔离(他 scope 降权保留可见,检索与注入同权重) |
30
30
 
31
+ ## 作用域级可逆闸门(issue #471)
32
+
33
+ 上面两个键是**事实轴**——答「这条记忆属于谁」,改的是归属数据、跟着记忆行走。下面两个键是**政策轴**——答「这个作用域现在通不通」,由人按作用域整体按下、跨会话存续、松开即原样回来,**一条记忆行都不动、一个配置键都不改、不用重启**。两条轴落在同一个判定出口(`gateByScope`,全仓唯一的过滤实现),可叠加。
34
+
35
+ 按下与松开走工具 `set_scope_state`(`dimension`: `agent`/`workspace`,`scope_value`: 标签本身或 `*` 表示该维度全局,`state`: `paused`/`active`)。状态存 `scope_state` 表,键 = 维度 + 作用域值,`UNIQUE(dimension, scope_value)` 保证幂等:**重复按下同一状态不刷新 `decided_at`**(同 `memory_archive` 的取舍——否则「最后一次真正变更」的时刻会被噪声盖掉)。
36
+
37
+ 两级语义与 `writeAdmission` / `preInjectGate` 同构:先观察、再拦。
38
+
39
+ | 键 | 默认 | 作用 | 开启后果 / 冲突 |
40
+ |---|---|---|---|
41
+ | `scopeState.enabled` | `false` | 跑政策轴判定:被按下的作用域其候选**照常注入**,但每次落一条 `llm_audit_logs`(`trigger_source=scopeState`,`metadata.paused` 记被按下的是哪些作用域、`metadata.gated` 记本应拦下几条、`metadata.enforced=false`) | 关 = 判定不跑、行为与今天逐字段一致。先看「如果按下会少掉多少」的真实分布,再决定真按——`gated` 恒为 0 就说明这一路本来就没在供料 |
42
+ | `scopeState.enforce` | `false` | 真的不注入:被按下的作用域其候选出池(检索侧同款,`gateByScope` 一处生效;巩固与睡眠两侧的读入口与写入路径同款) | 关 = 仅观察(`metadata.enforced=false`)。**enforce 无 enabled 时无效果**(同 `preInjectGate`)。按下的是政策不是事实:记忆行、向量、归档位、作用域标注全部不动,松开即恢复 |
43
+
44
+ 覆盖范围:`gateByScope` 的三条检索路(融合池、`entity:`、`attr:` 前缀路)与注入候选池(`injectCandidates` 内、图召回合并之后、pin 池之前)。
45
+
46
+ **不覆盖检视面**:`memory_list`、`memory_get`、面板列表**刻意不过这道闸**(它们走 `strictVisibility` 与 `store.list` 的 `visibility` 参数,是另一条平行的链)。按下之后人还要能查「我按下了什么、里面有什么」——检视面关掉,人就没有入口确认该不该松开。这也正是「按下的是政策不是事实」的具体含义:行还在,只是不再喂给模型。
47
+
48
+ **巩固(dream)侧另接四处**:`activeStoreSize`、叙述条 `inputs`、`runDream` 的读入口、总览 `summaryAll`——否则被按下的行会照常参与阈值判定与合并。**总览那一处只能从源头挡**:总览落库时是**无作用域**的 summary 行,而 `gateInjection` 只拦匹配暂停作用域的行,没有标注的行永远不会被它拦下;被按下的内容一旦进了总览就变成「洗干净的全局行」,此后一直注入。
49
+
50
+ **睡眠(sleep)侧另接四个相位**:冲突消解、归档降级、模式发现、关系补全的候选池。这一侧比日间更要紧,因为**它不只读**——`phaseDemotion` 会 `setArchived` 与 `demoteToSummary`(真改归档面),`phasePatterns` 会铸出 `type=pattern` 的新行;不接的话闸门开着而每 6 小时一次的睡眠照样在被按下的作用域上动手,「按下」就只等于让它在白天看不见。两处位置纪律写在注释里:降级的过滤放在循环之外(不是循环体里 `continue`,免得将来有人加副作用时漏掉),模式发现的过滤排在 `.slice(0, limit)` 之前(否则被按下的行照样占名额,它们既不会被扫、也不该挤掉别人)。
51
+
52
+ `llmAudit.enabled=false` 时观察档一行都不写(同 `writeAdmission` 的口径)。不进 lightMode 强制关闭清单——它只是查一张小表的判定,不引入重型后台路径、不加常驻文本(同 `actionPipelineEnabled` 的定性)。
53
+
31
54
  ## 审计
32
55
 
33
56
  | 键 | 默认 | 作用 | 开启后果 / 冲突 |
package/lib/api.js CHANGED
@@ -207,6 +207,15 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
207
207
  // feature flags 快照(GET/PUT 共用):overrides 是持久化的用户显式覆盖;
208
208
  // effective 是启动配置在白名单键上被 overrides 覆盖后的最终值。config 缺席
209
209
  // (旧调用方直连、未传 cfg)时只报被覆盖的键,不把不存在的默认值编造给前端。
210
+ //
211
+ // pendingRestart 回答的是第三个问题:「我改的开关,现在真的生效了吗?」
212
+ // 功能开关的生效节奏与 panel_mode 相同——启动时由 index.js 合并进 cfg,运行中
213
+ // 那份 cfg 是冻结的(见 index.js 装配处的注释)。于是面板显示「已改」不等于
214
+ // 运行时已改:#481 实测人格总闸正是这样,面板开着、人格段却不注入,用户只能
215
+ // 靠反复试错发现。effective 表达的是「运行时取什么值」(overrides 优先),
216
+ // **反推不出待重启**,必须另比一次冻结值——这是本字段存在的唯一理由。
217
+ // 只报「显式覆盖 ≠ 冻结值」的键:未被覆盖的键运行时值就是 cfg 值,不存在落差;
218
+ // config 缺席(旧调用方直连、未传 cfg)时无从比较,返回空数组而不编造。
210
219
  const flagKeys = [
211
220
  ...FEATURE_FLAG_SPEC.booleans,
212
221
  ...Object.keys(FEATURE_FLAG_SPEC.ints),
@@ -232,7 +241,11 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
232
241
  // Issue #399:蒸馏原文留存 + 溯源。两个键都是嵌套子字段(enabled 开关 +
233
242
  // retentionDays 整数),漏登记的后果同上——effective 静默缺键。
234
243
  "distillRecords.enabled": ["distillRecords", "enabled"],
235
- "distillRecords.retentionDays": ["distillRecords", "retentionDays"]
244
+ "distillRecords.retentionDays": ["distillRecords", "retentionDays"],
245
+ // Issue #471:政策轴——作用域级可逆闸门。两个键都是嵌套子字段,漏登记的后果
246
+ // 同上(effective 静默缺键、面板读不到也 PUT 不进)。
247
+ "scopeState.enabled": ["scopeState", "enabled"],
248
+ "scopeState.enforce": ["scopeState", "enforce"]
236
249
  // sensitiveScanEnabled(#164 A2)是顶层扁平键,不需要进这张表——走 configFlagValue
237
250
  // 的默认分支即可。
238
251
  };
@@ -241,6 +254,19 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
241
254
  if (path) return config?.[path[0]]?.[path[1]];
242
255
  return config?.[key];
243
256
  }
257
+ // 「显式覆盖 ≠ 冻结值」的键。用 Object.is 比而不是 !==:NaN 与 ±0 的边角上
258
+ // 两者会给出相反的结论(Object.is(NaN, NaN) 为真、Object.is(0, -0) 为假),
259
+ // 开关值里虽然不该出现它们,但比较语义与「值是否相同」对齐更省心。
260
+ // 未被覆盖的键不看:它的运行时值本来就取自 cfg,不存在落差。
261
+ function pendingRestartKeys(overrides) {
262
+ if (!config) return [];
263
+ const pending = [];
264
+ for (const key of flagKeys) {
265
+ if (overrides[key] === undefined) continue;
266
+ if (!Object.is(overrides[key], configFlagValue(key))) pending.push(key);
267
+ }
268
+ return pending;
269
+ }
244
270
  function featureSnapshot() {
245
271
  const overrides = settings.getFeatureFlags();
246
272
  const effective = {};
@@ -251,7 +277,7 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
251
277
  if (value !== undefined) effective[key] = value;
252
278
  }
253
279
  }
254
- return { overrides, effective };
280
+ return { overrides, effective, pendingRestart: pendingRestartKeys(overrides) };
255
281
  }
256
282
 
257
283
  // Ensure the service has an embedder when the API layer was handed one
@@ -1054,6 +1080,25 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
1054
1080
  const configured = embedder ? (typeof embedder.configured === "boolean" ? embedder.configured : true) : null;
1055
1081
  const embedded = Number(stats?.embeddedCount ?? 0);
1056
1082
  const total = Number(stats?.totalCount ?? 0);
1083
+ // Per-model / per-dimension provenance of stored vectors. "embedded"
1084
+ // counts rows with any vector; rows whose dimension differs from the
1085
+ // current embedder output are skipped by searchVector, so the honest
1086
+ // retrievable number is the current-dimension slice. modelBreakdown
1087
+ // groups by row-level embedding_model ('' = unknown origin).
1088
+ const dimensionBreakdown = typeof service.embeddingDimensionBreakdown === "function"
1089
+ ? service.embeddingDimensionBreakdown()
1090
+ : null;
1091
+ const currentDim = embedder?.dimension != null ? String(embedder.dimension) : null;
1092
+ const byDimension = dimensionBreakdown
1093
+ ? [...dimensionBreakdown.entries()].map(([dim, c]) => ({ dimension: dim === "" ? null : Number(dim), count: c }))
1094
+ : [];
1095
+ const unsearchable = (currentDim && dimensionBreakdown)
1096
+ ? [...dimensionBreakdown.entries()]
1097
+ .filter(([dim]) => dim !== currentDim)
1098
+ .reduce((s, [, c]) => s + c, 0)
1099
+ : 0; const modelBreakdown = typeof service.embeddingModelBreakdown === "function"
1100
+ ? service.embeddingModelBreakdown().map((r) => ({ model: r.model === "" ? null : r.model, count: r.c }))
1101
+ : [];
1057
1102
  sendJson(res, 200, {
1058
1103
  ready,
1059
1104
  configured,
@@ -1064,6 +1109,16 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
1064
1109
  : null,
1065
1110
  // 空库时 ratio 必须是 null 而非 0 —— 0 会被读成「一条都没嵌上」。
1066
1111
  coverage: { embedded, total, ratio: total > 0 ? embedded / total : null },
1112
+ // distill-tx-embed-gap 后续:向量「存在」≠「可检索」。unsearchable =
1113
+ // 维度与当前 embedder 输出不同的行数(searchVector 会跳过它们),
1114
+ // retrievable = 与当前维度同维、真正参与召回的行数。两者差值非零时
1115
+ // 面板必须能说出「有 N 条向量不可检索」,而不是只给一个虚高的 coverage。
1116
+ vectors: {
1117
+ byDimension,
1118
+ byModel: modelBreakdown,
1119
+ unsearchable,
1120
+ retrievable: currentDim ? (dimensionBreakdown.get(currentDim) ?? 0) : null,
1121
+ },
1067
1122
  // 配了、库里有东西可嵌、却一条都没嵌上 = 真的降级(本机此前长期
1068
1123
  // 处于 ready:true + embeddedCount:0,面板却显示正常)。
1069
1124
  degraded: configured === true && total > 0 && embedded === 0,
package/lib/client.js CHANGED
@@ -495,6 +495,11 @@ window.__ModuleLoader__.load({
495
495
  "memory.features.tagDefault.hint": "这一项用的是出厂默认值,没有被你改过",
496
496
  "memory.features.tagChanged": "已改",
497
497
  "memory.features.tagChanged.hint": "你改过这一项,它不再用默认值",
498
+ // 「待重启」是第三态,不是「已改」的加强版:#481 之前面板只有「已改」,
499
+ // 用户改完总闸看到徽标亮起就以为生效了,实际运行时还在用启动时的旧值。
500
+ "memory.features.tagPending": "待重启",
501
+ "memory.features.tagPending.hint": "这一项已存进配置,但本次启动还没用上——重启 DSH(或在插件管理里关开一次本插件)后才生效",
502
+ "memory.features.pendingRestartNotice": "有改动还没生效:标着「待重启」的开关要等重启 DSH 后才起作用(在插件管理里把本插件关开一次也行)。",
498
503
  "memory.features.autoSummarize": "自动把对话记下来(自动注入之外)",
499
504
  "memory.features.autoSummarize.hint": "开:对话结束自动提炼成长期记忆条目(autoSummarize);关:只有你手动存或 Agent 主动存才有记忆",
500
505
  "memory.features.hotMemoryEnabled": "顺带带上最近几轮原话",
@@ -676,6 +681,12 @@ window.__ModuleLoader__.load({
676
681
  "memory.features.sleepProvider": "睡眠 Provider",
677
682
  "memory.features.sleepModel": "睡眠模型",
678
683
  "memory.features.sleepModelHint": "留空 = 用巩固模型或当前模型;建议选非思考模型",
684
+ // Issue #478:max_tokens 预算键。hint 写清两件事:这是「单轮输出预算」,
685
+ // 以及服务商硬上限更小时后端会按报错自动降级重试(对齐 #478 的后端行为)。
686
+ "memory.features.dreamMaxTokens": "巩固单轮输出上限 (max_tokens)",
687
+ "memory.features.dreamMaxTokens.hint": "单轮巩固最多生成多少 token,256–131072。默认 131072 超过部分服务商硬上限时,后端会按报错里的上限自动降级重试",
688
+ "memory.features.sleepMaxTokens": "睡眠单轮输出上限 (max_tokens)",
689
+ "memory.features.sleepMaxTokens.hint": "睡眠阶段冲突/模式判断的单轮输出预算,256–131072;超上限同样自动降级",
679
690
  "memory.features.routeFollowDefault": "跟随默认路由",
680
691
  "memory.features.modelTest": "测试连通性",
681
692
  "memory.features.modelTesting": "测试中…",
@@ -1156,6 +1167,9 @@ window.__ModuleLoader__.load({
1156
1167
  "memory.features.tagDefault.hint": "This one is at its factory default — you have not changed it",
1157
1168
  "memory.features.tagChanged": "changed",
1158
1169
  "memory.features.tagChanged.hint": "You changed this one; it no longer uses the default",
1170
+ "memory.features.tagPending": "restart pending",
1171
+ "memory.features.tagPending.hint": "Saved, but not yet in effect for this session — restart DSH (or toggle this plugin off and on in the plugin manager) to apply it",
1172
+ "memory.features.pendingRestartNotice": "Some changes are not in effect yet: switches marked \"restart pending\" apply only after you restart DSH (toggling this plugin off and on in the plugin manager works too).",
1159
1173
  "memory.features.autoSummarize": "Turn conversations into memory automatically",
1160
1174
  "memory.features.autoSummarize.hint": "On: memory entries are distilled when a conversation ends (autoSummarize). Off: memory only exists if you save it, or the agent decides to",
1161
1175
  "memory.features.hotMemoryEnabled": "Carry the last few turns verbatim",
@@ -1329,6 +1343,12 @@ window.__ModuleLoader__.load({
1329
1343
  "memory.features.sleepProvider": "Sleep provider",
1330
1344
  "memory.features.sleepModel": "Sleep model",
1331
1345
  "memory.features.sleepModelHint": "Leave empty to reuse the consolidation model; a non-reasoning model is recommended",
1346
+ // Issue #478: max_tokens budget keys. Hint states both the scope (single-run
1347
+ // output budget) and the backend auto-downgrade on provider cap rejection.
1348
+ "memory.features.dreamMaxTokens": "Consolidation output cap (max_tokens)",
1349
+ "memory.features.dreamMaxTokens.hint": "Max tokens generated per consolidation run, 256–131072. When the default 131072 exceeds a provider's hard limit, the backend retries with the cap from the error",
1350
+ "memory.features.sleepMaxTokens": "Sleep output cap (max_tokens)",
1351
+ "memory.features.sleepMaxTokens.hint": "Single-run output budget for sleep conflict/mode calls, 256–131072; over-cap values are downgraded automatically",
1332
1352
  "memory.features.routeFollowDefault": "Follow default route",
1333
1353
  "memory.features.modelTest": "Test connectivity",
1334
1354
  "memory.features.modelTesting": "Testing…",
@@ -1830,6 +1850,13 @@ window.__ModuleLoader__.load({
1830
1850
  ".mneme-set-del{flex:none;opacity:0;transition:opacity .12s;border:none;background:none;color:var(--mneme-fg-dim);cursor:pointer;font-size:14px;line-height:20px;padding:0 4px;border-radius:6px}",
1831
1851
  ".mneme-set-row:hover .mneme-set-del,.mneme-set-row:focus-within .mneme-set-del{opacity:1}",
1832
1852
  ".mneme-set-del:hover{color:var(--mneme-danger);background:var(--dsw-alias-interactive-bg-hover)}",
1853
+ // 人格卡删除 × 独立类:不复用 mneme-set-del——其 opacity:0 仅在
1854
+ // .mneme-set-row:hover 解除,卡标签不在该容器里 → × 永远透明(v1.2
1855
+ // 实测踩中)。#457 曾用内联 opacity:1 覆写,但悬停变红仍走
1856
+ // .mneme-set-del:hover,连带常点亮了规则区的真删除按钮,被 #459 回滚。
1857
+ // 独立类常显,只沿用配色,规则区行为分毫不动。
1858
+ ".mneme-persona-del{flex:none;opacity:1;border:none;background:none;color:var(--mneme-fg-dim);cursor:pointer;font-size:16px;line-height:18px;padding:0 3px;border-radius:6px}",
1859
+ ".mneme-persona-del:hover{color:var(--mneme-danger);background:var(--dsw-alias-interactive-bg-hover)}",
1833
1860
  ".mneme-set-cmd{flex:1;min-width:0}",
1834
1861
  ".mneme-set-cmdname{font-size:13px;font-weight:600;color:var(--mneme-accent)}",
1835
1862
  ".mneme-set-cmddesc{font-size:12px;line-height:17px;color:var(--mneme-fg-dim);margin-top:1px;word-break:break-word}",
@@ -1967,6 +1994,13 @@ window.__ModuleLoader__.load({
1967
1994
  ".mneme-conflict-primary:disabled{opacity:.55;cursor:default}",
1968
1995
  ".mneme-conflict-primary:hover:not(:disabled){background:color-mix(in srgb,var(--mneme-accent) 85%,var(--dsw-alias-label-primary))}",
1969
1996
  ".mneme-badge--frozen{color:var(--mneme-warn);background:color-mix(in srgb,var(--mneme-warn) 12%,transparent)}",
1997
+ // 「待重启」徽标:沿用 frozen 的 warn 语义(都是「现状不是你刚设的样子」),
1998
+ // 但加边框把它和只读冻结态区分开——它需要用户动手,不是终态。
1999
+ ".mneme-badge--pending{color:var(--mneme-warn);background:color-mix(in srgb,var(--mneme-warn) 12%,transparent);border:1px solid color-mix(in srgb,var(--mneme-warn) 30%,transparent)}",
2000
+ ".mneme-pending{display:flex;align-items:flex-start;gap:8px;margin:8px 0 0;padding:8px 10px;border-radius:8px;font-size:12.5px;line-height:18px;color:var(--mneme-warn);background:color-mix(in srgb,var(--mneme-warn) 10%,transparent);border:1px solid color-mix(in srgb,var(--mneme-warn) 28%,transparent)}",
2001
+ // 圆点用当前色(currentColor)而不是再引一次 --mneme-warn:色值只有一个真源,
2002
+ // 改主题时提示行与圆点不会走散。
2003
+ ".mneme-pending-dot{flex:none;width:6px;height:6px;margin-top:6px;border-radius:50%;background:currentColor}",
1970
2004
  ".mneme-conflict-empty{border:1px dashed var(--dsw-alias-border-l2);border-radius:10px;padding:12px 14px;display:flex;flex-direction:column;gap:4px}",
1971
2005
  ".mneme-conflict-empty-title{font-size:12.5px;font-weight:600;color:var(--dsw-alias-label-secondary)}",
1972
2006
  ".mneme-conflict-empty-body{font-size:12px;line-height:18px;color:var(--mneme-fg-dim)}",
@@ -2910,7 +2944,7 @@ window.__ModuleLoader__.load({
2910
2944
  // 后端白名单、面板够不着——而 CONFIGURATION.md 明写「面板可启停」。
2911
2945
  // 其余整数键(429 调速器参数、distillMaxChars、codingBoostFactor)是有意
2912
2946
  // 不上 UI 的调优噪音,见上方注释,别顺手搬进来。
2913
- const FEATURE_NUMBERS = ["distillRecords.retentionDays"];
2947
+ const FEATURE_NUMBERS = ["distillRecords.retentionDays", "dreamMaxTokens", "sleepMaxTokens"];
2914
2948
  const EMBED_PROVIDERS = ["openai", "local", "ollama"];
2915
2949
  // 实体抽取思考强度(issue #109):与后端 FEATURE_FLAG_ENUMS 枚举对齐。
2916
2950
  const ENTITY_REASONING = ["none", "low", "medium", "high"];
@@ -3063,11 +3097,25 @@ window.__ModuleLoader__.load({
3063
3097
  // = 值仍来自 config schema 的默认值。面板自己判断,不加后端字段、
3064
3098
  // 不动 config.js(#427 的验收红线之一)。
3065
3099
  const ovr = (state && state.overrides) || {};
3066
- const flagTag = (k) => ({
3067
- kind: Object.prototype.hasOwnProperty.call(ovr, k) ? "mneme-badge--changed" : "mneme-badge--default",
3068
- text: t(Object.prototype.hasOwnProperty.call(ovr, k) ? "memory.features.tagChanged" : "memory.features.tagDefault"),
3069
- title: t(Object.prototype.hasOwnProperty.call(ovr, k) ? "memory.features.tagChanged.hint" : "memory.features.tagDefault.hint")
3070
- });
3100
+ // 「待重启」是第三态,必须由后端算:#481 的坑正是「面板显示已改 ≠ 运行时
3101
+ // 已改」——开关在宿主启动时合并进 cfg 并冻结,此后改 kv 只是持久化。这个
3102
+ // 落差前端看不见(overrides 里只有「改了」这一个事实),所以后端另比一次
3103
+ // 冻结值并给出 pendingRestart 名单;本面板只负责把它显示出来。
3104
+ const pending = new Set((state && state.pendingRestart) || []);
3105
+ const flagTag = (k) => {
3106
+ if (pending.has(k)) {
3107
+ return {
3108
+ kind: "mneme-badge--pending",
3109
+ text: t("memory.features.tagPending"),
3110
+ title: t("memory.features.tagPending.hint")
3111
+ };
3112
+ }
3113
+ return {
3114
+ kind: Object.prototype.hasOwnProperty.call(ovr, k) ? "mneme-badge--changed" : "mneme-badge--default",
3115
+ text: t(Object.prototype.hasOwnProperty.call(ovr, k) ? "memory.features.tagChanged" : "memory.features.tagDefault"),
3116
+ title: t(Object.prototype.hasOwnProperty.call(ovr, k) ? "memory.features.tagChanged.hint" : "memory.features.tagDefault.hint")
3117
+ };
3118
+ };
3071
3119
  const boolRow = (k, opts = {}) => h(FeatureRow, {
3072
3120
  key: k,
3073
3121
  name: t(`memory.features.${k}`),
@@ -3256,14 +3304,19 @@ window.__ModuleLoader__.load({
3256
3304
  // Issue #239 第 4 项镜像到巩固:高峰时段串(空 = 关闭)。放在 autoDream
3257
3305
  // 子块内——它是巩固的排程,开关关掉时不该还在界面上留着可编辑的输入框。
3258
3306
  strRow("dreamPeakHours"),
3259
- h("div", { className: "mneme-featsubhint" }, t("memory.features.dreamPeakHours.hint"))
3307
+ h("div", { className: "mneme-featsubhint" }, t("memory.features.dreamPeakHours.hint")),
3308
+ // Issue #478:单轮巩固的 max_tokens 预算。后端白名单(256–131072)早就有,
3309
+ // 修复前 UI 够不着;默认 131072 超过很多 OpenAI 兼容网关的硬上限又看不见调不了。
3310
+ numRow("dreamMaxTokens")
3260
3311
  );
3261
3312
 
3262
3313
  // 睡眠模型:sleepModeEnabled 开着才展开(sleepProvider/sleepModel 随本版
3263
3314
  // 进白名单;下拉与测试复用同一 /llm-providers 数据源)。
3264
3315
  const sleepSub = eff.sleepModeEnabled && Array.isArray(routes) && h("div", { className: "mneme-featsub" },
3265
3316
  routeSelects("sleepProvider", "sleepModel", sleepTest, setSleepTest, "sleepReasoningEffort"),
3266
- h("div", { className: "mneme-featsubhint" }, t("memory.features.sleepModelHint"))
3317
+ h("div", { className: "mneme-featsubhint" }, t("memory.features.sleepModelHint")),
3318
+ // Issue #478:睡眠侧同款预算键(#257 引入后端白名单,UI 一直缺席)。
3319
+ numRow("sleepMaxTokens")
3267
3320
  );
3268
3321
 
3269
3322
  // 实体抽取路由与思考强度(issue #109):entityExtractionEnabled 开着才
@@ -3325,6 +3378,14 @@ window.__ModuleLoader__.load({
3325
3378
  savedTick && h("span", { className: "mneme-saved", style: { marginLeft: 8, fontWeight: 400 } }, t("memory.features.restartHint"))
3326
3379
  ),
3327
3380
  h("div", { className: "mneme-set-desc" }, t("memory.features.desc")),
3381
+ // #481:改了但没生效的开关,在这张卡里明说。放在 desc 之后、错误行之前
3382
+ // ——它是常态提示而非错误,但比错误更该先被看见(用户刚改完就会看这里)。
3383
+ // 行级 badge(flagTag 的第三态)标出具体是哪几行,这条只负责说清「有事
3384
+ // 没生效」和「怎么办」。
3385
+ pending.size > 0 && h("div", { className: "mneme-pending" },
3386
+ h("span", { className: "mneme-pending-dot" }),
3387
+ h("span", null, t("memory.features.pendingRestartNotice"))
3388
+ ),
3328
3389
  // 见 saveError 的声明处:这条分支不能跟着加载失败那条走。
3329
3390
  saveError && h("div", { className: "mneme-mainterr" }, saveError),
3330
3391
  h(RuntimeProvisionBlock, { t }),
@@ -3857,9 +3918,11 @@ window.__ModuleLoader__.load({
3857
3918
  className: "mneme-btn" + (personaEditing === id ? " mneme-btn--active" : ""),
3858
3919
  onClick: () => setPersonaEditing(id)
3859
3920
  }, id),
3921
+ // 卡删除 × 常显:用独立类 mneme-persona-del(见上方样式定义处的
3922
+ // 注释——为何不复用 mneme-set-del,以及 #457→#459 一修一回滚的教训)。
3860
3923
  h("button", {
3861
3924
  type: "button",
3862
- className: "mneme-set-del",
3925
+ className: "mneme-persona-del",
3863
3926
  title: t("memory.settings.personaDeleteCard"),
3864
3927
  "aria-label": t("memory.settings.personaDeleteCard") + " " + id,
3865
3928
  onClick: () => removePersonaCard(id)
package/lib/config.js CHANGED
@@ -863,6 +863,23 @@ export const Config = z.object({
863
863
  // 数据、观察 A2 加权质量后再切硬过滤。
864
864
  strictScope: z.boolean().default(false),
865
865
 
866
+ // scopeState(issue #471):政策轴——作用域级可逆闸门。与上面两个键是**两条不同
867
+ // 的轴**:scopeEnabled/strictScope 管「这条记忆属于谁」(事实轴:持久、跟着记忆
868
+ // 行走、改的是归属数据),本键管「这个作用域现在通不通」(政策轴:暂时、由人单
869
+ // 方面按下、松开即恢复、不改任何记忆行)。
870
+ // 两级语义同 writeAdmission/preInjectGate——enabled 打开时被按下的作用域其候选
871
+ // 照常注入,但留一条审计行说清「本应被拦下几条」(观察档,先在真实流量看会少掉
872
+ // 多少再决定真按);enforce 打开才真的不注入(拦截档)。两个都默认关,打开前
873
+ // 行为与今天逐字段一致。
874
+ // 不进 LIGHT_MODE_OFF:它只是查一张小表的判定,不引入重型后台路径、不加常驻
875
+ // 文本(同 actionPipelineEnabled 的定性)——轻量档预设该关的是重型能力,不该替
876
+ // 用户关掉一个纯判定入口,用户显式勾选仍然赢。
877
+ // 也走 feature_flags(FEATURE_FLAG_BOOLEANS 白名单),面板可启停=线上回滚开关。
878
+ scopeState: z.object({
879
+ enabled: z.boolean().default(false),
880
+ enforce: z.boolean().default(false)
881
+ }).default({}),
882
+
866
883
  // --- v0.9.0 M.O.(Modus Operandi · 手法,beta)--------------------------
867
884
  // 命名:M.O. = Modus Operandi,拉丁语「一个人一贯的做事方式」,缩写 M.O.;
868
885
  // 也正是 org 词根 modus(modusensus)。界面主显示 M.O.,设置项 hint 第一句
@@ -21,7 +21,7 @@ import { randomUUID, createHash } from "node:crypto";
21
21
  import { validateDecisions, applyDecisions, ACTIONS } from "./decisions.js";
22
22
  import { findPotentialConflicts, cosineSimilarity } from "./clustering.js";
23
23
  import { scopeKeyOf } from "../scope.js";
24
- import { buildReceipt, describeStreamFailure, resolveDreamEffort, withEffortFallback, maintainIndexAfterDream } from "../dream.js";
24
+ import { buildReceipt, describeStreamFailure, resolveDreamEffort, withEffortFallback, withMaxTokensFallback, maintainIndexAfterDream } from "../dream.js";
25
25
  import { computeHeat } from "../heat.js";
26
26
 
27
27
  // Issue #126 review(Copilot):默认档(sleepActionSet="conflict")允许的动作 = 全局
@@ -172,6 +172,22 @@ function makeSummary(m) {
172
172
  return text.length <= SUMMARY_MAX ? text : `${text.slice(0, SUMMARY_MAX)}…`;
173
173
  }
174
174
 
175
+ // issue #471 政策轴:被按下的作用域也不参与睡眠整理。与 dream.js 的 withoutPaused
176
+ // 同源(那里记着「判定只许有一份实现」的理由),但这里是**单独一处**而不是复用:
177
+ // 两个模块各被独立装配(runSleep / runDream 各自接 service),任何一个被换进只
178
+ // 实现了一半 service 的夹具时都该各自降级,而不是因为对方模块没装而双双失效。
179
+ //
180
+ // 睡眠侧比日间加固侧更要紧:这几个相位不只读——phaseDemotion 会 setArchived 与
181
+ // demoteToSummary(真改归档面),phasePatterns 会 mint 出 type=pattern 的新行
182
+ // (凭空给被按下的作用域生出衍生记忆)。只挡日间那几处读入口的话,闸门开着而每
183
+ // 6 小时一次的睡眠照样动手,那等于「按下」只是让它在白天看不见。
184
+ //
185
+ // service 上取不到 filterPaused 时(老装配、测试夹具)原样返回,与改动前一致。
186
+ function withoutPaused(service, rows) {
187
+ if (typeof service?.filterPaused !== "function") return rows;
188
+ return service.filterPaused(rows);
189
+ }
190
+
175
191
  // ---------------------------------------------------------------- phases
176
192
 
177
193
  /**
@@ -189,7 +205,7 @@ async function phaseConflicts(ctx, service, config, logger, runId, semantic = nu
189
205
  }
190
206
  const strictness = config.sleepConflictStrictness ?? "normal";
191
207
  const threshold = CONFLICT_THRESHOLDS[strictness] ?? CONFLICT_THRESHOLDS.normal;
192
- const memories = service.all().filter((m) => !m.archived && !m.forgotten && m.type !== "summary" && m.type !== "document");
208
+ const memories = withoutPaused(service, service.all().filter((m) => !m.archived && !m.forgotten && m.type !== "summary" && m.type !== "document"));
193
209
  if (memories.length < 2) return { status: "skipped", reason: "too few memories" };
194
210
  if (signal?.aborted) return { status: "aborted", reason: "user activity" };
195
211
 
@@ -313,7 +329,7 @@ async function phaseConflicts(ctx, service, config, logger, runId, semantic = nu
313
329
  // actionSet 已在候选构造前解析(跨类型候选需要它),这里只按档位选 prompt:
314
330
  // 默认档保持只有 conflict/keep 的窄 prompt,"full" 才开放六分支。
315
331
  const conflictPrompt = actionSet === "full" ? STR.prompts.conflictFull[language] : STR.prompts.conflict[language];
316
- const runConflict = (withEffort) => {
332
+ const runConflict = (withEffort, tokens) => {
317
333
  conflictStreamFailure = "";
318
334
  return auditedSleepLlm(ctx, service, config, logger, {
319
335
  triggerSource: "sleep",
@@ -331,7 +347,7 @@ async function phaseConflicts(ctx, service, config, logger, runId, semantic = nu
331
347
  provider: route.provider,
332
348
  model: route.model,
333
349
  purpose: "sleep-conflict",
334
- maxTokens: config.sleepMaxTokens ?? 2048,
350
+ maxTokens: tokens,
335
351
  ...(withEffort && sleepEffort ? { reasoningEffort: sleepEffort } : {}),
336
352
  messages: [
337
353
  { role: "system", content: [{ type: "text", text: conflictPrompt }], source: { kind: "plugin:dsh-mneme", plugin: "dsh-mneme" } },
@@ -339,7 +355,9 @@ async function phaseConflicts(ctx, service, config, logger, runId, semantic = nu
339
355
  ]
340
356
  }, reportUsage, (reason) => { conflictStreamFailure = describeStreamFailure(reason); }));
341
357
  };
342
- const text = await withEffortFallback(ctx, sleepEffort, () => runConflict(true), () => runConflict(false), () => conflictStreamFailure);
358
+ // Issue #478:睡眠侧同样接 max_tokens 降级——面板 hint 已承诺「超上限同样自动降级」,
359
+ // 只挂 dream 三个点会让 sleepMaxTokens 的 131072 上限继续原样发。
360
+ const text = await withMaxTokensFallback(ctx, config.sleepMaxTokens ?? 2048, (tokens) => withEffortFallback(ctx, sleepEffort, () => runConflict(true, tokens), () => runConflict(false, tokens), () => conflictStreamFailure), () => conflictStreamFailure);
343
361
  if (text === undefined) {
344
362
  if (conflictStreamFailure) ctx.logger?.warn?.(`dsh-mneme sleep: conflict stream aborted or errored (${conflictStreamFailure})`);
345
363
  return { status: "failed", error: "llm failed" };
@@ -423,7 +441,10 @@ function phaseDemotion(service, config, logger, runId, signal = null) {
423
441
  const compressCut = Date.now() - compressDays * 86400000;
424
442
  const demoted = [];
425
443
  const archived = [];
426
- for (const m of service.all()) {
444
+ // 政策轴在这里尤其要紧:下面两行会真改归档面(setArchived / demoteToSummary)。
445
+ // 把过滤放在循环之外、而不是在循环体里 continue:一旦将来有人在循环体里加
446
+ // 副作用(现已有),漏掉那条 continue 就是静默改到被按下的行。
447
+ for (const m of withoutPaused(service, service.all())) {
427
448
  if (signal?.aborted) break;
428
449
  if (m.archived || m.forgotten) continue;
429
450
  const ref = m.last_accessed_at ?? m.created_at;
@@ -472,9 +493,11 @@ async function phasePatterns(ctx, service, config, logger, runId, signal = null)
472
493
  const limit = config.sleepPatternMinMemories ?? 100;
473
494
  // #230:document 在查询层排除(excludeTypes)而不是截断后过滤——LIMIT 200
474
495
  // 先生效的话,指针行一多就会把普通记忆挤出扫描窗,池子饿到门槛以下。
475
- const memories = service
496
+ // 政策轴同理必须排在 `.slice(0, limit)` **之前**,否则被按下的行照样占掉
497
+ // sleepPatternMinMemories 的名额,而它们既不会被扫、也不该挤掉别人(#471)。
498
+ const memories = withoutPaused(service, service
476
499
  .list({ limit: 200, includeForgotten: false, excludeTypes: ["document"] })
477
- .filter((m) => !m.archived && m.type !== "summary" && m.type !== "pattern")
500
+ .filter((m) => !m.archived && m.type !== "summary" && m.type !== "pattern"))
478
501
  .sort((a, b) => (a.updated_at < b.updated_at ? 1 : -1))
479
502
  .slice(0, limit);
480
503
  if (memories.length === 0) return { status: "skipped", reason: "no memories to scan" };
@@ -485,7 +508,7 @@ async function phasePatterns(ctx, service, config, logger, runId, signal = null)
485
508
  const maxPatterns = config.sleepMaxPatternPerRun ?? 3;
486
509
  const sleepEffort = await resolveDreamEffort(ctx, route, config.sleepReasoningEffort, logger);
487
510
  let patternStreamFailure = "";
488
- const runPattern = (withEffort) => {
511
+ const runPattern = (withEffort, tokens) => {
489
512
  patternStreamFailure = "";
490
513
  return auditedSleepLlm(ctx, service, config, logger, {
491
514
  triggerSource: "sleep",
@@ -500,7 +523,7 @@ async function phasePatterns(ctx, service, config, logger, runId, signal = null)
500
523
  provider: route.provider,
501
524
  model: route.model,
502
525
  purpose: "sleep-pattern",
503
- maxTokens: config.sleepMaxTokens ?? 2048,
526
+ maxTokens: tokens,
504
527
  ...(withEffort && sleepEffort ? { reasoningEffort: sleepEffort } : {}),
505
528
  messages: [
506
529
  { role: "system", content: [{ type: "text", text: STR.prompts.pattern[language].replace("N", String(maxPatterns)) }], source: { kind: "plugin:dsh-mneme", plugin: "dsh-mneme" } },
@@ -508,7 +531,7 @@ async function phasePatterns(ctx, service, config, logger, runId, signal = null)
508
531
  ]
509
532
  }, reportUsage, (reason) => { patternStreamFailure = describeStreamFailure(reason); }));
510
533
  };
511
- const text = await withEffortFallback(ctx, sleepEffort, () => runPattern(true), () => runPattern(false), () => patternStreamFailure);
534
+ const text = await withMaxTokensFallback(ctx, config.sleepMaxTokens ?? 2048, (tokens) => withEffortFallback(ctx, sleepEffort, () => runPattern(true, tokens), () => runPattern(false, tokens), () => patternStreamFailure), () => patternStreamFailure);
512
535
  if (text === undefined) {
513
536
  if (patternStreamFailure) ctx.logger?.warn?.(`dsh-mneme sleep: pattern stream aborted or errored (${patternStreamFailure})`);
514
537
  return { status: "failed", error: "llm failed" };
@@ -563,7 +586,7 @@ function phaseRelations(service, config, logger, runId, signal = null) {
563
586
  if (entities.length < 2) return { status: "skipped", reason: "too few entities" };
564
587
  const orphans = entities.filter((e) => (service.getRelations(e.id) ?? []).length === 0);
565
588
  if (orphans.length === 0) return { status: "skipped", reason: "no orphan entities" };
566
- const memories = service.all().filter((m) => !m.archived && !m.forgotten);
589
+ const memories = withoutPaused(service, service.all().filter((m) => !m.archived && !m.forgotten));
567
590
  const seen = new Set();
568
591
  const related = [];
569
592
  const MAX_RELATIONS_PER_ORPHAN = 3;