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 +10 -2
- 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 +575 -64
- package/lib/ui-app.html +1 -1
- package/package.json +1 -1
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
package/lib/dev_memory_common.py
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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"]
|
|
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
|
|
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
|
|
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
|
-
|
|
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":
|
|
558
|
-
"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
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
if
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
1047
|
+
final_payload = None
|
|
1048
|
+
if len(chunks) == 1:
|
|
784
1049
|
final_payload, attempt_records = run_executor_with_retries(
|
|
785
|
-
executor_name, preset,
|
|
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["
|
|
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
|
-
})
|
|
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"]
|
|
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
|
-
|
|
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
|
-
})
|
|
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,
|
|
862
|
-
f"{run
|
|
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, {
|
|
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
|
-
|
|
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
|
-
|
|
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":
|
|
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({
|
|
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);
|