nexo-brain 5.3.13 → 5.3.15
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/bin/nexo-brain.js +52 -1
- package/package.json +1 -1
- package/src/crons/sync.py +18 -4
- package/src/dashboard/static/favicon 2.svg +32 -0
- package/src/dashboard/static/nexo-logo 2.png +0 -0
- package/src/dashboard/static/nexo-logo 2.svg +40 -0
- package/src/dashboard/static/style 2.css +2458 -0
- package/src/dashboard/templates/adaptive 2.html +118 -0
- package/src/dashboard/templates/artifacts 2.html +133 -0
- package/src/dashboard/templates/backups 2.html +136 -0
- package/src/dashboard/templates/base 2.html +417 -0
- package/src/dashboard/templates/calendar 2.html +591 -0
- package/src/dashboard/templates/chat 2.html +356 -0
- package/src/dashboard/templates/claims 2.html +259 -0
- package/src/dashboard/templates/cortex 2.html +321 -0
- package/src/dashboard/templates/credentials 2.html +128 -0
- package/src/dashboard/templates/crons 2.html +370 -0
- package/src/dashboard/templates/dashboard 2.html +494 -0
- package/src/dashboard/templates/dreams 2.html +252 -0
- package/src/dashboard/templates/email 2.html +160 -0
- package/src/dashboard/templates/evolution 2.html +189 -0
- package/src/dashboard/templates/feed 2.html +249 -0
- package/src/dashboard/templates/followup_health 2.html +170 -0
- package/src/dashboard/templates/graph 2.html +201 -0
- package/src/dashboard/templates/guard 2.html +259 -0
- package/src/dashboard/templates/inbox 2.html +251 -0
- package/src/dashboard/templates/memory 2.html +420 -0
- package/src/dashboard/templates/operations 2.html +608 -0
- package/src/dashboard/templates/plugins 2.html +185 -0
- package/src/dashboard/templates/protocol 2.html +199 -0
- package/src/dashboard/templates/rules 2.html +246 -0
- package/src/dashboard/templates/sentiment 2.html +247 -0
- package/src/dashboard/templates/sessions 2.html +218 -0
- package/src/dashboard/templates/skills 2.html +329 -0
- package/src/dashboard/templates/somatic 2.html +73 -0
- package/src/dashboard/templates/triggers 2.html +133 -0
- package/src/dashboard/templates/trust 2.html +360 -0
- package/src/db/__init__ 2.py +259 -0
- package/src/db/_core 2.py +437 -0
- package/src/db/_credentials 2.py +124 -0
- package/src/db/_entities.py +1 -1
- package/src/db/_episodic 2.py +762 -0
- package/src/db/_evolution 2.py +54 -0
- package/src/db/_fts 2.py +406 -0
- package/src/db/_goal_profiles 2.py +376 -0
- package/src/db/_hot_context 2.py +660 -0
- package/src/db/_outcomes 2.py +800 -0
- package/src/db/_personal_scripts 2.py +582 -0
- package/src/db/_sessions 2.py +330 -0
- package/src/db/_tasks 2.py +91 -0
- package/src/db/_watchers 2.py +173 -0
- package/src/doctor/formatters 2.py +52 -0
- package/src/doctor/models 2.py +69 -0
- package/src/doctor/planes 2.py +87 -0
- package/src/doctor/providers/__init__ 2.py +1 -0
- package/src/doctor/providers/deep 2.py +367 -0
- package/src/evolution_cycle 2.py +519 -0
- package/src/hooks/auto_capture 2.py +208 -0
- package/src/hooks/caffeinate-guard 2.sh +8 -0
- package/src/hooks/capture-session 2.sh +21 -0
- package/src/hooks/capture-tool-logs 2.sh +158 -0
- package/src/hooks/daily-briefing-check 2.sh +33 -0
- package/src/hooks/heartbeat-enforcement 2.py +90 -0
- package/src/hooks/heartbeat-posttool 2.sh +18 -0
- package/src/hooks/inbox-hook 2.sh +76 -0
- package/src/hooks/post-compact 2.sh +152 -0
- package/src/hooks/pre-compact 2.sh +169 -0
- package/src/hooks/protocol-guardrail 2.sh +10 -0
- package/src/hooks/protocol-pretool-guardrail 2.sh +9 -0
- package/src/hooks/session-stop 2.sh +52 -0
- package/src/kg_populate 2.py +292 -0
- package/src/maintenance 2.py +53 -0
- package/src/memory_backends 2.py +71 -0
- package/src/migrate_embeddings 2.py +124 -0
- package/src/nexo_sdk 2.py +103 -0
- package/src/observability 2.py +199 -0
- package/src/plugin_loader 2.py +217 -0
- package/src/plugins/__init__ 2.py +0 -0
- package/src/plugins/agents.py +10 -3
- package/src/plugins/artifact_registry 2.py +450 -0
- package/src/plugins/backup 2.py +127 -0
- package/src/plugins/claims_tools 2.py +119 -0
- package/src/plugins/cognitive_memory 2.py +609 -0
- package/src/plugins/core_rules 2.py +252 -0
- package/src/plugins/cortex 2.py +1155 -0
- package/src/plugins/entities 2.py +67 -0
- package/src/plugins/episodic_memory 2.py +560 -0
- package/src/plugins/evolution 2.py +167 -0
- package/src/plugins/goal_engine 2.py +142 -0
- package/src/plugins/guard 2.py +862 -0
- package/src/plugins/impact 2.py +29 -0
- package/src/plugins/knowledge_graph_tools 2.py +137 -0
- package/src/plugins/media_memory_tools 2.py +98 -0
- package/src/plugins/memory_export 2.py +196 -0
- package/src/plugins/outcomes 2.py +130 -0
- package/src/plugins/personal_scripts 2.py +117 -0
- package/src/plugins/preferences 2.py +47 -0
- package/src/plugins/protocol 2.py +1449 -0
- package/src/plugins/schedule.py +2 -1
- package/src/plugins/simple_api 2.py +106 -0
- package/src/plugins/skills 2.py +341 -0
- package/src/plugins/state_watchers 2.py +79 -0
- package/src/plugins/update 2.py +986 -0
- package/src/plugins/user_state_tools 2.py +43 -0
- package/src/plugins/workflow 2.py +588 -0
- package/src/protocol_settings 2.py +59 -0
- package/src/public_contribution 2.py +466 -0
- package/src/public_evolution_queue 2.py +241 -0
- package/src/requirements 2.txt +14 -0
- package/src/requirements.txt +1 -1
- package/src/retroactive_learnings 2.py +373 -0
- package/src/rules/__init__ 2.py +0 -0
- package/src/rules/core-rules 2.json +331 -0
- package/src/rules/migrate 2.py +207 -0
- package/src/runtime_power 2.py +874 -0
- package/src/runtime_power.py +18 -1
- package/src/script_registry 2.py +1559 -0
- package/src/scripts/check-context 2.py +272 -0
- package/src/scripts/deep-sleep/apply_findings 2.py +2327 -0
- package/src/scripts/deep-sleep/collect 2.py +928 -0
- package/src/scripts/deep-sleep/extract 2.py +330 -0
- package/src/scripts/deep-sleep/extract-prompt 2.md +285 -0
- package/src/scripts/deep-sleep/synthesize 2.py +312 -0
- package/src/scripts/deep-sleep/synthesize-prompt 2.md +336 -0
- package/src/scripts/nexo-agent-run 2.py +75 -0
- package/src/scripts/nexo-auto-update 2.py +6 -0
- package/src/scripts/nexo-backup 2.sh +25 -0
- package/src/scripts/nexo-brain-activation 2.sh +140 -0
- package/src/scripts/nexo-catchup 2.py +300 -0
- package/src/scripts/nexo-cognitive-decay 2.py +257 -0
- package/src/scripts/nexo-cortex-cycle 2.py +293 -0
- package/src/scripts/nexo-cron-wrapper 2.sh +53 -0
- package/src/scripts/nexo-cron-wrapper.sh +7 -0
- package/src/scripts/nexo-daily-self-audit 2.py +2161 -0
- package/src/scripts/nexo-dashboard 2.sh +29 -0
- package/src/scripts/nexo-deep-sleep 2.sh +86 -0
- package/src/scripts/nexo-evolution-run 2.py +1664 -0
- package/src/scripts/nexo-followup-hygiene 2.py +139 -0
- package/src/scripts/nexo-hook-record 2.py +42 -0
- package/src/scripts/nexo-immune 2.py +936 -0
- package/src/scripts/nexo-impact-scorer 2.py +117 -0
- package/src/scripts/nexo-inbox-hook 2.sh +74 -0
- package/src/scripts/nexo-install 2.py +6 -0
- package/src/scripts/nexo-learning-housekeep 2.py +401 -0
- package/src/scripts/nexo-learning-validator 2.py +266 -0
- package/src/scripts/nexo-migrate 2.py +260 -0
- package/src/scripts/nexo-outcome-checker 2.py +127 -0
- package/src/scripts/nexo-postmortem-consolidator 2.py +456 -0
- package/src/scripts/nexo-pre-commit 2.py +120 -0
- package/src/scripts/nexo-prevent-sleep 2.sh +35 -0
- package/src/scripts/nexo-proactive-dashboard 2.py +354 -0
- package/src/scripts/nexo-reflection 2.py +256 -0
- package/src/scripts/nexo-runtime-preflight 2.py +274 -0
- package/src/scripts/nexo-sleep 2.py +631 -0
- package/src/scripts/nexo-snapshot-restore 2.sh +35 -0
- package/src/scripts/nexo-sync-clients 2.py +16 -0
- package/src/scripts/nexo-synthesis 2.py +475 -0
- package/src/scripts/nexo-tcc-approve 2.sh +79 -0
- package/src/scripts/nexo-update 2.sh +306 -0
- package/src/scripts/nexo-watchdog 2.sh +1207 -0
- package/src/scripts/nexo-watchdog-smoke 2.py +119 -0
- package/src/scripts/rehydrate_learnings_from_archive 2.py +245 -0
- package/src/server 2.py +1296 -0
- package/src/skills/run-nexo-audit-phase/guide 2.md +43 -0
- package/src/skills/run-nexo-audit-phase/skill 2.json +59 -0
- package/src/skills/run-nexo-core-fix-cycle/guide 2.md +17 -0
- package/src/skills/run-nexo-core-fix-cycle/script 2.py +276 -0
- package/src/skills/run-nexo-core-fix-cycle/skill 2.json +58 -0
- package/src/skills/run-release-final-audit/guide 2.md +16 -0
- package/src/skills/run-release-final-audit/script 2.py +259 -0
- package/src/skills/run-release-final-audit/skill 2.json +77 -0
- package/src/skills/run-runtime-doctor/guide 2.md +12 -0
- package/src/skills/run-runtime-doctor/script 2.py +21 -0
- package/src/skills/run-runtime-doctor/skill 2.json +25 -0
- package/src/skills_runtime 2.py +932 -0
- package/src/state_watchers_runtime 2.py +475 -0
- package/src/storage_router 2.py +32 -0
- package/src/system_catalog 2.py +786 -0
- package/src/tools_coordination 2.py +103 -0
- package/src/tools_credentials 2.py +68 -0
- package/src/tools_drive 2.py +487 -0
- package/src/tools_hot_context 2.py +163 -0
- package/src/tools_learnings 2.py +612 -0
- package/src/tools_menu 2.py +229 -0
- package/src/tools_reminders 2.py +88 -0
- package/src/tools_reminders_crud 2.py +363 -0
- package/src/tools_sessions 2.py +1054 -0
- package/src/tools_system_catalog 2.py +19 -0
- package/src/tools_task_history 2.py +57 -0
- package/src/tools_transcripts 2.py +98 -0
- package/src/transcript_utils 2.py +412 -0
- package/src/user_context 2.py +46 -0
- package/src/user_data_portability 2.py +328 -0
- package/src/user_state_model 2.py +170 -0
- package/templates/CLAUDE.md 2.template +108 -0
- package/templates/CODEX.AGENTS.md 2.template +66 -0
- package/templates/launchagents/README 2.md +132 -0
- package/templates/launchagents/com.nexo.auto-close-sessions 2.plist +39 -0
- package/templates/launchagents/com.nexo.auto-close-sessions.plist +1 -1
- package/templates/launchagents/com.nexo.catchup 2.plist +39 -0
- package/templates/launchagents/com.nexo.catchup.plist +1 -1
- package/templates/launchagents/com.nexo.cognitive-decay 2.plist +40 -0
- package/templates/launchagents/com.nexo.dashboard 2.plist +43 -0
- package/templates/launchagents/com.nexo.dashboard.plist +1 -1
- package/templates/launchagents/com.nexo.deep-sleep 2.plist +43 -0
- package/templates/launchagents/com.nexo.deep-sleep.plist +1 -1
- package/templates/launchagents/com.nexo.evolution 2.plist +44 -0
- package/templates/launchagents/com.nexo.evolution.plist +1 -1
- package/templates/launchagents/com.nexo.followup-hygiene 2.plist +45 -0
- package/templates/launchagents/com.nexo.followup-hygiene.plist +1 -1
- package/templates/launchagents/com.nexo.immune 2.plist +41 -0
- package/templates/launchagents/com.nexo.immune.plist +1 -1
- package/templates/launchagents/com.nexo.postmortem 2.plist +45 -0
- package/templates/launchagents/com.nexo.postmortem.plist +1 -1
- package/templates/launchagents/com.nexo.self-audit 2.plist +47 -0
- package/templates/launchagents/com.nexo.self-audit.plist +1 -1
- package/templates/launchagents/com.nexo.synthesis 2.plist +45 -0
- package/templates/launchagents/com.nexo.synthesis.plist +1 -1
- package/templates/launchagents/com.nexo.watchdog 2.plist +37 -0
- package/templates/launchagents/com.nexo.watchdog.plist +1 -1
- package/templates/nexo_helper 2.py +301 -0
- package/templates/openclaw 2.json +13 -0
- package/templates/plugin-template 2.py +40 -0
- package/templates/script-template 2.py +59 -0
- package/templates/script-template 2.sh +13 -0
- package/templates/script-template.py +5 -4
- package/templates/skill-script-template 2.py +48 -0
- package/templates/skill-script-template.py +2 -1
- 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)
|