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.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +31 -0
- package/package.json +1 -1
- package/src/agent_runner.py +1 -0
- package/src/auto_update.py +53 -0
- package/src/claim_graph.py +128 -15
- package/src/cognitive/_trust.py +2 -2
- package/src/compaction_memory.py +227 -0
- package/src/dashboard/app.py +15 -12
- package/src/doctor/providers/runtime.py +140 -11
- package/src/hook_guardrails.py +105 -9
- package/src/hooks/pre-compact.sh +18 -0
- package/src/media_memory.py +303 -0
- package/src/memory_backends.py +71 -0
- package/src/plugins/claims_tools.py +119 -0
- package/src/plugins/cognitive_memory.py +16 -1
- package/src/plugins/media_memory_tools.py +98 -0
- package/src/plugins/memory_export.py +196 -0
- package/src/plugins/user_state_tools.py +43 -0
- package/src/script_registry.py +31 -14
- package/src/scripts/deep-sleep/collect.py +12 -201
- package/src/server.py +42 -0
- package/src/system_catalog.py +786 -0
- package/src/tools_system_catalog.py +19 -0
- package/src/tools_transcripts.py +98 -0
- package/src/transcript_utils.py +412 -0
- package/src/user_state_model.py +170 -0
|
@@ -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
|
+
]
|
package/src/script_registry.py
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1230
|
-
content = content.replace("
|
|
1231
|
-
content = content.replace("Example
|
|
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":
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|