nexo-brain 5.3.26 → 5.3.27
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/package.json +1 -1
- package/src/server.py +3 -0
- package/src/tools_sessions.py +6 -1
- package/src/dashboard/static/favicon 2.svg +0 -32
- package/src/dashboard/static/nexo-logo 2.png +0 -0
- package/src/dashboard/static/nexo-logo 2.svg +0 -40
- package/src/dashboard/static/style 2.css +0 -2458
- package/src/dashboard/templates/adaptive 2.html +0 -118
- package/src/dashboard/templates/artifacts 2.html +0 -133
- package/src/dashboard/templates/backups 2.html +0 -136
- package/src/dashboard/templates/base 2.html +0 -417
- package/src/dashboard/templates/calendar 2.html +0 -591
- package/src/dashboard/templates/chat 2.html +0 -356
- package/src/dashboard/templates/claims 2.html +0 -259
- package/src/dashboard/templates/cortex 2.html +0 -321
- package/src/dashboard/templates/credentials 2.html +0 -128
- package/src/dashboard/templates/crons 2.html +0 -370
- package/src/dashboard/templates/dashboard 2.html +0 -494
- package/src/dashboard/templates/dreams 2.html +0 -252
- package/src/dashboard/templates/email 2.html +0 -160
- package/src/dashboard/templates/evolution 2.html +0 -189
- package/src/dashboard/templates/feed 2.html +0 -249
- package/src/dashboard/templates/followup_health 2.html +0 -170
- package/src/dashboard/templates/graph 2.html +0 -201
- package/src/dashboard/templates/guard 2.html +0 -259
- package/src/dashboard/templates/inbox 2.html +0 -251
- package/src/dashboard/templates/memory 2.html +0 -420
- package/src/dashboard/templates/operations 2.html +0 -608
- package/src/dashboard/templates/plugins 2.html +0 -185
- package/src/dashboard/templates/protocol 2.html +0 -199
- package/src/dashboard/templates/rules 2.html +0 -246
- package/src/dashboard/templates/sentiment 2.html +0 -247
- package/src/dashboard/templates/sessions 2.html +0 -218
- package/src/dashboard/templates/skills 2.html +0 -329
- package/src/dashboard/templates/somatic 2.html +0 -73
- package/src/dashboard/templates/triggers 2.html +0 -133
- package/src/dashboard/templates/trust 2.html +0 -360
- package/src/db/__init__ 2.py +0 -259
- package/src/db/_core 2.py +0 -437
- package/src/db/_credentials 2.py +0 -124
- package/src/db/_episodic 2.py +0 -762
- package/src/db/_evolution 2.py +0 -54
- package/src/db/_fts 2.py +0 -406
- package/src/db/_goal_profiles 2.py +0 -376
- package/src/db/_hot_context 2.py +0 -660
- package/src/db/_outcomes 2.py +0 -800
- package/src/db/_personal_scripts 2.py +0 -582
- package/src/db/_sessions 2.py +0 -330
- package/src/db/_tasks 2.py +0 -91
- package/src/db/_watchers 2.py +0 -173
- package/src/doctor/formatters 2.py +0 -52
- package/src/doctor/models 2.py +0 -69
- package/src/doctor/planes 2.py +0 -87
- package/src/doctor/providers/__init__ 2.py +0 -1
- package/src/doctor/providers/deep 2.py +0 -367
- package/src/evolution_cycle 2.py +0 -519
- package/src/hooks/auto_capture 2.py +0 -208
- package/src/hooks/caffeinate-guard 2.sh +0 -8
- package/src/hooks/capture-session 2.sh +0 -21
- package/src/hooks/capture-tool-logs 2.sh +0 -158
- package/src/hooks/daily-briefing-check 2.sh +0 -33
- package/src/hooks/heartbeat-enforcement 2.py +0 -90
- package/src/hooks/heartbeat-posttool 2.sh +0 -18
- package/src/hooks/inbox-hook 2.sh +0 -76
- package/src/hooks/post-compact 2.sh +0 -152
- package/src/hooks/pre-compact 2.sh +0 -169
- package/src/hooks/protocol-guardrail 2.sh +0 -10
- package/src/hooks/protocol-pretool-guardrail 2.sh +0 -9
- package/src/hooks/session-stop 2.sh +0 -52
- package/src/kg_populate 2.py +0 -292
- package/src/maintenance 2.py +0 -53
- package/src/memory_backends 2.py +0 -71
- package/src/migrate_embeddings 2.py +0 -124
- package/src/nexo_sdk 2.py +0 -103
- package/src/observability 2.py +0 -199
- package/src/plugin_loader 2.py +0 -217
- package/src/plugins/__init__ 2.py +0 -0
- package/src/plugins/artifact_registry 2.py +0 -450
- package/src/plugins/backup 2.py +0 -127
- package/src/plugins/claims_tools 2.py +0 -119
- package/src/plugins/cognitive_memory 2.py +0 -609
- package/src/plugins/core_rules 2.py +0 -252
- package/src/plugins/cortex 2.py +0 -1155
- package/src/plugins/entities 2.py +0 -67
- package/src/plugins/episodic_memory 2.py +0 -560
- package/src/plugins/evolution 2.py +0 -167
- package/src/plugins/goal_engine 2.py +0 -142
- package/src/plugins/guard 2.py +0 -862
- package/src/plugins/impact 2.py +0 -29
- package/src/plugins/knowledge_graph_tools 2.py +0 -137
- package/src/plugins/media_memory_tools 2.py +0 -98
- package/src/plugins/memory_export 2.py +0 -196
- package/src/plugins/outcomes 2.py +0 -130
- package/src/plugins/personal_scripts 2.py +0 -117
- package/src/plugins/preferences 2.py +0 -47
- package/src/plugins/protocol 2.py +0 -1449
- package/src/plugins/simple_api 2.py +0 -106
- package/src/plugins/skills 2.py +0 -341
- package/src/plugins/state_watchers 2.py +0 -79
- package/src/plugins/update 2.py +0 -986
- package/src/plugins/user_state_tools 2.py +0 -43
- package/src/plugins/workflow 2.py +0 -588
- package/src/protocol_settings 2.py +0 -59
- package/src/public_contribution 2.py +0 -466
- package/src/public_evolution_queue 2.py +0 -241
- package/src/requirements 2.txt +0 -14
- package/src/retroactive_learnings 2.py +0 -373
- package/src/rules/__init__ 2.py +0 -0
- package/src/rules/core-rules 2.json +0 -331
- package/src/rules/migrate 2.py +0 -207
- package/src/runtime_power 2.py +0 -874
- package/src/script_registry 2.py +0 -1559
- package/src/scripts/check-context 2.py +0 -272
- package/src/scripts/deep-sleep/apply_findings 2.py +0 -2327
- package/src/scripts/deep-sleep/collect 2.py +0 -928
- package/src/scripts/deep-sleep/extract 2.py +0 -330
- package/src/scripts/deep-sleep/extract-prompt 2.md +0 -285
- package/src/scripts/deep-sleep/synthesize 2.py +0 -312
- package/src/scripts/deep-sleep/synthesize-prompt 2.md +0 -336
- package/src/scripts/nexo-agent-run 2.py +0 -75
- package/src/scripts/nexo-auto-update 2.py +0 -6
- package/src/scripts/nexo-backup 2.sh +0 -25
- package/src/scripts/nexo-brain-activation 2.sh +0 -140
- package/src/scripts/nexo-catchup 2.py +0 -300
- package/src/scripts/nexo-cognitive-decay 2.py +0 -257
- package/src/scripts/nexo-cortex-cycle 2.py +0 -293
- package/src/scripts/nexo-cron-wrapper 2.sh +0 -53
- package/src/scripts/nexo-daily-self-audit 2.py +0 -2161
- package/src/scripts/nexo-dashboard 2.sh +0 -29
- package/src/scripts/nexo-deep-sleep 2.sh +0 -86
- package/src/scripts/nexo-evolution-run 2.py +0 -1664
- package/src/scripts/nexo-followup-hygiene 2.py +0 -139
- package/src/scripts/nexo-hook-record 2.py +0 -42
- package/src/scripts/nexo-immune 2.py +0 -936
- package/src/scripts/nexo-impact-scorer 2.py +0 -117
- package/src/scripts/nexo-inbox-hook 2.sh +0 -74
- package/src/scripts/nexo-install 2.py +0 -6
- package/src/scripts/nexo-learning-housekeep 2.py +0 -401
- package/src/scripts/nexo-learning-validator 2.py +0 -266
- package/src/scripts/nexo-migrate 2.py +0 -260
- package/src/scripts/nexo-outcome-checker 2.py +0 -127
- package/src/scripts/nexo-postmortem-consolidator 2.py +0 -456
- package/src/scripts/nexo-pre-commit 2.py +0 -120
- package/src/scripts/nexo-prevent-sleep 2.sh +0 -35
- package/src/scripts/nexo-proactive-dashboard 2.py +0 -354
- package/src/scripts/nexo-reflection 2.py +0 -256
- package/src/scripts/nexo-runtime-preflight 2.py +0 -274
- package/src/scripts/nexo-sleep 2.py +0 -631
- package/src/scripts/nexo-snapshot-restore 2.sh +0 -35
- package/src/scripts/nexo-sync-clients 2.py +0 -16
- package/src/scripts/nexo-synthesis 2.py +0 -475
- package/src/scripts/nexo-tcc-approve 2.sh +0 -79
- package/src/scripts/nexo-update 2.sh +0 -306
- package/src/scripts/nexo-watchdog 2.sh +0 -1207
- package/src/scripts/nexo-watchdog-smoke 2.py +0 -119
- package/src/scripts/rehydrate_learnings_from_archive 2.py +0 -245
- package/src/server 2.py +0 -1296
- package/src/skills/run-nexo-audit-phase/guide 2.md +0 -43
- package/src/skills/run-nexo-audit-phase/skill 2.json +0 -59
- package/src/skills/run-nexo-core-fix-cycle/guide 2.md +0 -17
- package/src/skills/run-nexo-core-fix-cycle/script 2.py +0 -276
- package/src/skills/run-nexo-core-fix-cycle/skill 2.json +0 -58
- package/src/skills/run-release-final-audit/guide 2.md +0 -16
- package/src/skills/run-release-final-audit/script 2.py +0 -259
- package/src/skills/run-release-final-audit/skill 2.json +0 -77
- package/src/skills/run-runtime-doctor/guide 2.md +0 -12
- package/src/skills/run-runtime-doctor/script 2.py +0 -21
- package/src/skills/run-runtime-doctor/skill 2.json +0 -25
- package/src/skills_runtime 2.py +0 -932
- package/src/state_watchers_runtime 2.py +0 -475
- package/src/storage_router 2.py +0 -32
- package/src/system_catalog 2.py +0 -786
- package/src/tools_coordination 2.py +0 -103
- package/src/tools_credentials 2.py +0 -68
- package/src/tools_drive 2.py +0 -487
- package/src/tools_hot_context 2.py +0 -163
- package/src/tools_learnings 2.py +0 -612
- package/src/tools_menu 2.py +0 -229
- package/src/tools_reminders 2.py +0 -88
- package/src/tools_reminders_crud 2.py +0 -363
- package/src/tools_sessions 2.py +0 -1054
- package/src/tools_system_catalog 2.py +0 -19
- package/src/tools_task_history 2.py +0 -57
- package/src/tools_transcripts 2.py +0 -98
- package/src/transcript_utils 2.py +0 -412
- package/src/user_context 2.py +0 -46
- package/src/user_data_portability 2.py +0 -328
- package/src/user_state_model 2.py +0 -170
- package/templates/CLAUDE.md 2.template +0 -108
- package/templates/CODEX.AGENTS.md 2.template +0 -66
- package/templates/launchagents/README 2.md +0 -132
- package/templates/launchagents/com.nexo.auto-close-sessions 2.plist +0 -39
- package/templates/launchagents/com.nexo.catchup 2.plist +0 -39
- package/templates/launchagents/com.nexo.cognitive-decay 2.plist +0 -40
- package/templates/launchagents/com.nexo.dashboard 2.plist +0 -43
- package/templates/launchagents/com.nexo.deep-sleep 2.plist +0 -43
- package/templates/launchagents/com.nexo.evolution 2.plist +0 -44
- package/templates/launchagents/com.nexo.followup-hygiene 2.plist +0 -45
- package/templates/launchagents/com.nexo.immune 2.plist +0 -41
- package/templates/launchagents/com.nexo.postmortem 2.plist +0 -45
- package/templates/launchagents/com.nexo.self-audit 2.plist +0 -47
- package/templates/launchagents/com.nexo.synthesis 2.plist +0 -45
- package/templates/launchagents/com.nexo.watchdog 2.plist +0 -37
- package/templates/nexo_helper 2.py +0 -301
- package/templates/openclaw 2.json +0 -13
- package/templates/plugin-template 2.py +0 -40
- package/templates/script-template 2.py +0 -59
- package/templates/script-template 2.sh +0 -13
- package/templates/skill-script-template 2.py +0 -48
- package/templates/skill-template 2.md +0 -33
package/src/tools_sessions 2.py
DELETED
|
@@ -1,1054 +0,0 @@
|
|
|
1
|
-
from __future__ import annotations
|
|
2
|
-
"""Session management tools: startup, heartbeat, status."""
|
|
3
|
-
|
|
4
|
-
import json
|
|
5
|
-
import os
|
|
6
|
-
import time
|
|
7
|
-
import secrets
|
|
8
|
-
import threading
|
|
9
|
-
from datetime import datetime, timezone
|
|
10
|
-
from pathlib import Path
|
|
11
|
-
from db import (
|
|
12
|
-
register_session, update_session, complete_session,
|
|
13
|
-
get_active_sessions, clean_stale_sessions, search_sessions,
|
|
14
|
-
get_inbox, get_pending_questions, now_epoch,
|
|
15
|
-
SESSION_STALE_SECONDS, check_session_has_diary,
|
|
16
|
-
save_checkpoint, read_checkpoint, increment_compaction_count,
|
|
17
|
-
get_db, build_pre_action_context, format_pre_action_context_bundle,
|
|
18
|
-
capture_context_event,
|
|
19
|
-
)
|
|
20
|
-
|
|
21
|
-
# ── Session Keepalive ────────────────────────────────────────────────
|
|
22
|
-
# Background thread per session that auto-pings last_update_epoch every
|
|
23
|
-
# KEEPALIVE_INTERVAL seconds. This prevents clean_stale_sessions from
|
|
24
|
-
# killing sessions that are alive but quiet (e.g. waiting on long Tasks).
|
|
25
|
-
# Threads are daemon=True so they die when the MCP server process exits.
|
|
26
|
-
|
|
27
|
-
KEEPALIVE_INTERVAL = 600 # 10 min — well inside the 15-min TTL
|
|
28
|
-
NEXO_HOME = Path(os.environ.get("NEXO_HOME", str(Path.home() / ".nexo")))
|
|
29
|
-
SESSION_PORTABILITY_DIR = NEXO_HOME / "operations" / "session-portability"
|
|
30
|
-
|
|
31
|
-
_keepalive_threads: dict[str, threading.Event] = {} # sid → stop_event
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
def _env_flag(name: str, default: bool = False) -> bool:
|
|
35
|
-
"""Parse a boolean environment flag with sane falsey values."""
|
|
36
|
-
raw = os.environ.get(name)
|
|
37
|
-
if raw is None:
|
|
38
|
-
return default
|
|
39
|
-
return raw.strip().lower() not in {"", "0", "false", "no", "off"}
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
def _keepalive_loop(sid: str, stop_event: threading.Event) -> None:
|
|
43
|
-
"""Periodically touch the session's last_update_epoch until stopped."""
|
|
44
|
-
while not stop_event.wait(KEEPALIVE_INTERVAL):
|
|
45
|
-
try:
|
|
46
|
-
update_session(sid, None) # None = keep current task, just touch timestamp
|
|
47
|
-
except Exception:
|
|
48
|
-
break # DB gone or session deleted — exit silently
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
def _start_keepalive(sid: str) -> None:
|
|
52
|
-
"""Start a keepalive thread for the given session."""
|
|
53
|
-
_stop_keepalive(sid) # clean up any leftover
|
|
54
|
-
stop_event = threading.Event()
|
|
55
|
-
_keepalive_threads[sid] = stop_event
|
|
56
|
-
t = threading.Thread(target=_keepalive_loop, args=(sid, stop_event), daemon=True)
|
|
57
|
-
t.start()
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
def _stop_keepalive(sid: str) -> None:
|
|
61
|
-
"""Signal the keepalive thread for the given session to stop."""
|
|
62
|
-
stop_event = _keepalive_threads.pop(sid, None)
|
|
63
|
-
if stop_event is not None:
|
|
64
|
-
stop_event.set()
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
def _generate_sid() -> str:
|
|
68
|
-
"""Generate unique session ID: nexo-{epoch}-{random}."""
|
|
69
|
-
return f"nexo-{int(time.time())}-{secrets.randbelow(100000)}"
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
def _format_age(epoch: float) -> str:
|
|
73
|
-
"""Format seconds since epoch as human-readable age."""
|
|
74
|
-
seconds = now_epoch() - epoch
|
|
75
|
-
if seconds < 60:
|
|
76
|
-
return f"{int(seconds)}s"
|
|
77
|
-
elif seconds < 3600:
|
|
78
|
-
return f"{int(seconds / 60)}m"
|
|
79
|
-
else:
|
|
80
|
-
return f"{int(seconds / 3600)}h{int((seconds % 3600) / 60)}m"
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
def _resolve_session_row(conn, sid: str = ""):
|
|
84
|
-
if sid.strip():
|
|
85
|
-
return conn.execute("SELECT * FROM sessions WHERE sid = ?", (sid.strip(),)).fetchone()
|
|
86
|
-
return conn.execute(
|
|
87
|
-
"SELECT * FROM sessions ORDER BY last_update_epoch DESC LIMIT 1"
|
|
88
|
-
).fetchone()
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
def _session_portability_bundle(sid: str = "") -> dict:
|
|
92
|
-
conn = get_db()
|
|
93
|
-
session_row = _resolve_session_row(conn, sid)
|
|
94
|
-
if not session_row:
|
|
95
|
-
return {"ok": False, "error": "session not found"}
|
|
96
|
-
|
|
97
|
-
session_id = str(session_row["sid"])
|
|
98
|
-
checkpoint = read_checkpoint(session_id) or {}
|
|
99
|
-
diary = conn.execute(
|
|
100
|
-
"""SELECT summary, decisions, pending, context_next, mental_state, domain, created_at
|
|
101
|
-
FROM session_diary
|
|
102
|
-
WHERE session_id = ?
|
|
103
|
-
ORDER BY created_at DESC
|
|
104
|
-
LIMIT 1""",
|
|
105
|
-
(session_id,),
|
|
106
|
-
).fetchone()
|
|
107
|
-
draft = conn.execute(
|
|
108
|
-
"""SELECT summary_draft, last_context_hint, updated_at
|
|
109
|
-
FROM session_diary_draft
|
|
110
|
-
WHERE sid = ?""",
|
|
111
|
-
(session_id,),
|
|
112
|
-
).fetchone()
|
|
113
|
-
protocol_tasks = [
|
|
114
|
-
dict(row) for row in conn.execute(
|
|
115
|
-
"""SELECT task_id, goal, task_type, area, status, opened_at
|
|
116
|
-
FROM protocol_tasks
|
|
117
|
-
WHERE session_id = ? AND status = 'open'
|
|
118
|
-
ORDER BY opened_at DESC
|
|
119
|
-
LIMIT 10""",
|
|
120
|
-
(session_id,),
|
|
121
|
-
).fetchall()
|
|
122
|
-
]
|
|
123
|
-
workflow_goals = [
|
|
124
|
-
dict(row) for row in conn.execute(
|
|
125
|
-
"""SELECT goal_id, title, status, priority, next_action, blocker_reason, updated_at
|
|
126
|
-
FROM workflow_goals
|
|
127
|
-
WHERE session_id = ? AND status IN ('active', 'blocked')
|
|
128
|
-
ORDER BY updated_at DESC
|
|
129
|
-
LIMIT 10""",
|
|
130
|
-
(session_id,),
|
|
131
|
-
).fetchall()
|
|
132
|
-
]
|
|
133
|
-
workflow_runs = [
|
|
134
|
-
dict(row) for row in conn.execute(
|
|
135
|
-
"""SELECT run_id, goal_id, goal, workflow_kind, status, priority, next_action, current_step_key, updated_at
|
|
136
|
-
FROM workflow_runs
|
|
137
|
-
WHERE session_id = ? AND status IN ('open', 'running', 'blocked', 'needs_approval')
|
|
138
|
-
ORDER BY updated_at DESC
|
|
139
|
-
LIMIT 10""",
|
|
140
|
-
(session_id,),
|
|
141
|
-
).fetchall()
|
|
142
|
-
]
|
|
143
|
-
recent_query = " | ".join(
|
|
144
|
-
part for part in [
|
|
145
|
-
str(session_row["task"] or "").strip(),
|
|
146
|
-
str((checkpoint or {}).get("current_goal") or "").strip(),
|
|
147
|
-
str((draft or {}).get("last_context_hint") or "").strip(),
|
|
148
|
-
] if part
|
|
149
|
-
)
|
|
150
|
-
recent_context = build_pre_action_context(
|
|
151
|
-
query=recent_query,
|
|
152
|
-
session_id=session_id,
|
|
153
|
-
hours=24,
|
|
154
|
-
limit=4,
|
|
155
|
-
) if recent_query else {"has_matches": False}
|
|
156
|
-
return {
|
|
157
|
-
"ok": True,
|
|
158
|
-
"generated_at": datetime.now(timezone.utc).isoformat(),
|
|
159
|
-
"session": {
|
|
160
|
-
"sid": session_id,
|
|
161
|
-
"task": session_row["task"],
|
|
162
|
-
"client": session_row["session_client"],
|
|
163
|
-
"external_session_id": session_row["external_session_id"],
|
|
164
|
-
"started_epoch": session_row["started_epoch"],
|
|
165
|
-
"last_update_epoch": session_row["last_update_epoch"],
|
|
166
|
-
"local_time": session_row["local_time"],
|
|
167
|
-
},
|
|
168
|
-
"checkpoint": dict(checkpoint) if checkpoint else {},
|
|
169
|
-
"latest_diary": dict(diary) if diary else {},
|
|
170
|
-
"diary_draft": dict(draft) if draft else {},
|
|
171
|
-
"recent_context": recent_context,
|
|
172
|
-
"open_protocol_tasks": protocol_tasks,
|
|
173
|
-
"open_workflow_goals": workflow_goals,
|
|
174
|
-
"open_workflow_runs": workflow_runs,
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
def handle_session_portable_context(sid: str = "") -> str:
|
|
179
|
-
"""Build a portable handoff packet for another client/runtime."""
|
|
180
|
-
bundle = _session_portability_bundle(sid)
|
|
181
|
-
if not bundle.get("ok"):
|
|
182
|
-
return f"ERROR: {bundle.get('error', 'session not found')}"
|
|
183
|
-
|
|
184
|
-
session = bundle["session"]
|
|
185
|
-
checkpoint = bundle.get("checkpoint") or {}
|
|
186
|
-
diary = bundle.get("latest_diary") or {}
|
|
187
|
-
draft = bundle.get("diary_draft") or {}
|
|
188
|
-
lines = [
|
|
189
|
-
"SESSION PORTABILITY PACKET",
|
|
190
|
-
f"SID: {session['sid']}",
|
|
191
|
-
f"Task: {session['task'] or '(none)'}",
|
|
192
|
-
f"Client: {session['client'] or '(unknown)'}",
|
|
193
|
-
]
|
|
194
|
-
if session.get("external_session_id"):
|
|
195
|
-
lines.append(f"External session: {session['external_session_id']}")
|
|
196
|
-
if checkpoint:
|
|
197
|
-
lines.extend(
|
|
198
|
-
[
|
|
199
|
-
"",
|
|
200
|
-
"Checkpoint:",
|
|
201
|
-
f"- Goal: {checkpoint.get('current_goal') or checkpoint.get('task') or '(none)'}",
|
|
202
|
-
f"- Next: {checkpoint.get('next_step') or '(none)'}",
|
|
203
|
-
f"- Files: {checkpoint.get('active_files') or '[]'}",
|
|
204
|
-
]
|
|
205
|
-
)
|
|
206
|
-
if diary:
|
|
207
|
-
lines.extend(
|
|
208
|
-
[
|
|
209
|
-
"",
|
|
210
|
-
"Latest diary:",
|
|
211
|
-
f"- Summary: {diary.get('summary') or '(none)'}",
|
|
212
|
-
f"- Pending: {diary.get('pending') or '(none)'}",
|
|
213
|
-
f"- Context next: {diary.get('context_next') or '(none)'}",
|
|
214
|
-
]
|
|
215
|
-
)
|
|
216
|
-
elif draft:
|
|
217
|
-
lines.extend(
|
|
218
|
-
[
|
|
219
|
-
"",
|
|
220
|
-
"Diary draft:",
|
|
221
|
-
f"- Summary draft: {draft.get('summary_draft') or '(none)'}",
|
|
222
|
-
f"- Context hint: {draft.get('last_context_hint') or '(none)'}",
|
|
223
|
-
]
|
|
224
|
-
)
|
|
225
|
-
recent_context = bundle.get("recent_context") or {}
|
|
226
|
-
if recent_context.get("has_matches"):
|
|
227
|
-
lines.extend(["", format_pre_action_context_bundle(recent_context, compact=True)])
|
|
228
|
-
|
|
229
|
-
protocol_tasks = bundle.get("open_protocol_tasks") or []
|
|
230
|
-
if protocol_tasks:
|
|
231
|
-
lines.extend(["", "Open protocol tasks:"])
|
|
232
|
-
for item in protocol_tasks[:5]:
|
|
233
|
-
lines.append(f"- {item['task_id']}: {item['goal']} [{item['task_type']}/{item['status']}]")
|
|
234
|
-
|
|
235
|
-
goals = bundle.get("open_workflow_goals") or []
|
|
236
|
-
if goals:
|
|
237
|
-
lines.extend(["", "Open goals:"])
|
|
238
|
-
for item in goals[:5]:
|
|
239
|
-
lines.append(f"- {item['goal_id']}: {item['title']} [{item['status']}] -> {item['next_action'] or '(no next action)'}")
|
|
240
|
-
|
|
241
|
-
runs = bundle.get("open_workflow_runs") or []
|
|
242
|
-
if runs:
|
|
243
|
-
lines.extend(["", "Open workflows:"])
|
|
244
|
-
for item in runs[:5]:
|
|
245
|
-
lines.append(
|
|
246
|
-
f"- {item['run_id']}: {item['goal']} [{item['status']}] "
|
|
247
|
-
f"step={item['current_step_key'] or '?'} next={item['next_action'] or '(none)'}"
|
|
248
|
-
)
|
|
249
|
-
|
|
250
|
-
return "\n".join(lines)
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
def handle_session_export_bundle(sid: str = "", path: str = "") -> str:
|
|
254
|
-
"""Export a machine-readable session bundle for cross-client handoff."""
|
|
255
|
-
bundle = _session_portability_bundle(sid)
|
|
256
|
-
if not bundle.get("ok"):
|
|
257
|
-
return json.dumps(bundle, ensure_ascii=False)
|
|
258
|
-
|
|
259
|
-
session_id = bundle["session"]["sid"]
|
|
260
|
-
export_path = Path(path).expanduser() if path else (SESSION_PORTABILITY_DIR / f"{session_id}.json")
|
|
261
|
-
export_path.parent.mkdir(parents=True, exist_ok=True)
|
|
262
|
-
export_path.write_text(json.dumps(bundle, indent=2, ensure_ascii=False) + "\n")
|
|
263
|
-
return json.dumps(
|
|
264
|
-
{
|
|
265
|
-
"ok": True,
|
|
266
|
-
"sid": session_id,
|
|
267
|
-
"path": str(export_path),
|
|
268
|
-
"open_protocol_tasks": len(bundle.get("open_protocol_tasks") or []),
|
|
269
|
-
"open_workflow_goals": len(bundle.get("open_workflow_goals") or []),
|
|
270
|
-
"open_workflow_runs": len(bundle.get("open_workflow_runs") or []),
|
|
271
|
-
},
|
|
272
|
-
ensure_ascii=False,
|
|
273
|
-
)
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
def handle_startup(
|
|
277
|
-
task: str = "Startup",
|
|
278
|
-
claude_session_id: str = "",
|
|
279
|
-
session_token: str = "",
|
|
280
|
-
session_client: str = "",
|
|
281
|
-
) -> str:
|
|
282
|
-
"""Full startup sequence: register, clean, report.
|
|
283
|
-
|
|
284
|
-
Args:
|
|
285
|
-
task: Initial task description
|
|
286
|
-
claude_session_id: Legacy alias for the external client session token.
|
|
287
|
-
session_token: External client session token. Claude Code passes its UUID via hooks;
|
|
288
|
-
other clients may pass a synthetic durable ID when useful.
|
|
289
|
-
Enables automatic inbox detection when hook-backed clients provide one.
|
|
290
|
-
session_client: Optional client label such as `claude_code` or `codex`.
|
|
291
|
-
"""
|
|
292
|
-
sid = _generate_sid()
|
|
293
|
-
cleaned = clean_stale_sessions()
|
|
294
|
-
linked_session_id = (session_token or claude_session_id or "").strip()
|
|
295
|
-
inferred_client = (session_client or "").strip()
|
|
296
|
-
if not inferred_client and claude_session_id and not session_token:
|
|
297
|
-
inferred_client = "claude_code"
|
|
298
|
-
register_session(
|
|
299
|
-
sid,
|
|
300
|
-
task,
|
|
301
|
-
claude_session_id=linked_session_id,
|
|
302
|
-
external_session_id=linked_session_id,
|
|
303
|
-
session_client=inferred_client,
|
|
304
|
-
)
|
|
305
|
-
_start_keepalive(sid)
|
|
306
|
-
active = get_active_sessions()
|
|
307
|
-
other_sessions = [s for s in active if s["sid"] != sid]
|
|
308
|
-
inbox = get_inbox(sid)
|
|
309
|
-
|
|
310
|
-
lines = [f"SID: {sid}"]
|
|
311
|
-
|
|
312
|
-
if cleaned > 0:
|
|
313
|
-
lines.append(f"Cleaned {cleaned} stale sessions.")
|
|
314
|
-
|
|
315
|
-
if other_sessions:
|
|
316
|
-
lines.append("")
|
|
317
|
-
lines.append("ACTIVE SESSIONS:")
|
|
318
|
-
for s in other_sessions:
|
|
319
|
-
age = _format_age(s["last_update_epoch"])
|
|
320
|
-
lines.append(f" {s['sid']} ({age}) — {s['task']}")
|
|
321
|
-
else:
|
|
322
|
-
lines.append("No other active sessions.")
|
|
323
|
-
|
|
324
|
-
if inbox:
|
|
325
|
-
lines.append("")
|
|
326
|
-
lines.append("PENDING MESSAGES:")
|
|
327
|
-
for m in inbox:
|
|
328
|
-
age = _format_age(m["created_epoch"])
|
|
329
|
-
lines.append(f" [{m['from_sid']}] ({age}): {m['text']}")
|
|
330
|
-
|
|
331
|
-
# Check LaunchAgent health (macOS only)
|
|
332
|
-
la_warnings = _check_launchagents()
|
|
333
|
-
if la_warnings:
|
|
334
|
-
lines.append("")
|
|
335
|
-
lines.append("⚠ LAUNCHAGENT MISMATCH (plist on disk ≠ loaded in memory):")
|
|
336
|
-
for w in la_warnings:
|
|
337
|
-
lines.append(f" {w}")
|
|
338
|
-
lines.append(" Fix: launchctl unload + load the affected plists, or restart.")
|
|
339
|
-
|
|
340
|
-
return "\n".join(lines)
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
def _check_launchagents() -> list[str]:
|
|
344
|
-
"""Compare on-disk plists with what launchctl has loaded. macOS only."""
|
|
345
|
-
import platform
|
|
346
|
-
if platform.system() != "Darwin":
|
|
347
|
-
return []
|
|
348
|
-
|
|
349
|
-
import os, subprocess, plistlib, glob
|
|
350
|
-
|
|
351
|
-
plist_dir = os.path.expanduser("~/Library/LaunchAgents")
|
|
352
|
-
warnings = []
|
|
353
|
-
|
|
354
|
-
for plist_path in glob.glob(os.path.join(plist_dir, "com.nexo.*.plist")):
|
|
355
|
-
label = os.path.basename(plist_path).replace(".plist", "")
|
|
356
|
-
try:
|
|
357
|
-
with open(plist_path, "rb") as f:
|
|
358
|
-
disk = plistlib.load(f)
|
|
359
|
-
disk_args = disk.get("ProgramArguments", [])
|
|
360
|
-
|
|
361
|
-
result = subprocess.run(
|
|
362
|
-
["launchctl", "list", label],
|
|
363
|
-
capture_output=True, text=True, timeout=5
|
|
364
|
-
)
|
|
365
|
-
if result.returncode != 0:
|
|
366
|
-
warnings.append(f"{label}: not loaded (plist exists on disk)")
|
|
367
|
-
continue
|
|
368
|
-
|
|
369
|
-
# Parse loaded ProgramArguments from launchctl output
|
|
370
|
-
loaded_args = []
|
|
371
|
-
in_args = False
|
|
372
|
-
for line in result.stdout.splitlines():
|
|
373
|
-
if '"ProgramArguments"' in line:
|
|
374
|
-
in_args = True
|
|
375
|
-
continue
|
|
376
|
-
if in_args:
|
|
377
|
-
line = line.strip().rstrip(";")
|
|
378
|
-
if line == ");":
|
|
379
|
-
break
|
|
380
|
-
if line.startswith('"') and line.endswith('"'):
|
|
381
|
-
loaded_args.append(line.strip('"'))
|
|
382
|
-
|
|
383
|
-
if loaded_args and disk_args and loaded_args != disk_args:
|
|
384
|
-
# Check if loaded path points to /tmp or nonexistent path
|
|
385
|
-
stale = any("/tmp/" in a or not os.path.exists(a) for a in loaded_args if "/" in a)
|
|
386
|
-
if stale:
|
|
387
|
-
# Auto-repair: reload the plist
|
|
388
|
-
subprocess.run(["launchctl", "unload", plist_path], capture_output=True, timeout=5)
|
|
389
|
-
subprocess.run(["launchctl", "load", plist_path], capture_output=True, timeout=5)
|
|
390
|
-
warnings.append(f"{label}: AUTO-REPAIRED (was pointing to stale/tmp path, reloaded from disk)")
|
|
391
|
-
else:
|
|
392
|
-
warnings.append(f"{label}: loaded args differ from disk plist")
|
|
393
|
-
except Exception:
|
|
394
|
-
continue
|
|
395
|
-
|
|
396
|
-
return warnings
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
def handle_heartbeat(sid: str, task: str, context_hint: str = '') -> str:
|
|
400
|
-
"""Update session, check inbox + questions. Lightweight — no embeddings, no RAG.
|
|
401
|
-
|
|
402
|
-
For cognitive features (sentiment, trust, RAG), use dedicated tools on-demand:
|
|
403
|
-
- nexo_cognitive_sentiment (sentiment detection)
|
|
404
|
-
- nexo_cognitive_trust (trust adjustment)
|
|
405
|
-
- nexo_cognitive_retrieve / nexo_recall (memory retrieval)
|
|
406
|
-
- nexo_context_packet (area-specific learnings)
|
|
407
|
-
|
|
408
|
-
Args:
|
|
409
|
-
sid: Session ID
|
|
410
|
-
task: Current task description
|
|
411
|
-
context_hint: Optional — stored for diary draft context and used for recent 24h continuity lookup.
|
|
412
|
-
|
|
413
|
-
OpenTelemetry: emits an ai.tool.nexo_heartbeat span when OTEL is
|
|
414
|
-
enabled (Fase 5 item 2). The span carries sid, task, and the
|
|
415
|
-
context_hint length so dashboards can correlate heartbeat cadence
|
|
416
|
-
with workload. No-op when telemetry is off.
|
|
417
|
-
"""
|
|
418
|
-
from observability import tool_span
|
|
419
|
-
with tool_span(
|
|
420
|
-
"nexo_heartbeat",
|
|
421
|
-
attributes={
|
|
422
|
-
"nexo.session.id": sid,
|
|
423
|
-
"nexo.heartbeat.task": (task or "")[:200],
|
|
424
|
-
"nexo.heartbeat.context_hint_length": len(context_hint or ""),
|
|
425
|
-
},
|
|
426
|
-
):
|
|
427
|
-
return _handle_heartbeat_inner(sid, task, context_hint)
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
def _handle_heartbeat_inner(sid: str, task: str, context_hint: str = '') -> str:
|
|
431
|
-
"""Inner body of handle_heartbeat — wrapped by tool_span above."""
|
|
432
|
-
from db import get_db
|
|
433
|
-
update_session(sid, task)
|
|
434
|
-
parts = [f"OK: {sid} — {task}"]
|
|
435
|
-
|
|
436
|
-
inbox = get_inbox(sid)
|
|
437
|
-
if inbox:
|
|
438
|
-
parts.append("")
|
|
439
|
-
parts.append("MESSAGES:")
|
|
440
|
-
for m in inbox:
|
|
441
|
-
age = _format_age(m["created_epoch"])
|
|
442
|
-
parts.append(f" [{m['from_sid']}] ({age}): {m['text']}")
|
|
443
|
-
|
|
444
|
-
questions = get_pending_questions(sid)
|
|
445
|
-
if questions:
|
|
446
|
-
parts.append("")
|
|
447
|
-
parts.append("PENDING QUESTIONS (respond with nexo_answer):")
|
|
448
|
-
for q in questions:
|
|
449
|
-
age = _format_age(q["created_epoch"])
|
|
450
|
-
parts.append(f" {q['qid']} de {q['from_sid']} ({age}): {q['question']}")
|
|
451
|
-
|
|
452
|
-
recent_query = (context_hint or task or "").strip()
|
|
453
|
-
if recent_query:
|
|
454
|
-
try:
|
|
455
|
-
bundle = build_pre_action_context(
|
|
456
|
-
query=recent_query,
|
|
457
|
-
session_id=sid,
|
|
458
|
-
hours=24,
|
|
459
|
-
limit=4,
|
|
460
|
-
)
|
|
461
|
-
if bundle.get("has_matches"):
|
|
462
|
-
parts.append("")
|
|
463
|
-
parts.append(format_pre_action_context_bundle(bundle, compact=True))
|
|
464
|
-
except Exception:
|
|
465
|
-
pass
|
|
466
|
-
|
|
467
|
-
# Incremental diary draft — accumulate every heartbeat, full UPSERT every 5
|
|
468
|
-
_hb_count = 0 # Hoisted for Layer 3 DIARY_OVERDUE signal
|
|
469
|
-
try:
|
|
470
|
-
import json as _json
|
|
471
|
-
from db import get_diary_draft, upsert_diary_draft
|
|
472
|
-
|
|
473
|
-
draft = get_diary_draft(sid)
|
|
474
|
-
hb_count = (draft["heartbeat_count"] + 1) if draft else 1
|
|
475
|
-
_hb_count = hb_count # Copy to outer scope for Layer 3
|
|
476
|
-
|
|
477
|
-
existing_tasks = _json.loads(draft["tasks_seen"]) if draft else []
|
|
478
|
-
if task and task not in existing_tasks:
|
|
479
|
-
existing_tasks.append(task)
|
|
480
|
-
|
|
481
|
-
_conn = get_db()
|
|
482
|
-
if hb_count % 5 == 0 or hb_count == 1:
|
|
483
|
-
change_rows = _conn.execute(
|
|
484
|
-
"SELECT id FROM change_log WHERE session_id = ? ORDER BY id", (sid,)
|
|
485
|
-
).fetchall()
|
|
486
|
-
change_ids = [r["id"] for r in change_rows]
|
|
487
|
-
|
|
488
|
-
decision_rows = _conn.execute(
|
|
489
|
-
"SELECT id FROM decisions WHERE session_id = ? ORDER BY id", (sid,)
|
|
490
|
-
).fetchall()
|
|
491
|
-
decision_ids = [r["id"] for r in decision_rows]
|
|
492
|
-
|
|
493
|
-
summary = f"Session tasks: {', '.join(existing_tasks[-10:])}"
|
|
494
|
-
upsert_diary_draft(
|
|
495
|
-
sid=sid,
|
|
496
|
-
tasks_seen=_json.dumps(existing_tasks),
|
|
497
|
-
change_ids=_json.dumps(change_ids),
|
|
498
|
-
decision_ids=_json.dumps(decision_ids),
|
|
499
|
-
last_context_hint=context_hint[:300] if context_hint else '',
|
|
500
|
-
heartbeat_count=hb_count,
|
|
501
|
-
summary_draft=summary,
|
|
502
|
-
)
|
|
503
|
-
else:
|
|
504
|
-
upsert_diary_draft(
|
|
505
|
-
sid=sid,
|
|
506
|
-
tasks_seen=_json.dumps(existing_tasks),
|
|
507
|
-
change_ids=draft["change_ids"] if draft else '[]',
|
|
508
|
-
decision_ids=draft["decision_ids"] if draft else '[]',
|
|
509
|
-
last_context_hint=context_hint[:300] if context_hint else (draft["last_context_hint"] if draft else ''),
|
|
510
|
-
heartbeat_count=hb_count,
|
|
511
|
-
summary_draft=draft["summary_draft"] if draft else f"Session task: {task}",
|
|
512
|
-
)
|
|
513
|
-
except Exception:
|
|
514
|
-
pass # Draft accumulation is best-effort, never block heartbeat
|
|
515
|
-
|
|
516
|
-
# Update session checkpoint with current goal (lightweight, every heartbeat)
|
|
517
|
-
try:
|
|
518
|
-
save_checkpoint(
|
|
519
|
-
sid=sid,
|
|
520
|
-
task=task,
|
|
521
|
-
current_goal=context_hint[:300] if context_hint else task,
|
|
522
|
-
)
|
|
523
|
-
except Exception:
|
|
524
|
-
pass # Checkpoint update is best-effort
|
|
525
|
-
|
|
526
|
-
try:
|
|
527
|
-
capture_context_event(
|
|
528
|
-
event_type="heartbeat",
|
|
529
|
-
title=task[:160],
|
|
530
|
-
summary=(context_hint or task)[:600],
|
|
531
|
-
body=context_hint[:1600] if context_hint else "",
|
|
532
|
-
context_key=f"session:{sid}",
|
|
533
|
-
context_title=task[:160],
|
|
534
|
-
context_summary=(context_hint or task)[:600],
|
|
535
|
-
context_type="session_topic",
|
|
536
|
-
state="active",
|
|
537
|
-
owner="session",
|
|
538
|
-
actor=sid,
|
|
539
|
-
source_type="heartbeat",
|
|
540
|
-
source_id=sid,
|
|
541
|
-
session_id=sid,
|
|
542
|
-
metadata={"task": task[:160]},
|
|
543
|
-
ttl_hours=24,
|
|
544
|
-
)
|
|
545
|
-
except Exception:
|
|
546
|
-
pass
|
|
547
|
-
|
|
548
|
-
# ── Drive/Curiosity: detect signals from context_hint (best-effort) ──
|
|
549
|
-
try:
|
|
550
|
-
if context_hint and len(context_hint.strip()) >= 15:
|
|
551
|
-
from tools_drive import detect_drive_signal as _detect_drive
|
|
552
|
-
_drive_allow_llm = _env_flag("NEXO_DRIVE_LLM_IN_HEARTBEAT", default=False)
|
|
553
|
-
_drive_result = _detect_drive(
|
|
554
|
-
context_hint,
|
|
555
|
-
source="heartbeat",
|
|
556
|
-
source_id=sid,
|
|
557
|
-
allow_llm=_drive_allow_llm,
|
|
558
|
-
)
|
|
559
|
-
if _drive_result:
|
|
560
|
-
# Check for READY signals relevant to current area
|
|
561
|
-
from db import get_drive_signals as _get_drive
|
|
562
|
-
_ready = _get_drive(status="ready", limit=3)
|
|
563
|
-
if _ready:
|
|
564
|
-
parts.append("")
|
|
565
|
-
parts.append(f"DRIVE: {len(_ready)} mature signal(s) ready for investigation")
|
|
566
|
-
for _ds in _ready[:2]:
|
|
567
|
-
parts.append(f" [{_ds['id']}] {_ds['signal_type']}: {_ds['summary'][:80]}")
|
|
568
|
-
except Exception:
|
|
569
|
-
pass # Drive detection is best-effort, never block heartbeat
|
|
570
|
-
|
|
571
|
-
# ── Layer 3: DIARY_OVERDUE signal based on heartbeat count + time ──
|
|
572
|
-
conn = get_db()
|
|
573
|
-
row = conn.execute("SELECT started_epoch FROM sessions WHERE sid = ?", (sid,)).fetchone()
|
|
574
|
-
if row:
|
|
575
|
-
age_seconds = now_epoch() - row["started_epoch"]
|
|
576
|
-
has_diary = check_session_has_diary(sid)
|
|
577
|
-
|
|
578
|
-
# DIARY_OVERDUE: >10 heartbeats OR >30 minutes, without a diary
|
|
579
|
-
if not has_diary and (_hb_count > 10 or age_seconds >= 1800):
|
|
580
|
-
parts.append("")
|
|
581
|
-
parts.append(f"⚠ DIARY_OVERDUE: {_hb_count} heartbeats, {int(age_seconds/60)}min active, no diary. Write nexo_session_diary_write NOW.")
|
|
582
|
-
|
|
583
|
-
# Guard check reminder: if context_hint mentions code editing and no guard_check this session
|
|
584
|
-
if context_hint and _hint_suggests_code_edit(context_hint):
|
|
585
|
-
try:
|
|
586
|
-
guard_used = conn.execute(
|
|
587
|
-
"SELECT COUNT(*) FROM guard_log WHERE session_id = ?", (sid,)
|
|
588
|
-
).fetchone()[0]
|
|
589
|
-
if guard_used == 0:
|
|
590
|
-
parts.append("")
|
|
591
|
-
parts.append("⚠ GUARD REMINDER: You appear to be editing code but haven't called `nexo_guard_check` this session. Do it NOW before any edits.")
|
|
592
|
-
except Exception:
|
|
593
|
-
pass # guard_log table may not exist in older installs
|
|
594
|
-
|
|
595
|
-
if context_hint and _hint_suggests_correction(context_hint):
|
|
596
|
-
try:
|
|
597
|
-
if not _recent_learning_capture_exists(conn, sid, window_seconds=300):
|
|
598
|
-
parts.append("")
|
|
599
|
-
parts.append(
|
|
600
|
-
"⚠ LEARNING REMINDER: This looks like a user correction and no recent learning was captured. "
|
|
601
|
-
"If it revealed a reusable pattern, write `nexo_learning_add` NOW."
|
|
602
|
-
)
|
|
603
|
-
except Exception:
|
|
604
|
-
pass # Best-effort reminder only
|
|
605
|
-
|
|
606
|
-
# Adaptive mode auto-fire from heartbeat. Closes the audit-followup
|
|
607
|
-
# adaptive_log circuit: previously the table only filled when an agent
|
|
608
|
-
# explicitly called nexo_adaptive_mode with signals, which almost never
|
|
609
|
-
# happened in normal flow. Result: the learn_weights() pipeline (Fase 2
|
|
610
|
-
# item 4) had zero training data and the shadow→active graduation
|
|
611
|
-
# never fired. Now every heartbeat derives the 6 signals from the
|
|
612
|
-
# context_hint and task fields and runs compute_mode, which writes one
|
|
613
|
-
# adaptive_log row per heartbeat. Wrapped in best-effort try/except so
|
|
614
|
-
# a failure here cannot block the heartbeat itself.
|
|
615
|
-
try:
|
|
616
|
-
if context_hint and len(context_hint.strip()) >= 5:
|
|
617
|
-
from plugins.adaptive_mode import compute_mode
|
|
618
|
-
from cognitive._trust import detect_sentiment
|
|
619
|
-
sentiment = detect_sentiment(context_hint)
|
|
620
|
-
vibe_label = sentiment.get("sentiment", "neutral")
|
|
621
|
-
vibe_intensity = float(sentiment.get("intensity", 0.5) or 0.5)
|
|
622
|
-
# Heuristic signal derivation — same fields the manual tool
|
|
623
|
-
# would feed compute_mode with, just synthesized from context.
|
|
624
|
-
compute_mode(
|
|
625
|
-
vibe=vibe_label,
|
|
626
|
-
vibe_intensity=vibe_intensity,
|
|
627
|
-
recent_corrections=0, # heartbeat does not see explicit corrections
|
|
628
|
-
user_msg_length=len(context_hint),
|
|
629
|
-
context_hint=context_hint[:300],
|
|
630
|
-
tool_had_error=False, # heartbeat is post-tool, not pre-tool
|
|
631
|
-
)
|
|
632
|
-
except Exception:
|
|
633
|
-
pass # Best-effort, never block heartbeat
|
|
634
|
-
|
|
635
|
-
# Protocol debt surfacing: if this session has open debts, warn so the
|
|
636
|
-
# agent can resolve them with nexo_protocol_debt_resolve before claiming
|
|
637
|
-
# any task complete. Mirrors task_open / task_close behavior so that
|
|
638
|
-
# protocol debt is visible at every protocol touchpoint, not only at
|
|
639
|
-
# task boundaries.
|
|
640
|
-
try:
|
|
641
|
-
from db import list_protocol_debts
|
|
642
|
-
session_debts = list_protocol_debts(status="open", session_id=sid, limit=5)
|
|
643
|
-
if session_debts:
|
|
644
|
-
error_count = sum(1 for d in session_debts if d.get("severity") == "error")
|
|
645
|
-
icon = "⛔" if error_count else "⚠"
|
|
646
|
-
parts.append("")
|
|
647
|
-
parts.append(
|
|
648
|
-
f"{icon} PROTOCOL DEBT: {len(session_debts)} open debt(s) in this session"
|
|
649
|
-
+ (f" ({error_count} error)" if error_count else "")
|
|
650
|
-
+ "."
|
|
651
|
-
)
|
|
652
|
-
for debt in session_debts[:3]:
|
|
653
|
-
evidence = (debt.get("evidence") or "").strip().replace("\n", " ")
|
|
654
|
-
parts.append(
|
|
655
|
-
f" [{debt.get('id')}] {debt.get('debt_type', '?')}"
|
|
656
|
-
f" ({debt.get('severity', '?')}): {evidence[:100]}"
|
|
657
|
-
)
|
|
658
|
-
parts.append(
|
|
659
|
-
" Resolve with nexo_protocol_debt_resolve before claiming task complete."
|
|
660
|
-
)
|
|
661
|
-
except Exception:
|
|
662
|
-
pass # Best-effort surfacing, never block heartbeat
|
|
663
|
-
|
|
664
|
-
return "\n".join(parts)
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
def handle_context_packet(area: str, files: str = "") -> str:
|
|
668
|
-
"""Build a context packet for a specific area/project — designed for subagent injection.
|
|
669
|
-
|
|
670
|
-
Returns: relevant learnings + last 5 changes + active followups + key preferences
|
|
671
|
-
for the given area. Use this before delegating to a subagent.
|
|
672
|
-
|
|
673
|
-
Args:
|
|
674
|
-
area: Project/area name (e.g., 'ecommerce', 'shopify', 'backend', 'mobile-app', 'nexo')
|
|
675
|
-
files: Optional comma-separated file paths for guard check
|
|
676
|
-
"""
|
|
677
|
-
from db import get_db
|
|
678
|
-
parts = []
|
|
679
|
-
|
|
680
|
-
# 1. Learnings for this area (from nexo.db)
|
|
681
|
-
conn = get_db()
|
|
682
|
-
learnings = conn.execute(
|
|
683
|
-
"SELECT id, title, content FROM learnings WHERE category LIKE ? OR content LIKE ? ORDER BY id DESC LIMIT 15",
|
|
684
|
-
(f"%{area}%", f"%{area}%")
|
|
685
|
-
).fetchall()
|
|
686
|
-
if learnings:
|
|
687
|
-
parts.append("## KNOWN ERRORS — DO NOT REPEAT")
|
|
688
|
-
for l in learnings:
|
|
689
|
-
parts.append(f" L#{l['id']}: {l['title']}")
|
|
690
|
-
# First 200 chars of content
|
|
691
|
-
parts.append(f" {l['content'][:200]}")
|
|
692
|
-
parts.append("")
|
|
693
|
-
|
|
694
|
-
# 2. Last 5 changes in this area
|
|
695
|
-
changes = conn.execute(
|
|
696
|
-
"SELECT id, files, what_changed, why FROM change_log WHERE files LIKE ? OR what_changed LIKE ? ORDER BY id DESC LIMIT 5",
|
|
697
|
-
(f"%{area}%", f"%{area}%")
|
|
698
|
-
).fetchall()
|
|
699
|
-
if changes:
|
|
700
|
-
parts.append("## RECENT CHANGES")
|
|
701
|
-
for c in changes:
|
|
702
|
-
parts.append(f" C#{c['id']}: {c['what_changed'][:150]}")
|
|
703
|
-
if c['why']:
|
|
704
|
-
parts.append(f" Why: {c['why'][:100]}")
|
|
705
|
-
parts.append("")
|
|
706
|
-
|
|
707
|
-
# 3. Active followups for this area
|
|
708
|
-
followups = conn.execute(
|
|
709
|
-
"SELECT id, description, date, verification FROM followups WHERE status = 'PENDING' AND (description LIKE ? OR verification LIKE ?) ORDER BY date ASC LIMIT 10",
|
|
710
|
-
(f"%{area}%", f"%{area}%")
|
|
711
|
-
).fetchall()
|
|
712
|
-
if followups:
|
|
713
|
-
parts.append("## ACTIVE FOLLOWUPS")
|
|
714
|
-
for f in followups:
|
|
715
|
-
parts.append(f" {f['id']}: {f['description'][:150]} (date: {f['date']})")
|
|
716
|
-
parts.append("")
|
|
717
|
-
|
|
718
|
-
# 4. Preferences related to this area
|
|
719
|
-
try:
|
|
720
|
-
prefs = conn.execute(
|
|
721
|
-
"SELECT key, value FROM preferences WHERE key LIKE ? OR value LIKE ? LIMIT 10",
|
|
722
|
-
(f"%{area}%", f"%{area}%")
|
|
723
|
-
).fetchall()
|
|
724
|
-
if prefs:
|
|
725
|
-
parts.append("## PREFERENCES")
|
|
726
|
-
for p in prefs:
|
|
727
|
-
parts.append(f" {p['key']}: {p['value'][:150]}")
|
|
728
|
-
parts.append("")
|
|
729
|
-
except Exception:
|
|
730
|
-
pass
|
|
731
|
-
|
|
732
|
-
# 5. Recent hot context in the last 24h
|
|
733
|
-
try:
|
|
734
|
-
hot_bundle = build_pre_action_context(query=area, hours=24, limit=4)
|
|
735
|
-
if hot_bundle.get("has_matches"):
|
|
736
|
-
parts.append("## RECENT HOT CONTEXT (24H)")
|
|
737
|
-
parts.append(format_pre_action_context_bundle(hot_bundle, compact=True))
|
|
738
|
-
parts.append("")
|
|
739
|
-
except Exception:
|
|
740
|
-
pass
|
|
741
|
-
|
|
742
|
-
# 6. Cognitive memories for this area
|
|
743
|
-
try:
|
|
744
|
-
import cognitive
|
|
745
|
-
results = cognitive.search(
|
|
746
|
-
query_text=area,
|
|
747
|
-
top_k=5,
|
|
748
|
-
min_score=0.55,
|
|
749
|
-
stores="ltm",
|
|
750
|
-
rehearse=False,
|
|
751
|
-
)
|
|
752
|
-
if results:
|
|
753
|
-
parts.append("## RELEVANT COGNITIVE MEMORIES")
|
|
754
|
-
for r in results:
|
|
755
|
-
parts.append(f" [{r['source_type']}] {r['source_title'] or r['content'][:80]}")
|
|
756
|
-
parts.append("")
|
|
757
|
-
except Exception:
|
|
758
|
-
pass
|
|
759
|
-
|
|
760
|
-
# 7. Data flow tracing requirement (mandatory for all subagents)
|
|
761
|
-
parts.append("## MANDATORY RULE: DATA FLOW TRACING")
|
|
762
|
-
parts.append("BEFORE modifying any file or data, answer these 3 questions:")
|
|
763
|
-
parts.append(" 1. WHO PRODUCES this data? (which function/cron/endpoint generates it)")
|
|
764
|
-
parts.append(" 2. WHO CONSUMES this data? (what other files/functions read it)")
|
|
765
|
-
parts.append(" 3. WHAT BREAKS if I change it? (downstream effects)")
|
|
766
|
-
parts.append("If you can't answer all 3 → READ the code that produces and consumes BEFORE touching.")
|
|
767
|
-
parts.append("If you still can't → STOP and return the question. Do NOT guess.")
|
|
768
|
-
parts.append("")
|
|
769
|
-
|
|
770
|
-
if not parts:
|
|
771
|
-
return f"No context found for area '{area}'. The subagent will start with no project-specific knowledge."
|
|
772
|
-
|
|
773
|
-
header = f"CONTEXT PACKET — {area.upper()}\n{'='*40}\n\n"
|
|
774
|
-
footer = f"\n{'='*40}\nINSTRUCTION: If you're not 100% sure about a fact, STOP and return the question. Do NOT invent."
|
|
775
|
-
return header + "\n".join(parts) + footer
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
def _load_session_tone() -> str | None:
|
|
779
|
-
"""Load session-tone.json generated by Deep Sleep and format as startup guidance.
|
|
780
|
-
|
|
781
|
-
Returns a human-readable instruction block that tells the agent HOW to behave
|
|
782
|
-
emotionally in this session, based on yesterday's analysis.
|
|
783
|
-
"""
|
|
784
|
-
import os
|
|
785
|
-
from pathlib import Path
|
|
786
|
-
nexo_home = Path(os.environ.get("NEXO_HOME", str(Path.home() / ".nexo")))
|
|
787
|
-
tone_file = nexo_home / "operations" / "session-tone.json"
|
|
788
|
-
|
|
789
|
-
if not tone_file.exists():
|
|
790
|
-
return None
|
|
791
|
-
|
|
792
|
-
try:
|
|
793
|
-
import json
|
|
794
|
-
tone = json.loads(tone_file.read_text())
|
|
795
|
-
except Exception:
|
|
796
|
-
return None
|
|
797
|
-
|
|
798
|
-
# Don't return stale tone (>48h old)
|
|
799
|
-
from datetime import datetime, timedelta
|
|
800
|
-
tone_date = tone.get("date", "")
|
|
801
|
-
if tone_date:
|
|
802
|
-
try:
|
|
803
|
-
td = datetime.strptime(tone_date, "%Y-%m-%d")
|
|
804
|
-
if datetime.now() - td > timedelta(hours=48):
|
|
805
|
-
return None
|
|
806
|
-
except ValueError:
|
|
807
|
-
pass
|
|
808
|
-
|
|
809
|
-
parts = ["SESSION TONE (from Deep Sleep analysis):"]
|
|
810
|
-
|
|
811
|
-
mood = tone.get("mood_yesterday", 0.5)
|
|
812
|
-
approach = tone.get("approach", "neutral")
|
|
813
|
-
parts.append(f" Yesterday mood: {mood:.0%} | Approach today: {approach}")
|
|
814
|
-
|
|
815
|
-
if tone.get("acknowledge_mistakes"):
|
|
816
|
-
mistakes = tone.get("mistakes_to_own", [])
|
|
817
|
-
parts.append(f" ⚠ OWN YOUR MISTAKES: You made errors yesterday. Acknowledge them specifically:")
|
|
818
|
-
for m in mistakes[:3]:
|
|
819
|
-
parts.append(f" - {m[:100]}")
|
|
820
|
-
parts.append(" Show what you learned. Don't just apologize — demonstrate improvement.")
|
|
821
|
-
|
|
822
|
-
if tone.get("motivational"):
|
|
823
|
-
if mood < 0.4:
|
|
824
|
-
parts.append(" 💪 USER HAD A TOUGH DAY: Be supportive. Lighter start. Acknowledge difficulty.")
|
|
825
|
-
else:
|
|
826
|
-
parts.append(" 🚀 USER HAD A GREAT DAY: Reinforce momentum. Reference wins. Push ambitious goals.")
|
|
827
|
-
|
|
828
|
-
if tone.get("reduce_load"):
|
|
829
|
-
parts.append(" 📉 REDUCE LOAD: Don't overwhelm with tasks. Propose 1-2 key things, not a full agenda.")
|
|
830
|
-
|
|
831
|
-
ctx = tone.get("suggested_greeting_context", "")
|
|
832
|
-
if ctx:
|
|
833
|
-
parts.append(f" Context: {ctx.strip()}")
|
|
834
|
-
|
|
835
|
-
return "\n".join(parts) if len(parts) > 1 else None
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
def handle_smart_startup_query() -> str:
|
|
839
|
-
"""Generate and execute a composite cognitive query from pending followups + diary topics + reminders.
|
|
840
|
-
|
|
841
|
-
Called during startup to pre-load the most relevant context for this session.
|
|
842
|
-
Returns cognitive memories that match the current operational state.
|
|
843
|
-
"""
|
|
844
|
-
from db import get_db
|
|
845
|
-
conn = get_db()
|
|
846
|
-
query_parts = []
|
|
847
|
-
|
|
848
|
-
# 1. Pending followups (what NEXO needs to do)
|
|
849
|
-
followups = conn.execute(
|
|
850
|
-
"SELECT description FROM followups WHERE status = 'PENDING' ORDER BY date ASC LIMIT 5"
|
|
851
|
-
).fetchall()
|
|
852
|
-
for f in followups:
|
|
853
|
-
query_parts.append(f['description'][:100])
|
|
854
|
-
|
|
855
|
-
# 2. Due reminders (what the user needs to know)
|
|
856
|
-
reminders = conn.execute(
|
|
857
|
-
"SELECT description FROM reminders WHERE status = 'PENDING' AND date <= date('now', '+1 day') ORDER BY date ASC LIMIT 5"
|
|
858
|
-
).fetchall()
|
|
859
|
-
for r in reminders:
|
|
860
|
-
query_parts.append(r['description'][:100])
|
|
861
|
-
|
|
862
|
-
# 3. Last session diary topics
|
|
863
|
-
try:
|
|
864
|
-
last_diary = conn.execute(
|
|
865
|
-
"SELECT summary FROM session_diary ORDER BY id DESC LIMIT 1"
|
|
866
|
-
).fetchone()
|
|
867
|
-
if last_diary and last_diary['summary']:
|
|
868
|
-
query_parts.append(last_diary['summary'][:200])
|
|
869
|
-
except Exception:
|
|
870
|
-
pass
|
|
871
|
-
|
|
872
|
-
if not query_parts:
|
|
873
|
-
return "No pending context to pre-load."
|
|
874
|
-
|
|
875
|
-
# Search per-part to avoid diffuse centroid that matches everything
|
|
876
|
-
try:
|
|
877
|
-
import cognitive
|
|
878
|
-
all_results = []
|
|
879
|
-
seen_ids = set()
|
|
880
|
-
for part in query_parts[:6]:
|
|
881
|
-
part_results = cognitive.search(
|
|
882
|
-
query_text=part,
|
|
883
|
-
top_k=3,
|
|
884
|
-
min_score=0.6,
|
|
885
|
-
stores="both",
|
|
886
|
-
rehearse=False, # Don't inflate strength on startup
|
|
887
|
-
)
|
|
888
|
-
for r in part_results:
|
|
889
|
-
key = (r["store"], r["id"])
|
|
890
|
-
if key not in seen_ids:
|
|
891
|
-
seen_ids.add(key)
|
|
892
|
-
all_results.append(r)
|
|
893
|
-
# Sort by score descending, take top 10
|
|
894
|
-
results = sorted(all_results, key=lambda x: x["score"], reverse=True)[:10]
|
|
895
|
-
composite_query = " | ".join(query_parts[:6])
|
|
896
|
-
if not results:
|
|
897
|
-
return "Smart startup query: no relevant memories found."
|
|
898
|
-
|
|
899
|
-
lines = [f"SMART STARTUP — {len(results)} memories pre-loaded from composite query:"]
|
|
900
|
-
lines.append(f"Query: {composite_query[:200]}...")
|
|
901
|
-
lines.append("")
|
|
902
|
-
lines.append(cognitive.format_results(results))
|
|
903
|
-
|
|
904
|
-
try:
|
|
905
|
-
hot_bundle = build_pre_action_context(query=composite_query, hours=24, limit=4)
|
|
906
|
-
if hot_bundle.get("has_matches"):
|
|
907
|
-
lines.append("")
|
|
908
|
-
lines.append(format_pre_action_context_bundle(hot_bundle, compact=True))
|
|
909
|
-
except Exception:
|
|
910
|
-
pass
|
|
911
|
-
|
|
912
|
-
# Session tone from Deep Sleep (emotional intelligence layer)
|
|
913
|
-
tone = _load_session_tone()
|
|
914
|
-
if tone:
|
|
915
|
-
lines.append("")
|
|
916
|
-
lines.append(tone)
|
|
917
|
-
|
|
918
|
-
# Toolbox reminder: skills + behavioral learnings count
|
|
919
|
-
toolbox = _toolbox_summary(conn)
|
|
920
|
-
if toolbox:
|
|
921
|
-
lines.append("")
|
|
922
|
-
lines.append(toolbox)
|
|
923
|
-
|
|
924
|
-
return "\n".join(lines)
|
|
925
|
-
except Exception as e:
|
|
926
|
-
return f"Smart startup query error: {e}"
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
def _hint_suggests_code_edit(hint: str) -> bool:
|
|
930
|
-
"""Check if a heartbeat context_hint suggests the agent is editing code."""
|
|
931
|
-
hint_lower = hint.lower()
|
|
932
|
-
edit_signals = ['edit', 'fix', 'patch', 'modify', 'implement', 'refactor', 'add function',
|
|
933
|
-
'change code', 'update script', 'write code', '.py', '.js', '.ts', '.php',
|
|
934
|
-
'commit', 'arregl', 'modific', 'implement', 'correg']
|
|
935
|
-
return any(signal in hint_lower for signal in edit_signals)
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
def _hint_suggests_correction(hint: str) -> bool:
|
|
939
|
-
"""Detect explicit user correction signals in a heartbeat context hint."""
|
|
940
|
-
hint_lower = hint.lower()
|
|
941
|
-
correction_signals = [
|
|
942
|
-
"that's wrong",
|
|
943
|
-
"that is wrong",
|
|
944
|
-
"wrong approach",
|
|
945
|
-
"not like that",
|
|
946
|
-
"fix this",
|
|
947
|
-
"fix it",
|
|
948
|
-
"está mal",
|
|
949
|
-
"esta mal",
|
|
950
|
-
"mal hecho",
|
|
951
|
-
"incorrecto",
|
|
952
|
-
"te equivocas",
|
|
953
|
-
"te has equivocado",
|
|
954
|
-
"lo hiciste mal",
|
|
955
|
-
"no era eso",
|
|
956
|
-
"corrige esto",
|
|
957
|
-
"corrígelo",
|
|
958
|
-
"corrigelo",
|
|
959
|
-
"ya te dije",
|
|
960
|
-
"otra vez el mismo",
|
|
961
|
-
"de nuevo el mismo",
|
|
962
|
-
"no deberías",
|
|
963
|
-
"no deberias",
|
|
964
|
-
"shouldn't have",
|
|
965
|
-
"should not have",
|
|
966
|
-
]
|
|
967
|
-
return any(signal in hint_lower for signal in correction_signals)
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
def _recent_learning_capture_exists(conn, sid: str, window_seconds: int = 300) -> bool:
|
|
971
|
-
"""Check whether a recent learning was captured manually or via protocol task close."""
|
|
972
|
-
cutoff_epoch = time.time() - window_seconds
|
|
973
|
-
|
|
974
|
-
row = conn.execute(
|
|
975
|
-
"SELECT 1 FROM learnings WHERE created_at >= ? LIMIT 1",
|
|
976
|
-
(cutoff_epoch,),
|
|
977
|
-
).fetchone()
|
|
978
|
-
if row:
|
|
979
|
-
return True
|
|
980
|
-
|
|
981
|
-
row = conn.execute(
|
|
982
|
-
"""
|
|
983
|
-
SELECT 1
|
|
984
|
-
FROM protocol_tasks
|
|
985
|
-
WHERE session_id = ?
|
|
986
|
-
AND learning_id IS NOT NULL
|
|
987
|
-
AND closed_at IS NOT NULL
|
|
988
|
-
AND CAST(strftime('%s', closed_at) AS INTEGER) >= ?
|
|
989
|
-
LIMIT 1
|
|
990
|
-
""",
|
|
991
|
-
(sid, int(cutoff_epoch)),
|
|
992
|
-
).fetchone()
|
|
993
|
-
return bool(row)
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
def _toolbox_summary(conn) -> str:
|
|
997
|
-
"""Quick count of available skills and behavioral learnings for startup reminder."""
|
|
998
|
-
try:
|
|
999
|
-
skill_count = conn.execute(
|
|
1000
|
-
"SELECT COUNT(*) FROM skills"
|
|
1001
|
-
).fetchone()[0]
|
|
1002
|
-
learning_count = conn.execute(
|
|
1003
|
-
"SELECT COUNT(*) FROM learnings WHERE status = 'active' AND priority IN ('critical', 'high')"
|
|
1004
|
-
).fetchone()[0]
|
|
1005
|
-
parts = []
|
|
1006
|
-
if skill_count > 0:
|
|
1007
|
-
parts.append(f"{skill_count} skills available — use `nexo_skill_match(task)` before multi-step tasks")
|
|
1008
|
-
try:
|
|
1009
|
-
from skills_runtime import get_featured_skill_summaries
|
|
1010
|
-
|
|
1011
|
-
featured = get_featured_skill_summaries(limit=3)
|
|
1012
|
-
if featured:
|
|
1013
|
-
parts.append("Featured skills:")
|
|
1014
|
-
for skill in featured:
|
|
1015
|
-
triggers = ", ".join(skill.get("trigger_patterns", [])[:2]) or "no triggers"
|
|
1016
|
-
parts.append(
|
|
1017
|
-
f"- {skill['id']} — {skill['mode']}/{skill['execution_level']} — triggers: {triggers}"
|
|
1018
|
-
)
|
|
1019
|
-
except Exception:
|
|
1020
|
-
pass
|
|
1021
|
-
if learning_count > 0:
|
|
1022
|
-
parts.append(f"{learning_count} high-priority learnings — use `nexo_guard_check` before editing code")
|
|
1023
|
-
if parts:
|
|
1024
|
-
return "TOOLBOX REMINDER:\n " + "\n ".join(parts)
|
|
1025
|
-
except Exception:
|
|
1026
|
-
pass
|
|
1027
|
-
return ""
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
def handle_stop(sid: str) -> str:
|
|
1031
|
-
"""Cleanly close a session, removing it from active sessions immediately."""
|
|
1032
|
-
_stop_keepalive(sid)
|
|
1033
|
-
complete_session(sid)
|
|
1034
|
-
return f"Session {sid} closed."
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
def handle_status(keyword: str | None = None) -> str:
|
|
1038
|
-
"""List active sessions, optionally filtered by keyword."""
|
|
1039
|
-
clean_stale_sessions()
|
|
1040
|
-
if keyword:
|
|
1041
|
-
sessions = search_sessions(keyword)
|
|
1042
|
-
if not sessions:
|
|
1043
|
-
return f"Nobody is working on '{keyword}'."
|
|
1044
|
-
else:
|
|
1045
|
-
sessions = get_active_sessions()
|
|
1046
|
-
|
|
1047
|
-
if not sessions:
|
|
1048
|
-
return "No active sessions."
|
|
1049
|
-
|
|
1050
|
-
lines = ["ACTIVE SESSIONS:"]
|
|
1051
|
-
for s in sessions:
|
|
1052
|
-
age = _format_age(s["last_update_epoch"])
|
|
1053
|
-
lines.append(f" {s['sid']} ({age}) — {s['task']}")
|
|
1054
|
-
return "\n".join(lines)
|