keepm 3.1.0__tar.gz → 3.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. {keepm-3.1.0/src/keepm.egg-info → keepm-3.2.0}/PKG-INFO +40 -8
  2. {keepm-3.1.0 → keepm-3.2.0}/README.md +39 -7
  3. {keepm-3.1.0 → keepm-3.2.0}/README_EN.md +39 -7
  4. {keepm-3.1.0 → keepm-3.2.0}/pyproject.toml +1 -1
  5. keepm-3.2.0/src/keepm/__init__.py +1 -0
  6. {keepm-3.1.0 → keepm-3.2.0}/src/keepm/cli.py +10 -0
  7. {keepm-3.1.0 → keepm-3.2.0}/src/keepm/config.py +22 -0
  8. {keepm-3.1.0 → keepm-3.2.0}/src/keepm/database.py +106 -50
  9. {keepm-3.1.0 → keepm-3.2.0}/src/keepm/doctor.py +35 -0
  10. keepm-3.2.0/src/keepm/elicitation.py +109 -0
  11. keepm-3.2.0/src/keepm/filesystems.py +96 -0
  12. keepm-3.2.0/src/keepm/gc.py +389 -0
  13. keepm-3.2.0/src/keepm/health.py +169 -0
  14. {keepm-3.1.0 → keepm-3.2.0}/src/keepm/identifiers.py +17 -0
  15. {keepm-3.1.0 → keepm-3.2.0}/src/keepm/instructions.py +16 -6
  16. keepm-3.2.0/src/keepm/maintenance.py +165 -0
  17. {keepm-3.1.0 → keepm-3.2.0}/src/keepm/server.py +61 -8
  18. {keepm-3.1.0 → keepm-3.2.0}/src/keepm/service.py +75 -33
  19. {keepm-3.1.0 → keepm-3.2.0}/src/keepm/setup.py +57 -1
  20. {keepm-3.1.0 → keepm-3.2.0}/src/keepm/storage.py +6 -1
  21. {keepm-3.1.0 → keepm-3.2.0}/src/keepm/sync.py +9 -4
  22. {keepm-3.1.0 → keepm-3.2.0/src/keepm.egg-info}/PKG-INFO +40 -8
  23. {keepm-3.1.0 → keepm-3.2.0}/src/keepm.egg-info/SOURCES.txt +11 -0
  24. {keepm-3.1.0 → keepm-3.2.0}/tests/test_cli.py +24 -0
  25. {keepm-3.1.0 → keepm-3.2.0}/tests/test_config.py +12 -0
  26. {keepm-3.1.0 → keepm-3.2.0}/tests/test_database.py +80 -0
  27. keepm-3.2.0/tests/test_elicitation.py +92 -0
  28. keepm-3.2.0/tests/test_filesystems.py +26 -0
  29. keepm-3.2.0/tests/test_gc.py +114 -0
  30. keepm-3.2.0/tests/test_health.py +81 -0
  31. keepm-3.2.0/tests/test_identifiers.py +21 -0
  32. {keepm-3.1.0 → keepm-3.2.0}/tests/test_instructions.py +15 -4
  33. keepm-3.2.0/tests/test_maintenance.py +58 -0
  34. {keepm-3.1.0 → keepm-3.2.0}/tests/test_server.py +13 -0
  35. {keepm-3.1.0 → keepm-3.2.0}/tests/test_service.py +126 -2
  36. {keepm-3.1.0 → keepm-3.2.0}/tests/test_setup.py +9 -0
  37. {keepm-3.1.0 → keepm-3.2.0}/tests/test_storage.py +6 -1
  38. {keepm-3.1.0 → keepm-3.2.0}/tests/test_sync.py +28 -0
  39. {keepm-3.1.0 → keepm-3.2.0}/tests/test_watcher.py +35 -2
  40. keepm-3.1.0/src/keepm/__init__.py +0 -1
  41. {keepm-3.1.0 → keepm-3.2.0}/LICENSE +0 -0
  42. {keepm-3.1.0 → keepm-3.2.0}/MANIFEST.in +0 -0
  43. {keepm-3.1.0 → keepm-3.2.0}/assets/keepm-wordmark.svg +0 -0
  44. {keepm-3.1.0 → keepm-3.2.0}/setup.cfg +0 -0
  45. {keepm-3.1.0 → keepm-3.2.0}/src/keepm/audit.py +0 -0
  46. {keepm-3.1.0 → keepm-3.2.0}/src/keepm/handoff.py +0 -0
  47. {keepm-3.1.0 → keepm-3.2.0}/src/keepm/hooks.py +0 -0
  48. {keepm-3.1.0 → keepm-3.2.0}/src/keepm/integrations.py +0 -0
  49. {keepm-3.1.0 → keepm-3.2.0}/src/keepm/locking.py +0 -0
  50. {keepm-3.1.0 → keepm-3.2.0}/src/keepm/markdown.py +0 -0
  51. {keepm-3.1.0 → keepm-3.2.0}/src/keepm/models.py +0 -0
  52. {keepm-3.1.0 → keepm-3.2.0}/src/keepm/projects.py +0 -0
  53. {keepm-3.1.0 → keepm-3.2.0}/src/keepm/proposal.py +0 -0
  54. {keepm-3.1.0 → keepm-3.2.0}/src/keepm/relationships.py +0 -0
  55. {keepm-3.1.0 → keepm-3.2.0}/src/keepm/repair.py +0 -0
  56. {keepm-3.1.0 → keepm-3.2.0}/src/keepm/tokens.py +0 -0
  57. {keepm-3.1.0 → keepm-3.2.0}/src/keepm/validation.py +0 -0
  58. {keepm-3.1.0 → keepm-3.2.0}/src/keepm/watcher.py +0 -0
  59. {keepm-3.1.0 → keepm-3.2.0}/src/keepm.egg-info/dependency_links.txt +0 -0
  60. {keepm-3.1.0 → keepm-3.2.0}/src/keepm.egg-info/entry_points.txt +0 -0
  61. {keepm-3.1.0 → keepm-3.2.0}/src/keepm.egg-info/requires.txt +0 -0
  62. {keepm-3.1.0 → keepm-3.2.0}/src/keepm.egg-info/top_level.txt +0 -0
  63. {keepm-3.1.0 → keepm-3.2.0}/tests/test_audit.py +0 -0
  64. {keepm-3.1.0 → keepm-3.2.0}/tests/test_dated_filenames.py +0 -0
  65. {keepm-3.1.0 → keepm-3.2.0}/tests/test_end_to_end.py +0 -0
  66. {keepm-3.1.0 → keepm-3.2.0}/tests/test_handoff.py +0 -0
  67. {keepm-3.1.0 → keepm-3.2.0}/tests/test_hooks.py +0 -0
  68. {keepm-3.1.0 → keepm-3.2.0}/tests/test_integrations.py +0 -0
  69. {keepm-3.1.0 → keepm-3.2.0}/tests/test_locking.py +0 -0
  70. {keepm-3.1.0 → keepm-3.2.0}/tests/test_markdown.py +0 -0
  71. {keepm-3.1.0 → keepm-3.2.0}/tests/test_projects.py +0 -0
  72. {keepm-3.1.0 → keepm-3.2.0}/tests/test_proposals.py +0 -0
  73. {keepm-3.1.0 → keepm-3.2.0}/tests/test_relationships.py +0 -0
  74. {keepm-3.1.0 → keepm-3.2.0}/tests/test_repair.py +0 -0
  75. {keepm-3.1.0 → keepm-3.2.0}/tests/test_tokens.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: keepm
3
- Version: 3.1.0
3
+ Version: 3.2.0
4
4
  Summary: Admission-controlled local Markdown memory MCP for AI coding agents
5
5
  Project-URL: Homepage, https://github.com/LeoKon3/KeepM
6
6
  Project-URL: Repository, https://github.com/LeoKon3/KeepM
@@ -24,7 +24,7 @@ Dynamic: license-file
24
24
 
25
25
  > 带准入控制、本地优先、无模型依赖的 AI 编码 Agent 长期记忆引擎。
26
26
 
27
- [![Version](https://img.shields.io/badge/version-3.1.0-6f42c1.svg)](https://pypi.org/project/keepm/)
27
+ [![Version](https://img.shields.io/badge/version-3.2.0-6f42c1.svg)](https://pypi.org/project/keepm/)
28
28
  [![Python](https://img.shields.io/badge/python-3.11%2B-3776ab.svg)](https://www.python.org/)
29
29
  [![MCP](https://img.shields.io/badge/MCP-supported-2ea44f.svg)](https://modelcontextprotocol.io/)
30
30
  [![License](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/LeoKon3/KeepM/blob/main/LICENSE)
@@ -108,8 +108,31 @@ uvx keepm setup
108
108
  uvx keepm setup --yes --root /path/to/notes --agent codex --project-dir /path/to/project
109
109
  ```
110
110
 
111
+ 可选的运维参数包括 `--deep-reconcile-seconds`(默认 1800,`0` 禁用周期深扫)、`--verification-review-days`(默认 180)和 `--sqlite-journal-mode wal|delete`(默认 `wal`;网络文件系统可显式选择 `delete`,但这不会提供跨机器锁)。
112
+
111
113
  重复执行 setup 是幂等的,只会更新目标项目中 KeepM 管理的配置块。`~/.config/keepm/config.toml` 只保存 KeepM 自身的本机 Vault 配置,不会在所有项目中全局启用 Agent。KeepM 不要求安装或运行 Obsidian;在 WSL 中直接使用 `/mnt/c/...` 形式的 Windows 挂载路径即可。
112
114
 
115
+ ## 极简维护命令
116
+
117
+ 健康报告复用 `memory_doctor` 的确定性诊断结果,只导出状态和 finding 元数据,不包含记忆正文、Proposal 内容或任何 repair/update token:
118
+
119
+ ```bash
120
+ uvx keepm doctor --format json
121
+ uvx keepm doctor --format markdown --output keepm-health.md
122
+ ```
123
+
124
+ Safe GC 只处理 `Memory/.trash/` 中具有可信软删除时间、且超过指定期限的普通文件。默认命令只生成预览和短时一次性计划;repair 备份、符号链接、无可信删除时间的文件以及正常的 active/archived/superseded 记忆均不会被删除:
125
+
126
+ ```bash
127
+ # 仅预览,不删除
128
+ uvx keepm gc --older-than-days 30
129
+
130
+ # 使用预览返回的 token 执行完全相同且未变化的计划
131
+ uvx keepm gc --confirm --token <gc-token>
132
+ ```
133
+
134
+ 确认后的 GC 是不可恢复删除;trash 内容、checksum、候选集合或 token 时效发生变化时会拒绝执行并要求重新预览。
135
+
113
136
  ## Agent 工作流与低噪对话式审核
114
137
 
115
138
  ```text
@@ -133,16 +156,16 @@ KeepM 使用受控对话完成 Inbox 审核,不依赖 GUI 或 Obsidian 插件
133
156
  >
134
157
  > 顺便提醒:检测到 2 条待审核候选记忆:
135
158
  >
136
- > 1. **[项目偏好]** 优先使用 pnpm 而非 npm(建议:**批准**)
137
- > 2. **[架构决策]** 放弃 Redis,改用 SQLite(建议:**批准**)
159
+ > - **Proposal P1[项目偏好]** 优先使用 pnpm 而非 npm(建议:**批准**)
160
+ > - **Proposal P2[架构决策]** 放弃 Redis,改用 SQLite(建议:**批准**)
138
161
  >
139
- > 可以回复“全部批准”“批准 1、拒绝 2”或“稍后”。
162
+ > 可以回复“批准 Proposal P1、拒绝 Proposal P2”或“稍后”。
140
163
 
141
- 主任务永远优先。用户回复“稍后”或忽略提醒后,本次会话不再重复打扰;沉默不会批准、拒绝、修改或删除任何内容。
164
+ 主任务永远优先,Proposal 使用独立于 `Step 1` 等主任务步骤的编号。“行”“好的”“按你说的办”“按建议处理”等泛化赞同只授权主任务,绝不授权 Inbox 变更;多条候选时用户必须明确引用 `Proposal P1` 等标签。只有最近明确呈报了一条 Proposal 时,“批准记忆提案”才可授权该唯一候选。用户回复“稍后”或忽略提醒后,本次会话不再重复打扰;沉默不会批准、拒绝、修改或删除任何内容。
142
165
 
143
166
  正式记忆使用“槽位命名”:`project-package-manager` 表示稳定主题,`pnpm` 只是该槽位当前的结论。`memory_context` / `memory_search` 在发现 active 名称、别名硬碰撞或同 kind 主题重叠时会返回有界的 `conflict_hints`;Agent 必须完整读取相关候选并核对项目真源,不能只信第一条。
144
167
 
145
- 确认冲突并得到用户明确授权后,Agent 先预览 `memory_resolve_conflict(dry_run=true)`,再用绑定同一方案的短时 token 执行。`supersede` 保留历史关系,`archive` 归档失去独立价值的节点,`delete` 只把错误节点移入可恢复回收站。KeepM 会同时校验两份 checksum/token、拒绝 `supersedes` 环,并通过文件事务日志与单次 SQLite 事务避免可见的半完成状态。
168
+ 确认冲突并得到用户明确授权后,Agent 先预览 `memory_resolve_conflict(dry_run=true)`,再用绑定同一方案的短时 token 执行。`supersede` 保留历史关系,`archive` 归档失去独立价值的节点,`delete` 只把错误节点移入可恢复回收站。KeepM 会同时校验两份 checksum/token、拒绝 `supersedes` 环,并通过可恢复的文件事务日志与单次 SQLite 事务保证崩溃一致;若进程在多文件替换中断,下次启动会继续提交或安全回滚。
146
169
 
147
170
  ## MCP 工具一览
148
171
 
@@ -150,13 +173,22 @@ KeepM 使用受控对话完成 Inbox 审核,不依赖 GUI 或 Obsidian 插件
150
173
  | --- | --- | --- |
151
174
  | 上下文 | `memory_status`、`memory_context` | 运行状态、有长度边界的任务上下文和仅计数的 Inbox 信号 |
152
175
  | 检索 | `memory_search`、`memory_read`、`memory_links` | active 记忆加权检索、权威读取、WikiLink 与反向链接 |
153
- | 正式记忆 | `memory_create`、`memory_update`、`memory_delete`、`memory_resolve_conflict` | 创建已确认记忆;保护更新、删除及两条 active 记忆的预览式原子冲销 |
176
+ | 正式记忆 | `memory_create`、`memory_update`、`memory_delete`、`memory_resolve_conflict` | 创建已确认记忆;保护更新、删除及两条 active 记忆的预览式冲突解决 |
154
177
  | Inbox | `memory_propose`、`memory_inbox_list`、`memory_inbox_read`、`memory_inbox_update`、`memory_inbox_promote`、`memory_inbox_reject`、`memory_inbox_prune` | 隔离、查看、修正、批准、拒绝、合并或安全清理候选 |
155
178
  | Handoff | `memory_handoff_create`、`memory_handoff_read`、`memory_handoff_update`、`memory_handoff_complete` | 保存、恢复、更新和归档未完成任务状态 |
156
179
  | 运维 | `memory_doctor`、`memory_repair`、`memory_sync` | 诊断损坏、预览授权的确定性修复、对账或重建索引 |
157
180
 
158
181
  修改或删除已有对象前必须完整读取当前内容,并提供一次性版本凭证;如果 Markdown 在读写之间发生变化,KeepM 会拒绝过期操作,不会盲目覆盖。
159
182
 
183
+ ## 对账、文件系统与长期健康
184
+
185
+ - **分层对账:** 高频快速路径使用 `mtime_ns + size`;服务启动、默认每 30 分钟一次的周期深扫,以及 `memory_doctor` 都会重新计算内容 SHA-256。`memory_status` 返回 `last_checksum_scan` 和 `deep_scan_due`。
186
+ - **落盘边界:** 单文件写入会先 `fsync` 临时文件,`os.replace` 后再 `fsync` 父目录;Markdown 仍是唯一真源,Doctor 只报告损坏,不猜测性重写。
187
+ - **单活 Writer:** 一个 Vault 同一时间只能由一台机器写入。`memory_status` 会尽力识别 NFS/SMB 等网络文件系统并给出提示;SQLite journal mode 可显式配置为 `wal` 或 `delete`,但两者都不能替代跨主机协调。
188
+ - **核验提示:** `memory_doctor` 只对 active 的 `constraint`、`fix`、`workflow` 给出 `verification_missing` / `verification_overdue` info;`decision` 和 `preference` 默认免检,系统绝不因此自动归档或改写记忆。
189
+ - **结构化报告:** `memory_doctor(report_format=json|markdown)` 和 `keepm doctor` 复用同一份无正文健康报告;CLI 可安全写入本地 JSON/Markdown,不引入 HTML 渲染。
190
+ - **Host 确认适配:** `memory_status.host_confirmation` 根据 MCP 客户端能力报告 `elicitation` 或 `proposal-protocol`。不支持、取消或调用错误都 fail closed;当前 Proposal P1/P2 明确授权协议始终保留,不会把能力缺失当成批准。
191
+
160
192
  ## 设计哲学与非目标
161
193
 
162
194
  - **本地优先且透明:** 不依赖云服务、HTTP 守护进程或私有存储格式。
@@ -4,7 +4,7 @@
4
4
 
5
5
  > 带准入控制、本地优先、无模型依赖的 AI 编码 Agent 长期记忆引擎。
6
6
 
7
- [![Version](https://img.shields.io/badge/version-3.1.0-6f42c1.svg)](https://pypi.org/project/keepm/)
7
+ [![Version](https://img.shields.io/badge/version-3.2.0-6f42c1.svg)](https://pypi.org/project/keepm/)
8
8
  [![Python](https://img.shields.io/badge/python-3.11%2B-3776ab.svg)](https://www.python.org/)
9
9
  [![MCP](https://img.shields.io/badge/MCP-supported-2ea44f.svg)](https://modelcontextprotocol.io/)
10
10
  [![License](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/LeoKon3/KeepM/blob/main/LICENSE)
@@ -88,8 +88,31 @@ uvx keepm setup
88
88
  uvx keepm setup --yes --root /path/to/notes --agent codex --project-dir /path/to/project
89
89
  ```
90
90
 
91
+ 可选的运维参数包括 `--deep-reconcile-seconds`(默认 1800,`0` 禁用周期深扫)、`--verification-review-days`(默认 180)和 `--sqlite-journal-mode wal|delete`(默认 `wal`;网络文件系统可显式选择 `delete`,但这不会提供跨机器锁)。
92
+
91
93
  重复执行 setup 是幂等的,只会更新目标项目中 KeepM 管理的配置块。`~/.config/keepm/config.toml` 只保存 KeepM 自身的本机 Vault 配置,不会在所有项目中全局启用 Agent。KeepM 不要求安装或运行 Obsidian;在 WSL 中直接使用 `/mnt/c/...` 形式的 Windows 挂载路径即可。
92
94
 
95
+ ## 极简维护命令
96
+
97
+ 健康报告复用 `memory_doctor` 的确定性诊断结果,只导出状态和 finding 元数据,不包含记忆正文、Proposal 内容或任何 repair/update token:
98
+
99
+ ```bash
100
+ uvx keepm doctor --format json
101
+ uvx keepm doctor --format markdown --output keepm-health.md
102
+ ```
103
+
104
+ Safe GC 只处理 `Memory/.trash/` 中具有可信软删除时间、且超过指定期限的普通文件。默认命令只生成预览和短时一次性计划;repair 备份、符号链接、无可信删除时间的文件以及正常的 active/archived/superseded 记忆均不会被删除:
105
+
106
+ ```bash
107
+ # 仅预览,不删除
108
+ uvx keepm gc --older-than-days 30
109
+
110
+ # 使用预览返回的 token 执行完全相同且未变化的计划
111
+ uvx keepm gc --confirm --token <gc-token>
112
+ ```
113
+
114
+ 确认后的 GC 是不可恢复删除;trash 内容、checksum、候选集合或 token 时效发生变化时会拒绝执行并要求重新预览。
115
+
93
116
  ## Agent 工作流与低噪对话式审核
94
117
 
95
118
  ```text
@@ -113,16 +136,16 @@ KeepM 使用受控对话完成 Inbox 审核,不依赖 GUI 或 Obsidian 插件
113
136
  >
114
137
  > 顺便提醒:检测到 2 条待审核候选记忆:
115
138
  >
116
- > 1. **[项目偏好]** 优先使用 pnpm 而非 npm(建议:**批准**)
117
- > 2. **[架构决策]** 放弃 Redis,改用 SQLite(建议:**批准**)
139
+ > - **Proposal P1[项目偏好]** 优先使用 pnpm 而非 npm(建议:**批准**)
140
+ > - **Proposal P2[架构决策]** 放弃 Redis,改用 SQLite(建议:**批准**)
118
141
  >
119
- > 可以回复“全部批准”“批准 1、拒绝 2”或“稍后”。
142
+ > 可以回复“批准 Proposal P1、拒绝 Proposal P2”或“稍后”。
120
143
 
121
- 主任务永远优先。用户回复“稍后”或忽略提醒后,本次会话不再重复打扰;沉默不会批准、拒绝、修改或删除任何内容。
144
+ 主任务永远优先,Proposal 使用独立于 `Step 1` 等主任务步骤的编号。“行”“好的”“按你说的办”“按建议处理”等泛化赞同只授权主任务,绝不授权 Inbox 变更;多条候选时用户必须明确引用 `Proposal P1` 等标签。只有最近明确呈报了一条 Proposal 时,“批准记忆提案”才可授权该唯一候选。用户回复“稍后”或忽略提醒后,本次会话不再重复打扰;沉默不会批准、拒绝、修改或删除任何内容。
122
145
 
123
146
  正式记忆使用“槽位命名”:`project-package-manager` 表示稳定主题,`pnpm` 只是该槽位当前的结论。`memory_context` / `memory_search` 在发现 active 名称、别名硬碰撞或同 kind 主题重叠时会返回有界的 `conflict_hints`;Agent 必须完整读取相关候选并核对项目真源,不能只信第一条。
124
147
 
125
- 确认冲突并得到用户明确授权后,Agent 先预览 `memory_resolve_conflict(dry_run=true)`,再用绑定同一方案的短时 token 执行。`supersede` 保留历史关系,`archive` 归档失去独立价值的节点,`delete` 只把错误节点移入可恢复回收站。KeepM 会同时校验两份 checksum/token、拒绝 `supersedes` 环,并通过文件事务日志与单次 SQLite 事务避免可见的半完成状态。
148
+ 确认冲突并得到用户明确授权后,Agent 先预览 `memory_resolve_conflict(dry_run=true)`,再用绑定同一方案的短时 token 执行。`supersede` 保留历史关系,`archive` 归档失去独立价值的节点,`delete` 只把错误节点移入可恢复回收站。KeepM 会同时校验两份 checksum/token、拒绝 `supersedes` 环,并通过可恢复的文件事务日志与单次 SQLite 事务保证崩溃一致;若进程在多文件替换中断,下次启动会继续提交或安全回滚。
126
149
 
127
150
  ## MCP 工具一览
128
151
 
@@ -130,13 +153,22 @@ KeepM 使用受控对话完成 Inbox 审核,不依赖 GUI 或 Obsidian 插件
130
153
  | --- | --- | --- |
131
154
  | 上下文 | `memory_status`、`memory_context` | 运行状态、有长度边界的任务上下文和仅计数的 Inbox 信号 |
132
155
  | 检索 | `memory_search`、`memory_read`、`memory_links` | active 记忆加权检索、权威读取、WikiLink 与反向链接 |
133
- | 正式记忆 | `memory_create`、`memory_update`、`memory_delete`、`memory_resolve_conflict` | 创建已确认记忆;保护更新、删除及两条 active 记忆的预览式原子冲销 |
156
+ | 正式记忆 | `memory_create`、`memory_update`、`memory_delete`、`memory_resolve_conflict` | 创建已确认记忆;保护更新、删除及两条 active 记忆的预览式冲突解决 |
134
157
  | Inbox | `memory_propose`、`memory_inbox_list`、`memory_inbox_read`、`memory_inbox_update`、`memory_inbox_promote`、`memory_inbox_reject`、`memory_inbox_prune` | 隔离、查看、修正、批准、拒绝、合并或安全清理候选 |
135
158
  | Handoff | `memory_handoff_create`、`memory_handoff_read`、`memory_handoff_update`、`memory_handoff_complete` | 保存、恢复、更新和归档未完成任务状态 |
136
159
  | 运维 | `memory_doctor`、`memory_repair`、`memory_sync` | 诊断损坏、预览授权的确定性修复、对账或重建索引 |
137
160
 
138
161
  修改或删除已有对象前必须完整读取当前内容,并提供一次性版本凭证;如果 Markdown 在读写之间发生变化,KeepM 会拒绝过期操作,不会盲目覆盖。
139
162
 
163
+ ## 对账、文件系统与长期健康
164
+
165
+ - **分层对账:** 高频快速路径使用 `mtime_ns + size`;服务启动、默认每 30 分钟一次的周期深扫,以及 `memory_doctor` 都会重新计算内容 SHA-256。`memory_status` 返回 `last_checksum_scan` 和 `deep_scan_due`。
166
+ - **落盘边界:** 单文件写入会先 `fsync` 临时文件,`os.replace` 后再 `fsync` 父目录;Markdown 仍是唯一真源,Doctor 只报告损坏,不猜测性重写。
167
+ - **单活 Writer:** 一个 Vault 同一时间只能由一台机器写入。`memory_status` 会尽力识别 NFS/SMB 等网络文件系统并给出提示;SQLite journal mode 可显式配置为 `wal` 或 `delete`,但两者都不能替代跨主机协调。
168
+ - **核验提示:** `memory_doctor` 只对 active 的 `constraint`、`fix`、`workflow` 给出 `verification_missing` / `verification_overdue` info;`decision` 和 `preference` 默认免检,系统绝不因此自动归档或改写记忆。
169
+ - **结构化报告:** `memory_doctor(report_format=json|markdown)` 和 `keepm doctor` 复用同一份无正文健康报告;CLI 可安全写入本地 JSON/Markdown,不引入 HTML 渲染。
170
+ - **Host 确认适配:** `memory_status.host_confirmation` 根据 MCP 客户端能力报告 `elicitation` 或 `proposal-protocol`。不支持、取消或调用错误都 fail closed;当前 Proposal P1/P2 明确授权协议始终保留,不会把能力缺失当成批准。
171
+
140
172
  ## 设计哲学与非目标
141
173
 
142
174
  - **本地优先且透明:** 不依赖云服务、HTTP 守护进程或私有存储格式。
@@ -4,7 +4,7 @@
4
4
 
5
5
  > An admission-controlled, local-first, model-free durable memory engine for AI coding agents.
6
6
 
7
- [![Version](https://img.shields.io/badge/version-3.1.0-6f42c1.svg)](https://pypi.org/project/keepm/)
7
+ [![Version](https://img.shields.io/badge/version-3.2.0-6f42c1.svg)](https://pypi.org/project/keepm/)
8
8
  [![Python](https://img.shields.io/badge/python-3.11%2B-3776ab.svg)](https://www.python.org/)
9
9
  [![MCP](https://img.shields.io/badge/MCP-supported-2ea44f.svg)](https://modelcontextprotocol.io/)
10
10
  [![License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
@@ -88,8 +88,31 @@ For automation:
88
88
  uvx keepm setup --yes --root /path/to/notes --agent codex --project-dir /path/to/project
89
89
  ```
90
90
 
91
+ Optional operations settings include `--deep-reconcile-seconds` (default 1800; `0` disables periodic deep scans), `--verification-review-days` (default 180), and `--sqlite-journal-mode wal|delete` (default `wal`; network filesystems may explicitly select `delete`, but it does not provide cross-machine locking).
92
+
91
93
  Setup is idempotent and updates only KeepM-managed configuration blocks in the target project. `~/.config/keepm/config.toml` stores KeepM's local Vault configuration only; it does not enable the Agent globally in every project. Obsidian does not need to be installed or running. From WSL, use a mounted Windows path such as `/mnt/c/...` directly.
92
94
 
95
+ ## Minimal maintenance commands
96
+
97
+ Health export reuses the deterministic `memory_doctor` findings and emits status/finding metadata only. It never includes memory bodies, Proposal content, or repair/update tokens:
98
+
99
+ ```bash
100
+ uvx keepm doctor --format json
101
+ uvx keepm doctor --format markdown --output keepm-health.md
102
+ ```
103
+
104
+ Safe GC considers only ordinary files in `Memory/.trash/` with a trustworthy tombstone timestamp older than the selected retention period. The default command creates a preview and a short-lived, one-time plan. Repair backups, symlinks, unknown-age files, and normal active/archived/superseded memories are excluded:
105
+
106
+ ```bash
107
+ # Preview only; deletes nothing
108
+ uvx keepm gc --older-than-days 30
109
+
110
+ # Execute the exact unchanged plan returned by the preview
111
+ uvx keepm gc --confirm --token <gc-token>
112
+ ```
113
+
114
+ Confirmed GC is irreversible. Any trash-content, checksum, candidate-set, or token-expiry change rejects execution and requires a new preview.
115
+
93
116
  ## Agent workflow and low-noise review
94
117
 
95
118
  ```text
@@ -113,16 +136,16 @@ KeepM reviews Inbox candidates through controlled conversation, with no GUI or O
113
136
  >
114
137
  > Two memory candidates are waiting for review:
115
138
  >
116
- > 1. **[Project preference]** Prefer pnpm over npm — suggested: **approve**
117
- > 2. **[Architecture decision]** Use SQLite instead of Redis — suggested: **approve**
139
+ > - **Proposal P1 [Project preference]** Prefer pnpm over npm — suggested: **approve**
140
+ > - **Proposal P2 [Architecture decision]** Use SQLite instead of Redis — suggested: **approve**
118
141
  >
119
- > Reply with “approve all”, “approve 1, reject 2”, or “later”.
142
+ > Reply with “approve Proposal P1, reject Proposal P2” or “later”.
120
143
 
121
- The main task always comes first. After “later” or an ignored offer, the Agent does not repeat the reminder in the same session. Silence never approves, rejects, edits, or deletes anything.
144
+ The main task always comes first, and Proposal labels are independent from primary-task labels such as `Step 1`. Generic assent such as “yes”, “okay”, “do it”, or “apply your suggestions” authorizes only the primary task, never an Inbox mutation; with multiple candidates the user must explicitly reference a label such as `Proposal P1`. Only when the most recent review presented exactly one Proposal may “approve the memory Proposal” authorize that sole candidate. After “later” or an ignored offer, the Agent does not repeat the reminder in the same session. Silence never approves, rejects, edits, or deletes anything.
122
145
 
123
146
  Formal memory uses slot-based naming: `project-package-manager` is the stable topic, while `pnpm` is only its current value. When active names or aliases collide, or same-kind results appear to cover one topic, `memory_context` and `memory_search` return bounded `conflict_hints`. The Agent must fully read the relevant candidates and check the project source of truth instead of trusting the first hit.
124
147
 
125
- After the conflict is confirmed and the user explicitly authorizes the change, the Agent previews `memory_resolve_conflict(dry_run=true)` and then executes the token-bound unchanged plan. `supersede` preserves valid history, `archive` retires a note that lost independent value, and `delete` moves only an erroneous note to recoverable trash. KeepM validates both checksum/token pairs, rejects `supersedes` cycles, and combines a recoverable file journal with one SQLite transaction to prevent a visible half-resolved state.
148
+ After the conflict is confirmed and the user explicitly authorizes the change, the Agent previews `memory_resolve_conflict(dry_run=true)` and then executes the token-bound unchanged plan. `supersede` preserves valid history, `archive` retires a note that lost independent value, and `delete` moves only an erroneous note to recoverable trash. KeepM validates both checksum/token pairs, rejects `supersedes` cycles, and combines a recoverable file journal with one SQLite transaction for crash consistency; if the process stops during the multi-file replacement, the next startup completes or safely rolls back the journal.
126
149
 
127
150
  ## MCP tools
128
151
 
@@ -130,13 +153,22 @@ After the conflict is confirmed and the user explicitly authorizes the change, t
130
153
  | --- | --- | --- |
131
154
  | Context | `memory_status`, `memory_context` | Runtime state and bounded task context with count-only Inbox signals |
132
155
  | Retrieval | `memory_search`, `memory_read`, `memory_links` | Weighted active-memory search, authoritative reads, WikiLinks, and backlinks |
133
- | Formal memory | `memory_create`, `memory_update`, `memory_delete`, `memory_resolve_conflict` | Create confirmed memory; protect updates, deletes, and previewed atomic resolution of two active notes |
156
+ | Formal memory | `memory_create`, `memory_update`, `memory_delete`, `memory_resolve_conflict` | Create confirmed memory; protect updates, deletes, and previewed conflict resolution of two active notes |
134
157
  | Inbox | `memory_propose`, `memory_inbox_list`, `memory_inbox_read`, `memory_inbox_update`, `memory_inbox_promote`, `memory_inbox_reject`, `memory_inbox_prune` | Isolate, inspect, edit, approve, reject, merge, or safely prune candidates |
135
158
  | Handoff | `memory_handoff_create`, `memory_handoff_read`, `memory_handoff_update`, `memory_handoff_complete` | Save, resume, update, and archive unfinished task state |
136
159
  | Operations | `memory_doctor`, `memory_repair`, `memory_sync` | Diagnose damage, preview authorized deterministic repair, and reconcile/rebuild the index |
137
160
 
138
161
  Mutating an existing object requires a complete current read plus one-time version credentials. If Markdown changes between read and write, KeepM rejects the stale operation instead of overwriting it.
139
162
 
163
+ ## Reconciliation, filesystems, and long-term health
164
+
165
+ - **Tiered reconciliation:** the frequent fast path uses `mtime_ns + size`; server startup, the periodic deep scan (every 30 minutes by default), and `memory_doctor` recompute content SHA-256. `memory_status` returns `last_checksum_scan` and `deep_scan_due`.
166
+ - **Durability boundary:** a single-file write fsyncs the temporary file, performs `os.replace`, and then fsyncs the parent directory. Markdown remains the only source of truth; Doctor reports damage without guessing a rewrite.
167
+ - **Single-active Writer:** only one machine may write a Vault at a time. `memory_status` makes a best-effort check for network filesystems such as NFS/SMB. SQLite journal mode can be configured as `wal` or `delete`, but neither substitutes for cross-host coordination.
168
+ - **Verification reminders:** `memory_doctor` emits `verification_missing` / `verification_overdue` info only for active `constraint`, `fix`, and `workflow` memories. `decision` and `preference` are exempt by default, and KeepM never archives or rewrites a memory because of age.
169
+ - **Structured reports:** `memory_doctor(report_format=json|markdown)` and `keepm doctor` share one body-free report model. The CLI can write local JSON/Markdown without adding HTML rendering.
170
+ - **Host confirmation adapter:** `memory_status.host_confirmation` reports `elicitation` or `proposal-protocol` from MCP client capabilities. Unsupported, cancelled, or failed elicitation remains fail-closed; the explicit Proposal P1/P2 protocol always remains available and missing capability is never approval.
171
+
140
172
  ## Design principles and non-goals
141
173
 
142
174
  - **Local-first and transparent:** no cloud service, HTTP daemon, or proprietary storage format.
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "keepm"
7
- version = "3.1.0"
7
+ version = "3.2.0"
8
8
  description = "Admission-controlled local Markdown memory MCP for AI coding agents"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -0,0 +1 @@
1
+ __version__ = "3.2.0"
@@ -22,6 +22,16 @@ def main(argv: Sequence[str] | None = None) -> None:
22
22
 
23
23
  hook_main(arguments[1:])
24
24
  return
25
+ if arguments[:1] == ["doctor"]:
26
+ from keepm.maintenance import doctor_main
27
+
28
+ doctor_main(arguments[1:])
29
+ return
30
+ if arguments[:1] == ["gc"]:
31
+ from keepm.maintenance import gc_main
32
+
33
+ gc_main(arguments[1:])
34
+ return
25
35
 
26
36
  from keepm.server import main as server_main
27
37
 
@@ -11,6 +11,7 @@ from typing import Literal, cast
11
11
 
12
12
  MemoryLanguage = Literal["zh", "en"]
13
13
  MemoryFeedback = Literal["off", "compact", "debug"]
14
+ SQLiteJournalMode = Literal["wal", "delete"]
14
15
 
15
16
 
16
17
  def default_config_path() -> Path:
@@ -38,6 +39,9 @@ class KeepMConfig:
38
39
  audit_enabled: bool = False
39
40
  inbox_review_after_days: int = 30
40
41
  memory_feedback: MemoryFeedback = "off"
42
+ deep_reconcile_interval_seconds: int = 1_800
43
+ verification_review_after_days: int = 180
44
+ sqlite_journal_mode: SQLiteJournalMode = "wal"
41
45
 
42
46
  def __post_init__(self) -> None:
43
47
  memory_path = Path(self.memory_dir)
@@ -59,6 +63,12 @@ class KeepMConfig:
59
63
  raise ValueError("inbox_review_after_days must be positive")
60
64
  if self.memory_feedback not in ("off", "compact", "debug"):
61
65
  raise ValueError("memory_feedback must be off, compact, or debug")
66
+ if self.deep_reconcile_interval_seconds < 0:
67
+ raise ValueError("deep_reconcile_interval_seconds must be non-negative")
68
+ if self.verification_review_after_days < 1:
69
+ raise ValueError("verification_review_after_days must be positive")
70
+ if self.sqlite_journal_mode not in ("wal", "delete"):
71
+ raise ValueError("sqlite_journal_mode must be wal or delete")
62
72
  object.__setattr__(self, "vault_path", self.vault_path.expanduser().resolve())
63
73
 
64
74
  @property
@@ -146,6 +156,15 @@ class ConfigStore:
146
156
  memory_feedback=cast(
147
157
  MemoryFeedback, str(data.get("memory_feedback", "off"))
148
158
  ),
159
+ deep_reconcile_interval_seconds=int(
160
+ data.get("deep_reconcile_interval_seconds", 1_800)
161
+ ),
162
+ verification_review_after_days=int(
163
+ data.get("verification_review_after_days", 180)
164
+ ),
165
+ sqlite_journal_mode=cast(
166
+ SQLiteJournalMode, str(data.get("sqlite_journal_mode", "wal"))
167
+ ),
149
168
  )
150
169
 
151
170
  def load_optional(self) -> KeepMConfig | None:
@@ -175,6 +194,9 @@ class ConfigStore:
175
194
  f"audit_enabled = {str(config.audit_enabled).lower()}\n"
176
195
  f"inbox_review_after_days = {config.inbox_review_after_days}\n"
177
196
  f"memory_feedback = {json.dumps(config.memory_feedback)}\n"
197
+ f"deep_reconcile_interval_seconds = {config.deep_reconcile_interval_seconds}\n"
198
+ f"verification_review_after_days = {config.verification_review_after_days}\n"
199
+ f"sqlite_journal_mode = {json.dumps(config.sqlite_journal_mode)}\n"
178
200
  )
179
201
  temporary = self.path.with_suffix(".tmp")
180
202
  temporary.write_text(content, encoding="utf-8", newline="\n")
@@ -9,9 +9,9 @@ import threading
9
9
  from contextlib import contextmanager
10
10
  from dataclasses import dataclass
11
11
  from pathlib import Path
12
- from typing import Iterator
12
+ from typing import Callable, Iterator, Literal
13
13
 
14
- from keepm.identifiers import normalize_identifier
14
+ from keepm.identifiers import normalize_identifier, portable_path_key
15
15
  from keepm.markdown import slug_filename
16
16
  from keepm.models import (
17
17
  MemoryDocument,
@@ -99,7 +99,11 @@ class IndexedProposal:
99
99
 
100
100
 
101
101
  class MemoryDatabase:
102
- def __init__(self, path: Path) -> None:
102
+ def __init__(
103
+ self, path: Path, *, journal_mode: Literal["wal", "delete"] = "wal"
104
+ ) -> None:
105
+ if journal_mode not in {"wal", "delete"}:
106
+ raise ValueError("journal_mode must be wal or delete")
103
107
  self.path = path.expanduser().resolve()
104
108
  self.path.parent.mkdir(parents=True, exist_ok=True)
105
109
  self._lock = threading.RLock()
@@ -110,7 +114,10 @@ class MemoryDatabase:
110
114
  check_same_thread=False,
111
115
  )
112
116
  self.connection.row_factory = sqlite3.Row
113
- self.connection.execute("PRAGMA journal_mode=WAL")
117
+ row = self.connection.execute(
118
+ f"PRAGMA journal_mode={journal_mode.upper()}"
119
+ ).fetchone()
120
+ self.journal_mode = str(row[0]).casefold() if row is not None else journal_mode
114
121
  self.connection.execute("PRAGMA busy_timeout=5000")
115
122
  self.connection.execute("PRAGMA foreign_keys=ON")
116
123
  try:
@@ -752,64 +759,113 @@ class MemoryDatabase:
752
759
  """SELECT id, path, name_key, aliases_json, scope, project_id, status
753
760
  FROM memories"""
754
761
  ).fetchall()
762
+
763
+ id_index: dict[str, list[sqlite3.Row]] = {}
764
+ path_index: dict[str, list[sqlite3.Row]] = {}
765
+ stem_index: dict[str, list[sqlite3.Row]] = {}
766
+ name_index: dict[str, list[sqlite3.Row]] = {}
767
+ alias_index: dict[str, list[sqlite3.Row]] = {}
768
+
769
+ def add(
770
+ index: dict[str, list[sqlite3.Row]], key: str, row: sqlite3.Row
771
+ ) -> None:
772
+ index.setdefault(key, []).append(row)
773
+
774
+ def link_path_key(value: str | Path) -> str:
775
+ return portable_path_key(str(value).replace("\\", "/")).strip(
776
+ "/"
777
+ ).removesuffix(".md")
778
+
779
+ for memory in memories:
780
+ add(id_index, str(memory["id"]).casefold(), memory)
781
+ path = Path(str(memory["path"]))
782
+ normalized_path = link_path_key(path)
783
+ parts = tuple(part for part in normalized_path.split("/") if part)
784
+ for start in range(len(parts)):
785
+ add(path_index, "/".join(parts[start:]), memory)
786
+ try:
787
+ add(stem_index, normalize_identifier(path.stem), memory)
788
+ except ValueError:
789
+ pass
790
+ add(name_index, str(memory["name_key"]), memory)
791
+ for alias in json.loads(str(memory["aliases_json"])):
792
+ try:
793
+ add(alias_index, normalize_identifier(str(alias)), memory)
794
+ except ValueError:
795
+ continue
796
+
755
797
  for link in rows:
756
798
  names = self._target_names(str(link["target_text"]))
757
799
  target_text = str(link["target_text"]).strip()
758
800
  target_key = target_text.casefold().replace("\\", "/")
759
- target_path_key = target_key.removesuffix(".md")
801
+ target_path_key = link_path_key(target_text)
760
802
  source_scope = str(link["scope"])
761
803
  source_project = str(link["project_id"])
762
- eligible: list[tuple[tuple[int, ...], sqlite3.Row]] = []
763
- for target in memories:
764
- if source_scope == "global":
765
- if target["scope"] != "global":
804
+ eligible: dict[str, tuple[tuple[int, ...], sqlite3.Row]] = {}
805
+
806
+ def consider(
807
+ candidates: list[sqlite3.Row],
808
+ priority_for: tuple[int, ...]
809
+ | Callable[[sqlite3.Row], tuple[int, ...]],
810
+ ) -> None:
811
+ for target in candidates:
812
+ if callable(priority_for):
813
+ match_priority = priority_for(target)
814
+ else:
815
+ match_priority = priority_for
816
+ if source_scope == "global":
817
+ if target["scope"] != "global":
818
+ continue
819
+ elif not (
820
+ target["scope"] == "global"
821
+ or (
822
+ target["scope"] == "project"
823
+ and target["project_id"] == source_project
824
+ )
825
+ ):
766
826
  continue
767
- elif not (
768
- target["scope"] == "global"
769
- or (
770
- target["scope"] == "project"
827
+ scope_priority = (
828
+ 0
829
+ if target["scope"] == "project"
771
830
  and target["project_id"] == source_project
831
+ else 1
772
832
  )
773
- ):
774
- continue
775
- path = Path(str(target["path"]))
776
- path_key = path.as_posix().casefold().removesuffix(".md")
777
- try:
778
- path_stem_key = normalize_identifier(path.stem)
779
- except ValueError:
780
- path_stem_key = path.stem.casefold()
781
- alias_keys: set[str] = set()
782
- for alias in json.loads(str(target["aliases_json"])):
783
- try:
784
- alias_keys.add(normalize_identifier(str(alias)))
785
- except ValueError:
786
- pass
787
- if target_key == str(target["id"]).casefold():
788
- priority = (0, 0)
789
- elif "/" in target_path_key and path_key.endswith(
790
- "/" + target_path_key.lstrip("/")
791
- ):
792
- priority = (0, 1)
793
- elif path_stem_key in names:
794
- priority = (0, 2)
795
- elif str(target["name_key"]) in names:
796
- priority = (1, 0 if target["status"] == "active" else 1, 0)
797
- elif alias_keys.intersection(names):
798
- priority = (1, 0 if target["status"] == "active" else 1, 1)
799
- else:
800
- continue
801
- scope_priority = (
802
- 0
803
- if target["scope"] == "project"
804
- and target["project_id"] == source_project
805
- else 1
833
+ priority = (*match_priority, scope_priority)
834
+ memory_id = str(target["id"])
835
+ previous = eligible.get(memory_id)
836
+ if previous is None or priority < previous[0]:
837
+ eligible[memory_id] = (priority, target)
838
+
839
+ consider(id_index.get(target_key, []), (0, 0))
840
+ if "/" in target_path_key:
841
+ consider(path_index.get(target_path_key.strip("/"), []), (0, 1))
842
+ for name in names:
843
+ consider(stem_index.get(name, []), (0, 2))
844
+ consider(
845
+ name_index.get(name, []),
846
+ lambda target: (
847
+ 1,
848
+ 0 if target["status"] == "active" else 1,
849
+ 0,
850
+ ),
851
+ )
852
+ consider(
853
+ alias_index.get(name, []),
854
+ lambda target: (
855
+ 1,
856
+ 0 if target["status"] == "active" else 1,
857
+ 1,
858
+ ),
806
859
  )
807
- eligible.append(((*priority, scope_priority), target))
860
+
808
861
  if not eligible:
809
862
  continue
810
- eligible.sort(key=lambda item: (*item[0], str(item[1]["id"])))
811
- best_priority = eligible[0][0]
812
- best = [item for item in eligible if item[0] == best_priority]
863
+ ranked = sorted(
864
+ eligible.values(),
865
+ key=lambda item: (*item[0], str(item[1]["id"])),
866
+ )
867
+ best_priority = ranked[0][0]
868
+ best = [item for item in ranked if item[0] == best_priority]
813
869
  if len(best) != 1:
814
870
  continue
815
871
  target = best[0][1]
@@ -1,5 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import datetime as dt
3
4
  from dataclasses import dataclass
4
5
  from pathlib import Path
5
6
 
@@ -18,6 +19,9 @@ from keepm.sync import sha256_file
18
19
  from keepm.validation import MemoryValidationError
19
20
 
20
21
 
22
+ VERIFICATION_SENSITIVE_KINDS = frozenset({"constraint", "fix", "workflow"})
23
+
24
+
21
25
  @dataclass(frozen=True, slots=True)
22
26
  class DoctorFinding:
23
27
  code: str
@@ -179,6 +183,37 @@ def run_doctor(
179
183
  ),
180
184
  [],
181
185
  ).append(path)
186
+
187
+ today = dt.datetime.now(dt.timezone.utc).date()
188
+ review_after_days = storage.config.verification_review_after_days
189
+ for path, document in parsed_by_path.items():
190
+ if (
191
+ document.status != "active"
192
+ or document.kind not in VERIFICATION_SENSITIVE_KINDS
193
+ ):
194
+ continue
195
+ if document.verified_at is None:
196
+ findings.append(
197
+ DoctorFinding(
198
+ "verification_missing",
199
+ path,
200
+ "active change-sensitive memory has never been verified against its source of truth",
201
+ "info",
202
+ )
203
+ )
204
+ continue
205
+ age_days = max(
206
+ 0, (today - dt.date.fromisoformat(document.verified_at)).days
207
+ )
208
+ if age_days >= review_after_days:
209
+ findings.append(
210
+ DoctorFinding(
211
+ "verification_overdue",
212
+ path,
213
+ f"active change-sensitive memory was last verified {age_days} days ago",
214
+ "info",
215
+ )
216
+ )
182
217
  for memory_id, duplicate_paths in ids.items():
183
218
  if len(duplicate_paths) > 1:
184
219
  for path in duplicate_paths: