dev-memory-cli 0.22.0 → 0.22.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +253 -292
- package/bin/dev-memory.js +8 -2
- package/hooks/README.md +170 -47
- package/hooks/codex-hooks.json +1 -1
- package/hooks/hooks.json +1 -1
- package/lib/dev_memory_capture.py +85 -7
- package/lib/dev_memory_common.py +40 -10
- package/lib/dev_memory_graduate.py +1 -1
- package/lib/dev_memory_session_scan.py +1052 -0
- package/lib/dev_memory_setup.py +8 -3
- package/lib/dev_memory_summary.py +25 -16
- package/lib/ui-app.html +128 -1
- package/lib/ui-server.js +67 -1
- package/package.json +1 -1
- package/scripts/hooks/_common.py +44 -7
- package/scripts/hooks/stop.py +6 -0
package/bin/dev-memory.js
CHANGED
|
@@ -56,7 +56,11 @@ function runPython(scriptPath, args, cwd = process.cwd(), extraEnv = {}) {
|
|
|
56
56
|
// inject DEFAULT_STORAGE_ROOT here — that would short-circuit Python's
|
|
57
57
|
// fallback chain (which prefers legacy ~/.dev-assets if it has data and
|
|
58
58
|
// ~/.dev-memory does not).
|
|
59
|
-
const env = {
|
|
59
|
+
const env = {
|
|
60
|
+
...process.env,
|
|
61
|
+
DEV_MEMORY_CLI_PATH: process.env.DEV_MEMORY_CLI_PATH || path.resolve(process.argv[1]),
|
|
62
|
+
...extraEnv,
|
|
63
|
+
};
|
|
60
64
|
const result = spawnSync(python, [scriptPath, ...args], {
|
|
61
65
|
cwd,
|
|
62
66
|
env,
|
|
@@ -136,7 +140,7 @@ function detectSessionSummaryCommand() {
|
|
|
136
140
|
if (commandExists("codex")) {
|
|
137
141
|
return {
|
|
138
142
|
provider: "codex",
|
|
139
|
-
command: "codex exec --ignore-user-config --ignore-rules --skip-git-repo-check --sandbox danger-full-access {prompt}",
|
|
143
|
+
command: "codex exec --ephemeral --ignore-user-config --ignore-rules --skip-git-repo-check --sandbox danger-full-access {prompt}",
|
|
140
144
|
};
|
|
141
145
|
}
|
|
142
146
|
if (commandExists("claude")) {
|
|
@@ -361,6 +365,7 @@ const PY_SUBCOMMAND_SCRIPTS = {
|
|
|
361
365
|
graduate: "dev_memory_graduate.py",
|
|
362
366
|
tidy: "dev_memory_tidy.py",
|
|
363
367
|
summary: "dev_memory_summary.py",
|
|
368
|
+
"session-scan": "dev_memory_session_scan.py",
|
|
364
369
|
};
|
|
365
370
|
|
|
366
371
|
function commandPySubcommand(name, rawArgs) {
|
|
@@ -694,6 +699,7 @@ function printHelp() {
|
|
|
694
699
|
dev-memory-cli graduate <dry-run|apply|index> [...]
|
|
695
700
|
dev-memory-cli tidy <prepare|apply> [...]
|
|
696
701
|
dev-memory-cli summary <extract-core> [...]
|
|
702
|
+
dev-memory-cli session-scan <run|install|status|stats|history|show|uninstall|config> [...]
|
|
697
703
|
dev-memory-cli branch [list|inspect|rename|fork|delete|init|inherit-worktree-base] [...] # no subcommand = interactive
|
|
698
704
|
|
|
699
705
|
Environment:
|
package/hooks/README.md
CHANGED
|
@@ -1,19 +1,17 @@
|
|
|
1
1
|
# Lifecycle Hooks
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
现在采用的是接近 ECC 的生命周期 hooks,并同时支持 Claude 和 Codex 两种 agent。
|
|
3
|
+
本项目使用 agent 生命周期 hook,不使用 Git hook。Claude Code 与 Codex 的可用事件不同,模板分别维护。
|
|
6
4
|
|
|
7
5
|
## 当前仓库里的接入方式
|
|
8
6
|
|
|
9
|
-
-
|
|
7
|
+
- repo-local 配置位置:
|
|
10
8
|
- Claude: `.claude/settings.local.json`
|
|
11
9
|
- Codex: `.codex/hooks.json`
|
|
12
10
|
- 可复用模板:
|
|
13
11
|
- Claude: [hooks/hooks.json](hooks.json)
|
|
14
12
|
- Codex: [hooks/codex-hooks.json](codex-hooks.json)
|
|
15
13
|
- `dev-memory-cli` 是这两套配置共用的稳定执行入口
|
|
16
|
-
-
|
|
14
|
+
- hook 仅在模板内容合并到对应 agent 配置后生效
|
|
17
15
|
|
|
18
16
|
### Codex 快速安装
|
|
19
17
|
|
|
@@ -40,48 +38,173 @@ dev-memory-cli install-hooks claude
|
|
|
40
38
|
|
|
41
39
|
已有旧配置如果还写着 `dev-memory hook ...` 或 `npx dev-memory hook ...`,重新执行安装命令会按 hook id 覆盖为 `dev-memory-cli hook ...`。
|
|
42
40
|
|
|
43
|
-
##
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
- `
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
41
|
+
## Hook 行为
|
|
42
|
+
|
|
43
|
+
| 事件 | Codex CLI | Claude Code CLI | 行为 |
|
|
44
|
+
| --- | :-: | :-: | --- |
|
|
45
|
+
| `SessionStart` | ✓ | ✓ | 读取当前 repo+branch 记忆并注入会话;同一 session 的重复触发幂等跳过 |
|
|
46
|
+
| `Stop` | ✓ | ✓ | 每次回复后记录轻量 HEAD marker;Codex 同时登记定时扫描候选 |
|
|
47
|
+
| `PreCompact` | | ✓ | 兼容占位;当前不执行额外刷新 |
|
|
48
|
+
| `SessionEnd` | | ✓ | 记录最终 HEAD,创建 transcript 总结任务并启动后台 worker |
|
|
49
|
+
|
|
50
|
+
`SessionStart` 的幂等记录位于 `<repo-memory>/jobs/session-start/injected/*.json`。重复触发只记录 skip 日志,不重复注入上下文。
|
|
51
|
+
|
|
52
|
+
## 会话总结
|
|
53
|
+
|
|
54
|
+
### 生效范围
|
|
55
|
+
|
|
56
|
+
`SessionEnd` 自动总结仅由 **Claude Code CLI** 的生命周期事件触发。Codex CLI hook 模板没有 `SessionEnd`;它的 `Stop` 只登记候选,不在每轮回答后启动模型。Codex Desktop 不使用这组项目 hook。
|
|
57
|
+
|
|
58
|
+
总结任务的触发端与执行端是两个独立概念:
|
|
59
|
+
|
|
60
|
+
- **触发端**:Claude Code CLI 的 `SessionEnd` hook。
|
|
61
|
+
- **执行端**:本机可用的 `coco`、`codex` 或 `claude` CLI。
|
|
62
|
+
|
|
63
|
+
因此,执行总结所用的 CLI 与产生原会话的客户端没有绑定关系。
|
|
64
|
+
|
|
65
|
+
### 总结工具选择
|
|
66
|
+
|
|
67
|
+
`install-hooks` 检测本地命令并初始化 `~/.dev-memory/config.json`。已有非空 `session_summary.command` 时保留现有配置,否则按以下顺序选择第一个可用工具:
|
|
68
|
+
|
|
69
|
+
单独执行 `install-hooks codex` 也会初始化这项 CLI 配置,但不会为 Codex CLI 增加 `SessionEnd` 事件;没有 Claude Code `SessionEnd` 或其它显式 enqueue 来源时,该配置不会自动启动总结任务。
|
|
70
|
+
|
|
71
|
+
| 优先级 | 工具 | 默认命令 |
|
|
72
|
+
| --- | --- | --- |
|
|
73
|
+
| 1 | `coco` | `coco -p --yolo --session-id {summary_session_id} {prompt}` |
|
|
74
|
+
| 2 | `codex` | `codex exec --ephemeral --ignore-user-config --ignore-rules --skip-git-repo-check --sandbox danger-full-access {prompt}` |
|
|
75
|
+
| 3 | `claude` | `claude -p --permission-mode bypassPermissions --session-id {summary_session_uuid} {prompt}` |
|
|
76
|
+
|
|
77
|
+
示例配置:
|
|
78
|
+
|
|
79
|
+
```json
|
|
80
|
+
{
|
|
81
|
+
"session_summary": {
|
|
82
|
+
"provider": "codex",
|
|
83
|
+
"command": "codex exec --ephemeral --ignore-user-config --ignore-rules --skip-git-repo-check --sandbox danger-full-access {prompt}",
|
|
84
|
+
"max_attempts": 3
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
该机制依赖本地 CLI 命令,模型、账号和认证配置沿用被选 CLI;dev-memory 不直接集成 provider HTTP API。`session_summary.command` 支持自定义其它命令,`DEV_MEMORY_SESSION_SUMMARY_CMD` 提供进程级临时 override;`DEV_MEMORY_DISABLE_SESSION_SUMMARY_AGENT=1` 禁用后台总结执行。
|
|
90
|
+
|
|
91
|
+
### 总结作用
|
|
92
|
+
|
|
93
|
+
`SessionEnd` 总结用于从一次会话中提取对后续开发仍然有效的语义信息,而不是保存 transcript 副本或生成 changelog。处理流程如下:
|
|
94
|
+
|
|
95
|
+
1. `SessionEnd` 记录最终 HEAD,并把 job 写入 `<repo-memory>/jobs/session-summary/pending/`。
|
|
96
|
+
2. worker 从 transcript 中提取核心 user/assistant 文本,并加载现有 branch/repo 记忆。
|
|
97
|
+
3. 总结 CLI 只生成 summary-output JSON,不直接调用 dev-memory 命令。
|
|
98
|
+
4. worker 校验 JSON,最多重试 `max_attempts` 次,再通过代码应用结构化 patch。
|
|
99
|
+
5. job 根据结果移动到 `done/`、`skipped/` 或 `failed/`。
|
|
100
|
+
|
|
101
|
+
允许写入或修正的内容包括:
|
|
102
|
+
|
|
103
|
+
- decisions、risks、glossary
|
|
104
|
+
- 功能文件索引 `file_map`
|
|
105
|
+
- repo 级 shared decisions、context、sources
|
|
106
|
+
- 已有 entry 的 rewrite 或 delete
|
|
107
|
+
|
|
108
|
+
以下内容不会写入:
|
|
109
|
+
|
|
110
|
+
- 工具调用流水账、system 消息、reasoning
|
|
111
|
+
- 完整 transcript 或提交历史副本
|
|
112
|
+
- “当前进展”“下一步”“当前阶段”等时效性状态
|
|
113
|
+
- 与现有记忆相比没有有效变化的内容
|
|
114
|
+
|
|
115
|
+
无有效变化的 job 进入 `skipped/`,不会刷新 capture manifest,也不计为真实记忆写入。hook 采用后台执行,不等待总结完成。
|
|
116
|
+
|
|
117
|
+
## Codex 定时扫描
|
|
118
|
+
|
|
119
|
+
Codex CLI 和 Desktop 共用本地 rollout 文件:
|
|
120
|
+
|
|
121
|
+
```text
|
|
122
|
+
~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl
|
|
123
|
+
~/.codex/archived_sessions/
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
安装 macOS LaunchAgent:
|
|
127
|
+
|
|
128
|
+
```bash
|
|
129
|
+
dev-memory-cli session-scan install
|
|
130
|
+
dev-memory-cli session-scan status
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
任务默认每天本地时间 03:00 运行。首次扫描只回看最近 3 天;后续使用持久化字节游标补扫上次成功处理后产生的全部数据。`install-hooks codex` 与 `session-scan install` 相互独立。
|
|
134
|
+
|
|
135
|
+
扫描器只提取尚未处理的 user/assistant 语义消息,不限制消息数量,也不截断单条消息。材料超过单次模型上下文时按顺序分块,每个分块都进入中间摘要,再结合现有 memory 生成最终结构化结果。游标只在最终结果成功应用后推进。
|
|
136
|
+
|
|
137
|
+
### 执行器配置
|
|
138
|
+
|
|
139
|
+
`~/.dev-memory/config.json` 中的 `session_scan` 独立配置扫描执行器。默认提供 `coco`、`codex`、`claude` 三个 preset,并按顺序选择第一个已安装且启用的命令:
|
|
140
|
+
|
|
141
|
+
```json
|
|
142
|
+
{
|
|
143
|
+
"session_scan": {
|
|
144
|
+
"executor": "auto",
|
|
145
|
+
"order": ["coco", "codex", "claude"],
|
|
146
|
+
"chunk_chars": 60000,
|
|
147
|
+
"idle_minutes": 60,
|
|
148
|
+
"first_lookback_days": 3,
|
|
149
|
+
"executors": {
|
|
150
|
+
"coco": {"enabled": true, "command": "coco", "model": null, "profile": null, "extra_args": [], "env": {}},
|
|
151
|
+
"codex": {"enabled": true, "command": "codex", "model": null, "profile": null, "extra_args": [], "env": {}},
|
|
152
|
+
"claude": {"enabled": true, "command": "claude", "model": null, "profile": null, "extra_args": [], "env": {}}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
`model` 由内置适配器转换为对应 CLI 参数。`profile`、`extra_args` 和 `env` 用于选择账号、模型供应商、本地 provider 或代理。也可以增加自定义 preset;自定义命令需要自行保证结构化 JSON 输出。
|
|
159
|
+
|
|
160
|
+
```bash
|
|
161
|
+
dev-memory-cli session-scan config show
|
|
162
|
+
dev-memory-cli session-scan config set-executor codex
|
|
163
|
+
dev-memory-cli session-scan config set-model codex <model>
|
|
164
|
+
dev-memory-cli session-scan config set-profile codex <profile>
|
|
165
|
+
dev-memory-cli session-scan config validate
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
`auto` 只在命令不存在或 preset 被禁用时选择下一个执行器。模型调用失败不会自动换供应商,以免重复产生不可控费用。
|
|
169
|
+
|
|
170
|
+
### 递归防护
|
|
171
|
+
|
|
172
|
+
Codex preset 固定使用 `codex exec --ephemeral --json`,总结调用不会写入新的 rollout。扫描器还会登记内部 session/thread ID,并排除 `dev-memory-summary-` session 和带内部 prompt marker 的会话。即使自定义执行器产生持久化会话,也不会被下一轮当成业务会话重复总结。
|
|
173
|
+
|
|
174
|
+
### 账本与用量
|
|
175
|
+
|
|
176
|
+
```text
|
|
177
|
+
~/.dev-memory/jobs/session-scan/
|
|
178
|
+
candidates/ # Codex Stop 登记
|
|
179
|
+
state/ # 会话字节游标
|
|
180
|
+
sessions/ # 原始大小、分块和用量摘要
|
|
181
|
+
runs/ # 每次扫描记录
|
|
182
|
+
logs/ # LaunchAgent stdout/stderr
|
|
183
|
+
events.jsonl
|
|
184
|
+
internal-sessions.jsonl
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
每个 run 记录原文件大小、本轮新增字节、语义消息和字符数、原会话累计 token、每次总结调用的执行器/模型/耗时/token,以及成功、跳过和失败状态。执行器没有返回 usage 时写为 `unavailable`,不会伪装成 0。
|
|
188
|
+
|
|
189
|
+
```bash
|
|
190
|
+
dev-memory-cli session-scan run --dry-run --json
|
|
191
|
+
dev-memory-cli session-scan run
|
|
192
|
+
dev-memory-cli session-scan stats --json
|
|
193
|
+
dev-memory-cli session-scan history --limit 20
|
|
194
|
+
dev-memory-cli session-scan show <run-id>
|
|
195
|
+
dev-memory-cli session-scan uninstall
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
`dev-memory-cli ui` 的“会话扫描”视图读取同一账本,展示仓库扫描次数、会话数量、原始大小、新增数据量和每次总结 token。
|
|
199
|
+
|
|
200
|
+
## 接入边界
|
|
201
|
+
|
|
202
|
+
这个仓库是 skill suite,不是自动注入所有项目的独立插件:
|
|
203
|
+
|
|
204
|
+
- Claude 把 [hooks/hooks.json](hooks.json) 合并到 `.claude/settings.local.json`。
|
|
205
|
+
- Codex CLI 把 [hooks/codex-hooks.json](codex-hooks.json) 合并到 `.codex/hooks.json`。
|
|
206
|
+
- Codex Desktop 通过本地 rollout 定时扫描覆盖,不依赖项目 hook。
|
|
207
|
+
- 其他仓库需要先安装 CLI,再安装对应 hook;hook 运行时统一调用 `dev-memory-cli hook ...`。
|
|
85
208
|
|
|
86
209
|
## 原则
|
|
87
210
|
|
package/hooks/codex-hooks.json
CHANGED
package/hooks/hooks.json
CHANGED
|
@@ -37,6 +37,7 @@ from dev_memory_common import (
|
|
|
37
37
|
get_setup_completed,
|
|
38
38
|
is_cross_branch_candidate,
|
|
39
39
|
join_sections,
|
|
40
|
+
limit_markdown_entries,
|
|
40
41
|
list_missing_docs,
|
|
41
42
|
merged_focus_areas,
|
|
42
43
|
now_iso,
|
|
@@ -101,7 +102,7 @@ def _emit_capture_log(paths, *, action, kind_label, summary, touched, extra_deta
|
|
|
101
102
|
)
|
|
102
103
|
|
|
103
104
|
|
|
104
|
-
def _append_with_separator(path, title, body):
|
|
105
|
+
def _append_with_separator(path, title, body, *, enforce_limit=True, max_entries=None):
|
|
105
106
|
"""Like append_to_section but always inserts a blank line between the
|
|
106
107
|
existing section body and the new entry. Drops placeholder-only bodies
|
|
107
108
|
instead of padding them with a blank line.
|
|
@@ -112,6 +113,7 @@ def _append_with_separator(path, title, body):
|
|
|
112
113
|
matched = False
|
|
113
114
|
updated = []
|
|
114
115
|
body_stripped = body.strip()
|
|
116
|
+
pruned = 0
|
|
115
117
|
|
|
116
118
|
def _is_placeholder_only(text):
|
|
117
119
|
lines = [ln.strip() for ln in text.splitlines() if ln.strip()]
|
|
@@ -125,13 +127,21 @@ def _append_with_separator(path, title, body):
|
|
|
125
127
|
combined = body_stripped
|
|
126
128
|
else:
|
|
127
129
|
combined = existing_body.rstrip() + "\n\n" + body_stripped
|
|
130
|
+
if enforce_limit:
|
|
131
|
+
combined, pruned = limit_markdown_entries(combined, max_entries=max_entries)
|
|
128
132
|
updated.append((existing_title, combined))
|
|
129
133
|
matched = True
|
|
130
134
|
else:
|
|
131
135
|
updated.append((existing_title, existing_body))
|
|
132
136
|
if not matched:
|
|
133
|
-
|
|
137
|
+
bounded, pruned = (
|
|
138
|
+
limit_markdown_entries(body_stripped, max_entries=max_entries)
|
|
139
|
+
if enforce_limit
|
|
140
|
+
else (body_stripped, 0)
|
|
141
|
+
)
|
|
142
|
+
updated.append((title, bounded))
|
|
134
143
|
path.write_text(join_sections(prefix, updated), encoding="utf-8")
|
|
144
|
+
return pruned
|
|
135
145
|
|
|
136
146
|
|
|
137
147
|
# ---------------------------------------------------------------------------
|
|
@@ -1125,6 +1135,43 @@ def prune_repo_shared_memory(paths, *, max_entries=None, max_entry_chars=None):
|
|
|
1125
1135
|
return touched
|
|
1126
1136
|
|
|
1127
1137
|
|
|
1138
|
+
def prune_bounded_memory(paths, *, max_entries=None):
|
|
1139
|
+
"""Apply the general semantic-entry cap after a multi-operation summary."""
|
|
1140
|
+
max_entries = _int_env("DEV_MEMORY_MAX_ENTRIES", 200) if max_entries is None else max_entries
|
|
1141
|
+
targets = {
|
|
1142
|
+
(spec["file"], spec["section"])
|
|
1143
|
+
for spec in KIND_MAP.values()
|
|
1144
|
+
if spec.get("default_mode") == "append"
|
|
1145
|
+
}
|
|
1146
|
+
touched = []
|
|
1147
|
+
for file_key, section_title in sorted(targets):
|
|
1148
|
+
path = paths.get(file_key)
|
|
1149
|
+
if not path or not path.exists():
|
|
1150
|
+
continue
|
|
1151
|
+
content = path.read_text(encoding="utf-8")
|
|
1152
|
+
prefix, sections = split_sections(content)
|
|
1153
|
+
updated = []
|
|
1154
|
+
changed = False
|
|
1155
|
+
for title, body in sections:
|
|
1156
|
+
if title.strip() != section_title.strip():
|
|
1157
|
+
updated.append((title, body))
|
|
1158
|
+
continue
|
|
1159
|
+
bounded, pruned = limit_markdown_entries(body, max_entries=max_entries)
|
|
1160
|
+
updated.append((title, bounded))
|
|
1161
|
+
if pruned:
|
|
1162
|
+
changed = True
|
|
1163
|
+
touched.append({
|
|
1164
|
+
"file": _label(file_key),
|
|
1165
|
+
"section": title,
|
|
1166
|
+
"mode": "prune",
|
|
1167
|
+
"pruned": pruned,
|
|
1168
|
+
"max_entries": max_entries,
|
|
1169
|
+
})
|
|
1170
|
+
if changed:
|
|
1171
|
+
path.write_text(join_sections(prefix, updated), encoding="utf-8")
|
|
1172
|
+
return touched
|
|
1173
|
+
|
|
1174
|
+
|
|
1128
1175
|
# ---------------------------------------------------------------------------
|
|
1129
1176
|
# Write primitives
|
|
1130
1177
|
# ---------------------------------------------------------------------------
|
|
@@ -1140,7 +1187,7 @@ def _resolve_target(paths, kind, title_override=None):
|
|
|
1140
1187
|
return spec["file"], target_path, section_title
|
|
1141
1188
|
|
|
1142
1189
|
|
|
1143
|
-
def _write_one(paths, kind, body, title_override=None, *, mode_override=None):
|
|
1190
|
+
def _write_one(paths, kind, body, title_override=None, *, mode_override=None, enforce_limit=True):
|
|
1144
1191
|
"""Write a body into the kind's target file. Picks append vs upsert from
|
|
1145
1192
|
KIND_MAP[kind].default_mode unless overridden. progress.md uses a
|
|
1146
1193
|
dedicated upsert that preserves the auto-sync marker.
|
|
@@ -1159,13 +1206,28 @@ def _write_one(paths, kind, body, title_override=None, *, mode_override=None):
|
|
|
1159
1206
|
body_to_write = body.strip()
|
|
1160
1207
|
if not body_to_write.startswith(("- ", "* ", "#")):
|
|
1161
1208
|
body_to_write = "- " + body_to_write
|
|
1162
|
-
|
|
1209
|
+
entry_limit = (
|
|
1210
|
+
_REPO_SHARED_MAX_ENTRIES
|
|
1211
|
+
if file_key in {"repo_decisions", "repo_glossary"}
|
|
1212
|
+
else _int_env("DEV_MEMORY_MAX_ENTRIES", 200)
|
|
1213
|
+
)
|
|
1214
|
+
pruned = _append_with_separator(
|
|
1215
|
+
target_path,
|
|
1216
|
+
section_title,
|
|
1217
|
+
body_to_write,
|
|
1218
|
+
enforce_limit=enforce_limit,
|
|
1219
|
+
max_entries=entry_limit,
|
|
1220
|
+
)
|
|
1163
1221
|
else:
|
|
1164
1222
|
if file_key == "progress":
|
|
1165
1223
|
upsert_progress_section(target_path, section_title, body)
|
|
1166
1224
|
else:
|
|
1167
1225
|
upsert_markdown_section(target_path, section_title, body)
|
|
1168
|
-
|
|
1226
|
+
result = {"file": _label(file_key), "section": section_title, "mode": mode}
|
|
1227
|
+
if mode == "append" and pruned:
|
|
1228
|
+
result["pruned"] = pruned
|
|
1229
|
+
result["max_entries"] = entry_limit
|
|
1230
|
+
return result
|
|
1169
1231
|
|
|
1170
1232
|
|
|
1171
1233
|
def _label(file_key):
|
|
@@ -1819,7 +1881,13 @@ def command_apply_summary_output(args):
|
|
|
1819
1881
|
"dedup_hint": hint,
|
|
1820
1882
|
})
|
|
1821
1883
|
return
|
|
1822
|
-
rec = _write_one(
|
|
1884
|
+
rec = _write_one(
|
|
1885
|
+
paths,
|
|
1886
|
+
kind,
|
|
1887
|
+
body,
|
|
1888
|
+
mode_override=mode_override,
|
|
1889
|
+
enforce_limit=False,
|
|
1890
|
+
)
|
|
1823
1891
|
touched.append(rec)
|
|
1824
1892
|
_maybe_worktree_writeback(
|
|
1825
1893
|
worktree_writeback,
|
|
@@ -1894,6 +1962,12 @@ def command_apply_summary_output(args):
|
|
|
1894
1962
|
"reason": item.get("reason"),
|
|
1895
1963
|
})
|
|
1896
1964
|
|
|
1965
|
+
bounded = prune_bounded_memory(paths)
|
|
1966
|
+
if bounded:
|
|
1967
|
+
touched.extend(bounded)
|
|
1968
|
+
for rec in bounded:
|
|
1969
|
+
actions.append({"op": "prune-memory", **rec})
|
|
1970
|
+
|
|
1897
1971
|
pruned = prune_repo_shared_memory(paths)
|
|
1898
1972
|
if pruned:
|
|
1899
1973
|
touched.extend(pruned)
|
|
@@ -2115,7 +2189,11 @@ def main():
|
|
|
2115
2189
|
suggest = subparsers.add_parser("suggest-kind", help="Dry-run classify a content string")
|
|
2116
2190
|
suggest.add_argument("--content", help="Inline content to classify")
|
|
2117
2191
|
suggest.add_argument("--content-file", help="File containing content to classify")
|
|
2118
|
-
suggest.add_argument(
|
|
2192
|
+
suggest.add_argument(
|
|
2193
|
+
"--already-setup",
|
|
2194
|
+
action="store_true",
|
|
2195
|
+
help="Accepted for compatibility; ambiguous content remains unsorted",
|
|
2196
|
+
)
|
|
2119
2197
|
suggest.add_argument("--branch-name", help="Branch name for cross-branch candidate check")
|
|
2120
2198
|
|
|
2121
2199
|
classify = subparsers.add_parser("classify", help="Classify content against the current branch context")
|
package/lib/dev_memory_common.py
CHANGED
|
@@ -1234,8 +1234,7 @@ def ensure_branch_paths_exist(repo, context_dir=None, branch=None):
|
|
|
1234
1234
|
# Heuristic classifier (capture routing)
|
|
1235
1235
|
# ---------------------------------------------------------------------------
|
|
1236
1236
|
|
|
1237
|
-
# Order matters: the first pattern that matches wins.
|
|
1238
|
-
# ahead of progress since their signals are more specific.
|
|
1237
|
+
# Order matters: the first pattern that matches wins.
|
|
1239
1238
|
_CLASSIFY_PATTERNS = [
|
|
1240
1239
|
("decision", re.compile(r"结论[::]|决[定议][::]|不再|改为|采用|废弃|选择.+?不选|abandoned|adopt")),
|
|
1241
1240
|
("risk", re.compile(r"阻塞|注意|坑|失败|风险|卡住|gotcha|caveat|warning")),
|
|
@@ -1244,19 +1243,18 @@ _CLASSIFY_PATTERNS = [
|
|
|
1244
1243
|
|
|
1245
1244
|
|
|
1246
1245
|
def classify_content(text, *, already_setup=False):
|
|
1247
|
-
"""Classify free-form content into
|
|
1248
|
-
glossary/unsorted. Used by capture when the caller doesn't pass --kind.
|
|
1246
|
+
"""Classify free-form content into decision/risk/glossary/unsorted.
|
|
1249
1247
|
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1248
|
+
``already_setup`` is retained for callers that still pass the setup state,
|
|
1249
|
+
but ambiguous content always stays in ``unsorted``. Ephemeral progress is
|
|
1250
|
+
intentionally derived from Git instead of being a semantic capture kind.
|
|
1253
1251
|
"""
|
|
1254
1252
|
if not text or not text.strip():
|
|
1255
1253
|
return "unsorted"
|
|
1256
1254
|
for label, pattern in _CLASSIFY_PATTERNS:
|
|
1257
1255
|
if pattern.search(text):
|
|
1258
1256
|
return label
|
|
1259
|
-
return "
|
|
1257
|
+
return "unsorted"
|
|
1260
1258
|
|
|
1261
1259
|
|
|
1262
1260
|
# Branch-name tokens that appear in generic technical content too often to
|
|
@@ -1774,7 +1772,37 @@ def _section_is_placeholder_only(text):
|
|
|
1774
1772
|
return all(any(marker in line for marker in PLACEHOLDER_MARKERS) for line in lines)
|
|
1775
1773
|
|
|
1776
1774
|
|
|
1777
|
-
def
|
|
1775
|
+
def memory_max_entries():
|
|
1776
|
+
value = os.environ.get("DEV_MEMORY_MAX_ENTRIES", "200").strip()
|
|
1777
|
+
try:
|
|
1778
|
+
return max(1, int(value))
|
|
1779
|
+
except ValueError:
|
|
1780
|
+
return 200
|
|
1781
|
+
|
|
1782
|
+
|
|
1783
|
+
def limit_markdown_entries(body, max_entries=None):
|
|
1784
|
+
"""Bound an accumulated markdown section while retaining newest entries.
|
|
1785
|
+
|
|
1786
|
+
Accumulation sections are stored oldest-to-newest so entry ids remain
|
|
1787
|
+
stable. Only top-level bullet blocks count as entries; continuation lines
|
|
1788
|
+
stay attached to their bullet.
|
|
1789
|
+
"""
|
|
1790
|
+
limit = memory_max_entries() if max_entries is None else max(1, int(max_entries))
|
|
1791
|
+
lines = (body or "").strip().splitlines()
|
|
1792
|
+
starts = [idx for idx, line in enumerate(lines) if re.match(r"^[-*]\s+", line)]
|
|
1793
|
+
if len(starts) <= limit:
|
|
1794
|
+
return (body or "").strip(), 0
|
|
1795
|
+
blocks = []
|
|
1796
|
+
for pos, start in enumerate(starts):
|
|
1797
|
+
end = starts[pos + 1] if pos + 1 < len(starts) else len(lines)
|
|
1798
|
+
blocks.append("\n".join(lines[start:end]).strip())
|
|
1799
|
+
preamble = "\n".join(lines[:starts[0]]).strip()
|
|
1800
|
+
kept = blocks[-limit:]
|
|
1801
|
+
bounded = "\n\n".join(([preamble] if preamble else []) + kept).strip()
|
|
1802
|
+
return bounded, len(blocks) - len(kept)
|
|
1803
|
+
|
|
1804
|
+
|
|
1805
|
+
def append_to_section(path, title, body, *, max_entries=None):
|
|
1778
1806
|
content = path.read_text(encoding="utf-8") if path.exists() else ""
|
|
1779
1807
|
prefix, sections = split_sections(content)
|
|
1780
1808
|
target = title.strip()
|
|
@@ -1786,12 +1814,14 @@ def append_to_section(path, title, body):
|
|
|
1786
1814
|
combined = body.strip()
|
|
1787
1815
|
else:
|
|
1788
1816
|
combined = (existing_body.rstrip() + "\n" + body.strip()).strip()
|
|
1817
|
+
combined, _pruned = limit_markdown_entries(combined, max_entries=max_entries)
|
|
1789
1818
|
updated.append((existing_title, combined))
|
|
1790
1819
|
matched = True
|
|
1791
1820
|
else:
|
|
1792
1821
|
updated.append((existing_title, existing_body))
|
|
1793
1822
|
if not matched:
|
|
1794
|
-
|
|
1823
|
+
bounded, _pruned = limit_markdown_entries(body.strip(), max_entries=max_entries)
|
|
1824
|
+
updated.append((title, bounded))
|
|
1795
1825
|
path.write_text(join_sections(prefix, updated), encoding="utf-8")
|
|
1796
1826
|
|
|
1797
1827
|
|
|
@@ -123,7 +123,7 @@ def _apply_entries(target_path, entries):
|
|
|
123
123
|
if not section:
|
|
124
124
|
raise ValueError(f"harvest entry missing 'section': {entry}")
|
|
125
125
|
if mode == "append":
|
|
126
|
-
append_to_section(target_path, section, body)
|
|
126
|
+
append_to_section(target_path, section, body, max_entries=20)
|
|
127
127
|
elif mode == "replace":
|
|
128
128
|
upsert_markdown_section(target_path, section, body)
|
|
129
129
|
else:
|