nexo-brain 5.3.19 → 5.3.21

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/bin/nexo-brain.js +52 -10
  3. package/package.json +1 -1
  4. package/src/auto_update.py +11 -8
  5. package/src/dashboard/static/favicon 2.svg +32 -0
  6. package/src/dashboard/static/nexo-logo 2.png +0 -0
  7. package/src/dashboard/static/nexo-logo 2.svg +40 -0
  8. package/src/dashboard/static/style 2.css +2458 -0
  9. package/src/dashboard/templates/adaptive 2.html +118 -0
  10. package/src/dashboard/templates/artifacts 2.html +133 -0
  11. package/src/dashboard/templates/backups 2.html +136 -0
  12. package/src/dashboard/templates/base 2.html +417 -0
  13. package/src/dashboard/templates/calendar 2.html +591 -0
  14. package/src/dashboard/templates/chat 2.html +356 -0
  15. package/src/dashboard/templates/claims 2.html +259 -0
  16. package/src/dashboard/templates/cortex 2.html +321 -0
  17. package/src/dashboard/templates/credentials 2.html +128 -0
  18. package/src/dashboard/templates/crons 2.html +370 -0
  19. package/src/dashboard/templates/dashboard 2.html +494 -0
  20. package/src/dashboard/templates/dreams 2.html +252 -0
  21. package/src/dashboard/templates/email 2.html +160 -0
  22. package/src/dashboard/templates/evolution 2.html +189 -0
  23. package/src/dashboard/templates/feed 2.html +249 -0
  24. package/src/dashboard/templates/followup_health 2.html +170 -0
  25. package/src/dashboard/templates/graph 2.html +201 -0
  26. package/src/dashboard/templates/guard 2.html +259 -0
  27. package/src/dashboard/templates/inbox 2.html +251 -0
  28. package/src/dashboard/templates/memory 2.html +420 -0
  29. package/src/dashboard/templates/operations 2.html +608 -0
  30. package/src/dashboard/templates/plugins 2.html +185 -0
  31. package/src/dashboard/templates/protocol 2.html +199 -0
  32. package/src/dashboard/templates/rules 2.html +246 -0
  33. package/src/dashboard/templates/sentiment 2.html +247 -0
  34. package/src/dashboard/templates/sessions 2.html +218 -0
  35. package/src/dashboard/templates/skills 2.html +329 -0
  36. package/src/dashboard/templates/somatic 2.html +73 -0
  37. package/src/dashboard/templates/triggers 2.html +133 -0
  38. package/src/dashboard/templates/trust 2.html +360 -0
  39. package/src/db/__init__ 2.py +259 -0
  40. package/src/db/_core 2.py +437 -0
  41. package/src/db/_credentials 2.py +124 -0
  42. package/src/db/_episodic 2.py +762 -0
  43. package/src/db/_evolution 2.py +54 -0
  44. package/src/db/_fts 2.py +406 -0
  45. package/src/db/_goal_profiles 2.py +376 -0
  46. package/src/db/_hot_context 2.py +660 -0
  47. package/src/db/_outcomes 2.py +800 -0
  48. package/src/db/_personal_scripts 2.py +582 -0
  49. package/src/db/_sessions 2.py +330 -0
  50. package/src/db/_tasks 2.py +91 -0
  51. package/src/db/_watchers 2.py +173 -0
  52. package/src/doctor/formatters 2.py +52 -0
  53. package/src/doctor/models 2.py +69 -0
  54. package/src/doctor/planes 2.py +87 -0
  55. package/src/doctor/providers/__init__ 2.py +1 -0
  56. package/src/doctor/providers/deep 2.py +367 -0
  57. package/src/evolution_cycle 2.py +519 -0
  58. package/src/hooks/auto_capture 2.py +208 -0
  59. package/src/hooks/caffeinate-guard 2.sh +8 -0
  60. package/src/hooks/capture-session 2.sh +21 -0
  61. package/src/hooks/capture-tool-logs 2.sh +158 -0
  62. package/src/hooks/daily-briefing-check 2.sh +33 -0
  63. package/src/hooks/heartbeat-enforcement 2.py +90 -0
  64. package/src/hooks/heartbeat-posttool 2.sh +18 -0
  65. package/src/hooks/inbox-hook 2.sh +76 -0
  66. package/src/hooks/post-compact 2.sh +152 -0
  67. package/src/hooks/pre-compact 2.sh +169 -0
  68. package/src/hooks/protocol-guardrail 2.sh +10 -0
  69. package/src/hooks/protocol-pretool-guardrail 2.sh +9 -0
  70. package/src/hooks/session-stop 2.sh +52 -0
  71. package/src/kg_populate 2.py +292 -0
  72. package/src/maintenance 2.py +53 -0
  73. package/src/memory_backends 2.py +71 -0
  74. package/src/migrate_embeddings 2.py +124 -0
  75. package/src/nexo_sdk 2.py +103 -0
  76. package/src/observability 2.py +199 -0
  77. package/src/plugin_loader 2.py +217 -0
  78. package/src/plugins/__init__ 2.py +0 -0
  79. package/src/plugins/artifact_registry 2.py +450 -0
  80. package/src/plugins/backup 2.py +127 -0
  81. package/src/plugins/claims_tools 2.py +119 -0
  82. package/src/plugins/cognitive_memory 2.py +609 -0
  83. package/src/plugins/core_rules 2.py +252 -0
  84. package/src/plugins/cortex 2.py +1155 -0
  85. package/src/plugins/entities 2.py +67 -0
  86. package/src/plugins/episodic_memory 2.py +560 -0
  87. package/src/plugins/evolution 2.py +167 -0
  88. package/src/plugins/goal_engine 2.py +142 -0
  89. package/src/plugins/guard 2.py +862 -0
  90. package/src/plugins/impact 2.py +29 -0
  91. package/src/plugins/knowledge_graph_tools 2.py +137 -0
  92. package/src/plugins/media_memory_tools 2.py +98 -0
  93. package/src/plugins/memory_export 2.py +196 -0
  94. package/src/plugins/outcomes 2.py +130 -0
  95. package/src/plugins/personal_scripts 2.py +117 -0
  96. package/src/plugins/preferences 2.py +47 -0
  97. package/src/plugins/protocol 2.py +1449 -0
  98. package/src/plugins/simple_api 2.py +106 -0
  99. package/src/plugins/skills 2.py +341 -0
  100. package/src/plugins/state_watchers 2.py +79 -0
  101. package/src/plugins/update 2.py +986 -0
  102. package/src/plugins/user_state_tools 2.py +43 -0
  103. package/src/plugins/workflow 2.py +588 -0
  104. package/src/protocol_settings 2.py +59 -0
  105. package/src/public_contribution 2.py +466 -0
  106. package/src/public_evolution_queue 2.py +241 -0
  107. package/src/requirements 2.txt +14 -0
  108. package/src/retroactive_learnings 2.py +373 -0
  109. package/src/rules/__init__ 2.py +0 -0
  110. package/src/rules/core-rules 2.json +331 -0
  111. package/src/rules/migrate 2.py +207 -0
  112. package/src/runtime_power 2.py +874 -0
  113. package/src/script_registry 2.py +1559 -0
  114. package/src/scripts/check-context 2.py +272 -0
  115. package/src/scripts/deep-sleep/apply_findings 2.py +2327 -0
  116. package/src/scripts/deep-sleep/collect 2.py +928 -0
  117. package/src/scripts/deep-sleep/extract 2.py +330 -0
  118. package/src/scripts/deep-sleep/extract-prompt 2.md +285 -0
  119. package/src/scripts/deep-sleep/synthesize 2.py +312 -0
  120. package/src/scripts/deep-sleep/synthesize-prompt 2.md +336 -0
  121. package/src/scripts/nexo-agent-run 2.py +75 -0
  122. package/src/scripts/nexo-auto-update 2.py +6 -0
  123. package/src/scripts/nexo-backup 2.sh +25 -0
  124. package/src/scripts/nexo-brain-activation 2.sh +140 -0
  125. package/src/scripts/nexo-catchup 2.py +300 -0
  126. package/src/scripts/nexo-cognitive-decay 2.py +257 -0
  127. package/src/scripts/nexo-cortex-cycle 2.py +293 -0
  128. package/src/scripts/nexo-cron-wrapper 2.sh +53 -0
  129. package/src/scripts/nexo-daily-self-audit 2.py +2161 -0
  130. package/src/scripts/nexo-dashboard 2.sh +29 -0
  131. package/src/scripts/nexo-deep-sleep 2.sh +86 -0
  132. package/src/scripts/nexo-evolution-run 2.py +1664 -0
  133. package/src/scripts/nexo-followup-hygiene 2.py +139 -0
  134. package/src/scripts/nexo-hook-record 2.py +42 -0
  135. package/src/scripts/nexo-immune 2.py +936 -0
  136. package/src/scripts/nexo-impact-scorer 2.py +117 -0
  137. package/src/scripts/nexo-inbox-hook 2.sh +74 -0
  138. package/src/scripts/nexo-install 2.py +6 -0
  139. package/src/scripts/nexo-learning-housekeep 2.py +401 -0
  140. package/src/scripts/nexo-learning-validator 2.py +266 -0
  141. package/src/scripts/nexo-migrate 2.py +260 -0
  142. package/src/scripts/nexo-outcome-checker 2.py +127 -0
  143. package/src/scripts/nexo-postmortem-consolidator 2.py +456 -0
  144. package/src/scripts/nexo-pre-commit 2.py +120 -0
  145. package/src/scripts/nexo-prevent-sleep 2.sh +35 -0
  146. package/src/scripts/nexo-proactive-dashboard 2.py +354 -0
  147. package/src/scripts/nexo-reflection 2.py +256 -0
  148. package/src/scripts/nexo-runtime-preflight 2.py +274 -0
  149. package/src/scripts/nexo-sleep 2.py +631 -0
  150. package/src/scripts/nexo-snapshot-restore 2.sh +35 -0
  151. package/src/scripts/nexo-sync-clients 2.py +16 -0
  152. package/src/scripts/nexo-synthesis 2.py +475 -0
  153. package/src/scripts/nexo-tcc-approve 2.sh +79 -0
  154. package/src/scripts/nexo-update 2.sh +306 -0
  155. package/src/scripts/nexo-watchdog 2.sh +1207 -0
  156. package/src/scripts/nexo-watchdog-smoke 2.py +119 -0
  157. package/src/scripts/rehydrate_learnings_from_archive 2.py +245 -0
  158. package/src/server 2.py +1296 -0
  159. package/src/skills/run-nexo-audit-phase/guide 2.md +43 -0
  160. package/src/skills/run-nexo-audit-phase/skill 2.json +59 -0
  161. package/src/skills/run-nexo-core-fix-cycle/guide 2.md +17 -0
  162. package/src/skills/run-nexo-core-fix-cycle/script 2.py +276 -0
  163. package/src/skills/run-nexo-core-fix-cycle/skill 2.json +58 -0
  164. package/src/skills/run-release-final-audit/guide 2.md +16 -0
  165. package/src/skills/run-release-final-audit/script 2.py +259 -0
  166. package/src/skills/run-release-final-audit/skill 2.json +77 -0
  167. package/src/skills/run-runtime-doctor/guide 2.md +12 -0
  168. package/src/skills/run-runtime-doctor/script 2.py +21 -0
  169. package/src/skills/run-runtime-doctor/skill 2.json +25 -0
  170. package/src/skills_runtime 2.py +932 -0
  171. package/src/state_watchers_runtime 2.py +475 -0
  172. package/src/storage_router 2.py +32 -0
  173. package/src/system_catalog 2.py +786 -0
  174. package/src/tools_coordination 2.py +103 -0
  175. package/src/tools_credentials 2.py +68 -0
  176. package/src/tools_drive 2.py +487 -0
  177. package/src/tools_hot_context 2.py +163 -0
  178. package/src/tools_learnings 2.py +612 -0
  179. package/src/tools_menu 2.py +229 -0
  180. package/src/tools_reminders 2.py +88 -0
  181. package/src/tools_reminders_crud 2.py +363 -0
  182. package/src/tools_sessions 2.py +1054 -0
  183. package/src/tools_system_catalog 2.py +19 -0
  184. package/src/tools_task_history 2.py +57 -0
  185. package/src/tools_transcripts 2.py +98 -0
  186. package/src/transcript_utils 2.py +412 -0
  187. package/src/user_context 2.py +46 -0
  188. package/src/user_data_portability 2.py +328 -0
  189. package/src/user_state_model 2.py +170 -0
  190. package/templates/CLAUDE.md 2.template +108 -0
  191. package/templates/CODEX.AGENTS.md 2.template +66 -0
  192. package/templates/launchagents/README 2.md +132 -0
  193. package/templates/launchagents/com.nexo.auto-close-sessions 2.plist +39 -0
  194. package/templates/launchagents/com.nexo.catchup 2.plist +39 -0
  195. package/templates/launchagents/com.nexo.cognitive-decay 2.plist +40 -0
  196. package/templates/launchagents/com.nexo.dashboard 2.plist +43 -0
  197. package/templates/launchagents/com.nexo.deep-sleep 2.plist +43 -0
  198. package/templates/launchagents/com.nexo.evolution 2.plist +44 -0
  199. package/templates/launchagents/com.nexo.followup-hygiene 2.plist +45 -0
  200. package/templates/launchagents/com.nexo.immune 2.plist +41 -0
  201. package/templates/launchagents/com.nexo.postmortem 2.plist +45 -0
  202. package/templates/launchagents/com.nexo.self-audit 2.plist +47 -0
  203. package/templates/launchagents/com.nexo.synthesis 2.plist +45 -0
  204. package/templates/launchagents/com.nexo.watchdog 2.plist +37 -0
  205. package/templates/nexo_helper 2.py +301 -0
  206. package/templates/openclaw 2.json +13 -0
  207. package/templates/plugin-template 2.py +40 -0
  208. package/templates/script-template 2.py +59 -0
  209. package/templates/script-template 2.sh +13 -0
  210. package/templates/skill-script-template 2.py +48 -0
  211. package/templates/skill-template 2.md +33 -0
@@ -0,0 +1,330 @@
1
+ from __future__ import annotations
2
+ """NEXO DB — Sessions module."""
3
+ import time, secrets, string, sqlite3
4
+ from datetime import datetime
5
+ from db._core import get_db, _gen_id, now_epoch, local_time_str, SESSION_STALE_SECONDS, MESSAGE_TTL_SECONDS, QUESTION_TTL_SECONDS
6
+
7
+ # ── Session operations ──────────────────────────────────────────────
8
+
9
+ def now_epoch() -> float:
10
+ return time.time()
11
+
12
+
13
+ def local_time_str() -> str:
14
+ from datetime import datetime
15
+ return datetime.now().strftime("%H:%M")
16
+
17
+
18
+ import re
19
+ _SID_EXACT = re.compile(r'^nexo-\d+-\d+$')
20
+ _SID_SEARCH = re.compile(r'nexo-\d+-\d+')
21
+
22
+ def _validate_sid(sid: str) -> str:
23
+ """Validate and sanitize SID. Extracts clean SID if embedded in text."""
24
+ if not sid:
25
+ raise ValueError("SID cannot be empty")
26
+ sid = sid.strip()
27
+ # Clean SID — most common case
28
+ if _SID_EXACT.match(sid):
29
+ return sid
30
+ # Extract SID from text like "SID: nexo-1234-5678\nOther stuff..."
31
+ match = _SID_SEARCH.search(sid)
32
+ if match:
33
+ return match.group(0)
34
+ raise ValueError(f"Invalid SID format: {sid[:80]}")
35
+
36
+
37
+ def register_session(
38
+ sid: str,
39
+ task: str,
40
+ claude_session_id: str = "",
41
+ *,
42
+ external_session_id: str = "",
43
+ session_client: str = "",
44
+ ) -> dict:
45
+ """Register or re-register a session."""
46
+ sid = _validate_sid(sid)
47
+ conn = get_db()
48
+ now = now_epoch()
49
+ linked_session_id = (external_session_id or claude_session_id or "").strip()
50
+ conn.execute(
51
+ "INSERT OR REPLACE INTO sessions (sid, task, started_epoch, last_update_epoch, local_time, claude_session_id, external_session_id, session_client) "
52
+ "VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
53
+ (sid, task, now, now, local_time_str(), linked_session_id, linked_session_id, (session_client or "").strip())
54
+ )
55
+ conn.commit()
56
+ return {"sid": sid, "task": task, "external_session_id": linked_session_id, "session_client": (session_client or "").strip()}
57
+
58
+
59
+ def update_session(sid: str, task: str | None) -> dict:
60
+ """Update session timestamp (and task if provided). Preserves started_epoch.
61
+
62
+ Args:
63
+ sid: Session ID.
64
+ task: New task description, or None to keep current task (keepalive touch).
65
+ """
66
+ sid = _validate_sid(sid)
67
+ conn = get_db()
68
+ now = now_epoch()
69
+ row = conn.execute("SELECT started_epoch, task FROM sessions WHERE sid = ?", (sid,)).fetchone()
70
+ if row:
71
+ effective_task = task if task is not None else row["task"]
72
+ conn.execute(
73
+ "UPDATE sessions SET task = ?, last_update_epoch = ?, local_time = ? WHERE sid = ?",
74
+ (effective_task, now, local_time_str(), sid)
75
+ )
76
+ else:
77
+ effective_task = task or "Unknown"
78
+ conn.execute(
79
+ "INSERT INTO sessions (sid, task, started_epoch, last_update_epoch, local_time) "
80
+ "VALUES (?, ?, ?, ?, ?)",
81
+ (sid, effective_task, now, now, local_time_str())
82
+ )
83
+ conn.commit()
84
+ return {"sid": sid, "task": effective_task}
85
+
86
+
87
+ def complete_session(sid: str):
88
+ """Remove session and its tracked files."""
89
+ sid = _validate_sid(sid)
90
+ conn = get_db()
91
+ conn.execute("PRAGMA foreign_keys=ON")
92
+ conn.execute("DELETE FROM tracked_files WHERE sid = ?", (sid,))
93
+ conn.execute("DELETE FROM sessions WHERE sid = ?", (sid,))
94
+ conn.commit()
95
+
96
+
97
+ def get_active_sessions() -> list[dict]:
98
+ """Get all sessions updated within STALE threshold."""
99
+ conn = get_db()
100
+ cutoff = now_epoch() - SESSION_STALE_SECONDS
101
+ rows = conn.execute(
102
+ "SELECT sid, task, started_epoch, last_update_epoch, local_time "
103
+ "FROM sessions WHERE last_update_epoch > ?",
104
+ (cutoff,)
105
+ ).fetchall()
106
+ return [dict(r) for r in rows]
107
+
108
+
109
+ def clean_stale_sessions() -> int:
110
+ """Remove stale sessions. Returns count removed."""
111
+ conn = get_db()
112
+ cutoff = now_epoch() - SESSION_STALE_SECONDS
113
+ stale = conn.execute(
114
+ "SELECT sid FROM sessions WHERE last_update_epoch <= ?", (cutoff,)
115
+ ).fetchall()
116
+ for row in stale:
117
+ conn.execute("DELETE FROM tracked_files WHERE sid = ?", (row["sid"],))
118
+ result = conn.execute(
119
+ "DELETE FROM sessions WHERE last_update_epoch <= ?", (cutoff,)
120
+ )
121
+ count = result.rowcount
122
+ conn.commit()
123
+ return count
124
+
125
+
126
+ def search_sessions(keyword: str) -> list[dict]:
127
+ """Find sessions whose task contains keyword (case-insensitive)."""
128
+ conn = get_db()
129
+ cutoff = now_epoch() - SESSION_STALE_SECONDS
130
+ rows = conn.execute(
131
+ "SELECT sid, task, last_update_epoch, local_time FROM sessions "
132
+ "WHERE last_update_epoch > ? AND LOWER(task) LIKE ?",
133
+ (cutoff, f"%{keyword.lower()}%")
134
+ ).fetchall()
135
+ return [dict(r) for r in rows]
136
+
137
+
138
+ # ── File tracking ───────────────────────────────────────────────────
139
+
140
+ def track_files(sid: str, paths: list[str]) -> dict:
141
+ """Track files for a session. Returns conflicts if any."""
142
+ conn = get_db()
143
+ now = now_epoch()
144
+ session = conn.execute("SELECT sid FROM sessions WHERE sid = ?", (sid,)).fetchone()
145
+ if not session:
146
+ return {"error": f"Session {sid} not found. Register first."}
147
+
148
+ for path in paths:
149
+ conn.execute(
150
+ "INSERT OR IGNORE INTO tracked_files (sid, path, tracked_at) VALUES (?, ?, ?)",
151
+ (sid, path, now)
152
+ )
153
+ conn.commit()
154
+ conflicts = _check_conflicts(conn, sid)
155
+ return {"tracked": paths, "conflicts": conflicts}
156
+
157
+
158
+ def untrack_files(sid: str, paths: list[str] | None = None):
159
+ """Untrack files. If paths is None, untrack all."""
160
+ conn = get_db()
161
+ if paths:
162
+ for path in paths:
163
+ conn.execute(
164
+ "DELETE FROM tracked_files WHERE sid = ? AND path = ?",
165
+ (sid, path)
166
+ )
167
+ else:
168
+ conn.execute("DELETE FROM tracked_files WHERE sid = ?", (sid,))
169
+ conn.commit()
170
+
171
+
172
+ def get_all_tracked_files() -> dict:
173
+ """Get all tracked files grouped by session."""
174
+ conn = get_db()
175
+ cutoff = now_epoch() - SESSION_STALE_SECONDS
176
+ rows = conn.execute(
177
+ "SELECT tf.sid, tf.path, s.task FROM tracked_files tf "
178
+ "JOIN sessions s ON tf.sid = s.sid "
179
+ "WHERE s.last_update_epoch > ?",
180
+ (cutoff,)
181
+ ).fetchall()
182
+ result = {}
183
+ for r in rows:
184
+ sid = r["sid"]
185
+ if sid not in result:
186
+ result[sid] = {"task": r["task"], "files": []}
187
+ result[sid]["files"].append(r["path"])
188
+ return result
189
+
190
+
191
+ def _check_conflicts(conn: sqlite3.Connection, sid: str) -> list[dict]:
192
+ """Check if any of sid's files are tracked by other active sessions."""
193
+ cutoff = now_epoch() - SESSION_STALE_SECONDS
194
+ my_files = conn.execute(
195
+ "SELECT path FROM tracked_files WHERE sid = ?", (sid,)
196
+ ).fetchall()
197
+ my_paths = {r["path"] for r in my_files}
198
+ if not my_paths:
199
+ return []
200
+
201
+ conflicts = []
202
+ others = conn.execute(
203
+ "SELECT tf.sid, tf.path, s.task FROM tracked_files tf "
204
+ "JOIN sessions s ON tf.sid = s.sid "
205
+ "WHERE tf.sid != ? AND s.last_update_epoch > ?",
206
+ (sid, cutoff)
207
+ ).fetchall()
208
+ by_sid = {}
209
+ for r in others:
210
+ if r["path"] in my_paths:
211
+ osid = r["sid"]
212
+ if osid not in by_sid:
213
+ by_sid[osid] = {"sid": osid, "task": r["task"], "files": []}
214
+ by_sid[osid]["files"].append(r["path"])
215
+ return list(by_sid.values())
216
+
217
+
218
+ # ── Messages ────────────────────────────────────────────────────────
219
+
220
+ def send_message(from_sid: str, to_sid: str, text: str) -> str:
221
+ """Send a message. to_sid can be 'all' for broadcast."""
222
+ conn = get_db()
223
+ _clean_old_messages(conn)
224
+ msg_id = _gen_id("msg", 6)
225
+ conn.execute(
226
+ "INSERT INTO messages (id, from_sid, to_sid, text, created_epoch) "
227
+ "VALUES (?, ?, ?, ?, ?)",
228
+ (msg_id, from_sid, to_sid, text, now_epoch())
229
+ )
230
+ conn.commit()
231
+ return msg_id
232
+
233
+
234
+ def get_inbox(sid: str) -> list[dict]:
235
+ """Get unread messages for a session."""
236
+ conn = get_db()
237
+ _clean_old_messages(conn)
238
+ rows = conn.execute(
239
+ "SELECT m.id, m.from_sid, m.to_sid, m.text, m.created_epoch "
240
+ "FROM messages m "
241
+ "WHERE (m.to_sid = 'all' OR m.to_sid = ?) "
242
+ "AND m.from_sid != ? "
243
+ "AND m.id NOT IN (SELECT message_id FROM message_reads WHERE sid = ?)",
244
+ (sid, sid, sid)
245
+ ).fetchall()
246
+ for r in rows:
247
+ conn.execute(
248
+ "INSERT OR IGNORE INTO message_reads (message_id, sid) VALUES (?, ?)",
249
+ (r["id"], sid)
250
+ )
251
+ conn.commit()
252
+ result = [dict(r) for r in rows]
253
+ return result
254
+
255
+
256
+ def _clean_old_messages(conn: sqlite3.Connection):
257
+ """Remove expired messages and commit immediately."""
258
+ cutoff = now_epoch() - MESSAGE_TTL_SECONDS
259
+ conn.execute("DELETE FROM messages WHERE created_epoch < ?", (cutoff,))
260
+ conn.commit()
261
+
262
+
263
+ # ── Questions ───────────────────────────────────────────────────────
264
+
265
+ def ask_question(from_sid: str, to_sid: str, question: str) -> str:
266
+ """Create a pending question. Returns qid."""
267
+ conn = get_db()
268
+ _expire_old_questions(conn)
269
+ qid = _gen_id("q", 8)
270
+ conn.execute(
271
+ "INSERT INTO questions (qid, from_sid, to_sid, question, status, created_epoch) "
272
+ "VALUES (?, ?, ?, ?, 'pending', ?)",
273
+ (qid, from_sid, to_sid, question, now_epoch())
274
+ )
275
+ conn.commit()
276
+ return qid
277
+
278
+
279
+ def answer_question(qid: str, answer: str) -> dict:
280
+ """Answer a pending question."""
281
+ conn = get_db()
282
+ row = conn.execute(
283
+ "SELECT * FROM questions WHERE qid = ?", (qid,)
284
+ ).fetchone()
285
+ if not row:
286
+ return {"error": f"Question {qid} not found"}
287
+ if row["status"] != "pending":
288
+ return {"error": f"Question {qid} is {row['status']}, not pending"}
289
+ conn.execute(
290
+ "UPDATE questions SET answer = ?, status = 'answered', answered_epoch = ? "
291
+ "WHERE qid = ?",
292
+ (answer, now_epoch(), qid)
293
+ )
294
+ conn.commit()
295
+ return {"qid": qid, "status": "answered"}
296
+
297
+
298
+ def get_pending_questions(sid: str) -> list[dict]:
299
+ """Get pending questions addressed to this session."""
300
+ conn = get_db()
301
+ _expire_old_questions(conn)
302
+ rows = conn.execute(
303
+ "SELECT qid, from_sid, question, created_epoch FROM questions "
304
+ "WHERE to_sid = ? AND status = 'pending'",
305
+ (sid,)
306
+ ).fetchall()
307
+ conn.commit()
308
+ return [dict(r) for r in rows]
309
+
310
+
311
+ def check_answer(qid: str) -> dict | None:
312
+ """Check if a question has been answered. Returns answer or None."""
313
+ conn = get_db()
314
+ row = conn.execute(
315
+ "SELECT qid, answer, status FROM questions WHERE qid = ?", (qid,)
316
+ ).fetchone()
317
+ if not row:
318
+ return None
319
+ return dict(row)
320
+
321
+
322
+ def _expire_old_questions(conn: sqlite3.Connection):
323
+ """Mark old pending questions as expired."""
324
+ cutoff = now_epoch() - QUESTION_TTL_SECONDS
325
+ conn.execute(
326
+ "UPDATE questions SET status = 'expired' "
327
+ "WHERE status = 'pending' AND created_epoch < ?",
328
+ (cutoff,)
329
+ )
330
+
@@ -0,0 +1,91 @@
1
+ """NEXO DB — Tasks module."""
2
+ from db._core import get_db, now_epoch
3
+
4
+ # ── Task History & Frequencies ─────────────────────────────────────
5
+
6
+ def log_task(task_num: str, task_name: str, notes: str = '', reasoning: str = '') -> dict:
7
+ """Log a task execution with optional reasoning."""
8
+ conn = get_db()
9
+ now = now_epoch()
10
+ cursor = conn.execute(
11
+ "INSERT INTO task_history (task_num, task_name, executed_at, notes, reasoning) "
12
+ "VALUES (?, ?, ?, ?, ?)",
13
+ (task_num, task_name, now, notes, reasoning)
14
+ )
15
+ conn.commit()
16
+ row = conn.execute(
17
+ "SELECT * FROM task_history WHERE id = ?", (cursor.lastrowid,)
18
+ ).fetchone()
19
+ return dict(row)
20
+
21
+
22
+ def list_task_history(task_num: str = None, days: int = 30) -> list[dict]:
23
+ """List task execution history, optionally filtered by task_num."""
24
+ conn = get_db()
25
+ cutoff = now_epoch() - (days * 86400)
26
+ if task_num:
27
+ rows = conn.execute(
28
+ "SELECT * FROM task_history WHERE task_num = ? AND executed_at >= ? "
29
+ "ORDER BY executed_at DESC",
30
+ (task_num, cutoff)
31
+ ).fetchall()
32
+ else:
33
+ rows = conn.execute(
34
+ "SELECT * FROM task_history WHERE executed_at >= ? "
35
+ "ORDER BY executed_at DESC",
36
+ (cutoff,)
37
+ ).fetchall()
38
+ return [dict(r) for r in rows]
39
+
40
+
41
+ def set_task_frequency(task_num: str, task_name: str,
42
+ frequency_days: int, description: str = '') -> dict:
43
+ """Set or update the expected frequency for a task."""
44
+ conn = get_db()
45
+ conn.execute(
46
+ "INSERT OR REPLACE INTO task_frequencies (task_num, task_name, frequency_days, description) "
47
+ "VALUES (?, ?, ?, ?)",
48
+ (task_num, task_name, frequency_days, description)
49
+ )
50
+ conn.commit()
51
+ row = conn.execute(
52
+ "SELECT * FROM task_frequencies WHERE task_num = ?", (task_num,)
53
+ ).fetchone()
54
+ return dict(row)
55
+
56
+
57
+ def get_overdue_tasks() -> list[dict]:
58
+ """Get tasks where last execution exceeds the configured frequency."""
59
+ conn = get_db()
60
+ freqs = conn.execute("SELECT * FROM task_frequencies").fetchall()
61
+ now = now_epoch()
62
+ overdue = []
63
+ for f in freqs:
64
+ last = conn.execute(
65
+ "SELECT MAX(executed_at) as last_exec FROM task_history WHERE task_num = ?",
66
+ (f["task_num"],)
67
+ ).fetchone()
68
+ last_exec = last["last_exec"] if last and last["last_exec"] else None
69
+ threshold = f["frequency_days"] * 86400
70
+ if last_exec is None or (now - last_exec) > threshold:
71
+ days_ago = round((now - last_exec) / 86400, 1) if last_exec else None
72
+ overdue.append({
73
+ "task_num": f["task_num"],
74
+ "task_name": f["task_name"],
75
+ "frequency_days": f["frequency_days"],
76
+ "last_executed": last_exec,
77
+ "days_since_last": days_ago,
78
+ "description": f["description"]
79
+ })
80
+ return overdue
81
+
82
+
83
+ def get_task_frequencies() -> list[dict]:
84
+ """Get all configured task frequencies."""
85
+ conn = get_db()
86
+ rows = conn.execute(
87
+ "SELECT * FROM task_frequencies ORDER BY task_num ASC"
88
+ ).fetchall()
89
+ return [dict(r) for r in rows]
90
+
91
+
@@ -0,0 +1,173 @@
1
+ from __future__ import annotations
2
+ """NEXO DB — state watchers registry."""
3
+
4
+ import json
5
+ import secrets
6
+ import time
7
+
8
+ from db._core import get_db
9
+
10
+ WATCHER_TYPES = {"repo_drift", "cron_drift", "api_health", "environment_drift", "expiry"}
11
+ WATCHER_STATUSES = {"active", "paused", "archived"}
12
+ WATCHER_HEALTH = {"unknown", "healthy", "degraded", "critical"}
13
+
14
+
15
+ def _watcher_id() -> str:
16
+ return f"SW-{int(time.time())}-{secrets.randbelow(100000)}"
17
+
18
+
19
+ def _as_json(value, default):
20
+ if value is None:
21
+ value = default
22
+ if isinstance(value, str):
23
+ return value
24
+ return json.dumps(value, ensure_ascii=False)
25
+
26
+
27
+ def _parse_json(value, default):
28
+ if value in (None, ""):
29
+ return default
30
+ if isinstance(value, (dict, list)):
31
+ return value
32
+ try:
33
+ return json.loads(value)
34
+ except Exception:
35
+ return default
36
+
37
+
38
+ def _row_to_watcher(row) -> dict:
39
+ watcher = dict(row)
40
+ watcher["config"] = _parse_json(watcher.get("config"), {})
41
+ watcher["last_result"] = _parse_json(watcher.get("last_result"), {})
42
+ return watcher
43
+
44
+
45
+ def create_state_watcher(
46
+ watcher_type: str,
47
+ title: str,
48
+ *,
49
+ target: str = "",
50
+ severity: str = "warn",
51
+ status: str = "active",
52
+ config=None,
53
+ ) -> dict:
54
+ clean_type = str(watcher_type or "").strip().lower()
55
+ if clean_type not in WATCHER_TYPES:
56
+ raise ValueError(f"Unsupported watcher_type: {watcher_type}")
57
+ clean_status = str(status or "active").strip().lower()
58
+ if clean_status not in WATCHER_STATUSES:
59
+ clean_status = "active"
60
+ watcher_id = _watcher_id()
61
+ conn = get_db()
62
+ conn.execute(
63
+ """INSERT INTO state_watchers (
64
+ watcher_id, watcher_type, title, target, severity, status, config,
65
+ last_health, last_result, last_checked_at
66
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
67
+ (
68
+ watcher_id,
69
+ clean_type,
70
+ str(title or "").strip(),
71
+ str(target or "").strip(),
72
+ str(severity or "warn").strip().lower() or "warn",
73
+ clean_status,
74
+ _as_json(config, {}),
75
+ "unknown",
76
+ "{}",
77
+ "",
78
+ ),
79
+ )
80
+ conn.commit()
81
+ return get_state_watcher(watcher_id) or {"watcher_id": watcher_id}
82
+
83
+
84
+ def get_state_watcher(watcher_id: str) -> dict | None:
85
+ conn = get_db()
86
+ row = conn.execute(
87
+ "SELECT * FROM state_watchers WHERE watcher_id = ?",
88
+ (str(watcher_id or "").strip(),),
89
+ ).fetchone()
90
+ return _row_to_watcher(row) if row else None
91
+
92
+
93
+ def list_state_watchers(*, status: str = "", watcher_type: str = "", limit: int = 100) -> list[dict]:
94
+ clauses: list[str] = []
95
+ params: list[object] = []
96
+ if status:
97
+ clauses.append("status = ?")
98
+ params.append(str(status).strip().lower())
99
+ if watcher_type:
100
+ clauses.append("watcher_type = ?")
101
+ params.append(str(watcher_type).strip().lower())
102
+ where = f"WHERE {' AND '.join(clauses)}" if clauses else ""
103
+ params.append(max(1, int(limit or 100)))
104
+ conn = get_db()
105
+ rows = conn.execute(
106
+ f"""SELECT *
107
+ FROM state_watchers
108
+ {where}
109
+ ORDER BY updated_at DESC, watcher_id DESC
110
+ LIMIT ?""",
111
+ tuple(params),
112
+ ).fetchall()
113
+ return [_row_to_watcher(row) for row in rows]
114
+
115
+
116
+ def update_state_watcher(
117
+ watcher_id: str,
118
+ *,
119
+ title: str | None = None,
120
+ target: str | None = None,
121
+ severity: str | None = None,
122
+ status: str | None = None,
123
+ config=None,
124
+ ) -> dict | None:
125
+ current = get_state_watcher(watcher_id)
126
+ if not current:
127
+ return None
128
+ updates = {
129
+ "title": current["title"] if title is None else str(title).strip(),
130
+ "target": current["target"] if target is None else str(target).strip(),
131
+ "severity": current["severity"] if severity is None else str(severity).strip().lower(),
132
+ "status": current["status"] if status is None else str(status).strip().lower(),
133
+ "config": _as_json(current["config"] if config is None else config, {}),
134
+ }
135
+ if updates["status"] not in WATCHER_STATUSES:
136
+ updates["status"] = current["status"]
137
+ conn = get_db()
138
+ conn.execute(
139
+ """UPDATE state_watchers
140
+ SET title = ?, target = ?, severity = ?, status = ?, config = ?,
141
+ updated_at = datetime('now')
142
+ WHERE watcher_id = ?""",
143
+ (
144
+ updates["title"],
145
+ updates["target"],
146
+ updates["severity"],
147
+ updates["status"],
148
+ updates["config"],
149
+ str(watcher_id).strip(),
150
+ ),
151
+ )
152
+ conn.commit()
153
+ return get_state_watcher(watcher_id)
154
+
155
+
156
+ def update_state_watcher_result(watcher_id: str, *, health: str, result=None, checked_at: str = "") -> dict | None:
157
+ clean_health = str(health or "unknown").strip().lower()
158
+ if clean_health not in WATCHER_HEALTH:
159
+ clean_health = "unknown"
160
+ conn = get_db()
161
+ conn.execute(
162
+ """UPDATE state_watchers
163
+ SET last_health = ?, last_result = ?, last_checked_at = ?, updated_at = datetime('now')
164
+ WHERE watcher_id = ?""",
165
+ (
166
+ clean_health,
167
+ _as_json(result, {}),
168
+ checked_at.strip(),
169
+ str(watcher_id or "").strip(),
170
+ ),
171
+ )
172
+ conn.commit()
173
+ return get_state_watcher(watcher_id)
@@ -0,0 +1,52 @@
1
+ """Doctor output formatters — text and JSON."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ from dataclasses import asdict
6
+
7
+ from doctor.models import DoctorReport
8
+
9
+
10
+ def format_report(report: DoctorReport, fmt: str = "text") -> str:
11
+ """Format a DoctorReport as text or JSON."""
12
+ if fmt == "json":
13
+ return json.dumps(asdict(report), indent=2, ensure_ascii=False)
14
+ return _format_text(report)
15
+
16
+
17
+ def _format_text(report: DoctorReport) -> str:
18
+ """Human-friendly text output."""
19
+ lines = []
20
+
21
+ # Header
22
+ icon = {"healthy": "✓", "degraded": "⚠", "critical": "✗"}.get(report.overall_status, "?")
23
+ lines.append(f"NEXO Doctor — {icon} {report.overall_status.upper()}")
24
+ lines.append(f" {report.counts.get('healthy', 0)} healthy, "
25
+ f"{report.counts.get('degraded', 0)} degraded, "
26
+ f"{report.counts.get('critical', 0)} critical "
27
+ f"({report.duration_ms}ms)")
28
+ lines.append("")
29
+
30
+ # Group by tier
31
+ current_tier = None
32
+ for check in report.checks:
33
+ if check.tier != current_tier:
34
+ current_tier = check.tier
35
+ lines.append(f"── {current_tier.upper()} ──")
36
+
37
+ icon = {"healthy": "✓", "degraded": "⚠", "critical": "✗"}.get(check.status, "?")
38
+ fixed = " [FIXED]" if check.fixed else ""
39
+ lines.append(f" {icon} {check.summary}{fixed}")
40
+
41
+ if check.status != "healthy":
42
+ for ev in check.evidence:
43
+ lines.append(f" → {ev}")
44
+ if check.repair_plan:
45
+ lines.append(" Fix:")
46
+ for step in check.repair_plan:
47
+ lines.append(f" • {step}")
48
+ if check.escalation_prompt:
49
+ lines.append(f" Escalation: {check.escalation_prompt}")
50
+
51
+ lines.append("")
52
+ return "\n".join(lines)