dev-memory-cli 0.22.2 → 0.23.0

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.
@@ -29,6 +29,37 @@ 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
+ MAINTENANCE_MARKER = "DEV_MEMORY_INTERNAL_MAINTENANCE_AGENT_V1"
33
+ INTERNAL_MARKERS = (INTERNAL_MARKER, MAINTENANCE_MARKER)
34
+ SUMMARY_MUTATION_FIELDS = (
35
+ "file_map",
36
+ "decisions",
37
+ "risks",
38
+ "glossary",
39
+ "shared_decisions",
40
+ "shared_context",
41
+ "shared_sources",
42
+ "upserts",
43
+ "appends",
44
+ "rewrites",
45
+ "deletes",
46
+ )
47
+ SUMMARY_PLACEHOLDER_WORDS = {
48
+ "decision",
49
+ "summary",
50
+ "reason",
51
+ "impact",
52
+ "risk",
53
+ "mitigation",
54
+ "term",
55
+ "definition",
56
+ "name",
57
+ "url",
58
+ "note",
59
+ "label",
60
+ "path",
61
+ "content",
62
+ }
32
63
 
33
64
 
34
65
  DEFAULT_EXECUTORS = {
@@ -97,10 +128,15 @@ def default_scan_config():
97
128
  "executor": "auto",
98
129
  "order": ["coco", "codex", "claude"],
99
130
  "executors": json.loads(json.dumps(DEFAULT_EXECUTORS)),
131
+ "schedule_times": ["03:00", "13:00"],
132
+ "skip_when_computer_active": True,
133
+ "active_within_minutes": 10,
134
+ "activity_check_fail_closed": True,
100
135
  "chunk_chars": 60000,
101
136
  "idle_minutes": 60,
102
137
  "first_lookback_days": 3,
103
138
  "max_attempts": 2,
139
+ "invocation_timeout_seconds": 360,
104
140
  }
105
141
 
106
142
 
@@ -145,9 +181,78 @@ def validate_config(config):
145
181
  errors.append("chunk_chars must be at least 1000")
146
182
  except (TypeError, ValueError):
147
183
  errors.append("chunk_chars must be an integer")
184
+ schedules = config.get("schedule_times")
185
+ if not isinstance(schedules, list) or not schedules:
186
+ errors.append("schedule_times must be a non-empty list")
187
+ else:
188
+ for value in schedules:
189
+ try:
190
+ _parse_schedule_time(value)
191
+ except ValueError as exc:
192
+ errors.append(str(exc))
193
+ try:
194
+ if int(config.get("active_within_minutes", 0)) < 1:
195
+ errors.append("active_within_minutes must be at least 1")
196
+ except (TypeError, ValueError):
197
+ errors.append("active_within_minutes must be an integer")
198
+ try:
199
+ if int(config.get("invocation_timeout_seconds", 0)) < 30:
200
+ errors.append("invocation_timeout_seconds must be at least 30")
201
+ except (TypeError, ValueError):
202
+ errors.append("invocation_timeout_seconds must be an integer")
148
203
  return {"valid": not errors, "errors": errors, "warnings": warnings}
149
204
 
150
205
 
206
+ def _parse_schedule_time(value):
207
+ match = re.fullmatch(r"(\d{1,2}):(\d{2})", str(value or "").strip())
208
+ if not match:
209
+ raise ValueError(f"invalid schedule time '{value}', expected HH:MM")
210
+ hour, minute = int(match.group(1)), int(match.group(2))
211
+ if not 0 <= hour <= 23 or not 0 <= minute <= 59:
212
+ raise ValueError(f"invalid schedule time '{value}', expected HH:MM")
213
+ return hour, minute
214
+
215
+
216
+ def _calendar_intervals(config):
217
+ unique = sorted({_parse_schedule_time(value) for value in config.get("schedule_times", [])})
218
+ return [{"Hour": hour, "Minute": minute} for hour, minute in unique]
219
+
220
+
221
+ def _mac_idle_seconds():
222
+ if sys.platform != "darwin":
223
+ return None
224
+ result = subprocess.run(
225
+ ["ioreg", "-c", "IOHIDSystem", "-d", "4"],
226
+ capture_output=True,
227
+ text=True,
228
+ check=False,
229
+ )
230
+ if result.returncode != 0:
231
+ return None
232
+ match = re.search(r'"HIDIdleTime"\s*=\s*(\d+)', result.stdout or "")
233
+ if not match:
234
+ return None
235
+ return int(match.group(1)) / 1_000_000_000
236
+
237
+
238
+ def computer_activity(config):
239
+ threshold_seconds = int(config.get("active_within_minutes", 10)) * 60
240
+ idle_seconds = _mac_idle_seconds()
241
+ if idle_seconds is None:
242
+ return {
243
+ "status": "unknown",
244
+ "idle_seconds": None,
245
+ "threshold_seconds": threshold_seconds,
246
+ "skip": bool(config.get("activity_check_fail_closed", True)),
247
+ }
248
+ return {
249
+ "status": "active" if idle_seconds < threshold_seconds else "idle",
250
+ "idle_seconds": round(idle_seconds, 3),
251
+ "threshold_seconds": threshold_seconds,
252
+ "skip": idle_seconds < threshold_seconds,
253
+ }
254
+
255
+
151
256
  def choose_executor(config):
152
257
  executors = config.get("executors", {})
153
258
  selected = config.get("executor", "auto")
@@ -257,7 +362,7 @@ def parse_codex_session(path, since_offset=0):
257
362
  if usage:
258
363
  total_usage = usage
259
364
  message = _semantic_message(obj)
260
- if message and INTERNAL_MARKER in message["text"]:
365
+ if message and any(marker in message["text"] for marker in INTERNAL_MARKERS):
261
366
  internal_marker = True
262
367
  if message and end > since_offset:
263
368
  message.update({"start_offset": start, "end_offset": end})
@@ -418,17 +523,107 @@ MESSAGES_JSON:
418
523
  """
419
524
 
420
525
 
526
+ def _single_prompt(target, chunk):
527
+ payload = {
528
+ "existing_memory": _existing_memory(target),
529
+ "messages": [{"role": item["role"], "text": item["text"]} for item in chunk],
530
+ }
531
+ return f"""{INTERNAL_MARKER}
532
+ 你是 dev-memory 的后台会话总结器。阅读完整会话与 existing_memory,直接生成最终 summary-output,不要先输出中间摘要。
533
+ 只保留未来开发会话中仍有价值、且 existing_memory 尚未覆盖的决策、约束、风险、术语、命令、外部入口和功能文件定位;不要记录聊天流水账、普通进展或可从 Git 直接恢复的历史。
534
+ 旧结论失效时使用 rewrites/deletes,不要追加矛盾条目。如果没有任何需要写入、改写或删除的内容,必须返回非空 skip_reason;禁止只返回 title 或空对象。
535
+ 字段 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。
536
+ 所有值必须来自会话事实并可独立理解。禁止输出 decision/summary/reason/impact/risk/mitigation/term/definition/name/url/note/path/content 等 schema 占位词,禁止留空 summary。
537
+ 只输出一个 JSON 对象,不要 markdown fence。允许字段:title、file_map、decisions、risks、glossary、shared_decisions、shared_context、shared_sources、upserts、appends、rewrites、deletes、skip_reason。
538
+ INPUT_JSON:
539
+ {json.dumps(payload, ensure_ascii=False)}
540
+ """
541
+
542
+
421
543
  def _final_prompt(target, partials):
422
544
  payload = {"existing_memory": _existing_memory(target), "partial_summaries": partials}
423
545
  return f"""{INTERNAL_MARKER}
424
546
  你是 dev-memory 的后台会话总结器。把所有 partial_summaries 与 existing_memory 合并为一个最终 summary-output。
425
547
  旧结论失效时使用 rewrites/deletes,不要追加矛盾条目;重复内容跳过。不要写当前进展、下一步或提交历史。
548
+ 如果没有任何需要写入、改写或删除的内容,必须返回非空 skip_reason;禁止只返回 title 或空对象。
549
+ decisions/shared_decisions 每项必须有非空 summary;risks/glossary/shared_context/shared_sources 必须是完整自然语言字符串;file_map 每项必须有 label 和真实 paths。禁止输出 schema 占位词或空 summary。
426
550
  只输出一个 JSON 对象,不要 markdown fence。允许字段:title、file_map、decisions、risks、glossary、shared_decisions、shared_context、shared_sources、upserts、appends、rewrites、deletes、skip_reason。
427
551
  INPUT_JSON:
428
552
  {json.dumps(payload, ensure_ascii=False)}
429
553
  """
430
554
 
431
555
 
556
+ def _summary_payload_meta(payload):
557
+ payload = payload if isinstance(payload, dict) else {}
558
+ serialized = json.dumps(payload, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
559
+ field_counts = {}
560
+ for key in SUMMARY_MUTATION_FIELDS:
561
+ value = payload.get(key)
562
+ if isinstance(value, list):
563
+ field_counts[key] = len(value)
564
+ elif value:
565
+ field_counts[key] = 1
566
+ skip_reason = payload.get("skip_reason")
567
+ skip_reason = skip_reason.strip() if isinstance(skip_reason, str) else None
568
+ return {
569
+ "keys": sorted(payload),
570
+ "field_counts": field_counts,
571
+ "mutation_count": sum(field_counts.values()),
572
+ "skip_reason": skip_reason or None,
573
+ "chars": len(serialized),
574
+ "sha256": hashlib.sha256(serialized.encode("utf-8")).hexdigest(),
575
+ }
576
+
577
+
578
+ def _looks_like_placeholder_text(value):
579
+ if not isinstance(value, str):
580
+ return False
581
+ words = []
582
+ for line in value.splitlines():
583
+ normalized = re.sub(r"^[\s>*+-]+", "", line).strip().strip("::").lower()
584
+ if normalized:
585
+ words.append(normalized)
586
+ return bool(words) and all(word in SUMMARY_PLACEHOLDER_WORDS for word in words)
587
+
588
+
589
+ def _summary_payload_validation_errors(payload):
590
+ errors = []
591
+ if not isinstance(payload, dict):
592
+ return ["summary output must be an object"]
593
+ for field in ("decisions", "shared_decisions"):
594
+ for index, item in enumerate(payload.get(field) or []):
595
+ if isinstance(item, str):
596
+ summary = item.strip()
597
+ elif isinstance(item, dict):
598
+ summary = str(item.get("summary") or item.get("decision") or "").strip()
599
+ else:
600
+ summary = ""
601
+ if not summary:
602
+ errors.append(f"{field}[{index}] requires a non-empty summary")
603
+ elif _looks_like_placeholder_text(summary):
604
+ errors.append(f"{field}[{index}] contains schema placeholder text")
605
+ for field in ("risks", "glossary", "shared_context", "shared_sources"):
606
+ for index, item in enumerate(payload.get(field) or []):
607
+ if not isinstance(item, str) or not item.strip():
608
+ errors.append(f"{field}[{index}] must be a non-empty string")
609
+ elif _looks_like_placeholder_text(item):
610
+ errors.append(f"{field}[{index}] contains schema placeholder text")
611
+ for index, item in enumerate(payload.get("file_map") or []):
612
+ paths = item.get("paths") if isinstance(item, dict) else None
613
+ if not isinstance(item, dict) or not str(item.get("label") or "").strip() or not paths:
614
+ errors.append(f"file_map[{index}] requires label and paths")
615
+ return errors
616
+
617
+
618
+ def _semantic_action_count(apply_result):
619
+ actions = apply_result.get("actions") if isinstance(apply_result, dict) else []
620
+ return sum(
621
+ 1
622
+ for action in (actions or [])
623
+ if isinstance(action, dict) and not str(action.get("op") or "").startswith("prune-")
624
+ )
625
+
626
+
432
627
  def _executor_args(name, preset, prompt):
433
628
  command = shlex.split(preset.get("command", name))
434
629
  model = preset.get("model")
@@ -547,22 +742,46 @@ def run_executor(name, preset, prompt, cwd, run_id, invocation):
547
742
  env = dict(os.environ)
548
743
  env.update({str(k): str(v) for k, v in (preset.get("env") or {}).items()})
549
744
  started = time.time()
550
- result = subprocess.run(args, cwd=cwd, env=env, capture_output=True, text=True, check=False)
745
+ timeout_seconds = int(preset.get("_timeout_seconds", 360))
551
746
  record = {
552
747
  "invocation": invocation,
748
+ "stage": "final" if ":final" in invocation else "partial",
553
749
  "executor": name,
554
750
  "model": preset.get("model"),
555
751
  "profile": preset.get("profile"),
752
+ "prompt_chars": len(prompt),
556
753
  "started_at": dt.datetime.fromtimestamp(started, dt.timezone.utc).isoformat(),
557
- "duration_ms": int((time.time() - started) * 1000),
558
- "returncode": result.returncode,
754
+ "duration_ms": 0,
755
+ "returncode": None,
559
756
  "usage_status": "unavailable",
560
757
  }
758
+ try:
759
+ result = subprocess.run(
760
+ args,
761
+ cwd=cwd,
762
+ env=env,
763
+ capture_output=True,
764
+ text=True,
765
+ check=False,
766
+ timeout=timeout_seconds,
767
+ )
768
+ except subprocess.TimeoutExpired:
769
+ record.update({
770
+ "duration_ms": int((time.time() - started) * 1000),
771
+ "returncode": None,
772
+ "timed_out": True,
773
+ "timeout_seconds": timeout_seconds,
774
+ "error": f"executor timed out after {timeout_seconds} seconds",
775
+ })
776
+ return None, record
777
+ record["duration_ms"] = int((time.time() - started) * 1000)
778
+ record["returncode"] = result.returncode
561
779
  if result.returncode != 0:
562
780
  record["error"] = (result.stderr or result.stdout or "executor failed")[-4000:]
563
781
  return None, record
564
782
  try:
565
783
  payload, usage, session_id = _parse_executor_output(name, result.stdout, result.stderr)
784
+ record["output"] = _summary_payload_meta(payload)
566
785
  record["usage"] = usage
567
786
  record["usage_status"] = "reported" if usage else "unavailable"
568
787
  record["internal_session_id"] = session_id
@@ -590,6 +809,8 @@ def run_executor_with_retries(name, preset, prompt, cwd, run_id, invocation, max
590
809
  records.append(record)
591
810
  if payload is not None:
592
811
  break
812
+ if record.get("timed_out"):
813
+ break
593
814
  return payload, records
594
815
 
595
816
 
@@ -697,27 +918,82 @@ def discover(config, since=None):
697
918
  return sessions
698
919
 
699
920
 
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)
921
+ def _persist_scan_run(run, event):
922
+ run_id = run["run_id"]
923
+ _atomic_json(SCAN_ROOT / "runs" / f"{run_id}.json", run)
924
+ _atomic_json(SCAN_ROOT / "last-run.json", run)
925
+ _append_jsonl(SCAN_ROOT / "events.jsonl", {"at": now_iso(), "event": event, "run_id": run_id})
926
+
927
+
928
+ def _skipped_activity_run(run_id, started, activity, *, dry_run=False):
929
+ status = "skipped_active" if activity["status"] == "active" else "skipped_activity_unknown"
930
+ return {
931
+ "schema_version": SCHEMA_VERSION,
932
+ "run_id": run_id,
933
+ "status": status,
934
+ "skip_reason": activity["status"],
935
+ "started_at": dt.datetime.fromtimestamp(started, dt.timezone.utc).isoformat(),
936
+ "finished_at": now_iso(),
937
+ "duration_ms": int((time.time() - started) * 1000),
938
+ "scheduled": True,
939
+ "dry_run": bool(dry_run),
940
+ "activity": activity,
941
+ "executor": None,
942
+ "model": None,
943
+ "profile": None,
944
+ "session_count": 0,
945
+ "candidate_count": 0,
946
+ "done_count": 0,
947
+ "summary_skipped_count": 0,
948
+ "failed_count": 0,
949
+ "skipped_count": 0,
950
+ "discovery_skipped_count": 0,
951
+ "raw_bytes": 0,
952
+ "new_bytes": 0,
953
+ "observed_new_bytes": 0,
954
+ "eligible_new_bytes": 0,
955
+ "skipped_new_bytes": 0,
956
+ "semantic_messages": 0,
957
+ "semantic_chars": 0,
958
+ "summary_usage": None,
959
+ "usage_unavailable_invocations": 0,
960
+ "invocations": [],
961
+ "sessions": [],
962
+ }
963
+
964
+
965
+ def _advance_session_state(session):
966
+ state_path = _state_path(session["session_id"])
967
+ existing = _read_json(state_path, {}) or {}
968
+ existing_offset = int(existing.get("processed_offset", 0))
969
+ if existing_offset >= session["end_offset"]:
970
+ return existing_offset
971
+ _atomic_json(state_path, {
972
+ "schema_version": SCHEMA_VERSION,
973
+ "session_id": session["session_id"],
974
+ "path": session["path"],
975
+ "processed_offset": session["end_offset"],
976
+ "raw_size": session["size"],
977
+ "sha256": hashlib.sha256(Path(session["path"]).read_bytes()).hexdigest(),
978
+ "session_usage": session["session_usage"],
979
+ "internal_marker": session["internal_marker"],
980
+ "repo_key": session["target"]["repo_key"],
981
+ "branch": session["target"]["branch"],
982
+ "updated_at": now_iso(),
983
+ })
984
+ return session["end_offset"]
985
+
986
+
987
+ def _execute_sessions(config, args, sessions, run_id, started, *, activity=None, run_kind="scan", replay_source=None):
716
988
  candidates = [item for item in sessions if item["status"] == "candidate" and item["messages"]]
717
989
  executor_name = None
718
990
  preset = None
719
991
  if candidates and not args.dry_run:
720
- executor_name, preset = choose_executor(config)
992
+ executor_override = getattr(args, "executor", None)
993
+ executor_config = {**config, "executor": executor_override} if executor_override else config
994
+ executor_name, preset = choose_executor(executor_config)
995
+ preset = dict(preset)
996
+ preset["_timeout_seconds"] = int(config.get("invocation_timeout_seconds", 360))
721
997
  invocations = []
722
998
  results = []
723
999
  for session in sessions:
@@ -741,6 +1017,8 @@ def run_scan(args):
741
1017
  "reason": session["reason"],
742
1018
  "last_scanned_at": now_iso(),
743
1019
  }
1020
+ if session.get("replay_source"):
1021
+ audit["replay_source"] = session["replay_source"]
744
1022
  if session not in candidates:
745
1023
  if audit["status"] == "candidate":
746
1024
  audit["status"] = "skipped"
@@ -766,49 +1044,75 @@ def run_scan(args):
766
1044
  results.append(audit)
767
1045
  _atomic_json(_session_audit_path(session["session_id"]), audit)
768
1046
  continue
769
- partials = []
770
1047
  failed = None
771
1048
  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:
1049
+ final_payload = None
1050
+ if len(chunks) == 1:
784
1051
  final_payload, attempt_records = run_executor_with_retries(
785
- executor_name, preset, _final_prompt(session["target"], partials),
1052
+ executor_name, preset, _single_prompt(session["target"], chunks[0]),
786
1053
  session["target"]["repo_root"], run_id, f"{session['session_id']}:final",
787
1054
  int(config.get("max_attempts", 2)),
788
1055
  )
789
1056
  invocations.extend(attempt_records)
790
1057
  if final_payload is None:
791
1058
  failed = attempt_records[-1].get("error", "final summary failed")
1059
+ else:
1060
+ partials = []
1061
+ for index, chunk in enumerate(chunks, 1):
1062
+ payload, attempt_records = run_executor_with_retries(
1063
+ executor_name, preset, _partial_prompt(session["target"], chunk, index, len(chunks)),
1064
+ session["target"]["repo_root"], run_id, f"{session['session_id']}:chunk:{index}",
1065
+ int(config.get("max_attempts", 2)),
1066
+ )
1067
+ invocations.extend(attempt_records)
1068
+ if payload is None:
1069
+ failed = attempt_records[-1].get("error", "chunk summary failed")
1070
+ break
1071
+ partials.append(payload)
1072
+ if not failed:
1073
+ final_payload, attempt_records = run_executor_with_retries(
1074
+ executor_name, preset, _final_prompt(session["target"], partials),
1075
+ session["target"]["repo_root"], run_id, f"{session['session_id']}:final",
1076
+ int(config.get("max_attempts", 2)),
1077
+ )
1078
+ invocations.extend(attempt_records)
1079
+ if final_payload is None:
1080
+ failed = attempt_records[-1].get("error", "final summary failed")
792
1081
  if not failed:
1082
+ summary_output = _summary_payload_meta(final_payload)
1083
+ validation_errors = _summary_payload_validation_errors(final_payload)
1084
+ if validation_errors:
1085
+ summary_output["validation_errors"] = validation_errors
1086
+ audit["summary_output"] = summary_output
1087
+ if validation_errors:
1088
+ failed = "invalid final summary output: " + "; ".join(validation_errors)
1089
+ elif not summary_output["mutation_count"] and not summary_output["skip_reason"]:
1090
+ failed = "final summary output has no memory mutations or skip_reason"
1091
+ if not failed and audit["summary_output"]["mutation_count"]:
793
1092
  try:
794
1093
  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
- })
1094
+ audit["semantic_action_count"] = _semantic_action_count(audit["apply_result"])
1095
+ if not audit["semantic_action_count"]:
1096
+ if audit["summary_output"]["skip_reason"]:
1097
+ audit["status"] = "skipped_summary"
1098
+ else:
1099
+ failed = "summary output produced no semantic memory actions or skip_reason"
1100
+ else:
1101
+ audit["status"] = "done"
810
1102
  except Exception as exc:
811
1103
  failed = str(exc)
1104
+ elif not failed:
1105
+ audit["status"] = "skipped_summary"
1106
+ audit["semantic_action_count"] = 0
1107
+ audit["apply_result"] = {
1108
+ "mode": "not-applied",
1109
+ "touched_targets": [],
1110
+ "actions": [],
1111
+ "skip_reason": audit["summary_output"]["skip_reason"],
1112
+ }
1113
+ if not failed and audit["status"] in {"done", "skipped_summary"}:
1114
+ audit["cursor_after"] = session["end_offset"]
1115
+ audit["state_offset_after"] = _advance_session_state(session)
812
1116
  if failed:
813
1117
  audit["status"] = "failed"
814
1118
  audit["error"] = failed
@@ -821,20 +1125,28 @@ def run_scan(args):
821
1125
  run = {
822
1126
  "schema_version": SCHEMA_VERSION,
823
1127
  "run_id": run_id,
1128
+ "run_kind": run_kind,
824
1129
  "started_at": dt.datetime.fromtimestamp(started, dt.timezone.utc).isoformat(),
825
1130
  "finished_at": now_iso(),
826
1131
  "duration_ms": int((time.time() - started) * 1000),
827
1132
  "dry_run": bool(args.dry_run),
1133
+ "scheduled": bool(getattr(args, "scheduled", False)),
1134
+ "activity": activity,
828
1135
  "executor": executor_name,
829
1136
  "model": preset.get("model") if preset else None,
830
1137
  "profile": preset.get("profile") if preset else None,
831
1138
  "session_count": len(results),
832
1139
  "candidate_count": len(candidates),
833
1140
  "done_count": sum(item["status"] == "done" for item in results),
1141
+ "summary_skipped_count": sum(item["status"] == "skipped_summary" for item in results),
834
1142
  "failed_count": sum(item["status"] == "failed" for item in results),
835
- "skipped_count": sum(item["status"] == "skipped" for item in results),
1143
+ "skipped_count": sum(item["status"] in {"skipped", "skipped_summary"} for item in results),
1144
+ "discovery_skipped_count": sum(item["status"] == "skipped" for item in results),
836
1145
  "raw_bytes": sum(item["raw_size"] for item in results),
837
1146
  "new_bytes": sum(item["new_bytes"] for item in results),
1147
+ "observed_new_bytes": sum(item["new_bytes"] for item in results),
1148
+ "eligible_new_bytes": sum(item["new_bytes"] for item in results if item["status"] != "skipped"),
1149
+ "skipped_new_bytes": sum(item["new_bytes"] for item in results if item["status"] == "skipped"),
838
1150
  "semantic_messages": sum(item["semantic_messages"] for item in results),
839
1151
  "semantic_chars": sum(item["semantic_chars"] for item in results),
840
1152
  "summary_usage": usage,
@@ -842,24 +1154,57 @@ def run_scan(args):
842
1154
  "invocations": invocations,
843
1155
  "sessions": results,
844
1156
  }
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
- })
1157
+ if replay_source:
1158
+ run["replay_source"] = replay_source
1159
+ _persist_scan_run(run, "scan_completed")
851
1160
  print(json.dumps(run, ensure_ascii=False, indent=2) if args.json else _format_run(run))
852
1161
  return 1 if run["failed_count"] else 0
853
1162
 
854
1163
 
1164
+ def run_scan(args):
1165
+ config = load_config()
1166
+ validation = validate_config(config)
1167
+ if not validation["valid"]:
1168
+ raise RuntimeError("; ".join(validation["errors"]))
1169
+ run_id = dt.datetime.now().strftime("%Y%m%dT%H%M%S") + f"-{os.getpid()}"
1170
+ started = time.time()
1171
+ activity = None
1172
+ if getattr(args, "scheduled", False) and config.get("skip_when_computer_active", True):
1173
+ activity = computer_activity(config)
1174
+ if activity["skip"]:
1175
+ run = _skipped_activity_run(run_id, started, activity, dry_run=args.dry_run)
1176
+ _persist_scan_run(run, run["status"])
1177
+ print(json.dumps(run, ensure_ascii=False, indent=2) if args.json else _format_run(run))
1178
+ return 0
1179
+ origin_path = SCAN_ROOT / "scan-origin.json"
1180
+ if not origin_path.exists():
1181
+ lookback_days = int(config.get("first_lookback_days", 3))
1182
+ _atomic_json(origin_path, {
1183
+ "created_at": now_iso(),
1184
+ "initial_cutoff_epoch": time.time() - lookback_days * 86400,
1185
+ "first_lookback_days": lookback_days,
1186
+ })
1187
+ sessions = discover(config, args.since)
1188
+ return _execute_sessions(config, args, sessions, run_id, started, activity=activity)
1189
+
1190
+
855
1191
  def _format_tokens(usage):
856
1192
  return str((usage or {}).get("total_tokens", "unavailable"))
857
1193
 
858
1194
 
859
1195
  def _format_run(run):
1196
+ if str(run.get("status", "")).startswith("skipped_"):
1197
+ activity = run.get("activity") or {}
1198
+ return (
1199
+ f"run {run['run_id']}: {run['status']}; "
1200
+ f"idle_seconds={activity.get('idle_seconds')} threshold={activity.get('threshold_seconds')}"
1201
+ )
860
1202
  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; "
1203
+ f"run {run['run_id']}: {run['done_count']} done, "
1204
+ f"{run.get('summary_skipped_count', 0)} summary-skipped, {run['failed_count']} failed, "
1205
+ f"{run.get('discovery_skipped_count', run['skipped_count'])} discovery-skipped; "
1206
+ f"{run.get('eligible_new_bytes', run['new_bytes'])} eligible / "
1207
+ f"{run.get('observed_new_bytes', run['new_bytes'])} observed new bytes; "
863
1208
  f"summary tokens {_format_tokens(run.get('summary_usage'))}"
864
1209
  )
865
1210
 
@@ -868,6 +1213,21 @@ def _runs():
868
1213
  return [value for path in sorted((SCAN_ROOT / "runs").glob("*.json"), reverse=True) if isinstance((value := _read_json(path)), dict)]
869
1214
 
870
1215
 
1216
+ def _covered_bytes(intervals):
1217
+ total = 0
1218
+ end = None
1219
+ for start, stop in sorted(intervals):
1220
+ if stop <= start:
1221
+ continue
1222
+ if end is None or start > end:
1223
+ total += stop - start
1224
+ end = stop
1225
+ elif stop > end:
1226
+ total += stop - end
1227
+ end = stop
1228
+ return total
1229
+
1230
+
871
1231
  def command_stats(args):
872
1232
  runs = _runs()
873
1233
  if args.since:
@@ -885,15 +1245,49 @@ def command_stats(args):
885
1245
  key = session.get("repo_key")
886
1246
  if not key or (args.repo and key != args.repo):
887
1247
  continue
888
- item = repos.setdefault(key, {"repo_key": key, "scan_count": 0, "sessions": set(), "raw_bytes": 0, "new_bytes": 0, "summary_tokens": 0})
1248
+ item = repos.setdefault(key, {
1249
+ "repo_key": key,
1250
+ "scan_count": 0,
1251
+ "sessions": set(),
1252
+ "intervals": {},
1253
+ "raw_bytes": 0,
1254
+ "new_bytes": 0,
1255
+ "observed_new_bytes": 0,
1256
+ "eligible_new_bytes": 0,
1257
+ "skipped_new_bytes": 0,
1258
+ "summary_tokens": 0,
1259
+ "done_count": 0,
1260
+ "summary_skipped_count": 0,
1261
+ "failed_count": 0,
1262
+ "empty_apply_count": 0,
1263
+ })
889
1264
  item["scan_count"] += 1
890
- item["sessions"].add(session.get("session_id"))
1265
+ session_id = session.get("session_id")
1266
+ item["sessions"].add(session_id)
891
1267
  item["raw_bytes"] += int(session.get("raw_size", 0))
892
- item["new_bytes"] += int(session.get("new_bytes", 0))
1268
+ observed = int(session.get("new_bytes", 0))
1269
+ item["new_bytes"] += observed
1270
+ item["observed_new_bytes"] += observed
1271
+ if session.get("status") == "skipped":
1272
+ item["skipped_new_bytes"] += observed
1273
+ else:
1274
+ item["eligible_new_bytes"] += observed
1275
+ start = int(session.get("cursor_before", 0))
1276
+ stop = min(int(session.get("raw_size", start + observed)), start + observed)
1277
+ item["intervals"].setdefault(session_id, []).append((start, stop))
893
1278
  item["summary_tokens"] += int((session.get("summary_usage") or {}).get("total_tokens", 0))
1279
+ status = session.get("status")
1280
+ item["done_count"] += int(status == "done")
1281
+ item["summary_skipped_count"] += int(status == "skipped_summary")
1282
+ item["failed_count"] += int(status == "failed")
1283
+ item["empty_apply_count"] += int(
1284
+ status == "done"
1285
+ and not (session.get("apply_result") or {}).get("touched_targets")
1286
+ )
894
1287
  repo_rows = []
895
1288
  for item in repos.values():
896
1289
  item["session_count"] = len(item.pop("sessions"))
1290
+ item["unique_new_bytes"] = sum(_covered_bytes(value) for value in item.pop("intervals").values())
897
1291
  repo_rows.append(item)
898
1292
  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
1293
  print(json.dumps(payload, ensure_ascii=False, indent=2))
@@ -915,6 +1309,81 @@ def command_show(args):
915
1309
  print(json.dumps(value, ensure_ascii=False, indent=2))
916
1310
 
917
1311
 
1312
+ def _replay_session(source_run_id, audit):
1313
+ session_id = audit.get("session_id")
1314
+ path = Path(audit.get("path") or "").expanduser()
1315
+ if not path.is_file():
1316
+ matches = [
1317
+ candidate
1318
+ for root in (CODEX_HOME / "sessions", CODEX_HOME / "archived_sessions")
1319
+ if root.exists()
1320
+ for candidate in root.rglob(f"*{session_id}*.jsonl")
1321
+ ]
1322
+ if not matches:
1323
+ raise RuntimeError(f"session transcript not found: {session_id}")
1324
+ path = max(matches, key=lambda item: (item.stat().st_size, item.stat().st_mtime))
1325
+ cursor_before = int(audit.get("cursor_before", 0))
1326
+ cursor_after = int(audit.get("cursor_after") or audit.get("raw_size") or 0)
1327
+ parsed = parse_codex_session(path, cursor_before)
1328
+ if cursor_after <= cursor_before or cursor_after > parsed["size"]:
1329
+ raise RuntimeError(
1330
+ f"invalid replay cursor for {session_id}: {cursor_before}..{cursor_after}, size={parsed['size']}"
1331
+ )
1332
+ parsed["messages"] = [
1333
+ item for item in parsed["messages"]
1334
+ if item["start_offset"] >= cursor_before and item["end_offset"] <= cursor_after
1335
+ ]
1336
+ parsed["size"] = cursor_after
1337
+ parsed["end_offset"] = cursor_after
1338
+ parsed["session_usage"] = audit.get("session_usage") or parsed.get("session_usage")
1339
+ meta = dict(parsed.get("meta") or {})
1340
+ if audit.get("cwd"):
1341
+ meta["cwd"] = audit["cwd"]
1342
+ parsed["meta"] = meta
1343
+ target, target_error = resolve_target(meta.get("cwd"))
1344
+ return {
1345
+ **parsed,
1346
+ "session_id": session_id,
1347
+ "cursor_before": cursor_before,
1348
+ "new_bytes": cursor_after - cursor_before,
1349
+ "target": target,
1350
+ "status": "candidate" if not target_error else "skipped",
1351
+ "reason": target_error,
1352
+ "replay_source": {
1353
+ "run_id": source_run_id,
1354
+ "previous_status": audit.get("status"),
1355
+ "previous_apply_empty": not bool((audit.get("apply_result") or {}).get("touched_targets")),
1356
+ },
1357
+ }
1358
+
1359
+
1360
+ def command_replay(args):
1361
+ config = load_config()
1362
+ validation = validate_config(config)
1363
+ if not validation["valid"]:
1364
+ raise RuntimeError("; ".join(validation["errors"]))
1365
+ source_path = SCAN_ROOT / "runs" / f"{args.run_id}.json"
1366
+ source_run = _read_json(source_path)
1367
+ if not isinstance(source_run, dict):
1368
+ raise RuntimeError(f"run not found: {args.run_id}")
1369
+ requested = list(dict.fromkeys(args.session_id))
1370
+ audits = {item.get("session_id"): item for item in source_run.get("sessions", [])}
1371
+ missing = [session_id for session_id in requested if session_id not in audits]
1372
+ if missing:
1373
+ raise RuntimeError(f"sessions not found in run {args.run_id}: {', '.join(missing)}")
1374
+ sessions = [_replay_session(args.run_id, audits[session_id]) for session_id in requested]
1375
+ run_id = dt.datetime.now().strftime("%Y%m%dT%H%M%S") + f"-replay-{os.getpid()}"
1376
+ return _execute_sessions(
1377
+ config,
1378
+ args,
1379
+ sessions,
1380
+ run_id,
1381
+ time.time(),
1382
+ run_kind="replay",
1383
+ replay_source={"run_id": args.run_id, "session_ids": requested},
1384
+ )
1385
+
1386
+
918
1387
  def _cli_path():
919
1388
  explicit = os.environ.get("DEV_MEMORY_CLI_PATH", "").strip()
920
1389
  if explicit:
@@ -930,8 +1399,8 @@ def command_install(_args):
930
1399
  logs.mkdir(parents=True, exist_ok=True)
931
1400
  plist = {
932
1401
  "Label": "com.dev-memory.session-scan",
933
- "ProgramArguments": [_cli_path(), "session-scan", "run"],
934
- "StartCalendarInterval": {"Hour": 3, "Minute": 0},
1402
+ "ProgramArguments": [_cli_path(), "session-scan", "run", "--scheduled"],
1403
+ "StartCalendarInterval": _calendar_intervals(config),
935
1404
  "RunAtLoad": False,
936
1405
  "StandardOutPath": str(logs / "launchd.stdout.log"),
937
1406
  "StandardErrorPath": str(logs / "launchd.stderr.log"),
@@ -944,7 +1413,14 @@ def command_install(_args):
944
1413
  result = subprocess.run(["launchctl", "load", str(PLIST_PATH)], capture_output=True, text=True, check=False)
945
1414
  if result.returncode != 0:
946
1415
  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))
1416
+ print(json.dumps({
1417
+ "installed": True,
1418
+ "plist": str(PLIST_PATH),
1419
+ "schedule_times": config["schedule_times"],
1420
+ "skip_when_computer_active": config["skip_when_computer_active"],
1421
+ "active_within_minutes": config["active_within_minutes"],
1422
+ "logs": str(logs),
1423
+ }, ensure_ascii=False, indent=2))
948
1424
 
949
1425
 
950
1426
  def command_uninstall(_args):
@@ -967,6 +1443,9 @@ def command_status(_args):
967
1443
  "codex_sessions": str(CODEX_HOME / "sessions"),
968
1444
  "executor": executor,
969
1445
  "model": preset.get("model") if preset else None,
1446
+ "schedule_times": config.get("schedule_times"),
1447
+ "skip_when_computer_active": config.get("skip_when_computer_active"),
1448
+ "active_within_minutes": config.get("active_within_minutes"),
970
1449
  "config": validation,
971
1450
  "last_run": _read_json(SCAN_ROOT / "last-run.json"),
972
1451
  }, ensure_ascii=False, indent=2))
@@ -992,7 +1471,24 @@ def command_config(args):
992
1471
  raise RuntimeError(f"unknown executor preset: {args.executor}")
993
1472
  field = "model" if args.config_command == "set-model" else "profile"
994
1473
  config["executors"][args.executor][field] = args.value
1474
+ elif args.config_command == "set-schedule":
1475
+ for value in args.times:
1476
+ _parse_schedule_time(value)
1477
+ config["schedule_times"] = args.times
1478
+ elif args.config_command == "set-active-minutes":
1479
+ if args.minutes < 1:
1480
+ raise RuntimeError("active minutes must be at least 1")
1481
+ config["active_within_minutes"] = args.minutes
1482
+ elif args.config_command == "set-timeout":
1483
+ if args.seconds < 30:
1484
+ raise RuntimeError("timeout seconds must be at least 30")
1485
+ config["invocation_timeout_seconds"] = args.seconds
1486
+ elif args.config_command == "set-active-check":
1487
+ config["skip_when_computer_active"] = args.enabled == "on"
995
1488
  save_scan_config(config)
1489
+ if args.config_command == "set-schedule" and PLIST_PATH.exists():
1490
+ command_install(args)
1491
+ return
996
1492
  print(json.dumps(config, ensure_ascii=False, indent=2))
997
1493
 
998
1494
 
@@ -1003,6 +1499,8 @@ def build_parser():
1003
1499
  run.add_argument("--since")
1004
1500
  run.add_argument("--dry-run", action="store_true")
1005
1501
  run.add_argument("--json", action="store_true")
1502
+ run.add_argument("--scheduled", action="store_true", help="Apply computer activity guard before scanning")
1503
+ run.add_argument("--executor", help="Override the configured executor for this run only")
1006
1504
  sub.add_parser("install")
1007
1505
  sub.add_parser("status")
1008
1506
  stats = sub.add_parser("stats")
@@ -1015,6 +1513,12 @@ def build_parser():
1015
1513
  history.add_argument("--json", action="store_true")
1016
1514
  show = sub.add_parser("show")
1017
1515
  show.add_argument("run_id")
1516
+ replay = sub.add_parser("replay")
1517
+ replay.add_argument("--run-id", required=True)
1518
+ replay.add_argument("--session-id", action="append", required=True)
1519
+ replay.add_argument("--dry-run", action="store_true")
1520
+ replay.add_argument("--json", action="store_true")
1521
+ replay.add_argument("--executor", help="Override the configured executor for this replay only")
1018
1522
  sub.add_parser("uninstall")
1019
1523
  config = sub.add_parser("config")
1020
1524
  config_sub = config.add_subparsers(dest="config_command", required=True)
@@ -1026,6 +1530,14 @@ def build_parser():
1026
1530
  item = config_sub.add_parser(command)
1027
1531
  item.add_argument("executor")
1028
1532
  item.add_argument("value")
1533
+ set_schedule = config_sub.add_parser("set-schedule")
1534
+ set_schedule.add_argument("times", nargs="+")
1535
+ set_active_minutes = config_sub.add_parser("set-active-minutes")
1536
+ set_active_minutes.add_argument("minutes", type=int)
1537
+ set_timeout = config_sub.add_parser("set-timeout")
1538
+ set_timeout.add_argument("seconds", type=int)
1539
+ set_active_check = config_sub.add_parser("set-active-check")
1540
+ set_active_check.add_argument("enabled", choices=("on", "off"))
1029
1541
  return parser
1030
1542
 
1031
1543
 
@@ -1038,6 +1550,7 @@ def main():
1038
1550
  "stats": command_stats,
1039
1551
  "history": command_history,
1040
1552
  "show": command_show,
1553
+ "replay": command_replay,
1041
1554
  "uninstall": command_uninstall,
1042
1555
  "config": command_config,
1043
1556
  }