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.
- package/README.md +37 -34
- package/bin/dev-memory.js +117 -0
- package/hooks/README.md +12 -1
- package/lib/dev_memory_capture.py +68 -5
- package/lib/dev_memory_common.py +28 -9
- package/lib/dev_memory_session_scan.py +578 -65
- package/lib/maintenance/archive.md +84 -0
- package/lib/maintenance/tidy.md +103 -0
- package/lib/ui-app.html +1 -1
- package/package.json +2 -1
- package/scripts/hooks/_common.py +4 -5
- package/suite-manifest.json +4 -4
|
@@ -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
|
|
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
|
-
|
|
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":
|
|
558
|
-
"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
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
if
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
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
|
-
|
|
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
|
-
|
|
773
|
-
|
|
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,
|
|
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["
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
"
|
|
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"]
|
|
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
|
-
|
|
846
|
-
|
|
847
|
-
|
|
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,
|
|
862
|
-
f"{run
|
|
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, {
|
|
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
|
-
|
|
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
|
-
|
|
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":
|
|
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({
|
|
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
|
}
|