diff --git a/AGENTS.md b/AGENTS.md index a2ad0428..b63abda6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -49,7 +49,7 @@ dsh-mneme 是 DSH 宿主的记忆插件(蒸馏 / 注入 / 检索 / 巩固 / sc | 检索辅助 | `src/search/{bm25,adaptive}.js`、`src/vector-index.js`、`src/embedding.js`、`src/local-embedder.js`、`src/reranker.js` | BM25 / 自适应阈值 / 向量索引 / 嵌入 / 重排 | | 热度 | `src/heat.js` | 纯函数遗忘曲线(opt-in) | | 复用统计 | `src/recall-stats.js` | recall_runs 只读聚合(#217,Top-N 召回 + 僵尸率) | -| 工作流 | `src/pipeline.js` + `.mneme/pipelines/*.json`(项目级) | M.O.(手法)读侧:两层来源(项目层优先、包内 `pipelines/` 为全局兜底)→ `mneme.pipeline/v1` 投影([docs/WORKFLOWS.md](dsh-mneme/docs/WORKFLOWS.md)) | +| 工作流 | `src/pipeline.js` + `.mneme/pipelines/*.json`(项目级) | M.O. 读侧:两层来源(项目层优先、包内 `pipelines/` 为全局兜底)→ `mneme.pipeline/v1` 投影([docs/WORKFLOWS.md](dsh-mneme/docs/WORKFLOWS.md)) | | 绕行与候选 | `src/candidates.js`(+ `/candidates/<项目键>/`) | M.O. 的出口与草稿层:绕行的分档判定(R0/R1 记账先走、R2/R3 停下问人 + 机械兜底清单 + 闸门保护)、候选的归并/归档/升格(进仓库要人批) | | 冷启动 | `src/bootstrap.js` | 从仓库文件反向构建初始记忆(POST /bootstrap) | | 配置 | `src/config.js`(schema + lightMode)、`src/settings.js`(feature flags 白名单) | 一切行为开关的家 | diff --git a/README.md b/README.md index 08d6178f..0a902e1f 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ | 日期 | 版本 | 头条 | |------|------|------| -| 2026-10-17 | **v0.9.0** | **M.O.(手法)beta** — 你的操作手法不再住在记忆库的一行文字里,而是住进项目仓库的 `.mneme/pipelines/.json`:能提交、能 diff、能 PR、能被 CI 读。面板画的图和 agent 读的是同一份数据。配套「候选分层」:草稿先在 `/candidates/` 攒着,走过两次**且**写得出反事实才提议升格——**升格不是降低标准,是补齐标准**。 | +| 2026-10-17 | **v0.9.0** | **M.O. beta** — 你的工作流不再住在记忆库的一行文字里,而是住进项目仓库的 `.mneme/pipelines/.json`:能提交、能 diff、能 PR、能被 CI 读。面板画的图和 agent 读的是同一份数据。配套「候选分层」:草稿先在 `/candidates/` 攒着,走过两次**且**写得出反事实才提议升格——**升格不是降低标准,是补齐标准**。 | | 2026-10-17 | **v0.9.0** | **设置面板重排**([#427](https://github.com/slow-stack/mneme/issues/427))— 折叠线从「冷门」挪到「新手之外」:首屏只留 3 个总开关 + 作用域组,其余整组收进「全部设置」;22 个条目名从 `bm25SearchEnabled` 改说人话,并标注「默认 / 已改」。 | | 2026-10-17 | 加固 | **安全审计第一批**([#462](https://github.com/slow-stack/mneme/pull/462))— 外部访问 API 的 39 条路由全部改成**声明式鉴权**:漏写策略在装配期直接抛错,而不是运行时静默放行;`/export` 与 `GET /external-api` 补上鉴权门(此前未鉴权即可导出含归档/已遗忘的整库、并就地铸造 token);请求体加 4 MiB 上限。 | | 2026-10-17 | 加固 | **路由策略重构**([#464](https://github.com/slow-stack/mneme/pull/464))— 鉴权从「每个 handler 自己记得写」变成「路由注册的必填字段」,23 处手写校验归零。供应链侧同步收紧:OSV 扫描改为有漏洞即失败、npm 发布带 `--provenance` 签名、secret scanning 与 Dependabot 打开。 | @@ -243,7 +243,7 @@ dsh-mneme-serve # 默认 ~/.dsh/memory + 127.0.0.1:8790 | 配置说明(全键参考) | [dsh-mneme/docs/CONFIGURATION.md](dsh-mneme/docs/CONFIGURATION.md) | | 蒸馏原文留存与记忆级溯源(#399) | [dsh-mneme/docs/DISTILL.md](dsh-mneme/docs/DISTILL.md) | | 实体结构化设计 | [dsh-mneme/docs/ENTITIES.md](dsh-mneme/docs/ENTITIES.md) | -| 工作流(M.O. · 手法):本体住仓库、证据住记忆库 | [dsh-mneme/docs/WORKFLOWS.md](dsh-mneme/docs/WORKFLOWS.md) | +| 工作流(M.O.):本体住仓库、证据住记忆库 | [dsh-mneme/docs/WORKFLOWS.md](dsh-mneme/docs/WORKFLOWS.md) | | 语义架构 | [dsh-mneme/docs/SEMANTIC.md](dsh-mneme/docs/SEMANTIC.md) | | 本地模型部署指南 | [dsh-mneme/docs/LOCAL_MODEL.md](dsh-mneme/docs/LOCAL_MODEL.md) | | 与 Zep / Mem0 / Letta / Hindsight 的能力对比 | [docs/COMPARISON.md](docs/COMPARISON.md) | @@ -253,7 +253,7 @@ dsh-mneme-serve # 默认 ~/.dsh/memory + 127.0.0.1:8790 ## 🗺️ 路线图 ``` -🧬 记忆基因 → 🛡️ 审计加固 → 💤 睡眠维护 → 🕸️ 召回融合与图谱 → ✨ 面板增强 → 🌡️ 自进化记忆 → 🔐 作用域隔离 → 🌐 生态(MCP / daemon)→ 🧭 M.O. 手法(项目级工作流) +🧬 记忆基因 → 🛡️ 审计加固 → 💤 睡眠维护 → 🕸️ 召回融合与图谱 → ✨ 面板增强 → 🌡️ 自进化记忆 → 🔐 作用域隔离 → 🌐 生态(MCP / daemon)→ 🧭 M.O.(项目级工作流) ``` | 版本 | 主题 | 状态 | @@ -264,7 +264,7 @@ dsh-mneme-serve # 默认 ~/.dsh/memory + 127.0.0.1:8790 | **v0.6** | 会话生命周期:删对话 ≠ 删记忆 | ✅ | | **v0.7** | 自进化记忆:热度衰减 + 睡眠双保护 + 桌面端工作台/功能开关 | ✅ | | **v0.8** | 作用域隔离(agent/workspace 双维隔离 + 检索加权 + opt-in 硬过滤)+ 冲突队列人工裁决 + 归属显式声明 + 生态化(stdio MCP server / 图召回轴 / 冷启动 / 注入截断与状态条 / 蒸馏可靠性 / 注入形态与 agent 主动整理接口 / 配置说明与 issue 模板) | ✅ 已发布(至 v0.8.16) | -| **v0.9** | M.O.(Modus Operandi · 手法)beta:项目级工作流本体 + 候选分层与升格判据 + 面板清单/图同屏;设置面板按「新手之外」重划折叠线(#427);安全审计与供应链加固(声明式路由鉴权、OSV 阻断、发布签名);**v0.9.1 补上写侧**:执行窗口、运行账、归属候选、观察旁挂与脱节检出、步骤 `assert`/`optional`、沉淀链草稿挖掘、人格定义页(按 agent 多卡);**v0.9.2 让看不见的失败显形**:功能开关第三态「待重启」、作用域级可逆闸门(#471)、巩固 `max_tokens` 超上限自动降级(#478) | ✅ 已发布(v0.9.2) | +| **v0.9** | M.O.(Modus Operandi)beta:项目级工作流本体 + 候选分层与升格判据 + 面板清单/图同屏;设置面板按「新手之外」重划折叠线(#427);安全审计与供应链加固(声明式路由鉴权、OSV 阻断、发布签名);**v0.9.1 补上写侧**:执行窗口、运行账、归属候选、观察旁挂与脱节检出、步骤 `assert`/`optional`、沉淀链草稿挖掘、人格定义页(按 agent 多卡);**v0.9.2 让看不见的失败显形**:功能开关第三态「待重启」、作用域级可逆闸门(#471)、巩固 `max_tokens` 超上限自动降级(#478) | ✅ 已发布(v0.9.2) | > 完整逐小版本说明见 [CHANGELOG](dsh-mneme/CHANGELOG.md)。 diff --git a/SECURITY.md b/SECURITY.md index bda2b4b4..9ecba7d1 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -153,8 +153,8 @@ The token is a lock on the **write** surface, not a wall around the whole API. - **The real boundary is loopback + the DSH host listener.** The host-side routes (`/api/dsh-mneme/*`) are mounted on the DSH port, which binds `127.0.0.1` and is fenced by the host against DNS rebinding and cross-site browser requests. That fence does **not** establish identity: any local process, and any page running in the host's own origin, is inside the perimeter. - **Authentication is opt-in and fail-open.** `apiToken` defaults to empty; while it is empty, `isAuthorized()` returns true for every request — reads *and* writes. Setting a token gates the write routes and the sensitive reads; it does not gate plain reads. -- **Open reads (no token even when one is configured)**: `GET /`, `/info`, `/version-check`, `/list`, `/recall-stats`, `/search`, `/semantic`, `/semantic/llm-audit`, `/semantic/llm-audit/stats`, `/semantic/graph/*`, `/entities`, `/memories/entities`, `/dream-status`, `/inject-status`, `/inject-preview`, `/pipeline`, `/candidates`, `/llm-providers`, plus the `GET`/`HEAD` branches of `/profile`, `/rules`, `/persona`, `/mode`, `/features`, `/commands`. Treat them as readable by anything local. -- **Gated routes (when a token is configured)**: every write — `/create`, `/update`, `/delete`, `/import`, `/vector-config`, `/vector-reindex`, `/runtime/provision`, `/maintenance/reclaim`, `/test-model`, `/conflicts/resolve`, and the non-`GET` branches of `/profile`, `/rules`, `/persona`, `/mode`, `/features`, `/commands` — plus four reads: `/export`, `/external-api`, `/conflicts`, `/health`. +- **Open reads (no token even when one is configured)**: `GET /`, `/info`, `/version-check`, `/list`, `/recall-stats`, `/search`, `/semantic`, `/semantic/llm-audit`, `/semantic/llm-audit/stats`, `/semantic/graph/*`, `/entities`, `/memories/entities`, `/dream-status`, `/inject-status`, `/inject-preview`, `/pipeline`, `/candidates`, `/llm-providers`, plus the `GET`/`HEAD` branches of `/profile`, `/rules`, `/persona`, `/mode`, `/features`, `/commands`, `/nudge`. Treat them as readable by anything local. +- **Gated routes (when a token is configured)**: every write — `/create`, `/update`, `/delete`, `/import`, `/vector-config`, `/vector-reindex`, `/runtime/provision`, `/maintenance/reclaim`, `/test-model`, `/conflicts/resolve`, and the non-`GET` branches of `/profile`, `/rules`, `/persona`, `/mode`, `/features`, `/commands`, `/nudge` — plus four reads: `/export`, `/external-api`, `/conflicts`, `/health`. - **The standalone data plane** (`127.0.0.1:8790`) is the opposite shape: every route except `GET /health` requires its own Bearer token. Pointing `externalApiHost` at a non-loopback address therefore turns it into a plaintext, token-only HTTP surface — an explicit operator decision. - **Consequence**: if you expose the plugin beyond loopback (reverse proxy, non-loopback bind), set `apiToken` *and* check that the plain reads above are acceptable to expose — they are not covered by it. - **How this list stays true**: every route declares its own `policy` (`open` / `token` / `token-write`) at registration, and `createRouteRegistrar` throws when a route has none or reuses a path. A new route that forgets its classification **fails to boot** instead of silently opening. The two lists above are compared against the routes actually mounted by `dsh-mneme/test/security-doc.test.js` in both directions, so adding a route without updating this section fails CI rather than quietly drifting — the matrix is derived from code, not from anyone's memory. @@ -534,8 +534,8 @@ Token 是**写**面上的锁,不是把整个 API 围起来的墙。 - **真正的边界是回环 + 宿主监听器。** 宿主侧路由(`/api/dsh-mneme/*`)挂在 DSH 端口上,该端口绑 `127.0.0.1`,并由宿主挡掉 DNS rebinding 与跨站浏览器请求。这道围栏**不建立身份**:任何本地进程、以及任何跑在宿主同源页面里的脚本,都在这道围栏之内。 - **鉴权是 opt-in 且 fail-open。** `apiToken` 默认为空;为空期间 `isAuthorized()` 对每个请求都返回 true——读和写都放行。配了 token 之后,它守的是写路由与敏感读,不守普通读。 -- **开放读(即使配了 token 也不需要)**:`GET /`、`/info`、`/version-check`、`/list`、`/recall-stats`、`/search`、`/semantic`、`/semantic/llm-audit`、`/semantic/llm-audit/stats`、`/semantic/graph/*`、`/entities`、`/memories/entities`、`/dream-status`、`/inject-status`、`/inject-preview`、`/pipeline`、`/candidates`、`/llm-providers`,以及 `/profile`、`/rules`、`/persona`、`/mode`、`/features`、`/commands` 的 `GET`/`HEAD` 分支。把它们当作「本地任何东西都能读」。 -- **受门控的路由(配了 token 之后)**:全部写操作——`/create`、`/update`、`/delete`、`/import`、`/vector-config`、`/vector-reindex`、`/runtime/provision`、`/maintenance/reclaim`、`/test-model`、`/conflicts/resolve`,以及 `/profile`、`/rules`、`/persona`、`/mode`、`/features`、`/commands` 的非 `GET` 分支——外加四条读:`/export`、`/external-api`、`/conflicts`、`/health`。 +- **开放读(即使配了 token 也不需要)**:`GET /`、`/info`、`/version-check`、`/list`、`/recall-stats`、`/search`、`/semantic`、`/semantic/llm-audit`、`/semantic/llm-audit/stats`、`/semantic/graph/*`、`/entities`、`/memories/entities`、`/dream-status`、`/inject-status`、`/inject-preview`、`/pipeline`、`/candidates`、`/llm-providers`,以及 `/profile`、`/rules`、`/persona`、`/mode`、`/features`、`/commands`、`/nudge` 的 `GET`/`HEAD` 分支。把它们当作「本地任何东西都能读」。 +- **受门控的路由(配了 token 之后)**:全部写操作——`/create`、`/update`、`/delete`、`/import`、`/vector-config`、`/vector-reindex`、`/runtime/provision`、`/maintenance/reclaim`、`/test-model`、`/conflicts/resolve`,以及 `/profile`、`/rules`、`/persona`、`/mode`、`/features`、`/commands`、`/nudge` 的非 `GET` 分支——外加四条读:`/export`、`/external-api`、`/conflicts`、`/health`。 - **独立数据面**(`127.0.0.1:8790`)是反过来的形状:除 `GET /health` 外每条路由都要自己的 Bearer token。因此把 `externalApiHost` 指向非回环地址,就等于把它变成一个「明文、只靠 token」的 HTTP 面——这是运营者的显式决定。 - **推论**:如果你把插件暴露到回环之外(反代、绑非回环地址),请设 `apiToken`,**并**核对上面那串开放读是否也能被看到——它们不受 token 保护。 - **这张表怎么保持为真**:每条路由在注册时自己声明 `policy`(`open` / `token` / `token-write`),`createRouteRegistrar` 在路由没声明或复用同一路径时直接抛错。新路由忘了归类会**启动失败**,而不是静默开门。上面两张清单还会被 `dsh-mneme/test/security-doc.test.js` 拿真实装配出来的路由做双向比对——加了路由却不更新这一节,CI 会红,而不是悄悄漂走。这张矩阵来自代码,不来自谁的记忆。 diff --git a/dsh-mneme/CHANGELOG.md b/dsh-mneme/CHANGELOG.md index 55c3c3ee..4d488551 100644 --- a/dsh-mneme/CHANGELOG.md +++ b/dsh-mneme/CHANGELOG.md @@ -2,6 +2,22 @@ ## [Unreleased] +## 🆕 新增 + +- **沉淀链接线:把「会自动沉淀工作流」从一句话变成一条能跑的路(`actionNudgeEnabled`,beta,默认关)**:`src/pipeline-nudge.js` 从 v0.9.1 起就完整实现了「多帧 zstd → 重复序列 → 缺席对照 → draft 草稿」,可它在运行时**没有任何调用点**——全仓引用只出现在 `test/pipeline-nudge.test.js` 里,也就是说机器挖得出来、却永远不会被叫去挖。缺的从来不是算法,是**归属**与**触发**:`nudgeFromSessions` 要的是一个项目的会话桶目录,而桶名是路径的**有损**变形(规则不在本仓),唯一可信来源是会话头里的 `cwd`。这一版把三处接上:①`resolveProjectSessionDir` 用 `cwd` 反查桶,查不到就**整跑跳过**(`skipped:true`)而不是降级成全局普查——后者会把 A 项目的动作算到 B 头上,而结果看起来完全正常;②新增 `nudgeForProject` 把「归属 → 挖掘 → 落盘」收成一个入口,调用方没有机会漏掉归属解析;③新增 `src/pipeline-nudge-auto.js` 挂在 `turn/end` 上按项目跑,冷却 10 分钟(**观测新鲜度**参数,不是性能参数)、单次预算 4 秒、延到 `setTimeout(0)` 的宏任务里跑(解压会话日志是同步 I/O,压在回合收尾那一帧上等于让每轮都卡一下)、一次只跑一个项目、抛错只 `warn` 不冒泡(`session/event` 那条链上还挂着摘要与续期)。**它只产提议,不产草稿**:自动跑恒 `materialize:false`,落进 `/proposals/<项目键>/`;升格仍然要人批,`boundaries` 里那句 `autoActivate:false` 与跳过分支共用同一个常量(`NUDGE_BOUNDARIES`)——两条出口说两句不同的话,读的人就会以为其中一条忘了做。旗标与看图(`actionPipelineEnabled`)、记账(`actionRunEnabled`)**三件互相独立**,开任何一个都不会顺带打开另一个;它进 `LIGHT_MODE_OFF`,是三个 M.O. 键里最重的一个。 + +## 🎨 面板 + +- **M.O. 面板多了「现在沉淀一次」:自动沉淀只在 `turn/end` 之后跑、还有冷却,人想立刻看结果时原本没有入口**:按钮走 `POST /api/dsh-mneme/nudge`,与自动跑**同一条** `nudgeForProject`,区别只有 `materialize` 恒关——它写提议、不改仓库。九句回执全部中英双语齐全,且刻意把三种结果说成三句不同的话:`saved`(提出 n 条)、`none`(这一遍没挖到新的重复动作)、`unreadable`(会话日志读不出来,是环境问题)。后两者在界面上长得一样时,人就只能瞎猜是「没东西」还是「坏了」。会话里读不到 `cwd` 时按钮**禁用**(服务端只会回 400,点了必然失败的按钮比没有按钮更糟);禁用态用虚线边而不是降透明度——整块降 `opacity` 会把文字一起压到 AA 以下。顺带把 `lib/client.js` 里最后三处「手法」改词,并把 `actionPipelineEnabled` 的 hint 从「沉淀和触发都还没做,建议暂时保持关闭」改写成三件能力的真实分工。 + +## 📚 文档 + +- **`docs/WORKFLOWS.md` 新增 §10「自动沉淀:工作流从哪长出来」**:§1 里「一条新工作流从哪来」此前一直是零代码,这一节补上来源——产物表(提议/草稿/定稿各住哪、谁批准)、归属为什么不能拿全机会话去挖、四种 `reason` 里 `unreadable` 必须与 `no-bucket` 分开(前者是故障、后者是关于这个项目的答案,混起来等于把故障报成空结果,端点层因此一个回 503、一个回 200)、触发控制表(`turn/end` / 10 分钟 / 4 秒 / `setTimeout(0)` / 一次一个,每行都写了为什么是这个值),以及「提议落盘为什么必须单独一个开关」:`nudgeFromSessions` 原本只要算得出项目键就**无条件**写提议,而面板每刷一次就 GET 一次——那是每刷一次往记忆库里塞一批文件。`persistProposals` 因此独立出来,预览路由显式传 `false`,这也是 `/nudge` 的 policy 是 `token-write` 而不是 `open` 的原因。`SECURITY.md` 英中两张表同步登记(`/nudge` 同时进「开放读」与「受门控」两张清单),`docs/CONFIGURATION.md` 补 `actionNudgeEnabled` 键与轻量档清单。 + +## 🐛 修复 + +- **术语漂移:整个 M.O. 的「手法」改回「工作流」**:M.O.(Modus Operandi)从 v0.9.0 起在正文里一直写作「手法」,而它与同一个功能在其余位置的叫法(`工作流`、`workflow`、`pipelines`)并不是同一个词——一个功能两个名字,读者得先猜它们是不是一回事。改法覆盖 `src/` 24 处、面板 3 处、文档 11 处,**已发版的 CHANGELOG 条目与测试里作为夹具主题词的「手法」不改**(历史不追溯,夹具词锁的是路线图插入逻辑、不是产品术语)。`git grep 手法 -- src` 现为零命中。 + ## [0.9.2] - 2026-10-19 ## 🆕 新增 diff --git a/dsh-mneme/README.md b/dsh-mneme/README.md index 592bbad8..ee49ac77 100644 --- a/dsh-mneme/README.md +++ b/dsh-mneme/README.md @@ -64,7 +64,7 @@ dsh web | `memory_register_document` | 登记 agent 产长文档的指针行(#230:摘要 + `doc_path` + evidence 落 `document` 类型;正文留在磁盘、由 agent 自管,注册口唯一,旁路写入被拒) | | `memory_runtime` | 本地推理运行时的状态 / 供给 / 校验(v0.7.32,自管运行时三档取件) | -### 工作流(M.O. · 手法,v0.9 beta) +### 工作流(M.O.,v0.9 beta) 记忆库答的是「这件事**为什么**这么办」,答不了「这件事**到底怎么跑**」。v0.9 把后者从记忆库里的一行文字变成项目里可提交、可 review、可被 CI 读的一份文件——一条工作流一个 JSON: @@ -267,11 +267,11 @@ v0.3.0 起新增**记忆基因**层:从记忆里抽取**命名实体**、**带 > 完整版本说明见 [CHANGELOG](CHANGELOG.md) 与各版本 [GitHub Release](https://github.com/slow-stack/mneme/releases);当前特性以本 README 正文与[配置表](#-配置)为准。早期版本中的实验性功能(Wiki-Link、tag 系统等)已在 v0.7.11 移除,详见 CHANGELOG 对应条目。 -🧬 Gene → 🛡️ 审计加固 → 💤 Sleep Mode → 🕸️ 召回融合与图谱 → ✨ 面板体验 → 🌡️ 自进化记忆 → 🔐 作用域隔离 → 🌐 MCP 生态 → 🧭 M.O. 手法(项目级工作流,v0.9 beta) +🧬 Gene → 🛡️ 审计加固 → 💤 Sleep Mode → 🕸️ 召回融合与图谱 → ✨ 面板体验 → 🌡️ 自进化记忆 → 🔐 作用域隔离 → 🌐 MCP 生态 → 🧭 M.O.(项目级工作流,v0.9 beta) | 版本 | 主题 | |------|------| -| **v0.9.0**(2026-10-17) | M.O.(Modus Operandi · 手法)beta:工作流本体住 `<项目>/.mneme/pipelines/`、候选分层与升格判据、面板清单与图同屏(`actionPipelineEnabled` 默认关);设置面板折叠线挪到「新手之外」并按人话重写 22 个条目名(#427);安全与供应链加固(39 条路由声明式鉴权、OSV 有漏洞即失败、发布带 provenance) | +| **v0.9.0**(2026-10-17) | M.O.(Modus Operandi)beta:工作流本体住 `<项目>/.mneme/pipelines/`、候选分层与升格判据、面板清单与图同屏(`actionPipelineEnabled` 默认关);设置面板折叠线挪到「新手之外」并按人话重写 22 个条目名(#427);安全与供应链加固(39 条路由声明式鉴权、OSV 有漏洞即失败、发布带 provenance) | | **v0.9.1**(2026-10-18) | M.O. 从「看得见」跑到「跑得出账」:执行窗口由 `/mo-run start` / `/mo-run stop` 显式划开、运行账落 `<项目>/.mneme/pipelines/.runs.jsonl`(账本坏了与从没跑过严格分开)、归属只出候选、观察旁挂与脱节检出、步骤 `assert` / `optional`、R1/R2 提议;沉淀链从真实会话日志挖 draft 工作流(判据恒 `null`,答案只能由人补);人格定义页 v1 → v1.2(按 agent 寻址多卡,默认关);面板、向量与检查更新的一圈修复(#448 / #450 / #460);Release 正文改分层渲染 | | **v0.9.2**(2026-10-19) | 让「看不见的失败」显形:功能开关的第三态「待重启」把「面板显示已改 ≠ 运行时已改」摆到界面上(#481);作用域级可逆闸门让一路记忆能被单独按下与松开、不必改配置重启(#471,默认关);巩固用的 `dreamMaxTokens` 默认值撞服务商上限、八天 180 次全失败,面板现在能改、超上限还会自动降级重试(#478);CLI 把 `--` 开头的值当新旗标的真实缺陷(一次 1/4096 概率的 CI 红揪出来的)、人格卡删除 × 永远不可见、自动蒸馏写入的行 100% 拿不到向量(#477) | | **v0.8.x** | 作用域隔离(agent / workspace 双维 + opt-in 硬过滤)、冲突队列人工裁决、生态化(stdio MCP server / 图召回轴 / 冷启动 / 注入截断) | @@ -702,7 +702,7 @@ npm run sync # 把 src/ 同步到 lib/(发布时由 prepack 钩子自动 - [实体结构化记忆设计](docs/ENTITIES.md) - [语义增强架构](docs/SEMANTIC.md) - [本地模型部署指南](docs/LOCAL_MODEL.md) -- [工作流(M.O. · 手法):本体住项目,证据住记忆库](docs/WORKFLOWS.md) +- [工作流(M.O.):本体住项目,证据住记忆库](docs/WORKFLOWS.md) - [连续性抢救(#249 N3):触发器口径与事件契约](docs/CONTINUITY.md) - [Sleep Mode:空闲四阶段深度维护](docs/SLEEP.md) - [存储与回收:表结构、保留期、`dsh-mneme reclaim`](docs/STORAGE.md) diff --git a/dsh-mneme/docs/CONFIGURATION.md b/dsh-mneme/docs/CONFIGURATION.md index 542e1b51..918f12a7 100644 --- a/dsh-mneme/docs/CONFIGURATION.md +++ b/dsh-mneme/docs/CONFIGURATION.md @@ -283,8 +283,9 @@ | `externalApiHost` | `127.0.0.1` | 绑定地址 | 改成非回环 = 把整个记忆库暴露给网络,自负其责 | | `disableMemorySearch` | `false` | 对模型隐藏 `memory_search` 工具(v0.8.5) | 慢/轻量模型的工具往返节流;隐藏即不可调 | | `disableMemoryArchive` | `false` | 对模型隐藏 `memory_archive` 工具 | 同上 | -| `actionPipelineEnabled` | `false` | M.O.(手法)只读视图的门控:做法图谱 + 对话行结算卡 | opt-in beta;只是纯 UI 入口、不引入重型后台路径,故不进 `LIGHT_MODE_OFF`。写账另有独立的执行记账键(`/mo-run`),看图 ≠ 写账 | +| `actionPipelineEnabled` | `false` | M.O. 只读视图的门控:做法图谱 + 对话行结算卡 | opt-in beta;只是纯 UI 入口、不引入重型后台路径,故不进 `LIGHT_MODE_OFF`。写账另有独立的执行记账键(`/mo-run`),看图 ≠ 写账 | | `actionRunEnabled` | `false` | M.O. 执行记账:`/mo-run` 开一次执行窗口,窗口内命令执行才可能被记成一条 run | opt-in beta;挂在热路径(每个会话事件都过一次监听),故 `LIGHT_MODE_OFF` 里有它 | +| `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,进仓库必须是人的显式动作 | ## 运行时与本地模型 @@ -316,7 +317,7 @@ `autoReindexOnBoot` · `hybridInject` · `injectGuidanceEnabled` · `searchSemanticDedup` · `selectiveInjectEnabled` · `bm25SearchEnabled` · `entityRecallEnabled` · `graphAnchoringEnabled` · `graphWeightEnabled` · `graphInjectHint` · `graphPassiveConfirm` · -`actionRunEnabled` · +`actionRunEnabled` · `actionNudgeEnabled` · `dreamNarrativeEnabled` · `documentMemoryEnabled` · `heatEnabled` · `continuityRescueEnabled` · `preInjectGate.enabled`(#380) · `distillRecords.enabled`(#399) diff --git a/dsh-mneme/docs/WORKFLOWS.md b/dsh-mneme/docs/WORKFLOWS.md index 00090dbb..c9255e77 100644 --- a/dsh-mneme/docs/WORKFLOWS.md +++ b/dsh-mneme/docs/WORKFLOWS.md @@ -1,4 +1,4 @@ -# dsh-mneme 工作流(M.O. · 手法)——本体住项目,证据住记忆库 +# dsh-mneme 工作流(M.O.)——本体住项目,证据住记忆库 - **日期**:2026-10-15 - **状态**:🚧 第一刀(项目级读侧 + 条目清单;执行侧未做) @@ -97,10 +97,14 @@ GET /api/dsh-mneme/pipeline?project=<绝对路径> # 该项目的清单( GET /api/dsh-mneme/pipeline?project=…&id= # 指定一条 GET /api/dsh-mneme/candidates?project=<绝对路径> # 该项目的**候选**(见 §9) + +GET /api/dsh-mneme/nudge?project=<绝对路径> # 沉淀预览:只看不写(见 §10) +POST /api/dsh-mneme/nudge?project=<绝对路径> # 真跑一次:落提议(要 token) ``` -两条路由读的是**两处不同的东西**,别混:`/pipeline` 读定稿(住仓库), -`/candidates` 读候选(住本地)。分开是刻意的,理由见 §9。 +三条路由读的是**三处不同的东西**,别混:`/pipeline` 读定稿(住仓库), +`/candidates` 读候选(住本地),`/nudge` 是**产生**候选的那台机器(见 §10)。 +分开是刻意的,理由分别见 §9 与 §10。 **`project` 必须是绝对路径**,否则回 `400 {"error":"project-not-absolute","detail":"…"}`。 忽略了它就会静默退成「全局」,而全局看起来一切正常——那正是这一版要修掉的那类错。 @@ -297,3 +301,66 @@ JSON,报错本身就是给人看的:`detail` 里是校验器**一次列全** - **不做绕行的自动批准**:一律 R0/R1 记账先走、R2/R3 停下问人,没有「全都可以先走」 - **不把候选塞进注入路径**:它是草稿,不是知识 - **不做「图自动重写」**:图的变化一律经过提议 + 人批;回写是新版本,不是改旧版本 + +## 10. 自动沉淀:工作流从哪长出来 + +§1 说过「一条新工作流从哪来」在那之前一直是零代码。这一节就是那个来源。 + +``` +turn/end → 这个项目自己的会话桶 → 挖重复动作 → memoryDir/proposals/<项目键>/ +``` + +### 10.1 它写什么、不写什么 + +| 产物 | 住哪 | 谁批准 | +|---|---|---| +| **提议**(proposal) | `/proposals/<项目键>/nudge-*.json` | 不需要批准,但**没人看就等于没有** | +| **草稿**(draft 工作流) | `<项目>/.mneme/pipelines/.json` | 人显式要(`materialize: true`),自动跑**永不**落 | +| **定稿**(active) | 同上,换 `status` | 人批,机器永远不给 | + +边界写在答复的 `boundaries` 里,跳过与跑完用的是**同一个常量**(`NUDGE_BOUNDARIES`) +——两条出口说两句不同的话,读的人就会以为其中一条忘了做: + +```jsonc +{ "autoActivate": false, "autoAnswerCounterfactual": false, + "writesOnlyDrafts": true, "riskIsFloor": true, "crossProjectSupport": false } +``` + +### 10.2 归属:为什么不能拿全机会话去挖 + +会话日志在 DSH 侧按**工作区**分桶(`/sessions/<桶>`),而桶名是路径的 +**有损**变形、规则不在本仓。所以「这个项目的工作流」只有在拿到**这个项目自己的桶** +时才成立;归属靠会话头里的 `cwd` 反查(`resolveProjectSessionDir`)。 + +拿不到归属时这一跑**整跑跳过**(`skipped: true`)而不是降级成全局普查:后者会把 +A 项目的动作算到 B 头上,而结果看起来完全正常(复核报告 §2.6 实测过)。 + +`reason` 的四种取值里,**`unreadable` 必须与 `no-bucket` 分开**:前者是「读不到」 +(故障),后者是「确实没有」(关于这个项目的答案)。混起来等于把故障报成空结果。 +端点层因此有两种状态码:`no-sessions-dir` / `unreadable` → **503**,其余 → **200**。 + +### 10.3 触发:为什么不是「读一次就沉淀一次」 + +| 控制 | 值 | 为什么是这个值 | +|---|---|---| +| 触发点 | `turn/end` | 一轮结束才算一个观测窗口,中途记的是半截 | +| 冷却 | `NUDGE_AUTO_MIN_INTERVAL_MS` = 10 分钟/项目 | **观测新鲜度**参数,不是性能参数:日志十分钟内长不出新重复模式 | +| 单次预算 | `NUDGE_AUTO_BUDGET_MS` = 4 秒 | 超了带着已读到的部分收工并标 `budgetExceeded`,**不重试、不补跑** | +| 执行时机 | `setTimeout(0)` | 解压会话日志是同步 I/O(本机 118 份实测 16.2 秒),压在 `turn/end` 那一帧上等于让回合收尾卡住 | +| 排队 | 一次只跑一个项目 | 同上,别把一帧拉长 | + +**开与不开**:`actionNudgeEnabled`(默认关)。它与 `actionPipelineEnabled`(看图)、 +`actionRunEnabled`(记账)是**三件独立能力**,开任何一个都不会顺带打开另一个。 +它在 `LIGHT_MODE_OFF` 里——三个 M.O. 键里它最重(挂 `session/event` + 同步解压)。 + +### 10.4 为什么提议落盘要单独一个开关 + +`nudgeFromSessions` 原本只要算得出项目键就**无条件**往 memoryDir 里写提议。面板每刷 +一次就 GET 一次,那样每刷一次就往记忆库里塞一批文件。所以有了 `persistProposals` +(默认 `true` 保持既有语义),预览路由显式传 `false`: + +- `GET` / `HEAD` → `persistProposals: false`,**一个文件都不写**; +- `POST` / `PUT` → `persistProposals: true`,落提议。 + +这也是 `/nudge` 的 `policy` 是 `token-write` 而不是 `open` 的原因:GET 与 +`/pipeline`、`/candidates` 同级(只读预览,免 token),POST 会写盘,写盘面一律受门控。 diff --git a/dsh-mneme/lib/api.js b/dsh-mneme/lib/api.js index 6bfbf393..22a41290 100644 --- a/dsh-mneme/lib/api.js +++ b/dsh-mneme/lib/api.js @@ -18,6 +18,14 @@ import { loadRunsById, runsDir, RUNS_SUFFIX } from "./pipeline-runs.js"; import { observationDir, loadObservationsById } from "./pipeline-observations.js"; import { projectKey } from "./candidates.js"; import { readCandidateDoc } from "./candidates.js"; +// M.O. 自动沉淀:/nudge 是 pipeline-nudge.js 的第一个运行时消费者(此前它只有测试在用, +// 是个孤儿模块——「不能自动沉淀工作流」的根因)。 +import { + resolveProjectSessionDir, + nudgeForProject, + NUDGE_BOUNDARIES, + NUDGE_AUTO_BUDGET_MS +} from "./pipeline-nudge.js"; // headers:少数端点(/export 附件下载)需要追加 Content-Disposition 等响应头。 function sendJson(res, status, payload, headers = {}) { @@ -1849,7 +1857,7 @@ export function createApi(ctx, service, settings, commands, embedder, semantic = } }); - // --- M.O.(Modus Operandi · 手法)工作流 --- + // --- M.O.(Modus Operandi)工作流 --- // 只读端点,读的是**项目里**的 `<项目>/.mneme/pipelines/*.json`(文件头在 // src/pipeline.js),项目层优先、插件包里那份降级成全局层;投影成 // mneme.pipeline/v1 交给面板与 agent——两边读同一份,画面只是它的投影。 @@ -1992,6 +2000,116 @@ export function createApi(ctx, service, settings, commands, embedder, semantic = } }); + // --- M.O. 自动沉淀(nudge)--- + // 读+写两种方法同路:GET 是**预览**(不落盘),POST 是**真跑**(落提议、可落草稿)。 + // 为什么不全放 GET:GET 会被浏览器预取、被面板刷新反复调用,而这一跑要解压几十份 + // zstd 会话日志(同步 I/O,本机实测 16.2s)。预览走 GET 时才关掉提议落盘 + // (persistProposals:false)——否则每刷一次面板就往 memoryDir 里塞一批文件。 + // + // policy 是 token-write 而不是 open:GET 免 token(与 /pipeline、/candidates 同级的 + // 只读预览),POST 要 token——它会写盘,写盘面一律受门控。 + register({ + kind: "exact", + path: "/api/dsh-mneme/nudge", + policy: "token-write", + handler(req, res) { + const method = String(req.method || "GET").toUpperCase(); + // 只认这四种。为什么必须在**读 body 之前**拦:下面 persistProposals 是按 + // 「不是 GET/HEAD」算的,于是 PATCH/DELETE 会被当成写动作,走进 run() 落提议; + // 而它又不像 PUT/POST 那样读 body,等于一个不需要 body 的静默写盘口。 + // 这条 PR 自己的卖点是「GET 只预览、写动作一律要 POST」,那就得把口子堵死 + // (v0.9.3 评审抓出:带 token 的 PATCH/DELETE 会往 memoryDir 里写提议)。 + const allowed = method === "GET" || method === "HEAD" || method === "POST" || method === "PUT"; + if (!allowed) { + sendJson(res, 405, { error: "method-not-allowed", allow: "GET, HEAD, POST, PUT" }, { Allow: "GET, HEAD, POST, PUT" }); + return; + } + const run = (body) => { + try { + const url = new URL(req.url, "http://localhost"); + const project = url.searchParams.get("project"); + // 与 /pipeline、/candidates 同一条规矩:不是绝对路径就拒掉,不能静默退成全局。 + if (project !== null && !isAbsolute(project)) { + sendJson(res, 400, { error: "project-not-absolute", detail: project }); + return; + } + // 预算从**这里**起算,不是一个端点两套预算:归属解析(同步扫桶,最多 500 个) + // 与后面的挖掘必须共用同一条截止线,否则「真卡就调 budgetMs」对前一段是空话。 + // POST(显式真跑)给三倍预算,面板预览给短预算。 + const previewMethod = method === "GET" || method === "HEAD"; + const startedAt = Date.now(); + const budgetMs = previewMethod ? NUDGE_AUTO_BUDGET_MS : NUDGE_AUTO_BUDGET_MS * 3; + const deadline = startedAt + budgetMs; + // 归属解析先做、单独回执:解析不到桶时要能说清是「这个项目还没跑过会话」 + // 还是「会话目录读不到」——两者在候选列表上都表现为空。 + const where = resolveProjectSessionDir({ projectRoot: project, deadline }); + if (!where.sessionDir) { + // no-sessions-dir / unreadable / budget-exceeded / bucket-limit 都是**环境问题** + // (读不到、没扫完、没扫全),回 503 让调用方知道这不是答案; + // no-bucket / bad-project 是**关于这个项目的答案**,回 200。 + const envProblem = where.reason === "no-sessions-dir" + || where.reason === "unreadable" + || where.reason === "budget-exceeded" + || where.reason === "bucket-limit"; + sendJson(res, envProblem ? 503 : 200, { + error: envProblem + ? (where.reason === "budget-exceeded" + ? "nudge-attribution-budget-exceeded" + : where.reason === "bucket-limit" + ? "nudge-attribution-bucket-limit" + : "nudge-sessions-unreadable") + : undefined, + contract: "mneme.nudge/v1", + attribution: where, + skipped: true, + skippedReason: "no-session-attribution", + counts: { candidates: 0, buckets: 0, sessionsWithCandidates: 0, savedProposals: 0, draftsWritten: 0 }, + available: [], + drafts: [], + boundaries: NUDGE_BOUNDARIES + }); + return; + } + const memoryDir = (config && typeof config.memoryDir === "string" && config.memoryDir.trim()) + ? config.memoryDir + : "~/.dsh/memory"; + const doc = nudgeForProject({ + projectRoot: project, + // 上面已经解析过并判过 400/503,这里传进来,别再扫一遍桶。 + attribution: where, + // now 传起算时刻:nudgeForProject 内部按 now+budgetMs 重新算截止点,这样 + // 两处算出来是同一条线(传 Date.now() 会让挖掘白拿一段已经花掉的预算)。 + now: startedAt, + memoryDir, + // GET 只预览:不落提议、不落草稿。写动作只认 POST/PUT——PATCH/DELETE 已在 + // 入口回 405,否则它们会落进这个「不是 GET/HEAD 就算写」的分支。 + persistProposals: method !== "GET" && method !== "HEAD", + // 落草稿是「往仓库里塞文件」,必须显式要(body.materialize),默认不落。 + materialize: body && body.materialize === true, + // 与归属解析共用同一条预算线:宁可少看几份并如实标 budgetExceeded, + // 也不能让一次面板刷新把宿主主线程按住十几秒。 + budgetMs + }); + sendJson(res, 200, doc); + } catch (error) { + sendJson(res, 500, { + error: "nudge-failed", + detail: String((error && error.message) || error) + }); + } + }; + if (method === "PUT" || method === "POST") { + readBody(req, res).then((text) => { + // readBody 已经回过 413:null 就是「别再往下走」。 + if (text === null) return; + run(parseBody(text)); + }); + return; + } + run(null); + } + }); + return { routes: countRoutes(), dispose: () => { diff --git a/dsh-mneme/lib/capabilities.js b/dsh-mneme/lib/capabilities.js index 4c3a2524..f050c6b2 100644 --- a/dsh-mneme/lib/capabilities.js +++ b/dsh-mneme/lib/capabilities.js @@ -1,5 +1,5 @@ /** - * M.O.(Modus Operandi · 手法)能力清单的**读侧**。 + * M.O.(Modus Operandi)能力清单的**读侧**。 * * 能力清单 = 「这台机器上、这个项目里,agent 能拿到哪些外部增强」的一份可复核投影。 * 三类:技能(注入文本指令)/MCP(注册可执行工具)/插件(加载单元)。三者在 DSH 里 diff --git a/dsh-mneme/lib/client.js b/dsh-mneme/lib/client.js index b23b8614..c833c13f 100644 --- a/dsh-mneme/lib/client.js +++ b/dsh-mneme/lib/client.js @@ -531,12 +531,12 @@ window.__ModuleLoader__.load({ "memory.features.heatEnabled.hint": "开:像遗忘曲线一样给记忆降温,长期没用过的往后排、不占注入名额;关:一直按相关度排(热度衰减,默认关)", "memory.features.documentMemoryEnabled": "长文档只存一张摘要卡", "memory.features.documentMemoryEnabled.hint": "开:Agent 写的长文档在记忆里只放「摘要卡」,正文仍由 Agent 保管、按需读取,注入只带摘要;关:整篇都往上下文里带(document 型记忆,默认关)", - // --- v0.9.0 M.O.(Modus Operandi · 手法,beta)--- + // --- v0.9.0 M.O.(Modus Operandi,beta)--- // M.O. = Modus Operandi,拉丁语「一个人一贯的做事方式」;也是 org 词根 // modus(modusensus)。界面主显示 M.O.,设置项 hint 第一句给全名。 "memory.actions.beta": "beta", "memory.actions.notice": "提炼结果是候选,可能有错、过时、会被推翻。", - "memory.actions.title": "M.O. · 手法", + "memory.actions.title": "M.O. · 工作流", "memory.actions.open": "打开 M.O.", "memory.actions.viewFull": "完整视图", "memory.actions.openPane": "在右栏打开完整视图", @@ -588,13 +588,24 @@ window.__ModuleLoader__.load({ "memory.actions.broken": "有文件读不出来:", "memory.actions.empty.title": "这个项目里还没有工作流", "memory.actions.empty.body": "工作流的本体住在项目里的 .mneme/pipelines/ 下——跟着仓库走,能被提交、能被 review、能被 CI 读。做法被复用第二次才算节点类型,第一次只是实例;同一路径被走通两次之后,它才会从会话里被提炼出来、落到那个目录里。", + "memory.actions.distill.action": "现在沉淀一次", + "memory.actions.distill.busy": "正在读会话…", + "memory.actions.distill.hint": "看一遍这个项目自己的会话日志,把反复走通的动作挖成提议。只写提议,不落草稿、不改仓库。", + "memory.actions.distill.noProject": "读不到会话的工作目录,所以不知道该看哪个项目的日志", + "memory.actions.distill.saved": "已提出 {n} 条提议(本次挖到 {m} 条候选)", + "memory.actions.distill.none": "这一遍没挖到新的重复动作", + "memory.actions.distill.noSessions": "这个项目还没有自己的会话日志,先攒几轮再来", + "memory.actions.distill.unreadable": "会话日志读不出来(这是环境问题,不是「没有重复动作」)", + "memory.actions.distill.failed": "这一遍没跑完,详见插件日志", "memory.actions.noRuns": "这一条还没有运行记录,所以每个节点都还是候选。", "memory.actions.settle.title": "本次运行", "memory.actions.settle.line": "「{title}」", "memory.actions.settle.used": "本次复用 {n} 条做法(勾选为被用上)", "memory.actions.settle.refutedWarn": "本次路径里有已被推翻的节点,结果仅供参考", "memory.features.actionPipelineEnabled": "M.O.(beta)", - "memory.features.actionPipelineEnabled.hint": "⚠️ 开发中:目前只有「看」这一层能用,自己沉淀和触发都还没做,建议暂时保持关闭。Modus Operandi(手法):把真实会话里反复走通的「做法」连成一张只读图谱——会话头图标、右栏完整视图、每次运行的结算卡。重启 DSH 后生效", + "memory.features.actionPipelineEnabled.hint": "只读视图:把真实会话里反复走通的动作连成一张图谱——会话头图标、右栏完整视图、每次运行的结算卡。看 ≠ 写:沉淀由「M.O. 自动沉淀」单独开,执行记账由 /mo-run 单独开。Modus Operandi(工作流)。重启 DSH 后生效", + "memory.features.actionNudgeEnabled": "自动沉淀工作流(beta)", + "memory.features.actionNudgeEnabled.hint": "开:每轮结束后看一眼这个项目自己的会话日志,把反复走通的动作挖成记忆库里的一条「提议」,等你确认才可能变成工作流。只产草稿、不自动启用、不往仓库里塞文件。挂热路径且要解压日志,轻量档会强制关掉。重启 DSH 后生效", "memory.features.resilientModelDownload": "模型下载断了能续上", "memory.features.resilientModelDownload.hint": "开:下载中断后从断点续传并自动重试;关:中断就整份重来(断点续传,默认开)", "memory.features.searchSemanticDedup": "搜到意思相同的只留一条", @@ -1254,13 +1265,24 @@ window.__ModuleLoader__.load({ "memory.actions.broken": "Some files could not be read:", "memory.actions.empty.title": "No workflows in this project yet", "memory.actions.empty.body": "A workflow lives in the project, under .mneme/pipelines/ — it travels with the repository: committable, reviewable, readable by CI. A practice becomes a node type on its second reuse; the first run is just an instance. Once the same path has been walked twice it gets distilled out of the session and lands in that directory.", + "memory.actions.distill.action": "Distill now", + "memory.actions.distill.busy": "Reading sessions…", + "memory.actions.distill.hint": "Scan this project's own session logs and mine repeated actions into proposals. Writes proposals only — no drafts, no repository changes.", + "memory.actions.distill.noProject": "The session's working directory is unknown, so there is no project to scan", + "memory.actions.distill.saved": "{n} proposal(s) written ({m} candidate(s) mined this pass)", + "memory.actions.distill.none": "No new repeated action this pass", + "memory.actions.distill.noSessions": "This project has no session logs of its own yet — come back after a few turns", + "memory.actions.distill.unreadable": "The session logs could not be read (an environment problem, not an empty result)", + "memory.actions.distill.failed": "This pass did not finish; see the plugin log", "memory.actions.noRuns": "No runs recorded for this workflow yet, so every step is still a candidate.", "memory.actions.settle.title": "This run", "memory.actions.settle.line": "\"{title}\"", "memory.actions.settle.used": "Reused {n} steps this run (checked = actually used)", "memory.actions.settle.refutedWarn": "This path contains refuted steps; take the result with caution", "memory.features.actionPipelineEnabled": "M.O. (beta)", - "memory.features.actionPipelineEnabled.hint": "⚠️ Under development: only the viewing layer works today — self-distillation and triggering are not built yet, so leave this off for now. Modus Operandi: the ways of working that actually got walked through in real sessions, drawn as a read-only graph — session-header icon, full sidebar view, and a per-run settlement card. Takes effect after restarting DSH", + "memory.features.actionPipelineEnabled.hint": "Read-only view: the ways of working that actually got walked through in real sessions, drawn as a graph — session-header icon, full sidebar view, and a per-run settlement card. Viewing is not writing: distillation has its own switch below, and run accounting has its own (/mo-run). Modus Operandi. Takes effect after restarting DSH", + "memory.features.actionNudgeEnabled": "Auto-distill workflows (beta)", + "memory.features.actionNudgeEnabled.hint": "On: after each turn, look at this project's own session logs and mine the actions it keeps repeating into a proposal in the memory store — it still needs your sign-off before becoming a workflow. Drafts only; never auto-activates; never writes files into the repo. Hooks a hot path and decompresses logs, so light mode forces it off. Takes effect after restarting DSH", "memory.features.resilientModelDownload": "Model downloads resume after interruption", "memory.features.resilientModelDownload.hint": "On: downloads continue from where they stopped and retry automatically. Off: an interrupted download starts over (resumable downloads, on by default)", "memory.features.searchSemanticDedup": "Keep one entry when search hits near-duplicates", @@ -2351,6 +2373,17 @@ window.__ModuleLoader__.load({ ".mneme-pipe-wfMeta{display:block;margin-top:3px;font-size:12px;color:var(--mneme-fg-dim)}", ".mneme-pipe-wfId{display:block;margin-top:2px;font-size:11px;color:var(--mneme-fg-dim)}", ".mneme-pipe-broken{flex:none;font-size:12px;line-height:17px;color:var(--mneme-warn);padding:6px 14px 0}", + // 手动沉淀:按钮与回执占一行。回执用 --mneme-fg-dim 而不是 --mneme-warn—— + // 「这一遍没挖到」是正常结果,不是警告;只有「读不出来」才是故障,那也由文案说清, + // 不靠颜色区分(红黄两色在深浅主题里最容易一起掉对比度)。 + ".mneme-pipe-distill{flex:none;display:flex;align-items:center;gap:8px;flex-wrap:wrap;font-size:12px}", + ".mneme-pipe-distillBtn{border:1px solid var(--dsw-alias-border-l2);background:0 0;border-radius:8px;cursor:pointer;color:var(--mneme-fg-dim);font-family:inherit;font-size:12px;line-height:16px;padding:2px 9px}", + ".mneme-pipe-distillBtn:hover:enabled{background:var(--dsw-alias-interactive-bg-hover)}", + ".mneme-pipe-distillBtn:focus-visible{outline:2px solid var(--dsw-alias-state-business-primary);outline-offset:1px}", + // 禁用态用虚线边而不是降透明度:整块降 opacity 会把文字一起压到 AA 以下, + // 而「为什么禁用」本来就由 title 说明(读不到会话工作目录)。 + ".mneme-pipe-distillBtn:disabled{cursor:not-allowed;border-style:dashed;color:var(--mneme-fg-dim)}", + ".mneme-pipe-distillNote{color:var(--mneme-fg-dim);line-height:17px;min-width:0}", ].join("\n"); if (typeof document !== "undefined" && document.querySelector(`style[data-plugin-css="${CSS_TAG}"]`) === null) { const tag = document.createElement("style"); @@ -2923,7 +2956,10 @@ window.__ModuleLoader__.load({ // (关=一个座位都不注册,见 apply 内的 M.O. 块)。独立成组只解决「混在 // enhance 末位」;可见层由 FEATURE_FIRST_SCREEN 决定(见其注释), // 本轮已把组名提进首屏——组成员资格不决定可见性。 - { key: "group.mo", items: ["actionPipelineEnabled"] }, + // 同一组的三个键是**三件独立能力**,不是一条链的三段:看图(座位闸门)、 + // 自动沉淀(写提议到记忆库)、执行记账(/mo-run 开窗)。各自默认关, + // 开了任何一个都不会顺带打开另一个——写能力必须由人一项一项点。 + { key: "group.mo", items: ["actionPipelineEnabled", "actionNudgeEnabled"] }, { key: "group.enhance", items: ["entityExtractionEnabled", "codingRetrospect", "rerankEnabled", "resilientModelDownload", "searchSemanticDedup", "bm25SearchEnabled", "heatEnabled", "documentMemoryEnabled", // 人格定义页(beta):注入总闸。分组随 enhance(行为开关,非座位闸门); // 人格内容在设置页「个性化」区的人格卡。 @@ -7603,8 +7639,62 @@ window.__ModuleLoader__.load({ // 条目清单(名字 + 规模 + 作用域):图上方的**选择器**。 // 它解决的是「这是哪条工作流」——一进来就是一张图时这个问题没有落点,多条之间也没得选。 // 但它不占整屏:图和它同屏,见下面的 PipelineBrowser。 + // 「现在就跑一次」。自动沉淀只在 turn/end 之后跑、还有 10 分钟冷却,所以人想立刻 + // 看到结果时没有入口。这里的 POST 与自动跑走**同一条** nudgeForProject,区别只有 + // materialize:按钮永远不落草稿,只把提议写进记忆库——升格仍然要人批。 + // project 为空(会话里读不到 cwd)时按钮是禁用的:服务端只会回 400, + // 而一个点了必然失败的按钮比没有按钮更糟。 + function PipelineDistillButton(props) { + const { t, project } = props; + const [busy, setBusy] = useState(false); + const [note, setNote] = useState(""); + const disabled = busy || !project; + const run = () => { + if (disabled) return; + setBusy(true); + setNote(""); + apiFetch("/api/dsh-mneme/nudge?project=" + encodeURIComponent(project), { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({}) + }) + .then((res) => res.json().then((j) => ({ ok: res.ok, status: res.status, body: j || {} }))) + .then(({ ok, status, body }) => { + if (!ok) { + // 503 = 会话日志读不到(环境问题),其余是这一跑本身失败。两句不能合并: + // 「读不到」和「没有重复动作」在界面上长得一样时,人就只能瞎猜。 + setNote(t(status === 503 + ? "memory.actions.distill.unreadable" + : "memory.actions.distill.failed")); + return; + } + if (body.skipped) { + setNote(t("memory.actions.distill.noSessions")); + return; + } + const saved = (body.counts && body.counts.savedProposals) || 0; + const found = (body.counts && body.counts.candidates) || 0; + setNote(found === 0 + ? t("memory.actions.distill.none") + : t("memory.actions.distill.saved").replace("{n}", String(saved)).replace("{m}", String(found))); + }) + .catch(() => setNote(t("memory.actions.distill.failed"))) + .then(() => setBusy(false)); + }; + return h("span", { className: "mneme-pipe-distill" }, + h("button", { + type: "button", + className: "mneme-pipe-distillBtn", + disabled, + title: !project ? t("memory.actions.distill.noProject") : t("memory.actions.distill.hint"), + onClick: run + }, busy ? t("memory.actions.distill.busy") : t("memory.actions.distill.action")), + note ? h("span", { className: "mneme-pipe-distillNote", role: "status" }, note) : null + ); + } + function PipelineWorkflowList(props) { - const { t, data, activeId, onOpen } = props; + const { t, data, activeId, onOpen, project } = props; const list = (data && data.available) || []; const scopeOf = (row) => (row.scope === "global" ? t("memory.actions.scope.global") @@ -7614,6 +7704,7 @@ window.__ModuleLoader__.load({ h("span", null, t("memory.actions.list.heading")), h("span", null, t("memory.actions.list.count").replace("{n}", String(list.length))) ), + h(PipelineDistillButton, { t, project }), list.length ? list.map((row) => h("button", { key: row.id, @@ -7664,7 +7755,7 @@ window.__ModuleLoader__.load({ className: "mneme-pipe", style: { height: "100%", display: "flex", flexDirection: "column", minHeight: "0" } }, - h(PipelineWorkflowList, { t, data: listDoc, activeId, onOpen: setPicked }), + h(PipelineWorkflowList, { t, data: listDoc, activeId, onOpen: setPicked, project }), h(PipelineView, { t, data: detailDoc }) ); } diff --git a/dsh-mneme/lib/config.js b/dsh-mneme/lib/config.js index 56edd446..b79b5c47 100644 --- a/dsh-mneme/lib/config.js +++ b/dsh-mneme/lib/config.js @@ -880,7 +880,7 @@ export const Config = z.object({ enforce: z.boolean().default(false) }).default({}), - // --- v0.9.0 M.O.(Modus Operandi · 手法,beta)-------------------------- + // --- v0.9.0 M.O.(Modus Operandi,beta)-------------------------- // 命名:M.O. = Modus Operandi,拉丁语「一个人一贯的做事方式」,缩写 M.O.; // 也正是 org 词根 modus(modusensus)。界面主显示 M.O.,设置项 hint 第一句 // 给全名——缩写不靠注释解释,靠用户第一眼能看见全名。 @@ -902,6 +902,20 @@ export const Config = z.object({ // 所以轻量档要关掉:`LIGHT_MODE_OFF` 里有它。 actionRunEnabled: z.boolean().default(false), + // M.O. 自动沉淀(v0.9.0 第三批):会话跑完一轮后,把这个项目自己的会话日志挖一遍, + // 把「反复出现的同一段能力序列」落成提议(memoryDir 下),可选再落一份 draft 草稿。 + // + // 与上面两个键三件事分开:actionPipelineEnabled 管「看图的座位」,actionRunEnabled + // 管「往账本里写」,这个管「从日志里长出候选」。三个都是独立能力,混成一个键会让 + // 「只想看图」的人顺手获得写盘能力。 + // + // 默认关(beta 必须 opt-in),并且进 LIGHT_MODE_OFF:它挂在 session/event 热路径上, + // 且解压会话日志是**同步**的(本机 118 份实测 16.2s),轻量档最不该背这条路径。 + // 触发侧另有 10 分钟/项目的冷却与 budgetMs 上限(见 src/pipeline-nudge.js 的 + // NUDGE_AUTO_MIN_INTERVAL_MS 与 src/index.js 的装配注释)——旗标只是总闸。 + // (冷却值以常量为准:早前这里写「30 分钟」,与实际 10 分钟不符。) + actionNudgeEnabled: z.boolean().default(false), + // --- 人格定义页(beta)--------------------------------------------------- // 注入器渲染人格段的顶层总闸(双闸门的 config 半边;user_settings "persona" // 键内的 persona.enabled 是 per-agent 半边,两闸同真才渲染)。API 的 effective @@ -962,7 +976,11 @@ const LIGHT_MODE_OFF = [ // M.O. 执行记账:它挂在**热路径**上(每个会话事件都过一次监听)。轻量档的取向 // 是「小模型 / 小上下文 / 少常驻路径」,这与一个每事件都过一遍的监听器相冲突。 // 默认关,这里只是轻量预设再确认一次;用户显式勾选仍然赢(合并顺序同上面两条)。 - "actionRunEnabled" + "actionRunEnabled", + // M.O. 自动沉淀:同样挂在 session/event 热路径上,而且解压会话日志是同步 I/O。 + // 轻量档的取向(小模型 / 小上下文 / 少常驻路径)与它直接冲突,预设里关掉; + // 用户显式勾选仍然赢(合并顺序同上面几条)。 + "actionNudgeEnabled" ]; /** diff --git a/dsh-mneme/lib/index.js b/dsh-mneme/lib/index.js index 11b9946e..bef8923e 100644 --- a/dsh-mneme/lib/index.js +++ b/dsh-mneme/lib/index.js @@ -24,6 +24,9 @@ import { createSettings } from "./settings.js"; import { createCommandManager } from "./commands.js"; import { createExecutionRecorder } from "./pipeline-runner.js"; import { registerMoRunCommand } from "./pipeline-command.js"; +// M.O. 自动沉淀的触发侧(冷却 / 排队 / 兜底)。挖掘与落盘在 pipeline-nudge.js, +// 这里只管「什么时候跑」。两者分开的理由见 src/pipeline-nudge-auto.js 文件头。 +import { createNudgeAutoRun } from "./pipeline-nudge-auto.js"; import { createVectorIndex } from "./vector-index.js"; // semantic(embedder/reranker/boot 回填)纯搬移至 src/semantic.js(PR2):宿主与 // daemon(dsh-mneme-serve,#363)共用同一套装配,原文件保留 barrel 出口。 @@ -590,6 +593,18 @@ export const apply = (ctx, config) => { disposers.push(api.dispose); } + // M.O. 自动沉淀(v0.9.3):项目里重复走的那套动作,自动变成 memoryDir 里的一条 + // 「提议」,等人确认后才可能进仓库。默认关(actionNudgeEnabled)。 + // + // 为什么位置在这里而不是和 M.O. 记账同段:`disposers` 声明在 :532,而记账块在它 + // 之前(TDZ)——照抄过去会在插件加载时直接抛 ReferenceError。这里已在其后。 + // + // 为什么挂 session/event 却又要进轻量档黑名单:它读的是 zstd 会话日志(同步解压), + // 是本插件里最重的一条后台路径;且它只产「提议」,关掉不影响记忆本身的正确性。 + if (cfg.actionNudgeEnabled === true) { + disposers.push(createNudgeAutoRun({ ctx, memoryDir, logger: ctx.logger })); + } + // Standalone external API (v0.7.12): plain node:http server for ecosystem // integrations outside the DSH host. Persisted external_api settings win // over the bundle config (enabled/port); the Bearer token lives in the same diff --git a/dsh-mneme/lib/pipeline-nudge-auto.js b/dsh-mneme/lib/pipeline-nudge-auto.js new file mode 100644 index 00000000..8c30e38d --- /dev/null +++ b/dsh-mneme/lib/pipeline-nudge-auto.js @@ -0,0 +1,126 @@ +/** + * M.O. 自动沉淀的**触发侧**:让「这个项目又重复走了一遍同一套动作」变成自动发生的观察, + * 而不是等人想起来去点一下。 + * + * 为什么单独一个文件:pipeline-nudge.js 是纯函数式的挖掘 / 落盘层(不碰宿主上下文, + * 测试可以直接驱动);触发时机、冷却、去重、错误兜底是**宿主接线**问题,混进那一层会 + * 让它不再纯粹。 + * + * 三条执行侧边界(口径与 pipeline-nudge.js 的 NUDGE_BOUNDARIES 一致): + * - 只产 draft,且**自动跑一律不落草稿**(materialize 恒 false)——往别人仓库里塞文件 + * 必须是人的显式动作; + * - 落进 memoryDir 的只是「提议」(proposal),升格成工作流仍然要人批; + * - 一次跑不完就带着已读到的部分收工并如实标 budgetExceeded,**不重试、不排队补跑**: + * 沉淀是锦上添花,卡住宿主是事故。 + */ +import { projectRootOf } from "./pipeline-runner.js"; +import { nudgeForProject, NUDGE_AUTO_BUDGET_MS, NUDGE_AUTO_MIN_INTERVAL_MS } from "./pipeline-nudge.js"; + +/** + * 冷却不是防重复写盘(saveProposal 同 id 覆盖,本来就幂等),而是**观测新鲜度**: + * 同一个项目的会话日志十分钟内不会长出新的重复模式,重跑只是把同样的解压开销再付一次。 + */ +export function createNudgeAutoRun({ + ctx = null, + memoryDir = null, + logger = null, + now = () => Date.now(), + runNudge = nudgeForProject, + minIntervalMs = NUDGE_AUTO_MIN_INTERVAL_MS, + budgetMs = NUDGE_AUTO_BUDGET_MS, + maxFiles = null +} = {}) { + const log = logger || (ctx && ctx.logger) || null; + let disposed = false; + let timer = null; + // 每个项目一条冷却记录;键是归一化后的项目根。 + const lastRunAt = new Map(); + // 已排队但还没跑的项目:同一个项目连来十个 turn/end,只跑最后一次。 + const pending = new Set(); + + const normalize = (p) => { + let s = String(p).replace(/\\/g, "/").replace(/\/+$/, ""); + if (/^[a-zA-Z]:\//.test(s)) s = s.toLowerCase(); + return s; + }; + + const runOne = (projectRoot) => { + pending.delete(projectRoot); + if (disposed) return null; + lastRunAt.set(normalize(projectRoot), now()); + try { + const doc = runNudge({ + projectRoot, + memoryDir, + // 自动跑:永不落草稿。它只在记忆库的 proposals 分桶里留提议。 + materialize: false, + persistProposals: true, + budgetMs, + ...(maxFiles ? { maxFiles } : {}) + }); + // 静默是默认,但不许无声无息:只有真挖到东西时才说一句。 + if (doc && doc.counts && doc.counts.savedProposals > 0) { + log?.info?.("[dsh-mneme] M.O. 自动沉淀:" + projectRoot + " 记录到 " + doc.counts.savedProposals + " 条重复模式(待人工确认)"); + } + return doc; + } catch (error) { + // 自动跑的失败绝不能冒泡到 session/event——那条链上还挂着摘要与续期。 + log?.warn?.("[dsh-mneme] M.O. 自动沉淀失败(" + projectRoot + "):" + String((error && error.message) || error)); + return null; + } + }; + + // 队列里一个一个推进:一次 setTimeout 里连跑多个项目会把一帧拉长。 + const flush = () => { + timer = null; + if (disposed) return; + const first = pending.values().next(); + if (first.done) return; + runOne(first.value); + if (pending.size > 0 && !disposed) defer(); + }; + + const defer = () => { + if (disposed || timer) return; + // 解压会话日志是同步 I/O(本机 118 份实测 16.2s)。绝不在这里直接跑:当前这一帧 + // 是宿主的 turn/end 处理链,占住它等于让整个回合收尾卡住。setTimeout(0) 把它挪到 + // 下一个宏任务,宿主先收尾。 + timer = setTimeout(flush, 0); + if (typeof timer.unref === "function") timer.unref(); + }; + + const schedule = (projectRoot) => { + const key = normalize(projectRoot); + const last = lastRunAt.get(key); + if (last !== undefined && now() - last < minIntervalMs) return; + if (pending.has(projectRoot)) return; + pending.add(projectRoot); + defer(); + }; + + const onSessionEvent = (session, event) => { + if (disposed) return; + // 只认 turn/end:一轮结束才算一个观测窗口,中途记的是半截。 + if (!event || event.type !== "turn/end") return; + const projectRoot = projectRootOf(session); + if (!projectRoot) return; + schedule(projectRoot); + }; + + const unsubscribe = ctx && typeof ctx.on === "function" ? ctx.on("session/event", onSessionEvent) : null; + + const dispose = () => { + disposed = true; + pending.clear(); + if (timer) { + clearTimeout(timer); + timer = null; + } + if (typeof unsubscribe === "function") unsubscribe(); + }; + // 手动入口与测试都要能直接驱动一次(不起 timer、不走冷却)。 + dispose.runNow = (projectRoot) => (disposed || !projectRoot ? null : runOne(projectRoot)); + dispose.pendingCount = () => pending.size; + dispose.lastRunAt = (projectRoot) => lastRunAt.get(normalize(projectRoot)); + return dispose; +} diff --git a/dsh-mneme/lib/pipeline-nudge.js b/dsh-mneme/lib/pipeline-nudge.js index 41be122d..653e4d1b 100644 --- a/dsh-mneme/lib/pipeline-nudge.js +++ b/dsh-mneme/lib/pipeline-nudge.js @@ -1,8 +1,8 @@ /** - * M.O. 沉淀链(nudge):让手法**自己从数据里长出来**。 + * M.O. 沉淀链(nudge):让工作流**自己从数据里长出来**。 * * 这一层补的是读侧之外缺的那一头。`pipeline.js` 能把人手写的 JSON 渲染成图、能从 - * 运行记录算状态;但「一条新手法从哪来」在那之前一直是零代码。本文件就是那个来源: + * 运行记录算状态;但「一条新工作流从哪来」在那之前一直是零代码。本文件就是那个来源: * * 会话/记忆里的观察 → 反复出现的同一段序列(候选) → 反事实判据(请人回答) * → status:"draft" 的草稿 →(人点头)→ active @@ -79,9 +79,9 @@ export const NUDGE_MAX_CANDIDATES = 20; * * 这不是想当然定的,是拿本机 520 份真实会话日志跑出来的:工具级序列的支持度高得离谱 * (`read → pwsh` 出现在 322 个会话、`write → pwsh` 253 个),但它们全是 agent 的 - * 通用动作习惯,不是项目手法——按那个口径挖,推给人的前八条全是这类噪音。 + * 通用动作习惯,不是项目工作流——按那个口径挖,推给人的前八条全是这类噪音。 * 能力级稀疏得多(真实数据里同一段能力序列只有两三个会话支持),但每一条都是 - * 「有人特意选了这个技能」的选择,那才可能是手法。 + * 「有人特意选了这个技能」的选择,那才可能是工作流。 * * 想挖工具级就显式传 `kinds: ["tool", ...]` 或 `kinds: null`(不过滤)——**别改默认值**, * 默认值一改,面板第一屏就变回噪音。 @@ -91,6 +91,36 @@ export const DEFAULT_NUDGE_KINDS = ["skill", "mcp"]; /** 单次扫描最多读多少会话文件(目录膨胀时宁可报截断,也不要把一次刷新变成全盘扫描)。 */ const MAX_SESSION_FILES = 2000; +/** + * 自动沉淀的**最小间隔**(同一个项目两次自动跑之间)。 + * + * 为什么需要它:解压会话日志是**同步** I/O,本机 118 份日志实测 16.2s。挂到 + * `session/event` 上如果每轮都跑,长会话就是把宿主主线程反复按住。十分钟这个数是 + * 取的「够久到不会在同一个工作段里反复触发,够短到当天的新习惯还来得及被看见」—— + * 它不是性能参数,是**观测新鲜度**参数;真卡的话先调 budgetMs(那一条保证不卡)。 + */ +export const NUDGE_AUTO_MIN_INTERVAL_MS = 10 * 60 * 1000; + +/** + * 自动沉淀的单次时间预算。超了就**带着已读到的部分收工**并如实标 `budgetExceeded`, + * 而不是把这一跑作废——半份观察好过零份,只要调用方知道它只看了后半截。 + */ +export const NUDGE_AUTO_BUDGET_MS = 4000; + +/** + * 沉淀链的**边界**,写进每一份 return 的契约里。抽成一份常量是防「两条出口说法不一样」: + * 走完一整跑和提前收工给出的边界必须是同一句话,否则读契约的人得先猜自己拿到的是哪一种。 + */ +export const NUDGE_BOUNDARIES = Object.freeze({ + autoActivate: false, + autoAnswerCounterfactual: false, + writesOnlyDrafts: true, + // 机器给的风险只是下限;R3 永远只能由人给。 + riskIsFloor: true, + // 支持度只在同一个工作区桶内累加;跨桶不并账。 + crossProjectSupport: false +}); + /** 会话日志文件名前缀。**不写死版本号**:本机同一棵树下并存 v4 / v3 / 无版本号三种。 */ const SESSION_FILE_PREFIX = "session"; @@ -139,6 +169,207 @@ export function resolveSessionDir({ dshHome = process.env.DSH_HOME || join(homed return join(String(dshHome), "sessions"); } +/** + * 路径规范化:只用来**比相等**,不用来读写文件(读写永远用原串)。 + * + * 为什么要折:同一份工作目录在会话头里可能是 `C:\\Users\\x\\proj`、`c:/users/x/proj`、 + * 末尾带不带分隔符三种写法之一,直接 `===` 会让归属解析**随机**命中或落空,而落空 + * 的症状和「这个项目确实没会话」完全同形。 + * 只在 win32 上折小写:POSIX 的文件名大小写敏感,在那里折小写会把 + * `/a/Proj` 与 `/a/proj` 两个不同项目并成一个——那正是本仓最防的一类「合并两个项目的账」。 + */ +export function normalizeProjectPath(p) { + if (typeof p !== "string") return null; + const trimmed = p.trim(); + if (trimmed === "") return null; + let s = trimmed.replace(/\\/g, "/"); + // 去掉末尾分隔符,但根("/" 或 "C:/")要留着——去掉就不是一个路径了。 + s = s.replace(/\/+$/, ""); + if (/^[A-Za-z]:$/.test(s)) s += "/"; + if (s === "") s = "/"; + return process.platform === "win32" ? s.toLowerCase() : s; +} + +/** + * 一个会话文件里记的 `cwd`(会话日志首行的 `{"type":"session",...,"cwd":"…"}`)。 + * + * 为什么读内容而不是反解桶名:桶名是工作区路径的**有损**变形(盘符后的 `-`、 + * 空格被吃掉、中文按 UTF-16 码元转义),本机 19 个桶里反解实测多处对不上—— + * 反解错的时候**不会报错**,只会把别的项目的账归到这个项目名下。会话头里的 + * `cwd` 是宿主自己写的原文,没有歧义。 + * + * 只解第一帧是性能选择(一份会话日志能到 1.5 MB、上百帧),但第一帧解不出时 + * **继续扫后面几帧**:宁可多解几帧,也不要把「拿不准」报成「这个桶没有项目」。 + */ +export function readSessionCwd(file) { + let buf; + try { + buf = readFileSync(file); + } catch { + return null; + } + let cwd = null; + const take = (text) => { + if (cwd) return; + for (const line of text.split("\n")) { + const t = line.trim(); + // 快判:没有 "cwd" 的行连 JSON.parse 都不用做(一帧里绝大多数行是消息)。 + if (!t || t[0] !== "{" || !t.includes('"cwd"')) continue; + try { + const obj = JSON.parse(t); + if (obj && typeof obj.cwd === "string" && obj.cwd.trim() !== "") { + cwd = obj.cwd; + return; + } + } catch { + // 半行/坏行:单行脏数据不该决定归属。 + } + } + }; + const start = buf.indexOf(ZSTD_MAGIC, 0); + if (start !== -1) { + let end = buf.indexOf(ZSTD_MAGIC, start + 4); + if (end === -1) end = buf.length; + try { + take(zlib.zstdDecompressSync(buf.subarray(start, end)).toString("utf8")); + } catch { + // 第一帧坏了:落到下面的整文件扫描。 + } + } + if (!cwd) forEachZstdFrame(buf, take); + buf.fill(0); + return cwd; +} + +/** + * 这个项目**自己的**会话桶目录(`/sessions/<工作区桶>`)。 + * + * 为什么非要它:`nudgeFromSessions` 只有在拿到「本项目自己的 sessionDir」时才敢给候选 + * 贴项目名、写提议桶、落草稿;给不出就会降级成「无归属普查」(见那里的 `refused`)。 + * 而桶名 → 项目这条路,除了会话头里的 `cwd` 没有可信来源。 + * + * 返回里 `reason` 是给人看的那句话: + * matched 找到了 + * bad-project 调用方没给项目根 / 给的不是路径 + * no-sessions-dir 会话根不存在(新机器、DSH 还没写过东西) + * no-bucket 扫完了,没有任何桶的会话头指向这个项目(这个项目确实还没跑过会话) + * unreadable 桶在,但所有会话头的 cwd 都读不出来——**这一条必须与 no-bucket 分开**: + * 前者是「读不到」,后者是「确实没有」,混起来就等于把故障报成空结果。 + * budget-exceeded 预算用光还没扫完——同样**不是**关于这个项目的答案(见 deadline 参数)。 + * bucket-limit 桶数超过 maxBuckets 被截断,且截断范围内没找到这个项目——**也不是** + * 「确实没有」:目标可能就在被截掉的那部分里(同日评审抓出)。 + * + * `deadline` 是这次解析的绝对截止时刻(毫秒)。不给就扫到底(测试与离线批处理要完整 + * 结果)。为什么要它:这一层是**同步**的,最多 500 个桶 × 3 个文件,全都走同步 I/O; + * 调用方手里的 `budgetMs` 原先只约束后面的挖掘,约束不到这里——一条「真卡就调 + * budgetMs」的建议对这段路径是空话(v0.9.3 评审抓出)。 + */ +export function resolveProjectSessionDir({ + dshHome = process.env.DSH_HOME || join(homedir(), ".dsh"), + projectRoot = null, + maxBuckets = 500, + filesPerBucket = 3, + deadline = null +} = {}) { + const target = normalizeProjectPath(projectRoot); + if (!target) { + return { sessionDir: null, bucket: null, reason: "bad-project", bucketsScanned: 0, filesRead: 0 }; + } + const root = resolveSessionDir({ dshHome }); + let entries; + try { + entries = readdirSync(root, { withFileTypes: true }); + } catch { + return { sessionDir: null, bucket: null, reason: "no-sessions-dir", sessionsRoot: root, bucketsScanned: 0, filesRead: 0 }; + } + const allBuckets = []; + for (const entry of entries) { + if (!entry.isDirectory()) continue; + allBuckets.push(entry.name); + } + // 先收全、再排序、最后截断——与 findSessionFiles 同一条规矩。早前是「边收边截」: + // 到 500 个就 break,然后才 sort(),于是**留下哪 500 个由文件系统遍历顺序决定**。 + // 目标项目的桶一旦落在被截掉的那部分,函数就返回 no-bucket——把一次截断报成 + // 「这个项目确实没有会话」,正好是本文件反复在防的「读不到 vs 确实没有」 + // (v0.9.3 评审抓出)。 + allBuckets.sort(); + const bucketsTruncated = allBuckets.length > maxBuckets; + const buckets = bucketsTruncated ? allBuckets.slice(0, maxBuckets) : allBuckets; + let filesRead = 0; + let bucketsWithCwd = 0; + const outOfBudget = () => deadline !== null && Number.isFinite(deadline) && Date.now() > deadline; + for (const bucket of buckets) { + // 桶循环里查预算:每个桶都可能要 readdir + 读 3 个文件,全同步, + // 一次解析跑满 500 个桶就是几百毫秒到几秒不等。 + if (outOfBudget()) { + return { + sessionDir: null, + bucket: null, + reason: "budget-exceeded", + sessionsRoot: root, + bucketsScanned: buckets.length, + bucketsTotal: allBuckets.length, + bucketsTruncated, + filesRead + }; + } + const dir = join(root, bucket); + // 每个桶只读前几个文件:同一个桶里所有会话的 cwd 必然相同(桶是按工作区分的), + // 读三个只是给「第一个文件正好是截断的」留余量,不是要把整个桶扫一遍。 + const found = findSessionFiles(dir, { limit: filesPerBucket }); + for (const file of found.files.slice(0, filesPerBucket)) { + // 文件循环里也查:filesPerBucket 可被调用方调大,3 是最小值不是上限。 + if (outOfBudget()) { + return { + sessionDir: null, + bucket: null, + reason: "budget-exceeded", + sessionsRoot: root, + bucketsScanned: buckets.length, + bucketsTotal: allBuckets.length, + bucketsTruncated, + filesRead + }; + } + const cwd = readSessionCwd(file); + filesRead += 1; + if (!cwd) continue; + bucketsWithCwd += 1; + if (normalizeProjectPath(cwd) === target) { + return { + sessionDir: dir, + bucket, + reason: "matched", + matchedCwd: cwd, + sessionsRoot: root, + bucketsScanned: buckets.length, + bucketsTotal: allBuckets.length, + bucketsTruncated, + filesRead + }; + } + } + } + return { + sessionDir: null, + bucket: null, + // 桶里一个 cwd 都读不出来 → 是读不了,不是没有。 + // 截断且没命中 → 也不是「确实没有」:目标可能就在被截掉的那部分里。 + reason: buckets.length === 0 + ? "no-sessions-dir" + : bucketsWithCwd === 0 + ? "unreadable" + : bucketsTruncated + ? "bucket-limit" + : "no-bucket", + sessionsRoot: root, + bucketsScanned: buckets.length, + bucketsTotal: allBuckets.length, + bucketsTruncated, + filesRead + }; +} + /** * 逐帧解一个**多帧** zstd buffer,边解边回调。 * @@ -235,7 +466,7 @@ export function findSessionFiles(dir, { limit = MAX_SESSION_FILES } = {}) { * `bucket` 是**工作区桶**(会话文件往上数第三层目录名)。为什么非要带上它: * 会话目录在 DSH 侧按工作区分桶,`sessionId` 只是桶内那个会话目录名——两条不同工作区 * 里的会话会拿到两个不同的 `sessionId`,于是一段序列在两个项目里各出现一次,就会被 - * 并成「支持度 2 的同一手法」。那是把两个项目的账合成一本,实测过(复核报告 §2.6)。 + * 并成「支持度 2 的同一工作流」。那是把两个项目的账合成一本,实测过(复核报告 §2.6)。 * 带上 bucket 之后,支持度只在同一个桶内累加。 */ export function observationsFromCalls(calls, { sessionId = null, bucket = null } = {}) { @@ -305,7 +536,7 @@ export function toolCallsInLine(line) { * 取不到就落 null——落 null 的观察只会让支持度算不出来,不会假装成「同一个会话」 * 或「同一个项目」。 */ -export function readSessionObservations({ sessionDir = resolveSessionDir(), limit = MAX_SESSION_FILES } = {}) { +export function readSessionObservations({ sessionDir = resolveSessionDir(), limit = MAX_SESSION_FILES, deadline = null } = {}) { const found = findSessionFiles(sessionDir, { limit }); const files = found.files.slice(0, limit); const totalFiles = Number.isFinite(found.totalFiles) ? found.totalFiles : found.files.length; @@ -315,6 +546,9 @@ export function readSessionObservations({ sessionDir = resolveSessionDir(), limi // 「目录在」与「目录真能列出来」分开报:否则「不可读」与「空目录」同形。 readable: found.present && found.unreadable.length === 0, truncated: found.truncated === true || totalFiles > files.length, + // 时间预算用光而提前收工:与「文件数上限」是两种截断,必须分开报——否则 + // 「扫了一部分」会伪装成「扫完了但没挖到」。面板与调用方据此说「只看了最近这些」。 + budgetExceeded: false, totalFiles, scannedFiles: 0, frames: 0, @@ -326,6 +560,14 @@ export function readSessionObservations({ sessionDir = resolveSessionDir(), limi }; for (const file of files) { + // 预算到点就停:解压是**同步**的,一次全量扫描会把宿主主线程按住十几秒 + // (本机 118 份日志实测 16.2s)。宁可少看几份并如实报出来,也不能让一次 + // 面板点击变成整窗卡死——那会被当成插件坏了。 + if (deadline !== null && Date.now() > deadline) { + result.budgetExceeded = true; + result.truncated = true; + break; + } let buf; try { buf = readFileSync(file); @@ -387,7 +629,7 @@ export function segmentObservations(observations) { /** * 一段里某个位置的观察样本怎么并进模式:命令**取并**——风险下限要看过全部命令才敢说 * 「没命中兜底清单」,只看第一条会把第三条里的 `git push` 漏掉。 - * 上限 12 条是内存护栏:同一个位置出现几百种不同命令时,它是噪音不是手法。 + * 上限 12 条是内存护栏:同一个位置出现几百种不同命令时,它是噪音不是工作流。 */ const MAX_COMMANDS_PER_POSITION = 12; @@ -580,12 +822,12 @@ export function filterSegmentsByKinds(segments, kinds) { * **极大模式**的交代。 * * 极大模式(maximal pattern):`A → B` 与 `A → B → C` 支持度相同时,只留长的那条。 - * 理由:`A → B` 是 `A → B → C` 的一段前缀,两条都推给人就是把同一条手法说两遍。 + * 理由:`A → B` 是 `A → B → C` 的一段前缀,两条都推给人就是把同一条工作流说两遍。 * 判据是**包含 + 会话集是子集**——只按字符串包含会把「支持度更低的短模式」也吞掉。 * * **支持度只在同一个工作区桶内累加**(`key` 前缀带 bucket):两个不同项目里各见过一次 - * 的同一段序列,不是「支持度 2 的同一手法」,而是两条各自只有一次的观察。混桶会凭空 - * 造出候选,并把别人的手法算到这个项目头上(复核报告 §2.6 实测过)。 + * 的同一段序列,不是「支持度 2 的同一工作流」,而是两条各自只有一次的观察。混桶会凭空 + * 造出候选,并把别人的工作流算到这个项目头上(复核报告 §2.6 实测过)。 */ export function mineRepeatedPatterns(segments, { minSupport = NUDGE_MIN_SUPPORT, minLength = NUDGE_MIN_LENGTH, maxLength = NUDGE_MAX_LENGTH, maxResults = NUDGE_MAX_CANDIDATES, kinds = DEFAULT_NUDGE_KINDS } = {}) { const scoped = filterSegmentsByKinds(segments, kinds); @@ -786,7 +1028,7 @@ function runsForCandidate(candidate, runs) { * 能给的证据三类: * · 复现:同一段序列在几个会话、几次回合、什么时间跨度里出现过(硬事实); * · 缺席对照:中间那一步「走过几次 / 少走几次」(硬事实,见 omissionContrast); - * · 运行记录与失败记录:如果这条手法已经有 runs,或者人喂了记忆侧的失败行,一并报数。 + * · 运行记录与失败记录:如果这条工作流已经有 runs,或者人喂了记忆侧的失败行,一并报数。 * * `failures` 传 `null`(默认)表示**记忆侧的失败记录还没接进来**,与「接进来了、 * 确实一条都没有」是两件事,所以这里不给 `null`,而是给 `{ available, reason }` @@ -905,12 +1147,12 @@ export function draftFromCandidate(candidate, { project = "", name = "", done = } const raw = { id: String(candidate.id || ""), - name: name || `(待补)由重复观察提出的手法:${(candidate.names || []).join(" → ")}`, + name: name || `(待补)由重复观察提出的工作流:${(candidate.names || []).join(" → ")}`, project: project || TODO, version, status: "draft", done: done || `${TODO}整条怎么算完成`, - trigger: Array.isArray(trigger) && trigger.length ? trigger : [{ kind: "phrase", value: `${TODO}什么话该触发这条手法` }], + trigger: Array.isArray(trigger) && trigger.length ? trigger : [{ kind: "phrase", value: `${TODO}什么话该触发这条工作流` }], steps: candidate.steps.map((s, index) => ({ id: s.id || `S${index + 1}`, phase: s.phase || "观察段", @@ -1082,7 +1324,7 @@ export function summarizeProposal(proposal) { occurrences: proposal.occurrences ?? null, firstSeenAt: proposal.firstSeenAt || null, lastSeenAt: proposal.lastSeenAt || null, - // 反事实覆盖率:答过几步 / 一共几步。19/19 与 6/19 是两条不同的手法,清单上就该看得出来。 + // 反事实覆盖率:答过几步 / 一共几步。19/19 与 6/19 是两条不同的工作流,清单上就该看得出来。 contrast: { answered: (proposal.steps || []).filter((s) => Array.isArray(s.evidence) && s.evidence.length > 0).length, total: (proposal.steps || []).length @@ -1096,7 +1338,7 @@ export function summarizeProposal(proposal) { /** * 已经在跑的工作流的自精简提议:跳过 ≥3 次 → 提议删掉这一步;绕开 ≥3 次 → 提议重画路径。 * 计数与阈值全部来自 `pipeline.js`(那份是权威),这里只做两件事:把提议包成 - * **要人点头的形状**(带一个封闭问题),并把它和「新手法候选」放进同一条通路。 + * **要人点头的形状**(带一个封闭问题),并把它和「新工作流候选」放进同一条通路。 */ export function retirementProposals(pipeline, { runs = [], now = Date.now(), observed = null } = {}) { // 四类提议各自的封闭问题与选项。判据都不能自动算(每类都是),把答案空间 @@ -1160,6 +1402,74 @@ export function retirementProposals(pipeline, { runs = [], now = Date.now(), obs return { contract: "mneme.nudge/v1", pipelineId: pipeline.id, generatedAt: new Date(now).toISOString(), items }; } +/** + * 一个项目的**自动沉淀入口**:先把项目解析到它自己的会话桶,再跑一次 `nudgeFromSessions`。 + * + * 为什么这一层要单独存在:`nudgeFromSessions` 要求调用方**已经知道**这个项目的 `sessionDir` + * (给了 projectRoot 却不给 sessionDir 会被降级成无归属普查)。而真实调用方手里只有一个 + * cwd。把「项目根 → 桶」和「跑一轮」焊在一起,是为了让调用方**没有机会**漏掉那一步而 + * 静默退化成普查——退化的输出看上去一切正常,只是候选全都不属于任何项目。 + * + * 解析不到桶时**不跑**,直接回一份空结果并带上 `attribution.reason`:不是省事,是因为 + * 跑了也只能得到无归属普查,而它对「这个项目的工作流」这个问题的答案永远是错的。 + * `skipped: true` 是关键——空列表必须能区分「没跑」与「跑了但没挖到」,否则调用方会把 + * 一次归属失败当成「这个项目很干净」。 + * + * `attribution` 参数允许调用方把**已经算好**的桶解析结果传进来:`/nudge` 路由要先做一遍 + * 解析才能决定要不要回 400,再传进来就跑两遍(一遍扫桶、一遍真挖)。 + */ +export function nudgeForProject({ + projectRoot = null, + dshHome = undefined, + memoryDir = null, + project = "", + now = Date.now(), + budgetMs = NUDGE_AUTO_BUDGET_MS, + materialize = false, + persistProposals = true, + maxFiles = MAX_SESSION_FILES, + kinds = DEFAULT_NUDGE_KINDS, + attribution = null +} = {}) { + // 归属解析自己也可能很贵(同步扫桶 500 个),所以它也要吃同一个预算。调用方给了现成的 + // attribution 就不再解析(`/nudge` 路由就是这么做的:它先解析一次才能决定要不要回 400)。 + // 这里**自己算**截止点而不是多接一个 deadline 参数:两处各算一次会出现「解析用 A 截止、 + // 挖掘用 B 截止」的双预算,那种不一致以后没人查得出来。 + const budgetDeadline = Number.isFinite(budgetMs) && budgetMs > 0 ? now + budgetMs : null; + const where = attribution || resolveProjectSessionDir({ dshHome, projectRoot, deadline: budgetDeadline }); + if (!where || !where.sessionDir) { + return { + contract: "mneme.nudge/v1", + generatedAt: new Date(now).toISOString(), + attribution: where || { sessionDir: null, bucket: null, reason: "bad-project" }, + skipped: true, + skippedReason: "no-session-attribution", + refused: null, + source: null, + segments: 0, + kinds: Array.isArray(kinds) ? kinds.slice() : null, + project: null, + proposalsDir: null, + counts: { candidates: 0, buckets: 0, sessionsWithCandidates: 0, savedProposals: 0, draftsWritten: 0 }, + available: [], + drafts: [], + boundaries: NUDGE_BOUNDARIES + }; + } + const doc = nudgeFromSessions({ + sessionDir: where.sessionDir, + projectRoot, + memoryDir, + project, + now, + maxFiles, + kinds, + materialize, + persistProposals, + budgetMs + }); + return { ...doc, attribution: where, skipped: false }; +} /** * 一次完整的沉淀跑:读真实会话日志 → 挖候选 → 算能算的对照 → 把提议落进 memoryDir → * (可选)把草稿落进项目。 @@ -1168,7 +1478,7 @@ export function retirementProposals(pipeline, { runs = [], now = Date.now(), obs * 会把「提议」变成「既成事实」。所以 `materialize` 默认 false——要落就得有人开口。 * * **会话范围与项目归属是一件事,不是两件**:会话目录在 DSH 侧按工作区分桶,桶名是 - * 工作区路径的有损变形、规则不在本仓。所以「这个项目的手法」只有在调用方给出 + * 工作区路径的有损变形、规则不在本仓。所以「这个项目的工作流」只有在调用方给出 * **这个项目自己的 `sessionDir`** 时才成立。给了 `projectRoot` 却没给 `sessionDir` * 时,这一跑会被**降级成「无归属的普查」**:候选不带项目名、不写任何项目的提议桶、 * 不落草稿——绝不允许拿全机会话去冒充某个项目的账(复核报告 §2.6)。 @@ -1187,7 +1497,11 @@ export function nudgeFromSessions({ maxFiles = MAX_SESSION_FILES, failures = null, kinds = DEFAULT_NUDGE_KINDS, - materialize = false + materialize = false, + budgetMs = 0, + // 提议落盘是**写**。预览(面板刷新一次就调一次)不能顺手往 memoryDir 里塞文件: + // 落盘要从这里显式关掉,不能靠调用方「记得别调」。默认 true 保持既有语义。 + persistProposals = true } = {}) { const scoped = typeof sessionDir === "string" && sessionDir.trim() !== ""; const dir = scoped ? sessionDir : resolveSessionDir({ dshHome }); @@ -1195,7 +1509,9 @@ export function nudgeFromSessions({ // 那同样是「拿一批跨项目的观察去贴一个项目名」)。 const attributionRefused = !scoped && Boolean(projectRoot || project); const allowedProjectRoot = attributionRefused ? null : projectRoot; - const read = readSessionObservations({ sessionDir: dir, limit: maxFiles }); + // budgetMs > 0 才设截止点:测试与离线批处理要完整结果,面板要「不卡」。 + const deadline = Number.isFinite(budgetMs) && budgetMs > 0 ? now + budgetMs : null; + const read = readSessionObservations({ sessionDir: dir, limit: maxFiles, deadline }); const segments = segmentObservations(read.observations); const candidates = mineRepeatedPatterns(segments, { minSupport, minLength, maxLength, maxResults, kinds }); const projectName = attributionRefused ? "" : project || (allowedProjectRoot ? projectLabel(allowedProjectRoot) : ""); @@ -1208,7 +1524,7 @@ export function nudgeFromSessions({ const propDir = attributionRefused ? null : proposalDir({ memoryDir, projectRoot: allowedProjectRoot }); const saved = []; - if (propDir) { + if (propDir && persistProposals !== false) { for (const c of enriched) saved.push(saveProposal(propDir, c).id); } @@ -1244,6 +1560,7 @@ export function nudgeFromSessions({ present: read.present, readable: read.readable, truncated: read.truncated, + budgetExceeded: read.budgetExceeded === true, totalFiles: read.totalFiles, scannedFiles: read.scannedFiles, frames: read.frames, @@ -1268,14 +1585,6 @@ export function nudgeFromSessions({ available: enriched.map(summarizeProposal), drafts, // 「这一刀不做的事」写进契约里,免得以后有人以为忘了做。 - boundaries: { - autoActivate: false, - autoAnswerCounterfactual: false, - writesOnlyDrafts: true, - // 机器给的风险只是下限;R3 永远只能由人给。 - riskIsFloor: true, - // 支持度只在同一个工作区桶内累加;跨桶不并账。 - crossProjectSupport: false - } + boundaries: NUDGE_BOUNDARIES }; } diff --git a/dsh-mneme/lib/pipeline-runs.js b/dsh-mneme/lib/pipeline-runs.js index 80c918e1..5b2669b9 100644 --- a/dsh-mneme/lib/pipeline-runs.js +++ b/dsh-mneme/lib/pipeline-runs.js @@ -1,5 +1,5 @@ /** - * M.O.(Modus Operandi · 手法)工作流的**运行账**。 + * M.O.(Modus Operandi)工作流的**运行账**。 * * 读侧(`src/pipeline.js`)已经能把「一条工作流跑了怎么样」算成四态,但账本一直没人写, * 于是 `runs` 恒为空数组、节点状态恒为 `candidate`。本模块补的就是这一环:把一次 run diff --git a/dsh-mneme/lib/pipeline.js b/dsh-mneme/lib/pipeline.js index 57a4f6ed..ba2d5cd1 100644 --- a/dsh-mneme/lib/pipeline.js +++ b/dsh-mneme/lib/pipeline.js @@ -1,5 +1,5 @@ /** - * M.O.(Modus Operandi · 手法)工作流的**读侧**。 + * M.O.(Modus Operandi)工作流的**读侧**。 * * 本体的落点是仓库(`<项目>/.mneme/pipelines/*.json`),不是记忆库: * 工作流是要跑的东西,必须能提交、能 PR、能 review、能被 CI 读、能整版回退; @@ -15,9 +15,9 @@ * * **两层来源**(2026-10-15 定):项目层在前、全局层在后,顺序即优先级。 * 一条工作流默认属于某个项目,跟着项目走;装一次插件就在所有项目里看到同一套 - * 「全局手法」,正是设计备忘要避免的局面——本机活跃记忆里 95.7% 的 + * 「全局工作流」,正是设计备忘要避免的局面——本机活跃记忆里 95.7% 的 * `workspace_scope` 为空,「全局」于是变成谁都不看的杂物堆。全局层留着的唯一 - * 理由是接那些**跨项目复用**的手法,而跨项目复用是一个要留痕的显式动作。 + * 理由是接那些**跨项目复用**的工作流,而跨项目复用是一个要留痕的显式动作。 */ import { readFileSync, readdirSync, existsSync } from "node:fs"; import { dirname, join, posix as pathPosix, win32 as pathWin32 } from "node:path"; @@ -33,7 +33,7 @@ const basenameWin32 = pathWin32.basename; * * 它只是**兜底层**,不是默认住处:默认住处是 `projectPipelineDir()`。发布包里今天 * 没有 `pipelines/`(目录为空即不进包),所以全局层通常是空的——**这正是想要的**: - * 一条手法该不该跨项目用,得是一个人说出来的动作,而不是装插件时顺手塞进去的默认。 + * 一条工作流该不该跨项目用,得是一个人说出来的动作,而不是装插件时顺手塞进去的默认。 */ export function defaultPipelineDir() { return join(dirname(fileURLToPath(import.meta.url)), "..", "pipelines"); diff --git a/dsh-mneme/lib/settings.js b/dsh-mneme/lib/settings.js index 5ea23d33..d9a2acea 100644 --- a/dsh-mneme/lib/settings.js +++ b/dsh-mneme/lib/settings.js @@ -209,7 +209,7 @@ const FEATURE_FLAG_BOOLEANS = [ // 点号键平铺存、合并时展开回 distillRecords 对象(同 llmAudit.enabled)。 // retentionDays 是 int,走下面的 INT_RANGES 表,不进开关白名单。 "distillRecords.enabled", - // v0.9.0:M.O.(Modus Operandi · 手法,beta)客户端视图开关。客户端读 /features + // v0.9.0:M.O.(Modus Operandi,beta)客户端视图开关。客户端读 /features // 的 effective // 决定是否注册座位(会话头图标 / 浮层 / 右栏 / 对话行卡片),服务端只负责把 // 这个键稳定暴露出来——键若不在 effective 里,客户端静默少键、一个座位都不注册。 @@ -219,6 +219,10 @@ const FEATURE_FLAG_BOOLEANS = [ // 与上面那个键分开:那个管「看图的座位」,这个管「往账本里写」。默认关(beta、 // opt-in),顶层扁平键,不需要 NESTED_FLAG_PATHS 条目。 "actionRunEnabled", + // v0.9.0 第三批:M.O. 自动沉淀(会话跑完一轮 → 挖这个项目自己的会话日志 → 落提议)。 + // 与前两个分开:这个管「从日志里长出候选」,那个管记账、另一个管看图。默认关 + // (beta、opt-in);顶层扁平键,不需要 NESTED_FLAG_PATHS 条目。进 LIGHT_MODE_OFF。 + "actionNudgeEnabled", // 人格定义页(beta):注入器渲染人格段的顶层总闸(双闸门的 config 半边;对象内 // 的 persona.enabled 是 per-agent 半边,存 user_settings 的 "persona" 键,见 // getPersona/setPersona)。默认关(beta、opt-in,老用户行为逐字节不变);不进 diff --git a/dsh-mneme/src/api.js b/dsh-mneme/src/api.js index 6bfbf393..22a41290 100644 --- a/dsh-mneme/src/api.js +++ b/dsh-mneme/src/api.js @@ -18,6 +18,14 @@ import { loadRunsById, runsDir, RUNS_SUFFIX } from "./pipeline-runs.js"; import { observationDir, loadObservationsById } from "./pipeline-observations.js"; import { projectKey } from "./candidates.js"; import { readCandidateDoc } from "./candidates.js"; +// M.O. 自动沉淀:/nudge 是 pipeline-nudge.js 的第一个运行时消费者(此前它只有测试在用, +// 是个孤儿模块——「不能自动沉淀工作流」的根因)。 +import { + resolveProjectSessionDir, + nudgeForProject, + NUDGE_BOUNDARIES, + NUDGE_AUTO_BUDGET_MS +} from "./pipeline-nudge.js"; // headers:少数端点(/export 附件下载)需要追加 Content-Disposition 等响应头。 function sendJson(res, status, payload, headers = {}) { @@ -1849,7 +1857,7 @@ export function createApi(ctx, service, settings, commands, embedder, semantic = } }); - // --- M.O.(Modus Operandi · 手法)工作流 --- + // --- M.O.(Modus Operandi)工作流 --- // 只读端点,读的是**项目里**的 `<项目>/.mneme/pipelines/*.json`(文件头在 // src/pipeline.js),项目层优先、插件包里那份降级成全局层;投影成 // mneme.pipeline/v1 交给面板与 agent——两边读同一份,画面只是它的投影。 @@ -1992,6 +2000,116 @@ export function createApi(ctx, service, settings, commands, embedder, semantic = } }); + // --- M.O. 自动沉淀(nudge)--- + // 读+写两种方法同路:GET 是**预览**(不落盘),POST 是**真跑**(落提议、可落草稿)。 + // 为什么不全放 GET:GET 会被浏览器预取、被面板刷新反复调用,而这一跑要解压几十份 + // zstd 会话日志(同步 I/O,本机实测 16.2s)。预览走 GET 时才关掉提议落盘 + // (persistProposals:false)——否则每刷一次面板就往 memoryDir 里塞一批文件。 + // + // policy 是 token-write 而不是 open:GET 免 token(与 /pipeline、/candidates 同级的 + // 只读预览),POST 要 token——它会写盘,写盘面一律受门控。 + register({ + kind: "exact", + path: "/api/dsh-mneme/nudge", + policy: "token-write", + handler(req, res) { + const method = String(req.method || "GET").toUpperCase(); + // 只认这四种。为什么必须在**读 body 之前**拦:下面 persistProposals 是按 + // 「不是 GET/HEAD」算的,于是 PATCH/DELETE 会被当成写动作,走进 run() 落提议; + // 而它又不像 PUT/POST 那样读 body,等于一个不需要 body 的静默写盘口。 + // 这条 PR 自己的卖点是「GET 只预览、写动作一律要 POST」,那就得把口子堵死 + // (v0.9.3 评审抓出:带 token 的 PATCH/DELETE 会往 memoryDir 里写提议)。 + const allowed = method === "GET" || method === "HEAD" || method === "POST" || method === "PUT"; + if (!allowed) { + sendJson(res, 405, { error: "method-not-allowed", allow: "GET, HEAD, POST, PUT" }, { Allow: "GET, HEAD, POST, PUT" }); + return; + } + const run = (body) => { + try { + const url = new URL(req.url, "http://localhost"); + const project = url.searchParams.get("project"); + // 与 /pipeline、/candidates 同一条规矩:不是绝对路径就拒掉,不能静默退成全局。 + if (project !== null && !isAbsolute(project)) { + sendJson(res, 400, { error: "project-not-absolute", detail: project }); + return; + } + // 预算从**这里**起算,不是一个端点两套预算:归属解析(同步扫桶,最多 500 个) + // 与后面的挖掘必须共用同一条截止线,否则「真卡就调 budgetMs」对前一段是空话。 + // POST(显式真跑)给三倍预算,面板预览给短预算。 + const previewMethod = method === "GET" || method === "HEAD"; + const startedAt = Date.now(); + const budgetMs = previewMethod ? NUDGE_AUTO_BUDGET_MS : NUDGE_AUTO_BUDGET_MS * 3; + const deadline = startedAt + budgetMs; + // 归属解析先做、单独回执:解析不到桶时要能说清是「这个项目还没跑过会话」 + // 还是「会话目录读不到」——两者在候选列表上都表现为空。 + const where = resolveProjectSessionDir({ projectRoot: project, deadline }); + if (!where.sessionDir) { + // no-sessions-dir / unreadable / budget-exceeded / bucket-limit 都是**环境问题** + // (读不到、没扫完、没扫全),回 503 让调用方知道这不是答案; + // no-bucket / bad-project 是**关于这个项目的答案**,回 200。 + const envProblem = where.reason === "no-sessions-dir" + || where.reason === "unreadable" + || where.reason === "budget-exceeded" + || where.reason === "bucket-limit"; + sendJson(res, envProblem ? 503 : 200, { + error: envProblem + ? (where.reason === "budget-exceeded" + ? "nudge-attribution-budget-exceeded" + : where.reason === "bucket-limit" + ? "nudge-attribution-bucket-limit" + : "nudge-sessions-unreadable") + : undefined, + contract: "mneme.nudge/v1", + attribution: where, + skipped: true, + skippedReason: "no-session-attribution", + counts: { candidates: 0, buckets: 0, sessionsWithCandidates: 0, savedProposals: 0, draftsWritten: 0 }, + available: [], + drafts: [], + boundaries: NUDGE_BOUNDARIES + }); + return; + } + const memoryDir = (config && typeof config.memoryDir === "string" && config.memoryDir.trim()) + ? config.memoryDir + : "~/.dsh/memory"; + const doc = nudgeForProject({ + projectRoot: project, + // 上面已经解析过并判过 400/503,这里传进来,别再扫一遍桶。 + attribution: where, + // now 传起算时刻:nudgeForProject 内部按 now+budgetMs 重新算截止点,这样 + // 两处算出来是同一条线(传 Date.now() 会让挖掘白拿一段已经花掉的预算)。 + now: startedAt, + memoryDir, + // GET 只预览:不落提议、不落草稿。写动作只认 POST/PUT——PATCH/DELETE 已在 + // 入口回 405,否则它们会落进这个「不是 GET/HEAD 就算写」的分支。 + persistProposals: method !== "GET" && method !== "HEAD", + // 落草稿是「往仓库里塞文件」,必须显式要(body.materialize),默认不落。 + materialize: body && body.materialize === true, + // 与归属解析共用同一条预算线:宁可少看几份并如实标 budgetExceeded, + // 也不能让一次面板刷新把宿主主线程按住十几秒。 + budgetMs + }); + sendJson(res, 200, doc); + } catch (error) { + sendJson(res, 500, { + error: "nudge-failed", + detail: String((error && error.message) || error) + }); + } + }; + if (method === "PUT" || method === "POST") { + readBody(req, res).then((text) => { + // readBody 已经回过 413:null 就是「别再往下走」。 + if (text === null) return; + run(parseBody(text)); + }); + return; + } + run(null); + } + }); + return { routes: countRoutes(), dispose: () => { diff --git a/dsh-mneme/src/capabilities.js b/dsh-mneme/src/capabilities.js index 4c3a2524..f050c6b2 100644 --- a/dsh-mneme/src/capabilities.js +++ b/dsh-mneme/src/capabilities.js @@ -1,5 +1,5 @@ /** - * M.O.(Modus Operandi · 手法)能力清单的**读侧**。 + * M.O.(Modus Operandi)能力清单的**读侧**。 * * 能力清单 = 「这台机器上、这个项目里,agent 能拿到哪些外部增强」的一份可复核投影。 * 三类:技能(注入文本指令)/MCP(注册可执行工具)/插件(加载单元)。三者在 DSH 里 diff --git a/dsh-mneme/src/config.js b/dsh-mneme/src/config.js index 56edd446..b79b5c47 100644 --- a/dsh-mneme/src/config.js +++ b/dsh-mneme/src/config.js @@ -880,7 +880,7 @@ export const Config = z.object({ enforce: z.boolean().default(false) }).default({}), - // --- v0.9.0 M.O.(Modus Operandi · 手法,beta)-------------------------- + // --- v0.9.0 M.O.(Modus Operandi,beta)-------------------------- // 命名:M.O. = Modus Operandi,拉丁语「一个人一贯的做事方式」,缩写 M.O.; // 也正是 org 词根 modus(modusensus)。界面主显示 M.O.,设置项 hint 第一句 // 给全名——缩写不靠注释解释,靠用户第一眼能看见全名。 @@ -902,6 +902,20 @@ export const Config = z.object({ // 所以轻量档要关掉:`LIGHT_MODE_OFF` 里有它。 actionRunEnabled: z.boolean().default(false), + // M.O. 自动沉淀(v0.9.0 第三批):会话跑完一轮后,把这个项目自己的会话日志挖一遍, + // 把「反复出现的同一段能力序列」落成提议(memoryDir 下),可选再落一份 draft 草稿。 + // + // 与上面两个键三件事分开:actionPipelineEnabled 管「看图的座位」,actionRunEnabled + // 管「往账本里写」,这个管「从日志里长出候选」。三个都是独立能力,混成一个键会让 + // 「只想看图」的人顺手获得写盘能力。 + // + // 默认关(beta 必须 opt-in),并且进 LIGHT_MODE_OFF:它挂在 session/event 热路径上, + // 且解压会话日志是**同步**的(本机 118 份实测 16.2s),轻量档最不该背这条路径。 + // 触发侧另有 10 分钟/项目的冷却与 budgetMs 上限(见 src/pipeline-nudge.js 的 + // NUDGE_AUTO_MIN_INTERVAL_MS 与 src/index.js 的装配注释)——旗标只是总闸。 + // (冷却值以常量为准:早前这里写「30 分钟」,与实际 10 分钟不符。) + actionNudgeEnabled: z.boolean().default(false), + // --- 人格定义页(beta)--------------------------------------------------- // 注入器渲染人格段的顶层总闸(双闸门的 config 半边;user_settings "persona" // 键内的 persona.enabled 是 per-agent 半边,两闸同真才渲染)。API 的 effective @@ -962,7 +976,11 @@ const LIGHT_MODE_OFF = [ // M.O. 执行记账:它挂在**热路径**上(每个会话事件都过一次监听)。轻量档的取向 // 是「小模型 / 小上下文 / 少常驻路径」,这与一个每事件都过一遍的监听器相冲突。 // 默认关,这里只是轻量预设再确认一次;用户显式勾选仍然赢(合并顺序同上面两条)。 - "actionRunEnabled" + "actionRunEnabled", + // M.O. 自动沉淀:同样挂在 session/event 热路径上,而且解压会话日志是同步 I/O。 + // 轻量档的取向(小模型 / 小上下文 / 少常驻路径)与它直接冲突,预设里关掉; + // 用户显式勾选仍然赢(合并顺序同上面几条)。 + "actionNudgeEnabled" ]; /** diff --git a/dsh-mneme/src/index.js b/dsh-mneme/src/index.js index 11b9946e..bef8923e 100644 --- a/dsh-mneme/src/index.js +++ b/dsh-mneme/src/index.js @@ -24,6 +24,9 @@ import { createSettings } from "./settings.js"; import { createCommandManager } from "./commands.js"; import { createExecutionRecorder } from "./pipeline-runner.js"; import { registerMoRunCommand } from "./pipeline-command.js"; +// M.O. 自动沉淀的触发侧(冷却 / 排队 / 兜底)。挖掘与落盘在 pipeline-nudge.js, +// 这里只管「什么时候跑」。两者分开的理由见 src/pipeline-nudge-auto.js 文件头。 +import { createNudgeAutoRun } from "./pipeline-nudge-auto.js"; import { createVectorIndex } from "./vector-index.js"; // semantic(embedder/reranker/boot 回填)纯搬移至 src/semantic.js(PR2):宿主与 // daemon(dsh-mneme-serve,#363)共用同一套装配,原文件保留 barrel 出口。 @@ -590,6 +593,18 @@ export const apply = (ctx, config) => { disposers.push(api.dispose); } + // M.O. 自动沉淀(v0.9.3):项目里重复走的那套动作,自动变成 memoryDir 里的一条 + // 「提议」,等人确认后才可能进仓库。默认关(actionNudgeEnabled)。 + // + // 为什么位置在这里而不是和 M.O. 记账同段:`disposers` 声明在 :532,而记账块在它 + // 之前(TDZ)——照抄过去会在插件加载时直接抛 ReferenceError。这里已在其后。 + // + // 为什么挂 session/event 却又要进轻量档黑名单:它读的是 zstd 会话日志(同步解压), + // 是本插件里最重的一条后台路径;且它只产「提议」,关掉不影响记忆本身的正确性。 + if (cfg.actionNudgeEnabled === true) { + disposers.push(createNudgeAutoRun({ ctx, memoryDir, logger: ctx.logger })); + } + // Standalone external API (v0.7.12): plain node:http server for ecosystem // integrations outside the DSH host. Persisted external_api settings win // over the bundle config (enabled/port); the Bearer token lives in the same diff --git a/dsh-mneme/src/pipeline-nudge-auto.js b/dsh-mneme/src/pipeline-nudge-auto.js new file mode 100644 index 00000000..8c30e38d --- /dev/null +++ b/dsh-mneme/src/pipeline-nudge-auto.js @@ -0,0 +1,126 @@ +/** + * M.O. 自动沉淀的**触发侧**:让「这个项目又重复走了一遍同一套动作」变成自动发生的观察, + * 而不是等人想起来去点一下。 + * + * 为什么单独一个文件:pipeline-nudge.js 是纯函数式的挖掘 / 落盘层(不碰宿主上下文, + * 测试可以直接驱动);触发时机、冷却、去重、错误兜底是**宿主接线**问题,混进那一层会 + * 让它不再纯粹。 + * + * 三条执行侧边界(口径与 pipeline-nudge.js 的 NUDGE_BOUNDARIES 一致): + * - 只产 draft,且**自动跑一律不落草稿**(materialize 恒 false)——往别人仓库里塞文件 + * 必须是人的显式动作; + * - 落进 memoryDir 的只是「提议」(proposal),升格成工作流仍然要人批; + * - 一次跑不完就带着已读到的部分收工并如实标 budgetExceeded,**不重试、不排队补跑**: + * 沉淀是锦上添花,卡住宿主是事故。 + */ +import { projectRootOf } from "./pipeline-runner.js"; +import { nudgeForProject, NUDGE_AUTO_BUDGET_MS, NUDGE_AUTO_MIN_INTERVAL_MS } from "./pipeline-nudge.js"; + +/** + * 冷却不是防重复写盘(saveProposal 同 id 覆盖,本来就幂等),而是**观测新鲜度**: + * 同一个项目的会话日志十分钟内不会长出新的重复模式,重跑只是把同样的解压开销再付一次。 + */ +export function createNudgeAutoRun({ + ctx = null, + memoryDir = null, + logger = null, + now = () => Date.now(), + runNudge = nudgeForProject, + minIntervalMs = NUDGE_AUTO_MIN_INTERVAL_MS, + budgetMs = NUDGE_AUTO_BUDGET_MS, + maxFiles = null +} = {}) { + const log = logger || (ctx && ctx.logger) || null; + let disposed = false; + let timer = null; + // 每个项目一条冷却记录;键是归一化后的项目根。 + const lastRunAt = new Map(); + // 已排队但还没跑的项目:同一个项目连来十个 turn/end,只跑最后一次。 + const pending = new Set(); + + const normalize = (p) => { + let s = String(p).replace(/\\/g, "/").replace(/\/+$/, ""); + if (/^[a-zA-Z]:\//.test(s)) s = s.toLowerCase(); + return s; + }; + + const runOne = (projectRoot) => { + pending.delete(projectRoot); + if (disposed) return null; + lastRunAt.set(normalize(projectRoot), now()); + try { + const doc = runNudge({ + projectRoot, + memoryDir, + // 自动跑:永不落草稿。它只在记忆库的 proposals 分桶里留提议。 + materialize: false, + persistProposals: true, + budgetMs, + ...(maxFiles ? { maxFiles } : {}) + }); + // 静默是默认,但不许无声无息:只有真挖到东西时才说一句。 + if (doc && doc.counts && doc.counts.savedProposals > 0) { + log?.info?.("[dsh-mneme] M.O. 自动沉淀:" + projectRoot + " 记录到 " + doc.counts.savedProposals + " 条重复模式(待人工确认)"); + } + return doc; + } catch (error) { + // 自动跑的失败绝不能冒泡到 session/event——那条链上还挂着摘要与续期。 + log?.warn?.("[dsh-mneme] M.O. 自动沉淀失败(" + projectRoot + "):" + String((error && error.message) || error)); + return null; + } + }; + + // 队列里一个一个推进:一次 setTimeout 里连跑多个项目会把一帧拉长。 + const flush = () => { + timer = null; + if (disposed) return; + const first = pending.values().next(); + if (first.done) return; + runOne(first.value); + if (pending.size > 0 && !disposed) defer(); + }; + + const defer = () => { + if (disposed || timer) return; + // 解压会话日志是同步 I/O(本机 118 份实测 16.2s)。绝不在这里直接跑:当前这一帧 + // 是宿主的 turn/end 处理链,占住它等于让整个回合收尾卡住。setTimeout(0) 把它挪到 + // 下一个宏任务,宿主先收尾。 + timer = setTimeout(flush, 0); + if (typeof timer.unref === "function") timer.unref(); + }; + + const schedule = (projectRoot) => { + const key = normalize(projectRoot); + const last = lastRunAt.get(key); + if (last !== undefined && now() - last < minIntervalMs) return; + if (pending.has(projectRoot)) return; + pending.add(projectRoot); + defer(); + }; + + const onSessionEvent = (session, event) => { + if (disposed) return; + // 只认 turn/end:一轮结束才算一个观测窗口,中途记的是半截。 + if (!event || event.type !== "turn/end") return; + const projectRoot = projectRootOf(session); + if (!projectRoot) return; + schedule(projectRoot); + }; + + const unsubscribe = ctx && typeof ctx.on === "function" ? ctx.on("session/event", onSessionEvent) : null; + + const dispose = () => { + disposed = true; + pending.clear(); + if (timer) { + clearTimeout(timer); + timer = null; + } + if (typeof unsubscribe === "function") unsubscribe(); + }; + // 手动入口与测试都要能直接驱动一次(不起 timer、不走冷却)。 + dispose.runNow = (projectRoot) => (disposed || !projectRoot ? null : runOne(projectRoot)); + dispose.pendingCount = () => pending.size; + dispose.lastRunAt = (projectRoot) => lastRunAt.get(normalize(projectRoot)); + return dispose; +} diff --git a/dsh-mneme/src/pipeline-nudge.js b/dsh-mneme/src/pipeline-nudge.js index 41be122d..653e4d1b 100644 --- a/dsh-mneme/src/pipeline-nudge.js +++ b/dsh-mneme/src/pipeline-nudge.js @@ -1,8 +1,8 @@ /** - * M.O. 沉淀链(nudge):让手法**自己从数据里长出来**。 + * M.O. 沉淀链(nudge):让工作流**自己从数据里长出来**。 * * 这一层补的是读侧之外缺的那一头。`pipeline.js` 能把人手写的 JSON 渲染成图、能从 - * 运行记录算状态;但「一条新手法从哪来」在那之前一直是零代码。本文件就是那个来源: + * 运行记录算状态;但「一条新工作流从哪来」在那之前一直是零代码。本文件就是那个来源: * * 会话/记忆里的观察 → 反复出现的同一段序列(候选) → 反事实判据(请人回答) * → status:"draft" 的草稿 →(人点头)→ active @@ -79,9 +79,9 @@ export const NUDGE_MAX_CANDIDATES = 20; * * 这不是想当然定的,是拿本机 520 份真实会话日志跑出来的:工具级序列的支持度高得离谱 * (`read → pwsh` 出现在 322 个会话、`write → pwsh` 253 个),但它们全是 agent 的 - * 通用动作习惯,不是项目手法——按那个口径挖,推给人的前八条全是这类噪音。 + * 通用动作习惯,不是项目工作流——按那个口径挖,推给人的前八条全是这类噪音。 * 能力级稀疏得多(真实数据里同一段能力序列只有两三个会话支持),但每一条都是 - * 「有人特意选了这个技能」的选择,那才可能是手法。 + * 「有人特意选了这个技能」的选择,那才可能是工作流。 * * 想挖工具级就显式传 `kinds: ["tool", ...]` 或 `kinds: null`(不过滤)——**别改默认值**, * 默认值一改,面板第一屏就变回噪音。 @@ -91,6 +91,36 @@ export const DEFAULT_NUDGE_KINDS = ["skill", "mcp"]; /** 单次扫描最多读多少会话文件(目录膨胀时宁可报截断,也不要把一次刷新变成全盘扫描)。 */ const MAX_SESSION_FILES = 2000; +/** + * 自动沉淀的**最小间隔**(同一个项目两次自动跑之间)。 + * + * 为什么需要它:解压会话日志是**同步** I/O,本机 118 份日志实测 16.2s。挂到 + * `session/event` 上如果每轮都跑,长会话就是把宿主主线程反复按住。十分钟这个数是 + * 取的「够久到不会在同一个工作段里反复触发,够短到当天的新习惯还来得及被看见」—— + * 它不是性能参数,是**观测新鲜度**参数;真卡的话先调 budgetMs(那一条保证不卡)。 + */ +export const NUDGE_AUTO_MIN_INTERVAL_MS = 10 * 60 * 1000; + +/** + * 自动沉淀的单次时间预算。超了就**带着已读到的部分收工**并如实标 `budgetExceeded`, + * 而不是把这一跑作废——半份观察好过零份,只要调用方知道它只看了后半截。 + */ +export const NUDGE_AUTO_BUDGET_MS = 4000; + +/** + * 沉淀链的**边界**,写进每一份 return 的契约里。抽成一份常量是防「两条出口说法不一样」: + * 走完一整跑和提前收工给出的边界必须是同一句话,否则读契约的人得先猜自己拿到的是哪一种。 + */ +export const NUDGE_BOUNDARIES = Object.freeze({ + autoActivate: false, + autoAnswerCounterfactual: false, + writesOnlyDrafts: true, + // 机器给的风险只是下限;R3 永远只能由人给。 + riskIsFloor: true, + // 支持度只在同一个工作区桶内累加;跨桶不并账。 + crossProjectSupport: false +}); + /** 会话日志文件名前缀。**不写死版本号**:本机同一棵树下并存 v4 / v3 / 无版本号三种。 */ const SESSION_FILE_PREFIX = "session"; @@ -139,6 +169,207 @@ export function resolveSessionDir({ dshHome = process.env.DSH_HOME || join(homed return join(String(dshHome), "sessions"); } +/** + * 路径规范化:只用来**比相等**,不用来读写文件(读写永远用原串)。 + * + * 为什么要折:同一份工作目录在会话头里可能是 `C:\\Users\\x\\proj`、`c:/users/x/proj`、 + * 末尾带不带分隔符三种写法之一,直接 `===` 会让归属解析**随机**命中或落空,而落空 + * 的症状和「这个项目确实没会话」完全同形。 + * 只在 win32 上折小写:POSIX 的文件名大小写敏感,在那里折小写会把 + * `/a/Proj` 与 `/a/proj` 两个不同项目并成一个——那正是本仓最防的一类「合并两个项目的账」。 + */ +export function normalizeProjectPath(p) { + if (typeof p !== "string") return null; + const trimmed = p.trim(); + if (trimmed === "") return null; + let s = trimmed.replace(/\\/g, "/"); + // 去掉末尾分隔符,但根("/" 或 "C:/")要留着——去掉就不是一个路径了。 + s = s.replace(/\/+$/, ""); + if (/^[A-Za-z]:$/.test(s)) s += "/"; + if (s === "") s = "/"; + return process.platform === "win32" ? s.toLowerCase() : s; +} + +/** + * 一个会话文件里记的 `cwd`(会话日志首行的 `{"type":"session",...,"cwd":"…"}`)。 + * + * 为什么读内容而不是反解桶名:桶名是工作区路径的**有损**变形(盘符后的 `-`、 + * 空格被吃掉、中文按 UTF-16 码元转义),本机 19 个桶里反解实测多处对不上—— + * 反解错的时候**不会报错**,只会把别的项目的账归到这个项目名下。会话头里的 + * `cwd` 是宿主自己写的原文,没有歧义。 + * + * 只解第一帧是性能选择(一份会话日志能到 1.5 MB、上百帧),但第一帧解不出时 + * **继续扫后面几帧**:宁可多解几帧,也不要把「拿不准」报成「这个桶没有项目」。 + */ +export function readSessionCwd(file) { + let buf; + try { + buf = readFileSync(file); + } catch { + return null; + } + let cwd = null; + const take = (text) => { + if (cwd) return; + for (const line of text.split("\n")) { + const t = line.trim(); + // 快判:没有 "cwd" 的行连 JSON.parse 都不用做(一帧里绝大多数行是消息)。 + if (!t || t[0] !== "{" || !t.includes('"cwd"')) continue; + try { + const obj = JSON.parse(t); + if (obj && typeof obj.cwd === "string" && obj.cwd.trim() !== "") { + cwd = obj.cwd; + return; + } + } catch { + // 半行/坏行:单行脏数据不该决定归属。 + } + } + }; + const start = buf.indexOf(ZSTD_MAGIC, 0); + if (start !== -1) { + let end = buf.indexOf(ZSTD_MAGIC, start + 4); + if (end === -1) end = buf.length; + try { + take(zlib.zstdDecompressSync(buf.subarray(start, end)).toString("utf8")); + } catch { + // 第一帧坏了:落到下面的整文件扫描。 + } + } + if (!cwd) forEachZstdFrame(buf, take); + buf.fill(0); + return cwd; +} + +/** + * 这个项目**自己的**会话桶目录(`/sessions/<工作区桶>`)。 + * + * 为什么非要它:`nudgeFromSessions` 只有在拿到「本项目自己的 sessionDir」时才敢给候选 + * 贴项目名、写提议桶、落草稿;给不出就会降级成「无归属普查」(见那里的 `refused`)。 + * 而桶名 → 项目这条路,除了会话头里的 `cwd` 没有可信来源。 + * + * 返回里 `reason` 是给人看的那句话: + * matched 找到了 + * bad-project 调用方没给项目根 / 给的不是路径 + * no-sessions-dir 会话根不存在(新机器、DSH 还没写过东西) + * no-bucket 扫完了,没有任何桶的会话头指向这个项目(这个项目确实还没跑过会话) + * unreadable 桶在,但所有会话头的 cwd 都读不出来——**这一条必须与 no-bucket 分开**: + * 前者是「读不到」,后者是「确实没有」,混起来就等于把故障报成空结果。 + * budget-exceeded 预算用光还没扫完——同样**不是**关于这个项目的答案(见 deadline 参数)。 + * bucket-limit 桶数超过 maxBuckets 被截断,且截断范围内没找到这个项目——**也不是** + * 「确实没有」:目标可能就在被截掉的那部分里(同日评审抓出)。 + * + * `deadline` 是这次解析的绝对截止时刻(毫秒)。不给就扫到底(测试与离线批处理要完整 + * 结果)。为什么要它:这一层是**同步**的,最多 500 个桶 × 3 个文件,全都走同步 I/O; + * 调用方手里的 `budgetMs` 原先只约束后面的挖掘,约束不到这里——一条「真卡就调 + * budgetMs」的建议对这段路径是空话(v0.9.3 评审抓出)。 + */ +export function resolveProjectSessionDir({ + dshHome = process.env.DSH_HOME || join(homedir(), ".dsh"), + projectRoot = null, + maxBuckets = 500, + filesPerBucket = 3, + deadline = null +} = {}) { + const target = normalizeProjectPath(projectRoot); + if (!target) { + return { sessionDir: null, bucket: null, reason: "bad-project", bucketsScanned: 0, filesRead: 0 }; + } + const root = resolveSessionDir({ dshHome }); + let entries; + try { + entries = readdirSync(root, { withFileTypes: true }); + } catch { + return { sessionDir: null, bucket: null, reason: "no-sessions-dir", sessionsRoot: root, bucketsScanned: 0, filesRead: 0 }; + } + const allBuckets = []; + for (const entry of entries) { + if (!entry.isDirectory()) continue; + allBuckets.push(entry.name); + } + // 先收全、再排序、最后截断——与 findSessionFiles 同一条规矩。早前是「边收边截」: + // 到 500 个就 break,然后才 sort(),于是**留下哪 500 个由文件系统遍历顺序决定**。 + // 目标项目的桶一旦落在被截掉的那部分,函数就返回 no-bucket——把一次截断报成 + // 「这个项目确实没有会话」,正好是本文件反复在防的「读不到 vs 确实没有」 + // (v0.9.3 评审抓出)。 + allBuckets.sort(); + const bucketsTruncated = allBuckets.length > maxBuckets; + const buckets = bucketsTruncated ? allBuckets.slice(0, maxBuckets) : allBuckets; + let filesRead = 0; + let bucketsWithCwd = 0; + const outOfBudget = () => deadline !== null && Number.isFinite(deadline) && Date.now() > deadline; + for (const bucket of buckets) { + // 桶循环里查预算:每个桶都可能要 readdir + 读 3 个文件,全同步, + // 一次解析跑满 500 个桶就是几百毫秒到几秒不等。 + if (outOfBudget()) { + return { + sessionDir: null, + bucket: null, + reason: "budget-exceeded", + sessionsRoot: root, + bucketsScanned: buckets.length, + bucketsTotal: allBuckets.length, + bucketsTruncated, + filesRead + }; + } + const dir = join(root, bucket); + // 每个桶只读前几个文件:同一个桶里所有会话的 cwd 必然相同(桶是按工作区分的), + // 读三个只是给「第一个文件正好是截断的」留余量,不是要把整个桶扫一遍。 + const found = findSessionFiles(dir, { limit: filesPerBucket }); + for (const file of found.files.slice(0, filesPerBucket)) { + // 文件循环里也查:filesPerBucket 可被调用方调大,3 是最小值不是上限。 + if (outOfBudget()) { + return { + sessionDir: null, + bucket: null, + reason: "budget-exceeded", + sessionsRoot: root, + bucketsScanned: buckets.length, + bucketsTotal: allBuckets.length, + bucketsTruncated, + filesRead + }; + } + const cwd = readSessionCwd(file); + filesRead += 1; + if (!cwd) continue; + bucketsWithCwd += 1; + if (normalizeProjectPath(cwd) === target) { + return { + sessionDir: dir, + bucket, + reason: "matched", + matchedCwd: cwd, + sessionsRoot: root, + bucketsScanned: buckets.length, + bucketsTotal: allBuckets.length, + bucketsTruncated, + filesRead + }; + } + } + } + return { + sessionDir: null, + bucket: null, + // 桶里一个 cwd 都读不出来 → 是读不了,不是没有。 + // 截断且没命中 → 也不是「确实没有」:目标可能就在被截掉的那部分里。 + reason: buckets.length === 0 + ? "no-sessions-dir" + : bucketsWithCwd === 0 + ? "unreadable" + : bucketsTruncated + ? "bucket-limit" + : "no-bucket", + sessionsRoot: root, + bucketsScanned: buckets.length, + bucketsTotal: allBuckets.length, + bucketsTruncated, + filesRead + }; +} + /** * 逐帧解一个**多帧** zstd buffer,边解边回调。 * @@ -235,7 +466,7 @@ export function findSessionFiles(dir, { limit = MAX_SESSION_FILES } = {}) { * `bucket` 是**工作区桶**(会话文件往上数第三层目录名)。为什么非要带上它: * 会话目录在 DSH 侧按工作区分桶,`sessionId` 只是桶内那个会话目录名——两条不同工作区 * 里的会话会拿到两个不同的 `sessionId`,于是一段序列在两个项目里各出现一次,就会被 - * 并成「支持度 2 的同一手法」。那是把两个项目的账合成一本,实测过(复核报告 §2.6)。 + * 并成「支持度 2 的同一工作流」。那是把两个项目的账合成一本,实测过(复核报告 §2.6)。 * 带上 bucket 之后,支持度只在同一个桶内累加。 */ export function observationsFromCalls(calls, { sessionId = null, bucket = null } = {}) { @@ -305,7 +536,7 @@ export function toolCallsInLine(line) { * 取不到就落 null——落 null 的观察只会让支持度算不出来,不会假装成「同一个会话」 * 或「同一个项目」。 */ -export function readSessionObservations({ sessionDir = resolveSessionDir(), limit = MAX_SESSION_FILES } = {}) { +export function readSessionObservations({ sessionDir = resolveSessionDir(), limit = MAX_SESSION_FILES, deadline = null } = {}) { const found = findSessionFiles(sessionDir, { limit }); const files = found.files.slice(0, limit); const totalFiles = Number.isFinite(found.totalFiles) ? found.totalFiles : found.files.length; @@ -315,6 +546,9 @@ export function readSessionObservations({ sessionDir = resolveSessionDir(), limi // 「目录在」与「目录真能列出来」分开报:否则「不可读」与「空目录」同形。 readable: found.present && found.unreadable.length === 0, truncated: found.truncated === true || totalFiles > files.length, + // 时间预算用光而提前收工:与「文件数上限」是两种截断,必须分开报——否则 + // 「扫了一部分」会伪装成「扫完了但没挖到」。面板与调用方据此说「只看了最近这些」。 + budgetExceeded: false, totalFiles, scannedFiles: 0, frames: 0, @@ -326,6 +560,14 @@ export function readSessionObservations({ sessionDir = resolveSessionDir(), limi }; for (const file of files) { + // 预算到点就停:解压是**同步**的,一次全量扫描会把宿主主线程按住十几秒 + // (本机 118 份日志实测 16.2s)。宁可少看几份并如实报出来,也不能让一次 + // 面板点击变成整窗卡死——那会被当成插件坏了。 + if (deadline !== null && Date.now() > deadline) { + result.budgetExceeded = true; + result.truncated = true; + break; + } let buf; try { buf = readFileSync(file); @@ -387,7 +629,7 @@ export function segmentObservations(observations) { /** * 一段里某个位置的观察样本怎么并进模式:命令**取并**——风险下限要看过全部命令才敢说 * 「没命中兜底清单」,只看第一条会把第三条里的 `git push` 漏掉。 - * 上限 12 条是内存护栏:同一个位置出现几百种不同命令时,它是噪音不是手法。 + * 上限 12 条是内存护栏:同一个位置出现几百种不同命令时,它是噪音不是工作流。 */ const MAX_COMMANDS_PER_POSITION = 12; @@ -580,12 +822,12 @@ export function filterSegmentsByKinds(segments, kinds) { * **极大模式**的交代。 * * 极大模式(maximal pattern):`A → B` 与 `A → B → C` 支持度相同时,只留长的那条。 - * 理由:`A → B` 是 `A → B → C` 的一段前缀,两条都推给人就是把同一条手法说两遍。 + * 理由:`A → B` 是 `A → B → C` 的一段前缀,两条都推给人就是把同一条工作流说两遍。 * 判据是**包含 + 会话集是子集**——只按字符串包含会把「支持度更低的短模式」也吞掉。 * * **支持度只在同一个工作区桶内累加**(`key` 前缀带 bucket):两个不同项目里各见过一次 - * 的同一段序列,不是「支持度 2 的同一手法」,而是两条各自只有一次的观察。混桶会凭空 - * 造出候选,并把别人的手法算到这个项目头上(复核报告 §2.6 实测过)。 + * 的同一段序列,不是「支持度 2 的同一工作流」,而是两条各自只有一次的观察。混桶会凭空 + * 造出候选,并把别人的工作流算到这个项目头上(复核报告 §2.6 实测过)。 */ export function mineRepeatedPatterns(segments, { minSupport = NUDGE_MIN_SUPPORT, minLength = NUDGE_MIN_LENGTH, maxLength = NUDGE_MAX_LENGTH, maxResults = NUDGE_MAX_CANDIDATES, kinds = DEFAULT_NUDGE_KINDS } = {}) { const scoped = filterSegmentsByKinds(segments, kinds); @@ -786,7 +1028,7 @@ function runsForCandidate(candidate, runs) { * 能给的证据三类: * · 复现:同一段序列在几个会话、几次回合、什么时间跨度里出现过(硬事实); * · 缺席对照:中间那一步「走过几次 / 少走几次」(硬事实,见 omissionContrast); - * · 运行记录与失败记录:如果这条手法已经有 runs,或者人喂了记忆侧的失败行,一并报数。 + * · 运行记录与失败记录:如果这条工作流已经有 runs,或者人喂了记忆侧的失败行,一并报数。 * * `failures` 传 `null`(默认)表示**记忆侧的失败记录还没接进来**,与「接进来了、 * 确实一条都没有」是两件事,所以这里不给 `null`,而是给 `{ available, reason }` @@ -905,12 +1147,12 @@ export function draftFromCandidate(candidate, { project = "", name = "", done = } const raw = { id: String(candidate.id || ""), - name: name || `(待补)由重复观察提出的手法:${(candidate.names || []).join(" → ")}`, + name: name || `(待补)由重复观察提出的工作流:${(candidate.names || []).join(" → ")}`, project: project || TODO, version, status: "draft", done: done || `${TODO}整条怎么算完成`, - trigger: Array.isArray(trigger) && trigger.length ? trigger : [{ kind: "phrase", value: `${TODO}什么话该触发这条手法` }], + trigger: Array.isArray(trigger) && trigger.length ? trigger : [{ kind: "phrase", value: `${TODO}什么话该触发这条工作流` }], steps: candidate.steps.map((s, index) => ({ id: s.id || `S${index + 1}`, phase: s.phase || "观察段", @@ -1082,7 +1324,7 @@ export function summarizeProposal(proposal) { occurrences: proposal.occurrences ?? null, firstSeenAt: proposal.firstSeenAt || null, lastSeenAt: proposal.lastSeenAt || null, - // 反事实覆盖率:答过几步 / 一共几步。19/19 与 6/19 是两条不同的手法,清单上就该看得出来。 + // 反事实覆盖率:答过几步 / 一共几步。19/19 与 6/19 是两条不同的工作流,清单上就该看得出来。 contrast: { answered: (proposal.steps || []).filter((s) => Array.isArray(s.evidence) && s.evidence.length > 0).length, total: (proposal.steps || []).length @@ -1096,7 +1338,7 @@ export function summarizeProposal(proposal) { /** * 已经在跑的工作流的自精简提议:跳过 ≥3 次 → 提议删掉这一步;绕开 ≥3 次 → 提议重画路径。 * 计数与阈值全部来自 `pipeline.js`(那份是权威),这里只做两件事:把提议包成 - * **要人点头的形状**(带一个封闭问题),并把它和「新手法候选」放进同一条通路。 + * **要人点头的形状**(带一个封闭问题),并把它和「新工作流候选」放进同一条通路。 */ export function retirementProposals(pipeline, { runs = [], now = Date.now(), observed = null } = {}) { // 四类提议各自的封闭问题与选项。判据都不能自动算(每类都是),把答案空间 @@ -1160,6 +1402,74 @@ export function retirementProposals(pipeline, { runs = [], now = Date.now(), obs return { contract: "mneme.nudge/v1", pipelineId: pipeline.id, generatedAt: new Date(now).toISOString(), items }; } +/** + * 一个项目的**自动沉淀入口**:先把项目解析到它自己的会话桶,再跑一次 `nudgeFromSessions`。 + * + * 为什么这一层要单独存在:`nudgeFromSessions` 要求调用方**已经知道**这个项目的 `sessionDir` + * (给了 projectRoot 却不给 sessionDir 会被降级成无归属普查)。而真实调用方手里只有一个 + * cwd。把「项目根 → 桶」和「跑一轮」焊在一起,是为了让调用方**没有机会**漏掉那一步而 + * 静默退化成普查——退化的输出看上去一切正常,只是候选全都不属于任何项目。 + * + * 解析不到桶时**不跑**,直接回一份空结果并带上 `attribution.reason`:不是省事,是因为 + * 跑了也只能得到无归属普查,而它对「这个项目的工作流」这个问题的答案永远是错的。 + * `skipped: true` 是关键——空列表必须能区分「没跑」与「跑了但没挖到」,否则调用方会把 + * 一次归属失败当成「这个项目很干净」。 + * + * `attribution` 参数允许调用方把**已经算好**的桶解析结果传进来:`/nudge` 路由要先做一遍 + * 解析才能决定要不要回 400,再传进来就跑两遍(一遍扫桶、一遍真挖)。 + */ +export function nudgeForProject({ + projectRoot = null, + dshHome = undefined, + memoryDir = null, + project = "", + now = Date.now(), + budgetMs = NUDGE_AUTO_BUDGET_MS, + materialize = false, + persistProposals = true, + maxFiles = MAX_SESSION_FILES, + kinds = DEFAULT_NUDGE_KINDS, + attribution = null +} = {}) { + // 归属解析自己也可能很贵(同步扫桶 500 个),所以它也要吃同一个预算。调用方给了现成的 + // attribution 就不再解析(`/nudge` 路由就是这么做的:它先解析一次才能决定要不要回 400)。 + // 这里**自己算**截止点而不是多接一个 deadline 参数:两处各算一次会出现「解析用 A 截止、 + // 挖掘用 B 截止」的双预算,那种不一致以后没人查得出来。 + const budgetDeadline = Number.isFinite(budgetMs) && budgetMs > 0 ? now + budgetMs : null; + const where = attribution || resolveProjectSessionDir({ dshHome, projectRoot, deadline: budgetDeadline }); + if (!where || !where.sessionDir) { + return { + contract: "mneme.nudge/v1", + generatedAt: new Date(now).toISOString(), + attribution: where || { sessionDir: null, bucket: null, reason: "bad-project" }, + skipped: true, + skippedReason: "no-session-attribution", + refused: null, + source: null, + segments: 0, + kinds: Array.isArray(kinds) ? kinds.slice() : null, + project: null, + proposalsDir: null, + counts: { candidates: 0, buckets: 0, sessionsWithCandidates: 0, savedProposals: 0, draftsWritten: 0 }, + available: [], + drafts: [], + boundaries: NUDGE_BOUNDARIES + }; + } + const doc = nudgeFromSessions({ + sessionDir: where.sessionDir, + projectRoot, + memoryDir, + project, + now, + maxFiles, + kinds, + materialize, + persistProposals, + budgetMs + }); + return { ...doc, attribution: where, skipped: false }; +} /** * 一次完整的沉淀跑:读真实会话日志 → 挖候选 → 算能算的对照 → 把提议落进 memoryDir → * (可选)把草稿落进项目。 @@ -1168,7 +1478,7 @@ export function retirementProposals(pipeline, { runs = [], now = Date.now(), obs * 会把「提议」变成「既成事实」。所以 `materialize` 默认 false——要落就得有人开口。 * * **会话范围与项目归属是一件事,不是两件**:会话目录在 DSH 侧按工作区分桶,桶名是 - * 工作区路径的有损变形、规则不在本仓。所以「这个项目的手法」只有在调用方给出 + * 工作区路径的有损变形、规则不在本仓。所以「这个项目的工作流」只有在调用方给出 * **这个项目自己的 `sessionDir`** 时才成立。给了 `projectRoot` 却没给 `sessionDir` * 时,这一跑会被**降级成「无归属的普查」**:候选不带项目名、不写任何项目的提议桶、 * 不落草稿——绝不允许拿全机会话去冒充某个项目的账(复核报告 §2.6)。 @@ -1187,7 +1497,11 @@ export function nudgeFromSessions({ maxFiles = MAX_SESSION_FILES, failures = null, kinds = DEFAULT_NUDGE_KINDS, - materialize = false + materialize = false, + budgetMs = 0, + // 提议落盘是**写**。预览(面板刷新一次就调一次)不能顺手往 memoryDir 里塞文件: + // 落盘要从这里显式关掉,不能靠调用方「记得别调」。默认 true 保持既有语义。 + persistProposals = true } = {}) { const scoped = typeof sessionDir === "string" && sessionDir.trim() !== ""; const dir = scoped ? sessionDir : resolveSessionDir({ dshHome }); @@ -1195,7 +1509,9 @@ export function nudgeFromSessions({ // 那同样是「拿一批跨项目的观察去贴一个项目名」)。 const attributionRefused = !scoped && Boolean(projectRoot || project); const allowedProjectRoot = attributionRefused ? null : projectRoot; - const read = readSessionObservations({ sessionDir: dir, limit: maxFiles }); + // budgetMs > 0 才设截止点:测试与离线批处理要完整结果,面板要「不卡」。 + const deadline = Number.isFinite(budgetMs) && budgetMs > 0 ? now + budgetMs : null; + const read = readSessionObservations({ sessionDir: dir, limit: maxFiles, deadline }); const segments = segmentObservations(read.observations); const candidates = mineRepeatedPatterns(segments, { minSupport, minLength, maxLength, maxResults, kinds }); const projectName = attributionRefused ? "" : project || (allowedProjectRoot ? projectLabel(allowedProjectRoot) : ""); @@ -1208,7 +1524,7 @@ export function nudgeFromSessions({ const propDir = attributionRefused ? null : proposalDir({ memoryDir, projectRoot: allowedProjectRoot }); const saved = []; - if (propDir) { + if (propDir && persistProposals !== false) { for (const c of enriched) saved.push(saveProposal(propDir, c).id); } @@ -1244,6 +1560,7 @@ export function nudgeFromSessions({ present: read.present, readable: read.readable, truncated: read.truncated, + budgetExceeded: read.budgetExceeded === true, totalFiles: read.totalFiles, scannedFiles: read.scannedFiles, frames: read.frames, @@ -1268,14 +1585,6 @@ export function nudgeFromSessions({ available: enriched.map(summarizeProposal), drafts, // 「这一刀不做的事」写进契约里,免得以后有人以为忘了做。 - boundaries: { - autoActivate: false, - autoAnswerCounterfactual: false, - writesOnlyDrafts: true, - // 机器给的风险只是下限;R3 永远只能由人给。 - riskIsFloor: true, - // 支持度只在同一个工作区桶内累加;跨桶不并账。 - crossProjectSupport: false - } + boundaries: NUDGE_BOUNDARIES }; } diff --git a/dsh-mneme/src/pipeline-runs.js b/dsh-mneme/src/pipeline-runs.js index 80c918e1..5b2669b9 100644 --- a/dsh-mneme/src/pipeline-runs.js +++ b/dsh-mneme/src/pipeline-runs.js @@ -1,5 +1,5 @@ /** - * M.O.(Modus Operandi · 手法)工作流的**运行账**。 + * M.O.(Modus Operandi)工作流的**运行账**。 * * 读侧(`src/pipeline.js`)已经能把「一条工作流跑了怎么样」算成四态,但账本一直没人写, * 于是 `runs` 恒为空数组、节点状态恒为 `candidate`。本模块补的就是这一环:把一次 run diff --git a/dsh-mneme/src/pipeline.js b/dsh-mneme/src/pipeline.js index 57a4f6ed..ba2d5cd1 100644 --- a/dsh-mneme/src/pipeline.js +++ b/dsh-mneme/src/pipeline.js @@ -1,5 +1,5 @@ /** - * M.O.(Modus Operandi · 手法)工作流的**读侧**。 + * M.O.(Modus Operandi)工作流的**读侧**。 * * 本体的落点是仓库(`<项目>/.mneme/pipelines/*.json`),不是记忆库: * 工作流是要跑的东西,必须能提交、能 PR、能 review、能被 CI 读、能整版回退; @@ -15,9 +15,9 @@ * * **两层来源**(2026-10-15 定):项目层在前、全局层在后,顺序即优先级。 * 一条工作流默认属于某个项目,跟着项目走;装一次插件就在所有项目里看到同一套 - * 「全局手法」,正是设计备忘要避免的局面——本机活跃记忆里 95.7% 的 + * 「全局工作流」,正是设计备忘要避免的局面——本机活跃记忆里 95.7% 的 * `workspace_scope` 为空,「全局」于是变成谁都不看的杂物堆。全局层留着的唯一 - * 理由是接那些**跨项目复用**的手法,而跨项目复用是一个要留痕的显式动作。 + * 理由是接那些**跨项目复用**的工作流,而跨项目复用是一个要留痕的显式动作。 */ import { readFileSync, readdirSync, existsSync } from "node:fs"; import { dirname, join, posix as pathPosix, win32 as pathWin32 } from "node:path"; @@ -33,7 +33,7 @@ const basenameWin32 = pathWin32.basename; * * 它只是**兜底层**,不是默认住处:默认住处是 `projectPipelineDir()`。发布包里今天 * 没有 `pipelines/`(目录为空即不进包),所以全局层通常是空的——**这正是想要的**: - * 一条手法该不该跨项目用,得是一个人说出来的动作,而不是装插件时顺手塞进去的默认。 + * 一条工作流该不该跨项目用,得是一个人说出来的动作,而不是装插件时顺手塞进去的默认。 */ export function defaultPipelineDir() { return join(dirname(fileURLToPath(import.meta.url)), "..", "pipelines"); diff --git a/dsh-mneme/src/settings.js b/dsh-mneme/src/settings.js index 5ea23d33..d9a2acea 100644 --- a/dsh-mneme/src/settings.js +++ b/dsh-mneme/src/settings.js @@ -209,7 +209,7 @@ const FEATURE_FLAG_BOOLEANS = [ // 点号键平铺存、合并时展开回 distillRecords 对象(同 llmAudit.enabled)。 // retentionDays 是 int,走下面的 INT_RANGES 表,不进开关白名单。 "distillRecords.enabled", - // v0.9.0:M.O.(Modus Operandi · 手法,beta)客户端视图开关。客户端读 /features + // v0.9.0:M.O.(Modus Operandi,beta)客户端视图开关。客户端读 /features // 的 effective // 决定是否注册座位(会话头图标 / 浮层 / 右栏 / 对话行卡片),服务端只负责把 // 这个键稳定暴露出来——键若不在 effective 里,客户端静默少键、一个座位都不注册。 @@ -219,6 +219,10 @@ const FEATURE_FLAG_BOOLEANS = [ // 与上面那个键分开:那个管「看图的座位」,这个管「往账本里写」。默认关(beta、 // opt-in),顶层扁平键,不需要 NESTED_FLAG_PATHS 条目。 "actionRunEnabled", + // v0.9.0 第三批:M.O. 自动沉淀(会话跑完一轮 → 挖这个项目自己的会话日志 → 落提议)。 + // 与前两个分开:这个管「从日志里长出候选」,那个管记账、另一个管看图。默认关 + // (beta、opt-in);顶层扁平键,不需要 NESTED_FLAG_PATHS 条目。进 LIGHT_MODE_OFF。 + "actionNudgeEnabled", // 人格定义页(beta):注入器渲染人格段的顶层总闸(双闸门的 config 半边;对象内 // 的 persona.enabled 是 per-agent 半边,存 user_settings 的 "persona" 键,见 // getPersona/setPersona)。默认关(beta、opt-in,老用户行为逐字节不变);不进 diff --git a/dsh-mneme/test/api-nudge-route.test.js b/dsh-mneme/test/api-nudge-route.test.js new file mode 100644 index 00000000..e4a6ba1e --- /dev/null +++ b/dsh-mneme/test/api-nudge-route.test.js @@ -0,0 +1,259 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { EventEmitter } from "node:events"; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync, existsSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import zlib from "node:zlib"; +import { createStore } from "../src/store.js"; +import { createService } from "../src/service.js"; +import { createApi } from "../src/api.js"; +import { createSettings } from "../src/settings.js"; + +// /api/dsh-mneme/nudge 的接线锁。三件事分开守,因为它们混在一起时各自的失败长得一样: +// 1. policy 是 token-write —— GET 免 token(面板每刷一次就 GET 一次),POST 要 token(会写盘); +// 2. 「读不到」与「确实没有」必须回不同状态码(503 vs 200),否则故障会被当成答案; +// 3. GET 是预览、不许落盘 —— 这一条是接线时发现真 bug 的地方:nudgeFromSessions +// 原先只要算得出项目键就无条件写提议,而面板刷新一次就是一次 GET。 + +function tmpDir() { + return mkdtempSync(join(tmpdir(), "mneme-nudge-route-")); +} + +async function withDir(fn) { + const dir = tmpDir(); + try { + return await fn(dir); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +} + +/** 一行会话日志,形状照 DSH 会话文件:data.message.content[] 里是 tool-call。 */ +function sessionLine(calls) { + const content = calls.map((c) => ({ type: "tool-call", name: c.name, arguments: JSON.stringify(c.args || {}) })); + return JSON.stringify({ time: 1700000000000, data: { turn: 1, message: { content } } }); +} + +/** 造一份会话日志:sessions/<桶>/<会话>/session.v4.jsonl.zstd,头行带 cwd。 */ +function writeSession(dshHome, { bucket, sessionId, cwd, calls }) { + const sub = join(dshHome, "sessions", bucket, sessionId); + mkdirSync(sub, { recursive: true }); + const head = JSON.stringify({ time: 1700000000000, cwd }); + const body = sessionLine(calls); + writeFileSync(join(sub, "session.v4.jsonl.zstd"), zlib.zstdCompressSync(Buffer.from([head, body].join("\n"), "utf8"))); +} + +class FakeRes extends EventEmitter { + constructor() { super(); this.statusCode = 200; this.body = ""; } + writeHead(code, headers) { this.statusCode = code; this.headers = headers; return this; } + end(text) { this.body = text ?? ""; this.emit("end"); return this; } +} + +/** + * POST 分支返回的是「已排进微任务的 promise」,不是自己的 promise:handler 直接 + * `return;`(src/api.js:2069-2075)。所以拿到 FakeRes 不够,必须等它真的 end, + * 否则断言跑在正文还没写之前——第一次写这个文件就是在这里被 200 !== 400 骗了。 + */ +function awaitEnd(res) { + return new Promise((resolve) => res.once("end", resolve)); +} + +function req(path, method = "GET", body = null, headers = {}) { + const r = new EventEmitter(); + r.url = path; + r.method = method; + r.headers = headers; + if (body !== null) { + process.nextTick(() => { + r.emit("data", Buffer.from(JSON.stringify(body))); + r.emit("end"); + }); + } + return r; +} + +function setup({ apiToken = "", memoryDir = null } = {}) { + const store = createStore(":memory:"); + const service = createService({ store, mirror: null, config: {} }); + const settings = createSettings(store.db); + const commands = { add: () => {}, remove: () => {}, list: () => [] }; + const routes = []; + const ctx = { webServer: { register(route) { routes.push(route); return () => {}; } } }; + createApi(ctx, service, settings, commands, null, undefined, apiToken, { memoryDir }); + const route = routes.find((r) => r.path === "/api/dsh-mneme/nudge"); + assert.ok(route, "路由没注册"); + return { route, store }; +} + +test("/nudge:policy 是 token-write —— GET 免 token,POST 要 token", async () => { + const { route } = setup({ apiToken: "secret-token" }); + + // GET 是预览(只读),与 /pipeline、/candidates 同级:不带 token 也要能读。 + // 面板每次展开 M.O. 都会 GET 一次,要 token 等于让预览永远 401。 + const get = new FakeRes(); + await route.handler(req("/api/dsh-mneme/nudge?project=relative"), get); + assert.notEqual(get.statusCode, 401, "GET 不该要 token"); + + // POST 会真写盘(落提议),一律受门控。没有这道闸,任何能打开面板的页面 + // 都能往记忆库里塞文件。 + const post = new FakeRes(); + await route.handler(req("/api/dsh-mneme/nudge?project=relative", "POST", {}), post); + assert.equal(post.statusCode, 401); + assert.equal(JSON.parse(post.body).error, "unauthorized"); + + // 带 token 才放行到业务判断(这里 project 是相对路径,应当由业务层回 400)。 + const ok = new FakeRes(); + const okDone = awaitEnd(ok); + route.handler(req("/api/dsh-mneme/nudge?project=relative", "POST", {}, { authorization: "Bearer secret-token" }), ok); + await okDone; + assert.equal(ok.statusCode, 400); +}); + +test("/nudge:project 必须是绝对路径,相对路径拒掉而不是静默退成全局", async () => { + // 与 /pipeline、/candidates 同一条规矩。忽略相对路径会静默退成「全局」, + // 而全局看起来一切正常——那是最难查的一类错。 + return withDir((dir) => { + const { route } = setup({ memoryDir: join(dir, "memory") }); + const res = new FakeRes(); + route.handler(req("/api/dsh-mneme/nudge?project=dsh-mneme", "GET"), res); + assert.equal(res.statusCode, 400); + assert.equal(JSON.parse(res.body).error, "project-not-absolute"); + }); +}); + +test("/nudge:「读不到会话」回 503,「这个项目确实没有」回 200", async () => { + // 两者在候选列表上都表现为空。混成一个状态码,调用方就只能瞎猜是「没东西」还是「坏了」。 + await withDir((dir) => { + const projectRoot = join(dir, "proj"); + mkdirSync(projectRoot, { recursive: true }); + const { route } = setup({ memoryDir: join(dir, "memory") }); + + const prev = process.env.DSH_HOME; + // 会话根根本不存在 → 新机器 / 路径配错,是**环境问题**。 + process.env.DSH_HOME = join(dir, "home-missing"); + try { + const broken = new FakeRes(); + route.handler(req("/api/dsh-mneme/nudge?project=" + encodeURIComponent(projectRoot), "GET"), broken); + assert.equal(broken.statusCode, 503); + const brokenDoc = JSON.parse(broken.body); + assert.equal(brokenDoc.error, "nudge-sessions-unreadable"); + assert.equal(brokenDoc.skipped, true); + assert.equal(brokenDoc.counts.candidates, 0); + + // 有一个桶,但 cwd 指向别处 → 关于这个项目的**答案**:它还没跑过会话。 + const home = join(dir, "home-other"); + writeSession(home, { + bucket: "other-bucket", + sessionId: "sess-x", + cwd: join(dir, "somewhere-else"), + calls: [{ name: "skill", args: { name: "alpha" } }] + }); + process.env.DSH_HOME = home; + const none = new FakeRes(); + route.handler(req("/api/dsh-mneme/nudge?project=" + encodeURIComponent(projectRoot), "GET"), none); + assert.equal(none.statusCode, 200); + const noneDoc = JSON.parse(none.body); + assert.equal(noneDoc.skipped, true); + assert.equal(noneDoc.skippedReason, "no-session-attribution"); + assert.equal(noneDoc.attribution.reason, "no-bucket"); + // 跳过与跑完必须是同一句边界话术(同一个 NUDGE_BOUNDARIES 常量)。 + assert.equal(noneDoc.boundaries.autoActivate, false); + assert.equal(noneDoc.boundaries.writesOnlyDrafts, true); + } finally { + if (prev === undefined) delete process.env.DSH_HOME; + else process.env.DSH_HOME = prev; + } + }); +}); + +test("/nudge:GET 只预览不落盘,POST 才写提议;materialize 默认关", async () => { + // 这条是接线时抓到的真 bug:nudgeFromSessions 原先只要算得出项目键就无条件 + // 写提议,而面板每刷一次就 GET 一次 —— 等于每刷新一次往记忆库塞一批文件。 + await withDir(async (dir) => { + const projectRoot = join(dir, "proj"); + mkdirSync(projectRoot, { recursive: true }); + const memoryDir = join(dir, "memory"); + const dshHome = join(dir, "dsh-home"); + const calls = [ + { name: "skill", args: { name: "alpha" } }, + { name: "skill", args: { name: "beta" } }, + { name: "pwsh", args: { command: "npm test" } } + ]; + writeSession(dshHome, { bucket: "ws-bucket", sessionId: "sess-a", cwd: projectRoot, calls }); + // 两个会话走同一段序列才够 NUDGE_MIN_SUPPORT=2(数的是会话,不是次数)。 + writeSession(dshHome, { bucket: "ws-bucket", sessionId: "sess-b", cwd: projectRoot, calls }); + + const prev = process.env.DSH_HOME; + process.env.DSH_HOME = dshHome; + try { + const { route } = setup({ memoryDir }); + + const preview = new FakeRes(); + await route.handler(req("/api/dsh-mneme/nudge?project=" + encodeURIComponent(projectRoot), "GET"), preview); + assert.equal(preview.statusCode, 200); + const previewDoc = JSON.parse(preview.body); + assert.equal(previewDoc.skipped, false); + assert.equal(previewDoc.attribution.reason, "matched"); + assert.ok(previewDoc.counts.candidates >= 1, "真数据里必须挖得出候选"); + assert.equal(previewDoc.counts.savedProposals, 0, "预览不许落提议"); + assert.equal(existsSync(join(memoryDir, "proposals")), false); + + const real = new FakeRes(); + const realDone = awaitEnd(real); + route.handler(req("/api/dsh-mneme/nudge?project=" + encodeURIComponent(projectRoot), "POST", {}), real); + await realDone; + assert.equal(real.statusCode, 200); + const realDoc = JSON.parse(real.body); + assert.equal(realDoc.counts.savedProposals, realDoc.counts.candidates); + assert.ok(existsSync(realDoc.proposalsDir)); + // 落草稿是「往仓库里塞文件」,必须 body.materialize===true 才做;自动跑的 + // 默认语义就是不碰仓库,端点必须与它一致。 + assert.equal(realDoc.counts.draftsWritten, 0); + assert.equal(existsSync(join(projectRoot, ".mneme", "pipelines")), false); + } finally { + if (prev === undefined) delete process.env.DSH_HOME; + else process.env.DSH_HOME = prev; + } + }); +}); + +test("/nudge:PATCH/DELETE 一律 405,绝不落进「不是 GET/HEAD 就算写」的分支", async () => { + // 这条是从评审里捞出来的真洞:persistProposals 按「method !== GET && !== HEAD」算, + // 而方法分流只拦 PUT/POST,于是带 token 的 PATCH/DELETE 会直落 run(null)、 + // 当成写动作往 memoryDir 里落提议——一个不需要 body 的静默写盘口,正好打在 + // 这个端点「GET 只预览、写动作一律要 POST」的卖点上。 + await withDir(async (dir) => { + const projectRoot = join(dir, "proj"); + mkdirSync(projectRoot, { recursive: true }); + const memoryDir = join(dir, "memory"); + const dshHome = join(dir, "dsh-home"); + const calls = [ + { name: "skill", args: { name: "alpha" } }, + { name: "skill", args: { name: "beta" } } + ]; + writeSession(dshHome, { bucket: "ws-bucket", sessionId: "sess-a", cwd: projectRoot, calls }); + writeSession(dshHome, { bucket: "ws-bucket", sessionId: "sess-b", cwd: projectRoot, calls }); + + const prev = process.env.DSH_HOME; + process.env.DSH_HOME = dshHome; + try { + const { route } = setup({ apiToken: "secret-token", memoryDir }); + for (const verb of ["PATCH", "DELETE"]) { + const res = new FakeRes(); + // 带合法 token:证明拦住它的是方法白名单,不是鉴权。 + await route.handler(req("/api/dsh-mneme/nudge?project=" + encodeURIComponent(projectRoot), verb, {}, { authorization: "Bearer secret-token" }), res); + assert.equal(res.statusCode, 405, verb + " 必须回 405"); + const doc = JSON.parse(res.body); + assert.equal(doc.error, "method-not-allowed"); + assert.equal(doc.allow, "GET, HEAD, POST, PUT"); + assert.equal(res.headers?.Allow, "GET, HEAD, POST, PUT", "405 要带 Allow 头"); + } + // 最硬的一条:这里必须**一个文件都没落**。只看状态码会漏掉「先写再报错」。 + assert.equal(existsSync(join(memoryDir, "proposals")), false, "PATCH/DELETE 不许落提议"); + } finally { + if (prev === undefined) delete process.env.DSH_HOME; + else process.env.DSH_HOME = prev; + } + }); +}); diff --git a/dsh-mneme/test/api.test.js b/dsh-mneme/test/api.test.js index 3993e917..f044b890 100644 --- a/dsh-mneme/test/api.test.js +++ b/dsh-mneme/test/api.test.js @@ -747,7 +747,8 @@ test("GET /api/dsh-mneme/features returns empty overrides and effective config d // 两个都是嵌套对象子字段,都进 NESTED_FLAG_PATHS)。 // 合并 #451(人格页)后的真实计数:98(本批前)+ actionRunEnabled + personaEnabled = 99 // #471 再 + 2 = 101。 - assert.equal(Object.keys(data.effective).length, 101); + // v0.9.3 新增 actionNudgeEnabled(M.O. 自动沉淀;顶层扁平键)= 102。 + assert.equal(Object.keys(data.effective).length, 102); assert.equal(data.effective.dreamSkipInvalid, true); assert.equal(data.effective.allowCrossTypeMerge, false); assert.equal(data.effective.dreamMergeGuard, true); @@ -792,6 +793,10 @@ test("GET /api/dsh-mneme/features returns empty overrides and effective config d assert.equal(data.effective.actionRunEnabled, false); // 人格页(beta):注入总闸默认关,effective 必须能读到(同 #254 坑形防线)。 assert.equal(data.effective.personaEnabled, false); + // v0.9.3:M.O. 自动沉淀的第三个键。与 actionPipelineEnabled(看图)、 + // actionRunEnabled(记账)都不是一件事——它挂 session/event 并解压会话日志, + // 是三者里最重的一条,所以独立开关、默认关、且进 LIGHT_MODE_OFF。 + assert.equal(data.effective.actionNudgeEnabled, false); // 新增字符串 / URL / 枚举键 assert.equal(data.effective.localEmbedModel, "Xenova/bge-small-zh-v1.5"); assert.equal(data.effective.ollamaBaseUrl, "http://localhost:11434"); diff --git a/dsh-mneme/test/client.test.js b/dsh-mneme/test/client.test.js index 165222f6..da0e890c 100644 --- a/dsh-mneme/test/client.test.js +++ b/dsh-mneme/test/client.test.js @@ -1926,13 +1926,51 @@ test("M.O. empty-state, list and scope copy exist in both languages", () => { "memory.actions.scope.project", "memory.actions.scope.global", "memory.actions.scope.none", - "memory.actions.broken" + "memory.actions.broken", + // 手动沉淀的全部回执:这八句里缺任何一句,按钮点下去就是一行 key 裸奔, + // 而那恰好是「点了没反应」的观感来源。 + "memory.actions.distill.action", + "memory.actions.distill.busy", + "memory.actions.distill.hint", + "memory.actions.distill.noProject", + "memory.actions.distill.saved", + "memory.actions.distill.none", + "memory.actions.distill.noSessions", + "memory.actions.distill.unreadable", + "memory.actions.distill.failed" ]) { const hits = (clientSource.match(new RegExp(`"${key.replace(/\./g, "\\.")}":`, "g")) || []).length; assert.equal(hits, 2, `${key} must exist in both zh and en (got ${hits})`); } }); +// 手动沉淀按钮:面板上「立即跑一次」的唯一入口。锁三件事,每件都对应一种会静默坏掉的情形: +// ① 它必须打 POST 且带上项目根——GET 是预览、不写盘,打成 GET 就成了「点了永远 0 条」; +// ② materialize 不许出现:按钮只产提议,落草稿是另一个决定; +// ③ 没有 project 时必须禁用——服务端只会回 400,点了必然失败的按钮比没有按钮更糟。 +test("M.O. pane offers a manual distill that posts the project root", () => { + const bodyOf = (name) => { + const start = clientSource.indexOf(`function ${name}(`); + assert.ok(start > -1, `${name} must exist`); + const next = clientSource.slice(start + 1).indexOf("\n function "); + return clientSource.slice(start, next > -1 ? start + 1 + next : clientSource.length); + }; + const bar = bodyOf("PipelineDistillButton"); + assert.ok(bar.includes('"/api/dsh-mneme/nudge?project="'), "the button must hit the nudge route"); + assert.ok(bar.includes("encodeURIComponent(project)"), "the project root must travel with the request"); + assert.ok(bar.includes('method: "POST"'), "a preview GET writes nothing — the button must POST"); + assert.ok(!bar.includes("materialize"), "the button must never materialize a draft"); + assert.ok(bar.includes("!project"), "the button must be disabled when the project is unknown"); + assert.ok(bar.includes("503"), "an unreadable session log must be told apart from an empty result"); + // 座位:按钮挂在清单头上,且清单要拿到 project——漏传的话按钮永远禁用, + // 界面上看起来只是「这个功能没做好」。 + assert.ok(bodyOf("PipelineWorkflowList").includes("PipelineDistillButton"), "the control belongs in the list header"); + assert.ok( + bodyOf("PipelineBrowser").includes("activeId, onOpen: setPicked, project }"), + "the browser must hand the project root down to the list" + ); +}); + // 画布能放大缩小。锁的重点不是「有没有缩放」,而是滚轮那一条必须**自己挂非被动监听**: // React 的 onWheel 挂在 root 上且是 passive 的,preventDefault 会被浏览器忽略, // 于是 Ctrl+滚轮既缩放画布又缩放整页,谁也没拦住——这个错本地测不出来,只在真浏览器里响。 diff --git a/dsh-mneme/test/pipeline-nudge-auto.test.js b/dsh-mneme/test/pipeline-nudge-auto.test.js new file mode 100644 index 00000000..890fc53d --- /dev/null +++ b/dsh-mneme/test/pipeline-nudge-auto.test.js @@ -0,0 +1,224 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { createNudgeAutoRun } from "../src/pipeline-nudge-auto.js"; + +// 这组守的是**触发侧**(v0.9.3):什么时候跑、跑几次、跑挂了怎么办。 +// 挖掘算法本身在 pipeline-nudge.test.js 里守,这里只守接线,所以 runNudge 全部用假的。 + +/** 一个假 ctx:只提供 session/event 的订阅面(真宿主用同一个口)。 */ +function fakeCtx() { + const listeners = []; + return { + logger: { info() {}, warn() {} }, + on(name, fn) { + if (name !== "session/event") throw new Error("unexpected event: " + name); + listeners.push(fn); + return () => { + const i = listeners.indexOf(fn); + if (i >= 0) listeners.splice(i, 1); + }; + }, + emit(session, event) { + for (const fn of listeners.slice()) fn(session, event); + }, + count: () => listeners.length + }; +} + +function sessionAt(cwd) { + return { header: { cwd } }; +} + +/** 把 setTimeout(0) 排的活跑完:本模块刻意不用同步跑(见文件头)。 */ +function tick(ms = 5) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +/** + * 等到条件成立。队列是**链式**排程(跑完一个再 defer 下一个),所以「两个项目都跑完」 + * 至少要等两个宏任务;固定 tick(30) 在满载的 CI 上会假阴性——全量跑时其它测试文件 + * 同时解压 zstd,一次 setTimeout(0) 被拉长就可能超过 30ms。这里只等该等的东西。 + */ +async function waitFor(predicate, timeout = 2000) { + const started = Date.now(); + while (!predicate()) { + if (Date.now() - started > timeout) return false; + await new Promise((resolve) => setTimeout(resolve, 5)); + } + return true; +} + +test("自动沉淀只认 turn/end:中途事件不排队、不预跑", async () => { + const ctx = fakeCtx(); + const calls = []; + const auto = createNudgeAutoRun({ + ctx, + runNudge: (args) => { calls.push(args); return { counts: { savedProposals: 0 } }; }, + now: () => 1_000_000 + }); + ctx.emit(sessionAt("D:/proj"), { type: "turn/start" }); + ctx.emit(sessionAt("D:/proj"), { type: "message" }); + await tick(); + assert.deepEqual(calls, [], "一轮没结束就不是观测窗口"); + ctx.emit(sessionAt("D:/proj"), { type: "turn/end" }); + await tick(); + assert.equal(calls.length, 1); + auto(); +}); + +test("自动跑永远是「只落提议、不落草稿」:materialize 恒 false", async () => { + const ctx = fakeCtx(); + const calls = []; + const auto = createNudgeAutoRun({ + ctx, + memoryDir: "D:/memory", + runNudge: (args) => { calls.push(args); return { counts: { savedProposals: 0 } }; }, + now: () => 1_000_000 + }); + ctx.emit(sessionAt("D:/proj"), { type: "turn/end" }); + await tick(); + assert.equal(calls.length, 1); + // 这三条是边界,不是偏好:往别人仓库里塞文件必须是人的显式动作。 + assert.equal(calls[0].materialize, false, "自动跑不许落草稿"); + assert.equal(calls[0].persistProposals, true, "提议要落进记忆库"); + assert.equal(calls[0].memoryDir, "D:/memory"); + auto(); +}); + +test("同一个项目在冷却期内不重跑(观测新鲜度,不是防重复写盘)", async () => { + const ctx = fakeCtx(); + const calls = []; + let clock = 1_000_000; + const auto = createNudgeAutoRun({ + ctx, + runNudge: (args) => { calls.push(args); return { counts: { savedProposals: 0 } }; }, + now: () => clock, + minIntervalMs: 10_000 + }); + ctx.emit(sessionAt("D:/proj"), { type: "turn/end" }); + await tick(); + assert.equal(calls.length, 1); + // 冷却期内:再来几轮也不跑。 + ctx.emit(sessionAt("D:/proj"), { type: "turn/end" }); + ctx.emit(sessionAt("D:/proj"), { type: "turn/end" }); + await tick(); + assert.equal(calls.length, 1, "十分钟内同一个项目只跑一次"); + // 过了冷却就该跑。 + clock += 20_000; + ctx.emit(sessionAt("D:/proj"), { type: "turn/end" }); + await tick(); + assert.equal(calls.length, 2); + auto(); +}); + +test("冷却按项目分账:另一个项目不受影响,也不许并成一次", async () => { + const ctx = fakeCtx(); + const roots = []; + const auto = createNudgeAutoRun({ + ctx, + runNudge: (args) => { roots.push(args.projectRoot); return { counts: { savedProposals: 0 } }; }, + now: () => 1_000_000, + minIntervalMs: 10_000 + }); + ctx.emit(sessionAt("D:/a"), { type: "turn/end" }); + ctx.emit(sessionAt("D:/b"), { type: "turn/end" }); + assert.ok(await waitFor(() => roots.length === 2), "两个项目都该被跑到"); + assert.deepEqual(roots.sort(), ["D:/a", "D:/b"], "两个项目各跑一次,跨项目不并账"); + auto(); +}); + +test("冷却键不区分大小写与尾斜杠(Windows 同一个项目别算成两个)", async () => { + const ctx = fakeCtx(); + const calls = []; + let clock = 1_000_000; + const auto = createNudgeAutoRun({ + ctx, + runNudge: (args) => { calls.push(args); return { counts: { savedProposals: 0 } }; }, + now: () => clock, + minIntervalMs: 10_000 + }); + ctx.emit(sessionAt("D:/Proj"), { type: "turn/end" }); + await tick(); + ctx.emit(sessionAt("d:\\proj\\"), { type: "turn/end" }); + await tick(); + assert.equal(calls.length, 1, "同一个项目换个写法不该被当成新项目"); + auto(); +}); + +test("挖挂了不许冒泡:session/event 那条链上还挂着摘要与续期", async () => { + const ctx = fakeCtx(); + let ran = 0; + const auto = createNudgeAutoRun({ + ctx, + logger: { info() {}, warn() {} }, + runNudge: () => { ran += 1; throw new Error("boom"); }, + now: () => 1_000_000 + }); + ctx.emit(sessionAt("D:/proj"), { type: "turn/end" }); + await assert.doesNotReject(tick().then(() => {}), "定时回调里的异常不许掀翻宿主"); + assert.equal(ran, 1); + auto(); +}); + +test("没有 cwd 的会话不排队(归属不明就别猜)", async () => { + const ctx = fakeCtx(); + const calls = []; + const auto = createNudgeAutoRun({ + ctx, + runNudge: (args) => { calls.push(args); return { counts: { savedProposals: 0 } }; }, + now: () => 1_000_000 + }); + ctx.emit({ header: {} }, { type: "turn/end" }); + ctx.emit(null, { type: "turn/end" }); + await tick(); + assert.deepEqual(calls, []); + assert.equal(auto.pendingCount(), 0); + auto(); +}); + +test("dispose 之后:取消订阅、清掉排队的活,runNow 也不再跑", async () => { + const ctx = fakeCtx(); + const calls = []; + const auto = createNudgeAutoRun({ + ctx, + runNudge: (args) => { calls.push(args); return { counts: { savedProposals: 0 } }; }, + now: () => 1_000_000 + }); + ctx.emit(sessionAt("D:/proj"), { type: "turn/end" }); + auto(); + assert.equal(ctx.count(), 0, "订阅必须被摘掉"); + assert.equal(auto.pendingCount(), 0, "排队的活必须被清掉"); + await tick(); + // 关键:dispose 之前排进 timer 的那一次也不许再跑(否则卸载后还会写盘)。 + assert.deepEqual(calls, [], "卸载之后不许还有一次偷偷的写盘"); + assert.equal(auto.runNow("D:/proj"), null); + assert.deepEqual(calls, []); + // 再发事件也不排队了。 + ctx.emit(sessionAt("D:/proj"), { type: "turn/end" }); + await tick(); + assert.deepEqual(calls, []); +}); + +test("runNow 绕开冷却与 timer,手动入口可以直接驱动一次", () => { + const ctx = fakeCtx(); + const calls = []; + const auto = createNudgeAutoRun({ + ctx, + runNudge: (args) => { calls.push(args); return { counts: { savedProposals: 0 } }; }, + now: () => 1_000_000, + minIntervalMs: 10_000 + }); + const doc = auto.runNow("D:/proj"); + assert.ok(doc, "手动入口要有同步回执"); + assert.equal(calls.length, 1); + // runNow 刻意**不走冷却**:它是人显式要的一次跑,人比定时器知道自己要什么。 + assert.ok(auto.runNow("D:/proj")); + assert.equal(calls.length, 2); + // 但它要把时间记上——否则紧接着的自动跑会立刻再来一次。 + assert.equal(typeof auto.lastRunAt("D:/proj"), "number"); + ctx.emit(sessionAt("D:/proj"), { type: "turn/end" }); + return tick().then(() => { + assert.equal(calls.length, 2, "runNow 之后冷却生效,自动跑不许立刻重跑"); + auto(); + }); +}); diff --git a/dsh-mneme/test/pipeline-nudge.test.js b/dsh-mneme/test/pipeline-nudge.test.js index 02c1e228..39f59fbd 100644 --- a/dsh-mneme/test/pipeline-nudge.test.js +++ b/dsh-mneme/test/pipeline-nudge.test.js @@ -33,10 +33,12 @@ import { loadProposals, summarizeProposal, retirementProposals, - nudgeFromSessions + nudgeFromSessions, + nudgeForProject, + resolveProjectSessionDir } from "../src/pipeline-nudge.js"; -// 这组测试守的是 M.O. 的**沉淀链**:一条手法能不能从真实数据里自己长出来。 +// 这组测试守的是 M.O. 的**沉淀链**:一条工作流能不能从真实数据里自己长出来。 // 三条不许越的分界线(见 src/pipeline-nudge.js 文件头),测试逐条守: // 1. 机器只写它真观察到的东西(措辞里不许出现编出来的判据,也不许漏本机路径); // 2. 反事实判据的结论**永远不由机器给**(verdict 必须是 null,问题必须带着选项); @@ -210,7 +212,7 @@ test("挖掘:至少两个不同会话才算候选;同一段路出现两次 }); }); -test("默认只挖能力级:内置工具的通用动作不算手法(本机 520 份日志实测那是噪音)", () => { +test("默认只挖能力级:内置工具的通用动作不算工作流(本机 520 份日志实测那是噪音)", () => { withDir((dir) => { const sessions = join(dir, "sessions"); for (const id of ["sess-a", "sess-b", "sess-c"]) { @@ -221,7 +223,7 @@ test("默认只挖能力级:内置工具的通用动作不算手法(本机 5 const read = readSessionObservations({ sessionDir: sessions }); const segments = segmentObservations(read.observations); assert.deepEqual(DEFAULT_NUDGE_KINDS, ["skill", "mcp"]); - // 三个会话都出现了 read → pwsh,支持度足够高,但它是通用动作习惯,不是项目手法。 + // 三个会话都出现了 read → pwsh,支持度足够高,但它是通用动作习惯,不是项目工作流。 assert.deepEqual(mineRepeatedPatterns(segments), [], "默认口径下这类序列不该冒出来"); // 显式开工具级才挖得到——而且要把「同一步连着走」重新折叠起来。 const toolLevel = mineRepeatedPatterns(segments, { kinds: null }); @@ -522,7 +524,7 @@ test("清单行:反事实覆盖率与风险下限照实投影", () => { counterfactual: { verdict: null } }; const row = summarizeProposal(proposal); - // 19/19 与 6/19 是两条不同的手法,清单上就该看得出来。 + // 19/19 与 6/19 是两条不同的工作流,清单上就该看得出来。 assert.deepEqual(row.contrast, { answered: 1, total: 2 }); assert.equal(row.riskFloor, "R2", "整条的风险取下限里的最高档"); assert.equal(row.verdict, null, "判据没有结论时清单里也是 null,不显示一个假结论"); @@ -729,7 +731,7 @@ test("回归·空回答不算「已答」:一条都没接受就不许推进状 assert.equal(ok.applied.length, 1); }); -test("回归·跨工作区不并账:两个桶各一次不是「支持度 2 的同一手法」", () => { +test("回归·跨工作区不并账:两个桶各一次不是「支持度 2 的同一工作流」", () => { withDir((dir) => { const sessions = join(dir, "sessions"); const seq = [[sessionLine({ turn: 1, calls: [skill("alpha"), skill("beta")] })]]; @@ -1034,3 +1036,156 @@ test("回归·文件上限按排序截断,不按遍历顺序(选择偏差要 assert.equal(read.scannedFiles, 2); }); }); + +// ───────────────────────────────────────────────────────────────────────────── +// nudgeForProject:把「归属解析」和「挖掘」绑成一次调用(v0.9.3 接线的入口)。 +// 守两件事:没有归属就**不许跑**(不能退化成无归属普查),预览不许写盘。 +// ───────────────────────────────────────────────────────────────────────────── + +/** 造一个假 DSH_HOME:sessions/<桶>/<会话>/session.v4.jsonl.zstd,头行带 cwd。 */ +function fakeDshHome(dir, { bucket = "ws-bucket", cwd = null, sessions = ["sess-a"] } = {}) { + const home = join(dir, "dsh-home"); + for (const id of sessions) { + const sub = join(home, "sessions", bucket, id); + mkdirSync(sub, { recursive: true }); + const head = JSON.stringify({ time: 1700000000000, cwd }); + const body = sessionLine({ turn: 1, calls: [skill("alpha"), skill("beta"), pwsh("npm test")] }); + writeFileSync(join(sub, "session.v4.jsonl.zstd"), zlib.zstdCompressSync(Buffer.from([head, body].join("\n"), "utf8"))); + } + return home; +} + +test("nudgeForProject:归属解析不到就整跑跳过,绝不退化成「无归属普查」", () => { + withDir((dir) => { + const projectRoot = join(dir, "proj"); + mkdirSync(projectRoot, { recursive: true }); + const dshHome = fakeDshHome(dir, { cwd: join(dir, "somewhere-else") }); + const doc = nudgeForProject({ projectRoot, dshHome, memoryDir: join(dir, "memory"), persistProposals: true }); + assert.equal(doc.skipped, true); + assert.equal(doc.skippedReason, "no-session-attribution"); + assert.equal(doc.attribution.reason, "no-bucket"); + assert.equal(doc.counts.candidates, 0); + assert.equal(doc.counts.savedProposals, 0); + // 边界话术:跳过与跑完必须是同一句(同一个 NUDGE_BOUNDARIES 常量)。 + assert.equal(doc.boundaries.autoActivate, false); + assert.equal(doc.boundaries.writesOnlyDrafts, true); + assert.equal(existsSync(join(dir, "memory", "proposals")), false, "没归属就不该建提议桶"); + }); +}); + +test("nudgeForProject:命中归属就真跑,预览(persistProposals:false)一个文件都不写", () => { + withDir((dir) => { + const projectRoot = join(dir, "proj"); + mkdirSync(projectRoot, { recursive: true }); + const memoryDir = join(dir, "memory"); + const dshHome = fakeDshHome(dir, { cwd: projectRoot }); + // 两个会话走同一段序列才够 NUDGE_MIN_SUPPORT=2(support 数的是**会话**,不是次数)。 + fakeDshHome(dir, { cwd: projectRoot, sessions: ["sess-b"] }); + + const preview = nudgeForProject({ projectRoot, dshHome, memoryDir, persistProposals: false }); + assert.equal(preview.skipped, false); + assert.equal(preview.attribution.reason, "matched"); + assert.ok(preview.counts.candidates >= 1, "真数据里必须挖得出候选"); + // 这一条是接线的关键:面板每刷一次就 GET 一次,预览绝不能往 memoryDir 里塞文件。 + assert.equal(preview.counts.savedProposals, 0); + assert.equal(existsSync(join(memoryDir, "proposals")), false, "预览不落盘"); + + const real = nudgeForProject({ projectRoot, dshHome, memoryDir, persistProposals: true }); + assert.equal(real.counts.savedProposals, real.counts.candidates); + assert.ok(existsSync(real.proposalsDir)); + // 自动跑永不落草稿:materialize 默认 false,项目里不该多出 pipelines 目录。 + assert.equal(real.counts.draftsWritten, 0); + assert.equal(existsSync(projectPipelineDir(projectRoot)), false); + }); +}); + +test("nudgeForProject:「读不到」与「确实没有」必须分开报", () => { + withDir((dir) => { + const projectRoot = join(dir, "proj"); + mkdirSync(projectRoot, { recursive: true }); + // 桶在,但里面一个会话文件都没有 → 读不出任何 cwd → unreadable(故障),不是 no-bucket。 + const homeEmpty = join(dir, "home-empty"); + mkdirSync(join(homeEmpty, "sessions", "ws-bucket"), { recursive: true }); + assert.equal(resolveProjectSessionDir({ dshHome: homeEmpty, projectRoot }).reason, "unreadable"); + // 会话根根本不存在 → no-sessions-dir(新机器),同样是故障侧。 + const homeMissing = join(dir, "home-missing"); + assert.equal(resolveProjectSessionDir({ dshHome: homeMissing, projectRoot }).reason, "no-sessions-dir"); + }); +}); + +test("归属解析也吃 budgetMs:预算用光报 budget-exceeded,不冒充 no-bucket", () => { + // 评审抓出:resolveProjectSessionDir 最多同步扫 500 个桶 × 3 个文件,原先完全不看 + // budgetMs,于是 PR 正文那句「真卡就调 budgetMs」对这段路径是空话。 + // 这里用「截止点已经过去」来构造,不依赖真实耗时,确定性。 + withDir((dir) => { + const projectRoot = join(dir, "proj"); + mkdirSync(projectRoot, { recursive: true }); + const home = fakeDshHome(dir, { cwd: projectRoot }); + // 已经过期的截止点 → 连一个桶都不该扫。 + const stalled = resolveProjectSessionDir({ dshHome: home, projectRoot, deadline: Date.now() - 1 }); + assert.equal(stalled.reason, "budget-exceeded"); + assert.equal(stalled.sessionDir, null); + assert.equal(stalled.filesRead, 0, "超预算时一个会话文件都不该读"); + // 不给 deadline = 扫到底,行为与改动前一致(测试与离线批处理要完整结果)。 + assert.equal(resolveProjectSessionDir({ dshHome: home, projectRoot }).reason, "matched"); + }); +}); + +test("nudgeForProject:归属解析超预算时 skipped 并如实带 budget-exceeded", () => { + withDir((dir) => { + const projectRoot = join(dir, "proj"); + mkdirSync(projectRoot, { recursive: true }); + const home = fakeDshHome(dir, { cwd: projectRoot }); + // now 往前推 10s + budget 4s → 截止点在过去,模拟「预算已经被前面的活花掉了」。 + const doc = nudgeForProject({ + projectRoot, + dshHome: home, + memoryDir: join(dir, "memory"), + now: Date.now() - 10000, + budgetMs: 4000, + persistProposals: false + }); + assert.equal(doc.skipped, true); + assert.equal(doc.skippedReason, "no-session-attribution"); + // 关键:原因必须能区别于「这个项目确实没跑过会话」(no-bucket)——否则一次 + // 扫不完会被读成「这个项目很干净」。 + assert.equal(doc.attribution.reason, "budget-exceeded"); + assert.equal(doc.counts.candidates, 0); + }); +}); + +test("桶数超过 maxBuckets:先排序再截断,截断没命中报 bucket-limit 而不是 no-bucket", () => { + // 评审抓出的第二个「读不到装成确实没有」:早前是边收边截(到 500 个就 break、 + // 然后才 sort),留下哪 500 个由文件系统遍历顺序决定;目标项目的桶一旦落在被 + // 截掉的那部分,就返回 no-bucket —— 一次截断被报成「这个项目没有会话」。 + withDir((dir) => { + const projectRoot = join(dir, "proj"); + mkdirSync(projectRoot, { recursive: true }); + const elsewhere = join(dir, "somewhere-else"); + mkdirSync(elsewhere, { recursive: true }); + // 四个桶:字母序 aaa-1, aaa-2, ws-bucket, zz-1。目标在 ws-bucket。 + // 无关桶里也放**真会话**(cwd 指向别处),这样 bucketsWithCwd > 0, + // 唯一剩下的原因就是截断本身——否则会先命中 unreadable,测不到这条。 + const home = fakeDshHome(dir, { bucket: "ws-bucket", cwd: projectRoot }); + fakeDshHome(dir, { bucket: "aaa-1", cwd: elsewhere }); + fakeDshHome(dir, { bucket: "aaa-2", cwd: elsewhere }); + fakeDshHome(dir, { bucket: "zz-1", cwd: elsewhere }); + + // 只扫前 2 个(aaa-1、aaa-2):目标被挤出,且这不是「它没有会话」。 + const miss = resolveProjectSessionDir({ dshHome: home, projectRoot, maxBuckets: 2 }); + assert.equal(miss.reason, "bucket-limit", "被截断 ≠ 这个项目没有会话"); + assert.notEqual(miss.reason, "no-bucket"); + assert.equal(miss.bucketsTruncated, true); + assert.equal(miss.bucketsScanned, 2); + assert.equal(miss.bucketsTotal, 4, "总数与扫描数都要报,截断才看得见"); + + // 放宽到 3 个:字母序把 ws-bucket 包进来 → 命中。 + const hit = resolveProjectSessionDir({ dshHome: home, projectRoot, maxBuckets: 3 }); + assert.equal(hit.reason, "matched", "留下谁由字母序决定,不由遍历序决定"); + + // 不截断时依然是真答案那套,且 truncated 明确为 false。 + const full = resolveProjectSessionDir({ dshHome: home, projectRoot, maxBuckets: 999 }); + assert.equal(full.reason, "matched"); + assert.equal(full.bucketsTruncated, false); + }); +});