@modusensus/dsh-mneme 0.9.2 → 0.9.4

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 (59) hide show
  1. package/README.md +11 -7
  2. package/docs/CONFIGURATION.md +9 -2
  3. package/docs/WORKFLOWS.md +70 -3
  4. package/lib/api.js +178 -3
  5. package/lib/capabilities.js +1 -1
  6. package/lib/client.js +315 -27
  7. package/lib/config.js +70 -3
  8. package/lib/dream.js +1 -1
  9. package/lib/index.js +21 -1
  10. package/lib/inject.js +10 -25
  11. package/lib/lang.js +25 -0
  12. package/lib/organize.js +4 -2
  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/profile-extract.js +161 -0
  18. package/lib/scope.js +30 -1
  19. package/lib/service.js +3 -2
  20. package/lib/settings.js +22 -14
  21. package/lib/summarize.js +64 -3
  22. package/lib/tools.js +240 -1
  23. package/lib/user-settings.js +78 -0
  24. package/package.json +1 -1
  25. package/src/api.js +178 -3
  26. package/src/capabilities.js +1 -1
  27. package/src/config.js +70 -3
  28. package/src/dream.js +1 -1
  29. package/src/index.js +21 -1
  30. package/src/inject.js +10 -25
  31. package/src/lang.js +25 -0
  32. package/src/organize.js +4 -2
  33. package/src/pipeline-nudge-auto.js +126 -0
  34. package/src/pipeline-nudge.js +337 -28
  35. package/src/pipeline-runs.js +1 -1
  36. package/src/pipeline.js +4 -4
  37. package/src/profile-extract.js +161 -0
  38. package/src/scope.js +30 -1
  39. package/src/service.js +3 -2
  40. package/src/settings.js +22 -14
  41. package/src/summarize.js +64 -3
  42. package/src/tools.js +240 -1
  43. package/src/user-settings.js +78 -0
  44. package/test/api-nudge-route.test.js +259 -0
  45. package/test/api.test.js +19 -1
  46. package/test/client.test.js +115 -7
  47. package/test/lang.test.js +11 -0
  48. package/test/organize-tool.test.js +232 -0
  49. package/test/persona-addressing.test.js +83 -0
  50. package/test/persona-settings.test.js +30 -8
  51. package/test/pipeline-nudge-auto.test.js +224 -0
  52. package/test/pipeline-nudge.test.js +161 -6
  53. package/test/profile-extract.test.js +269 -0
  54. package/test/scope-optional-service.test.js +110 -0
  55. package/test/session-usage.test.js +11 -2
  56. package/test/summarize.test.js +225 -0
  57. package/test/tools-dto-schema-matrix.test.js +48 -5
  58. package/test/tools.test.js +6 -1
  59. package/test/user-settings-preview.test.js +127 -0
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-2230%20passed-success)](https://github.com/slow-stack/mneme)
8
+ [![tests](https://img.shields.io/badge/tests-2302%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
 
@@ -267,13 +267,16 @@ 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
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. 的「手法」改回「工作流」 |
278
+ | **v0.9.4**(2026-10-21) | 三条链各自补上「最后一环」——东西早就造好了,只差没人能叫它:**agent 主动整理**(`memory_organize`,先 dryRun 出比对报告 → agent 判断 → apply 落库,筛除只归档不删、全程留审计回执;`organizeEnabled` 默认关,关时工具根本不注册);**画像提取草稿**(面板按钮读活跃记忆 + 一次模型调用,产一段「助手眼中的用户」草稿,`profileExtractEnabled` 默认关)——**只出草稿**,采用是用户再点一次,手写的画像原文不会被模型改写,空库与解析失败都直接报错、绝不回空草稿冒充「没提炼出东西」;**人格寻址收口**(`default` 卡以前无条件落给所有没建卡的身份,宿主里自带身份的 bot 每帧收到两句「你是谁」;`personaStrictAddressing` 默认关,把「没建卡」与「卡被显式关掉」都收回为不注入)。同版补蒸馏的**转录围栏 + 解析失败护栏**(#494:一条会话被同一窗口卡死、重试 20 次越试越贵的根因;失败行现在恒带原始输出形状),把面板自称「注入生效文本」的只读区改成后端单源渲染(并修它从不显示人格行),评审另收掉两处「坏了却不报」:整理报告侧的存在性探测口(`strictScope` 下按调用方可见性过滤落点并重算计数)、画像保存不判 `res.ok` 却提示已保存 |
279
+
277
280
  | **v0.8.x** | 作用域隔离(agent / workspace 双维 + opt-in 硬过滤)、冲突队列人工裁决、生态化(stdio MCP server / 图召回轴 / 冷启动 / 注入截断) |
278
281
 
279
282
  ## 📦 安装
@@ -370,6 +373,7 @@ dsh plugin --profile web add "C:/path/to/modusensus-dsh-mneme-0.9.0.tgz"
370
373
  | `summarizeMaxEntriesPerRun` | `0` | 单次蒸馏产出记忆条数上限(0-50,0=不限):节流防单会话大量重复条目(#127) |
371
374
  | `summarizeMinWindowChars` | `0` | 蒸馏前零 LLM 预判:窗口可蒸馏文本不足此字符数直接跳过调用(0-100000,0=关);skip 原因写入审计(#239) |
372
375
  | `summarizeMaxRunsPerSession` | `0` | 每会话最多发起多少次蒸馏 LLM 调用(0-1000,0=不限);只计真实调用,被预判拦下的不占额度(#239) |
376
+ | `summarizeParseFailureMaxRetries` | `0` | 连续几次解析失败后放弃当前窗口(0-10,0=关):确定性失败(模型把转录当成该自己回话,`finish_reason=stop`)重试收益为 0,而窗口只会越攒越大;设 2-3 可止血,达到阈值强制消费游标跳过,失败行 metadata 带 `raw_head`/`raw_has_bracket`/`streak`(#493) |
373
377
  | `summarizePeakHours` | 空 | 高峰时段(本地时间,逗号分隔、可带星期前缀,支持跨零点):空=关;命中时蒸馏顺延到非高峰、窗口累积后一次蒸、skip 原因入审计。例:按高峰计费的供应商可写 `mon-fri 08:00-12:00,14:00-18:00`(如 DeepSeek,以其官方定价页为准)(#239) |
374
378
  | `summarizePeakMaxDeferMinutes` | `120` | 高峰顺延上限(0-1440 分钟,0=不设上限):到点仍处高峰就照常跑,避免长高峰把蒸馏饿死(#239) |
375
379
  | `summarizeDedupeMode` | `off` | 落库前去重档位:`off`(默认=现状)/ `title`(零成本,仅拦完全同名)/ `vector`(复用 embedding 列做同会话语义近邻,无 LLM 调用,#127) |
@@ -675,7 +679,7 @@ src/
675
679
  ├── api.js # HTTP 路由(Web 面板数据通道,含 /conflicts 冲突队列)
676
680
  └── index.js # 插件接线
677
681
  lib/ # src 的同步分发产物(npm run sync,prepack 钩子在发布时自动跑;一致性由 scripts/check-sync.js 在 CI 与 release.yml 校验——它只查不同步;唯一手写例外 lib/client.js——Web 面板 bundle,sync 不覆盖)
678
- test/ # 2230 个 node:test 测试(审计与三轴线压测不变量;src↔lib 一致性由 scripts/check-sync.js 发布闸门校验)
682
+ test/ # 2302 个 node:test 测试(审计与三轴线压测不变量;src↔lib 一致性由 scripts/check-sync.js 发布闸门校验)
679
683
  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 运行时清单
680
684
  ```
681
685
 
@@ -684,7 +688,7 @@ scripts/ # e2e-dsh.js 端到端演示 · stress-dsh.js 三轴线压
684
688
  ```bash
685
689
  cd dsh-mneme
686
690
  npm install # 安装 peer 依赖(以 devDependencies 形式,用于本地测试)
687
- npm test # 运行 2230 个测试
691
+ npm test # 运行 2302 个测试
688
692
  npm run stress # 三轴线压测:长会话检索 / 冲突仲裁 / 多 Agent 并发(离线 mock LLM)
689
693
  npm run sync # 把 src/ 同步到 lib/(发布时由 prepack 钩子自动执行)
690
694
  ```
@@ -702,7 +706,7 @@ npm run sync # 把 src/ 同步到 lib/(发布时由 prepack 钩子自动
702
706
  - [实体结构化记忆设计](docs/ENTITIES.md)
703
707
  - [语义增强架构](docs/SEMANTIC.md)
704
708
  - [本地模型部署指南](docs/LOCAL_MODEL.md)
705
- - [工作流(M.O. · 手法):本体住项目,证据住记忆库](docs/WORKFLOWS.md)
709
+ - [工作流(M.O.):本体住项目,证据住记忆库](docs/WORKFLOWS.md)
706
710
  - [连续性抢救(#249 N3):触发器口径与事件契约](docs/CONTINUITY.md)
707
711
  - [Sleep Mode:空闲四阶段深度维护](docs/SLEEP.md)
708
712
  - [存储与回收:表结构、保留期、`dsh-mneme reclaim`](docs/STORAGE.md)
@@ -92,6 +92,7 @@
92
92
  | `summarizeMaxEntriesPerRun` | `0` | 单次蒸馏最多落库条数(≤50) | 0 = 不限 |
93
93
  | `summarizeMinWindowChars` | `0` | 窗口可蒸馏文本下限(字符),不足直接跳过 LLM(#239) | 被挡窗口照常消费游标并留 skip 审计 |
94
94
  | `summarizeMaxRunsPerSession` | `0` | 每会话蒸馏次数预算,只计真实发起的调用(≤1000) | 0 = 不限;进程内计数,重启清零 |
95
+ | `summarizeParseFailureMaxRetries` | `0` | 连续解析失败几次后放弃当前窗口(0–10,0 = 关,#493) | 达到阈值强制消费游标跳过该窗口;失败行 metadata 恒带 `raw_head`/`raw_has_bracket`/`streak` |
95
96
  | `summarizePeakHours` | `""` | 高峰时段串(`"09:00-18:00"` / `"mon-fri 08:00-12:00,14:00-18:00"`,支持跨零点) | 命中高峰不调 LLM、顺延补跑;任一写法非法整串按未配置处理 |
96
97
  | `summarizePeakMaxDeferMinutes` | `120` | 高峰顺延上限(分钟,≤1440) | 0 = 不设上限;到点仍处高峰照跑(bypassPeak) |
97
98
  | `summarizeDedupeMode` | `off` | 落库前去重档位:`off` / `title`(拦完全同名)/ `vector`(同会话语义近邻) | `vector` 复用已有 embedding 列,无 LLM 调用 |
@@ -140,6 +141,8 @@
140
141
  | `escapePromptVariables` | `true` | 注入内容连续花括号转义(#162) | 关 = 原样透传(DSH interpolate 可能 throw) |
141
142
  | `injectTimePrefix` | `false` | 对话开始注入一次当前时间(#34,v0.7.11 误删后随 #333 恢复) | opt-in;格式与键名与 v0.7.2 一致,存量 feature_flags 恢复即生效 |
142
143
  | `personaEnabled` | `false` | 人格段:用户自写的一小段常驻注入文本 | opt-in;只是轻量常驻文本、不引入重型后台路径,故不进 `LIGHT_MODE_OFF` |
144
+ | `personaStrictAddressing` | `false` | 人格寻址口径。关(历史行为):会话身份没建卡、或该卡被显式关掉时,落 `default` 卡 | 开:这两种情况都不注入人格,`default` 只兜「解析不到会话身份」的无名会话。用途是宿主里跑着自带身份的外部 bot 时,别让某个人格无条件进它每一帧。默认关 = 升级零变化 |
145
+ | `profileExtractEnabled` **light** | `false` | 画像提取草稿:`POST /api/dsh-mneme/profile/extract` 从活跃记忆反向写一段「助手眼中的用户」草稿(面板「用户画像」卡上的按钮) | opt-in;一次调用一轮 LLM + 读上百行记忆,故 `LIGHT_MODE_OFF` 里有它。**只出草稿**:它不写 `user_settings`,采用草稿是面板里另一次显式保存——画像正文必须是用户手写的原文。空库直接报 `no-input`、模型没按格式回报 `unparseable`,两者都不回空草稿 |
143
146
 
144
147
  ## 巩固(autoDream)
145
148
 
@@ -283,8 +286,10 @@
283
286
  | `externalApiHost` | `127.0.0.1` | 绑定地址 | 改成非回环 = 把整个记忆库暴露给网络,自负其责 |
284
287
  | `disableMemorySearch` | `false` | 对模型隐藏 `memory_search` 工具(v0.8.5) | 慢/轻量模型的工具往返节流;隐藏即不可调 |
285
288
  | `disableMemoryArchive` | `false` | 对模型隐藏 `memory_archive` 工具 | 同上 |
286
- | `actionPipelineEnabled` | `false` | M.O.(手法)只读视图的门控:做法图谱 + 对话行结算卡 | opt-in beta;只是纯 UI 入口、不引入重型后台路径,故不进 `LIGHT_MODE_OFF`。写账另有独立的执行记账键(`/mo-run`),看图 ≠ 写账 |
289
+ | `organizeEnabled` | `false` | 对模型开放 `memory_organize` 工具(#231:先 dryRun 出比对报告,agent 判断后 apply 落库) | 与上面两个**反向**:那两个藏默认在场的工具,本键开默认不在场的能力。关 = 工具根本不注册(模型看不到就不会调)。dryRun→apply 是两步协议,弱模型容易漏 `run_id` 或跳过报告,所以 `LIGHT_MODE_OFF` 里有它;apply 只能引用一次真实报告、且一份报告只许落地一次 |
290
+ | `actionPipelineEnabled` | `false` | M.O. 只读视图的门控:做法图谱 + 对话行结算卡 | opt-in beta;只是纯 UI 入口、不引入重型后台路径,故不进 `LIGHT_MODE_OFF`。写账另有独立的执行记账键(`/mo-run`),看图 ≠ 写账 |
287
291
  | `actionRunEnabled` | `false` | M.O. 执行记账:`/mo-run` 开一次执行窗口,窗口内命令执行才可能被记成一条 run | opt-in beta;挂在热路径(每个会话事件都过一次监听),故 `LIGHT_MODE_OFF` 里有它 |
292
+ | `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,进仓库必须是人的显式动作 |
288
293
 
289
294
  ## 运行时与本地模型
290
295
 
@@ -316,8 +321,10 @@
316
321
  `autoReindexOnBoot` · `hybridInject` · `injectGuidanceEnabled` · `searchSemanticDedup` ·
317
322
  `selectiveInjectEnabled` · `bm25SearchEnabled` · `entityRecallEnabled` ·
318
323
  `graphAnchoringEnabled` · `graphWeightEnabled` · `graphInjectHint` · `graphPassiveConfirm` ·
319
- `actionRunEnabled` ·
324
+ `actionRunEnabled` · `actionNudgeEnabled` ·
320
325
  `dreamNarrativeEnabled` · `documentMemoryEnabled` · `heatEnabled` · `continuityRescueEnabled` ·
326
+ `organizeEnabled`(#231) ·
327
+ `profileExtractEnabled`(#497) ·
321
328
  `preInjectGate.enabled`(#380) · `distillRecords.enabled`(#399)
322
329
 
323
330
  预设只是默认值而非强制:用户显式写进 feature_flags 的值在装配顺序上后展开、仍然生效
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
@@ -9,6 +9,7 @@ import { langOf } from "./lang.js";
9
9
  import { computeHeat } from "./heat.js";
10
10
  import { EFFORT_REJECT_RE, describeStreamFailure, resolveRoute, withEffortFallback } from "./dream.js";
11
11
  import { getInjectionSnapshot } from "./inject.js";
12
+ import { renderUserSettingsBlock } from "./user-settings.js";
12
13
  import { describeLocalRuntime, publicRuntimeStatus } from "./runtime/loader.js";
13
14
  import { hostModulesDir, provisionRuntime } from "./runtime/provision.js";
14
15
  import { classify, fetchLatestVersion, PACKAGE_VERSION } from "./version-check.js";
@@ -18,6 +19,14 @@ import { loadRunsById, runsDir, RUNS_SUFFIX } from "./pipeline-runs.js";
18
19
  import { observationDir, loadObservationsById } from "./pipeline-observations.js";
19
20
  import { projectKey } from "./candidates.js";
20
21
  import { readCandidateDoc } from "./candidates.js";
22
+ // M.O. 自动沉淀:/nudge 是 pipeline-nudge.js 的第一个运行时消费者(此前它只有测试在用,
23
+ // 是个孤儿模块——「不能自动沉淀工作流」的根因)。
24
+ import {
25
+ resolveProjectSessionDir,
26
+ nudgeForProject,
27
+ NUDGE_BOUNDARIES,
28
+ NUDGE_AUTO_BUDGET_MS
29
+ } from "./pipeline-nudge.js";
21
30
 
22
31
  // headers:少数端点(/export 附件下载)需要追加 Content-Disposition 等响应头。
23
32
  function sendJson(res, status, payload, headers = {}) {
@@ -174,7 +183,7 @@ function parseBody(text) {
174
183
  // maintenance 是第 9 个位置参数,默认 null:既有调用点(测试里的 14 处)全是位置传参、
175
184
  // 不传它,必须继续工作——不传时 /maintenance/reclaim 回 503 maintenance-unavailable,
176
185
  // 而不是把「没接线」伪装成崩溃(#386 第 4 条)。
177
- export function createApi(ctx, service, settings, commands, embedder, semantic = null, apiToken = "", config = null, maintenance = null) {
186
+ export function createApi(ctx, service, settings, commands, embedder, semantic = null, apiToken = "", config = null, maintenance = null, profileExtract = null) {
178
187
  const disposers = [];
179
188
 
180
189
  // #182 注入状态探测:面板状态页的「极简模式下注入按宿主设计关闭」提示需要
@@ -480,6 +489,49 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
480
489
  }
481
490
  });
482
491
 
492
+ // --- 画像提取(issue #497):一次性从活跃记忆出「助手眼中的用户」**草稿** ------
493
+ // 语义上它是「读 + 一次模型调用」,不是写端点:草稿回给面板,采用与否是用户在那里
494
+ // 另一次显式 PUT /profile。所以这个路由永远不碰 user_settings——画像正文是用户
495
+ // 手写的原文,让模型合并等于给它改写权,而改写后的文本每帧都注入。
496
+ register({
497
+ kind: "exact",
498
+ path: "/api/dsh-mneme/profile/extract",
499
+ // policy 是 `token` 而不是 `token-write`:这条路由**没有读分支**(非 POST 一律 405),
500
+ // 声明成 token-write 会让 SECURITY.md 的开放读清单按「GET 开放」把它算进去——
501
+ // 那张表是代码的投影,不是愿望清单(test/security-doc.test.js 就是钉这件事的)。
502
+ policy: "token",
503
+ handler(req, res) {
504
+ // 方法白名单在碰任何副作用之前:#489 的教训是「只拦 POST/PUT,其余直落
505
+ // run(null)」——那等于给带 token 的 PATCH/DELETE 开了一条静默花一次 LLM 的口子。
506
+ // 顺序也要紧:405 若挪到 readBody 之后,一个不带 body 的方法会把请求永远挂在
507
+ // 读体上(把这道判断改成只拦 GET 时实测卡住整条用例),等于一个免费的挂起面。
508
+ if (req.method !== "POST") {
509
+ res.writeHead(405, { Allow: "POST" });
510
+ res.end("");
511
+ return undefined;
512
+ }
513
+ if (config?.profileExtractEnabled !== true) return sendJson(res, 409, { error: "disabled" });
514
+ if (typeof profileExtract !== "function") return sendJson(res, 503, { error: "unavailable" });
515
+ return readBody(req, res).then((text) => {
516
+ if (text === null) return undefined; // 超限已在 readBody 里回过 413
517
+ return profileExtract().then(
518
+ (out) => sendJson(res, 200, out),
519
+ (error) => {
520
+ // 「没东西可提」与「模型没配合」必须能被调用方区分:把它们都压成 200 +
521
+ // 空草稿,面板就会把故障显示成「这次没提炼出东西」(#493 同一条教训)。
522
+ const code = typeof error?.code === "string" ? error.code : "";
523
+ if (code === "no-input") return sendJson(res, 409, { error: code });
524
+ // 没配模型是「环境缺东西」,与「模型不配合」两码:前者重跑也一样,后者可重试。
525
+ if (code === "no-route") return sendJson(res, 503, { error: code });
526
+ if (code === "unparseable" || code === "stream-failed") return sendJson(res, 502, { error: code });
527
+ ctx.logger?.warn?.(`dsh-mneme: profile extract failed: ${String(error?.message ?? error)}`);
528
+ return sendJson(res, 500, { error: "internal" });
529
+ }
530
+ );
531
+ });
532
+ }
533
+ });
534
+
483
535
  // --- agent persona(人格定义页,beta) -------------------------------------
484
536
  // 形状照抄 /profile 路由(设计稿②):写方法走 requireAuth + readBody + parseBody。
485
537
  // v1.2 多卡:GET 回全量 { default, byAgent };PUT 双形——单对象形(旧面板)只更新
@@ -511,7 +563,20 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
511
563
  sendJson(res, 200, { persona: settings.getPersona() });
512
564
  });
513
565
  }
514
- sendJson(res, 200, { persona: settings.getPersona() });
566
+ const url = new URL(req.url, "http://localhost");
567
+ // 预览(面板「Agent 眼中的画像」用):由注入器同一个渲染函数产出,前端只显示
568
+ // 返回的文本——以前端自己拼列表冒充「生效文本」是这个面板最容易漂的地方。
569
+ // ?agent=<身份> 让面板能逐张卡预览:传了就按那个身份寻址,没传按「解析不到身份」
570
+ // 走(也就是 default 卡的受众)。
571
+ // 这里不加鉴权是有意的:画像、规则、人格三处在同一条开放读面上本来就能各读一次
572
+ // (GET /profile 与 GET /persona 都无 token 可读),本字段只是把同样的内容拼成
573
+ // 模型实际看到的形状,没有新增可达面;真要锁,配 apiToken(写面与它同源)。
574
+ const preview = renderUserSettingsBlock({
575
+ settings,
576
+ config,
577
+ agentId: url.searchParams.get("agent")
578
+ });
579
+ sendJson(res, 200, { persona: settings.getPersona(), preview });
515
580
  } catch {
516
581
  sendJson(res, 500, { error: "internal" });
517
582
  }
@@ -1849,7 +1914,7 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
1849
1914
  }
1850
1915
  });
1851
1916
 
1852
- // --- M.O.(Modus Operandi · 手法)工作流 ---
1917
+ // --- M.O.(Modus Operandi)工作流 ---
1853
1918
  // 只读端点,读的是**项目里**的 `<项目>/.mneme/pipelines/*.json`(文件头在
1854
1919
  // src/pipeline.js),项目层优先、插件包里那份降级成全局层;投影成
1855
1920
  // mneme.pipeline/v1 交给面板与 agent——两边读同一份,画面只是它的投影。
@@ -1992,6 +2057,116 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
1992
2057
  }
1993
2058
  });
1994
2059
 
2060
+ // --- M.O. 自动沉淀(nudge)---
2061
+ // 读+写两种方法同路:GET 是**预览**(不落盘),POST 是**真跑**(落提议、可落草稿)。
2062
+ // 为什么不全放 GET:GET 会被浏览器预取、被面板刷新反复调用,而这一跑要解压几十份
2063
+ // zstd 会话日志(同步 I/O,本机实测 16.2s)。预览走 GET 时才关掉提议落盘
2064
+ // (persistProposals:false)——否则每刷一次面板就往 memoryDir 里塞一批文件。
2065
+ //
2066
+ // policy 是 token-write 而不是 open:GET 免 token(与 /pipeline、/candidates 同级的
2067
+ // 只读预览),POST 要 token——它会写盘,写盘面一律受门控。
2068
+ register({
2069
+ kind: "exact",
2070
+ path: "/api/dsh-mneme/nudge",
2071
+ policy: "token-write",
2072
+ handler(req, res) {
2073
+ const method = String(req.method || "GET").toUpperCase();
2074
+ // 只认这四种。为什么必须在**读 body 之前**拦:下面 persistProposals 是按
2075
+ // 「不是 GET/HEAD」算的,于是 PATCH/DELETE 会被当成写动作,走进 run() 落提议;
2076
+ // 而它又不像 PUT/POST 那样读 body,等于一个不需要 body 的静默写盘口。
2077
+ // 这条 PR 自己的卖点是「GET 只预览、写动作一律要 POST」,那就得把口子堵死
2078
+ // (v0.9.3 评审抓出:带 token 的 PATCH/DELETE 会往 memoryDir 里写提议)。
2079
+ const allowed = method === "GET" || method === "HEAD" || method === "POST" || method === "PUT";
2080
+ if (!allowed) {
2081
+ sendJson(res, 405, { error: "method-not-allowed", allow: "GET, HEAD, POST, PUT" }, { Allow: "GET, HEAD, POST, PUT" });
2082
+ return;
2083
+ }
2084
+ const run = (body) => {
2085
+ try {
2086
+ const url = new URL(req.url, "http://localhost");
2087
+ const project = url.searchParams.get("project");
2088
+ // 与 /pipeline、/candidates 同一条规矩:不是绝对路径就拒掉,不能静默退成全局。
2089
+ if (project !== null && !isAbsolute(project)) {
2090
+ sendJson(res, 400, { error: "project-not-absolute", detail: project });
2091
+ return;
2092
+ }
2093
+ // 预算从**这里**起算,不是一个端点两套预算:归属解析(同步扫桶,最多 500 个)
2094
+ // 与后面的挖掘必须共用同一条截止线,否则「真卡就调 budgetMs」对前一段是空话。
2095
+ // POST(显式真跑)给三倍预算,面板预览给短预算。
2096
+ const previewMethod = method === "GET" || method === "HEAD";
2097
+ const startedAt = Date.now();
2098
+ const budgetMs = previewMethod ? NUDGE_AUTO_BUDGET_MS : NUDGE_AUTO_BUDGET_MS * 3;
2099
+ const deadline = startedAt + budgetMs;
2100
+ // 归属解析先做、单独回执:解析不到桶时要能说清是「这个项目还没跑过会话」
2101
+ // 还是「会话目录读不到」——两者在候选列表上都表现为空。
2102
+ const where = resolveProjectSessionDir({ projectRoot: project, deadline });
2103
+ if (!where.sessionDir) {
2104
+ // no-sessions-dir / unreadable / budget-exceeded / bucket-limit 都是**环境问题**
2105
+ // (读不到、没扫完、没扫全),回 503 让调用方知道这不是答案;
2106
+ // no-bucket / bad-project 是**关于这个项目的答案**,回 200。
2107
+ const envProblem = where.reason === "no-sessions-dir"
2108
+ || where.reason === "unreadable"
2109
+ || where.reason === "budget-exceeded"
2110
+ || where.reason === "bucket-limit";
2111
+ sendJson(res, envProblem ? 503 : 200, {
2112
+ error: envProblem
2113
+ ? (where.reason === "budget-exceeded"
2114
+ ? "nudge-attribution-budget-exceeded"
2115
+ : where.reason === "bucket-limit"
2116
+ ? "nudge-attribution-bucket-limit"
2117
+ : "nudge-sessions-unreadable")
2118
+ : undefined,
2119
+ contract: "mneme.nudge/v1",
2120
+ attribution: where,
2121
+ skipped: true,
2122
+ skippedReason: "no-session-attribution",
2123
+ counts: { candidates: 0, buckets: 0, sessionsWithCandidates: 0, savedProposals: 0, draftsWritten: 0 },
2124
+ available: [],
2125
+ drafts: [],
2126
+ boundaries: NUDGE_BOUNDARIES
2127
+ });
2128
+ return;
2129
+ }
2130
+ const memoryDir = (config && typeof config.memoryDir === "string" && config.memoryDir.trim())
2131
+ ? config.memoryDir
2132
+ : "~/.dsh/memory";
2133
+ const doc = nudgeForProject({
2134
+ projectRoot: project,
2135
+ // 上面已经解析过并判过 400/503,这里传进来,别再扫一遍桶。
2136
+ attribution: where,
2137
+ // now 传起算时刻:nudgeForProject 内部按 now+budgetMs 重新算截止点,这样
2138
+ // 两处算出来是同一条线(传 Date.now() 会让挖掘白拿一段已经花掉的预算)。
2139
+ now: startedAt,
2140
+ memoryDir,
2141
+ // GET 只预览:不落提议、不落草稿。写动作只认 POST/PUT——PATCH/DELETE 已在
2142
+ // 入口回 405,否则它们会落进这个「不是 GET/HEAD 就算写」的分支。
2143
+ persistProposals: method !== "GET" && method !== "HEAD",
2144
+ // 落草稿是「往仓库里塞文件」,必须显式要(body.materialize),默认不落。
2145
+ materialize: body && body.materialize === true,
2146
+ // 与归属解析共用同一条预算线:宁可少看几份并如实标 budgetExceeded,
2147
+ // 也不能让一次面板刷新把宿主主线程按住十几秒。
2148
+ budgetMs
2149
+ });
2150
+ sendJson(res, 200, doc);
2151
+ } catch (error) {
2152
+ sendJson(res, 500, {
2153
+ error: "nudge-failed",
2154
+ detail: String((error && error.message) || error)
2155
+ });
2156
+ }
2157
+ };
2158
+ if (method === "PUT" || method === "POST") {
2159
+ readBody(req, res).then((text) => {
2160
+ // readBody 已经回过 413:null 就是「别再往下走」。
2161
+ if (text === null) return;
2162
+ run(parseBody(text));
2163
+ });
2164
+ return;
2165
+ }
2166
+ run(null);
2167
+ }
2168
+ });
2169
+
1995
2170
  return {
1996
2171
  routes: countRoutes(),
1997
2172
  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 里