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.
Files changed (211) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/package.json +1 -1
  3. package/src/server.py +3 -0
  4. package/src/tools_sessions.py +6 -1
  5. package/src/dashboard/static/favicon 2.svg +0 -32
  6. package/src/dashboard/static/nexo-logo 2.png +0 -0
  7. package/src/dashboard/static/nexo-logo 2.svg +0 -40
  8. package/src/dashboard/static/style 2.css +0 -2458
  9. package/src/dashboard/templates/adaptive 2.html +0 -118
  10. package/src/dashboard/templates/artifacts 2.html +0 -133
  11. package/src/dashboard/templates/backups 2.html +0 -136
  12. package/src/dashboard/templates/base 2.html +0 -417
  13. package/src/dashboard/templates/calendar 2.html +0 -591
  14. package/src/dashboard/templates/chat 2.html +0 -356
  15. package/src/dashboard/templates/claims 2.html +0 -259
  16. package/src/dashboard/templates/cortex 2.html +0 -321
  17. package/src/dashboard/templates/credentials 2.html +0 -128
  18. package/src/dashboard/templates/crons 2.html +0 -370
  19. package/src/dashboard/templates/dashboard 2.html +0 -494
  20. package/src/dashboard/templates/dreams 2.html +0 -252
  21. package/src/dashboard/templates/email 2.html +0 -160
  22. package/src/dashboard/templates/evolution 2.html +0 -189
  23. package/src/dashboard/templates/feed 2.html +0 -249
  24. package/src/dashboard/templates/followup_health 2.html +0 -170
  25. package/src/dashboard/templates/graph 2.html +0 -201
  26. package/src/dashboard/templates/guard 2.html +0 -259
  27. package/src/dashboard/templates/inbox 2.html +0 -251
  28. package/src/dashboard/templates/memory 2.html +0 -420
  29. package/src/dashboard/templates/operations 2.html +0 -608
  30. package/src/dashboard/templates/plugins 2.html +0 -185
  31. package/src/dashboard/templates/protocol 2.html +0 -199
  32. package/src/dashboard/templates/rules 2.html +0 -246
  33. package/src/dashboard/templates/sentiment 2.html +0 -247
  34. package/src/dashboard/templates/sessions 2.html +0 -218
  35. package/src/dashboard/templates/skills 2.html +0 -329
  36. package/src/dashboard/templates/somatic 2.html +0 -73
  37. package/src/dashboard/templates/triggers 2.html +0 -133
  38. package/src/dashboard/templates/trust 2.html +0 -360
  39. package/src/db/__init__ 2.py +0 -259
  40. package/src/db/_core 2.py +0 -437
  41. package/src/db/_credentials 2.py +0 -124
  42. package/src/db/_episodic 2.py +0 -762
  43. package/src/db/_evolution 2.py +0 -54
  44. package/src/db/_fts 2.py +0 -406
  45. package/src/db/_goal_profiles 2.py +0 -376
  46. package/src/db/_hot_context 2.py +0 -660
  47. package/src/db/_outcomes 2.py +0 -800
  48. package/src/db/_personal_scripts 2.py +0 -582
  49. package/src/db/_sessions 2.py +0 -330
  50. package/src/db/_tasks 2.py +0 -91
  51. package/src/db/_watchers 2.py +0 -173
  52. package/src/doctor/formatters 2.py +0 -52
  53. package/src/doctor/models 2.py +0 -69
  54. package/src/doctor/planes 2.py +0 -87
  55. package/src/doctor/providers/__init__ 2.py +0 -1
  56. package/src/doctor/providers/deep 2.py +0 -367
  57. package/src/evolution_cycle 2.py +0 -519
  58. package/src/hooks/auto_capture 2.py +0 -208
  59. package/src/hooks/caffeinate-guard 2.sh +0 -8
  60. package/src/hooks/capture-session 2.sh +0 -21
  61. package/src/hooks/capture-tool-logs 2.sh +0 -158
  62. package/src/hooks/daily-briefing-check 2.sh +0 -33
  63. package/src/hooks/heartbeat-enforcement 2.py +0 -90
  64. package/src/hooks/heartbeat-posttool 2.sh +0 -18
  65. package/src/hooks/inbox-hook 2.sh +0 -76
  66. package/src/hooks/post-compact 2.sh +0 -152
  67. package/src/hooks/pre-compact 2.sh +0 -169
  68. package/src/hooks/protocol-guardrail 2.sh +0 -10
  69. package/src/hooks/protocol-pretool-guardrail 2.sh +0 -9
  70. package/src/hooks/session-stop 2.sh +0 -52
  71. package/src/kg_populate 2.py +0 -292
  72. package/src/maintenance 2.py +0 -53
  73. package/src/memory_backends 2.py +0 -71
  74. package/src/migrate_embeddings 2.py +0 -124
  75. package/src/nexo_sdk 2.py +0 -103
  76. package/src/observability 2.py +0 -199
  77. package/src/plugin_loader 2.py +0 -217
  78. package/src/plugins/__init__ 2.py +0 -0
  79. package/src/plugins/artifact_registry 2.py +0 -450
  80. package/src/plugins/backup 2.py +0 -127
  81. package/src/plugins/claims_tools 2.py +0 -119
  82. package/src/plugins/cognitive_memory 2.py +0 -609
  83. package/src/plugins/core_rules 2.py +0 -252
  84. package/src/plugins/cortex 2.py +0 -1155
  85. package/src/plugins/entities 2.py +0 -67
  86. package/src/plugins/episodic_memory 2.py +0 -560
  87. package/src/plugins/evolution 2.py +0 -167
  88. package/src/plugins/goal_engine 2.py +0 -142
  89. package/src/plugins/guard 2.py +0 -862
  90. package/src/plugins/impact 2.py +0 -29
  91. package/src/plugins/knowledge_graph_tools 2.py +0 -137
  92. package/src/plugins/media_memory_tools 2.py +0 -98
  93. package/src/plugins/memory_export 2.py +0 -196
  94. package/src/plugins/outcomes 2.py +0 -130
  95. package/src/plugins/personal_scripts 2.py +0 -117
  96. package/src/plugins/preferences 2.py +0 -47
  97. package/src/plugins/protocol 2.py +0 -1449
  98. package/src/plugins/simple_api 2.py +0 -106
  99. package/src/plugins/skills 2.py +0 -341
  100. package/src/plugins/state_watchers 2.py +0 -79
  101. package/src/plugins/update 2.py +0 -986
  102. package/src/plugins/user_state_tools 2.py +0 -43
  103. package/src/plugins/workflow 2.py +0 -588
  104. package/src/protocol_settings 2.py +0 -59
  105. package/src/public_contribution 2.py +0 -466
  106. package/src/public_evolution_queue 2.py +0 -241
  107. package/src/requirements 2.txt +0 -14
  108. package/src/retroactive_learnings 2.py +0 -373
  109. package/src/rules/__init__ 2.py +0 -0
  110. package/src/rules/core-rules 2.json +0 -331
  111. package/src/rules/migrate 2.py +0 -207
  112. package/src/runtime_power 2.py +0 -874
  113. package/src/script_registry 2.py +0 -1559
  114. package/src/scripts/check-context 2.py +0 -272
  115. package/src/scripts/deep-sleep/apply_findings 2.py +0 -2327
  116. package/src/scripts/deep-sleep/collect 2.py +0 -928
  117. package/src/scripts/deep-sleep/extract 2.py +0 -330
  118. package/src/scripts/deep-sleep/extract-prompt 2.md +0 -285
  119. package/src/scripts/deep-sleep/synthesize 2.py +0 -312
  120. package/src/scripts/deep-sleep/synthesize-prompt 2.md +0 -336
  121. package/src/scripts/nexo-agent-run 2.py +0 -75
  122. package/src/scripts/nexo-auto-update 2.py +0 -6
  123. package/src/scripts/nexo-backup 2.sh +0 -25
  124. package/src/scripts/nexo-brain-activation 2.sh +0 -140
  125. package/src/scripts/nexo-catchup 2.py +0 -300
  126. package/src/scripts/nexo-cognitive-decay 2.py +0 -257
  127. package/src/scripts/nexo-cortex-cycle 2.py +0 -293
  128. package/src/scripts/nexo-cron-wrapper 2.sh +0 -53
  129. package/src/scripts/nexo-daily-self-audit 2.py +0 -2161
  130. package/src/scripts/nexo-dashboard 2.sh +0 -29
  131. package/src/scripts/nexo-deep-sleep 2.sh +0 -86
  132. package/src/scripts/nexo-evolution-run 2.py +0 -1664
  133. package/src/scripts/nexo-followup-hygiene 2.py +0 -139
  134. package/src/scripts/nexo-hook-record 2.py +0 -42
  135. package/src/scripts/nexo-immune 2.py +0 -936
  136. package/src/scripts/nexo-impact-scorer 2.py +0 -117
  137. package/src/scripts/nexo-inbox-hook 2.sh +0 -74
  138. package/src/scripts/nexo-install 2.py +0 -6
  139. package/src/scripts/nexo-learning-housekeep 2.py +0 -401
  140. package/src/scripts/nexo-learning-validator 2.py +0 -266
  141. package/src/scripts/nexo-migrate 2.py +0 -260
  142. package/src/scripts/nexo-outcome-checker 2.py +0 -127
  143. package/src/scripts/nexo-postmortem-consolidator 2.py +0 -456
  144. package/src/scripts/nexo-pre-commit 2.py +0 -120
  145. package/src/scripts/nexo-prevent-sleep 2.sh +0 -35
  146. package/src/scripts/nexo-proactive-dashboard 2.py +0 -354
  147. package/src/scripts/nexo-reflection 2.py +0 -256
  148. package/src/scripts/nexo-runtime-preflight 2.py +0 -274
  149. package/src/scripts/nexo-sleep 2.py +0 -631
  150. package/src/scripts/nexo-snapshot-restore 2.sh +0 -35
  151. package/src/scripts/nexo-sync-clients 2.py +0 -16
  152. package/src/scripts/nexo-synthesis 2.py +0 -475
  153. package/src/scripts/nexo-tcc-approve 2.sh +0 -79
  154. package/src/scripts/nexo-update 2.sh +0 -306
  155. package/src/scripts/nexo-watchdog 2.sh +0 -1207
  156. package/src/scripts/nexo-watchdog-smoke 2.py +0 -119
  157. package/src/scripts/rehydrate_learnings_from_archive 2.py +0 -245
  158. package/src/server 2.py +0 -1296
  159. package/src/skills/run-nexo-audit-phase/guide 2.md +0 -43
  160. package/src/skills/run-nexo-audit-phase/skill 2.json +0 -59
  161. package/src/skills/run-nexo-core-fix-cycle/guide 2.md +0 -17
  162. package/src/skills/run-nexo-core-fix-cycle/script 2.py +0 -276
  163. package/src/skills/run-nexo-core-fix-cycle/skill 2.json +0 -58
  164. package/src/skills/run-release-final-audit/guide 2.md +0 -16
  165. package/src/skills/run-release-final-audit/script 2.py +0 -259
  166. package/src/skills/run-release-final-audit/skill 2.json +0 -77
  167. package/src/skills/run-runtime-doctor/guide 2.md +0 -12
  168. package/src/skills/run-runtime-doctor/script 2.py +0 -21
  169. package/src/skills/run-runtime-doctor/skill 2.json +0 -25
  170. package/src/skills_runtime 2.py +0 -932
  171. package/src/state_watchers_runtime 2.py +0 -475
  172. package/src/storage_router 2.py +0 -32
  173. package/src/system_catalog 2.py +0 -786
  174. package/src/tools_coordination 2.py +0 -103
  175. package/src/tools_credentials 2.py +0 -68
  176. package/src/tools_drive 2.py +0 -487
  177. package/src/tools_hot_context 2.py +0 -163
  178. package/src/tools_learnings 2.py +0 -612
  179. package/src/tools_menu 2.py +0 -229
  180. package/src/tools_reminders 2.py +0 -88
  181. package/src/tools_reminders_crud 2.py +0 -363
  182. package/src/tools_sessions 2.py +0 -1054
  183. package/src/tools_system_catalog 2.py +0 -19
  184. package/src/tools_task_history 2.py +0 -57
  185. package/src/tools_transcripts 2.py +0 -98
  186. package/src/transcript_utils 2.py +0 -412
  187. package/src/user_context 2.py +0 -46
  188. package/src/user_data_portability 2.py +0 -328
  189. package/src/user_state_model 2.py +0 -170
  190. package/templates/CLAUDE.md 2.template +0 -108
  191. package/templates/CODEX.AGENTS.md 2.template +0 -66
  192. package/templates/launchagents/README 2.md +0 -132
  193. package/templates/launchagents/com.nexo.auto-close-sessions 2.plist +0 -39
  194. package/templates/launchagents/com.nexo.catchup 2.plist +0 -39
  195. package/templates/launchagents/com.nexo.cognitive-decay 2.plist +0 -40
  196. package/templates/launchagents/com.nexo.dashboard 2.plist +0 -43
  197. package/templates/launchagents/com.nexo.deep-sleep 2.plist +0 -43
  198. package/templates/launchagents/com.nexo.evolution 2.plist +0 -44
  199. package/templates/launchagents/com.nexo.followup-hygiene 2.plist +0 -45
  200. package/templates/launchagents/com.nexo.immune 2.plist +0 -41
  201. package/templates/launchagents/com.nexo.postmortem 2.plist +0 -45
  202. package/templates/launchagents/com.nexo.self-audit 2.plist +0 -47
  203. package/templates/launchagents/com.nexo.synthesis 2.plist +0 -45
  204. package/templates/launchagents/com.nexo.watchdog 2.plist +0 -37
  205. package/templates/nexo_helper 2.py +0 -301
  206. package/templates/openclaw 2.json +0 -13
  207. package/templates/plugin-template 2.py +0 -40
  208. package/templates/script-template 2.py +0 -59
  209. package/templates/script-template 2.sh +0 -13
  210. package/templates/skill-script-template 2.py +0 -48
  211. package/templates/skill-template 2.md +0 -33
@@ -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)