nexo-brain 5.3.26 → 5.3.28

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 (212) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/package.json +1 -1
  3. package/src/hook_guardrails.py +44 -0
  4. package/src/server.py +3 -0
  5. package/src/tools_sessions.py +6 -1
  6. package/src/dashboard/static/favicon 2.svg +0 -32
  7. package/src/dashboard/static/nexo-logo 2.png +0 -0
  8. package/src/dashboard/static/nexo-logo 2.svg +0 -40
  9. package/src/dashboard/static/style 2.css +0 -2458
  10. package/src/dashboard/templates/adaptive 2.html +0 -118
  11. package/src/dashboard/templates/artifacts 2.html +0 -133
  12. package/src/dashboard/templates/backups 2.html +0 -136
  13. package/src/dashboard/templates/base 2.html +0 -417
  14. package/src/dashboard/templates/calendar 2.html +0 -591
  15. package/src/dashboard/templates/chat 2.html +0 -356
  16. package/src/dashboard/templates/claims 2.html +0 -259
  17. package/src/dashboard/templates/cortex 2.html +0 -321
  18. package/src/dashboard/templates/credentials 2.html +0 -128
  19. package/src/dashboard/templates/crons 2.html +0 -370
  20. package/src/dashboard/templates/dashboard 2.html +0 -494
  21. package/src/dashboard/templates/dreams 2.html +0 -252
  22. package/src/dashboard/templates/email 2.html +0 -160
  23. package/src/dashboard/templates/evolution 2.html +0 -189
  24. package/src/dashboard/templates/feed 2.html +0 -249
  25. package/src/dashboard/templates/followup_health 2.html +0 -170
  26. package/src/dashboard/templates/graph 2.html +0 -201
  27. package/src/dashboard/templates/guard 2.html +0 -259
  28. package/src/dashboard/templates/inbox 2.html +0 -251
  29. package/src/dashboard/templates/memory 2.html +0 -420
  30. package/src/dashboard/templates/operations 2.html +0 -608
  31. package/src/dashboard/templates/plugins 2.html +0 -185
  32. package/src/dashboard/templates/protocol 2.html +0 -199
  33. package/src/dashboard/templates/rules 2.html +0 -246
  34. package/src/dashboard/templates/sentiment 2.html +0 -247
  35. package/src/dashboard/templates/sessions 2.html +0 -218
  36. package/src/dashboard/templates/skills 2.html +0 -329
  37. package/src/dashboard/templates/somatic 2.html +0 -73
  38. package/src/dashboard/templates/triggers 2.html +0 -133
  39. package/src/dashboard/templates/trust 2.html +0 -360
  40. package/src/db/__init__ 2.py +0 -259
  41. package/src/db/_core 2.py +0 -437
  42. package/src/db/_credentials 2.py +0 -124
  43. package/src/db/_episodic 2.py +0 -762
  44. package/src/db/_evolution 2.py +0 -54
  45. package/src/db/_fts 2.py +0 -406
  46. package/src/db/_goal_profiles 2.py +0 -376
  47. package/src/db/_hot_context 2.py +0 -660
  48. package/src/db/_outcomes 2.py +0 -800
  49. package/src/db/_personal_scripts 2.py +0 -582
  50. package/src/db/_sessions 2.py +0 -330
  51. package/src/db/_tasks 2.py +0 -91
  52. package/src/db/_watchers 2.py +0 -173
  53. package/src/doctor/formatters 2.py +0 -52
  54. package/src/doctor/models 2.py +0 -69
  55. package/src/doctor/planes 2.py +0 -87
  56. package/src/doctor/providers/__init__ 2.py +0 -1
  57. package/src/doctor/providers/deep 2.py +0 -367
  58. package/src/evolution_cycle 2.py +0 -519
  59. package/src/hooks/auto_capture 2.py +0 -208
  60. package/src/hooks/caffeinate-guard 2.sh +0 -8
  61. package/src/hooks/capture-session 2.sh +0 -21
  62. package/src/hooks/capture-tool-logs 2.sh +0 -158
  63. package/src/hooks/daily-briefing-check 2.sh +0 -33
  64. package/src/hooks/heartbeat-enforcement 2.py +0 -90
  65. package/src/hooks/heartbeat-posttool 2.sh +0 -18
  66. package/src/hooks/inbox-hook 2.sh +0 -76
  67. package/src/hooks/post-compact 2.sh +0 -152
  68. package/src/hooks/pre-compact 2.sh +0 -169
  69. package/src/hooks/protocol-guardrail 2.sh +0 -10
  70. package/src/hooks/protocol-pretool-guardrail 2.sh +0 -9
  71. package/src/hooks/session-stop 2.sh +0 -52
  72. package/src/kg_populate 2.py +0 -292
  73. package/src/maintenance 2.py +0 -53
  74. package/src/memory_backends 2.py +0 -71
  75. package/src/migrate_embeddings 2.py +0 -124
  76. package/src/nexo_sdk 2.py +0 -103
  77. package/src/observability 2.py +0 -199
  78. package/src/plugin_loader 2.py +0 -217
  79. package/src/plugins/__init__ 2.py +0 -0
  80. package/src/plugins/artifact_registry 2.py +0 -450
  81. package/src/plugins/backup 2.py +0 -127
  82. package/src/plugins/claims_tools 2.py +0 -119
  83. package/src/plugins/cognitive_memory 2.py +0 -609
  84. package/src/plugins/core_rules 2.py +0 -252
  85. package/src/plugins/cortex 2.py +0 -1155
  86. package/src/plugins/entities 2.py +0 -67
  87. package/src/plugins/episodic_memory 2.py +0 -560
  88. package/src/plugins/evolution 2.py +0 -167
  89. package/src/plugins/goal_engine 2.py +0 -142
  90. package/src/plugins/guard 2.py +0 -862
  91. package/src/plugins/impact 2.py +0 -29
  92. package/src/plugins/knowledge_graph_tools 2.py +0 -137
  93. package/src/plugins/media_memory_tools 2.py +0 -98
  94. package/src/plugins/memory_export 2.py +0 -196
  95. package/src/plugins/outcomes 2.py +0 -130
  96. package/src/plugins/personal_scripts 2.py +0 -117
  97. package/src/plugins/preferences 2.py +0 -47
  98. package/src/plugins/protocol 2.py +0 -1449
  99. package/src/plugins/simple_api 2.py +0 -106
  100. package/src/plugins/skills 2.py +0 -341
  101. package/src/plugins/state_watchers 2.py +0 -79
  102. package/src/plugins/update 2.py +0 -986
  103. package/src/plugins/user_state_tools 2.py +0 -43
  104. package/src/plugins/workflow 2.py +0 -588
  105. package/src/protocol_settings 2.py +0 -59
  106. package/src/public_contribution 2.py +0 -466
  107. package/src/public_evolution_queue 2.py +0 -241
  108. package/src/requirements 2.txt +0 -14
  109. package/src/retroactive_learnings 2.py +0 -373
  110. package/src/rules/__init__ 2.py +0 -0
  111. package/src/rules/core-rules 2.json +0 -331
  112. package/src/rules/migrate 2.py +0 -207
  113. package/src/runtime_power 2.py +0 -874
  114. package/src/script_registry 2.py +0 -1559
  115. package/src/scripts/check-context 2.py +0 -272
  116. package/src/scripts/deep-sleep/apply_findings 2.py +0 -2327
  117. package/src/scripts/deep-sleep/collect 2.py +0 -928
  118. package/src/scripts/deep-sleep/extract 2.py +0 -330
  119. package/src/scripts/deep-sleep/extract-prompt 2.md +0 -285
  120. package/src/scripts/deep-sleep/synthesize 2.py +0 -312
  121. package/src/scripts/deep-sleep/synthesize-prompt 2.md +0 -336
  122. package/src/scripts/nexo-agent-run 2.py +0 -75
  123. package/src/scripts/nexo-auto-update 2.py +0 -6
  124. package/src/scripts/nexo-backup 2.sh +0 -25
  125. package/src/scripts/nexo-brain-activation 2.sh +0 -140
  126. package/src/scripts/nexo-catchup 2.py +0 -300
  127. package/src/scripts/nexo-cognitive-decay 2.py +0 -257
  128. package/src/scripts/nexo-cortex-cycle 2.py +0 -293
  129. package/src/scripts/nexo-cron-wrapper 2.sh +0 -53
  130. package/src/scripts/nexo-daily-self-audit 2.py +0 -2161
  131. package/src/scripts/nexo-dashboard 2.sh +0 -29
  132. package/src/scripts/nexo-deep-sleep 2.sh +0 -86
  133. package/src/scripts/nexo-evolution-run 2.py +0 -1664
  134. package/src/scripts/nexo-followup-hygiene 2.py +0 -139
  135. package/src/scripts/nexo-hook-record 2.py +0 -42
  136. package/src/scripts/nexo-immune 2.py +0 -936
  137. package/src/scripts/nexo-impact-scorer 2.py +0 -117
  138. package/src/scripts/nexo-inbox-hook 2.sh +0 -74
  139. package/src/scripts/nexo-install 2.py +0 -6
  140. package/src/scripts/nexo-learning-housekeep 2.py +0 -401
  141. package/src/scripts/nexo-learning-validator 2.py +0 -266
  142. package/src/scripts/nexo-migrate 2.py +0 -260
  143. package/src/scripts/nexo-outcome-checker 2.py +0 -127
  144. package/src/scripts/nexo-postmortem-consolidator 2.py +0 -456
  145. package/src/scripts/nexo-pre-commit 2.py +0 -120
  146. package/src/scripts/nexo-prevent-sleep 2.sh +0 -35
  147. package/src/scripts/nexo-proactive-dashboard 2.py +0 -354
  148. package/src/scripts/nexo-reflection 2.py +0 -256
  149. package/src/scripts/nexo-runtime-preflight 2.py +0 -274
  150. package/src/scripts/nexo-sleep 2.py +0 -631
  151. package/src/scripts/nexo-snapshot-restore 2.sh +0 -35
  152. package/src/scripts/nexo-sync-clients 2.py +0 -16
  153. package/src/scripts/nexo-synthesis 2.py +0 -475
  154. package/src/scripts/nexo-tcc-approve 2.sh +0 -79
  155. package/src/scripts/nexo-update 2.sh +0 -306
  156. package/src/scripts/nexo-watchdog 2.sh +0 -1207
  157. package/src/scripts/nexo-watchdog-smoke 2.py +0 -119
  158. package/src/scripts/rehydrate_learnings_from_archive 2.py +0 -245
  159. package/src/server 2.py +0 -1296
  160. package/src/skills/run-nexo-audit-phase/guide 2.md +0 -43
  161. package/src/skills/run-nexo-audit-phase/skill 2.json +0 -59
  162. package/src/skills/run-nexo-core-fix-cycle/guide 2.md +0 -17
  163. package/src/skills/run-nexo-core-fix-cycle/script 2.py +0 -276
  164. package/src/skills/run-nexo-core-fix-cycle/skill 2.json +0 -58
  165. package/src/skills/run-release-final-audit/guide 2.md +0 -16
  166. package/src/skills/run-release-final-audit/script 2.py +0 -259
  167. package/src/skills/run-release-final-audit/skill 2.json +0 -77
  168. package/src/skills/run-runtime-doctor/guide 2.md +0 -12
  169. package/src/skills/run-runtime-doctor/script 2.py +0 -21
  170. package/src/skills/run-runtime-doctor/skill 2.json +0 -25
  171. package/src/skills_runtime 2.py +0 -932
  172. package/src/state_watchers_runtime 2.py +0 -475
  173. package/src/storage_router 2.py +0 -32
  174. package/src/system_catalog 2.py +0 -786
  175. package/src/tools_coordination 2.py +0 -103
  176. package/src/tools_credentials 2.py +0 -68
  177. package/src/tools_drive 2.py +0 -487
  178. package/src/tools_hot_context 2.py +0 -163
  179. package/src/tools_learnings 2.py +0 -612
  180. package/src/tools_menu 2.py +0 -229
  181. package/src/tools_reminders 2.py +0 -88
  182. package/src/tools_reminders_crud 2.py +0 -363
  183. package/src/tools_sessions 2.py +0 -1054
  184. package/src/tools_system_catalog 2.py +0 -19
  185. package/src/tools_task_history 2.py +0 -57
  186. package/src/tools_transcripts 2.py +0 -98
  187. package/src/transcript_utils 2.py +0 -412
  188. package/src/user_context 2.py +0 -46
  189. package/src/user_data_portability 2.py +0 -328
  190. package/src/user_state_model 2.py +0 -170
  191. package/templates/CLAUDE.md 2.template +0 -108
  192. package/templates/CODEX.AGENTS.md 2.template +0 -66
  193. package/templates/launchagents/README 2.md +0 -132
  194. package/templates/launchagents/com.nexo.auto-close-sessions 2.plist +0 -39
  195. package/templates/launchagents/com.nexo.catchup 2.plist +0 -39
  196. package/templates/launchagents/com.nexo.cognitive-decay 2.plist +0 -40
  197. package/templates/launchagents/com.nexo.dashboard 2.plist +0 -43
  198. package/templates/launchagents/com.nexo.deep-sleep 2.plist +0 -43
  199. package/templates/launchagents/com.nexo.evolution 2.plist +0 -44
  200. package/templates/launchagents/com.nexo.followup-hygiene 2.plist +0 -45
  201. package/templates/launchagents/com.nexo.immune 2.plist +0 -41
  202. package/templates/launchagents/com.nexo.postmortem 2.plist +0 -45
  203. package/templates/launchagents/com.nexo.self-audit 2.plist +0 -47
  204. package/templates/launchagents/com.nexo.synthesis 2.plist +0 -45
  205. package/templates/launchagents/com.nexo.watchdog 2.plist +0 -37
  206. package/templates/nexo_helper 2.py +0 -301
  207. package/templates/openclaw 2.json +0 -13
  208. package/templates/plugin-template 2.py +0 -40
  209. package/templates/script-template 2.py +0 -59
  210. package/templates/script-template 2.sh +0 -13
  211. package/templates/skill-script-template 2.py +0 -48
  212. package/templates/skill-template 2.md +0 -33
@@ -1,2327 +0,0 @@
1
- #!/usr/bin/env python3
2
- from __future__ import annotations
3
- """
4
- Deep Sleep v2 -- Phase 4: Apply synthesized findings.
5
-
6
- Reads $DATE-synthesis.json and executes actions:
7
- - learning_add: inserts learnings into nexo.db
8
- - followup_create: inserts followups into nexo.db
9
- - morning_briefing_item: writes to morning briefing file
10
-
11
- All actions are idempotent (dedupe_key checked against last 7 days),
12
- backed up before mutation, and logged to $DATE-applied.json.
13
-
14
- Environment variables:
15
- NEXO_HOME -- root of the NEXO installation (default: ~/.nexo)
16
- """
17
- import hashlib
18
- import json
19
- import os
20
- import re
21
- import sqlite3
22
- import sys
23
- from collections import Counter
24
- from datetime import datetime, timedelta
25
- from difflib import SequenceMatcher
26
- from pathlib import Path
27
-
28
- NEXO_HOME = Path(os.environ.get("NEXO_HOME", str(Path.home() / ".nexo")))
29
- NEXO_CODE = Path(os.environ.get("NEXO_CODE", str(Path(__file__).resolve().parents[2])))
30
- if str(NEXO_CODE) not in sys.path:
31
- sys.path.insert(0, str(NEXO_CODE))
32
-
33
- import db as nexo_db
34
-
35
- DEEP_SLEEP_DIR = NEXO_HOME / "operations" / "deep-sleep"
36
- NEXO_DB = NEXO_HOME / "data" / "nexo.db"
37
- COGNITIVE_DB = NEXO_HOME / "data" / "cognitive.db"
38
- OPERATIONS_DIR = NEXO_HOME / "operations"
39
- BACKUP_DIR = DEEP_SLEEP_DIR # backups stored alongside outputs
40
-
41
- STOPWORDS = {
42
- "the", "a", "an", "and", "or", "but", "with", "for", "from", "into", "onto",
43
- "that", "this", "these", "those", "have", "has", "had", "will", "would",
44
- "could", "should", "must", "need", "needs", "your", "their", "there", "here",
45
- "about", "before", "after", "during", "through", "without", "within", "while",
46
- "que", "con", "para", "por", "los", "las", "una", "uno", "sobre", "desde",
47
- "cuando", "como", "pero", "todo", "toda", "cada", "into", "across", "using",
48
- }
49
- CONCRETE_ACTION_VERBS = {
50
- "add", "implement", "create", "write", "build", "introduce", "enforce",
51
- "automate", "validate", "check", "verify", "guard", "fix", "migrate",
52
- "review", "reconcile", "pin", "sync", "instrument",
53
- }
54
- NEGATION_PATTERNS = (
55
- "do not", "don't", "never", "avoid", "skip", "without", "forbid", "forbidden",
56
- "disable", "disabled", "remove", "ban", "bypass",
57
- )
58
- CONTRADICTION_PAIRS = (
59
- ("enable", "disable"),
60
- ("use", "avoid"),
61
- ("add", "remove"),
62
- ("allow", "forbid"),
63
- ("always", "never"),
64
- ("before", "after"),
65
- ("require", "skip"),
66
- ("validate", "bypass"),
67
- ("include", "exclude"),
68
- )
69
- TABLE_COLUMNS_CACHE: dict[tuple[str, str], set[str]] = {}
70
-
71
-
72
- def generate_run_id(target_date: str) -> str:
73
- """Generate a unique run ID for this execution."""
74
- ts = datetime.now().strftime("%H%M%S")
75
- return f"{target_date}-{ts}"
76
-
77
-
78
- def _decode_json_object(value) -> dict:
79
- if isinstance(value, dict):
80
- return value
81
- if isinstance(value, str):
82
- stripped = value.strip()
83
- if not stripped:
84
- return {}
85
- try:
86
- parsed = json.loads(stripped)
87
- except json.JSONDecodeError:
88
- return {}
89
- return parsed if isinstance(parsed, dict) else {}
90
- return {}
91
-
92
-
93
- def _top_followups_by_impact(limit: int = 5) -> list[dict]:
94
- if not NEXO_DB.exists():
95
- return []
96
- try:
97
- conn = sqlite3.connect(str(NEXO_DB))
98
- conn.row_factory = sqlite3.Row
99
- columns = {str(row[1]) for row in conn.execute("PRAGMA table_info(followups)").fetchall()}
100
- if "impact_score" not in columns:
101
- conn.close()
102
- return []
103
- impact_factors_sql = ", impact_factors" if "impact_factors" in columns else ""
104
- rows = [
105
- dict(row)
106
- for row in conn.execute(
107
- f"""SELECT id, description, date, priority, impact_score{impact_factors_sql}
108
- FROM followups
109
- WHERE status IN ('PENDING', 'ACTIVE', 'WAITING', 'BLOCKED')
110
- ORDER BY
111
- CASE WHEN COALESCE(impact_score, 0) > 0 THEN 0 ELSE 1 END ASC,
112
- COALESCE(impact_score, 0) DESC,
113
- CASE WHEN date IS NULL OR date = '' THEN 1 ELSE 0 END ASC,
114
- date ASC
115
- LIMIT ?""",
116
- (max(1, int(limit)),),
117
- ).fetchall()
118
- ]
119
- conn.close()
120
- except Exception:
121
- return []
122
-
123
- for row in rows:
124
- factors = _decode_json_object(row.get("impact_factors"))
125
- row["impact_factors"] = factors
126
- row["impact_reasoning"] = str(factors.get("reasoning") or "").strip()
127
- return rows
128
-
129
-
130
- def _read_impact_summary() -> dict:
131
- path = NEXO_HOME / "coordination" / "impact-scorer-summary.json"
132
- if not path.exists():
133
- return {}
134
- try:
135
- return json.loads(path.read_text(encoding="utf-8"))
136
- except Exception:
137
- return {}
138
-
139
-
140
- def load_recent_dedupe_keys(target_date: str, days: int = 7) -> set[str]:
141
- """Load dedupe_keys from applied files in the last N days."""
142
- keys = set()
143
- base_date = datetime.strptime(target_date, "%Y-%m-%d")
144
- for i in range(days):
145
- d = (base_date - timedelta(days=i)).strftime("%Y-%m-%d")
146
- applied_file = DEEP_SLEEP_DIR / f"{d}-applied.json"
147
- if applied_file.exists():
148
- try:
149
- with open(applied_file) as f:
150
- data = json.load(f)
151
- for action in data.get("applied_actions", []):
152
- dk = action.get("dedupe_key", "")
153
- if dk:
154
- keys.add(dk)
155
- except (json.JSONDecodeError, KeyError):
156
- continue
157
- return keys
158
-
159
-
160
- def backup_db(db_path: Path, run_id: str) -> Path | None:
161
- """Create a backup of a database before mutations."""
162
- if not db_path.exists():
163
- return None
164
- backup_path = BACKUP_DIR / f"{run_id}-backup-{db_path.name}"
165
- try:
166
- import shutil
167
- shutil.copy2(str(db_path), str(backup_path))
168
- return backup_path
169
- except Exception as e:
170
- print(f" [apply] Warning: backup failed for {db_path.name}: {e}", file=sys.stderr)
171
- return None
172
-
173
-
174
- def _table_columns(db_path: Path, table: str) -> set[str]:
175
- cache_key = (str(db_path), table)
176
- if cache_key in TABLE_COLUMNS_CACHE:
177
- return TABLE_COLUMNS_CACHE[cache_key]
178
- if not db_path.exists():
179
- TABLE_COLUMNS_CACHE[cache_key] = set()
180
- return set()
181
- try:
182
- conn = sqlite3.connect(str(db_path))
183
- rows = conn.execute(f"PRAGMA table_info({table})").fetchall()
184
- conn.close()
185
- except Exception:
186
- TABLE_COLUMNS_CACHE[cache_key] = set()
187
- return set()
188
- cols = {str(row[1]) for row in rows}
189
- TABLE_COLUMNS_CACHE[cache_key] = cols
190
- return cols
191
-
192
-
193
- def _row_dict(row) -> dict:
194
- if row is None:
195
- return {}
196
- if isinstance(row, sqlite3.Row):
197
- return dict(row)
198
- return dict(zip(row.keys(), row)) if hasattr(row, "keys") else dict(row)
199
-
200
-
201
- def _normalize_text(value: str) -> str:
202
- text = str(value or "").lower()
203
- text = re.sub(r"https?://\S+", " ", text)
204
- text = re.sub(r"[^a-z0-9_/\-\s]+", " ", text)
205
- text = re.sub(r"\s+", " ", text)
206
- return text.strip()
207
-
208
-
209
- def _tokenize(value: str) -> list[str]:
210
- tokens = re.findall(r"[a-z0-9_/-]+", _normalize_text(value))
211
- return [token for token in tokens if len(token) > 2 and token not in STOPWORDS]
212
-
213
-
214
- def _text_similarity(left: str, right: str) -> float:
215
- normalized_left = _normalize_text(left)
216
- normalized_right = _normalize_text(right)
217
- if not normalized_left or not normalized_right:
218
- return 0.0
219
- if normalized_left == normalized_right:
220
- return 1.0
221
-
222
- left_tokens = set(_tokenize(normalized_left))
223
- right_tokens = set(_tokenize(normalized_right))
224
- shared = left_tokens & right_tokens
225
- if not shared:
226
- return SequenceMatcher(None, normalized_left, normalized_right).ratio()
227
-
228
- seq = SequenceMatcher(None, normalized_left, normalized_right).ratio()
229
- jaccard = len(shared) / len(left_tokens | right_tokens) if (left_tokens or right_tokens) else 0.0
230
- overlap = len(shared) / min(len(left_tokens), len(right_tokens)) if min(len(left_tokens), len(right_tokens)) else 0.0
231
- containment = (
232
- 1.0
233
- if normalized_left in normalized_right or normalized_right in normalized_left
234
- else 0.0
235
- )
236
- return round(max((seq * 0.45) + (jaccard * 0.2) + (overlap * 0.35), overlap, (containment * 0.8) + (seq * 0.2)), 4)
237
-
238
-
239
- def _is_concrete_action(text: str) -> bool:
240
- tokens = set(_tokenize(text))
241
- return bool(tokens & CONCRETE_ACTION_VERBS)
242
-
243
-
244
- def _prefer_due_date(current_value, new_value) -> str:
245
- current = _parse_any_datetime(current_value)
246
- new = _parse_any_datetime(new_value)
247
- if new and (not current or new <= current):
248
- return str(new_value or "")
249
- return str(current_value or "")
250
-
251
-
252
- def _append_note(base: str, note: str) -> str:
253
- base = str(base or "").strip()
254
- note = str(note or "").strip()
255
- if not note:
256
- return base
257
- if not base:
258
- return note
259
- if note.lower() in base.lower():
260
- return base
261
- return f"{base}\n\n{note}"
262
-
263
-
264
- def _contains_negation(text: str) -> bool:
265
- lowered = _normalize_text(text)
266
- return any(token in lowered for token in NEGATION_PATTERNS)
267
-
268
-
269
- def _negated_action_verbs(text: str) -> set[str]:
270
- lowered = _normalize_text(text)
271
- matches = set()
272
- for pattern in (r"(?:never|avoid|skip|disable|remove|forbid|bypass)\s+([a-z0-9_-]+)", r"(?:do not|don't)\s+([a-z0-9_-]+)"):
273
- matches.update(re.findall(pattern, lowered))
274
- return {match for match in matches if len(match) > 2}
275
-
276
-
277
- def _looks_contradictory(existing_text: str, new_text: str) -> bool:
278
- existing_norm = _normalize_text(existing_text)
279
- new_norm = _normalize_text(new_text)
280
- if not existing_norm or not new_norm:
281
- return False
282
- existing_tokens = set(_tokenize(existing_norm))
283
- new_tokens = set(_tokenize(new_norm))
284
- if len(existing_tokens & new_tokens) < 3:
285
- return False
286
- existing_negated_verbs = _negated_action_verbs(existing_norm)
287
- new_negated_verbs = _negated_action_verbs(new_norm)
288
- if existing_negated_verbs & new_tokens and not existing_negated_verbs & new_negated_verbs:
289
- return True
290
- if new_negated_verbs & existing_tokens and not existing_negated_verbs & new_negated_verbs:
291
- return True
292
- if _contains_negation(existing_norm) != _contains_negation(new_norm):
293
- return True
294
- for positive, negative in CONTRADICTION_PAIRS:
295
- existing_has_pair = positive in existing_norm or negative in existing_norm
296
- new_has_pair = positive in new_norm or negative in new_norm
297
- if existing_has_pair and new_has_pair:
298
- if (positive in existing_norm and negative in new_norm) or (negative in existing_norm and positive in new_norm):
299
- return True
300
- return False
301
-
302
-
303
- def _fetch_open_followups() -> list[dict]:
304
- if not NEXO_DB.exists():
305
- return []
306
- conn = sqlite3.connect(str(NEXO_DB))
307
- conn.row_factory = sqlite3.Row
308
- cols = _table_columns(NEXO_DB, "followups")
309
- reasoning_sql = ", reasoning" if "reasoning" in cols else ""
310
- verification_sql = ", verification" if "verification" in cols else ""
311
- impact_sql = ", impact_score" if "impact_score" in cols else ""
312
- try:
313
- rows = conn.execute(
314
- "SELECT id, description, date, status"
315
- f"{verification_sql}{reasoning_sql}{impact_sql} "
316
- "FROM followups WHERE status NOT LIKE 'COMPLETED%' "
317
- "AND status NOT IN ('DELETED','archived','blocked','waiting','CANCELLED')"
318
- ).fetchall()
319
- finally:
320
- conn.close()
321
- return [dict(row) for row in rows]
322
-
323
-
324
- def _find_similar_followup(description: str, threshold: float = 0.58) -> dict | None:
325
- candidates = []
326
- query = str(description or "").strip()
327
- if not query:
328
- return None
329
- query_tokens = set(_tokenize(query))
330
- for row in _fetch_open_followups():
331
- haystack = " ".join(
332
- [
333
- str(row.get("description", "") or ""),
334
- str(row.get("verification", "") or ""),
335
- str(row.get("reasoning", "") or ""),
336
- ]
337
- )
338
- haystack_tokens = set(_tokenize(haystack))
339
- if len(query_tokens & haystack_tokens) < 2 and _normalize_text(query) not in _normalize_text(haystack):
340
- continue
341
- score = _text_similarity(query, haystack)
342
- if score >= threshold:
343
- candidates.append({**row, "_similarity": score})
344
- if not candidates:
345
- return None
346
- candidates.sort(key=lambda item: item["_similarity"], reverse=True)
347
- return candidates[0]
348
-
349
-
350
- def _touch_existing_followup(
351
- existing: dict,
352
- *,
353
- description: str,
354
- date: str = "",
355
- reasoning_note: str = "",
356
- status: str = "",
357
- ) -> dict:
358
- cols = _table_columns(NEXO_DB, "followups")
359
- if not cols:
360
- return {"success": False, "error": "followups table not found"}
361
-
362
- updates: dict[str, object] = {}
363
- existing_description = str(existing.get("description", "") or "")
364
- if _is_concrete_action(description) and not _is_concrete_action(existing_description):
365
- updates["description"] = description
366
- preferred_date = _prefer_due_date(existing.get("date", ""), date)
367
- if preferred_date and preferred_date != str(existing.get("date", "") or "") and "date" in cols:
368
- updates["date"] = preferred_date
369
- desired_status = (status or "").strip()
370
- if desired_status and "status" in cols and desired_status != str(existing.get("status", "") or ""):
371
- updates["status"] = desired_status
372
- note = reasoning_note or "Deep Sleep matched this followup semantically."
373
- changed = False
374
- if updates:
375
- result = nexo_db.update_followup(
376
- str(existing["id"]),
377
- history_actor="deep-sleep",
378
- history_event="updated",
379
- history_note=note,
380
- **updates,
381
- )
382
- if result.get("error"):
383
- return {"success": False, "error": result["error"]}
384
- changed = True
385
- elif note:
386
- note_result = nexo_db.add_followup_note(str(existing["id"]), note, actor="deep-sleep")
387
- if note_result.get("error"):
388
- return {"success": False, "error": note_result["error"]}
389
- changed = True
390
-
391
- return {
392
- "success": True,
393
- "id": existing["id"],
394
- "outcome": "matched_existing_followup",
395
- "similarity": existing.get("_similarity", 1.0),
396
- "updated_existing": changed,
397
- }
398
-
399
-
400
- def _fetch_learning_candidates(category: str = "") -> list[dict]:
401
- if not NEXO_DB.exists():
402
- return []
403
- cols = _table_columns(NEXO_DB, "learnings")
404
- if not cols:
405
- return []
406
- select_fields = ["id", "category", "title", "content", "created_at", "updated_at"]
407
- for optional in ("reasoning", "prevention", "applies_to", "status", "review_due_at", "last_reviewed_at", "weight", "priority"):
408
- if optional in cols:
409
- select_fields.append(optional)
410
- query = f"SELECT {', '.join(select_fields)} FROM learnings"
411
- params: list[object] = []
412
- if category and "category" in cols:
413
- query += " WHERE category = ?"
414
- params.append(category)
415
- query += " ORDER BY COALESCE(updated_at, created_at) DESC LIMIT 240"
416
- conn = sqlite3.connect(str(NEXO_DB))
417
- conn.row_factory = sqlite3.Row
418
- try:
419
- rows = conn.execute(query, tuple(params)).fetchall()
420
- finally:
421
- conn.close()
422
- return [dict(row) for row in rows]
423
-
424
-
425
- def _find_learning_match(category: str, title: str, content: str) -> dict | None:
426
- candidates = []
427
- new_text = " ".join([str(title or ""), str(content or "")]).strip()
428
- for row in _fetch_learning_candidates(category):
429
- existing_text = " ".join([str(row.get("title", "") or ""), str(row.get("content", "") or "")])
430
- similarity = _text_similarity(new_text, existing_text)
431
- if similarity < 0.58:
432
- continue
433
- contradiction = _looks_contradictory(existing_text, new_text)
434
- candidates.append({**row, "_similarity": similarity, "_contradiction": contradiction})
435
- if not candidates:
436
- return None
437
- candidates.sort(
438
- key=lambda item: (item["_contradiction"], item["_similarity"], item.get("updated_at", 0) or item.get("created_at", 0)),
439
- reverse=True,
440
- )
441
- return candidates[0]
442
-
443
-
444
- def _update_learning_row(learning_id: int, updates: dict[str, object]) -> None:
445
- if not updates:
446
- return
447
- conn = sqlite3.connect(str(NEXO_DB))
448
- set_clause = ", ".join(f"{column} = ?" for column in updates)
449
- conn.execute(f"UPDATE learnings SET {set_clause} WHERE id = ?", list(updates.values()) + [learning_id])
450
- conn.commit()
451
- conn.close()
452
-
453
-
454
- def _bump_weight(existing_value, amount: float) -> float:
455
- try:
456
- base = float(existing_value or 0)
457
- except Exception:
458
- base = 0.0
459
- return round(min(10.0, base + amount), 2)
460
-
461
-
462
- def _flag_learning_contradiction(existing: dict, category: str, title: str, content: str) -> dict:
463
- review_description = (
464
- f"Reconcile contradictory learning in {category or 'general'}: "
465
- f"review existing learning #{existing.get('id')} ('{existing.get('title', '')}') "
466
- f"against new Deep Sleep finding '{title}'. Produce one canonical rule, update guardrails, and remove ambiguity."
467
- )
468
- followup_result = create_followup(
469
- description=review_description,
470
- date="",
471
- reasoning_note=f"Contradiction detected against learning #{existing.get('id')}: {content[:240]}",
472
- )
473
- return {
474
- "success": followup_result.get("success", False),
475
- "id": existing.get("id"),
476
- "outcome": "contradiction_review",
477
- "similarity": existing.get("_similarity", 0.0),
478
- "review_followup_id": followup_result.get("id"),
479
- "followup_result": followup_result,
480
- }
481
-
482
-
483
- def add_learning(category: str, title: str, content: str) -> dict:
484
- """Add a learning to nexo.db. Returns result dict."""
485
- if not NEXO_DB.exists():
486
- return {"success": False, "error": "nexo.db not found"}
487
- try:
488
- existing = _find_learning_match(category, title, content)
489
- if existing:
490
- similarity = existing.get("_similarity", 0.0)
491
- if existing.get("_contradiction"):
492
- return _flag_learning_contradiction(existing, category, title, content)
493
-
494
- updates: dict[str, object] = {}
495
- columns = _table_columns(NEXO_DB, "learnings")
496
- if "updated_at" in columns:
497
- updates["updated_at"] = datetime.now().timestamp()
498
-
499
- existing_title = _normalize_text(existing.get("title", ""))
500
- existing_content = _normalize_text(existing.get("content", ""))
501
- incoming_title = _normalize_text(title)
502
- incoming_content = _normalize_text(content)
503
-
504
- if similarity >= 0.95 and (
505
- existing_title == incoming_title
506
- or existing_content == incoming_content
507
- or incoming_content in existing_content
508
- or existing_content in incoming_content
509
- ):
510
- if "weight" in columns:
511
- updates["weight"] = _bump_weight(existing.get("weight"), 0.1)
512
- if "last_reviewed_at" in columns:
513
- updates["last_reviewed_at"] = datetime.now().timestamp()
514
- if "reasoning" in columns:
515
- updates["reasoning"] = _append_note(
516
- existing.get("reasoning", ""),
517
- f"Reconfirmed by Deep Sleep on {datetime.now().strftime('%Y-%m-%d')}.",
518
- )
519
- _update_learning_row(existing["id"], updates)
520
- return {
521
- "success": True,
522
- "id": existing["id"],
523
- "outcome": "duplicate_learning",
524
- "similarity": similarity,
525
- "updated_existing": bool(updates),
526
- }
527
-
528
- if similarity >= 0.58:
529
- if "weight" in columns:
530
- updates["weight"] = _bump_weight(existing.get("weight"), 0.25)
531
- if "reasoning" in columns:
532
- updates["reasoning"] = _append_note(
533
- existing.get("reasoning", ""),
534
- f"Deep Sleep reinforcement ({datetime.now().strftime('%Y-%m-%d')}): {title}. {content[:240]}",
535
- )
536
- elif "content" in columns and content and content not in str(existing.get("content", "")):
537
- updates["content"] = _append_note(
538
- existing.get("content", ""),
539
- f"Reinforced by Deep Sleep: {content[:240]}",
540
- )
541
- _update_learning_row(existing["id"], updates)
542
- return {
543
- "success": True,
544
- "id": existing["id"],
545
- "outcome": "reinforced_learning",
546
- "similarity": similarity,
547
- "updated_existing": bool(updates),
548
- }
549
-
550
- now = datetime.now().timestamp()
551
- columns = _table_columns(NEXO_DB, "learnings")
552
- payload = {
553
- "category": category,
554
- "title": title,
555
- "content": content,
556
- "created_at": now,
557
- "updated_at": now,
558
- }
559
- if "reasoning" in columns:
560
- payload["reasoning"] = "Deep Sleep v2 overnight analysis"
561
- if "status" in columns:
562
- payload["status"] = "active"
563
- insert_columns = [column for column in payload if column in columns]
564
- values = [payload[column] for column in insert_columns]
565
-
566
- conn = sqlite3.connect(str(NEXO_DB))
567
- cursor = conn.execute(
568
- f"INSERT INTO learnings ({', '.join(insert_columns)}) VALUES ({', '.join('?' for _ in insert_columns)})",
569
- values,
570
- )
571
- learning_id = cursor.lastrowid
572
- conn.commit()
573
- conn.close()
574
- return {"success": True, "id": learning_id, "outcome": "new_learning"}
575
- except Exception as e:
576
- return {"success": False, "error": str(e)}
577
-
578
-
579
- def create_followup(description: str, date: str = "", reasoning_note: str = "", status: str = "PENDING") -> dict:
580
- """Create a followup in nexo.db. Returns result dict."""
581
- if not NEXO_DB.exists():
582
- return {"success": False, "error": "nexo.db not found"}
583
- try:
584
- desired_status = (status or "PENDING").strip() or "PENDING"
585
- is_abandoned = description.strip().startswith("[Abandoned]")
586
- if not is_abandoned:
587
- matched = _find_similar_followup(description)
588
- if matched:
589
- return _touch_existing_followup(
590
- matched,
591
- description=description,
592
- date=date,
593
- reasoning_note=reasoning_note or "Deep Sleep matched this followup semantically.",
594
- status=desired_status,
595
- )
596
-
597
- # Generate a deterministic ID — content fingerprint, not security-sensitive.
598
- fid = "NF-DS-" + hashlib.md5(description.encode(), usedforsecurity=False).hexdigest()[:8].upper()
599
- existing = nexo_db.get_followup(fid)
600
- if existing:
601
- return _touch_existing_followup(
602
- existing,
603
- description=description,
604
- date=date,
605
- reasoning_note=reasoning_note or "Deep Sleep revisited this deterministic followup.",
606
- status=desired_status,
607
- )
608
-
609
- followup_result = nexo_db.create_followup(
610
- id=fid,
611
- description=description,
612
- date=date or None,
613
- verification="",
614
- status=desired_status,
615
- reasoning=reasoning_note or "Deep Sleep v2 overnight analysis",
616
- recurrence=None,
617
- )
618
- if followup_result.get("error"):
619
- return {"success": False, "error": followup_result["error"]}
620
- if desired_status != "PENDING":
621
- nexo_db.add_followup_note(
622
- fid,
623
- f"Deep Sleep created this followup directly as {desired_status}.",
624
- actor="deep-sleep",
625
- )
626
- return {"success": True, "id": fid, "outcome": "new_followup"}
627
- except Exception as e:
628
- return {"success": False, "error": str(e)}
629
-
630
-
631
- def update_calibration_mood(synthesis: dict) -> dict:
632
- """Update mood in calibration.json based on emotional analysis."""
633
- calibration_file = NEXO_HOME / "brain" / "calibration.json"
634
- if not calibration_file.exists():
635
- return {"success": False, "error": "calibration.json not found"}
636
-
637
- emotional_day = synthesis.get("emotional_day", {})
638
- if not emotional_day:
639
- return {"success": False, "error": "no emotional_day data"}
640
-
641
- try:
642
- cal = json.loads(calibration_file.read_text())
643
-
644
- # Add/update mood history
645
- if "mood_history" not in cal:
646
- cal["mood_history"] = []
647
-
648
- cal["mood_history"].append({
649
- "date": synthesis.get("date", ""),
650
- "score": emotional_day.get("mood_score", 0.5),
651
- "arc": emotional_day.get("mood_arc", ""),
652
- "triggers": emotional_day.get("recurring_triggers", {}),
653
- })
654
-
655
- # Keep last 30 days
656
- cal["mood_history"] = cal["mood_history"][-30:]
657
-
658
- # Apply calibration recommendation automatically
659
- rec = emotional_day.get("calibration_recommendation")
660
- if rec and rec != "null":
661
- applied_changes = []
662
-
663
- # Parse and apply known calibration adjustments
664
- rec_lower = rec.lower()
665
- personality = cal.get("personality", {})
666
-
667
- # Autonomy adjustments
668
- if "autonomy" in rec_lower or "autonomía" in rec_lower:
669
- if any(w in rec_lower for w in ["full", "más autonomía", "subir", "increase"]):
670
- personality["autonomy"] = "full"
671
- applied_changes.append("autonomy → full")
672
- elif any(w in rec_lower for w in ["conservative", "reducir", "bajar"]):
673
- personality["autonomy"] = "conservative"
674
- applied_changes.append("autonomy → conservative")
675
-
676
- # Communication adjustments
677
- if any(w in rec_lower for w in ["concis", "breve", "shorter", "telegráf"]):
678
- personality["communication"] = "concise"
679
- applied_changes.append("communication → concise")
680
- elif any(w in rec_lower for w in ["detail", "explicar más", "más contexto"]):
681
- personality["communication"] = "detailed"
682
- applied_changes.append("communication → detailed")
683
-
684
- # Proactivity adjustments
685
- if any(w in rec_lower for w in ["más proactiv", "proactive", "anticipar"]):
686
- personality["proactivity"] = "proactive"
687
- applied_changes.append("proactivity → proactive")
688
-
689
- cal["personality"] = personality
690
-
691
- # Log the recommendation and what was applied
692
- if "calibration_log" not in cal:
693
- cal["calibration_log"] = []
694
- cal["calibration_log"].append({
695
- "date": synthesis.get("date", ""),
696
- "recommendation": rec,
697
- "applied": applied_changes if applied_changes else ["noted, no auto-applicable changes"],
698
- })
699
- cal["calibration_log"] = cal["calibration_log"][-20:]
700
-
701
- calibration_file.write_text(json.dumps(cal, indent=2, ensure_ascii=False))
702
- changes_str = ", ".join(applied_changes) if rec and applied_changes else "none"
703
- return {"success": True, "mood_score": emotional_day.get("mood_score"), "calibration_applied": changes_str}
704
- except Exception as e:
705
- return {"success": False, "error": str(e)}
706
-
707
-
708
- def calibrate_trust_score(synthesis: dict, target_date: str) -> dict:
709
- """Set the daily trust score from Deep Sleep analysis.
710
-
711
- This is the authoritative score for the day — replaces incremental
712
- adjustments with a holistic evaluation of the entire day.
713
- """
714
- trust_cal = synthesis.get("trust_calibration")
715
- if not trust_cal or "score" not in trust_cal:
716
- return {"success": False, "error": "no trust_calibration in synthesis"}
717
-
718
- score = max(0, min(100, trust_cal["score"]))
719
- reasoning = trust_cal.get("reasoning", "Deep Sleep calibration")
720
- trend = trust_cal.get("trend", "stable")
721
- highlights = trust_cal.get("highlights", [])
722
- lowlights = trust_cal.get("lowlights", [])
723
-
724
- context = (
725
- f"Deep Sleep {target_date} | trend: {trend} | "
726
- f"highlights: {', '.join(highlights[:3])} | "
727
- f"lowlights: {', '.join(lowlights[:3])}"
728
- )
729
-
730
- try:
731
- # Get current score for delta calculation
732
- db = sqlite3.connect(str(COGNITIVE_DB))
733
- row = db.execute(
734
- "SELECT score FROM trust_score ORDER BY id DESC LIMIT 1"
735
- ).fetchone()
736
- old_score = row[0] if row else 50.0
737
- delta = score - old_score
738
-
739
- db.execute(
740
- "INSERT INTO trust_score (score, event, delta, context) VALUES (?, ?, ?, ?)",
741
- (score, f"deep_sleep_calibration: {reasoning[:200]}", delta, context[:500])
742
- )
743
- db.commit()
744
- db.close()
745
-
746
- return {
747
- "success": True,
748
- "old_score": old_score,
749
- "new_score": score,
750
- "delta": delta,
751
- "trend": trend,
752
- }
753
- except Exception as e:
754
- return {"success": False, "error": str(e)}
755
-
756
-
757
- def create_skill(skill_data: dict) -> dict:
758
- """Create a personal Skill v2 definition and sync it into SQLite."""
759
- try:
760
- from db import materialize_personal_skill_definition
761
-
762
- skill_id = skill_data.get("id", "")
763
- if not skill_id:
764
- # Content fingerprint, not security-sensitive.
765
- skill_id = "SK-DS-" + hashlib.md5(
766
- skill_data.get("name", "").encode(), usedforsecurity=False
767
- ).hexdigest()[:8].upper()
768
-
769
- execution_level = skill_data.get("execution_level", "")
770
- scriptable = bool(skill_data.get("scriptable"))
771
- mode = skill_data.get("mode", "")
772
- if not mode:
773
- if scriptable and execution_level == "read-only":
774
- mode = "hybrid"
775
- else:
776
- mode = "guide"
777
-
778
- approval_required = bool(skill_data.get("approval_required", execution_level in {"local", "remote"}))
779
- script_body = str(skill_data.get("script_body", "") or "")
780
- executable_entry = str(skill_data.get("executable_entry", "") or "")
781
-
782
- result = materialize_personal_skill_definition(
783
- {
784
- "id": skill_id,
785
- "name": skill_data.get("name", ""),
786
- "description": skill_data.get("description", ""),
787
- "level": skill_data.get("level", "draft"),
788
- "mode": mode,
789
- "execution_level": execution_level if mode != "guide" else "none",
790
- "approval_required": approval_required,
791
- "tags": skill_data.get("tags", []),
792
- "trigger_patterns": skill_data.get("trigger_patterns", []),
793
- "source_sessions": skill_data.get("source_sessions", []),
794
- "steps": skill_data.get("steps", []),
795
- "gotchas": skill_data.get("gotchas", []),
796
- "params_schema": skill_data.get("params_schema", skill_data.get("candidate_params", {})),
797
- "command_template": skill_data.get("command_template", {}),
798
- "executable_entry": executable_entry,
799
- "script_body": script_body,
800
- "content": skill_data.get("content", ""),
801
- }
802
- )
803
- if "error" in result:
804
- return {"success": False, "error": result["error"], "id": skill_id}
805
- return {"success": True, "id": result["id"], "name": result.get("name", "")}
806
- except Exception as e:
807
- return {"success": False, "error": str(e)}
808
-
809
-
810
- def create_abandoned_followups(synthesis: dict) -> list[dict]:
811
- """Create followups for truly abandoned projects."""
812
- results = []
813
- abandoned = synthesis.get("abandoned_projects", [])
814
- for proj in abandoned:
815
- if proj.get("has_followup"):
816
- continue
817
- rec = proj.get("recommendation", "")
818
- if "ignore" in rec.lower():
819
- continue
820
- result = create_followup(
821
- description=f"[Abandoned] {proj.get('description', '')}",
822
- date="", # No date — it's a discovered gap
823
- reasoning_note="Deep Sleep marked this as abandoned. Keep it as archived history, not active work.",
824
- status="archived",
825
- )
826
- results.append(result)
827
- return results
828
-
829
-
830
- def _safe_query(db_path: Path, query: str, params: tuple = ()) -> list[dict]:
831
- if not db_path.exists():
832
- return []
833
- try:
834
- conn = sqlite3.connect(str(db_path))
835
- conn.row_factory = sqlite3.Row
836
- rows = conn.execute(query, params).fetchall()
837
- conn.close()
838
- return [dict(row) for row in rows]
839
- except Exception:
840
- return []
841
-
842
-
843
- def _parse_any_datetime(value) -> datetime | None:
844
- if value in (None, ""):
845
- return None
846
- try:
847
- if isinstance(value, (int, float)) or (isinstance(value, str) and str(value).strip().isdigit()):
848
- return datetime.fromtimestamp(float(value))
849
- except Exception:
850
- return None
851
- raw = str(value).strip()
852
- for fmt in ("%Y-%m-%d", "%Y-%m-%d %H:%M:%S"):
853
- try:
854
- return datetime.strptime(raw[:19], fmt)
855
- except Exception:
856
- continue
857
- try:
858
- dt = datetime.fromisoformat(raw.replace("Z", "+00:00"))
859
- return dt.replace(tzinfo=None)
860
- except Exception:
861
- return None
862
-
863
-
864
- def _load_project_aliases() -> dict[str, set[str]]:
865
- atlas_path = NEXO_HOME / "brain" / "project-atlas.json"
866
- if not atlas_path.is_file():
867
- return {}
868
- try:
869
- payload = json.loads(atlas_path.read_text())
870
- except Exception:
871
- return {}
872
- if not isinstance(payload, dict):
873
- return {}
874
- aliases: dict[str, set[str]] = {}
875
- for key, value in payload.items():
876
- if str(key).startswith("_"):
877
- continue
878
- canonical = str(key).strip().lower()
879
- alias_set = {canonical, canonical.replace("-", " "), canonical.replace("_", " ")}
880
- if isinstance(value, dict):
881
- for alias in value.get("aliases", []) or []:
882
- alias_value = str(alias or "").strip().lower()
883
- if alias_value:
884
- alias_set.add(alias_value)
885
- alias_set.add(alias_value.replace("-", " "))
886
- aliases[canonical] = {item for item in alias_set if item}
887
- return aliases
888
-
889
-
890
- def _match_projects(text: str, alias_map: dict[str, set[str]]) -> set[str]:
891
- haystack = str(text or "").strip().lower()
892
- if not haystack:
893
- return set()
894
- matches: set[str] = set()
895
- for canonical, aliases in alias_map.items():
896
- for alias in sorted(aliases, key=len, reverse=True):
897
- if alias and alias in haystack:
898
- matches.add(canonical)
899
- break
900
- return matches
901
-
902
-
903
- def _priority_weight(value) -> float:
904
- lowered = str(value or "").strip().lower()
905
- if lowered in {"critical", "urgent"}:
906
- return 4.0
907
- if lowered == "high":
908
- return 3.0
909
- if lowered == "medium":
910
- return 2.0
911
- if lowered == "low":
912
- return 1.0
913
- return 1.5
914
-
915
-
916
- def _project_weighting_window(target_date: str, *, window_days: int) -> list[dict]:
917
- target_day = datetime.strptime(target_date, "%Y-%m-%d")
918
- window_start = target_day - timedelta(days=max(0, window_days - 1))
919
- alias_map = _load_project_aliases()
920
- scoreboard: dict[str, dict] = {}
921
-
922
- def normalize_project(project: str) -> str:
923
- lowered = str(project or "").strip().lower()
924
- if not lowered:
925
- return ""
926
- matched = _match_projects(lowered, alias_map)
927
- if matched:
928
- return sorted(matched)[0]
929
- return lowered
930
-
931
- def bump(project: str, score: float, signal_key: str, reason: str) -> None:
932
- canonical = normalize_project(project)
933
- if not canonical:
934
- return
935
- slot = scoreboard.setdefault(
936
- canonical,
937
- {
938
- "project": canonical,
939
- "score": 0.0,
940
- "signals": {
941
- "diary_sessions": 0,
942
- "learnings": 0,
943
- "followups": 0,
944
- "decisions": 0,
945
- },
946
- "reasons": [],
947
- },
948
- )
949
- slot["score"] += score
950
- slot["signals"][signal_key] += 1
951
- if reason and reason not in slot["reasons"]:
952
- slot["reasons"].append(reason)
953
-
954
- diary_rows = _safe_query(
955
- NEXO_DB,
956
- "SELECT created_at, summary, self_critique, domain FROM session_diary ORDER BY created_at DESC",
957
- )
958
- for row in diary_rows:
959
- created = _parse_any_datetime(row.get("created_at"))
960
- if not created or created < window_start or created > target_day + timedelta(days=1):
961
- continue
962
- recency_bonus = 1.4 if (target_day - created).days <= 7 else 1.0
963
- matched = _match_projects(
964
- " ".join(
965
- [
966
- str(row.get("summary", "") or ""),
967
- str(row.get("self_critique", "") or ""),
968
- ]
969
- ),
970
- alias_map,
971
- )
972
- domain = normalize_project(str(row.get("domain", "") or ""))
973
- if domain:
974
- matched.add(domain)
975
- for project in matched:
976
- bump(project, 3.0 * recency_bonus, "diary_sessions", "recent diary activity")
977
-
978
- learning_rows = _safe_query(
979
- NEXO_DB,
980
- "SELECT title, content, applies_to, priority, weight, updated_at, created_at FROM learnings "
981
- "ORDER BY COALESCE(updated_at, created_at) DESC LIMIT 180",
982
- )
983
- for row in learning_rows:
984
- when = _parse_any_datetime(row.get("updated_at") or row.get("created_at"))
985
- if when and when < window_start:
986
- continue
987
- matched = _match_projects(
988
- " ".join(
989
- [
990
- str(row.get("applies_to", "") or ""),
991
- str(row.get("title", "") or ""),
992
- str(row.get("content", "") or ""),
993
- ]
994
- ),
995
- alias_map,
996
- )
997
- if not matched:
998
- continue
999
- score = 1.0 + _priority_weight(row.get("priority")) + min(2.0, max(0.0, float(row.get("weight", 0) or 0)))
1000
- for project in matched:
1001
- bump(project, score, "learnings", "recent leverage-bearing learning")
1002
-
1003
- followup_rows = _safe_query(
1004
- NEXO_DB,
1005
- "SELECT description, date, status, priority, created_at, updated_at, reasoning, impact_score FROM followups "
1006
- "WHERE status NOT IN ('COMPLETED', 'CANCELLED') ORDER BY date ASC, created_at ASC LIMIT 160",
1007
- )
1008
- for row in followup_rows:
1009
- matched = _match_projects(
1010
- " ".join(
1011
- [
1012
- str(row.get("description", "") or ""),
1013
- str(row.get("reasoning", "") or ""),
1014
- ]
1015
- ),
1016
- alias_map,
1017
- )
1018
- if not matched:
1019
- continue
1020
- overdue_bonus = 0.0
1021
- due_dt = _parse_any_datetime(row.get("date"))
1022
- if due_dt and due_dt <= target_day:
1023
- overdue_bonus = 1.5
1024
- impact_bonus = min(4.0, max(0.0, float(row.get("impact_score", 0) or 0)) / 25.0)
1025
- score = 1.5 + _priority_weight(row.get("priority")) + overdue_bonus + impact_bonus
1026
- for project in matched:
1027
- bump(project, score, "followups", "open followup pressure")
1028
-
1029
- decision_rows = _safe_query(
1030
- NEXO_DB,
1031
- "SELECT domain, outcome, status, reasoning, created_at, review_due_at FROM decisions "
1032
- "ORDER BY COALESCE(created_at, review_due_at) DESC LIMIT 160",
1033
- )
1034
- for row in decision_rows:
1035
- when = _parse_any_datetime(row.get("created_at") or row.get("review_due_at"))
1036
- if when and when < window_start:
1037
- continue
1038
- matched = _match_projects(
1039
- " ".join(
1040
- [
1041
- str(row.get("reasoning", "") or ""),
1042
- str(row.get("outcome", "") or ""),
1043
- str(row.get("status", "") or ""),
1044
- ]
1045
- ),
1046
- alias_map,
1047
- )
1048
- domain = normalize_project(str(row.get("domain", "") or ""))
1049
- if domain:
1050
- matched.add(domain)
1051
- if not matched:
1052
- continue
1053
- outcome = str(row.get("outcome", "") or "").lower()
1054
- status = str(row.get("status", "") or "").lower()
1055
- score = 2.5
1056
- if any(token in outcome for token in ("fail", "error", "blocked", "regression")):
1057
- score += 2.0
1058
- if status in {"pending", "blocked", "open"}:
1059
- score += 1.5
1060
- for project in matched:
1061
- bump(project, score, "decisions", "recent decision pressure")
1062
-
1063
- ranked = sorted(scoreboard.values(), key=lambda item: item["score"], reverse=True)
1064
- for item in ranked:
1065
- item["score"] = round(item["score"], 2)
1066
- item["reasons"] = item["reasons"][:4]
1067
- return ranked[:8]
1068
-
1069
-
1070
- def _load_period_syntheses(target_date: str, *, window_days: int) -> list[dict]:
1071
- target_day = datetime.strptime(target_date, "%Y-%m-%d")
1072
- syntheses: list[dict] = []
1073
- for offset in range(window_days):
1074
- date_str = (target_day - timedelta(days=offset)).strftime("%Y-%m-%d")
1075
- path = DEEP_SLEEP_DIR / f"{date_str}-synthesis.json"
1076
- if not path.is_file():
1077
- continue
1078
- try:
1079
- payload = json.loads(path.read_text())
1080
- except Exception:
1081
- continue
1082
- if isinstance(payload, dict):
1083
- syntheses.append(payload)
1084
- syntheses.reverse()
1085
- return syntheses
1086
-
1087
-
1088
- def _load_period_extractions(target_date: str, *, window_days: int) -> list[dict]:
1089
- target_day = datetime.strptime(target_date, "%Y-%m-%d")
1090
- payloads: list[dict] = []
1091
- for offset in range(window_days):
1092
- date_str = (target_day - timedelta(days=offset)).strftime("%Y-%m-%d")
1093
- path = DEEP_SLEEP_DIR / f"{date_str}-extractions.json"
1094
- if not path.is_file():
1095
- continue
1096
- try:
1097
- payload = json.loads(path.read_text())
1098
- except Exception:
1099
- continue
1100
- if isinstance(payload, dict):
1101
- payloads.append(payload)
1102
- payloads.reverse()
1103
- return payloads
1104
-
1105
-
1106
- def _load_period_applied_logs(target_date: str, *, window_days: int) -> list[dict]:
1107
- target_day = datetime.strptime(target_date, "%Y-%m-%d")
1108
- payloads: list[dict] = []
1109
- for offset in range(window_days):
1110
- date_str = (target_day - timedelta(days=offset)).strftime("%Y-%m-%d")
1111
- path = DEEP_SLEEP_DIR / f"{date_str}-applied.json"
1112
- if not path.is_file():
1113
- continue
1114
- try:
1115
- payload = json.loads(path.read_text())
1116
- except Exception:
1117
- continue
1118
- if isinstance(payload, dict):
1119
- payloads.append(payload)
1120
- payloads.reverse()
1121
- return payloads
1122
-
1123
-
1124
- def _safe_pct(numerator: float, denominator: float) -> float | None:
1125
- if denominator <= 0:
1126
- return None
1127
- return round((numerator / denominator) * 100.0, 1)
1128
-
1129
-
1130
- def _aggregate_protocol_summary(extractions: list[dict]) -> dict:
1131
- totals = {
1132
- "sessions": 0,
1133
- "guard_check": {"required": 0, "executed": 0},
1134
- "heartbeat": {"total": 0, "with_context": 0},
1135
- "change_log": {"edits": 0, "logged": 0},
1136
- }
1137
-
1138
- for payload in extractions:
1139
- for item in payload.get("extractions", []) or []:
1140
- if not isinstance(item, dict) or item.get("error"):
1141
- continue
1142
- totals["sessions"] += 1
1143
- protocol_summary = item.get("protocol_summary") or {}
1144
- for key in ("guard_check", "heartbeat", "change_log"):
1145
- current = protocol_summary.get(key) or {}
1146
- if key == "guard_check":
1147
- totals[key]["required"] += int(current.get("required", 0) or 0)
1148
- totals[key]["executed"] += int(current.get("executed", 0) or 0)
1149
- elif key == "heartbeat":
1150
- totals[key]["total"] += int(current.get("total", 0) or 0)
1151
- totals[key]["with_context"] += int(current.get("with_context", 0) or 0)
1152
- else:
1153
- totals[key]["edits"] += int(current.get("edits", 0) or 0)
1154
- totals[key]["logged"] += int(current.get("logged", 0) or 0)
1155
-
1156
- guard_pct = _safe_pct(totals["guard_check"]["executed"], totals["guard_check"]["required"])
1157
- heartbeat_pct = _safe_pct(totals["heartbeat"]["with_context"], totals["heartbeat"]["total"])
1158
- change_pct = _safe_pct(totals["change_log"]["logged"], totals["change_log"]["edits"])
1159
- available = [value for value in (guard_pct, heartbeat_pct, change_pct) if value is not None]
1160
-
1161
- totals["guard_check"]["compliance_pct"] = guard_pct
1162
- totals["heartbeat"]["compliance_pct"] = heartbeat_pct
1163
- totals["change_log"]["compliance_pct"] = change_pct
1164
- totals["overall_compliance_pct"] = round(sum(available) / len(available), 1) if available else None
1165
- return totals
1166
-
1167
-
1168
- def _aggregate_delivery_metrics(applied_logs: list[dict]) -> dict:
1169
- totals = {
1170
- "runs": len(applied_logs),
1171
- "applied_actions": 0,
1172
- "deferred_actions": 0,
1173
- "skipped_dedupe": 0,
1174
- "errors": 0,
1175
- "engineering_followups": 0,
1176
- "followup_dedupe_matches": 0,
1177
- "learning_reinforcements": 0,
1178
- "learning_duplicate_skips": 0,
1179
- "learning_contradiction_reviews": 0,
1180
- }
1181
- for payload in applied_logs:
1182
- stats = payload.get("stats") or {}
1183
- totals["applied_actions"] += int(stats.get("applied", 0) or 0)
1184
- totals["deferred_actions"] += int(stats.get("deferred", 0) or 0)
1185
- totals["skipped_dedupe"] += int(stats.get("skipped_dedupe", 0) or 0)
1186
- totals["errors"] += int(stats.get("errors", 0) or 0)
1187
- for action in payload.get("applied_actions", []) or []:
1188
- details = action.get("details") or {}
1189
- if action.get("action_type") == "followup_create":
1190
- description = str(details.get("description", "") or "") + " " + str(details.get("reasoning", "") or "")
1191
- if "engineering" in description.lower() or "guardrail" in description.lower():
1192
- totals["engineering_followups"] += 1
1193
- if details.get("outcome") == "matched_existing_followup":
1194
- totals["followup_dedupe_matches"] += 1
1195
- elif action.get("action_type") == "learning_add":
1196
- outcome = str(details.get("outcome", "") or "")
1197
- if outcome == "reinforced_learning":
1198
- totals["learning_reinforcements"] += 1
1199
- elif outcome == "duplicate_learning":
1200
- totals["learning_duplicate_skips"] += 1
1201
- elif outcome == "contradiction_review":
1202
- totals["learning_contradiction_reviews"] += 1
1203
-
1204
- attempted = totals["applied_actions"] + totals["deferred_actions"] + totals["skipped_dedupe"] + totals["errors"]
1205
- totals["dedupe_rate_pct"] = _safe_pct(totals["skipped_dedupe"], attempted)
1206
- totals["error_rate_pct"] = _safe_pct(totals["errors"], attempted)
1207
- return totals
1208
-
1209
-
1210
- def _semantic_duplicate_metrics(items: list[tuple[str, str]], *, threshold: float = 0.82) -> dict:
1211
- filtered = [(item_id, _normalize_text(text)) for item_id, text in items if _normalize_text(text)]
1212
- if len(filtered) < 2:
1213
- return {
1214
- "cluster_count": 0,
1215
- "duplicate_items": 0,
1216
- "duplicate_excess": 0,
1217
- "sample_clusters": [],
1218
- }
1219
-
1220
- used: set[int] = set()
1221
- clusters: list[list[tuple[str, str]]] = []
1222
- for index, (item_id, text) in enumerate(filtered):
1223
- if index in used:
1224
- continue
1225
- cluster = [(item_id, text)]
1226
- for other_index in range(index + 1, len(filtered)):
1227
- if other_index in used:
1228
- continue
1229
- other_id, other_text = filtered[other_index]
1230
- if _text_similarity(text, other_text) >= threshold:
1231
- cluster.append((other_id, other_text))
1232
- used.add(other_index)
1233
- if len(cluster) > 1:
1234
- used.add(index)
1235
- clusters.append(cluster)
1236
-
1237
- return {
1238
- "cluster_count": len(clusters),
1239
- "duplicate_items": sum(len(cluster) for cluster in clusters),
1240
- "duplicate_excess": sum(max(0, len(cluster) - 1) for cluster in clusters),
1241
- "sample_clusters": [
1242
- [item_id for item_id, _ in cluster[:4]]
1243
- for cluster in clusters[:5]
1244
- ],
1245
- }
1246
-
1247
-
1248
- def _followup_deduplication_metrics() -> dict:
1249
- cols = _table_columns(NEXO_DB, "followups")
1250
- if "description" not in cols:
1251
- return {
1252
- "open_followups": 0,
1253
- "duplicate_clusters": 0,
1254
- "duplicate_open_followups": 0,
1255
- "duplicate_rate_pct": None,
1256
- "sample_clusters": [],
1257
- }
1258
-
1259
- select_cols = ["description"]
1260
- if "id" in cols:
1261
- select_cols.append("id")
1262
- if "status" in cols:
1263
- select_cols.append("status")
1264
-
1265
- conn = sqlite3.connect(str(NEXO_DB))
1266
- conn.row_factory = sqlite3.Row
1267
- rows = [dict(row) for row in conn.execute(f"SELECT {', '.join(select_cols)} FROM followups").fetchall()]
1268
- conn.close()
1269
-
1270
- open_rows = []
1271
- for row in rows:
1272
- status = str(row.get("status", "pending") or "pending").strip().lower()
1273
- if status in {"done", "completed", "cancelled", "resolved"}:
1274
- continue
1275
- identifier = str(row.get("id") or row.get("description") or f"followup-{len(open_rows)+1}")
1276
- open_rows.append((identifier, str(row.get("description", "") or "")))
1277
-
1278
- duplicates = _semantic_duplicate_metrics(open_rows)
1279
- return {
1280
- "open_followups": len(open_rows),
1281
- "duplicate_clusters": duplicates["cluster_count"],
1282
- "duplicate_open_followups": duplicates["duplicate_excess"],
1283
- "duplicate_rate_pct": _safe_pct(duplicates["duplicate_excess"], len(open_rows)),
1284
- "sample_clusters": duplicates["sample_clusters"],
1285
- }
1286
-
1287
-
1288
- def _learning_consolidation_metrics() -> dict:
1289
- cols = _table_columns(NEXO_DB, "learnings")
1290
- if not {"title", "content"}.issubset(cols):
1291
- return {
1292
- "active_learnings": 0,
1293
- "weak_active_learnings": 0,
1294
- "duplicate_clusters": 0,
1295
- "duplicate_active_learnings": 0,
1296
- "noise_pressure": 0,
1297
- "noise_rate_pct": None,
1298
- "sample_clusters": [],
1299
- }
1300
-
1301
- select_cols = ["title", "content"]
1302
- if "id" in cols:
1303
- select_cols.append("id")
1304
- for field in ("status", "weight", "reasoning", "prevention", "applies_to", "guard_hits"):
1305
- if field in cols:
1306
- select_cols.append(field)
1307
-
1308
- conn = sqlite3.connect(str(NEXO_DB))
1309
- conn.row_factory = sqlite3.Row
1310
- rows = [dict(row) for row in conn.execute(f"SELECT {', '.join(select_cols)} FROM learnings").fetchall()]
1311
- conn.close()
1312
-
1313
- active_rows = []
1314
- weak_active = 0
1315
- for row in rows:
1316
- status = str(row.get("status", "active") or "active").strip().lower()
1317
- if status != "active":
1318
- continue
1319
- active_rows.append(row)
1320
- weight = row.get("weight")
1321
- reasoning = str(row.get("reasoning", "") or "").strip()
1322
- prevention = str(row.get("prevention", "") or "").strip()
1323
- guard_hits = int(row.get("guard_hits", 0) or 0)
1324
- applies_to = str(row.get("applies_to", "") or "").strip()
1325
- if isinstance(weight, (int, float)) and float(weight) < 1.0:
1326
- weak_active += 1
1327
- elif not reasoning and not prevention:
1328
- weak_active += 1
1329
- elif applies_to and guard_hits <= 0:
1330
- weak_active += 1
1331
-
1332
- duplicates = _semantic_duplicate_metrics(
1333
- [
1334
- (str(row.get("id") or f"learning-{index}"), f"{row.get('title', '')} {row.get('content', '')}")
1335
- for index, row in enumerate(active_rows, 1)
1336
- ],
1337
- threshold=0.8,
1338
- )
1339
- noise_pressure = weak_active + duplicates["duplicate_excess"]
1340
- return {
1341
- "active_learnings": len(active_rows),
1342
- "weak_active_learnings": weak_active,
1343
- "duplicate_clusters": duplicates["cluster_count"],
1344
- "duplicate_active_learnings": duplicates["duplicate_excess"],
1345
- "noise_pressure": noise_pressure,
1346
- "noise_rate_pct": _safe_pct(noise_pressure, len(active_rows)),
1347
- "sample_clusters": duplicates["sample_clusters"],
1348
- }
1349
-
1350
-
1351
- def _load_previous_period_summary(kind: str, label: str) -> dict | None:
1352
- pattern = f"*-{kind}-summary.json"
1353
- candidates: list[tuple[str, Path]] = []
1354
- for path in DEEP_SLEEP_DIR.glob(pattern):
1355
- try:
1356
- payload = json.loads(path.read_text())
1357
- except Exception:
1358
- continue
1359
- candidate_label = str(payload.get("label", "") or "")
1360
- if candidate_label and candidate_label < label:
1361
- candidates.append((candidate_label, path))
1362
- if not candidates:
1363
- return None
1364
- _, path = sorted(candidates, key=lambda item: item[0])[-1]
1365
- try:
1366
- payload = json.loads(path.read_text())
1367
- except Exception:
1368
- return None
1369
- return payload if isinstance(payload, dict) else None
1370
-
1371
-
1372
- def _build_project_pulse(top_projects: list[dict], previous_summary: dict | None) -> list[dict]:
1373
- previous_scores: dict[str, float] = {}
1374
- if previous_summary:
1375
- for item in previous_summary.get("project_pulse", []) or previous_summary.get("top_projects", []) or []:
1376
- project = str(item.get("project", "") or "")
1377
- if project:
1378
- previous_scores[project] = float(item.get("score", 0) or 0)
1379
-
1380
- pulse: list[dict] = []
1381
- for item in top_projects:
1382
- project = str(item.get("project", "") or "")
1383
- score = float(item.get("score", 0) or 0)
1384
- previous_score = previous_scores.get(project, 0.0)
1385
- delta = round(score - previous_score, 2)
1386
- if score >= 18:
1387
- status = "critical"
1388
- elif score >= 10:
1389
- status = "elevated"
1390
- else:
1391
- status = "watch"
1392
- if delta >= 2.0:
1393
- trend = "rising"
1394
- elif delta <= -2.0:
1395
- trend = "cooling"
1396
- else:
1397
- trend = "steady"
1398
- pulse.append(
1399
- {
1400
- "project": project,
1401
- "score": round(score, 2),
1402
- "delta_vs_previous": delta,
1403
- "trend": trend,
1404
- "status": status,
1405
- "signals": item.get("signals", {}),
1406
- "reasons": item.get("reasons", []),
1407
- }
1408
- )
1409
- return pulse
1410
-
1411
-
1412
- def _build_period_trend(summary: dict, previous_summary: dict | None) -> dict:
1413
- if not previous_summary:
1414
- return {
1415
- "has_previous": False,
1416
- "avg_mood_delta": None,
1417
- "avg_trust_delta": None,
1418
- "total_corrections_delta": None,
1419
- "protocol_compliance_delta": None,
1420
- "followup_duplicate_open_delta": None,
1421
- "followup_duplicate_rate_delta": None,
1422
- "learning_noise_delta": None,
1423
- "learning_noise_rate_delta": None,
1424
- }
1425
-
1426
- current_protocol = summary.get("protocol_summary", {}).get("overall_compliance_pct")
1427
- previous_protocol = (previous_summary.get("protocol_summary") or {}).get("overall_compliance_pct")
1428
- current_mood = summary.get("avg_mood_score")
1429
- previous_mood = previous_summary.get("avg_mood_score")
1430
- current_trust = summary.get("avg_trust_score")
1431
- previous_trust = previous_summary.get("avg_trust_score")
1432
- current_followup = (summary.get("followup_deduplication") or {}).get("duplicate_open_followups")
1433
- previous_followup = (previous_summary.get("followup_deduplication") or {}).get("duplicate_open_followups")
1434
- current_followup_rate = (summary.get("followup_deduplication") or {}).get("duplicate_rate_pct")
1435
- previous_followup_rate = (previous_summary.get("followup_deduplication") or {}).get("duplicate_rate_pct")
1436
- current_learning_noise = (summary.get("learning_consolidation") or {}).get("noise_pressure")
1437
- previous_learning_noise = (previous_summary.get("learning_consolidation") or {}).get("noise_pressure")
1438
- current_learning_rate = (summary.get("learning_consolidation") or {}).get("noise_rate_pct")
1439
- previous_learning_rate = (previous_summary.get("learning_consolidation") or {}).get("noise_rate_pct")
1440
-
1441
- return {
1442
- "has_previous": True,
1443
- "avg_mood_delta": round(current_mood - previous_mood, 3) if isinstance(current_mood, (int, float)) and isinstance(previous_mood, (int, float)) else None,
1444
- "avg_trust_delta": round(current_trust - previous_trust, 1) if isinstance(current_trust, (int, float)) and isinstance(previous_trust, (int, float)) else None,
1445
- "total_corrections_delta": int(summary.get("total_corrections", 0) or 0) - int(previous_summary.get("total_corrections", 0) or 0),
1446
- "protocol_compliance_delta": round(current_protocol - previous_protocol, 1) if isinstance(current_protocol, (int, float)) and isinstance(previous_protocol, (int, float)) else None,
1447
- "followup_duplicate_open_delta": int(current_followup or 0) - int(previous_followup or 0) if current_followup is not None or previous_followup is not None else None,
1448
- "followup_duplicate_rate_delta": round(float(current_followup_rate) - float(previous_followup_rate), 1) if isinstance(current_followup_rate, (int, float)) and isinstance(previous_followup_rate, (int, float)) else None,
1449
- "learning_noise_delta": int(current_learning_noise or 0) - int(previous_learning_noise or 0) if current_learning_noise is not None or previous_learning_noise is not None else None,
1450
- "learning_noise_rate_delta": round(float(current_learning_rate) - float(previous_learning_rate), 1) if isinstance(current_learning_rate, (int, float)) and isinstance(previous_learning_rate, (int, float)) else None,
1451
- }
1452
-
1453
-
1454
- def _build_period_summary(target_date: str, synthesis: dict, *, kind: str, window_days: int) -> dict:
1455
- target_day = datetime.strptime(target_date, "%Y-%m-%d")
1456
- window_start = (target_day - timedelta(days=max(0, window_days - 1))).strftime("%Y-%m-%d")
1457
- label = (
1458
- f"{target_day.isocalendar().year}-W{target_day.isocalendar().week:02d}"
1459
- if kind == "weekly"
1460
- else target_day.strftime("%Y-%m")
1461
- )
1462
- syntheses = _load_period_syntheses(target_date, window_days=window_days)
1463
- extractions = _load_period_extractions(target_date, window_days=window_days)
1464
- applied_logs = _load_period_applied_logs(target_date, window_days=window_days)
1465
- if not any(item.get("date") == target_date for item in syntheses):
1466
- syntheses.append(synthesis)
1467
-
1468
- mood_scores = []
1469
- trust_scores = []
1470
- total_corrections = 0
1471
- pattern_counter: Counter[str] = Counter()
1472
- agenda_counter: Counter[str] = Counter()
1473
- for item in syntheses:
1474
- mood = item.get("emotional_day", {}).get("mood_score")
1475
- if isinstance(mood, (int, float)):
1476
- mood_scores.append(float(mood))
1477
- trust = item.get("trust_calibration", {}).get("score")
1478
- if isinstance(trust, (int, float)):
1479
- trust_scores.append(float(trust))
1480
- total_corrections += int(item.get("productivity_day", {}).get("total_corrections", 0) or 0)
1481
- for pattern in item.get("cross_session_patterns", []) or []:
1482
- text = str(pattern.get("pattern", "") or "").strip()
1483
- if text:
1484
- pattern_counter[text] += 1
1485
- for agenda in item.get("morning_agenda", []) or []:
1486
- title = str(agenda.get("title", "") or "").strip()
1487
- if title:
1488
- agenda_counter[title] += 1
1489
-
1490
- top_projects = _project_weighting_window(target_date, window_days=window_days)
1491
- avg_mood = round(sum(mood_scores) / len(mood_scores), 3) if mood_scores else None
1492
- avg_trust = round(sum(trust_scores) / len(trust_scores), 1) if trust_scores else None
1493
- top_patterns = [
1494
- {"pattern": pattern, "count": count}
1495
- for pattern, count in pattern_counter.most_common(6)
1496
- ]
1497
- recurring_agenda = [
1498
- {"title": title, "count": count}
1499
- for title, count in agenda_counter.most_common(6)
1500
- ]
1501
- protocol_summary = _aggregate_protocol_summary(extractions)
1502
- delivery_metrics = _aggregate_delivery_metrics(applied_logs)
1503
- followup_deduplication = _followup_deduplication_metrics()
1504
- learning_consolidation = _learning_consolidation_metrics()
1505
- previous_summary = _load_previous_period_summary(kind, label)
1506
- project_pulse = _build_project_pulse(top_projects, previous_summary)
1507
-
1508
- summary_parts = [f"{len(syntheses)} Deep Sleep run(s)"]
1509
- if top_projects:
1510
- summary_parts.append(f"top focus: {top_projects[0]['project']}")
1511
- if top_patterns:
1512
- summary_parts.append(f"recurring pattern: {top_patterns[0]['pattern']}")
1513
- if avg_trust is not None:
1514
- summary_parts.append(f"avg trust {avg_trust:.1f}")
1515
- if protocol_summary.get("overall_compliance_pct") is not None:
1516
- summary_parts.append(f"protocol {protocol_summary['overall_compliance_pct']:.1f}%")
1517
- summary = " | ".join(summary_parts)
1518
-
1519
- period_summary = {
1520
- "kind": kind,
1521
- "label": label,
1522
- "window_days": window_days,
1523
- "window_start": window_start,
1524
- "window_end": target_date,
1525
- "generated_at": datetime.now().isoformat(),
1526
- "daily_syntheses": len(syntheses),
1527
- "avg_mood_score": avg_mood,
1528
- "avg_trust_score": avg_trust,
1529
- "total_corrections": total_corrections,
1530
- "top_projects": top_projects,
1531
- "project_pulse": project_pulse,
1532
- "top_patterns": top_patterns,
1533
- "recurring_agenda": recurring_agenda,
1534
- "protocol_summary": protocol_summary,
1535
- "delivery_metrics": delivery_metrics,
1536
- "followup_deduplication": followup_deduplication,
1537
- "learning_consolidation": learning_consolidation,
1538
- "summary": summary,
1539
- }
1540
- period_summary["trend"] = _build_period_trend(period_summary, previous_summary)
1541
- return period_summary
1542
-
1543
- def _render_period_summary_markdown(summary: dict) -> str:
1544
- lines = [
1545
- f"# {summary.get('kind', 'period').title()} Deep Sleep Summary — {summary.get('label', '')}",
1546
- "",
1547
- f"- Window: {summary.get('window_start', '')} -> {summary.get('window_end', '')}",
1548
- f"- Deep Sleep runs: {summary.get('daily_syntheses', 0)}",
1549
- ]
1550
- if summary.get("avg_mood_score") is not None:
1551
- lines.append(f"- Avg mood score: {summary['avg_mood_score']:.2f}")
1552
- if summary.get("avg_trust_score") is not None:
1553
- lines.append(f"- Avg trust score: {summary['avg_trust_score']:.1f}")
1554
- lines.append(f"- Total corrections: {summary.get('total_corrections', 0)}")
1555
- lines.append("")
1556
- if summary.get("summary"):
1557
- lines.append(f"> {summary['summary']}")
1558
- lines.append("")
1559
-
1560
- protocol_summary = summary.get("protocol_summary") or {}
1561
- if protocol_summary:
1562
- lines.append("## Protocol Compliance")
1563
- lines.append("")
1564
- overall = protocol_summary.get("overall_compliance_pct")
1565
- if overall is not None:
1566
- lines.append(f"- Overall compliance: {overall:.1f}%")
1567
- guard = protocol_summary.get("guard_check", {})
1568
- heartbeat = protocol_summary.get("heartbeat", {})
1569
- change_log = protocol_summary.get("change_log", {})
1570
- if guard:
1571
- lines.append(
1572
- f"- guard_check: {guard.get('executed', 0)}/{guard.get('required', 0)}"
1573
- + (f" ({guard['compliance_pct']:.1f}%)" if guard.get("compliance_pct") is not None else "")
1574
- )
1575
- if heartbeat:
1576
- lines.append(
1577
- f"- heartbeat with context: {heartbeat.get('with_context', 0)}/{heartbeat.get('total', 0)}"
1578
- + (f" ({heartbeat['compliance_pct']:.1f}%)" if heartbeat.get("compliance_pct") is not None else "")
1579
- )
1580
- if change_log:
1581
- lines.append(
1582
- f"- change_log after edits: {change_log.get('logged', 0)}/{change_log.get('edits', 0)}"
1583
- + (f" ({change_log['compliance_pct']:.1f}%)" if change_log.get("compliance_pct") is not None else "")
1584
- )
1585
- lines.append("")
1586
-
1587
- delivery_metrics = summary.get("delivery_metrics") or {}
1588
- if delivery_metrics:
1589
- lines.append("## Loop Output")
1590
- lines.append("")
1591
- lines.append(f"- Applied actions: {delivery_metrics.get('applied_actions', 0)}")
1592
- lines.append(f"- Deferred actions: {delivery_metrics.get('deferred_actions', 0)}")
1593
- lines.append(f"- Dedupe skips: {delivery_metrics.get('skipped_dedupe', 0)}")
1594
- lines.append(f"- Engineering followups: {delivery_metrics.get('engineering_followups', 0)}")
1595
- if delivery_metrics.get("dedupe_rate_pct") is not None:
1596
- lines.append(f"- Dedupe rate: {delivery_metrics['dedupe_rate_pct']:.1f}%")
1597
- if delivery_metrics.get("error_rate_pct") is not None:
1598
- lines.append(f"- Error rate: {delivery_metrics['error_rate_pct']:.1f}%")
1599
- lines.append(f"- Followup dedupe matches: {delivery_metrics.get('followup_dedupe_matches', 0)}")
1600
- lines.append(f"- Learning reinforcements: {delivery_metrics.get('learning_reinforcements', 0)}")
1601
- lines.append(f"- Learning duplicate skips: {delivery_metrics.get('learning_duplicate_skips', 0)}")
1602
- lines.append(f"- Learning contradiction reviews: {delivery_metrics.get('learning_contradiction_reviews', 0)}")
1603
- lines.append("")
1604
-
1605
- followup_deduplication = summary.get("followup_deduplication") or {}
1606
- learning_consolidation = summary.get("learning_consolidation") or {}
1607
- if followup_deduplication or learning_consolidation:
1608
- lines.append("## Prevention Quality")
1609
- lines.append("")
1610
- if followup_deduplication:
1611
- lines.append(f"- Open followups: {followup_deduplication.get('open_followups', 0)}")
1612
- lines.append(f"- Duplicate open followups: {followup_deduplication.get('duplicate_open_followups', 0)}")
1613
- if followup_deduplication.get("duplicate_rate_pct") is not None:
1614
- lines.append(f"- Duplicate followup rate: {followup_deduplication['duplicate_rate_pct']:.1f}%")
1615
- if learning_consolidation:
1616
- lines.append(f"- Active learnings: {learning_consolidation.get('active_learnings', 0)}")
1617
- lines.append(f"- Learning noise pressure: {learning_consolidation.get('noise_pressure', 0)}")
1618
- if learning_consolidation.get("noise_rate_pct") is not None:
1619
- lines.append(f"- Learning noise rate: {learning_consolidation['noise_rate_pct']:.1f}%")
1620
- lines.append("")
1621
-
1622
- if summary.get("top_projects"):
1623
- lines.append("## Top Projects")
1624
- lines.append("")
1625
- for item in summary["top_projects"][:5]:
1626
- lines.append(f"- **{item['project']}** — score {item['score']}")
1627
- if item.get("reasons"):
1628
- lines.append(f" Reasons: {', '.join(item['reasons'])}")
1629
- lines.append("")
1630
-
1631
- if summary.get("project_pulse"):
1632
- lines.append("## Project Pulse")
1633
- lines.append("")
1634
- for item in summary["project_pulse"][:5]:
1635
- delta = item.get("delta_vs_previous")
1636
- delta_label = ""
1637
- if isinstance(delta, (int, float)):
1638
- delta_label = f" | Δ {delta:+.2f}"
1639
- lines.append(
1640
- f"- **{item['project']}** — {item.get('status', 'watch')} / {item.get('trend', 'steady')}"
1641
- f" | score {item.get('score', 0)}{delta_label}"
1642
- )
1643
- lines.append("")
1644
-
1645
- if summary.get("top_patterns"):
1646
- lines.append("## Recurring Patterns")
1647
- lines.append("")
1648
- for item in summary["top_patterns"][:5]:
1649
- lines.append(f"- {item['pattern']} ({item['count']}x)")
1650
- lines.append("")
1651
-
1652
- if summary.get("recurring_agenda"):
1653
- lines.append("## Recurring Agenda")
1654
- lines.append("")
1655
- for item in summary["recurring_agenda"][:5]:
1656
- lines.append(f"- {item['title']} ({item['count']}x)")
1657
- lines.append("")
1658
-
1659
- trend = summary.get("trend") or {}
1660
- if trend.get("has_previous"):
1661
- lines.append("## Trend vs Previous")
1662
- lines.append("")
1663
- if trend.get("avg_mood_delta") is not None:
1664
- lines.append(f"- Mood delta: {trend['avg_mood_delta']:+.3f}")
1665
- if trend.get("avg_trust_delta") is not None:
1666
- lines.append(f"- Trust delta: {trend['avg_trust_delta']:+.1f}")
1667
- if trend.get("total_corrections_delta") is not None:
1668
- lines.append(f"- Corrections delta: {trend['total_corrections_delta']:+d}")
1669
- if trend.get("protocol_compliance_delta") is not None:
1670
- lines.append(f"- Protocol delta: {trend['protocol_compliance_delta']:+.1f}%")
1671
- if trend.get("followup_duplicate_open_delta") is not None:
1672
- lines.append(f"- Duplicate followups delta: {trend['followup_duplicate_open_delta']:+d}")
1673
- if trend.get("followup_duplicate_rate_delta") is not None:
1674
- lines.append(f"- Duplicate followup rate delta: {trend['followup_duplicate_rate_delta']:+.1f}%")
1675
- if trend.get("learning_noise_delta") is not None:
1676
- lines.append(f"- Learning noise delta: {trend['learning_noise_delta']:+d}")
1677
- if trend.get("learning_noise_rate_delta") is not None:
1678
- lines.append(f"- Learning noise rate delta: {trend['learning_noise_rate_delta']:+.1f}%")
1679
- lines.append("")
1680
-
1681
- return "\n".join(lines).rstrip() + "\n"
1682
-
1683
-
1684
- def write_periodic_summaries(target_date: str, synthesis: dict) -> dict:
1685
- outputs: dict[str, str] = {}
1686
- for kind, window_days in (("weekly", 7), ("monthly", 30)):
1687
- summary = _build_period_summary(target_date, synthesis, kind=kind, window_days=window_days)
1688
- label = summary["label"]
1689
- json_path = DEEP_SLEEP_DIR / f"{label}-{kind}-summary.json"
1690
- md_path = DEEP_SLEEP_DIR / f"{label}-{kind}-summary.md"
1691
- json_path.write_text(json.dumps(summary, indent=2, ensure_ascii=False))
1692
- md_path.write_text(_render_period_summary_markdown(summary), encoding="utf-8")
1693
- outputs[f"{kind}_json"] = str(json_path)
1694
- outputs[f"{kind}_markdown"] = str(md_path)
1695
- return outputs
1696
-
1697
-
1698
- def generate_session_tone(synthesis: dict, target_date: str) -> dict:
1699
- """Generate emotional tone guidance for next session startup.
1700
-
1701
- This is the 'psychology' layer — tells NEXO how to behave emotionally
1702
- based on yesterday's analysis. Read by startup hook to adapt greeting.
1703
- """
1704
- emotional = synthesis.get("emotional_day", {})
1705
- productivity = synthesis.get("productivity_day", {})
1706
- patterns = synthesis.get("cross_session_patterns", [])
1707
- abandoned = synthesis.get("abandoned_projects", [])
1708
- mood_score = emotional.get("mood_score", 0.5)
1709
- corrections = productivity.get("total_corrections", 0)
1710
- proactivity = productivity.get("overall_proactivity", "mixed")
1711
-
1712
- tone = {
1713
- "date": target_date,
1714
- "mood_yesterday": mood_score,
1715
- "approach": "neutral",
1716
- "opening_style": "normal",
1717
- "acknowledge_mistakes": False,
1718
- "mistakes_to_own": [],
1719
- "motivational": False,
1720
- "reduce_load": False,
1721
- "suggested_greeting_context": "",
1722
- }
1723
-
1724
- # Agent made many mistakes yesterday → own it, apologize, show learning
1725
- if corrections > 5:
1726
- tone["acknowledge_mistakes"] = True
1727
- tone["opening_style"] = "humble"
1728
- # Collect what went wrong
1729
- high_patterns = [p["pattern"] for p in patterns if p.get("severity") == "high"]
1730
- tone["mistakes_to_own"] = high_patterns[:3]
1731
- tone["suggested_greeting_context"] = (
1732
- f"Yesterday the agent needed {corrections} corrections. "
1733
- f"Acknowledge specific mistakes, show what was learned, "
1734
- f"and demonstrate improvement from the first interaction."
1735
- )
1736
-
1737
- # User had a bad day → supportive, less pressure
1738
- if mood_score < 0.4:
1739
- tone["approach"] = "supportive"
1740
- tone["motivational"] = True
1741
- tone["reduce_load"] = True
1742
- frustration_triggers = emotional.get("recurring_triggers", {}).get("frustration", [])
1743
- tone["suggested_greeting_context"] += (
1744
- f" User had a tough day (mood {mood_score:.0%}). "
1745
- f"Be supportive, acknowledge the difficulty, and propose a lighter start. "
1746
- f"Avoid these frustration triggers: {', '.join(frustration_triggers[:3])}."
1747
- )
1748
-
1749
- # User had a great day → reinforce, push momentum
1750
- elif mood_score > 0.7:
1751
- tone["approach"] = "energetic"
1752
- tone["motivational"] = True
1753
- flow_triggers = emotional.get("recurring_triggers", {}).get("flow", [])
1754
- tone["suggested_greeting_context"] += (
1755
- f" User had a great day (mood {mood_score:.0%}). "
1756
- f"Reinforce the momentum. Reference yesterday's wins. "
1757
- f"Propose ambitious next steps. Flow triggers: {', '.join(flow_triggers[:3])}."
1758
- )
1759
-
1760
- # Agent was too reactive → be proactive today
1761
- if proactivity == "reactive":
1762
- tone["approach"] = "proactive"
1763
- tone["suggested_greeting_context"] += (
1764
- " Agent was too reactive yesterday — today lead with proposals, "
1765
- "don't wait for instructions."
1766
- )
1767
-
1768
- # There are abandoned projects → gently bring up
1769
- if abandoned:
1770
- truly_abandoned = [a for a in abandoned if not a.get("has_followup")]
1771
- if truly_abandoned:
1772
- tone["suggested_greeting_context"] += (
1773
- f" {len(truly_abandoned)} project(s) were started but not finished. "
1774
- f"Offer to pick them up today without pressure."
1775
- )
1776
-
1777
- return tone
1778
-
1779
-
1780
- def write_morning_briefing(target_date: str, synthesis: dict) -> Path:
1781
- """Write the morning briefing file from synthesis data."""
1782
- briefing_dir = OPERATIONS_DIR
1783
- briefing_dir.mkdir(parents=True, exist_ok=True)
1784
- briefing_file = briefing_dir / "morning-briefing.md"
1785
-
1786
- # Generate session tone for startup
1787
- tone = generate_session_tone(synthesis, target_date)
1788
- tone_file = briefing_dir / "session-tone.json"
1789
- tone_file.write_text(json.dumps(tone, indent=2, ensure_ascii=False))
1790
-
1791
- lines = [
1792
- f"# Morning Briefing -- {target_date}",
1793
- f"_Generated by Deep Sleep at {datetime.now().strftime('%H:%M')}_",
1794
- ""
1795
- ]
1796
-
1797
- # Summary
1798
- summary = synthesis.get("summary", "")
1799
- if summary:
1800
- lines.append(f"> {summary}")
1801
- lines.append("")
1802
-
1803
- # Morning agenda
1804
- agenda = synthesis.get("morning_agenda", [])
1805
- if agenda:
1806
- lines.append("## Agenda")
1807
- lines.append("")
1808
- for item in agenda:
1809
- priority = item.get("priority", "?")
1810
- title = item.get("title", "")
1811
- desc = item.get("description", "")
1812
- item_type = item.get("type", "")
1813
- lines.append(f"### {priority}. {title}")
1814
- if item_type:
1815
- lines.append(f"_Type: {item_type}_")
1816
- lines.append(desc)
1817
- if item.get("context"):
1818
- lines.append(f"\n> {item['context']}")
1819
- lines.append("")
1820
-
1821
- top_impact = _top_followups_by_impact(limit=5)
1822
- if top_impact:
1823
- lines.append("## Top by Impact")
1824
- lines.append("")
1825
- for item in top_impact:
1826
- impact = float(item.get("impact_score") or 0.0)
1827
- due = item.get("date") or "—"
1828
- reason = item.get("impact_reasoning") or "impact score persisted without explicit reasoning"
1829
- lines.append(
1830
- f"- **{item.get('id', '?')}** [{impact:.1f}] {item.get('description', '')} "
1831
- f"(due {due}, priority {item.get('priority') or 'medium'})"
1832
- )
1833
- lines.append(f" Why: {reason}")
1834
- lines.append("")
1835
-
1836
- impact_summary = _read_impact_summary()
1837
- top_changes = [item for item in (impact_summary.get("top_changes") or []) if abs(float(item.get("delta") or 0.0)) >= 1.0]
1838
- if top_changes:
1839
- lines.append("## Impact Queue Changes")
1840
- lines.append("")
1841
- for item in top_changes[:5]:
1842
- delta = float(item.get("delta") or 0.0)
1843
- direction = "+" if delta >= 0 else ""
1844
- lines.append(
1845
- f"- **{item.get('id', '?')}** {direction}{delta:.1f} -> {float(item.get('impact_score') or 0.0):.1f} "
1846
- f"({item.get('impact_reasoning') or 'score recalculated'})"
1847
- )
1848
- lines.append("")
1849
-
1850
- # Emotional day
1851
- emotional = synthesis.get("emotional_day", {})
1852
- if emotional:
1853
- mood_score = emotional.get("mood_score", 0.5)
1854
- mood_bar = "🟢" if mood_score >= 0.7 else "🟡" if mood_score >= 0.4 else "🔴"
1855
- lines.append(f"## Mood {mood_bar} {mood_score:.0%}")
1856
- lines.append("")
1857
- if emotional.get("mood_arc"):
1858
- lines.append(emotional["mood_arc"])
1859
- triggers = emotional.get("recurring_triggers", {})
1860
- if triggers.get("frustration"):
1861
- lines.append(f"**Frustration triggers:** {', '.join(triggers['frustration'])}")
1862
- if triggers.get("flow"):
1863
- lines.append(f"**Flow triggers:** {', '.join(triggers['flow'])}")
1864
- if emotional.get("calibration_recommendation"):
1865
- lines.append(f"\n💡 **Recommendation:** {emotional['calibration_recommendation']}")
1866
- lines.append("")
1867
-
1868
- # Productivity
1869
- productivity = synthesis.get("productivity_day", {})
1870
- if productivity:
1871
- lines.append("## Productivity")
1872
- lines.append("")
1873
- lines.append(f"- Corrections needed: {productivity.get('total_corrections', '?')}")
1874
- lines.append(f"- Proactivity: {productivity.get('overall_proactivity', '?')}")
1875
- if productivity.get("tool_insights"):
1876
- lines.append(f"- Tools: {productivity['tool_insights']}")
1877
- inefficiencies = productivity.get("systemic_inefficiencies", [])
1878
- if inefficiencies:
1879
- lines.append(f"- Issues: {', '.join(inefficiencies)}")
1880
- lines.append("")
1881
-
1882
- # Abandoned projects
1883
- abandoned = synthesis.get("abandoned_projects", [])
1884
- if abandoned:
1885
- truly_abandoned = [a for a in abandoned if not a.get("has_followup")]
1886
- if truly_abandoned:
1887
- lines.append("## Abandoned Projects")
1888
- lines.append("")
1889
- for a in truly_abandoned:
1890
- lines.append(f"- {a.get('description', '?')}")
1891
- if a.get("recommendation"):
1892
- lines.append(f" → {a['recommendation']}")
1893
- lines.append("")
1894
-
1895
- # Cross-session patterns
1896
- patterns = synthesis.get("cross_session_patterns", [])
1897
- if patterns:
1898
- lines.append("## Patterns Detected")
1899
- lines.append("")
1900
- for p in patterns:
1901
- severity = p.get("severity", "")
1902
- lines.append(f"- **[{severity}]** {p.get('pattern', '')}")
1903
- sessions = p.get("sessions", [])
1904
- if sessions:
1905
- lines.append(f" Sessions: {', '.join(sessions)}")
1906
- lines.append("")
1907
-
1908
- # Draft actions (things that need user decision)
1909
- draft_actions = [
1910
- a for a in synthesis.get("actions", [])
1911
- if a.get("action_class") == "draft_for_morning"
1912
- ]
1913
- if draft_actions:
1914
- lines.append("## Items for Review")
1915
- lines.append("")
1916
- for a in draft_actions:
1917
- confidence = a.get("confidence", 0)
1918
- lines.append(f"- **{a.get('action_type', '')}** (confidence: {confidence:.0%})")
1919
- content = a.get("content", {})
1920
- if isinstance(content, dict):
1921
- title = content.get("title", content.get("description", ""))
1922
- lines.append(f" {title}")
1923
- evidence = a.get("evidence", [])
1924
- if evidence and isinstance(evidence, list):
1925
- for ev in evidence[:2]:
1926
- quote = ev.get("quote", "")
1927
- if quote:
1928
- lines.append(f' > "{quote}"')
1929
- lines.append("")
1930
-
1931
- # Context packets
1932
- packets = synthesis.get("context_packets", [])
1933
- if packets:
1934
- lines.append("## Context for Today's Work")
1935
- lines.append("")
1936
- for p in packets:
1937
- lines.append(f"### {p.get('topic', 'Unknown')}")
1938
- lines.append(f"**Last state:** {p.get('last_state', 'N/A')}")
1939
- files = p.get("key_files", [])
1940
- if files:
1941
- lines.append(f"**Files:** {', '.join(files)}")
1942
- questions = p.get("open_questions", [])
1943
- if questions:
1944
- lines.append("**Open questions:**")
1945
- for q in questions:
1946
- lines.append(f" - {q}")
1947
- lines.append("")
1948
-
1949
- briefing_file.write_text("\n".join(lines), encoding="utf-8")
1950
- return briefing_file
1951
-
1952
-
1953
- def apply_action(action: dict, run_id: str) -> dict:
1954
- """Apply a single action and return the result log."""
1955
- action_type = action.get("action_type", "")
1956
- action_class = action.get("action_class", "")
1957
- content = action.get("content", {})
1958
- dedupe_key = action.get("dedupe_key", "")
1959
-
1960
- # Content fingerprint, not security-sensitive.
1961
- applied_id = f"{run_id}-{hashlib.md5(dedupe_key.encode(), usedforsecurity=False).hexdigest()[:8]}"
1962
-
1963
- log_entry = {
1964
- "applied_action_id": applied_id,
1965
- "action_type": action_type,
1966
- "action_class": action_class,
1967
- "dedupe_key": dedupe_key,
1968
- "timestamp": datetime.now().isoformat(),
1969
- "status": "skipped",
1970
- "details": {}
1971
- }
1972
-
1973
- # Only auto_apply actions get executed
1974
- if action_class != "auto_apply":
1975
- log_entry["status"] = "deferred_to_morning"
1976
- log_entry["details"] = {"reason": "action_class is not auto_apply"}
1977
- return log_entry
1978
-
1979
- if not isinstance(content, dict):
1980
- log_entry["status"] = "error"
1981
- log_entry["details"] = {"error": "content is not a dict"}
1982
- return log_entry
1983
-
1984
- if action_type == "learning_add":
1985
- result = add_learning(
1986
- category=content.get("category", "process"),
1987
- title=content.get("title", "Deep Sleep finding"),
1988
- content=content.get("content", content.get("description", ""))
1989
- )
1990
- log_entry["status"] = "applied" if result.get("success") else "error"
1991
- log_entry["details"] = result
1992
-
1993
- elif action_type == "followup_create":
1994
- result = create_followup(
1995
- description=content.get("description", content.get("title", "")),
1996
- date=content.get("date", ""),
1997
- reasoning_note=content.get("reasoning", content.get("why", "")),
1998
- )
1999
- log_entry["status"] = "applied" if result.get("success") else "error"
2000
- log_entry["details"] = result
2001
-
2002
- elif action_type == "skill_create":
2003
- result = create_skill(content)
2004
- log_entry["status"] = "applied" if result.get("success") else "error"
2005
- log_entry["details"] = result
2006
-
2007
- elif action_type == "morning_briefing_item":
2008
- # These are included in the briefing file, not applied separately
2009
- log_entry["status"] = "included_in_briefing"
2010
-
2011
- elif action_type == "code_change":
2012
- # Closes Fase 2 item 5: deep sleep can now hand a concrete code
2013
- # change to the evolution apply pipeline. We do NOT touch any source
2014
- # file directly here — that would bypass the sandbox/snapshot/rollback
2015
- # safety net that lives in nexo-evolution-run.py:execute_auto_proposal.
2016
- # Instead we persist the proposal to evolution_log with status=accepted
2017
- # and a proposal_payload, so the next evolution cycle picks it up via
2018
- # _apply_accepted_proposals (added in Fase 2 item 1, m38).
2019
- result = apply_code_change_action(content, dedupe_key)
2020
- log_entry["status"] = "applied" if result.get("success") else "error"
2021
- log_entry["details"] = result
2022
-
2023
- else:
2024
- log_entry["status"] = "unknown_type"
2025
- log_entry["details"] = {"error": f"Unknown action_type: {action_type}"}
2026
-
2027
- return log_entry
2028
-
2029
-
2030
- def apply_code_change_action(content: dict, dedupe_key: str) -> dict:
2031
- """Stage a code_change finding into evolution_log for the next cycle.
2032
-
2033
- Required content keys:
2034
- dimension: evolution dimension (reliability/safety/etc)
2035
- action: short human-readable description of what to do
2036
- reasoning: why deep sleep proposed this change
2037
- changes: list of {file, operation, search?, content} entries
2038
- matching nexo-evolution-run.py:apply_change semantics
2039
-
2040
- Optional:
2041
- scope: 'local' | 'public' (default 'local')
2042
- classification: 'propose' | 'auto' (default 'propose')
2043
-
2044
- Idempotent: if a row with the same dedupe_key already exists in
2045
- evolution_log (matched against proposal_payload extras.dedupe_key), the
2046
- function returns success without inserting a duplicate.
2047
-
2048
- Returns: {success: bool, evolution_log_id: int|None, reason: str|None,
2049
- skipped_duplicate: bool}
2050
- """
2051
- if not isinstance(content, dict):
2052
- return {"success": False, "reason": "content must be a dict"}
2053
-
2054
- dimension = (content.get("dimension") or "").strip()
2055
- action_text = (content.get("action") or content.get("title") or "").strip()
2056
- reasoning = (content.get("reasoning") or content.get("why") or "").strip()
2057
- changes = content.get("changes") or []
2058
- scope = (content.get("scope") or "local").strip() or "local"
2059
- classification = (content.get("classification") or "propose").strip() or "propose"
2060
-
2061
- if not dimension:
2062
- return {"success": False, "reason": "missing dimension"}
2063
- if not action_text:
2064
- return {"success": False, "reason": "missing action"}
2065
- if not isinstance(changes, list) or not changes:
2066
- return {"success": False, "reason": "missing or empty changes array"}
2067
- for idx, change in enumerate(changes):
2068
- if not isinstance(change, dict):
2069
- return {"success": False, "reason": f"changes[{idx}] is not a dict"}
2070
- if not change.get("file"):
2071
- return {"success": False, "reason": f"changes[{idx}] missing file"}
2072
- if not change.get("operation"):
2073
- return {"success": False, "reason": f"changes[{idx}] missing operation"}
2074
-
2075
- payload = {
2076
- "classification": classification,
2077
- "dimension": dimension,
2078
- "action": action_text,
2079
- "reasoning": reasoning,
2080
- "scope": scope,
2081
- "changes": changes,
2082
- "extras": {
2083
- "source": "deep_sleep",
2084
- "dedupe_key": dedupe_key,
2085
- },
2086
- }
2087
- payload_json = json.dumps(payload, ensure_ascii=False)
2088
-
2089
- try:
2090
- conn = sqlite3.connect(str(NEXO_DB), timeout=10)
2091
- except Exception as e:
2092
- return {"success": False, "reason": f"cannot open nexo.db: {e}"}
2093
-
2094
- try:
2095
- # Idempotency: skip if any prior row already staged this dedupe_key.
2096
- existing = conn.execute(
2097
- "SELECT id FROM evolution_log "
2098
- "WHERE proposal_payload IS NOT NULL "
2099
- " AND proposal_payload LIKE ? "
2100
- "ORDER BY id DESC LIMIT 1",
2101
- (f'%"dedupe_key": "{dedupe_key}"%',),
2102
- ).fetchone()
2103
- if existing:
2104
- return {
2105
- "success": True,
2106
- "skipped_duplicate": True,
2107
- "evolution_log_id": int(existing[0]),
2108
- "reason": "already staged in evolution_log",
2109
- }
2110
-
2111
- cur = conn.execute(
2112
- "INSERT INTO evolution_log "
2113
- "(cycle_number, dimension, proposal, classification, reasoning, status, proposal_payload) "
2114
- "VALUES (?, ?, ?, ?, ?, ?, ?)",
2115
- (
2116
- 0, # cycle_number 0 = staged from outside the regular cycle
2117
- dimension,
2118
- action_text,
2119
- classification,
2120
- reasoning or "deep sleep proposed code change",
2121
- "accepted",
2122
- payload_json,
2123
- ),
2124
- )
2125
- try:
2126
- conn.commit()
2127
- except Exception:
2128
- pass
2129
- return {
2130
- "success": True,
2131
- "skipped_duplicate": False,
2132
- "evolution_log_id": int(cur.lastrowid),
2133
- }
2134
- except Exception as e:
2135
- return {"success": False, "reason": f"insert failed: {e}"}
2136
- finally:
2137
- try:
2138
- conn.close()
2139
- except Exception:
2140
- pass
2141
-
2142
-
2143
- def main():
2144
- target_date = sys.argv[1] if len(sys.argv) > 1 else datetime.now().strftime("%Y-%m-%d")
2145
-
2146
- synthesis_file = DEEP_SLEEP_DIR / f"{target_date}-synthesis.json"
2147
- if not synthesis_file.exists():
2148
- print(f"[apply] No synthesis file for {target_date}. Run synthesize.py first.")
2149
- sys.exit(1)
2150
-
2151
- with open(synthesis_file) as f:
2152
- synthesis = json.load(f)
2153
-
2154
- run_id = generate_run_id(target_date)
2155
- actions = synthesis.get("actions", [])
2156
- print(f"[apply] Phase 4: Applying findings for {target_date} (run: {run_id})")
2157
- print(f"[apply] Actions to process: {len(actions)}")
2158
-
2159
- # Load recent dedupe keys for idempotency
2160
- existing_keys = load_recent_dedupe_keys(target_date)
2161
- print(f"[apply] Existing dedupe keys (7d): {len(existing_keys)}")
2162
-
2163
- # Backup databases before mutations
2164
- auto_apply_count = sum(1 for a in actions if a.get("action_class") == "auto_apply")
2165
- if auto_apply_count > 0:
2166
- print("[apply] Creating database backups...")
2167
- nexo_backup = backup_db(NEXO_DB, run_id)
2168
- cog_backup = backup_db(COGNITIVE_DB, run_id)
2169
- if nexo_backup:
2170
- print(f" Backup: {nexo_backup}")
2171
- if cog_backup:
2172
- print(f" Backup: {cog_backup}")
2173
-
2174
- # Process actions
2175
- applied_actions = []
2176
- stats = {"applied": 0, "deferred": 0, "skipped_dedupe": 0, "errors": 0}
2177
-
2178
- for action in actions:
2179
- dedupe_key = action.get("dedupe_key", "")
2180
-
2181
- # Idempotency check
2182
- if dedupe_key and dedupe_key in existing_keys:
2183
- applied_actions.append({
2184
- "applied_action_id": f"{run_id}-deduped",
2185
- "action_type": action.get("action_type"),
2186
- "dedupe_key": dedupe_key,
2187
- "status": "skipped_dedupe",
2188
- "timestamp": datetime.now().isoformat()
2189
- })
2190
- stats["skipped_dedupe"] += 1
2191
- continue
2192
-
2193
- result = apply_action(action, run_id)
2194
- applied_actions.append(result)
2195
-
2196
- if result["status"] == "applied":
2197
- stats["applied"] += 1
2198
- print(f" Applied: {action.get('action_type')} -- {action.get('content', {}).get('title', '')[:50]}")
2199
- elif result["status"] == "deferred_to_morning":
2200
- stats["deferred"] += 1
2201
- elif result["status"] == "error":
2202
- stats["errors"] += 1
2203
- print(f" Error: {result.get('details', {}).get('error', 'unknown')}", file=sys.stderr)
2204
-
2205
- # Update mood in calibration.json
2206
- print("[apply] Updating mood/calibration...")
2207
- mood_result = update_calibration_mood(synthesis)
2208
- if mood_result.get("success"):
2209
- stats["applied"] += 1
2210
- print(f" Mood score: {mood_result.get('mood_score', '?')}")
2211
- else:
2212
- print(f" Mood skip: {mood_result.get('error', '?')}")
2213
-
2214
- # Calibrate trust score (authoritative daily score from Deep Sleep)
2215
- print("[apply] Calibrating trust score...")
2216
- trust_result = calibrate_trust_score(synthesis, target_date)
2217
- if trust_result.get("success"):
2218
- stats["applied"] += 1
2219
- print(f" Trust: {trust_result['old_score']:.0f} → {trust_result['new_score']:.0f} (Δ{trust_result['delta']:+.0f}, {trust_result['trend']})")
2220
- else:
2221
- print(f" Trust skip: {trust_result.get('error', '?')}")
2222
-
2223
- # Create skills from synthesis
2224
- skills_data = synthesis.get("skills", [])
2225
- if skills_data:
2226
- print(f"[apply] Creating {len(skills_data)} skill(s)...")
2227
- for skill_data in skills_data:
2228
- if skill_data.get("confidence", 0) < 0.7:
2229
- continue
2230
- if skill_data.get("merge_with"):
2231
- print(f" Skip {skill_data.get('id', '?')}: merge candidate (needs runtime merge)")
2232
- continue
2233
- result = create_skill(skill_data)
2234
- if result.get("success"):
2235
- stats["applied"] += 1
2236
- print(f" Skill created: {result['id']} — {result.get('name', '')[:50]}")
2237
- elif "already exists" in result.get("error", ""):
2238
- stats["skipped_dedupe"] += 1
2239
- else:
2240
- stats["errors"] += 1
2241
- print(f" Skill error: {result.get('error', 'unknown')}", file=sys.stderr)
2242
-
2243
- evolution_candidates = synthesis.get("skill_evolution_candidates", [])
2244
- if evolution_candidates:
2245
- evolution_file = DEEP_SLEEP_DIR / f"{target_date}-skill-evolution-candidates.json"
2246
- with open(evolution_file, "w") as f:
2247
- json.dump(evolution_candidates, f, indent=2, ensure_ascii=False)
2248
- print(f" Skill evolution candidates: {evolution_file}")
2249
-
2250
- try:
2251
- from skills_runtime import auto_promote_skill_evolution
2252
-
2253
- promotion_result = auto_promote_skill_evolution()
2254
- if promotion_result.get("promoted"):
2255
- promotion_file = DEEP_SLEEP_DIR / f"{target_date}-skill-autopromotions.json"
2256
- with open(promotion_file, "w") as f:
2257
- json.dump(promotion_result, f, indent=2, ensure_ascii=False)
2258
- stats["applied"] += len(promotion_result["promoted"])
2259
- print(f" Skill autopromotions: {len(promotion_result['promoted'])} → {promotion_file}")
2260
- except Exception as e:
2261
- print(f" Skill autopromotion error: {e}", file=sys.stderr)
2262
-
2263
- # Apply drive synthesis (investigate/dismiss/promote signals)
2264
- drive_synthesis = synthesis.get("drive_synthesis", {})
2265
- if drive_synthesis:
2266
- print("[apply] Processing drive synthesis...")
2267
- try:
2268
- from db import update_drive_signal_status, reinforce_drive_signal
2269
- for item in drive_synthesis.get("investigated", []):
2270
- signal_id = item.get("signal_id")
2271
- action_taken = item.get("action_taken", "acted")
2272
- outcome = item.get("outcome", item.get("finding", ""))
2273
- if signal_id and outcome:
2274
- update_drive_signal_status(signal_id, action_taken, outcome[:500])
2275
- stats["applied"] += 1
2276
- print(f" Drive signal #{signal_id}: {action_taken}")
2277
- for item in drive_synthesis.get("promoted", []):
2278
- signal_id = item.get("signal_id")
2279
- reason = item.get("reason", "promoted by Deep Sleep")
2280
- if signal_id:
2281
- reinforce_drive_signal(signal_id, f"Deep Sleep promotion: {reason}"[:500])
2282
- print(f" Drive signal #{signal_id}: promoted")
2283
- except Exception as e:
2284
- print(f" Drive synthesis error: {e}", file=sys.stderr)
2285
-
2286
- # Create followups for abandoned projects
2287
- abandoned_results = create_abandoned_followups(synthesis)
2288
- for r in abandoned_results:
2289
- if r.get("success"):
2290
- stats["applied"] += 1
2291
- print(f" Abandoned project followup: {r.get('id')}")
2292
-
2293
- # Write morning briefing
2294
- print("[apply] Writing morning briefing...")
2295
- briefing_path = write_morning_briefing(target_date, synthesis)
2296
- print(f" Briefing: {briefing_path}")
2297
-
2298
- print("[apply] Writing weekly/monthly Deep Sleep summaries...")
2299
- periodic_outputs = write_periodic_summaries(target_date, synthesis)
2300
- for label, path in periodic_outputs.items():
2301
- print(f" {label}: {path}")
2302
-
2303
- # Write applied log
2304
- applied_log = {
2305
- "date": target_date,
2306
- "run_id": run_id,
2307
- "applied_at": datetime.now().isoformat(),
2308
- "stats": stats,
2309
- "applied_actions": applied_actions,
2310
- "summary": synthesis.get("summary", ""),
2311
- "periodic_summaries": periodic_outputs,
2312
- }
2313
-
2314
- applied_file = DEEP_SLEEP_DIR / f"{target_date}-applied.json"
2315
- with open(applied_file, "w") as f:
2316
- json.dump(applied_log, f, indent=2, ensure_ascii=False)
2317
-
2318
- print(f"\n[apply] Done.")
2319
- print(f" Applied: {stats['applied']}")
2320
- print(f" Deferred to morning: {stats['deferred']}")
2321
- print(f" Skipped (dedupe): {stats['skipped_dedupe']}")
2322
- print(f" Errors: {stats['errors']}")
2323
- print(f"[apply] Log: {applied_file}")
2324
-
2325
-
2326
- if __name__ == "__main__":
2327
- main()