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.
@@ -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())