dev-memory-cli 0.22.0 → 0.22.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +253 -292
- package/bin/dev-memory.js +8 -2
- package/hooks/README.md +170 -47
- package/hooks/codex-hooks.json +1 -1
- package/hooks/hooks.json +1 -1
- package/lib/dev_memory_capture.py +85 -7
- package/lib/dev_memory_common.py +40 -10
- package/lib/dev_memory_graduate.py +1 -1
- package/lib/dev_memory_session_scan.py +1052 -0
- package/lib/dev_memory_setup.py +8 -3
- package/lib/dev_memory_summary.py +25 -16
- package/lib/ui-app.html +128 -1
- package/lib/ui-server.js +67 -1
- package/package.json +1 -1
- package/scripts/hooks/_common.py +44 -7
- package/scripts/hooks/stop.py +6 -0
|
@@ -0,0 +1,1052 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import datetime as dt
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import plistlib
|
|
9
|
+
import re
|
|
10
|
+
import shlex
|
|
11
|
+
import shutil
|
|
12
|
+
import subprocess
|
|
13
|
+
import sys
|
|
14
|
+
import tempfile
|
|
15
|
+
import time
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
from dev_memory_common import get_branch_paths, list_repos_in_workspace, now_iso
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
SCHEMA_VERSION = 1
|
|
22
|
+
PACKAGE_ROOT = Path(__file__).resolve().parents[1]
|
|
23
|
+
CONFIG_PATH = Path(os.environ.get("DEV_MEMORY_CONFIG_PATH", "~/.dev-memory/config.json")).expanduser()
|
|
24
|
+
DEV_MEMORY_HOME = Path(os.environ.get("DEV_MEMORY_HOME", "~/.dev-memory")).expanduser()
|
|
25
|
+
SCAN_ROOT = Path(os.environ.get("DEV_MEMORY_SCAN_ROOT", DEV_MEMORY_HOME / "jobs" / "session-scan")).expanduser()
|
|
26
|
+
CODEX_HOME = Path(os.environ.get("CODEX_HOME", "~/.codex")).expanduser()
|
|
27
|
+
PLIST_PATH = Path(os.environ.get(
|
|
28
|
+
"DEV_MEMORY_SCAN_PLIST",
|
|
29
|
+
"~/Library/LaunchAgents/com.dev-memory.session-scan.plist",
|
|
30
|
+
)).expanduser()
|
|
31
|
+
INTERNAL_MARKER = "DEV_MEMORY_INTERNAL_SESSION_SUMMARY_V1"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
DEFAULT_EXECUTORS = {
|
|
35
|
+
"coco": {
|
|
36
|
+
"enabled": True,
|
|
37
|
+
"command": "coco",
|
|
38
|
+
"model": None,
|
|
39
|
+
"profile": None,
|
|
40
|
+
"extra_args": [],
|
|
41
|
+
"env": {},
|
|
42
|
+
},
|
|
43
|
+
"codex": {
|
|
44
|
+
"enabled": True,
|
|
45
|
+
"command": "codex",
|
|
46
|
+
"model": None,
|
|
47
|
+
"profile": None,
|
|
48
|
+
"extra_args": [],
|
|
49
|
+
"env": {},
|
|
50
|
+
},
|
|
51
|
+
"claude": {
|
|
52
|
+
"enabled": True,
|
|
53
|
+
"command": "claude",
|
|
54
|
+
"model": None,
|
|
55
|
+
"profile": None,
|
|
56
|
+
"extra_args": [],
|
|
57
|
+
"env": {},
|
|
58
|
+
},
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _read_json(path, default=None):
|
|
63
|
+
try:
|
|
64
|
+
value = json.loads(Path(path).read_text(encoding="utf-8"))
|
|
65
|
+
return value
|
|
66
|
+
except (OSError, json.JSONDecodeError):
|
|
67
|
+
return default
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _atomic_json(path, payload):
|
|
71
|
+
path = Path(path)
|
|
72
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
73
|
+
tmp = path.with_name(f".{path.name}.tmp.{os.getpid()}.{time.time_ns()}")
|
|
74
|
+
tmp.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
|
75
|
+
os.replace(tmp, path)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _append_jsonl(path, payload):
|
|
79
|
+
path = Path(path)
|
|
80
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
81
|
+
with path.open("a", encoding="utf-8") as stream:
|
|
82
|
+
stream.write(json.dumps(payload, ensure_ascii=False, separators=(",", ":")) + "\n")
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _deep_merge(base, override):
|
|
86
|
+
out = dict(base)
|
|
87
|
+
for key, value in (override or {}).items():
|
|
88
|
+
if isinstance(value, dict) and isinstance(out.get(key), dict):
|
|
89
|
+
out[key] = _deep_merge(out[key], value)
|
|
90
|
+
else:
|
|
91
|
+
out[key] = value
|
|
92
|
+
return out
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def default_scan_config():
|
|
96
|
+
return {
|
|
97
|
+
"executor": "auto",
|
|
98
|
+
"order": ["coco", "codex", "claude"],
|
|
99
|
+
"executors": json.loads(json.dumps(DEFAULT_EXECUTORS)),
|
|
100
|
+
"chunk_chars": 60000,
|
|
101
|
+
"idle_minutes": 60,
|
|
102
|
+
"first_lookback_days": 3,
|
|
103
|
+
"max_attempts": 2,
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def load_config():
|
|
108
|
+
root = _read_json(CONFIG_PATH, {})
|
|
109
|
+
root = root if isinstance(root, dict) else {}
|
|
110
|
+
section = root.get("session_scan")
|
|
111
|
+
return _deep_merge(default_scan_config(), section if isinstance(section, dict) else {})
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def save_scan_config(section):
|
|
115
|
+
root = _read_json(CONFIG_PATH, {})
|
|
116
|
+
root = root if isinstance(root, dict) else {}
|
|
117
|
+
root["session_scan"] = section
|
|
118
|
+
_atomic_json(CONFIG_PATH, root)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def validate_config(config):
|
|
122
|
+
errors = []
|
|
123
|
+
warnings = []
|
|
124
|
+
selected = config.get("executor", "auto")
|
|
125
|
+
executors = config.get("executors")
|
|
126
|
+
if not isinstance(executors, dict) or not executors:
|
|
127
|
+
errors.append("session_scan.executors must be a non-empty object")
|
|
128
|
+
executors = {}
|
|
129
|
+
if selected != "auto" and selected not in executors:
|
|
130
|
+
errors.append(f"executor '{selected}' has no preset")
|
|
131
|
+
order = config.get("order")
|
|
132
|
+
if not isinstance(order, list) or not order:
|
|
133
|
+
errors.append("session_scan.order must be a non-empty list")
|
|
134
|
+
for name, preset in executors.items():
|
|
135
|
+
if not isinstance(preset, dict):
|
|
136
|
+
errors.append(f"executor '{name}' must be an object")
|
|
137
|
+
continue
|
|
138
|
+
command = preset.get("command")
|
|
139
|
+
if not isinstance(command, str) or not command.strip():
|
|
140
|
+
errors.append(f"executor '{name}' requires command")
|
|
141
|
+
if name == "codex" and "--ephemeral" in (preset.get("extra_args") or []):
|
|
142
|
+
warnings.append("codex.extra_args does not need --ephemeral; the scanner enforces it")
|
|
143
|
+
try:
|
|
144
|
+
if int(config.get("chunk_chars", 0)) < 1000:
|
|
145
|
+
errors.append("chunk_chars must be at least 1000")
|
|
146
|
+
except (TypeError, ValueError):
|
|
147
|
+
errors.append("chunk_chars must be an integer")
|
|
148
|
+
return {"valid": not errors, "errors": errors, "warnings": warnings}
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def choose_executor(config):
|
|
152
|
+
executors = config.get("executors", {})
|
|
153
|
+
selected = config.get("executor", "auto")
|
|
154
|
+
names = config.get("order", []) if selected == "auto" else [selected]
|
|
155
|
+
for name in names:
|
|
156
|
+
preset = executors.get(name)
|
|
157
|
+
if not isinstance(preset, dict) or preset.get("enabled") is False:
|
|
158
|
+
continue
|
|
159
|
+
command = preset.get("command", name)
|
|
160
|
+
executable = shlex.split(command)[0] if command else ""
|
|
161
|
+
if executable and (Path(executable).exists() or shutil.which(executable)):
|
|
162
|
+
return name, preset
|
|
163
|
+
raise RuntimeError(f"no available session-scan executor in {names}")
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _content_text(content):
|
|
167
|
+
if isinstance(content, str):
|
|
168
|
+
return content.strip()
|
|
169
|
+
if not isinstance(content, list):
|
|
170
|
+
return ""
|
|
171
|
+
parts = []
|
|
172
|
+
for item in content:
|
|
173
|
+
if not isinstance(item, dict):
|
|
174
|
+
continue
|
|
175
|
+
if item.get("type") in {"tool_use", "tool_result", "function_call", "function_call_output"}:
|
|
176
|
+
continue
|
|
177
|
+
text = item.get("text")
|
|
178
|
+
if isinstance(text, str) and text.strip():
|
|
179
|
+
parts.append(text.strip())
|
|
180
|
+
return "\n".join(parts)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _semantic_message(obj):
|
|
184
|
+
if obj.get("type") != "response_item":
|
|
185
|
+
return None
|
|
186
|
+
payload = obj.get("payload")
|
|
187
|
+
if not isinstance(payload, dict) or payload.get("type") != "message":
|
|
188
|
+
return None
|
|
189
|
+
role = payload.get("role")
|
|
190
|
+
if role not in {"user", "assistant"}:
|
|
191
|
+
return None
|
|
192
|
+
text = _content_text(payload.get("content"))
|
|
193
|
+
if not text:
|
|
194
|
+
return None
|
|
195
|
+
return {"role": role, "text": text, "timestamp": obj.get("timestamp")}
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _usage_dict(value):
|
|
199
|
+
if not isinstance(value, dict):
|
|
200
|
+
return None
|
|
201
|
+
aliases = {
|
|
202
|
+
"input_tokens": ("input_tokens", "inputTokens"),
|
|
203
|
+
"cached_input_tokens": ("cached_input_tokens", "cachedInputTokens", "cache_read_input_tokens"),
|
|
204
|
+
"output_tokens": ("output_tokens", "outputTokens"),
|
|
205
|
+
"reasoning_output_tokens": ("reasoning_output_tokens", "reasoningOutputTokens"),
|
|
206
|
+
"total_tokens": ("total_tokens", "totalTokens"),
|
|
207
|
+
}
|
|
208
|
+
out = {}
|
|
209
|
+
for target, keys in aliases.items():
|
|
210
|
+
for key in keys:
|
|
211
|
+
if isinstance(value.get(key), (int, float)):
|
|
212
|
+
out[target] = int(value[key])
|
|
213
|
+
break
|
|
214
|
+
if out and "total_tokens" not in out:
|
|
215
|
+
out["total_tokens"] = out.get("input_tokens", 0) + out.get("output_tokens", 0)
|
|
216
|
+
return out or None
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def parse_codex_session(path, since_offset=0):
|
|
220
|
+
path = Path(path)
|
|
221
|
+
meta = {}
|
|
222
|
+
messages = []
|
|
223
|
+
total_usage = None
|
|
224
|
+
internal_marker = False
|
|
225
|
+
end_offset = since_offset
|
|
226
|
+
with path.open("rb") as stream:
|
|
227
|
+
if since_offset:
|
|
228
|
+
while True:
|
|
229
|
+
raw = stream.readline()
|
|
230
|
+
if not raw:
|
|
231
|
+
break
|
|
232
|
+
try:
|
|
233
|
+
obj = json.loads(raw.decode("utf-8"))
|
|
234
|
+
except (UnicodeDecodeError, json.JSONDecodeError):
|
|
235
|
+
continue
|
|
236
|
+
if obj.get("type") == "session_meta" and isinstance(obj.get("payload"), dict):
|
|
237
|
+
meta = obj["payload"]
|
|
238
|
+
break
|
|
239
|
+
stream.seek(since_offset)
|
|
240
|
+
while True:
|
|
241
|
+
start = stream.tell()
|
|
242
|
+
raw = stream.readline()
|
|
243
|
+
if not raw:
|
|
244
|
+
break
|
|
245
|
+
end = stream.tell()
|
|
246
|
+
end_offset = end
|
|
247
|
+
try:
|
|
248
|
+
obj = json.loads(raw.decode("utf-8"))
|
|
249
|
+
except (UnicodeDecodeError, json.JSONDecodeError):
|
|
250
|
+
continue
|
|
251
|
+
if obj.get("type") == "session_meta" and isinstance(obj.get("payload"), dict):
|
|
252
|
+
meta = obj["payload"]
|
|
253
|
+
payload = obj.get("payload") if isinstance(obj.get("payload"), dict) else {}
|
|
254
|
+
if obj.get("type") == "event_msg" and payload.get("type") == "token_count":
|
|
255
|
+
info = payload.get("info") if isinstance(payload.get("info"), dict) else {}
|
|
256
|
+
usage = _usage_dict(info.get("total_token_usage"))
|
|
257
|
+
if usage:
|
|
258
|
+
total_usage = usage
|
|
259
|
+
message = _semantic_message(obj)
|
|
260
|
+
if message and INTERNAL_MARKER in message["text"]:
|
|
261
|
+
internal_marker = True
|
|
262
|
+
if message and end > since_offset:
|
|
263
|
+
message.update({"start_offset": start, "end_offset": end})
|
|
264
|
+
messages.append(message)
|
|
265
|
+
stat = path.stat()
|
|
266
|
+
return {
|
|
267
|
+
"path": str(path),
|
|
268
|
+
"size": stat.st_size,
|
|
269
|
+
"mtime": dt.datetime.fromtimestamp(stat.st_mtime, dt.timezone.utc).isoformat(),
|
|
270
|
+
"end_offset": end_offset,
|
|
271
|
+
"meta": meta,
|
|
272
|
+
"messages": messages,
|
|
273
|
+
"session_usage": total_usage,
|
|
274
|
+
"internal_marker": internal_marker,
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _codex_session_meta(path):
|
|
279
|
+
with Path(path).open("rb") as stream:
|
|
280
|
+
for raw in stream:
|
|
281
|
+
try:
|
|
282
|
+
obj = json.loads(raw.decode("utf-8"))
|
|
283
|
+
except (UnicodeDecodeError, json.JSONDecodeError):
|
|
284
|
+
continue
|
|
285
|
+
if obj.get("type") == "session_meta" and isinstance(obj.get("payload"), dict):
|
|
286
|
+
return obj["payload"]
|
|
287
|
+
return {}
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def _session_files(lookback_days=None, cutoff_epoch=None):
|
|
291
|
+
roots = [CODEX_HOME / "sessions", CODEX_HOME / "archived_sessions"]
|
|
292
|
+
cutoff = cutoff_epoch
|
|
293
|
+
if cutoff is None and lookback_days is not None:
|
|
294
|
+
cutoff = time.time() - (lookback_days * 86400)
|
|
295
|
+
found = []
|
|
296
|
+
for root in roots:
|
|
297
|
+
if not root.exists():
|
|
298
|
+
continue
|
|
299
|
+
for path in root.rglob("*.jsonl"):
|
|
300
|
+
try:
|
|
301
|
+
if cutoff is not None and path.stat().st_mtime < cutoff:
|
|
302
|
+
continue
|
|
303
|
+
except OSError:
|
|
304
|
+
continue
|
|
305
|
+
found.append(path)
|
|
306
|
+
return sorted(found, key=lambda p: (p.stat().st_mtime, str(p)))
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def _state_path(session_id):
|
|
310
|
+
safe = re.sub(r"[^0-9A-Za-z_.-]", "_", session_id)
|
|
311
|
+
return SCAN_ROOT / "state" / f"{safe}.json"
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def _session_audit_path(session_id):
|
|
315
|
+
safe = re.sub(r"[^0-9A-Za-z_.-]", "_", session_id)
|
|
316
|
+
return SCAN_ROOT / "sessions" / f"{safe}.json"
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def _workspace_primary(cwd):
|
|
320
|
+
for name in (".dev-memory-workspace.json", ".dev-assets-workspace.json"):
|
|
321
|
+
data = _read_json(Path(cwd) / name, {})
|
|
322
|
+
if isinstance(data, dict) and data.get("primary_repo"):
|
|
323
|
+
return data["primary_repo"]
|
|
324
|
+
return None
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def resolve_target(cwd):
|
|
328
|
+
if not cwd:
|
|
329
|
+
return None, "missing_cwd"
|
|
330
|
+
root = Path(cwd).expanduser()
|
|
331
|
+
if not root.exists() or not root.is_dir():
|
|
332
|
+
return None, "cwd_not_found"
|
|
333
|
+
repos = list_repos_in_workspace(str(root))
|
|
334
|
+
if repos:
|
|
335
|
+
primary = _workspace_primary(root)
|
|
336
|
+
if primary:
|
|
337
|
+
root = next((repo for repo in repos if repo.name == primary), None)
|
|
338
|
+
if root is None:
|
|
339
|
+
return None, "workspace_primary_not_found"
|
|
340
|
+
elif len(repos) == 1:
|
|
341
|
+
root = repos[0]
|
|
342
|
+
else:
|
|
343
|
+
return None, "workspace_primary_required"
|
|
344
|
+
try:
|
|
345
|
+
repo_root, branch, branch_key, storage_root, repo_key, repo_dir, branch_dir = get_branch_paths(str(root))
|
|
346
|
+
except Exception as exc:
|
|
347
|
+
return None, f"repo_resolution_failed:{exc}"
|
|
348
|
+
if not branch_dir.exists():
|
|
349
|
+
return None, "memory_not_initialized"
|
|
350
|
+
return {
|
|
351
|
+
"repo_root": str(repo_root),
|
|
352
|
+
"repo_key": repo_key,
|
|
353
|
+
"repo_dir": str(repo_dir),
|
|
354
|
+
"branch": branch,
|
|
355
|
+
"branch_key": branch_key,
|
|
356
|
+
"branch_dir": str(branch_dir),
|
|
357
|
+
"storage_root": str(storage_root),
|
|
358
|
+
}, None
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def _chunk_messages(messages, max_chars):
|
|
362
|
+
expanded = []
|
|
363
|
+
for message in messages:
|
|
364
|
+
text = message["text"]
|
|
365
|
+
if len(text) <= max_chars:
|
|
366
|
+
expanded.append(message)
|
|
367
|
+
continue
|
|
368
|
+
segment_count = (len(text) + max_chars - 1) // max_chars
|
|
369
|
+
for index in range(segment_count):
|
|
370
|
+
expanded.append({
|
|
371
|
+
**message,
|
|
372
|
+
"text": text[index * max_chars:(index + 1) * max_chars],
|
|
373
|
+
"segment_index": index + 1,
|
|
374
|
+
"segment_count": segment_count,
|
|
375
|
+
})
|
|
376
|
+
chunks = []
|
|
377
|
+
current = []
|
|
378
|
+
current_chars = 0
|
|
379
|
+
for message in expanded:
|
|
380
|
+
text = message["text"]
|
|
381
|
+
if current and current_chars + len(text) > max_chars:
|
|
382
|
+
chunks.append(current)
|
|
383
|
+
current = []
|
|
384
|
+
current_chars = 0
|
|
385
|
+
current.append(message)
|
|
386
|
+
current_chars += len(text)
|
|
387
|
+
if current_chars >= max_chars:
|
|
388
|
+
chunks.append(current)
|
|
389
|
+
current = []
|
|
390
|
+
current_chars = 0
|
|
391
|
+
if current:
|
|
392
|
+
chunks.append(current)
|
|
393
|
+
return chunks
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def _existing_memory(target):
|
|
397
|
+
paths = [
|
|
398
|
+
Path(target["branch_dir"]) / name
|
|
399
|
+
for name in ("overview.md", "decisions.md", "risks.md", "glossary.md")
|
|
400
|
+
] + [
|
|
401
|
+
Path(target["repo_dir"]) / "repo" / name
|
|
402
|
+
for name in ("decisions.md", "glossary.md")
|
|
403
|
+
]
|
|
404
|
+
return [
|
|
405
|
+
{"path": str(path), "content": path.read_text(encoding="utf-8")}
|
|
406
|
+
for path in paths if path.exists()
|
|
407
|
+
]
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def _partial_prompt(target, chunk, index, total):
|
|
411
|
+
material = [{"role": item["role"], "text": item["text"]} for item in chunk]
|
|
412
|
+
return f"""{INTERNAL_MARKER}
|
|
413
|
+
你是 dev-memory 的后台会话总结器。下面是仓库 {target['repo_key']} 分支 {target['branch']} 的第 {index}/{total} 个连续会话分块。
|
|
414
|
+
完整阅读本分块,不要忽略前部内容。只提炼在未来开发会话中仍有价值的决策、约束、风险、术语、命令、外部入口和功能文件定位;不要记录聊天流水账、普通进展或可从 Git 直接恢复的历史。
|
|
415
|
+
只输出 JSON 对象,允许字段:decisions、risks、glossary、file_map、shared_decisions、shared_context、shared_sources、skip_reason。字段内容沿用 summary-output 语义。
|
|
416
|
+
MESSAGES_JSON:
|
|
417
|
+
{json.dumps(material, ensure_ascii=False)}
|
|
418
|
+
"""
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def _final_prompt(target, partials):
|
|
422
|
+
payload = {"existing_memory": _existing_memory(target), "partial_summaries": partials}
|
|
423
|
+
return f"""{INTERNAL_MARKER}
|
|
424
|
+
你是 dev-memory 的后台会话总结器。把所有 partial_summaries 与 existing_memory 合并为一个最终 summary-output。
|
|
425
|
+
旧结论失效时使用 rewrites/deletes,不要追加矛盾条目;重复内容跳过。不要写当前进展、下一步或提交历史。
|
|
426
|
+
只输出一个 JSON 对象,不要 markdown fence。允许字段:title、file_map、decisions、risks、glossary、shared_decisions、shared_context、shared_sources、upserts、appends、rewrites、deletes、skip_reason。
|
|
427
|
+
INPUT_JSON:
|
|
428
|
+
{json.dumps(payload, ensure_ascii=False)}
|
|
429
|
+
"""
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def _executor_args(name, preset, prompt):
|
|
433
|
+
command = shlex.split(preset.get("command", name))
|
|
434
|
+
model = preset.get("model")
|
|
435
|
+
profile = preset.get("profile")
|
|
436
|
+
extra = [str(item) for item in (preset.get("extra_args") or [])]
|
|
437
|
+
if name == "coco":
|
|
438
|
+
args = command + ["-p", "--yolo", "--output-format", "json"]
|
|
439
|
+
if model:
|
|
440
|
+
args += ["-c", f"model={model}"]
|
|
441
|
+
if profile:
|
|
442
|
+
args += ["-c", f"profile={profile}"]
|
|
443
|
+
args += extra + [prompt]
|
|
444
|
+
elif name == "codex":
|
|
445
|
+
args = command + [
|
|
446
|
+
"exec", "--ephemeral", "--json", "--ignore-rules",
|
|
447
|
+
"--skip-git-repo-check", "--sandbox", "danger-full-access",
|
|
448
|
+
]
|
|
449
|
+
if model:
|
|
450
|
+
args += ["--model", str(model)]
|
|
451
|
+
if profile:
|
|
452
|
+
args += ["--profile", str(profile)]
|
|
453
|
+
args += extra + [prompt]
|
|
454
|
+
elif name == "claude":
|
|
455
|
+
args = command + ["-p", "--permission-mode", "bypassPermissions", "--output-format", "json", "--no-session-persistence"]
|
|
456
|
+
if model:
|
|
457
|
+
args += ["--model", str(model)]
|
|
458
|
+
args += extra + [prompt]
|
|
459
|
+
else:
|
|
460
|
+
args = command + extra
|
|
461
|
+
args = [part.replace("{prompt}", prompt) for part in args]
|
|
462
|
+
if not any("{prompt}" in part for part in command + extra):
|
|
463
|
+
args.append(prompt)
|
|
464
|
+
return args
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
def _find_json_object(text):
|
|
468
|
+
text = (text or "").strip()
|
|
469
|
+
try:
|
|
470
|
+
value = json.loads(text)
|
|
471
|
+
if isinstance(value, dict):
|
|
472
|
+
return value
|
|
473
|
+
except json.JSONDecodeError:
|
|
474
|
+
pass
|
|
475
|
+
decoder = json.JSONDecoder()
|
|
476
|
+
for match in re.finditer(r"\{", text):
|
|
477
|
+
try:
|
|
478
|
+
value, _ = decoder.raw_decode(text[match.start():])
|
|
479
|
+
if isinstance(value, dict):
|
|
480
|
+
return value
|
|
481
|
+
except json.JSONDecodeError:
|
|
482
|
+
continue
|
|
483
|
+
raise ValueError("executor output contains no JSON object")
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
def _walk_dicts(value):
|
|
487
|
+
if isinstance(value, dict):
|
|
488
|
+
yield value
|
|
489
|
+
for child in value.values():
|
|
490
|
+
yield from _walk_dicts(child)
|
|
491
|
+
elif isinstance(value, list):
|
|
492
|
+
for child in value:
|
|
493
|
+
yield from _walk_dicts(child)
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
def _parse_executor_output(name, stdout, stderr):
|
|
497
|
+
records = []
|
|
498
|
+
for line in stdout.splitlines():
|
|
499
|
+
try:
|
|
500
|
+
records.append(json.loads(line))
|
|
501
|
+
except json.JSONDecodeError:
|
|
502
|
+
continue
|
|
503
|
+
if not records:
|
|
504
|
+
try:
|
|
505
|
+
records = [json.loads(stdout)]
|
|
506
|
+
except json.JSONDecodeError:
|
|
507
|
+
records = []
|
|
508
|
+
usage = None
|
|
509
|
+
session_id = None
|
|
510
|
+
candidates = []
|
|
511
|
+
for record in records:
|
|
512
|
+
for item in _walk_dicts(record):
|
|
513
|
+
for key in ("usage", "token_usage", "total_token_usage"):
|
|
514
|
+
parsed_usage = _usage_dict(item.get(key))
|
|
515
|
+
if parsed_usage:
|
|
516
|
+
usage = parsed_usage
|
|
517
|
+
session_id = session_id or item.get("thread_id") or item.get("session_id")
|
|
518
|
+
for key in ("result", "text", "output_text"):
|
|
519
|
+
if isinstance(item.get(key), str):
|
|
520
|
+
candidates.append(item[key])
|
|
521
|
+
if item.get("type") == "agent_message" and isinstance(item.get("text"), str):
|
|
522
|
+
candidates.append(item["text"])
|
|
523
|
+
last_error = None
|
|
524
|
+
for candidate in reversed(candidates):
|
|
525
|
+
try:
|
|
526
|
+
return _find_json_object(candidate), usage, session_id
|
|
527
|
+
except ValueError as exc:
|
|
528
|
+
last_error = exc
|
|
529
|
+
for candidate in (stdout, stderr):
|
|
530
|
+
try:
|
|
531
|
+
value = json.loads(candidate)
|
|
532
|
+
if isinstance(value, dict):
|
|
533
|
+
for key in ("result", "text", "output_text"):
|
|
534
|
+
if isinstance(value.get(key), str):
|
|
535
|
+
return _find_json_object(value[key]), usage, session_id
|
|
536
|
+
return value, usage, session_id
|
|
537
|
+
except json.JSONDecodeError:
|
|
538
|
+
try:
|
|
539
|
+
return _find_json_object(candidate), usage, session_id
|
|
540
|
+
except ValueError as exc:
|
|
541
|
+
last_error = exc
|
|
542
|
+
raise last_error or ValueError(f"unable to parse {name} output")
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
def run_executor(name, preset, prompt, cwd, run_id, invocation):
|
|
546
|
+
args = _executor_args(name, preset, prompt)
|
|
547
|
+
env = dict(os.environ)
|
|
548
|
+
env.update({str(k): str(v) for k, v in (preset.get("env") or {}).items()})
|
|
549
|
+
started = time.time()
|
|
550
|
+
result = subprocess.run(args, cwd=cwd, env=env, capture_output=True, text=True, check=False)
|
|
551
|
+
record = {
|
|
552
|
+
"invocation": invocation,
|
|
553
|
+
"executor": name,
|
|
554
|
+
"model": preset.get("model"),
|
|
555
|
+
"profile": preset.get("profile"),
|
|
556
|
+
"started_at": dt.datetime.fromtimestamp(started, dt.timezone.utc).isoformat(),
|
|
557
|
+
"duration_ms": int((time.time() - started) * 1000),
|
|
558
|
+
"returncode": result.returncode,
|
|
559
|
+
"usage_status": "unavailable",
|
|
560
|
+
}
|
|
561
|
+
if result.returncode != 0:
|
|
562
|
+
record["error"] = (result.stderr or result.stdout or "executor failed")[-4000:]
|
|
563
|
+
return None, record
|
|
564
|
+
try:
|
|
565
|
+
payload, usage, session_id = _parse_executor_output(name, result.stdout, result.stderr)
|
|
566
|
+
record["usage"] = usage
|
|
567
|
+
record["usage_status"] = "reported" if usage else "unavailable"
|
|
568
|
+
record["internal_session_id"] = session_id
|
|
569
|
+
if session_id:
|
|
570
|
+
_append_jsonl(SCAN_ROOT / "internal-sessions.jsonl", {
|
|
571
|
+
"at": now_iso(), "run_id": run_id, "executor": name, "session_id": session_id,
|
|
572
|
+
})
|
|
573
|
+
return payload, record
|
|
574
|
+
except Exception as exc:
|
|
575
|
+
record["error"] = str(exc)
|
|
576
|
+
return None, record
|
|
577
|
+
|
|
578
|
+
|
|
579
|
+
def run_executor_with_retries(name, preset, prompt, cwd, run_id, invocation, max_attempts):
|
|
580
|
+
records = []
|
|
581
|
+
payload = None
|
|
582
|
+
for attempt in range(1, max(1, max_attempts) + 1):
|
|
583
|
+
attempt_prompt = prompt
|
|
584
|
+
if attempt > 1:
|
|
585
|
+
attempt_prompt += "\n上一次调用失败。请重新阅读输入,只输出一个合法 JSON 对象。"
|
|
586
|
+
payload, record = run_executor(
|
|
587
|
+
name, preset, attempt_prompt, cwd, run_id, f"{invocation}:attempt:{attempt}"
|
|
588
|
+
)
|
|
589
|
+
record["attempt"] = attempt
|
|
590
|
+
records.append(record)
|
|
591
|
+
if payload is not None:
|
|
592
|
+
break
|
|
593
|
+
return payload, records
|
|
594
|
+
|
|
595
|
+
|
|
596
|
+
def _apply_summary(target, payload):
|
|
597
|
+
result = subprocess.run(
|
|
598
|
+
[sys.executable, str(PACKAGE_ROOT / "lib" / "dev_memory_capture.py"), "apply-summary-output", "--repo", target["repo_root"], "--json", json.dumps(payload, ensure_ascii=False)],
|
|
599
|
+
cwd=target["repo_root"], capture_output=True, text=True, check=False,
|
|
600
|
+
)
|
|
601
|
+
if result.returncode not in (0, 2):
|
|
602
|
+
raise RuntimeError((result.stderr or result.stdout or "apply-summary-output failed").strip())
|
|
603
|
+
try:
|
|
604
|
+
return json.loads(result.stdout)
|
|
605
|
+
except json.JSONDecodeError:
|
|
606
|
+
return {"raw": result.stdout.strip()}
|
|
607
|
+
|
|
608
|
+
|
|
609
|
+
def _sum_usage(invocations):
|
|
610
|
+
total = {}
|
|
611
|
+
unavailable = 0
|
|
612
|
+
for item in invocations:
|
|
613
|
+
usage = item.get("usage")
|
|
614
|
+
if not isinstance(usage, dict):
|
|
615
|
+
unavailable += 1
|
|
616
|
+
continue
|
|
617
|
+
for key, value in usage.items():
|
|
618
|
+
if isinstance(value, int):
|
|
619
|
+
total[key] = total.get(key, 0) + value
|
|
620
|
+
return total or None, unavailable
|
|
621
|
+
|
|
622
|
+
|
|
623
|
+
def _internal_ids():
|
|
624
|
+
ids = set()
|
|
625
|
+
path = SCAN_ROOT / "internal-sessions.jsonl"
|
|
626
|
+
try:
|
|
627
|
+
for line in path.read_text(encoding="utf-8").splitlines():
|
|
628
|
+
value = json.loads(line)
|
|
629
|
+
if value.get("session_id"):
|
|
630
|
+
ids.add(value["session_id"])
|
|
631
|
+
except (OSError, json.JSONDecodeError):
|
|
632
|
+
pass
|
|
633
|
+
return ids
|
|
634
|
+
|
|
635
|
+
|
|
636
|
+
def discover(config, since=None):
|
|
637
|
+
origin = _read_json(SCAN_ROOT / "scan-origin.json", {}) or {}
|
|
638
|
+
first_run = not origin
|
|
639
|
+
lookback = config.get("first_lookback_days", 3) if first_run and since is None else None
|
|
640
|
+
cutoff_epoch = origin.get("initial_cutoff_epoch") if origin and since is None else None
|
|
641
|
+
if since:
|
|
642
|
+
parsed = dt.datetime.fromisoformat(since.replace("Z", "+00:00"))
|
|
643
|
+
cutoff_epoch = parsed.astimezone(dt.timezone.utc).timestamp()
|
|
644
|
+
lookback = None
|
|
645
|
+
internal_ids = _internal_ids()
|
|
646
|
+
sessions = []
|
|
647
|
+
idle_seconds = int(config.get("idle_minutes", 60)) * 60
|
|
648
|
+
source_by_session = {}
|
|
649
|
+
for path in _session_files(lookback, cutoff_epoch=cutoff_epoch):
|
|
650
|
+
meta = _codex_session_meta(path)
|
|
651
|
+
session_id = meta.get("session_id") or meta.get("id") or path.stem
|
|
652
|
+
previous = source_by_session.get(session_id)
|
|
653
|
+
if previous is None or (path.stat().st_size, path.stat().st_mtime) > (
|
|
654
|
+
previous[0].stat().st_size, previous[0].stat().st_mtime
|
|
655
|
+
):
|
|
656
|
+
source_by_session[session_id] = (path, meta)
|
|
657
|
+
for session_id, (path, meta) in sorted(
|
|
658
|
+
source_by_session.items(), key=lambda item: (item[1][0].stat().st_mtime, str(item[1][0]))
|
|
659
|
+
):
|
|
660
|
+
stat = path.stat()
|
|
661
|
+
state = _read_json(_state_path(session_id), {}) or {}
|
|
662
|
+
cursor = int(state.get("processed_offset", 0))
|
|
663
|
+
if cursor and stat.st_size <= cursor:
|
|
664
|
+
parsed = {
|
|
665
|
+
"path": str(path),
|
|
666
|
+
"size": stat.st_size,
|
|
667
|
+
"mtime": dt.datetime.fromtimestamp(stat.st_mtime, dt.timezone.utc).isoformat(),
|
|
668
|
+
"end_offset": cursor,
|
|
669
|
+
"meta": meta,
|
|
670
|
+
"messages": [],
|
|
671
|
+
"session_usage": state.get("session_usage"),
|
|
672
|
+
"internal_marker": bool(state.get("internal_marker")),
|
|
673
|
+
}
|
|
674
|
+
else:
|
|
675
|
+
parsed = parse_codex_session(path, cursor)
|
|
676
|
+
reason = None
|
|
677
|
+
if session_id in internal_ids or str(session_id).startswith("dev-memory-summary-") or parsed["internal_marker"]:
|
|
678
|
+
reason = "excluded_internal"
|
|
679
|
+
elif meta.get("thread_source") == "automation":
|
|
680
|
+
reason = "excluded_automation"
|
|
681
|
+
elif time.time() - path.stat().st_mtime < idle_seconds:
|
|
682
|
+
reason = "not_idle"
|
|
683
|
+
elif parsed["size"] <= cursor:
|
|
684
|
+
reason = "unchanged"
|
|
685
|
+
target, target_error = resolve_target(meta.get("cwd"))
|
|
686
|
+
if not reason and target_error:
|
|
687
|
+
reason = target_error
|
|
688
|
+
sessions.append({
|
|
689
|
+
**parsed,
|
|
690
|
+
"session_id": session_id,
|
|
691
|
+
"cursor_before": cursor,
|
|
692
|
+
"new_bytes": max(0, parsed["size"] - cursor),
|
|
693
|
+
"target": target,
|
|
694
|
+
"status": "candidate" if not reason else "skipped",
|
|
695
|
+
"reason": reason,
|
|
696
|
+
})
|
|
697
|
+
return sessions
|
|
698
|
+
|
|
699
|
+
|
|
700
|
+
def run_scan(args):
|
|
701
|
+
config = load_config()
|
|
702
|
+
validation = validate_config(config)
|
|
703
|
+
if not validation["valid"]:
|
|
704
|
+
raise RuntimeError("; ".join(validation["errors"]))
|
|
705
|
+
run_id = dt.datetime.now().strftime("%Y%m%dT%H%M%S") + f"-{os.getpid()}"
|
|
706
|
+
started = time.time()
|
|
707
|
+
origin_path = SCAN_ROOT / "scan-origin.json"
|
|
708
|
+
if not origin_path.exists():
|
|
709
|
+
lookback_days = int(config.get("first_lookback_days", 3))
|
|
710
|
+
_atomic_json(origin_path, {
|
|
711
|
+
"created_at": now_iso(),
|
|
712
|
+
"initial_cutoff_epoch": time.time() - lookback_days * 86400,
|
|
713
|
+
"first_lookback_days": lookback_days,
|
|
714
|
+
})
|
|
715
|
+
sessions = discover(config, args.since)
|
|
716
|
+
candidates = [item for item in sessions if item["status"] == "candidate" and item["messages"]]
|
|
717
|
+
executor_name = None
|
|
718
|
+
preset = None
|
|
719
|
+
if candidates and not args.dry_run:
|
|
720
|
+
executor_name, preset = choose_executor(config)
|
|
721
|
+
invocations = []
|
|
722
|
+
results = []
|
|
723
|
+
for session in sessions:
|
|
724
|
+
audit = {
|
|
725
|
+
"schema_version": SCHEMA_VERSION,
|
|
726
|
+
"session_id": session["session_id"],
|
|
727
|
+
"path": session["path"],
|
|
728
|
+
"originator": session["meta"].get("originator"),
|
|
729
|
+
"source": session["meta"].get("source"),
|
|
730
|
+
"thread_source": session["meta"].get("thread_source"),
|
|
731
|
+
"cwd": session["meta"].get("cwd"),
|
|
732
|
+
"raw_size": session["size"],
|
|
733
|
+
"cursor_before": session["cursor_before"],
|
|
734
|
+
"new_bytes": session["new_bytes"],
|
|
735
|
+
"semantic_messages": len(session["messages"]),
|
|
736
|
+
"semantic_chars": sum(len(item["text"]) for item in session["messages"]),
|
|
737
|
+
"session_usage": session["session_usage"],
|
|
738
|
+
"repo_key": (session["target"] or {}).get("repo_key"),
|
|
739
|
+
"branch": (session["target"] or {}).get("branch"),
|
|
740
|
+
"status": session["status"],
|
|
741
|
+
"reason": session["reason"],
|
|
742
|
+
"last_scanned_at": now_iso(),
|
|
743
|
+
}
|
|
744
|
+
if session not in candidates:
|
|
745
|
+
if audit["status"] == "candidate":
|
|
746
|
+
audit["status"] = "skipped"
|
|
747
|
+
audit["reason"] = "no_semantic_messages"
|
|
748
|
+
results.append(audit)
|
|
749
|
+
_atomic_json(_session_audit_path(session["session_id"]), audit)
|
|
750
|
+
continue
|
|
751
|
+
chunks = _chunk_messages(session["messages"], int(config.get("chunk_chars", 60000)))
|
|
752
|
+
audit["chunk_count"] = len(chunks)
|
|
753
|
+
audit["chunks"] = [
|
|
754
|
+
{
|
|
755
|
+
"index": index,
|
|
756
|
+
"start_offset": chunk[0]["start_offset"],
|
|
757
|
+
"end_offset": chunk[-1]["end_offset"],
|
|
758
|
+
"messages": len(chunk),
|
|
759
|
+
"chars": sum(len(item["text"]) for item in chunk),
|
|
760
|
+
"sha256": hashlib.sha256("\n".join(item["text"] for item in chunk).encode()).hexdigest(),
|
|
761
|
+
}
|
|
762
|
+
for index, chunk in enumerate(chunks, 1)
|
|
763
|
+
]
|
|
764
|
+
if args.dry_run:
|
|
765
|
+
audit["status"] = "dry_run"
|
|
766
|
+
results.append(audit)
|
|
767
|
+
_atomic_json(_session_audit_path(session["session_id"]), audit)
|
|
768
|
+
continue
|
|
769
|
+
partials = []
|
|
770
|
+
failed = None
|
|
771
|
+
invocation_start = len(invocations)
|
|
772
|
+
for index, chunk in enumerate(chunks, 1):
|
|
773
|
+
payload, attempt_records = run_executor_with_retries(
|
|
774
|
+
executor_name, preset, _partial_prompt(session["target"], chunk, index, len(chunks)),
|
|
775
|
+
session["target"]["repo_root"], run_id, f"{session['session_id']}:chunk:{index}",
|
|
776
|
+
int(config.get("max_attempts", 2)),
|
|
777
|
+
)
|
|
778
|
+
invocations.extend(attempt_records)
|
|
779
|
+
if payload is None:
|
|
780
|
+
failed = attempt_records[-1].get("error", "chunk summary failed")
|
|
781
|
+
break
|
|
782
|
+
partials.append(payload)
|
|
783
|
+
if not failed:
|
|
784
|
+
final_payload, attempt_records = run_executor_with_retries(
|
|
785
|
+
executor_name, preset, _final_prompt(session["target"], partials),
|
|
786
|
+
session["target"]["repo_root"], run_id, f"{session['session_id']}:final",
|
|
787
|
+
int(config.get("max_attempts", 2)),
|
|
788
|
+
)
|
|
789
|
+
invocations.extend(attempt_records)
|
|
790
|
+
if final_payload is None:
|
|
791
|
+
failed = attempt_records[-1].get("error", "final summary failed")
|
|
792
|
+
if not failed:
|
|
793
|
+
try:
|
|
794
|
+
audit["apply_result"] = _apply_summary(session["target"], final_payload)
|
|
795
|
+
audit["status"] = "done"
|
|
796
|
+
audit["cursor_after"] = session["end_offset"]
|
|
797
|
+
_atomic_json(_state_path(session["session_id"]), {
|
|
798
|
+
"schema_version": SCHEMA_VERSION,
|
|
799
|
+
"session_id": session["session_id"],
|
|
800
|
+
"path": session["path"],
|
|
801
|
+
"processed_offset": session["end_offset"],
|
|
802
|
+
"raw_size": session["size"],
|
|
803
|
+
"sha256": hashlib.sha256(Path(session["path"]).read_bytes()).hexdigest(),
|
|
804
|
+
"session_usage": session["session_usage"],
|
|
805
|
+
"internal_marker": session["internal_marker"],
|
|
806
|
+
"repo_key": session["target"]["repo_key"],
|
|
807
|
+
"branch": session["target"]["branch"],
|
|
808
|
+
"updated_at": now_iso(),
|
|
809
|
+
})
|
|
810
|
+
except Exception as exc:
|
|
811
|
+
failed = str(exc)
|
|
812
|
+
if failed:
|
|
813
|
+
audit["status"] = "failed"
|
|
814
|
+
audit["error"] = failed
|
|
815
|
+
audit["summary_usage"], audit["usage_unavailable_invocations"] = _sum_usage(invocations[invocation_start:])
|
|
816
|
+
audit["executor"] = executor_name
|
|
817
|
+
audit["model"] = preset.get("model")
|
|
818
|
+
results.append(audit)
|
|
819
|
+
_atomic_json(_session_audit_path(session["session_id"]), audit)
|
|
820
|
+
usage, unavailable = _sum_usage(invocations)
|
|
821
|
+
run = {
|
|
822
|
+
"schema_version": SCHEMA_VERSION,
|
|
823
|
+
"run_id": run_id,
|
|
824
|
+
"started_at": dt.datetime.fromtimestamp(started, dt.timezone.utc).isoformat(),
|
|
825
|
+
"finished_at": now_iso(),
|
|
826
|
+
"duration_ms": int((time.time() - started) * 1000),
|
|
827
|
+
"dry_run": bool(args.dry_run),
|
|
828
|
+
"executor": executor_name,
|
|
829
|
+
"model": preset.get("model") if preset else None,
|
|
830
|
+
"profile": preset.get("profile") if preset else None,
|
|
831
|
+
"session_count": len(results),
|
|
832
|
+
"candidate_count": len(candidates),
|
|
833
|
+
"done_count": sum(item["status"] == "done" for item in results),
|
|
834
|
+
"failed_count": sum(item["status"] == "failed" for item in results),
|
|
835
|
+
"skipped_count": sum(item["status"] == "skipped" for item in results),
|
|
836
|
+
"raw_bytes": sum(item["raw_size"] for item in results),
|
|
837
|
+
"new_bytes": sum(item["new_bytes"] for item in results),
|
|
838
|
+
"semantic_messages": sum(item["semantic_messages"] for item in results),
|
|
839
|
+
"semantic_chars": sum(item["semantic_chars"] for item in results),
|
|
840
|
+
"summary_usage": usage,
|
|
841
|
+
"usage_unavailable_invocations": unavailable,
|
|
842
|
+
"invocations": invocations,
|
|
843
|
+
"sessions": results,
|
|
844
|
+
}
|
|
845
|
+
_atomic_json(SCAN_ROOT / "runs" / f"{run_id}.json", run)
|
|
846
|
+
_atomic_json(SCAN_ROOT / "last-run.json", run)
|
|
847
|
+
_append_jsonl(SCAN_ROOT / "events.jsonl", {
|
|
848
|
+
"at": now_iso(), "event": "scan_completed", "run_id": run_id,
|
|
849
|
+
"done": run["done_count"], "failed": run["failed_count"], "skipped": run["skipped_count"],
|
|
850
|
+
})
|
|
851
|
+
print(json.dumps(run, ensure_ascii=False, indent=2) if args.json else _format_run(run))
|
|
852
|
+
return 1 if run["failed_count"] else 0
|
|
853
|
+
|
|
854
|
+
|
|
855
|
+
def _format_tokens(usage):
|
|
856
|
+
return str((usage or {}).get("total_tokens", "unavailable"))
|
|
857
|
+
|
|
858
|
+
|
|
859
|
+
def _format_run(run):
|
|
860
|
+
return (
|
|
861
|
+
f"run {run['run_id']}: {run['done_count']} done, {run['failed_count']} failed, "
|
|
862
|
+
f"{run['skipped_count']} skipped; {run['new_bytes']} new bytes; "
|
|
863
|
+
f"summary tokens {_format_tokens(run.get('summary_usage'))}"
|
|
864
|
+
)
|
|
865
|
+
|
|
866
|
+
|
|
867
|
+
def _runs():
|
|
868
|
+
return [value for path in sorted((SCAN_ROOT / "runs").glob("*.json"), reverse=True) if isinstance((value := _read_json(path)), dict)]
|
|
869
|
+
|
|
870
|
+
|
|
871
|
+
def command_stats(args):
|
|
872
|
+
runs = _runs()
|
|
873
|
+
if args.since:
|
|
874
|
+
runs = [item for item in runs if (item.get("started_at") or "") >= args.since]
|
|
875
|
+
repos = {}
|
|
876
|
+
total_usage = {}
|
|
877
|
+
unavailable = 0
|
|
878
|
+
for run in runs:
|
|
879
|
+
usage = run.get("summary_usage") or {}
|
|
880
|
+
for key, value in usage.items():
|
|
881
|
+
if isinstance(value, int):
|
|
882
|
+
total_usage[key] = total_usage.get(key, 0) + value
|
|
883
|
+
unavailable += int(run.get("usage_unavailable_invocations", 0))
|
|
884
|
+
for session in run.get("sessions", []):
|
|
885
|
+
key = session.get("repo_key")
|
|
886
|
+
if not key or (args.repo and key != args.repo):
|
|
887
|
+
continue
|
|
888
|
+
item = repos.setdefault(key, {"repo_key": key, "scan_count": 0, "sessions": set(), "raw_bytes": 0, "new_bytes": 0, "summary_tokens": 0})
|
|
889
|
+
item["scan_count"] += 1
|
|
890
|
+
item["sessions"].add(session.get("session_id"))
|
|
891
|
+
item["raw_bytes"] += int(session.get("raw_size", 0))
|
|
892
|
+
item["new_bytes"] += int(session.get("new_bytes", 0))
|
|
893
|
+
item["summary_tokens"] += int((session.get("summary_usage") or {}).get("total_tokens", 0))
|
|
894
|
+
repo_rows = []
|
|
895
|
+
for item in repos.values():
|
|
896
|
+
item["session_count"] = len(item.pop("sessions"))
|
|
897
|
+
repo_rows.append(item)
|
|
898
|
+
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
|
+
print(json.dumps(payload, ensure_ascii=False, indent=2))
|
|
900
|
+
|
|
901
|
+
|
|
902
|
+
def command_history(args):
|
|
903
|
+
runs = _runs()
|
|
904
|
+
if args.repo:
|
|
905
|
+
runs = [run for run in runs if any(item.get("repo_key") == args.repo for item in run.get("sessions", []))]
|
|
906
|
+
runs = runs[:args.limit]
|
|
907
|
+
print(json.dumps(runs, ensure_ascii=False, indent=2) if args.json else "\n".join(_format_run(run) for run in runs))
|
|
908
|
+
|
|
909
|
+
|
|
910
|
+
def command_show(args):
|
|
911
|
+
path = SCAN_ROOT / "runs" / f"{args.run_id}.json"
|
|
912
|
+
value = _read_json(path)
|
|
913
|
+
if not isinstance(value, dict):
|
|
914
|
+
raise RuntimeError(f"run not found: {args.run_id}")
|
|
915
|
+
print(json.dumps(value, ensure_ascii=False, indent=2))
|
|
916
|
+
|
|
917
|
+
|
|
918
|
+
def _cli_path():
|
|
919
|
+
explicit = os.environ.get("DEV_MEMORY_CLI_PATH", "").strip()
|
|
920
|
+
if explicit:
|
|
921
|
+
return str(Path(explicit).expanduser().resolve())
|
|
922
|
+
installed = shutil.which("dev-memory-cli")
|
|
923
|
+
return installed or str(PACKAGE_ROOT / "bin" / "dev-memory.js")
|
|
924
|
+
|
|
925
|
+
|
|
926
|
+
def command_install(_args):
|
|
927
|
+
config = load_config()
|
|
928
|
+
save_scan_config(config)
|
|
929
|
+
logs = SCAN_ROOT / "logs"
|
|
930
|
+
logs.mkdir(parents=True, exist_ok=True)
|
|
931
|
+
plist = {
|
|
932
|
+
"Label": "com.dev-memory.session-scan",
|
|
933
|
+
"ProgramArguments": [_cli_path(), "session-scan", "run"],
|
|
934
|
+
"StartCalendarInterval": {"Hour": 3, "Minute": 0},
|
|
935
|
+
"RunAtLoad": False,
|
|
936
|
+
"StandardOutPath": str(logs / "launchd.stdout.log"),
|
|
937
|
+
"StandardErrorPath": str(logs / "launchd.stderr.log"),
|
|
938
|
+
"EnvironmentVariables": {"PATH": os.environ.get("PATH", "/usr/local/bin:/usr/bin:/bin")},
|
|
939
|
+
}
|
|
940
|
+
PLIST_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
941
|
+
with PLIST_PATH.open("wb") as stream:
|
|
942
|
+
plistlib.dump(plist, stream)
|
|
943
|
+
subprocess.run(["launchctl", "unload", str(PLIST_PATH)], capture_output=True, check=False)
|
|
944
|
+
result = subprocess.run(["launchctl", "load", str(PLIST_PATH)], capture_output=True, text=True, check=False)
|
|
945
|
+
if result.returncode != 0:
|
|
946
|
+
raise RuntimeError(result.stderr.strip() or "launchctl load failed")
|
|
947
|
+
print(json.dumps({"installed": True, "plist": str(PLIST_PATH), "schedule": "03:00", "logs": str(logs)}, ensure_ascii=False, indent=2))
|
|
948
|
+
|
|
949
|
+
|
|
950
|
+
def command_uninstall(_args):
|
|
951
|
+
subprocess.run(["launchctl", "unload", str(PLIST_PATH)], capture_output=True, check=False)
|
|
952
|
+
PLIST_PATH.unlink(missing_ok=True)
|
|
953
|
+
print(json.dumps({"installed": False, "plist": str(PLIST_PATH)}, ensure_ascii=False, indent=2))
|
|
954
|
+
|
|
955
|
+
|
|
956
|
+
def command_status(_args):
|
|
957
|
+
config = load_config()
|
|
958
|
+
validation = validate_config(config)
|
|
959
|
+
try:
|
|
960
|
+
executor, preset = choose_executor(config)
|
|
961
|
+
except RuntimeError:
|
|
962
|
+
executor, preset = None, None
|
|
963
|
+
print(json.dumps({
|
|
964
|
+
"installed": PLIST_PATH.exists(),
|
|
965
|
+
"plist": str(PLIST_PATH),
|
|
966
|
+
"scan_root": str(SCAN_ROOT),
|
|
967
|
+
"codex_sessions": str(CODEX_HOME / "sessions"),
|
|
968
|
+
"executor": executor,
|
|
969
|
+
"model": preset.get("model") if preset else None,
|
|
970
|
+
"config": validation,
|
|
971
|
+
"last_run": _read_json(SCAN_ROOT / "last-run.json"),
|
|
972
|
+
}, ensure_ascii=False, indent=2))
|
|
973
|
+
|
|
974
|
+
|
|
975
|
+
def command_config(args):
|
|
976
|
+
config = load_config()
|
|
977
|
+
if args.config_command == "show":
|
|
978
|
+
print(json.dumps(config, ensure_ascii=False, indent=2))
|
|
979
|
+
return
|
|
980
|
+
if args.config_command == "validate":
|
|
981
|
+
result = validate_config(config)
|
|
982
|
+
print(json.dumps(result, ensure_ascii=False, indent=2))
|
|
983
|
+
if not result["valid"]:
|
|
984
|
+
raise SystemExit(1)
|
|
985
|
+
return
|
|
986
|
+
if args.config_command == "set-executor":
|
|
987
|
+
if args.executor != "auto" and args.executor not in config["executors"]:
|
|
988
|
+
raise RuntimeError(f"unknown executor preset: {args.executor}")
|
|
989
|
+
config["executor"] = args.executor
|
|
990
|
+
elif args.config_command in {"set-model", "set-profile"}:
|
|
991
|
+
if args.executor not in config["executors"]:
|
|
992
|
+
raise RuntimeError(f"unknown executor preset: {args.executor}")
|
|
993
|
+
field = "model" if args.config_command == "set-model" else "profile"
|
|
994
|
+
config["executors"][args.executor][field] = args.value
|
|
995
|
+
save_scan_config(config)
|
|
996
|
+
print(json.dumps(config, ensure_ascii=False, indent=2))
|
|
997
|
+
|
|
998
|
+
|
|
999
|
+
def build_parser():
|
|
1000
|
+
parser = argparse.ArgumentParser(description="Scan Codex sessions into dev-memory")
|
|
1001
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
1002
|
+
run = sub.add_parser("run")
|
|
1003
|
+
run.add_argument("--since")
|
|
1004
|
+
run.add_argument("--dry-run", action="store_true")
|
|
1005
|
+
run.add_argument("--json", action="store_true")
|
|
1006
|
+
sub.add_parser("install")
|
|
1007
|
+
sub.add_parser("status")
|
|
1008
|
+
stats = sub.add_parser("stats")
|
|
1009
|
+
stats.add_argument("--repo")
|
|
1010
|
+
stats.add_argument("--since")
|
|
1011
|
+
stats.add_argument("--json", action="store_true")
|
|
1012
|
+
history = sub.add_parser("history")
|
|
1013
|
+
history.add_argument("--repo")
|
|
1014
|
+
history.add_argument("--limit", type=int, default=20)
|
|
1015
|
+
history.add_argument("--json", action="store_true")
|
|
1016
|
+
show = sub.add_parser("show")
|
|
1017
|
+
show.add_argument("run_id")
|
|
1018
|
+
sub.add_parser("uninstall")
|
|
1019
|
+
config = sub.add_parser("config")
|
|
1020
|
+
config_sub = config.add_subparsers(dest="config_command", required=True)
|
|
1021
|
+
config_sub.add_parser("show")
|
|
1022
|
+
config_sub.add_parser("validate")
|
|
1023
|
+
set_executor = config_sub.add_parser("set-executor")
|
|
1024
|
+
set_executor.add_argument("executor")
|
|
1025
|
+
for command in ("set-model", "set-profile"):
|
|
1026
|
+
item = config_sub.add_parser(command)
|
|
1027
|
+
item.add_argument("executor")
|
|
1028
|
+
item.add_argument("value")
|
|
1029
|
+
return parser
|
|
1030
|
+
|
|
1031
|
+
|
|
1032
|
+
def main():
|
|
1033
|
+
args = build_parser().parse_args()
|
|
1034
|
+
handlers = {
|
|
1035
|
+
"run": run_scan,
|
|
1036
|
+
"install": command_install,
|
|
1037
|
+
"status": command_status,
|
|
1038
|
+
"stats": command_stats,
|
|
1039
|
+
"history": command_history,
|
|
1040
|
+
"show": command_show,
|
|
1041
|
+
"uninstall": command_uninstall,
|
|
1042
|
+
"config": command_config,
|
|
1043
|
+
}
|
|
1044
|
+
try:
|
|
1045
|
+
return handlers[args.command](args) or 0
|
|
1046
|
+
except RuntimeError as exc:
|
|
1047
|
+
print(f"ERROR: {exc}", file=sys.stderr)
|
|
1048
|
+
return 1
|
|
1049
|
+
|
|
1050
|
+
|
|
1051
|
+
if __name__ == "__main__":
|
|
1052
|
+
raise SystemExit(main())
|