nexo-brain 2.6.12 → 2.6.14

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.
@@ -275,19 +275,19 @@ def build_evolution_prompt(week_data: dict, objective: dict) -> str:
275
275
  if mode == "review":
276
276
  mode_desc = "review-only, nothing executes automatically"
277
277
  safe_zones = "~/.nexo/scripts/, ~/.nexo/plugins/, ~/.nexo/brain/"
278
- immutable_files = "db.py, server.py, plugin_loader.py, storage_router.py, cognitive.py, knowledge_graph.py, tools_*.py, nexo-watchdog.sh, evolution_cycle.py, CLAUDE.md"
278
+ immutable_files = "db.py, server.py, plugin_loader.py, storage_router.py, cognitive.py, knowledge_graph.py, tools_*.py, nexo-watchdog.sh, evolution_cycle.py, CLAUDE.md, AGENTS.md"
279
279
  elif mode == "managed":
280
280
  mode_desc = f"owner-managed, max {max_auto} auto-applied changes with rollback and followups"
281
281
  safe_zones = "~/.nexo/scripts/, ~/.nexo/plugins/, ~/.nexo/brain/, NEXO_CODE/src, repo bin/docs/templates/tests"
282
- immutable_files = "db.py, server.py, plugin_loader.py, storage_router.py, nexo-watchdog.sh, evolution_cycle.py, CLAUDE.md, personality.md, user-profile.md"
282
+ immutable_files = "db.py, server.py, plugin_loader.py, storage_router.py, nexo-watchdog.sh, evolution_cycle.py, CLAUDE.md, AGENTS.md, personality.md, user-profile.md"
283
283
  elif mode == "public_core":
284
284
  mode_desc = "public core contribution via isolated checkout and Draft PR"
285
285
  safe_zones = "isolated public repo checkout only"
286
- immutable_files = "personal runtime, ~/.nexo/**, local DBs/logs, CLAUDE.md, user-profile.md"
286
+ immutable_files = "personal runtime, ~/.nexo/**, local DBs/logs, CLAUDE.md, AGENTS.md, user-profile.md"
287
287
  else:
288
288
  mode_desc = f"public auto, max {max_auto} auto-applied changes in personal safe zones"
289
289
  safe_zones = "~/.nexo/scripts/, ~/.nexo/plugins/"
290
- immutable_files = "db.py, server.py, plugin_loader.py, storage_router.py, cognitive.py, knowledge_graph.py, tools_*.py, nexo-watchdog.sh, evolution_cycle.py, CLAUDE.md"
290
+ immutable_files = "db.py, server.py, plugin_loader.py, storage_router.py, cognitive.py, knowledge_graph.py, tools_*.py, nexo-watchdog.sh, evolution_cycle.py, CLAUDE.md, AGENTS.md"
291
291
 
292
292
  prompt = f"""You are NEXO Evolution — the weekly self-improvement cycle.
293
293
 
@@ -173,6 +173,22 @@ if os.path.exists(audit_file):
173
173
  except Exception:
174
174
  pass
175
175
 
176
+ # Evolution status
177
+ evolution_file = os.path.join(nexo_home, 'brain', 'evolution-objective.json')
178
+ if os.path.exists(evolution_file):
179
+ try:
180
+ evo = json.load(open(evolution_file))
181
+ lines.append('## Evolution')
182
+ lines.append(f\"Enabled: {bool(evo.get('evolution_enabled', True))}\")
183
+ lines.append(f\"Mode: {evo.get('evolution_mode', 'auto')}\")
184
+ lines.append(f\"Last evolution: {evo.get('last_evolution', 'never')}\")
185
+ lines.append(f\"Total evolutions: {evo.get('total_evolutions', 0)}\")
186
+ if evo.get('disabled_reason'):
187
+ lines.append(f\"Disabled reason: {evo.get('disabled_reason')}\")
188
+ lines.append('')
189
+ except Exception:
190
+ pass
191
+
176
192
  print('\n'.join(lines))
177
193
  " > "$BRIEFING_FILE" 2>/dev/null
178
194
 
@@ -56,21 +56,20 @@ def handle_schedule_status(hours: int = 24, cron_id: str = '') -> str:
56
56
 
57
57
  def handle_schedule_add(cron_id: str, script: str, schedule: str = '',
58
58
  interval_seconds: int = 0, description: str = '',
59
- script_type: str = 'auto') -> str:
59
+ script_type: str = 'auto', keep_alive: bool = False) -> str:
60
60
  """Add a new personal cron job. Generates and installs the LaunchAgent (macOS) or systemd timer (Linux).
61
61
 
62
62
  Args:
63
63
  cron_id: Unique ID for this cron (e.g. 'my-backup', 'report-daily'). Must be lowercase with hyphens.
64
64
  script: Path to the script to run (absolute or relative to NEXO_HOME/scripts/).
65
65
  schedule: Time-based schedule as 'HH:MM' (daily) or 'HH:MM:weekday' (e.g. '08:00:1' for Monday 8AM). Mutually exclusive with interval_seconds.
66
- interval_seconds: Run every N seconds (e.g. 300 for every 5 min). Mutually exclusive with schedule.
66
+ interval_seconds: Run every N seconds (e.g. 300 for every 5 min). Mutually exclusive with schedule/keep_alive.
67
67
  description: What this cron does (for logs and status).
68
68
  script_type: 'auto' (default), 'python', 'shell', 'node', or 'php'.
69
+ keep_alive: Run as a daemon/keep-alive service instead of a timer.
69
70
  """
70
71
  if not cron_id or not script:
71
72
  return "ERROR: cron_id and script are required."
72
- if not schedule and not interval_seconds:
73
- return "ERROR: either schedule (e.g. '08:00') or interval_seconds (e.g. 300) is required."
74
73
 
75
74
  nexo_home = Path(os.environ.get("NEXO_HOME", str(Path.home() / ".nexo")))
76
75
  script_path = Path(script)
@@ -82,6 +81,11 @@ def handle_schedule_add(cron_id: str, script: str, schedule: str = '',
82
81
  script_meta = parse_inline_metadata(script_path)
83
82
  detected_runtime = classify_runtime(script_path, script_meta)
84
83
  declared = get_declared_schedule(script_meta, script_meta.get("name", script_path.stem))
84
+ keep_alive = bool(keep_alive or declared.get("schedule_type") == "keep_alive")
85
+
86
+ if sum(bool(value) for value in [schedule, interval_seconds, keep_alive]) != 1:
87
+ return "ERROR: choose exactly one schedule mode: schedule, interval_seconds, or keep_alive."
88
+
85
89
  script_type = (script_type or "auto").strip().lower()
86
90
  if script_type == "auto":
87
91
  script_type = detected_runtime if detected_runtime != "unknown" else "python"
@@ -102,6 +106,7 @@ def handle_schedule_add(cron_id: str, script: str, schedule: str = '',
102
106
  description or script_meta.get("description", ""),
103
107
  script_type,
104
108
  nexo_home,
109
+ keep_alive=keep_alive,
105
110
  declared=declared,
106
111
  )
107
112
  elif system == "Linux":
@@ -114,6 +119,7 @@ def handle_schedule_add(cron_id: str, script: str, schedule: str = '',
114
119
  description or script_meta.get("description", ""),
115
120
  script_type,
116
121
  nexo_home,
122
+ keep_alive=keep_alive,
117
123
  declared=declared,
118
124
  )
119
125
  else:
@@ -134,7 +140,7 @@ def _runtime_command(script_type: str) -> str:
134
140
  return "python3"
135
141
 
136
142
 
137
- def _register_schedule_metadata(cron_id, script_path, schedule, interval_seconds, description, script_type, label="", plist_path=""):
143
+ def _register_schedule_metadata(cron_id, script_path, schedule, interval_seconds, description, script_type, label="", plist_path="", keep_alive: bool = False):
138
144
  init_db()
139
145
  script_meta = parse_inline_metadata(Path(script_path))
140
146
  runtime = classify_runtime(Path(script_path), script_meta)
@@ -148,7 +154,11 @@ def _register_schedule_metadata(cron_id, script_path, schedule, interval_seconds
148
154
  source="filesystem",
149
155
  has_inline_metadata=bool(script_meta),
150
156
  )
151
- if interval_seconds:
157
+ if keep_alive:
158
+ schedule_type = "keep_alive"
159
+ schedule_value = "true"
160
+ schedule_label = "keep alive"
161
+ elif interval_seconds:
152
162
  schedule_type = "interval"
153
163
  schedule_value = str(interval_seconds)
154
164
  schedule_label = f"every {interval_seconds}s"
@@ -174,7 +184,7 @@ def _register_schedule_metadata(cron_id, script_path, schedule, interval_seconds
174
184
 
175
185
 
176
186
  def _add_launchagent(cron_id, script_path, wrapper_path, schedule, interval_seconds,
177
- description, script_type, nexo_home, *, declared: dict | None = None):
187
+ description, script_type, nexo_home, *, keep_alive: bool = False, declared: dict | None = None):
178
188
  """Create and load a macOS LaunchAgent."""
179
189
  import plistlib
180
190
 
@@ -201,7 +211,9 @@ def _add_launchagent(cron_id, script_path, wrapper_path, schedule, interval_seco
201
211
  },
202
212
  }
203
213
 
204
- if interval_seconds:
214
+ if keep_alive:
215
+ plist["KeepAlive"] = True
216
+ elif interval_seconds:
205
217
  plist["StartInterval"] = interval_seconds
206
218
  elif schedule:
207
219
  parts = schedule.split(":")
@@ -211,7 +223,7 @@ def _add_launchagent(cron_id, script_path, wrapper_path, schedule, interval_seco
211
223
  plist["StartCalendarInterval"] = cal
212
224
 
213
225
  declared = declared or {}
214
- if declared.get("run_on_boot"):
226
+ if declared.get("run_on_boot") or (keep_alive and "run_on_boot" not in declared):
215
227
  plist["RunAtLoad"] = True
216
228
 
217
229
  with open(plist_path, "wb") as f:
@@ -228,13 +240,20 @@ def _add_launchagent(cron_id, script_path, wrapper_path, schedule, interval_seco
228
240
  script_type,
229
241
  label=label,
230
242
  plist_path=str(plist_path),
243
+ keep_alive=keep_alive,
231
244
  )
232
245
 
233
- return f"Cron '{cron_id}' installed at {plist_path} and loaded.{' Schedule: ' + schedule if schedule else f' Interval: {interval_seconds}s'}"
246
+ if keep_alive:
247
+ detail = " KeepAlive daemon"
248
+ elif schedule:
249
+ detail = f" Schedule: {schedule}"
250
+ else:
251
+ detail = f" Interval: {interval_seconds}s"
252
+ return f"Cron '{cron_id}' installed at {plist_path} and loaded.{detail}"
234
253
 
235
254
 
236
255
  def _add_systemd_timer(cron_id, script_path, wrapper_path, schedule, interval_seconds,
237
- description, script_type, nexo_home, *, declared: dict | None = None):
256
+ description, script_type, nexo_home, *, keep_alive: bool = False, declared: dict | None = None):
238
257
  """Create and enable a systemd user timer (Linux)."""
239
258
  unit_dir = Path.home() / ".config" / "systemd" / "user"
240
259
  unit_dir.mkdir(parents=True, exist_ok=True)
@@ -257,9 +276,46 @@ Environment=NEXO_PERSONAL_CRON_ID={cron_id}
257
276
  service_path = unit_dir / f"nexo-{cron_id}.service"
258
277
  service_path.write_text(service_content)
259
278
 
260
- # Timer unit
261
279
  declared = declared or {}
262
280
 
281
+ if keep_alive:
282
+ service_content = f"""[Unit]
283
+ Description=NEXO daemon: {description or cron_id}
284
+
285
+ [Service]
286
+ Type=simple
287
+ ExecStart={exec_cmd}
288
+ Restart=always
289
+ RestartSec=10
290
+ Environment=NEXO_HOME={nexo_home}
291
+ Environment=HOME={Path.home()}
292
+ Environment={PERSONAL_SCHEDULE_MANAGED_ENV}=1
293
+ Environment=NEXO_PERSONAL_CRON_ID={cron_id}
294
+
295
+ [Install]
296
+ WantedBy=default.target
297
+ """
298
+ service_path = unit_dir / f"nexo-{cron_id}.service"
299
+ service_path.write_text(service_content)
300
+
301
+ subprocess.run(["systemctl", "--user", "daemon-reload"], capture_output=True)
302
+ subprocess.run(["systemctl", "--user", "enable", "--now", f"nexo-{cron_id}.service"], capture_output=True)
303
+
304
+ _register_schedule_metadata(
305
+ cron_id,
306
+ script_path,
307
+ schedule,
308
+ interval_seconds,
309
+ description,
310
+ script_type,
311
+ label=f"nexo-{cron_id}",
312
+ plist_path="",
313
+ keep_alive=True,
314
+ )
315
+
316
+ return f"Cron '{cron_id}' installed as KeepAlive systemd service and enabled. Service: {service_path}"
317
+
318
+ # Timer unit
263
319
  if interval_seconds:
264
320
  timer_spec = f"OnUnitActiveSec={interval_seconds}s"
265
321
  if declared.get("run_on_boot") or not declared.get("required"):
@@ -301,6 +357,7 @@ WantedBy=timers.target
301
357
  script_type,
302
358
  label=f"nexo-{cron_id}",
303
359
  plist_path="",
360
+ keep_alive=False,
304
361
  )
305
362
 
306
363
  return f"Cron '{cron_id}' installed as systemd timer and enabled. Service: {service_path}, Timer: {timer_path}"
@@ -32,6 +32,16 @@ _IGNORED_FILES = {
32
32
  }
33
33
  _IGNORED_DIRS = {"deep-sleep", "__pycache__"}
34
34
 
35
+ _LEGACY_WAKE_RECOVERY_METADATA = [
36
+ "# nexo: name=nexo-wake-recovery",
37
+ "# nexo: description=Recover interval LaunchAgents after macOS sleep/wake gaps",
38
+ "# nexo: runtime=shell",
39
+ "# nexo: cron_id=wake-recovery",
40
+ "# nexo: schedule_required=true",
41
+ "# nexo: recovery_policy=restart_daemon",
42
+ "# nexo: run_on_boot=true",
43
+ ]
44
+
35
45
  # Forbidden patterns — direct DB access from personal scripts
36
46
  _FORBIDDEN_PATTERNS = [
37
47
  re.compile(r"\bsqlite3\b"),
@@ -77,6 +87,33 @@ def get_scripts_dir() -> Path:
77
87
  return NEXO_HOME / "scripts"
78
88
 
79
89
 
90
+ def _apply_legacy_personal_script_backfills() -> None:
91
+ """Backfill metadata for known legacy personal scripts shipped before the registry existed."""
92
+ scripts_dir = get_scripts_dir()
93
+ wake_recovery = scripts_dir / "nexo-wake-recovery.sh"
94
+ if not wake_recovery.is_file():
95
+ return
96
+
97
+ try:
98
+ text = wake_recovery.read_text()
99
+ except Exception:
100
+ return
101
+
102
+ if "# nexo:" in "\n".join(text.splitlines()[:25]):
103
+ return
104
+ if "Wake Recovery" not in text:
105
+ return
106
+
107
+ lines = text.splitlines(keepends=True)
108
+ head: list[str] = []
109
+ start = 0
110
+ if lines and lines[0].startswith("#!"):
111
+ head.append(lines[0])
112
+ start = 1
113
+ head.extend([line + "\n" for line in _LEGACY_WAKE_RECOVERY_METADATA])
114
+ wake_recovery.write_text("".join(head + lines[start:]))
115
+
116
+
80
117
  def load_core_script_names() -> set[str]:
81
118
  """Load script names from crons/manifest.json (these are core, not personal)."""
82
119
  names: set[str] = set()
@@ -279,6 +316,11 @@ def get_declared_schedule(metadata: dict, default_name: str = "") -> dict:
279
316
  "cron_id": cron_id,
280
317
  }
281
318
 
319
+ def _effective_run_on_boot(policy: str) -> bool:
320
+ if "run_on_boot" in metadata:
321
+ return run_on_boot
322
+ return policy == "restart_daemon"
323
+
282
324
  def _effective_run_on_wake(policy: str) -> bool:
283
325
  if "run_on_wake" in metadata:
284
326
  return run_on_wake
@@ -379,12 +421,29 @@ def get_declared_schedule(metadata: dict, default_name: str = "") -> dict:
379
421
  "schedule": schedule_raw,
380
422
  "interval_seconds": 0,
381
423
  "recovery_policy": recovery_policy_raw or "catchup",
382
- "run_on_boot": run_on_boot,
424
+ "run_on_boot": _effective_run_on_boot(recovery_policy_raw or "catchup"),
383
425
  "run_on_wake": _effective_run_on_wake(recovery_policy_raw or "catchup"),
384
426
  "idempotent": _effective_idempotent(recovery_policy_raw or "catchup"),
385
427
  "max_catchup_age": max_catchup_age or (14 * 86400 if weekday is not None else 48 * 3600),
386
428
  }
387
429
 
430
+ if required and recovery_policy_raw == "restart_daemon":
431
+ return {
432
+ "required": required,
433
+ "valid": True,
434
+ "cron_id": cron_id,
435
+ "schedule_type": "keep_alive",
436
+ "schedule_value": "true",
437
+ "schedule_label": "keep alive",
438
+ "schedule": "",
439
+ "interval_seconds": 0,
440
+ "recovery_policy": "restart_daemon",
441
+ "run_on_boot": _effective_run_on_boot("restart_daemon"),
442
+ "run_on_wake": _effective_run_on_wake("restart_daemon"),
443
+ "idempotent": _effective_idempotent("restart_daemon"),
444
+ "max_catchup_age": max_catchup_age,
445
+ }
446
+
388
447
  return {
389
448
  "required": required,
390
449
  "valid": not required,
@@ -416,6 +475,7 @@ def _script_entry(path: Path, meta: dict, *, is_core: bool, classification: str,
416
475
 
417
476
  def classify_scripts_dir() -> dict:
418
477
  """Classify every file in NEXO_HOME/scripts into personal/core/ignored/non-script buckets."""
478
+ _apply_legacy_personal_script_backfills()
419
479
  scripts_dir = get_scripts_dir()
420
480
  if not scripts_dir.is_dir():
421
481
  return {"scripts_dir": str(scripts_dir), "entries": [], "summary": {}}
@@ -927,6 +987,7 @@ def ensure_personal_schedules(*, dry_run: bool = False) -> dict:
927
987
  interval_seconds=declared.get("interval_seconds", 0),
928
988
  description=script.get("description", ""),
929
989
  script_type=script.get("runtime", "auto"),
990
+ keep_alive=declared.get("schedule_type") == "keep_alive",
930
991
  )
931
992
  target = report["repaired" if existing else "created"]
932
993
  target.append({
@@ -4,7 +4,8 @@ from __future__ import annotations
4
4
  Deep Sleep v2 -- Phase 1: Collect all context for overnight analysis.
5
5
 
6
6
  Gathers transcripts, DB data, logs, and discovered files into a single
7
- plain-text context file that subsequent phases read via Claude's Read tool.
7
+ plain-text context file that subsequent phases read via the configured
8
+ automation backend.
8
9
 
9
10
  Environment variables:
10
11
  NEXO_HOME -- root of the NEXO installation (default: ~/.nexo)
@@ -52,22 +53,43 @@ def _redact_sensitive(text: str) -> str:
52
53
  return _SENSITIVE_PATTERNS.sub('[REDACTED]', text)
53
54
 
54
55
 
55
- # ── Transcript collection (kept from collect_transcripts.py) ──────────────
56
+ # ── Transcript collection (Claude Code + Codex) ────────────────────────────
56
57
 
57
58
 
58
- def find_session_dirs() -> list[Path]:
59
- """Find all Claude Code project directories that contain .jsonl files."""
59
+ def _session_identifier(client: str, session_file: str) -> str:
60
+ return f"{client}:{session_file}"
61
+
62
+
63
+ def find_claude_session_files() -> list[Path]:
64
+ """Find Claude Code session JSONL files under ~/.claude/projects."""
60
65
  claude_dir = Path.home() / ".claude" / "projects"
61
66
  if not claude_dir.exists():
62
67
  return []
63
- dirs = set()
64
- for jsonl in claude_dir.rglob("*.jsonl"):
65
- dirs.add(jsonl.parent)
66
- return list(dirs)
68
+ return sorted(claude_dir.rglob("*.jsonl"))
69
+
70
+
71
+ def find_codex_session_files() -> list[Path]:
72
+ """Find Codex session JSONL files under ~/.codex/sessions and archived_sessions."""
73
+ roots = [
74
+ Path.home() / ".codex" / "sessions",
75
+ Path.home() / ".codex" / "archived_sessions",
76
+ ]
77
+ files: list[Path] = []
78
+ seen: set[str] = set()
79
+ for root in roots:
80
+ if not root.exists():
81
+ continue
82
+ for jsonl in sorted(root.rglob("*.jsonl")):
83
+ key = jsonl.name
84
+ if key in seen:
85
+ continue
86
+ seen.add(key)
87
+ files.append(jsonl)
88
+ return files
67
89
 
68
90
 
69
- def extract_session(jsonl_path: Path) -> dict | None:
70
- """Extract clean transcript from a session JSONL file."""
91
+ def extract_claude_session(jsonl_path: Path) -> dict | None:
92
+ """Extract clean transcript from a Claude Code JSONL session."""
71
93
  messages = []
72
94
  tool_uses = []
73
95
  user_msg_count = 0
@@ -136,13 +158,99 @@ def extract_session(jsonl_path: Path) -> dict | None:
136
158
  return None
137
159
 
138
160
  return {
139
- "session_file": jsonl_path.name,
161
+ "client": "claude_code",
162
+ "session_file": _session_identifier("claude_code", jsonl_path.name),
163
+ "display_name": jsonl_path.name,
164
+ "session_path": str(jsonl_path),
165
+ "message_count": len(messages),
166
+ "user_message_count": user_msg_count,
167
+ "tool_use_count": len(tool_uses),
168
+ "messages": messages,
169
+ "tool_uses": tool_uses,
170
+ "source": "claude_projects",
171
+ }
172
+
173
+
174
+ def extract_codex_session(jsonl_path: Path) -> dict | None:
175
+ """Extract clean transcript from a Codex JSONL session."""
176
+ messages = []
177
+ tool_uses = []
178
+ user_msg_count = 0
179
+ session_meta: dict = {}
180
+
181
+ try:
182
+ with open(jsonl_path, "r") as f:
183
+ for line_no, line in enumerate(f, 1):
184
+ line = line.strip()
185
+ if not line:
186
+ continue
187
+ try:
188
+ d = json.loads(line)
189
+ except json.JSONDecodeError:
190
+ continue
191
+
192
+ item_type = d.get("type")
193
+ payload = d.get("payload", {})
194
+
195
+ if item_type == "session_meta" and isinstance(payload, dict):
196
+ session_meta = payload
197
+ continue
198
+
199
+ if item_type == "event_msg" and isinstance(payload, dict) and payload.get("type") == "user_message":
200
+ content = str(payload.get("message", "") or "").strip()
201
+ if not content or content.startswith("<environment_context>"):
202
+ continue
203
+ messages.append({
204
+ "role": "user",
205
+ "index": line_no,
206
+ "text": _redact_sensitive(content[:5000]),
207
+ })
208
+ user_msg_count += 1
209
+ continue
210
+
211
+ if item_type == "response_item" and isinstance(payload, dict):
212
+ response_type = payload.get("type")
213
+ role = payload.get("role")
214
+ if response_type == "message" and role == "assistant":
215
+ text_parts = []
216
+ for block in payload.get("content", []) or []:
217
+ if isinstance(block, dict) and block.get("type") == "output_text":
218
+ text_parts.append(str(block.get("text", "")))
219
+ combined = "\n".join(part for part in text_parts if part).strip()
220
+ if combined:
221
+ messages.append({
222
+ "role": "assistant",
223
+ "index": line_no,
224
+ "text": _redact_sensitive(combined[:5000]),
225
+ })
226
+ elif response_type == "function_call":
227
+ tool_uses.append({
228
+ "tool": payload.get("name", ""),
229
+ "input_keys": [],
230
+ "file": _redact_sensitive(str(payload.get("arguments", ""))[:100]),
231
+ })
232
+
233
+ except Exception as e:
234
+ print(f" [collect] Error reading {jsonl_path}: {e}", file=sys.stderr)
235
+ return None
236
+
237
+ if user_msg_count < MIN_USER_MESSAGES:
238
+ return None
239
+
240
+ return {
241
+ "client": "codex",
242
+ "session_file": _session_identifier("codex", jsonl_path.name),
243
+ "display_name": jsonl_path.name,
140
244
  "session_path": str(jsonl_path),
141
245
  "message_count": len(messages),
142
246
  "user_message_count": user_msg_count,
143
247
  "tool_use_count": len(tool_uses),
144
248
  "messages": messages,
145
- "tool_uses": tool_uses
249
+ "tool_uses": tool_uses,
250
+ "source": session_meta.get("source", "codex"),
251
+ "cwd": session_meta.get("cwd", ""),
252
+ "originator": session_meta.get("originator", ""),
253
+ "session_uid": session_meta.get("id", ""),
146
254
  }
147
255
 
148
256
 
@@ -156,17 +264,22 @@ def collect_transcripts_since(since_iso: str, until_iso: str = "") -> list[dict]
156
264
  until_dt = datetime.fromisoformat(until_iso) if until_iso else datetime.now()
157
265
 
158
266
  sessions = []
159
- for sdir in find_session_dirs():
160
- for f in sdir.glob("*.jsonl"):
161
- try:
162
- mtime = datetime.fromtimestamp(f.stat().st_mtime)
163
- except OSError:
164
- continue
165
- if since_dt < mtime <= until_dt:
166
- session = extract_session(f)
167
- if session:
168
- session["modified"] = mtime.isoformat()
169
- sessions.append(session)
267
+ transcript_files: list[tuple[str, Path]] = [
268
+ ("claude_code", path) for path in find_claude_session_files()
269
+ ] + [
270
+ ("codex", path) for path in find_codex_session_files()
271
+ ]
272
+ for client, session_file in transcript_files:
273
+ try:
274
+ mtime = datetime.fromtimestamp(session_file.stat().st_mtime)
275
+ except OSError:
276
+ continue
277
+ if not (since_dt < mtime <= until_dt):
278
+ continue
279
+ session = extract_codex_session(session_file) if client == "codex" else extract_claude_session(session_file)
280
+ if session:
281
+ session["modified"] = mtime.isoformat()
282
+ sessions.append(session)
170
283
  sessions.sort(key=lambda s: s["modified"])
171
284
  return sessions
172
285
 
@@ -353,6 +466,9 @@ def format_transcripts(sessions: list[dict]) -> str:
353
466
  for i, session in enumerate(sessions):
354
467
  lines.append(f"\n{'─' * 60}")
355
468
  lines.append(f"SESSION {i + 1}: {session['session_file']}")
469
+ lines.append(f"Client: {session.get('client', 'unknown')}")
470
+ if session.get("source"):
471
+ lines.append(f"Source: {session['source']}")
356
472
  lines.append(f"Modified: {session['modified']}")
357
473
  lines.append(f"Messages: {session['message_count']}, Tool uses: {session['tool_use_count']}")
358
474
  lines.append(f"{'─' * 60}")
@@ -460,18 +576,27 @@ def main():
460
576
 
461
577
  # Individual session files
462
578
  session_files_written = []
579
+ session_txt_map = {}
463
580
  total_size = len(shared_text.encode("utf-8"))
464
581
  for i, session in enumerate(sessions):
465
- sid_short = session["session_file"].replace(".jsonl", "")[:20]
582
+ raw_id = session["session_file"].replace(".jsonl", "").replace(":", "-")
583
+ sid_short = raw_id[:30]
466
584
  filename = f"session-{i+1:02d}-{sid_short}.txt"
467
585
  session_path = date_dir / filename
468
586
 
469
587
  lines = [
470
588
  f"Session: {session['session_file']}",
589
+ f"Display name: {session.get('display_name', session['session_file'])}",
590
+ f"Client: {session.get('client', 'unknown')}",
591
+ f"Source: {session.get('source', 'unknown')}",
471
592
  f"Modified: {session['modified']}",
472
593
  f"Messages: {session['message_count']}, Tool uses: {session['tool_use_count']}",
473
594
  f"{'─' * 60}",
474
595
  ]
596
+ if session.get("cwd"):
597
+ lines.insert(4, f"CWD: {session['cwd']}")
598
+ if session.get("originator"):
599
+ lines.insert(4, f"Originator: {session['originator']}")
475
600
  for msg in session["messages"]:
476
601
  role = "USER" if msg["role"] == "user" else "AGENT"
477
602
  idx = msg.get("index", "?")
@@ -487,6 +612,7 @@ def main():
487
612
  session_text = "\n".join(lines)
488
613
  session_path.write_text(session_text, encoding="utf-8")
489
614
  session_files_written.append(filename)
615
+ session_txt_map[session["session_file"]] = filename
490
616
  total_size += len(session_text.encode("utf-8"))
491
617
  print(f" {filename}: {len(session_text) / 1024:.0f} KB")
492
618
 
@@ -508,6 +634,18 @@ def main():
508
634
  "sessions_found": len(sessions),
509
635
  "session_files": [s["session_file"] for s in sessions],
510
636
  "session_txt_files": session_files_written,
637
+ "session_txt_map": session_txt_map,
638
+ "session_manifest": [
639
+ {
640
+ "session_id": s["session_file"],
641
+ "display_name": s.get("display_name", s["session_file"]),
642
+ "client": s.get("client", "unknown"),
643
+ "source": s.get("source", ""),
644
+ "session_path": s.get("session_path", ""),
645
+ "session_txt_file": session_txt_map.get(s["session_file"], ""),
646
+ }
647
+ for s in sessions
648
+ ],
511
649
  "total_messages": sum(s["message_count"] for s in sessions),
512
650
  "total_tool_uses": sum(s["tool_use_count"] for s in sessions),
513
651
  "followups_active": len(followups),