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.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +37 -5
- package/bin/nexo-brain.js +1 -0
- package/package.json +3 -2
- package/src/agent_runner.py +19 -0
- package/src/auto_update.py +62 -77
- package/src/bootstrap_docs.py +357 -0
- package/src/cli.py +3 -1
- package/src/client_sync.py +58 -6
- package/src/doctor/providers/runtime.py +172 -0
- package/src/evolution_cycle.py +4 -4
- package/src/hooks/session-start.sh +16 -0
- package/src/plugins/schedule.py +69 -12
- package/src/script_registry.py +62 -1
- package/src/scripts/deep-sleep/collect.py +162 -24
- package/src/scripts/deep-sleep/extract.py +33 -6
- package/src/scripts/nexo-daily-self-audit.py +1 -1
- package/src/scripts/nexo-deep-sleep.sh +2 -2
- package/src/scripts/nexo-evolution-run.py +5 -2
- package/src/scripts/nexo-watchdog.sh +1 -1
- package/src/server.py +9 -2
- package/src/tools_sessions.py +3 -2
- package/templates/CLAUDE.md.template +34 -10
- package/templates/CODEX.AGENTS.md.template +45 -0
package/src/evolution_cycle.py
CHANGED
|
@@ -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
|
|
package/src/plugins/schedule.py
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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}"
|
package/src/script_registry.py
CHANGED
|
@@ -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":
|
|
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
|
|
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 (
|
|
56
|
+
# ── Transcript collection (Claude Code + Codex) ────────────────────────────
|
|
56
57
|
|
|
57
58
|
|
|
58
|
-
def
|
|
59
|
-
|
|
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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
|
70
|
-
"""Extract clean transcript from a
|
|
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
|
-
"
|
|
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
|
-
|
|
160
|
-
for
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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
|
-
|
|
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),
|