nexo-brain 3.1.9 → 4.0.0

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,196 @@
1
+ """Readable export and auto-flush inspection tools."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ from datetime import datetime
8
+ from pathlib import Path
9
+
10
+ import cognitive
11
+ import claim_graph
12
+ import compaction_memory
13
+ import media_memory
14
+ import user_state_model
15
+ from db import get_db
16
+ from memory_backends import get_backend, list_backends
17
+
18
+
19
+ def _nexo_home() -> Path:
20
+ return Path(os.environ.get("NEXO_HOME", str(Path.home() / ".nexo"))).expanduser()
21
+
22
+
23
+ def _write(path: Path, text: str) -> None:
24
+ path.parent.mkdir(parents=True, exist_ok=True)
25
+ path.write_text(text, encoding="utf-8")
26
+
27
+
28
+ def handle_auto_flush_recent(limit: int = 20, session_id: str = "") -> str:
29
+ rows = compaction_memory.list_auto_flushes(session_id=session_id, limit=limit)
30
+ if not rows:
31
+ return "No auto-flush records."
32
+ lines = [f"AUTO-FLUSH — {len(rows)} record(s):", ""]
33
+ for row in rows:
34
+ lines.append(f" #{row['id']} {row['created_at']} [{row.get('session_id','unknown')}]")
35
+ lines.append(f" {row.get('summary','')[:220]}")
36
+ if row.get("next_step"):
37
+ lines.append(f" next: {row['next_step'][:160]}")
38
+ return "\n".join(lines)
39
+
40
+
41
+ def handle_auto_flush_stats(days: int = 7) -> str:
42
+ stats = compaction_memory.auto_flush_stats(days=days)
43
+ return (
44
+ f"AUTO-FLUSH STATS — {stats['window_days']}d\n"
45
+ f" total: {stats['total']}\n"
46
+ f" backend: {stats['backend']}\n"
47
+ f" by_source: {stats['by_source']}"
48
+ )
49
+
50
+
51
+ def handle_memory_backend_status() -> str:
52
+ active = get_backend()
53
+ backends = list_backends()
54
+ lines = [
55
+ f"MEMORY BACKEND: {active.key} — {active.label}",
56
+ f"Description: {active.description}",
57
+ f"Supports: {', '.join(active.supports)}",
58
+ "",
59
+ "Registered backends:",
60
+ ]
61
+ for item in backends:
62
+ marker = "*" if item["active"] else "-"
63
+ lines.append(f" {marker} {item['key']} [{item['maturity']}] {item['label']}")
64
+ return "\n".join(lines)
65
+
66
+
67
+ def handle_memory_export(format: str = "markdown", output_dir: str = "") -> str:
68
+ if format.strip().lower() != "markdown":
69
+ return "ERROR: only markdown export is supported for now."
70
+
71
+ stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
72
+ root = Path(output_dir).expanduser() if output_dir.strip() else (_nexo_home() / "exports" / "memory" / stamp)
73
+ root.mkdir(parents=True, exist_ok=True)
74
+
75
+ conn = get_db()
76
+ learnings = [dict(r) for r in conn.execute("SELECT id, category, title, status, prevention, updated_at FROM learnings ORDER BY updated_at DESC LIMIT 50").fetchall()]
77
+ decisions = [dict(r) for r in conn.execute("SELECT id, domain, decision, confidence, status, created_at FROM decisions ORDER BY created_at DESC LIMIT 50").fetchall()]
78
+ claims = claim_graph.search_claims(limit=100)
79
+ claim_lint = claim_graph.lint_claims(limit=50)
80
+ media = media_memory.list_media_memories(limit=100)
81
+ flushes = compaction_memory.list_auto_flushes(limit=100)
82
+ user_state = user_state_model.build_user_state(days=7, persist=False)
83
+ user_history = user_state_model.list_user_state_snapshots(limit=30)
84
+ cognitive_stats = cognitive.get_stats()
85
+
86
+ _write(
87
+ root / "README.md",
88
+ "\n".join(
89
+ [
90
+ "# NEXO Memory Export",
91
+ "",
92
+ f"- Generated: {datetime.now().isoformat(timespec='seconds')}",
93
+ f"- Backend: {get_backend().key}",
94
+ f"- Learnings: {len(learnings)}",
95
+ f"- Decisions: {len(decisions)}",
96
+ f"- Claims: {len(claims)}",
97
+ f"- Media memories: {len(media)}",
98
+ f"- Auto-flush records: {len(flushes)}",
99
+ "",
100
+ "Files:",
101
+ "- `learnings.md`",
102
+ "- `decisions.md`",
103
+ "- `claims.md`",
104
+ "- `media.md`",
105
+ "- `auto-flush.md`",
106
+ "- `user-state.md`",
107
+ "- `cognitive.json`",
108
+ ]
109
+ ),
110
+ )
111
+ _write(
112
+ root / "learnings.md",
113
+ "\n".join(
114
+ ["# Learnings", ""]
115
+ + [
116
+ f"- #{item['id']} [{item.get('category','general')}] {item['title']} "
117
+ f"({item.get('status','active')}, updated {item.get('updated_at','')})"
118
+ for item in learnings
119
+ ]
120
+ ),
121
+ )
122
+ _write(
123
+ root / "decisions.md",
124
+ "\n".join(
125
+ ["# Decisions", ""]
126
+ + [
127
+ f"- #{item['id']} [{item.get('domain','other')}] {item['decision']} "
128
+ f"({item.get('confidence','medium')}, {item.get('status','pending_review')})"
129
+ for item in decisions
130
+ ]
131
+ ),
132
+ )
133
+ _write(
134
+ root / "claims.md",
135
+ "\n".join(
136
+ ["# Claims", ""]
137
+ + [
138
+ f"- #{item['id']} [{item.get('verification_status','unverified')}] "
139
+ f"{item.get('freshness_state','?')}({item.get('freshness_score',0)}): {item['text']}"
140
+ for item in claims
141
+ ]
142
+ + ["", "## Attention", ""]
143
+ + [
144
+ f"- #{item['id']} [{', '.join(item.get('lint_reasons', []))}] {item['text']}"
145
+ for item in claim_lint
146
+ ]
147
+ ),
148
+ )
149
+ _write(
150
+ root / "media.md",
151
+ "\n".join(
152
+ ["# Media Memory", ""]
153
+ + [
154
+ f"- #{item['id']} [{item['media_type']}] {item['title']} :: {item.get('file_path') or item.get('url') or 'n/a'}"
155
+ for item in media
156
+ ]
157
+ ),
158
+ )
159
+ _write(
160
+ root / "auto-flush.md",
161
+ "\n".join(
162
+ ["# Auto Flush", ""]
163
+ + [
164
+ f"- #{item['id']} [{item.get('session_id','unknown')}] {item.get('created_at','')}: "
165
+ f"{item.get('summary','')}"
166
+ for item in flushes
167
+ ]
168
+ ),
169
+ )
170
+ _write(
171
+ root / "user-state.md",
172
+ "\n".join(
173
+ [
174
+ "# User State",
175
+ "",
176
+ f"- Current: {user_state['state_label']} ({user_state['confidence']})",
177
+ f"- Trust: {user_state['trust_score']}",
178
+ f"- Guidance: {user_state['guidance']}",
179
+ "",
180
+ "## Signals",
181
+ ]
182
+ + [f"- {key}: {value}" for key, value in user_state["signals"].items()]
183
+ + ["", "## History", ""]
184
+ + [f"- {item['created_at']} :: {item['state_label']} ({item['confidence']})" for item in user_history]
185
+ ),
186
+ )
187
+ _write(root / "cognitive.json", json.dumps(cognitive_stats, indent=2, sort_keys=True))
188
+ return f"Memory export written to {root}"
189
+
190
+
191
+ TOOLS = [
192
+ (handle_auto_flush_recent, "nexo_auto_flush_recent", "Show recent structured auto-flush records written before compaction."),
193
+ (handle_auto_flush_stats, "nexo_auto_flush_stats", "Stats for pre-compaction auto-flush activity."),
194
+ (handle_memory_backend_status, "nexo_memory_backend_status", "Show the active memory backend contract and registered backend list."),
195
+ (handle_memory_export, "nexo_memory_export", "Export a readable markdown snapshot of key NEXO memory layers."),
196
+ ]
@@ -0,0 +1,43 @@
1
+ """User-state modeling tools."""
2
+
3
+ import user_state_model
4
+
5
+
6
+ def handle_user_state(days: int = 7, persist: bool = True) -> str:
7
+ snapshot = user_state_model.build_user_state(days=days, persist=persist)
8
+ lines = [
9
+ f"USER STATE: {snapshot['state_label'].upper()} (confidence={snapshot['confidence']})",
10
+ f"Trust: {snapshot['trust_score']}/100",
11
+ f"Guidance: {snapshot['guidance']}",
12
+ "Signals:",
13
+ ]
14
+ for key, value in snapshot["signals"].items():
15
+ lines.append(f" {key}: {value}")
16
+ return "\n".join(lines)
17
+
18
+
19
+ def handle_user_state_history(limit: int = 20) -> str:
20
+ items = user_state_model.list_user_state_snapshots(limit=limit)
21
+ if not items:
22
+ return "No user-state snapshots yet."
23
+ lines = [f"USER STATE HISTORY — {len(items)} snapshot(s):", ""]
24
+ for item in items:
25
+ lines.append(f" {item['created_at']} — {item['state_label']} ({item['confidence']})")
26
+ return "\n".join(lines)
27
+
28
+
29
+ def handle_user_state_stats(days: int = 30) -> str:
30
+ stats = user_state_model.user_state_stats(days=days)
31
+ return (
32
+ f"USER STATE STATS — {days}d\n"
33
+ f" snapshots: {stats['snapshots']}\n"
34
+ f" backend: {stats['backend']}\n"
35
+ f" by_state: {stats['by_state']}"
36
+ )
37
+
38
+
39
+ TOOLS = [
40
+ (handle_user_state, "nexo_user_state", "Compute a richer, inspectable user-state snapshot from trust, corrections, sentiment, and hot context."),
41
+ (handle_user_state_history, "nexo_user_state_history", "List recent user-state snapshots."),
42
+ (handle_user_state_stats, "nexo_user_state_stats", "Aggregate user-state snapshots by label."),
43
+ ]
@@ -77,6 +77,7 @@ METADATA_KEYS = {
77
77
  SUPPORTED_RUNTIMES = {"python", "shell", "node", "php", "unknown"}
78
78
  PERSONAL_SCHEDULE_MANAGED_ENV = "NEXO_MANAGED_PERSONAL_CRON"
79
79
  SUPPORTED_RECOVERY_POLICIES = {"none", "run_once_on_wake", "catchup", "restart", "restart_daemon"}
80
+ PERSONAL_SCRIPT_FILENAME_PREFIX = "ps-"
80
81
 
81
82
 
82
83
  def get_nexo_home() -> Path:
@@ -248,6 +249,17 @@ def _safe_slug(value: str) -> str:
248
249
  return slug or "script"
249
250
 
250
251
 
252
+ def has_personal_script_filename_prefix(value: str) -> bool:
253
+ return _safe_slug(value).startswith(PERSONAL_SCRIPT_FILENAME_PREFIX)
254
+
255
+
256
+ def _logical_personal_script_name(name: str) -> str:
257
+ slug = _safe_slug(name)
258
+ if slug.startswith(PERSONAL_SCRIPT_FILENAME_PREFIX):
259
+ slug = slug[len(PERSONAL_SCRIPT_FILENAME_PREFIX):]
260
+ return slug or "personal-script"
261
+
262
+
251
263
  def get_declared_schedule(metadata: dict, default_name: str = "") -> dict:
252
264
  """Parse desired schedule metadata from inline script metadata."""
253
265
  explicit_name = metadata.get("name", "").strip()
@@ -460,7 +472,7 @@ def get_declared_schedule(metadata: dict, default_name: str = "") -> dict:
460
472
  def _script_entry(path: Path, meta: dict, *, is_core: bool, classification: str, reason: str = "") -> dict:
461
473
  runtime = classify_runtime(path, meta)
462
474
  name = meta.get("name", path.stem)
463
- return {
475
+ entry = {
464
476
  "name": name,
465
477
  "runtime": runtime,
466
478
  "description": meta.get("description", ""),
@@ -470,7 +482,11 @@ def _script_entry(path: Path, meta: dict, *, is_core: bool, classification: str,
470
482
  "classification": classification,
471
483
  "reason": reason,
472
484
  "declared_schedule": get_declared_schedule(meta, name),
485
+ "filename_prefixed": has_personal_script_filename_prefix(path.stem),
473
486
  }
487
+ if classification == "personal":
488
+ entry["naming_policy"] = "preferred" if entry["filename_prefixed"] else "legacy-nonprefixed"
489
+ return entry
474
490
 
475
491
 
476
492
  def classify_scripts_dir() -> dict:
@@ -1173,13 +1189,7 @@ def _template_path(filename: str) -> Path | None:
1173
1189
 
1174
1190
 
1175
1191
  def _script_filename_from_name(name: str, runtime: str) -> str:
1176
- slug = []
1177
- for ch in name.strip().lower():
1178
- if ch.isalnum():
1179
- slug.append(ch)
1180
- elif ch in {" ", "-", "_"}:
1181
- slug.append("-")
1182
- stem = "".join(slug).strip("-") or "personal-script"
1192
+ stem = _safe_slug(name) or "personal-script"
1183
1193
  ext = {
1184
1194
  "python": ".py",
1185
1195
  "shell": ".sh",
@@ -1189,6 +1199,11 @@ def _script_filename_from_name(name: str, runtime: str) -> str:
1189
1199
  return stem + ext
1190
1200
 
1191
1201
 
1202
+ def _personal_script_filename_from_name(name: str, runtime: str) -> str:
1203
+ logical_name = _logical_personal_script_name(name)
1204
+ return _script_filename_from_name(f"{PERSONAL_SCRIPT_FILENAME_PREFIX}{logical_name}", runtime)
1205
+
1206
+
1192
1207
  def create_script(name: str, *, description: str = "", runtime: str = "python", force: bool = False) -> dict:
1193
1208
  runtime = runtime if runtime in SUPPORTED_RUNTIMES else "python"
1194
1209
  if runtime == "unknown":
@@ -1196,7 +1211,8 @@ def create_script(name: str, *, description: str = "", runtime: str = "python",
1196
1211
 
1197
1212
  scripts_dir = get_scripts_dir()
1198
1213
  scripts_dir.mkdir(parents=True, exist_ok=True)
1199
- filename = _script_filename_from_name(name, runtime)
1214
+ logical_name = _logical_personal_script_name(name)
1215
+ filename = _personal_script_filename_from_name(name, runtime)
1200
1216
  path = scripts_dir / filename
1201
1217
  if path.exists() and not force:
1202
1218
  raise FileExistsError(f"Script already exists: {path}")
@@ -1226,10 +1242,9 @@ def create_script(name: str, *, description: str = "", runtime: str = "python",
1226
1242
  "print('hello')\n"
1227
1243
  )
1228
1244
 
1229
- script_name = Path(filename).stem
1230
- content = content.replace("example-script", script_name)
1231
- content = content.replace("Example personal script using the stable NEXO CLI", description or f"Personal script: {script_name}")
1232
- content = content.replace("Example shell script using NEXO", description or f"Personal script: {script_name}")
1245
+ content = content.replace("example-script", logical_name)
1246
+ content = content.replace("Example personal script using the stable NEXO CLI", description or f"Personal script: {logical_name}")
1247
+ content = content.replace("Example shell script using NEXO", description or f"Personal script: {logical_name}")
1233
1248
 
1234
1249
  path.write_text(content)
1235
1250
  if runtime in {"shell", "python"}:
@@ -1237,8 +1252,10 @@ def create_script(name: str, *, description: str = "", runtime: str = "python",
1237
1252
  sync_result = sync_personal_scripts()
1238
1253
  return {
1239
1254
  "ok": True,
1240
- "name": script_name,
1255
+ "name": logical_name,
1256
+ "requested_name": name,
1241
1257
  "path": str(path),
1258
+ "filename": filename,
1242
1259
  "runtime": runtime,
1243
1260
  "description": description,
1244
1261
  "sync": sync_result,
@@ -20,8 +20,14 @@ from collections import Counter
20
20
  from datetime import datetime, timedelta
21
21
  from pathlib import Path
22
22
 
23
+ _DEFAULT_RUNTIME_ROOT = Path(__file__).resolve().parents[2]
24
+ NEXO_CODE = Path(os.environ.get("NEXO_CODE", str(_DEFAULT_RUNTIME_ROOT)))
25
+ if str(NEXO_CODE) not in sys.path:
26
+ sys.path.insert(0, str(NEXO_CODE))
27
+
28
+ import transcript_utils as _transcripts
29
+
23
30
  NEXO_HOME = Path(os.environ.get("NEXO_HOME", str(Path.home() / ".nexo")))
24
- NEXO_CODE = Path(os.environ.get("NEXO_CODE", ""))
25
31
  DEEP_SLEEP_DIR = NEXO_HOME / "operations" / "deep-sleep"
26
32
  NEXO_DB = NEXO_HOME / "data" / "nexo.db"
27
33
  COGNITIVE_DB = NEXO_HOME / "data" / "cognitive.db"
@@ -64,196 +70,22 @@ def _session_identifier(client: str, session_file: str) -> str:
64
70
 
65
71
  def find_claude_session_files() -> list[Path]:
66
72
  """Find Claude Code session JSONL files under ~/.claude/projects."""
67
- claude_dir = Path.home() / ".claude" / "projects"
68
- if not claude_dir.exists():
69
- return []
70
- return sorted(claude_dir.rglob("*.jsonl"))
73
+ return _transcripts.find_claude_session_files()
71
74
 
72
75
 
73
76
  def find_codex_session_files() -> list[Path]:
74
77
  """Find Codex session JSONL files under ~/.codex/sessions and archived_sessions."""
75
- roots = [
76
- Path.home() / ".codex" / "sessions",
77
- Path.home() / ".codex" / "archived_sessions",
78
- ]
79
- files: list[Path] = []
80
- seen: set[str] = set()
81
- for root in roots:
82
- if not root.exists():
83
- continue
84
- for jsonl in sorted(root.rglob("*.jsonl")):
85
- key = jsonl.name
86
- if key in seen:
87
- continue
88
- seen.add(key)
89
- files.append(jsonl)
90
- return files
78
+ return _transcripts.find_codex_session_files()
91
79
 
92
80
 
93
81
  def extract_claude_session(jsonl_path: Path) -> dict | None:
94
82
  """Extract clean transcript from a Claude Code JSONL session."""
95
- messages = []
96
- tool_uses = []
97
- user_msg_count = 0
98
-
99
- try:
100
- with open(jsonl_path, "r") as f:
101
- for line_no, line in enumerate(f, 1):
102
- line = line.strip()
103
- if not line:
104
- continue
105
- try:
106
- d = json.loads(line)
107
- except json.JSONDecodeError:
108
- continue
109
-
110
- msg_type = d.get("type")
111
-
112
- # User messages
113
- if msg_type == "user":
114
- content = d.get("message", {}).get("content", "")
115
- if isinstance(content, str) and content.strip():
116
- if content.startswith("<system-reminder>"):
117
- continue
118
- messages.append({
119
- "role": "user",
120
- "index": line_no,
121
- "text": _redact_sensitive(content[:5000]),
122
- "uuid": d.get("uuid", "")
123
- })
124
- user_msg_count += 1
125
-
126
- # Assistant messages
127
- elif msg_type in ("message", "assistant"):
128
- msg = d.get("message", {})
129
- content_blocks = msg.get("content", [])
130
- text_parts = []
131
- for block in content_blocks:
132
- if isinstance(block, dict):
133
- if block.get("type") == "text":
134
- text_parts.append(block.get("text", ""))
135
- elif block.get("type") == "tool_use":
136
- tool_input = block.get("input", {})
137
- raw_file = (
138
- tool_input.get("file_path", "")
139
- or str(tool_input.get("command", ""))[:100]
140
- ) if isinstance(tool_input, dict) else ""
141
- tool_uses.append({
142
- "tool": block.get("name", ""),
143
- "input_keys": list(tool_input.keys()) if isinstance(tool_input, dict) else [],
144
- "file": _redact_sensitive(raw_file)
145
- })
146
- if text_parts:
147
- combined = "\n".join(text_parts)[:5000]
148
- combined = _redact_sensitive(combined)
149
- messages.append({
150
- "role": "assistant",
151
- "index": line_no,
152
- "text": combined
153
- })
154
-
155
- except Exception as e:
156
- print(f" [collect] Error reading {jsonl_path}: {e}", file=sys.stderr)
157
- return None
158
-
159
- if user_msg_count < MIN_USER_MESSAGES:
160
- return None
161
-
162
- return {
163
- "client": "claude_code",
164
- "session_file": _session_identifier("claude_code", jsonl_path.name),
165
- "display_name": jsonl_path.name,
166
- "session_path": str(jsonl_path),
167
- "message_count": len(messages),
168
- "user_message_count": user_msg_count,
169
- "tool_use_count": len(tool_uses),
170
- "messages": messages,
171
- "tool_uses": tool_uses,
172
- "source": "claude_projects",
173
- }
83
+ return _transcripts.extract_claude_session(jsonl_path)
174
84
 
175
85
 
176
86
  def extract_codex_session(jsonl_path: Path) -> dict | None:
177
87
  """Extract clean transcript from a Codex JSONL session."""
178
- messages = []
179
- tool_uses = []
180
- user_msg_count = 0
181
- session_meta: dict = {}
182
-
183
- try:
184
- with open(jsonl_path, "r") as f:
185
- for line_no, line in enumerate(f, 1):
186
- line = line.strip()
187
- if not line:
188
- continue
189
- try:
190
- d = json.loads(line)
191
- except json.JSONDecodeError:
192
- continue
193
-
194
- item_type = d.get("type")
195
- payload = d.get("payload", {})
196
-
197
- if item_type == "session_meta" and isinstance(payload, dict):
198
- session_meta = payload
199
- continue
200
-
201
- if item_type == "event_msg" and isinstance(payload, dict) and payload.get("type") == "user_message":
202
- content = str(payload.get("message", "") or "").strip()
203
- if not content or content.startswith("<environment_context>"):
204
- continue
205
- messages.append({
206
- "role": "user",
207
- "index": line_no,
208
- "text": _redact_sensitive(content[:5000]),
209
- })
210
- user_msg_count += 1
211
- continue
212
-
213
- if item_type == "response_item" and isinstance(payload, dict):
214
- response_type = payload.get("type")
215
- role = payload.get("role")
216
- if response_type == "message" and role == "assistant":
217
- text_parts = []
218
- for block in payload.get("content", []) or []:
219
- if isinstance(block, dict) and block.get("type") == "output_text":
220
- text_parts.append(str(block.get("text", "")))
221
- combined = "\n".join(part for part in text_parts if part).strip()
222
- if combined:
223
- messages.append({
224
- "role": "assistant",
225
- "index": line_no,
226
- "text": _redact_sensitive(combined[:5000]),
227
- })
228
- elif response_type == "function_call":
229
- tool_uses.append({
230
- "tool": payload.get("name", ""),
231
- "input_keys": [],
232
- "file": _redact_sensitive(str(payload.get("arguments", ""))[:100]),
233
- })
234
-
235
- except Exception as e:
236
- print(f" [collect] Error reading {jsonl_path}: {e}", file=sys.stderr)
237
- return None
238
-
239
- if user_msg_count < MIN_USER_MESSAGES:
240
- return None
241
-
242
- return {
243
- "client": "codex",
244
- "session_file": _session_identifier("codex", jsonl_path.name),
245
- "display_name": jsonl_path.name,
246
- "session_path": str(jsonl_path),
247
- "message_count": len(messages),
248
- "user_message_count": user_msg_count,
249
- "tool_use_count": len(tool_uses),
250
- "messages": messages,
251
- "tool_uses": tool_uses,
252
- "source": session_meta.get("source", "codex"),
253
- "cwd": session_meta.get("cwd", ""),
254
- "originator": session_meta.get("originator", ""),
255
- "session_uid": session_meta.get("id", ""),
256
- }
88
+ return _transcripts.extract_codex_session(jsonl_path)
257
89
 
258
90
 
259
91
  def collect_transcripts_since(since_iso: str, until_iso: str = "") -> list[dict]:
@@ -262,28 +94,7 @@ def collect_transcripts_since(since_iso: str, until_iso: str = "") -> list[dict]
262
94
  Uses a watermark approach: deep sleep tracks the last processed timestamp
263
95
  so nothing is missed regardless of when sessions happen (day, night, etc.).
264
96
  """
265
- since_dt = datetime.fromisoformat(since_iso)
266
- until_dt = datetime.fromisoformat(until_iso) if until_iso else datetime.now()
267
-
268
- sessions = []
269
- transcript_files: list[tuple[str, Path]] = [
270
- ("claude_code", path) for path in find_claude_session_files()
271
- ] + [
272
- ("codex", path) for path in find_codex_session_files()
273
- ]
274
- for client, session_file in transcript_files:
275
- try:
276
- mtime = datetime.fromtimestamp(session_file.stat().st_mtime)
277
- except OSError:
278
- continue
279
- if not (since_dt < mtime <= until_dt):
280
- continue
281
- session = extract_codex_session(session_file) if client == "codex" else extract_claude_session(session_file)
282
- if session:
283
- session["modified"] = mtime.isoformat()
284
- sessions.append(session)
285
- sessions.sort(key=lambda s: s["modified"])
286
- return sessions
97
+ return _transcripts.collect_transcripts_since(since_iso, until_iso)
287
98
 
288
99
 
289
100
  # ── Database queries ──────────────────────────────────────────────────────
package/src/server.py CHANGED
@@ -23,6 +23,15 @@ from tools_hot_context import (
23
23
  handle_recent_context_resolve,
24
24
  handle_hot_context_list,
25
25
  )
26
+ from tools_transcripts import (
27
+ handle_transcript_recent,
28
+ handle_transcript_search,
29
+ handle_transcript_read,
30
+ )
31
+ from tools_system_catalog import (
32
+ handle_system_catalog,
33
+ handle_tool_explain,
34
+ )
26
35
  from user_context import get_context as _get_ctx
27
36
  from tools_coordination import (
28
37
  handle_track, handle_untrack, handle_files,
@@ -209,6 +218,9 @@ mcp = FastMCP(
209
218
  "- **Delegate:** prefer direct. If needed: `nexo_context_packet(area)` + guard + 'if unsure STOP'\n"
210
219
  "- **Memory:** `nexo_recall` searches all. For fresh 24h continuity use `nexo_pre_action_context(query='...')` before acting and "
211
220
  "`nexo_recent_context_capture(...)` / `nexo_recent_context_resolve(...)` for important ongoing threads. "
221
+ "If that is not enough, use `nexo_transcript_search(...)` / `nexo_transcript_read(...)` as the raw fallback to full conversations. "
222
+ "Use `nexo_system_catalog(...)` / `nexo_tool_explain(...)` when you need the live map of NEXO itself. "
223
+ "Before the first use of an unfamiliar NEXO tool, call `nexo_tool_explain(name)` to see its signature, examples, workflow notes, and common errors. "
212
224
  "Capture: errors→`nexo_learning_add`, prefs, entities, decisions\n"
213
225
  "- **Change log:** `nexo_task_close` should be the default closure path. If you bypass it, call `nexo_change_log(...)` after production edits. NOT for config dir\n"
214
226
  "- **Diary:** When user signals end of session (any language, any style — 'bye', 'done', 'cierro', etc.), "
@@ -380,6 +392,36 @@ def nexo_hot_context_list(hours: int = 24, limit: int = 10, state: str = "") ->
380
392
  return handle_hot_context_list(hours, limit, state)
381
393
 
382
394
 
395
+ @mcp.tool
396
+ def nexo_transcript_recent(hours: int = 24, client: str = "", limit: int = 10) -> str:
397
+ """List recent Claude Code / Codex transcripts visible to NEXO."""
398
+ return handle_transcript_recent(hours, client, limit)
399
+
400
+
401
+ @mcp.tool
402
+ def nexo_transcript_search(query: str = "", hours: int = 24, client: str = "", limit: int = 10) -> str:
403
+ """Search recent transcripts directly when recall/hot-context are not enough."""
404
+ return handle_transcript_search(query, hours, client, limit)
405
+
406
+
407
+ @mcp.tool
408
+ def nexo_transcript_read(session_ref: str = "", transcript_path: str = "", client: str = "", max_messages: int = 80) -> str:
409
+ """Read a full transcript fallback by session id, transcript display name, session_uid, or exact path."""
410
+ return handle_transcript_read(session_ref, transcript_path, client, max_messages)
411
+
412
+
413
+ @mcp.tool
414
+ def nexo_system_catalog(section: str = "", query: str = "", limit: int = 20) -> str:
415
+ """Read NEXO's live system catalog built from core tools, plugins, skills, scripts, crons, projects, and artifacts."""
416
+ return handle_system_catalog(section, query, limit)
417
+
418
+
419
+ @mcp.tool
420
+ def nexo_tool_explain(name: str) -> str:
421
+ """Explain a live NEXO tool/capability from the generated system catalog."""
422
+ return handle_tool_explain(name)
423
+
424
+
383
425
  @mcp.tool
384
426
  def nexo_smart_startup() -> str:
385
427
  """Pre-load relevant cognitive memories based on pending followups, due reminders, and last session topics.