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/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 = { ...process.env, ...extraEnv };
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
- 这套仓库不再使用 Git hooks
4
-
5
- 现在采用的是接近 ECC 的生命周期 hooks,并同时支持 Claude 和 Codex 两种 agent。
3
+ 本项目使用 agent 生命周期 hook,不使用 Git hookClaude Code 与 Codex 的可用事件不同,模板分别维护。
6
4
 
7
5
  ## 当前仓库里的接入方式
8
6
 
9
- - 推荐的 repo-local 落地点:
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
- ## 这些 hook 做什么
44
-
45
- - Claude:
46
- - `SessionStart`
47
- - `PreCompact`
48
- - `Stop`
49
- - `SessionEnd`
50
- - Codex:
51
- - `SessionStart`
52
- - `Stop`
53
-
54
- 语义分别是:
55
-
56
- - `SessionStart`
57
- 读取当前 repo+branch 的 dev-memory,并把可恢复上下文注入新会话;同一 session resume 重触发时通过
58
- `<repo-memory>/jobs/session-start/injected/*.json` 幂等跳过重复注入,只写 skip 日志并返回空 payload
59
- - `PreCompact`
60
- 在上下文压缩前刷新 working-tree 派生导航信息
61
- - `Stop`
62
- 在每次回复后记录轻量 HEAD 标记
63
- - `SessionEnd`
64
- 在会话结束时再落一次最终 HEAD 标记,并把 transcript 总结任务 enqueue 到
65
- `<repo-memory>/jobs/session-summary/pending/`。`install-hooks` 会扫描本地工具并写入
66
- `~/.dev-memory/config.json` 的 `session_summary.command`(优先 coco,其次 codex,再 claude)。hook 会启动后台 worker,先生成
67
- `<repo-memory>/jobs/session-summary/inputs/*.json`,再把已提取的 core messages 和 existing memory
68
- 内联进 `{prompt}`。agent 只输出 summary-output JSON,不调用 CLI;worker 负责 JSON 校验、同 session 最多 3 次重试、落盘和 pending/done/skipped/failed 迁移;无 touched targets 的空总结进入 `skipped/`,不算真实落盘。hook 不等待总结完成。`DEV_MEMORY_SESSION_SUMMARY_CMD` 仅作临时 override,禁用用
69
- `DEV_MEMORY_DISABLE_SESSION_SUMMARY_AGENT=1`
70
-
71
- ## ECC 的差异
72
-
73
- ECC 是插件形态,安装后可以靠插件机制自动加载 hook 配置。
74
-
75
- 这个仓库当前是 skill suite,不是独立 Claude 插件,所以:
76
-
77
- - 对本仓库自身开发:
78
- - Claude 把 [hooks/hooks.json](hooks.json) 合并到本地 `.claude/settings.local.json`
79
- - Codex 把 [hooks/codex-hooks.json](codex-hooks.json) 放到或合并到本地 `.codex/hooks.json`
80
- - 对其他仓库:
81
- - 先安装 `dev-memory-cli`
82
- - 再把模板 merge 到对应 repo-local 配置
83
- - hook 运行时统一走 `dev-memory-cli hook ...`
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
 
@@ -24,7 +24,7 @@
24
24
  "timeout": 15
25
25
  }
26
26
  ],
27
- "description": "Persist lightweight HEAD markers after each response",
27
+ "description": "Persist HEAD markers and register the session for scheduled scanning",
28
28
  "id": "dev-memory:stop"
29
29
  }
30
30
  ]
package/hooks/hooks.json CHANGED
@@ -24,7 +24,7 @@
24
24
  "timeout": 20
25
25
  }
26
26
  ],
27
- "description": "Refresh working-tree-derived navigation before compaction",
27
+ "description": "Compatibility no-op; SessionStart handles working-tree refresh",
28
28
  "id": "dev-memory:pre-compact"
29
29
  }
30
30
  ],
@@ -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
- updated.append((title, body_stripped))
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
- _append_with_separator(target_path, section_title, body_to_write)
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
- return {"file": _label(file_key), "section": section_title, "mode": mode}
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(paths, kind, body, mode_override=mode_override)
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("--already-setup", action="store_true", help="Hint that setup is completed (shifts default from unsorted to progress)")
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")
@@ -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. Keep decisions/risks
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 one of decisions/progress/risks/
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
- Before setup, ambiguous content falls to `unsorted` so the user can sort
1251
- it later via setup merge. After setup, the default shifts to `progress`
1252
- because the user has signaled they want aggressive categorization.
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 "progress" if already_setup else "unsorted"
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 append_to_section(path, title, body):
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
- updated.append((title, body.strip()))
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: