nexo-brain 7.25.6 → 7.26.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 +3 -1
- package/bin/nexo-brain.js +233 -29
- package/codex/openai-codex-0.133.0.tgz +0 -0
- package/package.json +7 -1
- package/src/agent_runner.py +96 -31
- package/src/cli.py +117 -4
- package/src/client_preferences.py +293 -1
- package/src/client_sync.py +327 -1
- package/src/db/_schema.py +23 -0
- package/src/db/_sessions.py +75 -24
- package/src/provider_runtime.py +39 -0
- package/src/scripts/deep-sleep/extract.py +2 -0
- package/src/scripts/deep-sleep/synthesize.py +1 -0
- package/src/scripts/nexo-cron-wrapper.sh +108 -25
- package/src/scripts/nexo-morning-agent.py +1 -1
- package/src/server.py +3 -1
- package/src/tools_automation_sessions.py +2 -1
- package/src/tools_sessions.py +13 -8
|
@@ -45,21 +45,71 @@ from datetime import datetime, timezone
|
|
|
45
45
|
print(datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S"))
|
|
46
46
|
PY
|
|
47
47
|
)
|
|
48
|
+
RUNTIME_META=$(python3 - "$NEXO_HOME" <<'PY' 2>/dev/null || true
|
|
49
|
+
from __future__ import annotations
|
|
50
|
+
import json
|
|
51
|
+
import sys
|
|
52
|
+
from pathlib import Path
|
|
53
|
+
|
|
54
|
+
nexo_home = Path(sys.argv[1]).expanduser()
|
|
55
|
+
schedule = {}
|
|
56
|
+
for candidate in (
|
|
57
|
+
nexo_home / "personal" / "config" / "schedule.json",
|
|
58
|
+
nexo_home / "config" / "schedule.json",
|
|
59
|
+
):
|
|
60
|
+
try:
|
|
61
|
+
schedule = json.loads(candidate.read_text(encoding="utf-8"))
|
|
62
|
+
break
|
|
63
|
+
except Exception:
|
|
64
|
+
continue
|
|
65
|
+
|
|
66
|
+
provider_runtime = schedule.get("provider_runtime") if isinstance(schedule.get("provider_runtime"), dict) else {}
|
|
67
|
+
backend = str(provider_runtime.get("automation_backend") or schedule.get("automation_backend") or "claude_code").strip().lower()
|
|
68
|
+
provider = str(provider_runtime.get("automation_provider") or "").strip().lower()
|
|
69
|
+
if provider not in {"anthropic", "openai", "none"}:
|
|
70
|
+
provider = {"claude_code": "anthropic", "codex": "openai", "none": "none"}.get(backend, "")
|
|
71
|
+
snapshot = {
|
|
72
|
+
"selected_chat_provider": provider_runtime.get("selected_chat_provider") or "",
|
|
73
|
+
"automation_provider": provider,
|
|
74
|
+
"automation_backend": backend,
|
|
75
|
+
"fallback_policy": provider_runtime.get("fallback_policy") or {"automation": "fail_closed"},
|
|
76
|
+
}
|
|
77
|
+
print(provider + "\t" + backend + "\t" + json.dumps(snapshot, ensure_ascii=False, separators=(",", ":")))
|
|
78
|
+
PY
|
|
79
|
+
)
|
|
80
|
+
if [ -n "$RUNTIME_META" ]; then
|
|
81
|
+
IFS=$'\t' read -r CRON_PROVIDER CRON_BACKEND RUNTIME_SNAPSHOT <<< "$RUNTIME_META"
|
|
82
|
+
else
|
|
83
|
+
CRON_PROVIDER=""
|
|
84
|
+
CRON_BACKEND=""
|
|
85
|
+
RUNTIME_SNAPSHOT="{}"
|
|
86
|
+
fi
|
|
48
87
|
|
|
49
88
|
# Phase 1: INSERT row at start (ended_at NULL = "running").
|
|
50
89
|
# ROW_ID empty on DB failure; spool-fallback at the end handles that.
|
|
51
90
|
ROW_ID=""
|
|
52
|
-
ROW_ID=$(python3 - "$DB" "$CRON_ID" "$STARTED_AT" <<'PY' 2>/dev/null
|
|
91
|
+
ROW_ID=$(python3 - "$DB" "$CRON_ID" "$STARTED_AT" "$CRON_PROVIDER" "$CRON_BACKEND" "$RUNTIME_SNAPSHOT" <<'PY' 2>/dev/null
|
|
53
92
|
from __future__ import annotations
|
|
54
93
|
import sqlite3
|
|
55
94
|
import sys
|
|
56
|
-
db_path, cron_id, started_at = sys.argv[1:]
|
|
95
|
+
db_path, cron_id, started_at, provider, backend, runtime_snapshot = sys.argv[1:]
|
|
57
96
|
conn = sqlite3.connect(db_path)
|
|
58
97
|
try:
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
98
|
+
try:
|
|
99
|
+
cur = conn.execute(
|
|
100
|
+
"""
|
|
101
|
+
INSERT INTO cron_runs (cron_id, started_at, ended_at, provider, backend, runtime_snapshot)
|
|
102
|
+
VALUES (?, ?, NULL, ?, ?, ?)
|
|
103
|
+
""",
|
|
104
|
+
(cron_id, started_at, provider, backend, runtime_snapshot or "{}"),
|
|
105
|
+
)
|
|
106
|
+
except sqlite3.OperationalError as exc:
|
|
107
|
+
if "provider" not in str(exc) and "backend" not in str(exc) and "runtime_snapshot" not in str(exc):
|
|
108
|
+
raise
|
|
109
|
+
cur = conn.execute(
|
|
110
|
+
"INSERT INTO cron_runs (cron_id, started_at, ended_at) VALUES (?, ?, NULL)",
|
|
111
|
+
(cron_id, started_at),
|
|
112
|
+
)
|
|
63
113
|
conn.commit()
|
|
64
114
|
print(cur.lastrowid)
|
|
65
115
|
finally:
|
|
@@ -97,30 +147,60 @@ PY
|
|
|
97
147
|
fi
|
|
98
148
|
|
|
99
149
|
# Update the row we inserted at start — or INSERT fresh if the start write failed.
|
|
100
|
-
if ! python3 - "$DB" "$ROW_ID" "$CRON_ID" "$STARTED_AT" "$ended_at" "$EXIT_CODE" "$summary" "$error" "$duration" <<'PY' 2>/dev/null
|
|
150
|
+
if ! python3 - "$DB" "$ROW_ID" "$CRON_ID" "$STARTED_AT" "$ended_at" "$EXIT_CODE" "$summary" "$error" "$duration" "$CRON_PROVIDER" "$CRON_BACKEND" "$RUNTIME_SNAPSHOT" <<'PY' 2>/dev/null
|
|
101
151
|
from __future__ import annotations
|
|
102
152
|
import sqlite3
|
|
103
153
|
import sys
|
|
104
|
-
db_path, row_id, cron_id, started_at, ended_at, exit_code, summary, error, duration_secs = sys.argv[1:]
|
|
154
|
+
db_path, row_id, cron_id, started_at, ended_at, exit_code, summary, error, duration_secs, provider, backend, runtime_snapshot = sys.argv[1:]
|
|
105
155
|
conn = sqlite3.connect(db_path)
|
|
106
156
|
try:
|
|
107
157
|
if row_id:
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
158
|
+
try:
|
|
159
|
+
conn.execute(
|
|
160
|
+
"""
|
|
161
|
+
UPDATE cron_runs
|
|
162
|
+
SET ended_at=?, exit_code=?, summary=?, error=?, duration_secs=?,
|
|
163
|
+
provider=COALESCE(NULLIF(provider, ''), ?),
|
|
164
|
+
backend=COALESCE(NULLIF(backend, ''), ?),
|
|
165
|
+
runtime_snapshot=CASE
|
|
166
|
+
WHEN runtime_snapshot IS NULL OR runtime_snapshot = '' OR runtime_snapshot = '{}'
|
|
167
|
+
THEN ?
|
|
168
|
+
ELSE runtime_snapshot
|
|
169
|
+
END
|
|
170
|
+
WHERE id=?
|
|
171
|
+
""",
|
|
172
|
+
(ended_at, int(exit_code), summary, error, float(duration_secs), provider, backend, runtime_snapshot or "{}", int(row_id)),
|
|
173
|
+
)
|
|
174
|
+
except sqlite3.OperationalError as exc:
|
|
175
|
+
if "provider" not in str(exc) and "backend" not in str(exc) and "runtime_snapshot" not in str(exc):
|
|
176
|
+
raise
|
|
177
|
+
conn.execute(
|
|
178
|
+
"""
|
|
179
|
+
UPDATE cron_runs
|
|
180
|
+
SET ended_at=?, exit_code=?, summary=?, error=?, duration_secs=?
|
|
181
|
+
WHERE id=?
|
|
182
|
+
""",
|
|
183
|
+
(ended_at, int(exit_code), summary, error, float(duration_secs), int(row_id)),
|
|
184
|
+
)
|
|
116
185
|
else:
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
186
|
+
try:
|
|
187
|
+
conn.execute(
|
|
188
|
+
"""
|
|
189
|
+
INSERT INTO cron_runs (cron_id, started_at, ended_at, exit_code, summary, error, duration_secs, provider, backend, runtime_snapshot)
|
|
190
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
191
|
+
""",
|
|
192
|
+
(cron_id, started_at, ended_at, int(exit_code), summary, error, float(duration_secs), provider, backend, runtime_snapshot or "{}"),
|
|
193
|
+
)
|
|
194
|
+
except sqlite3.OperationalError as exc:
|
|
195
|
+
if "provider" not in str(exc) and "backend" not in str(exc) and "runtime_snapshot" not in str(exc):
|
|
196
|
+
raise
|
|
197
|
+
conn.execute(
|
|
198
|
+
"""
|
|
199
|
+
INSERT INTO cron_runs (cron_id, started_at, ended_at, exit_code, summary, error, duration_secs)
|
|
200
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
201
|
+
""",
|
|
202
|
+
(cron_id, started_at, ended_at, int(exit_code), summary, error, float(duration_secs)),
|
|
203
|
+
)
|
|
124
204
|
conn.commit()
|
|
125
205
|
finally:
|
|
126
206
|
conn.close()
|
|
@@ -128,12 +208,12 @@ PY
|
|
|
128
208
|
then
|
|
129
209
|
mkdir -p "$SPOOL_DIR"
|
|
130
210
|
local spool_file="$SPOOL_DIR/${CRON_ID}-$(date +%Y%m%d-%H%M%S)-$$.json"
|
|
131
|
-
python3 - "$spool_file" "$CRON_ID" "$STARTED_AT" "$ended_at" "$EXIT_CODE" "$summary" "$error" "$duration" <<'PY'
|
|
211
|
+
python3 - "$spool_file" "$CRON_ID" "$STARTED_AT" "$ended_at" "$EXIT_CODE" "$summary" "$error" "$duration" "$CRON_PROVIDER" "$CRON_BACKEND" "$RUNTIME_SNAPSHOT" <<'PY'
|
|
132
212
|
from __future__ import annotations
|
|
133
213
|
import json
|
|
134
214
|
import sys
|
|
135
215
|
from pathlib import Path
|
|
136
|
-
spool_file, cron_id, started_at, ended_at, exit_code, summary, error, duration_secs = sys.argv[1:]
|
|
216
|
+
spool_file, cron_id, started_at, ended_at, exit_code, summary, error, duration_secs, provider, backend, runtime_snapshot = sys.argv[1:]
|
|
137
217
|
Path(spool_file).write_text(
|
|
138
218
|
json.dumps({
|
|
139
219
|
"cron_id": cron_id,
|
|
@@ -143,6 +223,9 @@ Path(spool_file).write_text(
|
|
|
143
223
|
"summary": summary,
|
|
144
224
|
"error": error,
|
|
145
225
|
"duration_secs": float(duration_secs),
|
|
226
|
+
"provider": provider,
|
|
227
|
+
"backend": backend,
|
|
228
|
+
"runtime_snapshot": runtime_snapshot or "{}",
|
|
146
229
|
}, indent=2, ensure_ascii=False) + "\n",
|
|
147
230
|
encoding="utf-8",
|
|
148
231
|
)
|
|
@@ -569,7 +569,7 @@ def generate_briefing(prompt: str) -> tuple[str, str]:
|
|
|
569
569
|
output_format="json",
|
|
570
570
|
append_system_prompt=render_core_prompt("morning-agent-json-output"),
|
|
571
571
|
allowed_tools="Read,Glob,Grep",
|
|
572
|
-
bare_mode=
|
|
572
|
+
bare_mode=False,
|
|
573
573
|
)
|
|
574
574
|
if result.returncode != 0:
|
|
575
575
|
detail = (result.stderr or result.stdout or "").strip()
|
package/src/server.py
CHANGED
|
@@ -440,7 +440,7 @@ def _run_kwargs_from_env() -> dict:
|
|
|
440
440
|
# ── Session management (3 tools) ──────────────────────────────────
|
|
441
441
|
|
|
442
442
|
@mcp.tool
|
|
443
|
-
def nexo_startup(task: str = "Startup", claude_session_id: str = "", session_token: str = "", session_client: str = "", conversation_id: str = "") -> str:
|
|
443
|
+
def nexo_startup(task: str = "Startup", claude_session_id: str = "", session_token: str = "", session_client: str = "", session_provider: str = "", conversation_id: str = "") -> str:
|
|
444
444
|
"""Register new session, clean stale ones, return active sessions + alerts.
|
|
445
445
|
|
|
446
446
|
Call this ONCE at the start of every conversation.
|
|
@@ -453,6 +453,7 @@ def nexo_startup(task: str = "Startup", claude_session_id: str = "", session_tok
|
|
|
453
453
|
other clients may pass a synthetic durable token when useful.
|
|
454
454
|
Pass this to enable automatic inter-terminal inbox detection when available.
|
|
455
455
|
session_client: Optional client label such as `claude_code` or `codex`.
|
|
456
|
+
session_provider: Optional provider label such as `anthropic` or `openai`.
|
|
456
457
|
conversation_id: Stable client-side conversation identifier when available.
|
|
457
458
|
"""
|
|
458
459
|
return handle_startup(
|
|
@@ -460,6 +461,7 @@ def nexo_startup(task: str = "Startup", claude_session_id: str = "", session_tok
|
|
|
460
461
|
claude_session_id=claude_session_id,
|
|
461
462
|
session_token=session_token,
|
|
462
463
|
session_client=session_client,
|
|
464
|
+
session_provider=session_provider,
|
|
463
465
|
conversation_id=conversation_id,
|
|
464
466
|
)
|
|
465
467
|
|
|
@@ -84,11 +84,12 @@ def handle_session_log_create(payload: dict | None = None, **kwargs) -> dict:
|
|
|
84
84
|
except Exception:
|
|
85
85
|
resonance_tier = ""
|
|
86
86
|
|
|
87
|
-
from agent_runner import _record_automation_start
|
|
87
|
+
from agent_runner import _automation_provider_for_backend, _record_automation_start
|
|
88
88
|
|
|
89
89
|
row_id, err = _record_automation_start(
|
|
90
90
|
caller=caller,
|
|
91
91
|
backend=backend,
|
|
92
|
+
provider=_automation_provider_for_backend(backend),
|
|
92
93
|
session_type=session_type,
|
|
93
94
|
task_profile="",
|
|
94
95
|
model=model,
|
package/src/tools_sessions.py
CHANGED
|
@@ -12,6 +12,7 @@ import threading
|
|
|
12
12
|
from datetime import datetime, timezone
|
|
13
13
|
from pathlib import Path
|
|
14
14
|
from core_prompts import render_core_prompt
|
|
15
|
+
from client_preferences import client_to_provider, normalize_provider_key
|
|
15
16
|
from db import (
|
|
16
17
|
register_session, update_session, complete_session,
|
|
17
18
|
get_active_sessions, clean_stale_sessions, search_sessions,
|
|
@@ -582,6 +583,7 @@ def handle_startup(
|
|
|
582
583
|
claude_session_id: str = "",
|
|
583
584
|
session_token: str = "",
|
|
584
585
|
session_client: str = "",
|
|
586
|
+
session_provider: str = "",
|
|
585
587
|
conversation_id: str = "",
|
|
586
588
|
) -> str:
|
|
587
589
|
"""Full startup sequence: register, clean, report.
|
|
@@ -593,12 +595,16 @@ def handle_startup(
|
|
|
593
595
|
other clients may pass a synthetic durable ID when useful.
|
|
594
596
|
Enables automatic inbox detection when hook-backed clients provide one.
|
|
595
597
|
session_client: Optional client label such as `claude_code` or `codex`.
|
|
598
|
+
session_provider: Optional provider label such as `anthropic` or `openai`.
|
|
596
599
|
"""
|
|
597
600
|
_set_interactive_db_timeout()
|
|
598
601
|
sid = _generate_sid()
|
|
599
602
|
startup_warnings: list[str] = []
|
|
600
603
|
cleaned = _safe_interactive("stale-session cleanup", clean_stale_sessions, 0, startup_warnings)
|
|
601
604
|
linked_session_id = (session_token or claude_session_id or "").strip()
|
|
605
|
+
inferred_client = (session_client or "").strip()
|
|
606
|
+
if not inferred_client and claude_session_id and not session_token:
|
|
607
|
+
inferred_client = "claude_code"
|
|
602
608
|
# v6.0.7 hotfix: when the caller did not pass an explicit UUID, fall back to
|
|
603
609
|
# the Claude Code SessionStart UUID written by the SessionStart hook to
|
|
604
610
|
# <NEXO_HOME>/coordination/.claude-session-id. This fixes the "unknown
|
|
@@ -606,15 +612,13 @@ def handle_startup(
|
|
|
606
612
|
# nexo_startup() without propagating the hook payload (bug revisited
|
|
607
613
|
# after PR #208 — PR #208 covered the hook side; this covers the
|
|
608
614
|
# startup side so every session row is born correlated).
|
|
609
|
-
if not linked_session_id:
|
|
615
|
+
if not linked_session_id and (not inferred_client or inferred_client == "claude_code"):
|
|
610
616
|
linked_session_id = _autodetect_claude_session_id()
|
|
611
|
-
inferred_client = (session_client or "").strip()
|
|
612
|
-
if not inferred_client and claude_session_id and not session_token:
|
|
613
|
-
inferred_client = "claude_code"
|
|
614
617
|
if not inferred_client and linked_session_id:
|
|
615
618
|
# If we recovered the UUID from the coordination file, the only
|
|
616
619
|
# client that writes there is Claude Code.
|
|
617
620
|
inferred_client = "claude_code"
|
|
621
|
+
inferred_provider = normalize_provider_key(session_provider) or client_to_provider(inferred_client)
|
|
618
622
|
conversation = str(conversation_id or "").strip()
|
|
619
623
|
conflicts = []
|
|
620
624
|
if conversation:
|
|
@@ -623,7 +627,7 @@ def handle_startup(
|
|
|
623
627
|
conn = get_db()
|
|
624
628
|
rows = conn.execute(
|
|
625
629
|
"""
|
|
626
|
-
SELECT sid, task, last_update_epoch, external_session_id, session_client
|
|
630
|
+
SELECT sid, task, last_update_epoch, external_session_id, session_client, session_provider
|
|
627
631
|
FROM sessions
|
|
628
632
|
WHERE conversation_id = ? AND last_update_epoch > ?
|
|
629
633
|
ORDER BY last_update_epoch DESC
|
|
@@ -638,9 +642,10 @@ def handle_startup(
|
|
|
638
642
|
lambda: register_session(
|
|
639
643
|
sid,
|
|
640
644
|
task,
|
|
641
|
-
claude_session_id=linked_session_id,
|
|
645
|
+
claude_session_id=linked_session_id if inferred_client == "claude_code" else "",
|
|
642
646
|
external_session_id=linked_session_id,
|
|
643
647
|
session_client=inferred_client,
|
|
648
|
+
session_provider=inferred_provider,
|
|
644
649
|
conversation_id=conversation,
|
|
645
650
|
),
|
|
646
651
|
None,
|
|
@@ -668,7 +673,7 @@ def handle_startup(
|
|
|
668
673
|
# Backward-compatible: if the alias table does not yet exist (older
|
|
669
674
|
# DB), register_claude_session_alias returns False silently and
|
|
670
675
|
# the legacy sessions.claude_session_id column stays authoritative.
|
|
671
|
-
if linked_session_id:
|
|
676
|
+
if linked_session_id and inferred_client == "claude_code":
|
|
672
677
|
try:
|
|
673
678
|
from hook_guardrails import register_claude_session_alias
|
|
674
679
|
from db import get_db as _get_db
|
|
@@ -712,7 +717,7 @@ def handle_startup(
|
|
|
712
717
|
age = _format_age(row["last_update_epoch"])
|
|
713
718
|
lines.append(
|
|
714
719
|
f" {row['sid']} ({age}) — {row['task']} "
|
|
715
|
-
f"[client={row.get('session_client') or '?'} external={row.get('external_session_id') or '?'}]"
|
|
720
|
+
f"[provider={row.get('session_provider') or '?'} client={row.get('session_client') or '?'} external={row.get('external_session_id') or '?'}]"
|
|
716
721
|
)
|
|
717
722
|
|
|
718
723
|
if memory_maintenance and not memory_maintenance.get("ok"):
|