@modusensus/dsh-mneme 0.9.1 → 0.9.3

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.
Files changed (55) hide show
  1. package/README.md +12 -10
  2. package/bin/cli.mjs +61 -7
  3. package/docs/CONFIGURATION.md +26 -2
  4. package/docs/WORKFLOWS.md +70 -3
  5. package/lib/api.js +176 -3
  6. package/lib/capabilities.js +1 -1
  7. package/lib/client.js +170 -16
  8. package/lib/config.js +37 -2
  9. package/lib/dream/sleep.js +35 -12
  10. package/lib/dream.js +86 -14
  11. package/lib/embedding.js +52 -22
  12. package/lib/index.js +15 -0
  13. package/lib/pipeline-nudge-auto.js +126 -0
  14. package/lib/pipeline-nudge.js +337 -28
  15. package/lib/pipeline-runs.js +1 -1
  16. package/lib/pipeline.js +4 -4
  17. package/lib/scope-state.js +277 -0
  18. package/lib/scope.js +30 -1
  19. package/lib/service.js +80 -3
  20. package/lib/settings.js +12 -1
  21. package/lib/store.js +139 -0
  22. package/lib/tools.js +71 -0
  23. package/package.json +1 -1
  24. package/src/api.js +176 -3
  25. package/src/capabilities.js +1 -1
  26. package/src/config.js +37 -2
  27. package/src/dream/sleep.js +35 -12
  28. package/src/dream.js +86 -14
  29. package/src/embedding.js +52 -22
  30. package/src/index.js +15 -0
  31. package/src/pipeline-nudge-auto.js +126 -0
  32. package/src/pipeline-nudge.js +337 -28
  33. package/src/pipeline-runs.js +1 -1
  34. package/src/pipeline.js +4 -4
  35. package/src/scope-state.js +277 -0
  36. package/src/scope.js +30 -1
  37. package/src/service.js +80 -3
  38. package/src/settings.js +12 -1
  39. package/src/store.js +139 -0
  40. package/src/tools.js +71 -0
  41. package/test/api-nudge-route.test.js +259 -0
  42. package/test/api.test.js +118 -1
  43. package/test/cli-flag-value.test.js +86 -0
  44. package/test/client.test.js +103 -4
  45. package/test/distill-tx-embed-gap.test.js +110 -0
  46. package/test/dream-maxtokens-fallback.test.js +132 -0
  47. package/test/embed-text-reasons.test.js +112 -0
  48. package/test/pipeline-nudge-auto.test.js +224 -0
  49. package/test/pipeline-nudge.test.js +161 -6
  50. package/test/readme-claims.test.js +68 -0
  51. package/test/scope-optional-service.test.js +110 -0
  52. package/test/scope-state.test.js +481 -0
  53. package/test/session-usage.test.js +11 -2
  54. package/test/tools-dto-schema-matrix.test.js +12 -3
  55. package/test/tools.test.js +6 -6
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-2257%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)
@@ -64,7 +64,7 @@ dsh web
64
64
  | `memory_register_document` | 登记 agent 产长文档的指针行(#230:摘要 + `doc_path` + evidence 落 `document` 类型;正文留在磁盘、由 agent 自管,注册口唯一,旁路写入被拒) |
65
65
  | `memory_runtime` | 本地推理运行时的状态 / 供给 / 校验(v0.7.32,自管运行时三档取件) |
66
66
 
67
- ### 工作流(M.O. · 手法,v0.9 beta)
67
+ ### 工作流(M.O.,v0.9 beta)
68
68
 
69
69
  记忆库答的是「这件事**为什么**这么办」,答不了「这件事**到底怎么跑**」。v0.9 把后者从记忆库里的一行文字变成项目里可提交、可 review、可被 CI 读的一份文件——一条工作流一个 JSON:
70
70
 
@@ -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 准确率
@@ -267,12 +267,14 @@ v0.3.0 起新增**记忆基因**层:从记忆里抽取**命名实体**、**带
267
267
 
268
268
  > 完整版本说明见 [CHANGELOG](CHANGELOG.md) 与各版本 [GitHub Release](https://github.com/slow-stack/mneme/releases);当前特性以本 README 正文与[配置表](#-配置)为准。早期版本中的实验性功能(Wiki-Link、tag 系统等)已在 v0.7.11 移除,详见 CHANGELOG 对应条目。
269
269
 
270
- 🧬 Gene → 🛡️ 审计加固 → 💤 Sleep Mode → 🕸️ 召回融合与图谱 → ✨ 面板体验 → 🌡️ 自进化记忆 → 🔐 作用域隔离 → 🌐 MCP 生态 → 🧭 M.O. 手法(项目级工作流,v0.9 beta)
270
+ 🧬 Gene → 🛡️ 审计加固 → 💤 Sleep Mode → 🕸️ 召回融合与图谱 → ✨ 面板体验 → 🌡️ 自进化记忆 → 🔐 作用域隔离 → 🌐 MCP 生态 → 🧭 M.O.(项目级工作流,v0.9 beta)
271
271
 
272
272
  | 版本 | 主题 |
273
273
  |------|------|
274
- | **v0.9.0**(2026-10-17) | M.O.(Modus Operandi · 手法)beta:工作流本体住 `<项目>/.mneme/pipelines/`、候选分层与升格判据、面板清单与图同屏(`actionPipelineEnabled` 默认关);设置面板折叠线挪到「新手之外」并按人话重写 22 个条目名(#427);安全与供应链加固(39 条路由声明式鉴权、OSV 有漏洞即失败、发布带 provenance) |
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) |
277
+ | **v0.9.3**(2026-10-20) | M.O. 从「挖得出来」到「会被叫去挖」:沉淀链算法自 v0.9.1 起就完整实现(多帧 zstd → 重复序列 → 缺席对照 → draft 草稿),运行时却没有任何调用点——机器挖得出来、却永远不会被叫去挖。这一版把归属与触发接上:按会话头 `cwd` 反查项目桶(桶名是路径的有损变形,查不到整跑跳过而不是降级成全局普查)、挂 `turn/end`、冷却 10 分钟、单次预算 4 秒、延到宏任务、一次一个项目、抛错只 `warn`,**只产提议不产草稿**(`actionNudgeEnabled`,beta,默认关);面板加「现在沉淀一次」,与自动跑同一条入口,区别只有它不改仓库;另修作用域反查自 v0.8.0 起每次都抛错走降级(#487)、`/nudge` 三处「没看全」被报成「确实没有」(#489 评审)、多帧 zstd 反证断言绑了 Node 整块解语义(#490),并把整个 M.O. 的「手法」改回「工作流」 |
276
278
  | **v0.8.x** | 作用域隔离(agent / workspace 双维 + opt-in 硬过滤)、冲突队列人工裁决、生态化(stdio MCP server / 图召回轴 / 冷启动 / 注入截断) |
277
279
 
278
280
  ## 📦 安装
@@ -597,7 +599,7 @@ dsh-mneme-serve --embed off # 纯关键词 + BM
597
599
 
598
600
  ### MCP Server(任意 MCP 客户端接入)
599
601
 
600
- > Claude Code / Codex / Hermes / OpenCode / OpenClaw 等各客户端的最小挂载配置速查表见[根 README](../README.md#用在其他-ai-工具里mcp);本节是完整配置与安全说明。规划中的独立分发包 `mneme-memory` 落地后,挂载命令将保持兼容(详见仓库 Discussions #300)。
602
+ > 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
603
 
602
604
  插件自带 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
605
 
@@ -674,7 +676,7 @@ src/
674
676
  ├── api.js # HTTP 路由(Web 面板数据通道,含 /conflicts 冲突队列)
675
677
  └── index.js # 插件接线
676
678
  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 发布闸门校验)
679
+ test/ # 2257 个 node:test 测试(审计与三轴线压测不变量;src↔lib 一致性由 scripts/check-sync.js 发布闸门校验)
678
680
  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
681
  ```
680
682
 
@@ -683,7 +685,7 @@ scripts/ # e2e-dsh.js 端到端演示 · stress-dsh.js 三轴线压
683
685
  ```bash
684
686
  cd dsh-mneme
685
687
  npm install # 安装 peer 依赖(以 devDependencies 形式,用于本地测试)
686
- npm test # 运行 2173 个测试
688
+ npm test # 运行 2257 个测试
687
689
  npm run stress # 三轴线压测:长会话检索 / 冲突仲裁 / 多 Agent 并发(离线 mock LLM)
688
690
  npm run sync # 把 src/ 同步到 lib/(发布时由 prepack 钩子自动执行)
689
691
  ```
@@ -701,7 +703,7 @@ npm run sync # 把 src/ 同步到 lib/(发布时由 prepack 钩子自动
701
703
  - [实体结构化记忆设计](docs/ENTITIES.md)
702
704
  - [语义增强架构](docs/SEMANTIC.md)
703
705
  - [本地模型部署指南](docs/LOCAL_MODEL.md)
704
- - [工作流(M.O. · 手法):本体住项目,证据住记忆库](docs/WORKFLOWS.md)
706
+ - [工作流(M.O.):本体住项目,证据住记忆库](docs/WORKFLOWS.md)
705
707
  - [连续性抢救(#249 N3):触发器口径与事件契约](docs/CONTINUITY.md)
706
708
  - [Sleep Mode:空闲四阶段深度维护](docs/SLEEP.md)
707
709
  - [存储与回收:表结构、保留期、`dsh-mneme reclaim`](docs/STORAGE.md)
@@ -728,7 +730,7 @@ dsh-mneme 的机制设计与下列公开研究相互印证(以下引用均逐
728
730
  - **何时该忘 / scope 门控**:[PersistBench](https://arxiv.org/abs/2602.01146)(arXiv:2602.01146);呈现层缓解见 [Structured Memory](https://arxiv.org/abs/2608.08300)(arXiv:2608.08300)——scope 语义域门控的问题出处与零契约变更改法。
729
731
  - **领域全景**:[Write–Manage–Read 综述](https://arxiv.org/abs/2603.07670)(arXiv:2603.07670)——「写入 / 管理 / 读取」三段式与插件整体形状互证。
730
732
  - **数月尺度的可靠性工程**:[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 参与但产物全程留痕可回放,冲突冻结待人工复核,默认离线。
733
+ - **相邻系统(对照)**:[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
734
 
733
735
  ## 📜 License
734
736
 
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
  | 键 | 默认 | 作用 | 开启后果 / 冲突 |
@@ -260,8 +283,9 @@
260
283
  | `externalApiHost` | `127.0.0.1` | 绑定地址 | 改成非回环 = 把整个记忆库暴露给网络,自负其责 |
261
284
  | `disableMemorySearch` | `false` | 对模型隐藏 `memory_search` 工具(v0.8.5) | 慢/轻量模型的工具往返节流;隐藏即不可调 |
262
285
  | `disableMemoryArchive` | `false` | 对模型隐藏 `memory_archive` 工具 | 同上 |
263
- | `actionPipelineEnabled` | `false` | M.O.(手法)只读视图的门控:做法图谱 + 对话行结算卡 | opt-in beta;只是纯 UI 入口、不引入重型后台路径,故不进 `LIGHT_MODE_OFF`。写账另有独立的执行记账键(`/mo-run`),看图 ≠ 写账 |
286
+ | `actionPipelineEnabled` | `false` | M.O. 只读视图的门控:做法图谱 + 对话行结算卡 | opt-in beta;只是纯 UI 入口、不引入重型后台路径,故不进 `LIGHT_MODE_OFF`。写账另有独立的执行记账键(`/mo-run`),看图 ≠ 写账 |
264
287
  | `actionRunEnabled` | `false` | M.O. 执行记账:`/mo-run` 开一次执行窗口,窗口内命令执行才可能被记成一条 run | opt-in beta;挂在热路径(每个会话事件都过一次监听),故 `LIGHT_MODE_OFF` 里有它 |
288
+ | `actionNudgeEnabled` | `false` | M.O. 自动沉淀:每轮结束(`turn/end`)后挖一次「这个项目重复走的动作」,结果落成 `memoryDir/proposals/<项目键>/` 里的提议,等人确认 | opt-in beta;三个 M.O. 键里最重的一条——挂 `session/event` 且要**同步解压 zstd 会话日志**(本机 118 份实测 16.2s,单次预算 `budgetMs`=4s 兜底),故 `LIGHT_MODE_OFF` 里有它。**永不自动落草稿**:`materialize` 恒 false,进仓库必须是人的显式动作 |
265
289
 
266
290
  ## 运行时与本地模型
267
291
 
@@ -293,7 +317,7 @@
293
317
  `autoReindexOnBoot` · `hybridInject` · `injectGuidanceEnabled` · `searchSemanticDedup` ·
294
318
  `selectiveInjectEnabled` · `bm25SearchEnabled` · `entityRecallEnabled` ·
295
319
  `graphAnchoringEnabled` · `graphWeightEnabled` · `graphInjectHint` · `graphPassiveConfirm` ·
296
- `actionRunEnabled` ·
320
+ `actionRunEnabled` · `actionNudgeEnabled` ·
297
321
  `dreamNarrativeEnabled` · `documentMemoryEnabled` · `heatEnabled` · `continuityRescueEnabled` ·
298
322
  `preInjectGate.enabled`(#380) · `distillRecords.enabled`(#399)
299
323
 
package/docs/WORKFLOWS.md CHANGED
@@ -1,4 +1,4 @@
1
- # dsh-mneme 工作流(M.O. · 手法)——本体住项目,证据住记忆库
1
+ # dsh-mneme 工作流(M.O.)——本体住项目,证据住记忆库
2
2
 
3
3
  - **日期**:2026-10-15
4
4
  - **状态**:🚧 第一刀(项目级读侧 + 条目清单;执行侧未做)
@@ -97,10 +97,14 @@ GET /api/dsh-mneme/pipeline?project=<绝对路径> # 该项目的清单(
97
97
  GET /api/dsh-mneme/pipeline?project=…&id=<id> # 指定一条
98
98
 
99
99
  GET /api/dsh-mneme/candidates?project=<绝对路径> # 该项目的**候选**(见 §9)
100
+
101
+ GET /api/dsh-mneme/nudge?project=<绝对路径> # 沉淀预览:只看不写(见 §10)
102
+ POST /api/dsh-mneme/nudge?project=<绝对路径> # 真跑一次:落提议(要 token)
100
103
  ```
101
104
 
102
- 两条路由读的是**两处不同的东西**,别混:`/pipeline` 读定稿(住仓库),
103
- `/candidates` 读候选(住本地)。分开是刻意的,理由见 §9。
105
+ 三条路由读的是**三处不同的东西**,别混:`/pipeline` 读定稿(住仓库),
106
+ `/candidates` 读候选(住本地),`/nudge` 是**产生**候选的那台机器(见 §10)。
107
+ 分开是刻意的,理由分别见 §9 与 §10。
104
108
 
105
109
  **`project` 必须是绝对路径**,否则回 `400 {"error":"project-not-absolute","detail":"…"}`。
106
110
  忽略了它就会静默退成「全局」,而全局看起来一切正常——那正是这一版要修掉的那类错。
@@ -297,3 +301,66 @@ JSON,报错本身就是给人看的:`detail` 里是校验器**一次列全**
297
301
  - **不做绕行的自动批准**:一律 R0/R1 记账先走、R2/R3 停下问人,没有「全都可以先走」
298
302
  - **不把候选塞进注入路径**:它是草稿,不是知识
299
303
  - **不做「图自动重写」**:图的变化一律经过提议 + 人批;回写是新版本,不是改旧版本
304
+
305
+ ## 10. 自动沉淀:工作流从哪长出来
306
+
307
+ §1 说过「一条新工作流从哪来」在那之前一直是零代码。这一节就是那个来源。
308
+
309
+ ```
310
+ turn/end → 这个项目自己的会话桶 → 挖重复动作 → memoryDir/proposals/<项目键>/
311
+ ```
312
+
313
+ ### 10.1 它写什么、不写什么
314
+
315
+ | 产物 | 住哪 | 谁批准 |
316
+ |---|---|---|
317
+ | **提议**(proposal) | `<memoryDir>/proposals/<项目键>/nudge-*.json` | 不需要批准,但**没人看就等于没有** |
318
+ | **草稿**(draft 工作流) | `<项目>/.mneme/pipelines/<id>.json` | 人显式要(`materialize: true`),自动跑**永不**落 |
319
+ | **定稿**(active) | 同上,换 `status` | 人批,机器永远不给 |
320
+
321
+ 边界写在答复的 `boundaries` 里,跳过与跑完用的是**同一个常量**(`NUDGE_BOUNDARIES`)
322
+ ——两条出口说两句不同的话,读的人就会以为其中一条忘了做:
323
+
324
+ ```jsonc
325
+ { "autoActivate": false, "autoAnswerCounterfactual": false,
326
+ "writesOnlyDrafts": true, "riskIsFloor": true, "crossProjectSupport": false }
327
+ ```
328
+
329
+ ### 10.2 归属:为什么不能拿全机会话去挖
330
+
331
+ 会话日志在 DSH 侧按**工作区**分桶(`<DSH_HOME>/sessions/<桶>`),而桶名是路径的
332
+ **有损**变形、规则不在本仓。所以「这个项目的工作流」只有在拿到**这个项目自己的桶**
333
+ 时才成立;归属靠会话头里的 `cwd` 反查(`resolveProjectSessionDir`)。
334
+
335
+ 拿不到归属时这一跑**整跑跳过**(`skipped: true`)而不是降级成全局普查:后者会把
336
+ A 项目的动作算到 B 头上,而结果看起来完全正常(复核报告 §2.6 实测过)。
337
+
338
+ `reason` 的四种取值里,**`unreadable` 必须与 `no-bucket` 分开**:前者是「读不到」
339
+ (故障),后者是「确实没有」(关于这个项目的答案)。混起来等于把故障报成空结果。
340
+ 端点层因此有两种状态码:`no-sessions-dir` / `unreadable` → **503**,其余 → **200**。
341
+
342
+ ### 10.3 触发:为什么不是「读一次就沉淀一次」
343
+
344
+ | 控制 | 值 | 为什么是这个值 |
345
+ |---|---|---|
346
+ | 触发点 | `turn/end` | 一轮结束才算一个观测窗口,中途记的是半截 |
347
+ | 冷却 | `NUDGE_AUTO_MIN_INTERVAL_MS` = 10 分钟/项目 | **观测新鲜度**参数,不是性能参数:日志十分钟内长不出新重复模式 |
348
+ | 单次预算 | `NUDGE_AUTO_BUDGET_MS` = 4 秒 | 超了带着已读到的部分收工并标 `budgetExceeded`,**不重试、不补跑** |
349
+ | 执行时机 | `setTimeout(0)` | 解压会话日志是同步 I/O(本机 118 份实测 16.2 秒),压在 `turn/end` 那一帧上等于让回合收尾卡住 |
350
+ | 排队 | 一次只跑一个项目 | 同上,别把一帧拉长 |
351
+
352
+ **开与不开**:`actionNudgeEnabled`(默认关)。它与 `actionPipelineEnabled`(看图)、
353
+ `actionRunEnabled`(记账)是**三件独立能力**,开任何一个都不会顺带打开另一个。
354
+ 它在 `LIGHT_MODE_OFF` 里——三个 M.O. 键里它最重(挂 `session/event` + 同步解压)。
355
+
356
+ ### 10.4 为什么提议落盘要单独一个开关
357
+
358
+ `nudgeFromSessions` 原本只要算得出项目键就**无条件**往 memoryDir 里写提议。面板每刷
359
+ 一次就 GET 一次,那样每刷一次就往记忆库里塞一批文件。所以有了 `persistProposals`
360
+ (默认 `true` 保持既有语义),预览路由显式传 `false`:
361
+
362
+ - `GET` / `HEAD` → `persistProposals: false`,**一个文件都不写**;
363
+ - `POST` / `PUT` → `persistProposals: true`,落提议。
364
+
365
+ 这也是 `/nudge` 的 `policy` 是 `token-write` 而不是 `open` 的原因:GET 与
366
+ `/pipeline`、`/candidates` 同级(只读预览,免 token),POST 会写盘,写盘面一律受门控。
package/lib/api.js CHANGED
@@ -18,6 +18,14 @@ import { loadRunsById, runsDir, RUNS_SUFFIX } from "./pipeline-runs.js";
18
18
  import { observationDir, loadObservationsById } from "./pipeline-observations.js";
19
19
  import { projectKey } from "./candidates.js";
20
20
  import { readCandidateDoc } from "./candidates.js";
21
+ // M.O. 自动沉淀:/nudge 是 pipeline-nudge.js 的第一个运行时消费者(此前它只有测试在用,
22
+ // 是个孤儿模块——「不能自动沉淀工作流」的根因)。
23
+ import {
24
+ resolveProjectSessionDir,
25
+ nudgeForProject,
26
+ NUDGE_BOUNDARIES,
27
+ NUDGE_AUTO_BUDGET_MS
28
+ } from "./pipeline-nudge.js";
21
29
 
22
30
  // headers:少数端点(/export 附件下载)需要追加 Content-Disposition 等响应头。
23
31
  function sendJson(res, status, payload, headers = {}) {
@@ -207,6 +215,15 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
207
215
  // feature flags 快照(GET/PUT 共用):overrides 是持久化的用户显式覆盖;
208
216
  // effective 是启动配置在白名单键上被 overrides 覆盖后的最终值。config 缺席
209
217
  // (旧调用方直连、未传 cfg)时只报被覆盖的键,不把不存在的默认值编造给前端。
218
+ //
219
+ // pendingRestart 回答的是第三个问题:「我改的开关,现在真的生效了吗?」
220
+ // 功能开关的生效节奏与 panel_mode 相同——启动时由 index.js 合并进 cfg,运行中
221
+ // 那份 cfg 是冻结的(见 index.js 装配处的注释)。于是面板显示「已改」不等于
222
+ // 运行时已改:#481 实测人格总闸正是这样,面板开着、人格段却不注入,用户只能
223
+ // 靠反复试错发现。effective 表达的是「运行时取什么值」(overrides 优先),
224
+ // **反推不出待重启**,必须另比一次冻结值——这是本字段存在的唯一理由。
225
+ // 只报「显式覆盖 ≠ 冻结值」的键:未被覆盖的键运行时值就是 cfg 值,不存在落差;
226
+ // config 缺席(旧调用方直连、未传 cfg)时无从比较,返回空数组而不编造。
210
227
  const flagKeys = [
211
228
  ...FEATURE_FLAG_SPEC.booleans,
212
229
  ...Object.keys(FEATURE_FLAG_SPEC.ints),
@@ -232,7 +249,11 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
232
249
  // Issue #399:蒸馏原文留存 + 溯源。两个键都是嵌套子字段(enabled 开关 +
233
250
  // retentionDays 整数),漏登记的后果同上——effective 静默缺键。
234
251
  "distillRecords.enabled": ["distillRecords", "enabled"],
235
- "distillRecords.retentionDays": ["distillRecords", "retentionDays"]
252
+ "distillRecords.retentionDays": ["distillRecords", "retentionDays"],
253
+ // Issue #471:政策轴——作用域级可逆闸门。两个键都是嵌套子字段,漏登记的后果
254
+ // 同上(effective 静默缺键、面板读不到也 PUT 不进)。
255
+ "scopeState.enabled": ["scopeState", "enabled"],
256
+ "scopeState.enforce": ["scopeState", "enforce"]
236
257
  // sensitiveScanEnabled(#164 A2)是顶层扁平键,不需要进这张表——走 configFlagValue
237
258
  // 的默认分支即可。
238
259
  };
@@ -241,6 +262,19 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
241
262
  if (path) return config?.[path[0]]?.[path[1]];
242
263
  return config?.[key];
243
264
  }
265
+ // 「显式覆盖 ≠ 冻结值」的键。用 Object.is 比而不是 !==:NaN 与 ±0 的边角上
266
+ // 两者会给出相反的结论(Object.is(NaN, NaN) 为真、Object.is(0, -0) 为假),
267
+ // 开关值里虽然不该出现它们,但比较语义与「值是否相同」对齐更省心。
268
+ // 未被覆盖的键不看:它的运行时值本来就取自 cfg,不存在落差。
269
+ function pendingRestartKeys(overrides) {
270
+ if (!config) return [];
271
+ const pending = [];
272
+ for (const key of flagKeys) {
273
+ if (overrides[key] === undefined) continue;
274
+ if (!Object.is(overrides[key], configFlagValue(key))) pending.push(key);
275
+ }
276
+ return pending;
277
+ }
244
278
  function featureSnapshot() {
245
279
  const overrides = settings.getFeatureFlags();
246
280
  const effective = {};
@@ -251,7 +285,7 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
251
285
  if (value !== undefined) effective[key] = value;
252
286
  }
253
287
  }
254
- return { overrides, effective };
288
+ return { overrides, effective, pendingRestart: pendingRestartKeys(overrides) };
255
289
  }
256
290
 
257
291
  // Ensure the service has an embedder when the API layer was handed one
@@ -1054,6 +1088,25 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
1054
1088
  const configured = embedder ? (typeof embedder.configured === "boolean" ? embedder.configured : true) : null;
1055
1089
  const embedded = Number(stats?.embeddedCount ?? 0);
1056
1090
  const total = Number(stats?.totalCount ?? 0);
1091
+ // Per-model / per-dimension provenance of stored vectors. "embedded"
1092
+ // counts rows with any vector; rows whose dimension differs from the
1093
+ // current embedder output are skipped by searchVector, so the honest
1094
+ // retrievable number is the current-dimension slice. modelBreakdown
1095
+ // groups by row-level embedding_model ('' = unknown origin).
1096
+ const dimensionBreakdown = typeof service.embeddingDimensionBreakdown === "function"
1097
+ ? service.embeddingDimensionBreakdown()
1098
+ : null;
1099
+ const currentDim = embedder?.dimension != null ? String(embedder.dimension) : null;
1100
+ const byDimension = dimensionBreakdown
1101
+ ? [...dimensionBreakdown.entries()].map(([dim, c]) => ({ dimension: dim === "" ? null : Number(dim), count: c }))
1102
+ : [];
1103
+ const unsearchable = (currentDim && dimensionBreakdown)
1104
+ ? [...dimensionBreakdown.entries()]
1105
+ .filter(([dim]) => dim !== currentDim)
1106
+ .reduce((s, [, c]) => s + c, 0)
1107
+ : 0; const modelBreakdown = typeof service.embeddingModelBreakdown === "function"
1108
+ ? service.embeddingModelBreakdown().map((r) => ({ model: r.model === "" ? null : r.model, count: r.c }))
1109
+ : [];
1057
1110
  sendJson(res, 200, {
1058
1111
  ready,
1059
1112
  configured,
@@ -1064,6 +1117,16 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
1064
1117
  : null,
1065
1118
  // 空库时 ratio 必须是 null 而非 0 —— 0 会被读成「一条都没嵌上」。
1066
1119
  coverage: { embedded, total, ratio: total > 0 ? embedded / total : null },
1120
+ // distill-tx-embed-gap 后续:向量「存在」≠「可检索」。unsearchable =
1121
+ // 维度与当前 embedder 输出不同的行数(searchVector 会跳过它们),
1122
+ // retrievable = 与当前维度同维、真正参与召回的行数。两者差值非零时
1123
+ // 面板必须能说出「有 N 条向量不可检索」,而不是只给一个虚高的 coverage。
1124
+ vectors: {
1125
+ byDimension,
1126
+ byModel: modelBreakdown,
1127
+ unsearchable,
1128
+ retrievable: currentDim ? (dimensionBreakdown.get(currentDim) ?? 0) : null,
1129
+ },
1067
1130
  // 配了、库里有东西可嵌、却一条都没嵌上 = 真的降级(本机此前长期
1068
1131
  // 处于 ready:true + embeddedCount:0,面板却显示正常)。
1069
1132
  degraded: configured === true && total > 0 && embedded === 0,
@@ -1794,7 +1857,7 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
1794
1857
  }
1795
1858
  });
1796
1859
 
1797
- // --- M.O.(Modus Operandi · 手法)工作流 ---
1860
+ // --- M.O.(Modus Operandi)工作流 ---
1798
1861
  // 只读端点,读的是**项目里**的 `<项目>/.mneme/pipelines/*.json`(文件头在
1799
1862
  // src/pipeline.js),项目层优先、插件包里那份降级成全局层;投影成
1800
1863
  // mneme.pipeline/v1 交给面板与 agent——两边读同一份,画面只是它的投影。
@@ -1937,6 +2000,116 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
1937
2000
  }
1938
2001
  });
1939
2002
 
2003
+ // --- M.O. 自动沉淀(nudge)---
2004
+ // 读+写两种方法同路:GET 是**预览**(不落盘),POST 是**真跑**(落提议、可落草稿)。
2005
+ // 为什么不全放 GET:GET 会被浏览器预取、被面板刷新反复调用,而这一跑要解压几十份
2006
+ // zstd 会话日志(同步 I/O,本机实测 16.2s)。预览走 GET 时才关掉提议落盘
2007
+ // (persistProposals:false)——否则每刷一次面板就往 memoryDir 里塞一批文件。
2008
+ //
2009
+ // policy 是 token-write 而不是 open:GET 免 token(与 /pipeline、/candidates 同级的
2010
+ // 只读预览),POST 要 token——它会写盘,写盘面一律受门控。
2011
+ register({
2012
+ kind: "exact",
2013
+ path: "/api/dsh-mneme/nudge",
2014
+ policy: "token-write",
2015
+ handler(req, res) {
2016
+ const method = String(req.method || "GET").toUpperCase();
2017
+ // 只认这四种。为什么必须在**读 body 之前**拦:下面 persistProposals 是按
2018
+ // 「不是 GET/HEAD」算的,于是 PATCH/DELETE 会被当成写动作,走进 run() 落提议;
2019
+ // 而它又不像 PUT/POST 那样读 body,等于一个不需要 body 的静默写盘口。
2020
+ // 这条 PR 自己的卖点是「GET 只预览、写动作一律要 POST」,那就得把口子堵死
2021
+ // (v0.9.3 评审抓出:带 token 的 PATCH/DELETE 会往 memoryDir 里写提议)。
2022
+ const allowed = method === "GET" || method === "HEAD" || method === "POST" || method === "PUT";
2023
+ if (!allowed) {
2024
+ sendJson(res, 405, { error: "method-not-allowed", allow: "GET, HEAD, POST, PUT" }, { Allow: "GET, HEAD, POST, PUT" });
2025
+ return;
2026
+ }
2027
+ const run = (body) => {
2028
+ try {
2029
+ const url = new URL(req.url, "http://localhost");
2030
+ const project = url.searchParams.get("project");
2031
+ // 与 /pipeline、/candidates 同一条规矩:不是绝对路径就拒掉,不能静默退成全局。
2032
+ if (project !== null && !isAbsolute(project)) {
2033
+ sendJson(res, 400, { error: "project-not-absolute", detail: project });
2034
+ return;
2035
+ }
2036
+ // 预算从**这里**起算,不是一个端点两套预算:归属解析(同步扫桶,最多 500 个)
2037
+ // 与后面的挖掘必须共用同一条截止线,否则「真卡就调 budgetMs」对前一段是空话。
2038
+ // POST(显式真跑)给三倍预算,面板预览给短预算。
2039
+ const previewMethod = method === "GET" || method === "HEAD";
2040
+ const startedAt = Date.now();
2041
+ const budgetMs = previewMethod ? NUDGE_AUTO_BUDGET_MS : NUDGE_AUTO_BUDGET_MS * 3;
2042
+ const deadline = startedAt + budgetMs;
2043
+ // 归属解析先做、单独回执:解析不到桶时要能说清是「这个项目还没跑过会话」
2044
+ // 还是「会话目录读不到」——两者在候选列表上都表现为空。
2045
+ const where = resolveProjectSessionDir({ projectRoot: project, deadline });
2046
+ if (!where.sessionDir) {
2047
+ // no-sessions-dir / unreadable / budget-exceeded / bucket-limit 都是**环境问题**
2048
+ // (读不到、没扫完、没扫全),回 503 让调用方知道这不是答案;
2049
+ // no-bucket / bad-project 是**关于这个项目的答案**,回 200。
2050
+ const envProblem = where.reason === "no-sessions-dir"
2051
+ || where.reason === "unreadable"
2052
+ || where.reason === "budget-exceeded"
2053
+ || where.reason === "bucket-limit";
2054
+ sendJson(res, envProblem ? 503 : 200, {
2055
+ error: envProblem
2056
+ ? (where.reason === "budget-exceeded"
2057
+ ? "nudge-attribution-budget-exceeded"
2058
+ : where.reason === "bucket-limit"
2059
+ ? "nudge-attribution-bucket-limit"
2060
+ : "nudge-sessions-unreadable")
2061
+ : undefined,
2062
+ contract: "mneme.nudge/v1",
2063
+ attribution: where,
2064
+ skipped: true,
2065
+ skippedReason: "no-session-attribution",
2066
+ counts: { candidates: 0, buckets: 0, sessionsWithCandidates: 0, savedProposals: 0, draftsWritten: 0 },
2067
+ available: [],
2068
+ drafts: [],
2069
+ boundaries: NUDGE_BOUNDARIES
2070
+ });
2071
+ return;
2072
+ }
2073
+ const memoryDir = (config && typeof config.memoryDir === "string" && config.memoryDir.trim())
2074
+ ? config.memoryDir
2075
+ : "~/.dsh/memory";
2076
+ const doc = nudgeForProject({
2077
+ projectRoot: project,
2078
+ // 上面已经解析过并判过 400/503,这里传进来,别再扫一遍桶。
2079
+ attribution: where,
2080
+ // now 传起算时刻:nudgeForProject 内部按 now+budgetMs 重新算截止点,这样
2081
+ // 两处算出来是同一条线(传 Date.now() 会让挖掘白拿一段已经花掉的预算)。
2082
+ now: startedAt,
2083
+ memoryDir,
2084
+ // GET 只预览:不落提议、不落草稿。写动作只认 POST/PUT——PATCH/DELETE 已在
2085
+ // 入口回 405,否则它们会落进这个「不是 GET/HEAD 就算写」的分支。
2086
+ persistProposals: method !== "GET" && method !== "HEAD",
2087
+ // 落草稿是「往仓库里塞文件」,必须显式要(body.materialize),默认不落。
2088
+ materialize: body && body.materialize === true,
2089
+ // 与归属解析共用同一条预算线:宁可少看几份并如实标 budgetExceeded,
2090
+ // 也不能让一次面板刷新把宿主主线程按住十几秒。
2091
+ budgetMs
2092
+ });
2093
+ sendJson(res, 200, doc);
2094
+ } catch (error) {
2095
+ sendJson(res, 500, {
2096
+ error: "nudge-failed",
2097
+ detail: String((error && error.message) || error)
2098
+ });
2099
+ }
2100
+ };
2101
+ if (method === "PUT" || method === "POST") {
2102
+ readBody(req, res).then((text) => {
2103
+ // readBody 已经回过 413:null 就是「别再往下走」。
2104
+ if (text === null) return;
2105
+ run(parseBody(text));
2106
+ });
2107
+ return;
2108
+ }
2109
+ run(null);
2110
+ }
2111
+ });
2112
+
1940
2113
  return {
1941
2114
  routes: countRoutes(),
1942
2115
  dispose: () => {
@@ -1,5 +1,5 @@
1
1
  /**
2
- * M.O.(Modus Operandi · 手法)能力清单的**读侧**。
2
+ * M.O.(Modus Operandi)能力清单的**读侧**。
3
3
  *
4
4
  * 能力清单 = 「这台机器上、这个项目里,agent 能拿到哪些外部增强」的一份可复核投影。
5
5
  * 三类:技能(注入文本指令)/MCP(注册可执行工具)/插件(加载单元)。三者在 DSH 里