dev-memory-cli 0.22.2 → 0.23.1

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 CHANGED
@@ -81,7 +81,7 @@ dev-memory-cli session-scan install
81
81
  dev-memory-cli session-scan status
82
82
  ```
83
83
 
84
- 扫描任务默认在本地时间 03:00 运行。`install-hooks codex` 只安装 CLI hook,不会隐式安装定时任务。
84
+ 扫描任务默认在本地时间 03:00 和 13:00 运行,时间列表可配置。LaunchAgent 触发时会读取 macOS HID 空闲时长;最近 10 分钟有键鼠输入则记录 `skipped_active` 并退出,不扫描文件或调用模型。活跃检测失败时默认保守跳过。手工执行 `session-scan run` 不受该检测影响。`install-hooks codex` 只安装 CLI hook,不会隐式安装定时任务。
85
85
 
86
86
  ## 基本使用
87
87
 
@@ -223,11 +223,19 @@ dev-memory-cli session-scan run --dry-run --json
223
223
  dev-memory-cli session-scan run
224
224
  dev-memory-cli session-scan stats --json
225
225
  dev-memory-cli session-scan history --limit 20
226
+ dev-memory-cli session-scan replay --run-id <run-id> --session-id <session-id> [--session-id <session-id> ...] [--executor codex]
226
227
  dev-memory-cli session-scan config show
227
228
  dev-memory-cli session-scan config set-executor codex
228
229
  dev-memory-cli session-scan config set-model codex <model>
230
+ dev-memory-cli session-scan config set-schedule 03:00 13:00
231
+ dev-memory-cli session-scan config set-active-minutes 10
232
+ dev-memory-cli session-scan config set-timeout 360
229
233
  ```
230
234
 
235
+ 已安装定时任务时,`set-schedule` 会自动重载 LaunchAgent,使新的时间列表立即生效。
236
+
237
+ `replay` 会按历史 run 记录的 `cursor_before` / `cursor_after` 精确重放指定会话,不回退当前会话游标;`--executor` 只覆盖本次运行,不修改持久配置。单分块会话只调用一次模型;最终结果必须包含至少一个记忆变更,或提供非空 `skip_reason`。空对象、只有 `title`、以及没有产生语义 action 且未说明原因的结果都会标记失败并保留重试空间。单次模型调用默认 360 秒超时,超时不会立即重试。run 账本会记录输出字段计数、`skip_reason`、payload 哈希、有效/观测字节数和语义 action 数,不保存原始总结正文。
238
+
231
239
  ## 运行模式
232
240
 
233
241
  | 模式 | 检测条件 | 行为 |
@@ -327,7 +335,7 @@ dev-memory-cli branch [list|inspect|rename|fork|delete|init|inherit-worktree-bas
327
335
  dev-memory-cli context <show|sync|injection-preview>
328
336
  dev-memory-cli workspace <show|primary>
329
337
  dev-memory-cli summary <extract-core>
330
- dev-memory-cli session-scan <run|install|status|stats|history|show|uninstall|config>
338
+ dev-memory-cli session-scan <run|replay|install|status|stats|history|show|uninstall|config>
331
339
  dev-memory-cli hook <session-start|pre-compact|stop|session-end>
332
340
  dev-memory-cli ui
333
341
  dev-memory-cli install-hooks <codex|claude|--all>
package/hooks/README.md CHANGED
@@ -130,7 +130,9 @@ dev-memory-cli session-scan install
130
130
  dev-memory-cli session-scan status
131
131
  ```
132
132
 
133
- 任务默认每天本地时间 03:00 运行。首次扫描只回看最近 3 天;后续使用持久化字节游标补扫上次成功处理后产生的全部数据。`install-hooks codex` `session-scan install` 相互独立。
133
+ 任务默认每天本地时间 03:00 13:00 运行。LaunchAgent 使用 `session-scan run --scheduled`,运行前检查 macOS HID 空闲时长;最近 10 分钟有键鼠输入时写入 `skipped_active` 记录并退出,不读取会话正文、不调用模型。检测不可用时默认保守跳过。手工执行 `session-scan run` 不启用活跃检测。
134
+
135
+ 首次扫描只回看最近 3 天;后续使用持久化字节游标补扫上次成功处理后产生的全部数据。`install-hooks codex` 与 `session-scan install` 相互独立。
134
136
 
135
137
  扫描器只提取尚未处理的 user/assistant 语义消息,不限制消息数量,也不截断单条消息。材料超过单次模型上下文时按顺序分块,每个分块都进入中间摘要,再结合现有 memory 生成最终结构化结果。游标只在最终结果成功应用后推进。
136
138
 
@@ -143,6 +145,10 @@ dev-memory-cli session-scan status
143
145
  "session_scan": {
144
146
  "executor": "auto",
145
147
  "order": ["coco", "codex", "claude"],
148
+ "schedule_times": ["03:00", "13:00"],
149
+ "skip_when_computer_active": true,
150
+ "active_within_minutes": 10,
151
+ "activity_check_fail_closed": true,
146
152
  "chunk_chars": 60000,
147
153
  "idle_minutes": 60,
148
154
  "first_lookback_days": 3,
@@ -162,9 +168,14 @@ dev-memory-cli session-scan config show
162
168
  dev-memory-cli session-scan config set-executor codex
163
169
  dev-memory-cli session-scan config set-model codex <model>
164
170
  dev-memory-cli session-scan config set-profile codex <profile>
171
+ dev-memory-cli session-scan config set-schedule 03:00 13:00
172
+ dev-memory-cli session-scan config set-active-minutes 10
173
+ dev-memory-cli session-scan config set-active-check on
165
174
  dev-memory-cli session-scan config validate
166
175
  ```
167
176
 
177
+ 已安装定时任务时,`set-schedule` 会自动重载 LaunchAgent;活跃阈值和开关由每次运行动态读取,无需重装。
178
+
168
179
  `auto` 只在命令不存在或 preset 被禁用时选择下一个执行器。模型调用失败不会自动换供应商,以免重复产生不可控费用。
169
180
 
170
181
  ### 递归防护
@@ -16,9 +16,11 @@ Subcommands:
16
16
 
17
17
  import argparse
18
18
  import difflib
19
+ import fcntl
19
20
  import json
20
21
  import os
21
22
  import sys
23
+ from contextlib import contextmanager
22
24
  from pathlib import Path
23
25
 
24
26
  sys.path.insert(0, str(Path(__file__).resolve().parent))
@@ -27,6 +29,7 @@ from dev_memory_common import (
27
29
  AUTO_END,
28
30
  AUTO_START,
29
31
  PLACEHOLDER_MARKERS,
32
+ atomic_write_text,
30
33
  append_log_event,
31
34
  append_to_section,
32
35
  asset_paths,
@@ -34,6 +37,7 @@ from dev_memory_common import (
34
37
  collect_git_facts,
35
38
  ensure_branch_paths_exist,
36
39
  get_head_commit,
40
+ get_branch_paths,
37
41
  get_setup_completed,
38
42
  is_cross_branch_candidate,
39
43
  join_sections,
@@ -140,7 +144,7 @@ def _append_with_separator(path, title, body, *, enforce_limit=True, max_entries
140
144
  else (body_stripped, 0)
141
145
  )
142
146
  updated.append((title, bounded))
143
- path.write_text(join_sections(prefix, updated), encoding="utf-8")
147
+ atomic_write_text(path, join_sections(prefix, updated))
144
148
  return pruned
145
149
 
146
150
 
@@ -770,7 +774,7 @@ def _entry_mutation(paths, eid, *, new_text=None, delete=False):
770
774
 
771
775
  new_sections = list(sections)
772
776
  new_sections[section_idx] = (section_title, new_body)
773
- target_path.write_text(join_sections(prefix, new_sections), encoding="utf-8")
777
+ atomic_write_text(target_path, join_sections(prefix, new_sections))
774
778
  return {
775
779
  "id": eid,
776
780
  "file_key": file_key,
@@ -1100,7 +1104,7 @@ def _prune_repo_shared_section(path, section_title, *, max_entries, max_entry_ch
1100
1104
  updated.append((existing_title, new_body))
1101
1105
  if not changed:
1102
1106
  return None
1103
- path.write_text(join_sections(prefix, updated), encoding="utf-8")
1107
+ atomic_write_text(path, join_sections(prefix, updated))
1104
1108
  return result
1105
1109
 
1106
1110
 
@@ -1168,7 +1172,7 @@ def prune_bounded_memory(paths, *, max_entries=None):
1168
1172
  "max_entries": max_entries,
1169
1173
  })
1170
1174
  if changed:
1171
- path.write_text(join_sections(prefix, updated), encoding="utf-8")
1175
+ atomic_write_text(path, join_sections(prefix, updated))
1172
1176
  return touched
1173
1177
 
1174
1178
 
@@ -1278,6 +1282,8 @@ def _decision_content(item):
1278
1282
  if isinstance(item, str):
1279
1283
  return item.strip()
1280
1284
  if isinstance(item, dict):
1285
+ if not _decision_summary(item):
1286
+ return ""
1281
1287
  return decision_body(item)
1282
1288
  return ""
1283
1289
 
@@ -1962,6 +1968,45 @@ def command_apply_summary_output(args):
1962
1968
  "reason": item.get("reason"),
1963
1969
  })
1964
1970
 
1971
+ # Repair schema placeholder prose left by malformed historical summary
1972
+ # output. Valid entries are top-level bullets; these exact unbulleted
1973
+ # words are generator scaffolding and should never survive in memory.
1974
+ placeholder_words = {
1975
+ "decision", "summary", "reason", "impact", "risk", "mitigation",
1976
+ "term", "definition", "name", "url", "note", "label", "path", "content",
1977
+ }
1978
+ for file_key in ("decisions", "risks", "glossary", "repo_glossary"):
1979
+ path = paths[file_key]
1980
+ if not path.exists():
1981
+ continue
1982
+ content = path.read_text(encoding="utf-8")
1983
+ prefix, sections = split_sections(content)
1984
+ updated = []
1985
+ removed = 0
1986
+ for section_title, section_body in sections:
1987
+ kept_lines = []
1988
+ section_removed = 0
1989
+ for line in section_body.splitlines():
1990
+ stripped = line.strip().strip("::").lower()
1991
+ is_bullet = line.lstrip().startswith(("- ", "* ", "+ "))
1992
+ if stripped in placeholder_words and not is_bullet:
1993
+ section_removed += 1
1994
+ continue
1995
+ kept_lines.append(line)
1996
+ if section_removed:
1997
+ rec = {
1998
+ "file": _label(file_key),
1999
+ "section": section_title,
2000
+ "mode": "remove-placeholder-orphans",
2001
+ "removed": section_removed,
2002
+ }
2003
+ touched.append(rec)
2004
+ actions.append({"op": "remove-placeholder-orphans", **rec})
2005
+ removed += section_removed
2006
+ updated.append((section_title, "\n".join(kept_lines).strip()))
2007
+ if removed:
2008
+ atomic_write_text(path, join_sections(prefix, updated))
2009
+
1965
2010
  bounded = prune_bounded_memory(paths)
1966
2011
  if bounded:
1967
2012
  touched.extend(bounded)
@@ -2177,6 +2222,20 @@ def _add_common_args(parser):
2177
2222
  parser.add_argument("--branch", help="Branch name. Defaults to the current checked-out branch")
2178
2223
 
2179
2224
 
2225
+ @contextmanager
2226
+ def _repo_command_lock(args):
2227
+ """Serialize capture commands per repo so read-modify-write stays coherent."""
2228
+ _, _, _, _, _, repo_dir, _ = get_branch_paths(args.repo, args.context_dir, args.branch)
2229
+ lock_path = repo_dir / "jobs" / "capture.lock"
2230
+ lock_path.parent.mkdir(parents=True, exist_ok=True)
2231
+ with lock_path.open("a+", encoding="utf-8") as lock_stream:
2232
+ fcntl.flock(lock_stream.fileno(), fcntl.LOCK_EX)
2233
+ try:
2234
+ yield
2235
+ finally:
2236
+ fcntl.flock(lock_stream.fileno(), fcntl.LOCK_UN)
2237
+
2238
+
2180
2239
  def main():
2181
2240
  parser = argparse.ArgumentParser(
2182
2241
  description="Unified write entrypoint for dev-memory repo+branch memory (merges old sync + update).",
@@ -2297,7 +2356,11 @@ def main():
2297
2356
  "sync-working-tree": command_sync_working_tree,
2298
2357
  "record-head": command_record_head,
2299
2358
  }
2300
- return handlers[args.command](args)
2359
+ handler = handlers[args.command]
2360
+ if args.command == "suggest-kind":
2361
+ return handler(args)
2362
+ with _repo_command_lock(args):
2363
+ return handler(args)
2301
2364
  except Exception as exc:
2302
2365
  print(json.dumps({"error": str(exc)}, ensure_ascii=False), file=sys.stderr)
2303
2366
  return 1
@@ -6,6 +6,7 @@ import os
6
6
  import re
7
7
  import shutil
8
8
  import subprocess
9
+ import tempfile
9
10
  import time
10
11
  import uuid
11
12
  from collections import Counter
@@ -511,11 +512,29 @@ def asset_paths(repo_dir, branch_dir):
511
512
 
512
513
  def ensure_file(path, content):
513
514
  if not path.exists():
514
- path.write_text(content, encoding="utf-8")
515
+ atomic_write_text(path, content)
516
+
517
+
518
+ def atomic_write_text(path, content):
519
+ """Atomically replace a UTF-8 text file without exposing partial bytes."""
520
+ path = Path(path)
521
+ path.parent.mkdir(parents=True, exist_ok=True)
522
+ mode = (path.stat().st_mode & 0o777) if path.exists() else 0o644
523
+ fd, tmp_name = tempfile.mkstemp(prefix=f".{path.name}.", suffix=".tmp", dir=path.parent)
524
+ tmp_path = Path(tmp_name)
525
+ try:
526
+ with os.fdopen(fd, "w", encoding="utf-8") as stream:
527
+ stream.write(content)
528
+ stream.flush()
529
+ os.fsync(stream.fileno())
530
+ os.chmod(tmp_path, mode)
531
+ os.replace(tmp_path, path)
532
+ finally:
533
+ tmp_path.unlink(missing_ok=True)
515
534
 
516
535
 
517
536
  def write_json(path, payload):
518
- path.write_text(json.dumps(payload, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
537
+ atomic_write_text(path, json.dumps(payload, indent=2, ensure_ascii=False) + "\n")
519
538
 
520
539
 
521
540
  def read_json(path):
@@ -1705,7 +1724,7 @@ def ensure_progress_auto_block(path):
1705
1724
  else:
1706
1725
  updated = content.rstrip() + auto_section
1707
1726
 
1708
- path.write_text(updated + ("" if updated.endswith("\n") else "\n"), encoding="utf-8")
1727
+ atomic_write_text(path, updated + ("" if updated.endswith("\n") else "\n"))
1709
1728
  return path.read_text(encoding="utf-8")
1710
1729
 
1711
1730
 
@@ -1762,7 +1781,7 @@ def upsert_markdown_section(path, title, body):
1762
1781
  updated.append((existing_title, existing_body))
1763
1782
  if not replaced:
1764
1783
  updated.append((title, body))
1765
- path.write_text(join_sections(prefix, updated), encoding="utf-8")
1784
+ atomic_write_text(path, join_sections(prefix, updated))
1766
1785
 
1767
1786
 
1768
1787
  def _section_is_placeholder_only(text):
@@ -1822,7 +1841,7 @@ def append_to_section(path, title, body, *, max_entries=None):
1822
1841
  if not matched:
1823
1842
  bounded, _pruned = limit_markdown_entries(body.strip(), max_entries=max_entries)
1824
1843
  updated.append((title, bounded))
1825
- path.write_text(join_sections(prefix, updated), encoding="utf-8")
1844
+ atomic_write_text(path, join_sections(prefix, updated))
1826
1845
 
1827
1846
 
1828
1847
  def upsert_progress_section(path, title, body):
@@ -1849,7 +1868,7 @@ def upsert_progress_section(path, title, body):
1849
1868
  if not replaced:
1850
1869
  updated.append((title, body))
1851
1870
  rewritten = join_sections(prefix, updated).rstrip() + "\n\n" + marker + after
1852
- path.write_text(rewritten, encoding="utf-8")
1871
+ atomic_write_text(path, rewritten)
1853
1872
 
1854
1873
 
1855
1874
  def sync_progress(paths, facts):
@@ -1864,7 +1883,7 @@ def sync_progress(paths, facts):
1864
1883
  )
1865
1884
  current = ensure_progress_auto_block(paths["progress"])
1866
1885
  updated = replace_auto_block(current, build_auto_block(facts))
1867
- paths["progress"].write_text(updated, encoding="utf-8")
1886
+ atomic_write_text(paths["progress"], updated)
1868
1887
 
1869
1888
 
1870
1889
  # ---------------------------------------------------------------------------
@@ -1918,7 +1937,7 @@ def append_log_event(log_path, action, *, kind=None, summary=None, details=None)
1918
1937
  return
1919
1938
  if not log_path.exists():
1920
1939
  log_path.parent.mkdir(parents=True, exist_ok=True)
1921
- log_path.write_text(template_log("unknown"), encoding="utf-8")
1940
+ atomic_write_text(log_path, template_log("unknown"))
1922
1941
 
1923
1942
  header_parts = [f"## [{now_iso()}] {action}"]
1924
1943
  if kind:
@@ -1938,7 +1957,7 @@ def append_log_event(log_path, action, *, kind=None, summary=None, details=None)
1938
1957
  existing = log_path.read_text(encoding="utf-8")
1939
1958
  if not existing.endswith("\n"):
1940
1959
  existing += "\n"
1941
- log_path.write_text(existing + "\n" + block, encoding="utf-8")
1960
+ atomic_write_text(log_path, existing + "\n" + block)
1942
1961
 
1943
1962
 
1944
1963
  # ---------------------------------------------------------------------------
@@ -29,6 +29,35 @@ PLIST_PATH = Path(os.environ.get(
29
29
  "~/Library/LaunchAgents/com.dev-memory.session-scan.plist",
30
30
  )).expanduser()
31
31
  INTERNAL_MARKER = "DEV_MEMORY_INTERNAL_SESSION_SUMMARY_V1"
32
+ SUMMARY_MUTATION_FIELDS = (
33
+ "file_map",
34
+ "decisions",
35
+ "risks",
36
+ "glossary",
37
+ "shared_decisions",
38
+ "shared_context",
39
+ "shared_sources",
40
+ "upserts",
41
+ "appends",
42
+ "rewrites",
43
+ "deletes",
44
+ )
45
+ SUMMARY_PLACEHOLDER_WORDS = {
46
+ "decision",
47
+ "summary",
48
+ "reason",
49
+ "impact",
50
+ "risk",
51
+ "mitigation",
52
+ "term",
53
+ "definition",
54
+ "name",
55
+ "url",
56
+ "note",
57
+ "label",
58
+ "path",
59
+ "content",
60
+ }
32
61
 
33
62
 
34
63
  DEFAULT_EXECUTORS = {
@@ -97,10 +126,15 @@ def default_scan_config():
97
126
  "executor": "auto",
98
127
  "order": ["coco", "codex", "claude"],
99
128
  "executors": json.loads(json.dumps(DEFAULT_EXECUTORS)),
129
+ "schedule_times": ["03:00", "13:00"],
130
+ "skip_when_computer_active": True,
131
+ "active_within_minutes": 10,
132
+ "activity_check_fail_closed": True,
100
133
  "chunk_chars": 60000,
101
134
  "idle_minutes": 60,
102
135
  "first_lookback_days": 3,
103
136
  "max_attempts": 2,
137
+ "invocation_timeout_seconds": 360,
104
138
  }
105
139
 
106
140
 
@@ -145,9 +179,78 @@ def validate_config(config):
145
179
  errors.append("chunk_chars must be at least 1000")
146
180
  except (TypeError, ValueError):
147
181
  errors.append("chunk_chars must be an integer")
182
+ schedules = config.get("schedule_times")
183
+ if not isinstance(schedules, list) or not schedules:
184
+ errors.append("schedule_times must be a non-empty list")
185
+ else:
186
+ for value in schedules:
187
+ try:
188
+ _parse_schedule_time(value)
189
+ except ValueError as exc:
190
+ errors.append(str(exc))
191
+ try:
192
+ if int(config.get("active_within_minutes", 0)) < 1:
193
+ errors.append("active_within_minutes must be at least 1")
194
+ except (TypeError, ValueError):
195
+ errors.append("active_within_minutes must be an integer")
196
+ try:
197
+ if int(config.get("invocation_timeout_seconds", 0)) < 30:
198
+ errors.append("invocation_timeout_seconds must be at least 30")
199
+ except (TypeError, ValueError):
200
+ errors.append("invocation_timeout_seconds must be an integer")
148
201
  return {"valid": not errors, "errors": errors, "warnings": warnings}
149
202
 
150
203
 
204
+ def _parse_schedule_time(value):
205
+ match = re.fullmatch(r"(\d{1,2}):(\d{2})", str(value or "").strip())
206
+ if not match:
207
+ raise ValueError(f"invalid schedule time '{value}', expected HH:MM")
208
+ hour, minute = int(match.group(1)), int(match.group(2))
209
+ if not 0 <= hour <= 23 or not 0 <= minute <= 59:
210
+ raise ValueError(f"invalid schedule time '{value}', expected HH:MM")
211
+ return hour, minute
212
+
213
+
214
+ def _calendar_intervals(config):
215
+ unique = sorted({_parse_schedule_time(value) for value in config.get("schedule_times", [])})
216
+ return [{"Hour": hour, "Minute": minute} for hour, minute in unique]
217
+
218
+
219
+ def _mac_idle_seconds():
220
+ if sys.platform != "darwin":
221
+ return None
222
+ result = subprocess.run(
223
+ ["ioreg", "-c", "IOHIDSystem", "-d", "4"],
224
+ capture_output=True,
225
+ text=True,
226
+ check=False,
227
+ )
228
+ if result.returncode != 0:
229
+ return None
230
+ match = re.search(r'"HIDIdleTime"\s*=\s*(\d+)', result.stdout or "")
231
+ if not match:
232
+ return None
233
+ return int(match.group(1)) / 1_000_000_000
234
+
235
+
236
+ def computer_activity(config):
237
+ threshold_seconds = int(config.get("active_within_minutes", 10)) * 60
238
+ idle_seconds = _mac_idle_seconds()
239
+ if idle_seconds is None:
240
+ return {
241
+ "status": "unknown",
242
+ "idle_seconds": None,
243
+ "threshold_seconds": threshold_seconds,
244
+ "skip": bool(config.get("activity_check_fail_closed", True)),
245
+ }
246
+ return {
247
+ "status": "active" if idle_seconds < threshold_seconds else "idle",
248
+ "idle_seconds": round(idle_seconds, 3),
249
+ "threshold_seconds": threshold_seconds,
250
+ "skip": idle_seconds < threshold_seconds,
251
+ }
252
+
253
+
151
254
  def choose_executor(config):
152
255
  executors = config.get("executors", {})
153
256
  selected = config.get("executor", "auto")
@@ -418,17 +521,107 @@ MESSAGES_JSON:
418
521
  """
419
522
 
420
523
 
524
+ def _single_prompt(target, chunk):
525
+ payload = {
526
+ "existing_memory": _existing_memory(target),
527
+ "messages": [{"role": item["role"], "text": item["text"]} for item in chunk],
528
+ }
529
+ return f"""{INTERNAL_MARKER}
530
+ 你是 dev-memory 的后台会话总结器。阅读完整会话与 existing_memory,直接生成最终 summary-output,不要先输出中间摘要。
531
+ 只保留未来开发会话中仍有价值、且 existing_memory 尚未覆盖的决策、约束、风险、术语、命令、外部入口和功能文件定位;不要记录聊天流水账、普通进展或可从 Git 直接恢复的历史。
532
+ 旧结论失效时使用 rewrites/deletes,不要追加矛盾条目。如果没有任何需要写入、改写或删除的内容,必须返回非空 skip_reason;禁止只返回 title 或空对象。
533
+ 字段 schema:decisions/shared_decisions 是对象数组,每项使用 summary(完整结论)、可选 reason、可选 impact;risks/glossary/shared_context/shared_sources 是完整自然语言字符串数组;file_map 每项为 {{"label":"功能说明","paths":["真实相对路径"]}};upserts/appends 每项必须有 kind 和 content;rewrites 每项必须有 id/content/reason;deletes 每项必须有 id/reason。
534
+ 所有值必须来自会话事实并可独立理解。禁止输出 decision/summary/reason/impact/risk/mitigation/term/definition/name/url/note/path/content 等 schema 占位词,禁止留空 summary。
535
+ 只输出一个 JSON 对象,不要 markdown fence。允许字段:title、file_map、decisions、risks、glossary、shared_decisions、shared_context、shared_sources、upserts、appends、rewrites、deletes、skip_reason。
536
+ INPUT_JSON:
537
+ {json.dumps(payload, ensure_ascii=False)}
538
+ """
539
+
540
+
421
541
  def _final_prompt(target, partials):
422
542
  payload = {"existing_memory": _existing_memory(target), "partial_summaries": partials}
423
543
  return f"""{INTERNAL_MARKER}
424
544
  你是 dev-memory 的后台会话总结器。把所有 partial_summaries 与 existing_memory 合并为一个最终 summary-output。
425
545
  旧结论失效时使用 rewrites/deletes,不要追加矛盾条目;重复内容跳过。不要写当前进展、下一步或提交历史。
546
+ 如果没有任何需要写入、改写或删除的内容,必须返回非空 skip_reason;禁止只返回 title 或空对象。
547
+ decisions/shared_decisions 每项必须有非空 summary;risks/glossary/shared_context/shared_sources 必须是完整自然语言字符串;file_map 每项必须有 label 和真实 paths。禁止输出 schema 占位词或空 summary。
426
548
  只输出一个 JSON 对象,不要 markdown fence。允许字段:title、file_map、decisions、risks、glossary、shared_decisions、shared_context、shared_sources、upserts、appends、rewrites、deletes、skip_reason。
427
549
  INPUT_JSON:
428
550
  {json.dumps(payload, ensure_ascii=False)}
429
551
  """
430
552
 
431
553
 
554
+ def _summary_payload_meta(payload):
555
+ payload = payload if isinstance(payload, dict) else {}
556
+ serialized = json.dumps(payload, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
557
+ field_counts = {}
558
+ for key in SUMMARY_MUTATION_FIELDS:
559
+ value = payload.get(key)
560
+ if isinstance(value, list):
561
+ field_counts[key] = len(value)
562
+ elif value:
563
+ field_counts[key] = 1
564
+ skip_reason = payload.get("skip_reason")
565
+ skip_reason = skip_reason.strip() if isinstance(skip_reason, str) else None
566
+ return {
567
+ "keys": sorted(payload),
568
+ "field_counts": field_counts,
569
+ "mutation_count": sum(field_counts.values()),
570
+ "skip_reason": skip_reason or None,
571
+ "chars": len(serialized),
572
+ "sha256": hashlib.sha256(serialized.encode("utf-8")).hexdigest(),
573
+ }
574
+
575
+
576
+ def _looks_like_placeholder_text(value):
577
+ if not isinstance(value, str):
578
+ return False
579
+ words = []
580
+ for line in value.splitlines():
581
+ normalized = re.sub(r"^[\s>*+-]+", "", line).strip().strip("::").lower()
582
+ if normalized:
583
+ words.append(normalized)
584
+ return bool(words) and all(word in SUMMARY_PLACEHOLDER_WORDS for word in words)
585
+
586
+
587
+ def _summary_payload_validation_errors(payload):
588
+ errors = []
589
+ if not isinstance(payload, dict):
590
+ return ["summary output must be an object"]
591
+ for field in ("decisions", "shared_decisions"):
592
+ for index, item in enumerate(payload.get(field) or []):
593
+ if isinstance(item, str):
594
+ summary = item.strip()
595
+ elif isinstance(item, dict):
596
+ summary = str(item.get("summary") or item.get("decision") or "").strip()
597
+ else:
598
+ summary = ""
599
+ if not summary:
600
+ errors.append(f"{field}[{index}] requires a non-empty summary")
601
+ elif _looks_like_placeholder_text(summary):
602
+ errors.append(f"{field}[{index}] contains schema placeholder text")
603
+ for field in ("risks", "glossary", "shared_context", "shared_sources"):
604
+ for index, item in enumerate(payload.get(field) or []):
605
+ if not isinstance(item, str) or not item.strip():
606
+ errors.append(f"{field}[{index}] must be a non-empty string")
607
+ elif _looks_like_placeholder_text(item):
608
+ errors.append(f"{field}[{index}] contains schema placeholder text")
609
+ for index, item in enumerate(payload.get("file_map") or []):
610
+ paths = item.get("paths") if isinstance(item, dict) else None
611
+ if not isinstance(item, dict) or not str(item.get("label") or "").strip() or not paths:
612
+ errors.append(f"file_map[{index}] requires label and paths")
613
+ return errors
614
+
615
+
616
+ def _semantic_action_count(apply_result):
617
+ actions = apply_result.get("actions") if isinstance(apply_result, dict) else []
618
+ return sum(
619
+ 1
620
+ for action in (actions or [])
621
+ if isinstance(action, dict) and not str(action.get("op") or "").startswith("prune-")
622
+ )
623
+
624
+
432
625
  def _executor_args(name, preset, prompt):
433
626
  command = shlex.split(preset.get("command", name))
434
627
  model = preset.get("model")
@@ -547,22 +740,46 @@ def run_executor(name, preset, prompt, cwd, run_id, invocation):
547
740
  env = dict(os.environ)
548
741
  env.update({str(k): str(v) for k, v in (preset.get("env") or {}).items()})
549
742
  started = time.time()
550
- result = subprocess.run(args, cwd=cwd, env=env, capture_output=True, text=True, check=False)
743
+ timeout_seconds = int(preset.get("_timeout_seconds", 360))
551
744
  record = {
552
745
  "invocation": invocation,
746
+ "stage": "final" if ":final" in invocation else "partial",
553
747
  "executor": name,
554
748
  "model": preset.get("model"),
555
749
  "profile": preset.get("profile"),
750
+ "prompt_chars": len(prompt),
556
751
  "started_at": dt.datetime.fromtimestamp(started, dt.timezone.utc).isoformat(),
557
- "duration_ms": int((time.time() - started) * 1000),
558
- "returncode": result.returncode,
752
+ "duration_ms": 0,
753
+ "returncode": None,
559
754
  "usage_status": "unavailable",
560
755
  }
756
+ try:
757
+ result = subprocess.run(
758
+ args,
759
+ cwd=cwd,
760
+ env=env,
761
+ capture_output=True,
762
+ text=True,
763
+ check=False,
764
+ timeout=timeout_seconds,
765
+ )
766
+ except subprocess.TimeoutExpired:
767
+ record.update({
768
+ "duration_ms": int((time.time() - started) * 1000),
769
+ "returncode": None,
770
+ "timed_out": True,
771
+ "timeout_seconds": timeout_seconds,
772
+ "error": f"executor timed out after {timeout_seconds} seconds",
773
+ })
774
+ return None, record
775
+ record["duration_ms"] = int((time.time() - started) * 1000)
776
+ record["returncode"] = result.returncode
561
777
  if result.returncode != 0:
562
778
  record["error"] = (result.stderr or result.stdout or "executor failed")[-4000:]
563
779
  return None, record
564
780
  try:
565
781
  payload, usage, session_id = _parse_executor_output(name, result.stdout, result.stderr)
782
+ record["output"] = _summary_payload_meta(payload)
566
783
  record["usage"] = usage
567
784
  record["usage_status"] = "reported" if usage else "unavailable"
568
785
  record["internal_session_id"] = session_id
@@ -590,6 +807,8 @@ def run_executor_with_retries(name, preset, prompt, cwd, run_id, invocation, max
590
807
  records.append(record)
591
808
  if payload is not None:
592
809
  break
810
+ if record.get("timed_out"):
811
+ break
593
812
  return payload, records
594
813
 
595
814
 
@@ -697,27 +916,82 @@ def discover(config, since=None):
697
916
  return sessions
698
917
 
699
918
 
700
- def run_scan(args):
701
- config = load_config()
702
- validation = validate_config(config)
703
- if not validation["valid"]:
704
- raise RuntimeError("; ".join(validation["errors"]))
705
- run_id = dt.datetime.now().strftime("%Y%m%dT%H%M%S") + f"-{os.getpid()}"
706
- started = time.time()
707
- origin_path = SCAN_ROOT / "scan-origin.json"
708
- if not origin_path.exists():
709
- lookback_days = int(config.get("first_lookback_days", 3))
710
- _atomic_json(origin_path, {
711
- "created_at": now_iso(),
712
- "initial_cutoff_epoch": time.time() - lookback_days * 86400,
713
- "first_lookback_days": lookback_days,
714
- })
715
- sessions = discover(config, args.since)
919
+ def _persist_scan_run(run, event):
920
+ run_id = run["run_id"]
921
+ _atomic_json(SCAN_ROOT / "runs" / f"{run_id}.json", run)
922
+ _atomic_json(SCAN_ROOT / "last-run.json", run)
923
+ _append_jsonl(SCAN_ROOT / "events.jsonl", {"at": now_iso(), "event": event, "run_id": run_id})
924
+
925
+
926
+ def _skipped_activity_run(run_id, started, activity, *, dry_run=False):
927
+ status = "skipped_active" if activity["status"] == "active" else "skipped_activity_unknown"
928
+ return {
929
+ "schema_version": SCHEMA_VERSION,
930
+ "run_id": run_id,
931
+ "status": status,
932
+ "skip_reason": activity["status"],
933
+ "started_at": dt.datetime.fromtimestamp(started, dt.timezone.utc).isoformat(),
934
+ "finished_at": now_iso(),
935
+ "duration_ms": int((time.time() - started) * 1000),
936
+ "scheduled": True,
937
+ "dry_run": bool(dry_run),
938
+ "activity": activity,
939
+ "executor": None,
940
+ "model": None,
941
+ "profile": None,
942
+ "session_count": 0,
943
+ "candidate_count": 0,
944
+ "done_count": 0,
945
+ "summary_skipped_count": 0,
946
+ "failed_count": 0,
947
+ "skipped_count": 0,
948
+ "discovery_skipped_count": 0,
949
+ "raw_bytes": 0,
950
+ "new_bytes": 0,
951
+ "observed_new_bytes": 0,
952
+ "eligible_new_bytes": 0,
953
+ "skipped_new_bytes": 0,
954
+ "semantic_messages": 0,
955
+ "semantic_chars": 0,
956
+ "summary_usage": None,
957
+ "usage_unavailable_invocations": 0,
958
+ "invocations": [],
959
+ "sessions": [],
960
+ }
961
+
962
+
963
+ def _advance_session_state(session):
964
+ state_path = _state_path(session["session_id"])
965
+ existing = _read_json(state_path, {}) or {}
966
+ existing_offset = int(existing.get("processed_offset", 0))
967
+ if existing_offset >= session["end_offset"]:
968
+ return existing_offset
969
+ _atomic_json(state_path, {
970
+ "schema_version": SCHEMA_VERSION,
971
+ "session_id": session["session_id"],
972
+ "path": session["path"],
973
+ "processed_offset": session["end_offset"],
974
+ "raw_size": session["size"],
975
+ "sha256": hashlib.sha256(Path(session["path"]).read_bytes()).hexdigest(),
976
+ "session_usage": session["session_usage"],
977
+ "internal_marker": session["internal_marker"],
978
+ "repo_key": session["target"]["repo_key"],
979
+ "branch": session["target"]["branch"],
980
+ "updated_at": now_iso(),
981
+ })
982
+ return session["end_offset"]
983
+
984
+
985
+ def _execute_sessions(config, args, sessions, run_id, started, *, activity=None, run_kind="scan", replay_source=None):
716
986
  candidates = [item for item in sessions if item["status"] == "candidate" and item["messages"]]
717
987
  executor_name = None
718
988
  preset = None
719
989
  if candidates and not args.dry_run:
720
- executor_name, preset = choose_executor(config)
990
+ executor_override = getattr(args, "executor", None)
991
+ executor_config = {**config, "executor": executor_override} if executor_override else config
992
+ executor_name, preset = choose_executor(executor_config)
993
+ preset = dict(preset)
994
+ preset["_timeout_seconds"] = int(config.get("invocation_timeout_seconds", 360))
721
995
  invocations = []
722
996
  results = []
723
997
  for session in sessions:
@@ -741,6 +1015,8 @@ def run_scan(args):
741
1015
  "reason": session["reason"],
742
1016
  "last_scanned_at": now_iso(),
743
1017
  }
1018
+ if session.get("replay_source"):
1019
+ audit["replay_source"] = session["replay_source"]
744
1020
  if session not in candidates:
745
1021
  if audit["status"] == "candidate":
746
1022
  audit["status"] = "skipped"
@@ -766,49 +1042,75 @@ def run_scan(args):
766
1042
  results.append(audit)
767
1043
  _atomic_json(_session_audit_path(session["session_id"]), audit)
768
1044
  continue
769
- partials = []
770
1045
  failed = None
771
1046
  invocation_start = len(invocations)
772
- for index, chunk in enumerate(chunks, 1):
773
- payload, attempt_records = run_executor_with_retries(
774
- executor_name, preset, _partial_prompt(session["target"], chunk, index, len(chunks)),
775
- session["target"]["repo_root"], run_id, f"{session['session_id']}:chunk:{index}",
776
- int(config.get("max_attempts", 2)),
777
- )
778
- invocations.extend(attempt_records)
779
- if payload is None:
780
- failed = attempt_records[-1].get("error", "chunk summary failed")
781
- break
782
- partials.append(payload)
783
- if not failed:
1047
+ final_payload = None
1048
+ if len(chunks) == 1:
784
1049
  final_payload, attempt_records = run_executor_with_retries(
785
- executor_name, preset, _final_prompt(session["target"], partials),
1050
+ executor_name, preset, _single_prompt(session["target"], chunks[0]),
786
1051
  session["target"]["repo_root"], run_id, f"{session['session_id']}:final",
787
1052
  int(config.get("max_attempts", 2)),
788
1053
  )
789
1054
  invocations.extend(attempt_records)
790
1055
  if final_payload is None:
791
1056
  failed = attempt_records[-1].get("error", "final summary failed")
1057
+ else:
1058
+ partials = []
1059
+ for index, chunk in enumerate(chunks, 1):
1060
+ payload, attempt_records = run_executor_with_retries(
1061
+ executor_name, preset, _partial_prompt(session["target"], chunk, index, len(chunks)),
1062
+ session["target"]["repo_root"], run_id, f"{session['session_id']}:chunk:{index}",
1063
+ int(config.get("max_attempts", 2)),
1064
+ )
1065
+ invocations.extend(attempt_records)
1066
+ if payload is None:
1067
+ failed = attempt_records[-1].get("error", "chunk summary failed")
1068
+ break
1069
+ partials.append(payload)
1070
+ if not failed:
1071
+ final_payload, attempt_records = run_executor_with_retries(
1072
+ executor_name, preset, _final_prompt(session["target"], partials),
1073
+ session["target"]["repo_root"], run_id, f"{session['session_id']}:final",
1074
+ int(config.get("max_attempts", 2)),
1075
+ )
1076
+ invocations.extend(attempt_records)
1077
+ if final_payload is None:
1078
+ failed = attempt_records[-1].get("error", "final summary failed")
792
1079
  if not failed:
1080
+ summary_output = _summary_payload_meta(final_payload)
1081
+ validation_errors = _summary_payload_validation_errors(final_payload)
1082
+ if validation_errors:
1083
+ summary_output["validation_errors"] = validation_errors
1084
+ audit["summary_output"] = summary_output
1085
+ if validation_errors:
1086
+ failed = "invalid final summary output: " + "; ".join(validation_errors)
1087
+ elif not summary_output["mutation_count"] and not summary_output["skip_reason"]:
1088
+ failed = "final summary output has no memory mutations or skip_reason"
1089
+ if not failed and audit["summary_output"]["mutation_count"]:
793
1090
  try:
794
1091
  audit["apply_result"] = _apply_summary(session["target"], final_payload)
795
- audit["status"] = "done"
796
- audit["cursor_after"] = session["end_offset"]
797
- _atomic_json(_state_path(session["session_id"]), {
798
- "schema_version": SCHEMA_VERSION,
799
- "session_id": session["session_id"],
800
- "path": session["path"],
801
- "processed_offset": session["end_offset"],
802
- "raw_size": session["size"],
803
- "sha256": hashlib.sha256(Path(session["path"]).read_bytes()).hexdigest(),
804
- "session_usage": session["session_usage"],
805
- "internal_marker": session["internal_marker"],
806
- "repo_key": session["target"]["repo_key"],
807
- "branch": session["target"]["branch"],
808
- "updated_at": now_iso(),
809
- })
1092
+ audit["semantic_action_count"] = _semantic_action_count(audit["apply_result"])
1093
+ if not audit["semantic_action_count"]:
1094
+ if audit["summary_output"]["skip_reason"]:
1095
+ audit["status"] = "skipped_summary"
1096
+ else:
1097
+ failed = "summary output produced no semantic memory actions or skip_reason"
1098
+ else:
1099
+ audit["status"] = "done"
810
1100
  except Exception as exc:
811
1101
  failed = str(exc)
1102
+ elif not failed:
1103
+ audit["status"] = "skipped_summary"
1104
+ audit["semantic_action_count"] = 0
1105
+ audit["apply_result"] = {
1106
+ "mode": "not-applied",
1107
+ "touched_targets": [],
1108
+ "actions": [],
1109
+ "skip_reason": audit["summary_output"]["skip_reason"],
1110
+ }
1111
+ if not failed and audit["status"] in {"done", "skipped_summary"}:
1112
+ audit["cursor_after"] = session["end_offset"]
1113
+ audit["state_offset_after"] = _advance_session_state(session)
812
1114
  if failed:
813
1115
  audit["status"] = "failed"
814
1116
  audit["error"] = failed
@@ -821,20 +1123,28 @@ def run_scan(args):
821
1123
  run = {
822
1124
  "schema_version": SCHEMA_VERSION,
823
1125
  "run_id": run_id,
1126
+ "run_kind": run_kind,
824
1127
  "started_at": dt.datetime.fromtimestamp(started, dt.timezone.utc).isoformat(),
825
1128
  "finished_at": now_iso(),
826
1129
  "duration_ms": int((time.time() - started) * 1000),
827
1130
  "dry_run": bool(args.dry_run),
1131
+ "scheduled": bool(getattr(args, "scheduled", False)),
1132
+ "activity": activity,
828
1133
  "executor": executor_name,
829
1134
  "model": preset.get("model") if preset else None,
830
1135
  "profile": preset.get("profile") if preset else None,
831
1136
  "session_count": len(results),
832
1137
  "candidate_count": len(candidates),
833
1138
  "done_count": sum(item["status"] == "done" for item in results),
1139
+ "summary_skipped_count": sum(item["status"] == "skipped_summary" for item in results),
834
1140
  "failed_count": sum(item["status"] == "failed" for item in results),
835
- "skipped_count": sum(item["status"] == "skipped" for item in results),
1141
+ "skipped_count": sum(item["status"] in {"skipped", "skipped_summary"} for item in results),
1142
+ "discovery_skipped_count": sum(item["status"] == "skipped" for item in results),
836
1143
  "raw_bytes": sum(item["raw_size"] for item in results),
837
1144
  "new_bytes": sum(item["new_bytes"] for item in results),
1145
+ "observed_new_bytes": sum(item["new_bytes"] for item in results),
1146
+ "eligible_new_bytes": sum(item["new_bytes"] for item in results if item["status"] != "skipped"),
1147
+ "skipped_new_bytes": sum(item["new_bytes"] for item in results if item["status"] == "skipped"),
838
1148
  "semantic_messages": sum(item["semantic_messages"] for item in results),
839
1149
  "semantic_chars": sum(item["semantic_chars"] for item in results),
840
1150
  "summary_usage": usage,
@@ -842,24 +1152,57 @@ def run_scan(args):
842
1152
  "invocations": invocations,
843
1153
  "sessions": results,
844
1154
  }
845
- _atomic_json(SCAN_ROOT / "runs" / f"{run_id}.json", run)
846
- _atomic_json(SCAN_ROOT / "last-run.json", run)
847
- _append_jsonl(SCAN_ROOT / "events.jsonl", {
848
- "at": now_iso(), "event": "scan_completed", "run_id": run_id,
849
- "done": run["done_count"], "failed": run["failed_count"], "skipped": run["skipped_count"],
850
- })
1155
+ if replay_source:
1156
+ run["replay_source"] = replay_source
1157
+ _persist_scan_run(run, "scan_completed")
851
1158
  print(json.dumps(run, ensure_ascii=False, indent=2) if args.json else _format_run(run))
852
1159
  return 1 if run["failed_count"] else 0
853
1160
 
854
1161
 
1162
+ def run_scan(args):
1163
+ config = load_config()
1164
+ validation = validate_config(config)
1165
+ if not validation["valid"]:
1166
+ raise RuntimeError("; ".join(validation["errors"]))
1167
+ run_id = dt.datetime.now().strftime("%Y%m%dT%H%M%S") + f"-{os.getpid()}"
1168
+ started = time.time()
1169
+ activity = None
1170
+ if getattr(args, "scheduled", False) and config.get("skip_when_computer_active", True):
1171
+ activity = computer_activity(config)
1172
+ if activity["skip"]:
1173
+ run = _skipped_activity_run(run_id, started, activity, dry_run=args.dry_run)
1174
+ _persist_scan_run(run, run["status"])
1175
+ print(json.dumps(run, ensure_ascii=False, indent=2) if args.json else _format_run(run))
1176
+ return 0
1177
+ origin_path = SCAN_ROOT / "scan-origin.json"
1178
+ if not origin_path.exists():
1179
+ lookback_days = int(config.get("first_lookback_days", 3))
1180
+ _atomic_json(origin_path, {
1181
+ "created_at": now_iso(),
1182
+ "initial_cutoff_epoch": time.time() - lookback_days * 86400,
1183
+ "first_lookback_days": lookback_days,
1184
+ })
1185
+ sessions = discover(config, args.since)
1186
+ return _execute_sessions(config, args, sessions, run_id, started, activity=activity)
1187
+
1188
+
855
1189
  def _format_tokens(usage):
856
1190
  return str((usage or {}).get("total_tokens", "unavailable"))
857
1191
 
858
1192
 
859
1193
  def _format_run(run):
1194
+ if str(run.get("status", "")).startswith("skipped_"):
1195
+ activity = run.get("activity") or {}
1196
+ return (
1197
+ f"run {run['run_id']}: {run['status']}; "
1198
+ f"idle_seconds={activity.get('idle_seconds')} threshold={activity.get('threshold_seconds')}"
1199
+ )
860
1200
  return (
861
- f"run {run['run_id']}: {run['done_count']} done, {run['failed_count']} failed, "
862
- f"{run['skipped_count']} skipped; {run['new_bytes']} new bytes; "
1201
+ f"run {run['run_id']}: {run['done_count']} done, "
1202
+ f"{run.get('summary_skipped_count', 0)} summary-skipped, {run['failed_count']} failed, "
1203
+ f"{run.get('discovery_skipped_count', run['skipped_count'])} discovery-skipped; "
1204
+ f"{run.get('eligible_new_bytes', run['new_bytes'])} eligible / "
1205
+ f"{run.get('observed_new_bytes', run['new_bytes'])} observed new bytes; "
863
1206
  f"summary tokens {_format_tokens(run.get('summary_usage'))}"
864
1207
  )
865
1208
 
@@ -868,6 +1211,21 @@ def _runs():
868
1211
  return [value for path in sorted((SCAN_ROOT / "runs").glob("*.json"), reverse=True) if isinstance((value := _read_json(path)), dict)]
869
1212
 
870
1213
 
1214
+ def _covered_bytes(intervals):
1215
+ total = 0
1216
+ end = None
1217
+ for start, stop in sorted(intervals):
1218
+ if stop <= start:
1219
+ continue
1220
+ if end is None or start > end:
1221
+ total += stop - start
1222
+ end = stop
1223
+ elif stop > end:
1224
+ total += stop - end
1225
+ end = stop
1226
+ return total
1227
+
1228
+
871
1229
  def command_stats(args):
872
1230
  runs = _runs()
873
1231
  if args.since:
@@ -885,15 +1243,49 @@ def command_stats(args):
885
1243
  key = session.get("repo_key")
886
1244
  if not key or (args.repo and key != args.repo):
887
1245
  continue
888
- item = repos.setdefault(key, {"repo_key": key, "scan_count": 0, "sessions": set(), "raw_bytes": 0, "new_bytes": 0, "summary_tokens": 0})
1246
+ item = repos.setdefault(key, {
1247
+ "repo_key": key,
1248
+ "scan_count": 0,
1249
+ "sessions": set(),
1250
+ "intervals": {},
1251
+ "raw_bytes": 0,
1252
+ "new_bytes": 0,
1253
+ "observed_new_bytes": 0,
1254
+ "eligible_new_bytes": 0,
1255
+ "skipped_new_bytes": 0,
1256
+ "summary_tokens": 0,
1257
+ "done_count": 0,
1258
+ "summary_skipped_count": 0,
1259
+ "failed_count": 0,
1260
+ "empty_apply_count": 0,
1261
+ })
889
1262
  item["scan_count"] += 1
890
- item["sessions"].add(session.get("session_id"))
1263
+ session_id = session.get("session_id")
1264
+ item["sessions"].add(session_id)
891
1265
  item["raw_bytes"] += int(session.get("raw_size", 0))
892
- item["new_bytes"] += int(session.get("new_bytes", 0))
1266
+ observed = int(session.get("new_bytes", 0))
1267
+ item["new_bytes"] += observed
1268
+ item["observed_new_bytes"] += observed
1269
+ if session.get("status") == "skipped":
1270
+ item["skipped_new_bytes"] += observed
1271
+ else:
1272
+ item["eligible_new_bytes"] += observed
1273
+ start = int(session.get("cursor_before", 0))
1274
+ stop = min(int(session.get("raw_size", start + observed)), start + observed)
1275
+ item["intervals"].setdefault(session_id, []).append((start, stop))
893
1276
  item["summary_tokens"] += int((session.get("summary_usage") or {}).get("total_tokens", 0))
1277
+ status = session.get("status")
1278
+ item["done_count"] += int(status == "done")
1279
+ item["summary_skipped_count"] += int(status == "skipped_summary")
1280
+ item["failed_count"] += int(status == "failed")
1281
+ item["empty_apply_count"] += int(
1282
+ status == "done"
1283
+ and not (session.get("apply_result") or {}).get("touched_targets")
1284
+ )
894
1285
  repo_rows = []
895
1286
  for item in repos.values():
896
1287
  item["session_count"] = len(item.pop("sessions"))
1288
+ item["unique_new_bytes"] = sum(_covered_bytes(value) for value in item.pop("intervals").values())
897
1289
  repo_rows.append(item)
898
1290
  payload = {"run_count": len(runs), "summary_usage": total_usage or None, "usage_unavailable_invocations": unavailable, "repos": sorted(repo_rows, key=lambda x: x["repo_key"])}
899
1291
  print(json.dumps(payload, ensure_ascii=False, indent=2))
@@ -915,6 +1307,81 @@ def command_show(args):
915
1307
  print(json.dumps(value, ensure_ascii=False, indent=2))
916
1308
 
917
1309
 
1310
+ def _replay_session(source_run_id, audit):
1311
+ session_id = audit.get("session_id")
1312
+ path = Path(audit.get("path") or "").expanduser()
1313
+ if not path.is_file():
1314
+ matches = [
1315
+ candidate
1316
+ for root in (CODEX_HOME / "sessions", CODEX_HOME / "archived_sessions")
1317
+ if root.exists()
1318
+ for candidate in root.rglob(f"*{session_id}*.jsonl")
1319
+ ]
1320
+ if not matches:
1321
+ raise RuntimeError(f"session transcript not found: {session_id}")
1322
+ path = max(matches, key=lambda item: (item.stat().st_size, item.stat().st_mtime))
1323
+ cursor_before = int(audit.get("cursor_before", 0))
1324
+ cursor_after = int(audit.get("cursor_after") or audit.get("raw_size") or 0)
1325
+ parsed = parse_codex_session(path, cursor_before)
1326
+ if cursor_after <= cursor_before or cursor_after > parsed["size"]:
1327
+ raise RuntimeError(
1328
+ f"invalid replay cursor for {session_id}: {cursor_before}..{cursor_after}, size={parsed['size']}"
1329
+ )
1330
+ parsed["messages"] = [
1331
+ item for item in parsed["messages"]
1332
+ if item["start_offset"] >= cursor_before and item["end_offset"] <= cursor_after
1333
+ ]
1334
+ parsed["size"] = cursor_after
1335
+ parsed["end_offset"] = cursor_after
1336
+ parsed["session_usage"] = audit.get("session_usage") or parsed.get("session_usage")
1337
+ meta = dict(parsed.get("meta") or {})
1338
+ if audit.get("cwd"):
1339
+ meta["cwd"] = audit["cwd"]
1340
+ parsed["meta"] = meta
1341
+ target, target_error = resolve_target(meta.get("cwd"))
1342
+ return {
1343
+ **parsed,
1344
+ "session_id": session_id,
1345
+ "cursor_before": cursor_before,
1346
+ "new_bytes": cursor_after - cursor_before,
1347
+ "target": target,
1348
+ "status": "candidate" if not target_error else "skipped",
1349
+ "reason": target_error,
1350
+ "replay_source": {
1351
+ "run_id": source_run_id,
1352
+ "previous_status": audit.get("status"),
1353
+ "previous_apply_empty": not bool((audit.get("apply_result") or {}).get("touched_targets")),
1354
+ },
1355
+ }
1356
+
1357
+
1358
+ def command_replay(args):
1359
+ config = load_config()
1360
+ validation = validate_config(config)
1361
+ if not validation["valid"]:
1362
+ raise RuntimeError("; ".join(validation["errors"]))
1363
+ source_path = SCAN_ROOT / "runs" / f"{args.run_id}.json"
1364
+ source_run = _read_json(source_path)
1365
+ if not isinstance(source_run, dict):
1366
+ raise RuntimeError(f"run not found: {args.run_id}")
1367
+ requested = list(dict.fromkeys(args.session_id))
1368
+ audits = {item.get("session_id"): item for item in source_run.get("sessions", [])}
1369
+ missing = [session_id for session_id in requested if session_id not in audits]
1370
+ if missing:
1371
+ raise RuntimeError(f"sessions not found in run {args.run_id}: {', '.join(missing)}")
1372
+ sessions = [_replay_session(args.run_id, audits[session_id]) for session_id in requested]
1373
+ run_id = dt.datetime.now().strftime("%Y%m%dT%H%M%S") + f"-replay-{os.getpid()}"
1374
+ return _execute_sessions(
1375
+ config,
1376
+ args,
1377
+ sessions,
1378
+ run_id,
1379
+ time.time(),
1380
+ run_kind="replay",
1381
+ replay_source={"run_id": args.run_id, "session_ids": requested},
1382
+ )
1383
+
1384
+
918
1385
  def _cli_path():
919
1386
  explicit = os.environ.get("DEV_MEMORY_CLI_PATH", "").strip()
920
1387
  if explicit:
@@ -930,8 +1397,8 @@ def command_install(_args):
930
1397
  logs.mkdir(parents=True, exist_ok=True)
931
1398
  plist = {
932
1399
  "Label": "com.dev-memory.session-scan",
933
- "ProgramArguments": [_cli_path(), "session-scan", "run"],
934
- "StartCalendarInterval": {"Hour": 3, "Minute": 0},
1400
+ "ProgramArguments": [_cli_path(), "session-scan", "run", "--scheduled"],
1401
+ "StartCalendarInterval": _calendar_intervals(config),
935
1402
  "RunAtLoad": False,
936
1403
  "StandardOutPath": str(logs / "launchd.stdout.log"),
937
1404
  "StandardErrorPath": str(logs / "launchd.stderr.log"),
@@ -944,7 +1411,14 @@ def command_install(_args):
944
1411
  result = subprocess.run(["launchctl", "load", str(PLIST_PATH)], capture_output=True, text=True, check=False)
945
1412
  if result.returncode != 0:
946
1413
  raise RuntimeError(result.stderr.strip() or "launchctl load failed")
947
- print(json.dumps({"installed": True, "plist": str(PLIST_PATH), "schedule": "03:00", "logs": str(logs)}, ensure_ascii=False, indent=2))
1414
+ print(json.dumps({
1415
+ "installed": True,
1416
+ "plist": str(PLIST_PATH),
1417
+ "schedule_times": config["schedule_times"],
1418
+ "skip_when_computer_active": config["skip_when_computer_active"],
1419
+ "active_within_minutes": config["active_within_minutes"],
1420
+ "logs": str(logs),
1421
+ }, ensure_ascii=False, indent=2))
948
1422
 
949
1423
 
950
1424
  def command_uninstall(_args):
@@ -967,6 +1441,9 @@ def command_status(_args):
967
1441
  "codex_sessions": str(CODEX_HOME / "sessions"),
968
1442
  "executor": executor,
969
1443
  "model": preset.get("model") if preset else None,
1444
+ "schedule_times": config.get("schedule_times"),
1445
+ "skip_when_computer_active": config.get("skip_when_computer_active"),
1446
+ "active_within_minutes": config.get("active_within_minutes"),
970
1447
  "config": validation,
971
1448
  "last_run": _read_json(SCAN_ROOT / "last-run.json"),
972
1449
  }, ensure_ascii=False, indent=2))
@@ -992,7 +1469,24 @@ def command_config(args):
992
1469
  raise RuntimeError(f"unknown executor preset: {args.executor}")
993
1470
  field = "model" if args.config_command == "set-model" else "profile"
994
1471
  config["executors"][args.executor][field] = args.value
1472
+ elif args.config_command == "set-schedule":
1473
+ for value in args.times:
1474
+ _parse_schedule_time(value)
1475
+ config["schedule_times"] = args.times
1476
+ elif args.config_command == "set-active-minutes":
1477
+ if args.minutes < 1:
1478
+ raise RuntimeError("active minutes must be at least 1")
1479
+ config["active_within_minutes"] = args.minutes
1480
+ elif args.config_command == "set-timeout":
1481
+ if args.seconds < 30:
1482
+ raise RuntimeError("timeout seconds must be at least 30")
1483
+ config["invocation_timeout_seconds"] = args.seconds
1484
+ elif args.config_command == "set-active-check":
1485
+ config["skip_when_computer_active"] = args.enabled == "on"
995
1486
  save_scan_config(config)
1487
+ if args.config_command == "set-schedule" and PLIST_PATH.exists():
1488
+ command_install(args)
1489
+ return
996
1490
  print(json.dumps(config, ensure_ascii=False, indent=2))
997
1491
 
998
1492
 
@@ -1003,6 +1497,8 @@ def build_parser():
1003
1497
  run.add_argument("--since")
1004
1498
  run.add_argument("--dry-run", action="store_true")
1005
1499
  run.add_argument("--json", action="store_true")
1500
+ run.add_argument("--scheduled", action="store_true", help="Apply computer activity guard before scanning")
1501
+ run.add_argument("--executor", help="Override the configured executor for this run only")
1006
1502
  sub.add_parser("install")
1007
1503
  sub.add_parser("status")
1008
1504
  stats = sub.add_parser("stats")
@@ -1015,6 +1511,12 @@ def build_parser():
1015
1511
  history.add_argument("--json", action="store_true")
1016
1512
  show = sub.add_parser("show")
1017
1513
  show.add_argument("run_id")
1514
+ replay = sub.add_parser("replay")
1515
+ replay.add_argument("--run-id", required=True)
1516
+ replay.add_argument("--session-id", action="append", required=True)
1517
+ replay.add_argument("--dry-run", action="store_true")
1518
+ replay.add_argument("--json", action="store_true")
1519
+ replay.add_argument("--executor", help="Override the configured executor for this replay only")
1018
1520
  sub.add_parser("uninstall")
1019
1521
  config = sub.add_parser("config")
1020
1522
  config_sub = config.add_subparsers(dest="config_command", required=True)
@@ -1026,6 +1528,14 @@ def build_parser():
1026
1528
  item = config_sub.add_parser(command)
1027
1529
  item.add_argument("executor")
1028
1530
  item.add_argument("value")
1531
+ set_schedule = config_sub.add_parser("set-schedule")
1532
+ set_schedule.add_argument("times", nargs="+")
1533
+ set_active_minutes = config_sub.add_parser("set-active-minutes")
1534
+ set_active_minutes.add_argument("minutes", type=int)
1535
+ set_timeout = config_sub.add_parser("set-timeout")
1536
+ set_timeout.add_argument("seconds", type=int)
1537
+ set_active_check = config_sub.add_parser("set-active-check")
1538
+ set_active_check.add_argument("enabled", choices=("on", "off"))
1029
1539
  return parser
1030
1540
 
1031
1541
 
@@ -1038,6 +1548,7 @@ def main():
1038
1548
  "stats": command_stats,
1039
1549
  "history": command_history,
1040
1550
  "show": command_show,
1551
+ "replay": command_replay,
1041
1552
  "uninstall": command_uninstall,
1042
1553
  "config": command_config,
1043
1554
  }
package/lib/ui-app.html CHANGED
@@ -743,7 +743,7 @@ function renderScan() {
743
743
  el("td", { class: "num" }, run.session_count),
744
744
  el("td", { class: "num" }, formatBytes(run.new_bytes)),
745
745
  el("td", { class: "num" }, usage == null ? "未上报" : Number(usage).toLocaleString()),
746
- el("td", null, run.done_count + " 完成 · " + run.failed_count + " 失败 · " + run.skipped_count + " 跳过")
746
+ el("td", null, run.status ? run.status : (run.done_count + " 完成 · " + run.failed_count + " 失败 · " + run.skipped_count + " 跳过"))
747
747
  ));
748
748
  });
749
749
  runTable.appendChild(runBody);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dev-memory-cli",
3
- "version": "0.22.2",
3
+ "version": "0.23.1",
4
4
  "description": "CLI for dev-memory hooks and repo-local setup (formerly @xluos/dev-assets-cli)",
5
5
  "license": "MIT",
6
6
  "repository": {