nexo-brain 2.6.20 → 2.7.0

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.
@@ -17,10 +17,12 @@ Environment variables:
17
17
  import hashlib
18
18
  import json
19
19
  import os
20
+ import re
20
21
  import sqlite3
21
22
  import sys
22
23
  from collections import Counter
23
24
  from datetime import datetime, timedelta
25
+ from difflib import SequenceMatcher
24
26
  from pathlib import Path
25
27
 
26
28
  NEXO_HOME = Path(os.environ.get("NEXO_HOME", str(Path.home() / ".nexo")))
@@ -34,6 +36,36 @@ COGNITIVE_DB = NEXO_HOME / "data" / "cognitive.db"
34
36
  OPERATIONS_DIR = NEXO_HOME / "operations"
35
37
  BACKUP_DIR = DEEP_SLEEP_DIR # backups stored alongside outputs
36
38
 
39
+ STOPWORDS = {
40
+ "the", "a", "an", "and", "or", "but", "with", "for", "from", "into", "onto",
41
+ "that", "this", "these", "those", "have", "has", "had", "will", "would",
42
+ "could", "should", "must", "need", "needs", "your", "their", "there", "here",
43
+ "about", "before", "after", "during", "through", "without", "within", "while",
44
+ "que", "con", "para", "por", "los", "las", "una", "uno", "sobre", "desde",
45
+ "cuando", "como", "pero", "todo", "toda", "cada", "into", "across", "using",
46
+ }
47
+ CONCRETE_ACTION_VERBS = {
48
+ "add", "implement", "create", "write", "build", "introduce", "enforce",
49
+ "automate", "validate", "check", "verify", "guard", "fix", "migrate",
50
+ "review", "reconcile", "pin", "sync", "instrument",
51
+ }
52
+ NEGATION_PATTERNS = (
53
+ "do not", "don't", "never", "avoid", "skip", "without", "forbid", "forbidden",
54
+ "disable", "disabled", "remove", "ban", "bypass",
55
+ )
56
+ CONTRADICTION_PAIRS = (
57
+ ("enable", "disable"),
58
+ ("use", "avoid"),
59
+ ("add", "remove"),
60
+ ("allow", "forbid"),
61
+ ("always", "never"),
62
+ ("before", "after"),
63
+ ("require", "skip"),
64
+ ("validate", "bypass"),
65
+ ("include", "exclude"),
66
+ )
67
+ TABLE_COLUMNS_CACHE: dict[tuple[str, str], set[str]] = {}
68
+
37
69
 
38
70
  def generate_run_id(target_date: str) -> str:
39
71
  """Generate a unique run ID for this execution."""
@@ -75,41 +107,435 @@ def backup_db(db_path: Path, run_id: str) -> Path | None:
75
107
  return None
76
108
 
77
109
 
110
+ def _table_columns(db_path: Path, table: str) -> set[str]:
111
+ cache_key = (str(db_path), table)
112
+ if cache_key in TABLE_COLUMNS_CACHE:
113
+ return TABLE_COLUMNS_CACHE[cache_key]
114
+ if not db_path.exists():
115
+ TABLE_COLUMNS_CACHE[cache_key] = set()
116
+ return set()
117
+ try:
118
+ conn = sqlite3.connect(str(db_path))
119
+ rows = conn.execute(f"PRAGMA table_info({table})").fetchall()
120
+ conn.close()
121
+ except Exception:
122
+ TABLE_COLUMNS_CACHE[cache_key] = set()
123
+ return set()
124
+ cols = {str(row[1]) for row in rows}
125
+ TABLE_COLUMNS_CACHE[cache_key] = cols
126
+ return cols
127
+
128
+
129
+ def _row_dict(row) -> dict:
130
+ if row is None:
131
+ return {}
132
+ if isinstance(row, sqlite3.Row):
133
+ return dict(row)
134
+ return dict(zip(row.keys(), row)) if hasattr(row, "keys") else dict(row)
135
+
136
+
137
+ def _normalize_text(value: str) -> str:
138
+ text = str(value or "").lower()
139
+ text = re.sub(r"https?://\S+", " ", text)
140
+ text = re.sub(r"[^a-z0-9_/\-\s]+", " ", text)
141
+ text = re.sub(r"\s+", " ", text)
142
+ return text.strip()
143
+
144
+
145
+ def _tokenize(value: str) -> list[str]:
146
+ tokens = re.findall(r"[a-z0-9_/-]+", _normalize_text(value))
147
+ return [token for token in tokens if len(token) > 2 and token not in STOPWORDS]
148
+
149
+
150
+ def _text_similarity(left: str, right: str) -> float:
151
+ normalized_left = _normalize_text(left)
152
+ normalized_right = _normalize_text(right)
153
+ if not normalized_left or not normalized_right:
154
+ return 0.0
155
+ if normalized_left == normalized_right:
156
+ return 1.0
157
+
158
+ left_tokens = set(_tokenize(normalized_left))
159
+ right_tokens = set(_tokenize(normalized_right))
160
+ shared = left_tokens & right_tokens
161
+ if not shared:
162
+ return SequenceMatcher(None, normalized_left, normalized_right).ratio()
163
+
164
+ seq = SequenceMatcher(None, normalized_left, normalized_right).ratio()
165
+ jaccard = len(shared) / len(left_tokens | right_tokens) if (left_tokens or right_tokens) else 0.0
166
+ overlap = len(shared) / min(len(left_tokens), len(right_tokens)) if min(len(left_tokens), len(right_tokens)) else 0.0
167
+ containment = (
168
+ 1.0
169
+ if normalized_left in normalized_right or normalized_right in normalized_left
170
+ else 0.0
171
+ )
172
+ return round(max((seq * 0.45) + (jaccard * 0.2) + (overlap * 0.35), overlap, (containment * 0.8) + (seq * 0.2)), 4)
173
+
174
+
175
+ def _is_concrete_action(text: str) -> bool:
176
+ tokens = set(_tokenize(text))
177
+ return bool(tokens & CONCRETE_ACTION_VERBS)
178
+
179
+
180
+ def _prefer_due_date(current_value, new_value) -> str:
181
+ current = _parse_any_datetime(current_value)
182
+ new = _parse_any_datetime(new_value)
183
+ if new and (not current or new <= current):
184
+ return str(new_value or "")
185
+ return str(current_value or "")
186
+
187
+
188
+ def _append_note(base: str, note: str) -> str:
189
+ base = str(base or "").strip()
190
+ note = str(note or "").strip()
191
+ if not note:
192
+ return base
193
+ if not base:
194
+ return note
195
+ if note.lower() in base.lower():
196
+ return base
197
+ return f"{base}\n\n{note}"
198
+
199
+
200
+ def _contains_negation(text: str) -> bool:
201
+ lowered = _normalize_text(text)
202
+ return any(token in lowered for token in NEGATION_PATTERNS)
203
+
204
+
205
+ def _negated_action_verbs(text: str) -> set[str]:
206
+ lowered = _normalize_text(text)
207
+ matches = set()
208
+ for pattern in (r"(?:never|avoid|skip|disable|remove|forbid|bypass)\s+([a-z0-9_-]+)", r"(?:do not|don't)\s+([a-z0-9_-]+)"):
209
+ matches.update(re.findall(pattern, lowered))
210
+ return {match for match in matches if len(match) > 2}
211
+
212
+
213
+ def _looks_contradictory(existing_text: str, new_text: str) -> bool:
214
+ existing_norm = _normalize_text(existing_text)
215
+ new_norm = _normalize_text(new_text)
216
+ if not existing_norm or not new_norm:
217
+ return False
218
+ existing_tokens = set(_tokenize(existing_norm))
219
+ new_tokens = set(_tokenize(new_norm))
220
+ if len(existing_tokens & new_tokens) < 3:
221
+ return False
222
+ existing_negated_verbs = _negated_action_verbs(existing_norm)
223
+ new_negated_verbs = _negated_action_verbs(new_norm)
224
+ if existing_negated_verbs & new_tokens and not existing_negated_verbs & new_negated_verbs:
225
+ return True
226
+ if new_negated_verbs & existing_tokens and not existing_negated_verbs & new_negated_verbs:
227
+ return True
228
+ if _contains_negation(existing_norm) != _contains_negation(new_norm):
229
+ return True
230
+ for positive, negative in CONTRADICTION_PAIRS:
231
+ existing_has_pair = positive in existing_norm or negative in existing_norm
232
+ new_has_pair = positive in new_norm or negative in new_norm
233
+ if existing_has_pair and new_has_pair:
234
+ if (positive in existing_norm and negative in new_norm) or (negative in existing_norm and positive in new_norm):
235
+ return True
236
+ return False
237
+
238
+
239
+ def _fetch_open_followups() -> list[dict]:
240
+ if not NEXO_DB.exists():
241
+ return []
242
+ conn = sqlite3.connect(str(NEXO_DB))
243
+ conn.row_factory = sqlite3.Row
244
+ cols = _table_columns(NEXO_DB, "followups")
245
+ reasoning_sql = ", reasoning" if "reasoning" in cols else ""
246
+ verification_sql = ", verification" if "verification" in cols else ""
247
+ try:
248
+ rows = conn.execute(
249
+ "SELECT id, description, date, status"
250
+ f"{verification_sql}{reasoning_sql} "
251
+ "FROM followups WHERE status NOT LIKE 'COMPLETED%' "
252
+ "AND status NOT IN ('DELETED','archived','blocked','waiting','CANCELLED')"
253
+ ).fetchall()
254
+ finally:
255
+ conn.close()
256
+ return [dict(row) for row in rows]
257
+
258
+
259
+ def _find_similar_followup(description: str, threshold: float = 0.58) -> dict | None:
260
+ candidates = []
261
+ query = str(description or "").strip()
262
+ if not query:
263
+ return None
264
+ query_tokens = set(_tokenize(query))
265
+ for row in _fetch_open_followups():
266
+ haystack = " ".join(
267
+ [
268
+ str(row.get("description", "") or ""),
269
+ str(row.get("verification", "") or ""),
270
+ str(row.get("reasoning", "") or ""),
271
+ ]
272
+ )
273
+ haystack_tokens = set(_tokenize(haystack))
274
+ if len(query_tokens & haystack_tokens) < 2 and _normalize_text(query) not in _normalize_text(haystack):
275
+ continue
276
+ score = _text_similarity(query, haystack)
277
+ if score >= threshold:
278
+ candidates.append({**row, "_similarity": score})
279
+ if not candidates:
280
+ return None
281
+ candidates.sort(key=lambda item: item["_similarity"], reverse=True)
282
+ return candidates[0]
283
+
284
+
285
+ def _touch_existing_followup(existing: dict, *, description: str, date: str = "", reasoning_note: str = "") -> dict:
286
+ cols = _table_columns(NEXO_DB, "followups")
287
+ if not cols:
288
+ return {"success": False, "error": "followups table not found"}
289
+
290
+ updates: dict[str, object] = {}
291
+ existing_description = str(existing.get("description", "") or "")
292
+ if _is_concrete_action(description) and not _is_concrete_action(existing_description):
293
+ updates["description"] = description
294
+ preferred_date = _prefer_due_date(existing.get("date", ""), date)
295
+ if preferred_date and preferred_date != str(existing.get("date", "") or "") and "date" in cols:
296
+ updates["date"] = preferred_date
297
+ if "reasoning" in cols and reasoning_note:
298
+ updates["reasoning"] = _append_note(existing.get("reasoning", ""), reasoning_note)
299
+ if "updated_at" in cols:
300
+ updates["updated_at"] = datetime.now().timestamp()
301
+
302
+ if updates:
303
+ conn = sqlite3.connect(str(NEXO_DB))
304
+ set_clause = ", ".join(f"{column} = ?" for column in updates)
305
+ params = list(updates.values()) + [existing["id"]]
306
+ conn.execute(f"UPDATE followups SET {set_clause} WHERE id = ?", params)
307
+ conn.commit()
308
+ conn.close()
309
+
310
+ return {
311
+ "success": True,
312
+ "id": existing["id"],
313
+ "outcome": "matched_existing_followup",
314
+ "similarity": existing.get("_similarity", 1.0),
315
+ "updated_existing": bool(updates),
316
+ }
317
+
318
+
319
+ def _fetch_learning_candidates(category: str = "") -> list[dict]:
320
+ if not NEXO_DB.exists():
321
+ return []
322
+ cols = _table_columns(NEXO_DB, "learnings")
323
+ if not cols:
324
+ return []
325
+ select_fields = ["id", "category", "title", "content", "created_at", "updated_at"]
326
+ for optional in ("reasoning", "prevention", "applies_to", "status", "review_due_at", "last_reviewed_at", "weight", "priority"):
327
+ if optional in cols:
328
+ select_fields.append(optional)
329
+ query = f"SELECT {', '.join(select_fields)} FROM learnings"
330
+ params: list[object] = []
331
+ if category and "category" in cols:
332
+ query += " WHERE category = ?"
333
+ params.append(category)
334
+ query += " ORDER BY COALESCE(updated_at, created_at) DESC LIMIT 240"
335
+ conn = sqlite3.connect(str(NEXO_DB))
336
+ conn.row_factory = sqlite3.Row
337
+ try:
338
+ rows = conn.execute(query, tuple(params)).fetchall()
339
+ finally:
340
+ conn.close()
341
+ return [dict(row) for row in rows]
342
+
343
+
344
+ def _find_learning_match(category: str, title: str, content: str) -> dict | None:
345
+ candidates = []
346
+ new_text = " ".join([str(title or ""), str(content or "")]).strip()
347
+ for row in _fetch_learning_candidates(category):
348
+ existing_text = " ".join([str(row.get("title", "") or ""), str(row.get("content", "") or "")])
349
+ similarity = _text_similarity(new_text, existing_text)
350
+ if similarity < 0.58:
351
+ continue
352
+ contradiction = _looks_contradictory(existing_text, new_text)
353
+ candidates.append({**row, "_similarity": similarity, "_contradiction": contradiction})
354
+ if not candidates:
355
+ return None
356
+ candidates.sort(
357
+ key=lambda item: (item["_contradiction"], item["_similarity"], item.get("updated_at", 0) or item.get("created_at", 0)),
358
+ reverse=True,
359
+ )
360
+ return candidates[0]
361
+
362
+
363
+ def _update_learning_row(learning_id: int, updates: dict[str, object]) -> None:
364
+ if not updates:
365
+ return
366
+ conn = sqlite3.connect(str(NEXO_DB))
367
+ set_clause = ", ".join(f"{column} = ?" for column in updates)
368
+ conn.execute(f"UPDATE learnings SET {set_clause} WHERE id = ?", list(updates.values()) + [learning_id])
369
+ conn.commit()
370
+ conn.close()
371
+
372
+
373
+ def _bump_weight(existing_value, amount: float) -> float:
374
+ try:
375
+ base = float(existing_value or 0)
376
+ except Exception:
377
+ base = 0.0
378
+ return round(min(10.0, base + amount), 2)
379
+
380
+
381
+ def _flag_learning_contradiction(existing: dict, category: str, title: str, content: str) -> dict:
382
+ review_description = (
383
+ f"Reconcile contradictory learning in {category or 'general'}: "
384
+ f"review existing learning #{existing.get('id')} ('{existing.get('title', '')}') "
385
+ f"against new Deep Sleep finding '{title}'. Produce one canonical rule, update guardrails, and remove ambiguity."
386
+ )
387
+ followup_result = create_followup(
388
+ description=review_description,
389
+ date="",
390
+ reasoning_note=f"Contradiction detected against learning #{existing.get('id')}: {content[:240]}",
391
+ )
392
+ return {
393
+ "success": followup_result.get("success", False),
394
+ "id": existing.get("id"),
395
+ "outcome": "contradiction_review",
396
+ "similarity": existing.get("_similarity", 0.0),
397
+ "review_followup_id": followup_result.get("id"),
398
+ "followup_result": followup_result,
399
+ }
400
+
401
+
78
402
  def add_learning(category: str, title: str, content: str) -> dict:
79
403
  """Add a learning to nexo.db. Returns result dict."""
80
404
  if not NEXO_DB.exists():
81
405
  return {"success": False, "error": "nexo.db not found"}
82
406
  try:
407
+ existing = _find_learning_match(category, title, content)
408
+ if existing:
409
+ similarity = existing.get("_similarity", 0.0)
410
+ if existing.get("_contradiction"):
411
+ return _flag_learning_contradiction(existing, category, title, content)
412
+
413
+ updates: dict[str, object] = {}
414
+ columns = _table_columns(NEXO_DB, "learnings")
415
+ if "updated_at" in columns:
416
+ updates["updated_at"] = datetime.now().timestamp()
417
+
418
+ existing_title = _normalize_text(existing.get("title", ""))
419
+ existing_content = _normalize_text(existing.get("content", ""))
420
+ incoming_title = _normalize_text(title)
421
+ incoming_content = _normalize_text(content)
422
+
423
+ if similarity >= 0.95 and (
424
+ existing_title == incoming_title
425
+ or existing_content == incoming_content
426
+ or incoming_content in existing_content
427
+ or existing_content in incoming_content
428
+ ):
429
+ if "weight" in columns:
430
+ updates["weight"] = _bump_weight(existing.get("weight"), 0.1)
431
+ if "last_reviewed_at" in columns:
432
+ updates["last_reviewed_at"] = datetime.now().timestamp()
433
+ if "reasoning" in columns:
434
+ updates["reasoning"] = _append_note(
435
+ existing.get("reasoning", ""),
436
+ f"Reconfirmed by Deep Sleep on {datetime.now().strftime('%Y-%m-%d')}.",
437
+ )
438
+ _update_learning_row(existing["id"], updates)
439
+ return {
440
+ "success": True,
441
+ "id": existing["id"],
442
+ "outcome": "duplicate_learning",
443
+ "similarity": similarity,
444
+ "updated_existing": bool(updates),
445
+ }
446
+
447
+ if similarity >= 0.58:
448
+ if "weight" in columns:
449
+ updates["weight"] = _bump_weight(existing.get("weight"), 0.25)
450
+ if "reasoning" in columns:
451
+ updates["reasoning"] = _append_note(
452
+ existing.get("reasoning", ""),
453
+ f"Deep Sleep reinforcement ({datetime.now().strftime('%Y-%m-%d')}): {title}. {content[:240]}",
454
+ )
455
+ elif "content" in columns and content and content not in str(existing.get("content", "")):
456
+ updates["content"] = _append_note(
457
+ existing.get("content", ""),
458
+ f"Reinforced by Deep Sleep: {content[:240]}",
459
+ )
460
+ _update_learning_row(existing["id"], updates)
461
+ return {
462
+ "success": True,
463
+ "id": existing["id"],
464
+ "outcome": "reinforced_learning",
465
+ "similarity": similarity,
466
+ "updated_existing": bool(updates),
467
+ }
468
+
83
469
  now = datetime.now().timestamp()
470
+ columns = _table_columns(NEXO_DB, "learnings")
471
+ payload = {
472
+ "category": category,
473
+ "title": title,
474
+ "content": content,
475
+ "created_at": now,
476
+ "updated_at": now,
477
+ }
478
+ if "reasoning" in columns:
479
+ payload["reasoning"] = "Deep Sleep v2 overnight analysis"
480
+ if "status" in columns:
481
+ payload["status"] = "active"
482
+ insert_columns = [column for column in payload if column in columns]
483
+ values = [payload[column] for column in insert_columns]
484
+
84
485
  conn = sqlite3.connect(str(NEXO_DB))
85
486
  cursor = conn.execute(
86
- "INSERT INTO learnings (category, title, content, created_at, updated_at, reasoning) VALUES (?, ?, ?, ?, ?, ?)",
87
- (category, title, content, now, now, "Deep Sleep v2 overnight analysis")
487
+ f"INSERT INTO learnings ({', '.join(insert_columns)}) VALUES ({', '.join('?' for _ in insert_columns)})",
488
+ values,
88
489
  )
89
490
  learning_id = cursor.lastrowid
90
491
  conn.commit()
91
492
  conn.close()
92
- return {"success": True, "id": learning_id}
493
+ return {"success": True, "id": learning_id, "outcome": "new_learning"}
93
494
  except Exception as e:
94
495
  return {"success": False, "error": str(e)}
95
496
 
96
497
 
97
- def create_followup(description: str, date: str = "") -> dict:
498
+ def create_followup(description: str, date: str = "", reasoning_note: str = "") -> dict:
98
499
  """Create a followup in nexo.db. Returns result dict."""
99
500
  if not NEXO_DB.exists():
100
501
  return {"success": False, "error": "nexo.db not found"}
101
502
  try:
503
+ matched = _find_similar_followup(description)
504
+ if matched:
505
+ return _touch_existing_followup(
506
+ matched,
507
+ description=description,
508
+ date=date,
509
+ reasoning_note=reasoning_note or "Deep Sleep matched this followup semantically.",
510
+ )
511
+
102
512
  now = datetime.now().timestamp()
103
513
  # Generate a deterministic ID
104
514
  fid = "NF-DS-" + hashlib.md5(description.encode()).hexdigest()[:8].upper()
515
+ columns = _table_columns(NEXO_DB, "followups")
516
+ payload = {
517
+ "id": fid,
518
+ "description": description,
519
+ "date": date,
520
+ "status": "PENDING",
521
+ "created_at": now,
522
+ "updated_at": now,
523
+ }
524
+ if "reasoning" in columns:
525
+ payload["reasoning"] = reasoning_note or "Deep Sleep v2 overnight analysis"
526
+ if "verification" in columns:
527
+ payload["verification"] = ""
528
+ insert_columns = [column for column in payload if column in columns]
529
+ values = [payload[column] for column in insert_columns]
530
+
105
531
  conn = sqlite3.connect(str(NEXO_DB))
106
532
  conn.execute(
107
- "INSERT OR IGNORE INTO followups (id, description, date, status, created_at, updated_at, reasoning) VALUES (?, ?, ?, 'PENDING', ?, ?, ?)",
108
- (fid, description, date, now, now, "Deep Sleep v2 overnight analysis")
533
+ f"INSERT OR IGNORE INTO followups ({', '.join(insert_columns)}) VALUES ({', '.join('?' for _ in insert_columns)})",
534
+ values,
109
535
  )
110
536
  conn.commit()
111
537
  conn.close()
112
- return {"success": True, "id": fid}
538
+ return {"success": True, "id": fid, "outcome": "new_followup"}
113
539
  except Exception as e:
114
540
  return {"success": False, "error": str(e)}
115
541
 
@@ -566,6 +992,201 @@ def _load_period_syntheses(target_date: str, *, window_days: int) -> list[dict]:
566
992
  return syntheses
567
993
 
568
994
 
995
+ def _load_period_extractions(target_date: str, *, window_days: int) -> list[dict]:
996
+ target_day = datetime.strptime(target_date, "%Y-%m-%d")
997
+ payloads: list[dict] = []
998
+ for offset in range(window_days):
999
+ date_str = (target_day - timedelta(days=offset)).strftime("%Y-%m-%d")
1000
+ path = DEEP_SLEEP_DIR / f"{date_str}-extractions.json"
1001
+ if not path.is_file():
1002
+ continue
1003
+ try:
1004
+ payload = json.loads(path.read_text())
1005
+ except Exception:
1006
+ continue
1007
+ if isinstance(payload, dict):
1008
+ payloads.append(payload)
1009
+ payloads.reverse()
1010
+ return payloads
1011
+
1012
+
1013
+ def _load_period_applied_logs(target_date: str, *, window_days: int) -> list[dict]:
1014
+ target_day = datetime.strptime(target_date, "%Y-%m-%d")
1015
+ payloads: list[dict] = []
1016
+ for offset in range(window_days):
1017
+ date_str = (target_day - timedelta(days=offset)).strftime("%Y-%m-%d")
1018
+ path = DEEP_SLEEP_DIR / f"{date_str}-applied.json"
1019
+ if not path.is_file():
1020
+ continue
1021
+ try:
1022
+ payload = json.loads(path.read_text())
1023
+ except Exception:
1024
+ continue
1025
+ if isinstance(payload, dict):
1026
+ payloads.append(payload)
1027
+ payloads.reverse()
1028
+ return payloads
1029
+
1030
+
1031
+ def _safe_pct(numerator: float, denominator: float) -> float | None:
1032
+ if denominator <= 0:
1033
+ return None
1034
+ return round((numerator / denominator) * 100.0, 1)
1035
+
1036
+
1037
+ def _aggregate_protocol_summary(extractions: list[dict]) -> dict:
1038
+ totals = {
1039
+ "sessions": 0,
1040
+ "guard_check": {"required": 0, "executed": 0},
1041
+ "heartbeat": {"total": 0, "with_context": 0},
1042
+ "change_log": {"edits": 0, "logged": 0},
1043
+ }
1044
+
1045
+ for payload in extractions:
1046
+ for item in payload.get("extractions", []) or []:
1047
+ if not isinstance(item, dict) or item.get("error"):
1048
+ continue
1049
+ totals["sessions"] += 1
1050
+ protocol_summary = item.get("protocol_summary") or {}
1051
+ for key in ("guard_check", "heartbeat", "change_log"):
1052
+ current = protocol_summary.get(key) or {}
1053
+ if key == "guard_check":
1054
+ totals[key]["required"] += int(current.get("required", 0) or 0)
1055
+ totals[key]["executed"] += int(current.get("executed", 0) or 0)
1056
+ elif key == "heartbeat":
1057
+ totals[key]["total"] += int(current.get("total", 0) or 0)
1058
+ totals[key]["with_context"] += int(current.get("with_context", 0) or 0)
1059
+ else:
1060
+ totals[key]["edits"] += int(current.get("edits", 0) or 0)
1061
+ totals[key]["logged"] += int(current.get("logged", 0) or 0)
1062
+
1063
+ guard_pct = _safe_pct(totals["guard_check"]["executed"], totals["guard_check"]["required"])
1064
+ heartbeat_pct = _safe_pct(totals["heartbeat"]["with_context"], totals["heartbeat"]["total"])
1065
+ change_pct = _safe_pct(totals["change_log"]["logged"], totals["change_log"]["edits"])
1066
+ available = [value for value in (guard_pct, heartbeat_pct, change_pct) if value is not None]
1067
+
1068
+ totals["guard_check"]["compliance_pct"] = guard_pct
1069
+ totals["heartbeat"]["compliance_pct"] = heartbeat_pct
1070
+ totals["change_log"]["compliance_pct"] = change_pct
1071
+ totals["overall_compliance_pct"] = round(sum(available) / len(available), 1) if available else None
1072
+ return totals
1073
+
1074
+
1075
+ def _aggregate_delivery_metrics(applied_logs: list[dict]) -> dict:
1076
+ totals = {
1077
+ "runs": len(applied_logs),
1078
+ "applied_actions": 0,
1079
+ "deferred_actions": 0,
1080
+ "skipped_dedupe": 0,
1081
+ "errors": 0,
1082
+ "engineering_followups": 0,
1083
+ }
1084
+ for payload in applied_logs:
1085
+ stats = payload.get("stats") or {}
1086
+ totals["applied_actions"] += int(stats.get("applied", 0) or 0)
1087
+ totals["deferred_actions"] += int(stats.get("deferred", 0) or 0)
1088
+ totals["skipped_dedupe"] += int(stats.get("skipped_dedupe", 0) or 0)
1089
+ totals["errors"] += int(stats.get("errors", 0) or 0)
1090
+ for action in payload.get("applied_actions", []) or []:
1091
+ details = action.get("details") or {}
1092
+ if action.get("action_type") == "followup_create":
1093
+ description = str(details.get("description", "") or "") + " " + str(details.get("reasoning", "") or "")
1094
+ if "engineering" in description.lower() or "guardrail" in description.lower():
1095
+ totals["engineering_followups"] += 1
1096
+
1097
+ attempted = totals["applied_actions"] + totals["deferred_actions"] + totals["skipped_dedupe"] + totals["errors"]
1098
+ totals["dedupe_rate_pct"] = _safe_pct(totals["skipped_dedupe"], attempted)
1099
+ totals["error_rate_pct"] = _safe_pct(totals["errors"], attempted)
1100
+ return totals
1101
+
1102
+
1103
+ def _load_previous_period_summary(kind: str, label: str) -> dict | None:
1104
+ pattern = f"*-{kind}-summary.json"
1105
+ candidates: list[tuple[str, Path]] = []
1106
+ for path in DEEP_SLEEP_DIR.glob(pattern):
1107
+ try:
1108
+ payload = json.loads(path.read_text())
1109
+ except Exception:
1110
+ continue
1111
+ candidate_label = str(payload.get("label", "") or "")
1112
+ if candidate_label and candidate_label < label:
1113
+ candidates.append((candidate_label, path))
1114
+ if not candidates:
1115
+ return None
1116
+ _, path = sorted(candidates, key=lambda item: item[0])[-1]
1117
+ try:
1118
+ payload = json.loads(path.read_text())
1119
+ except Exception:
1120
+ return None
1121
+ return payload if isinstance(payload, dict) else None
1122
+
1123
+
1124
+ def _build_project_pulse(top_projects: list[dict], previous_summary: dict | None) -> list[dict]:
1125
+ previous_scores: dict[str, float] = {}
1126
+ if previous_summary:
1127
+ for item in previous_summary.get("project_pulse", []) or previous_summary.get("top_projects", []) or []:
1128
+ project = str(item.get("project", "") or "")
1129
+ if project:
1130
+ previous_scores[project] = float(item.get("score", 0) or 0)
1131
+
1132
+ pulse: list[dict] = []
1133
+ for item in top_projects:
1134
+ project = str(item.get("project", "") or "")
1135
+ score = float(item.get("score", 0) or 0)
1136
+ previous_score = previous_scores.get(project, 0.0)
1137
+ delta = round(score - previous_score, 2)
1138
+ if score >= 18:
1139
+ status = "critical"
1140
+ elif score >= 10:
1141
+ status = "elevated"
1142
+ else:
1143
+ status = "watch"
1144
+ if delta >= 2.0:
1145
+ trend = "rising"
1146
+ elif delta <= -2.0:
1147
+ trend = "cooling"
1148
+ else:
1149
+ trend = "steady"
1150
+ pulse.append(
1151
+ {
1152
+ "project": project,
1153
+ "score": round(score, 2),
1154
+ "delta_vs_previous": delta,
1155
+ "trend": trend,
1156
+ "status": status,
1157
+ "signals": item.get("signals", {}),
1158
+ "reasons": item.get("reasons", []),
1159
+ }
1160
+ )
1161
+ return pulse
1162
+
1163
+
1164
+ def _build_period_trend(summary: dict, previous_summary: dict | None) -> dict:
1165
+ if not previous_summary:
1166
+ return {
1167
+ "has_previous": False,
1168
+ "avg_mood_delta": None,
1169
+ "avg_trust_delta": None,
1170
+ "total_corrections_delta": None,
1171
+ "protocol_compliance_delta": None,
1172
+ }
1173
+
1174
+ current_protocol = summary.get("protocol_summary", {}).get("overall_compliance_pct")
1175
+ previous_protocol = (previous_summary.get("protocol_summary") or {}).get("overall_compliance_pct")
1176
+ current_mood = summary.get("avg_mood_score")
1177
+ previous_mood = previous_summary.get("avg_mood_score")
1178
+ current_trust = summary.get("avg_trust_score")
1179
+ previous_trust = previous_summary.get("avg_trust_score")
1180
+
1181
+ return {
1182
+ "has_previous": True,
1183
+ "avg_mood_delta": round(current_mood - previous_mood, 3) if isinstance(current_mood, (int, float)) and isinstance(previous_mood, (int, float)) else None,
1184
+ "avg_trust_delta": round(current_trust - previous_trust, 1) if isinstance(current_trust, (int, float)) and isinstance(previous_trust, (int, float)) else None,
1185
+ "total_corrections_delta": int(summary.get("total_corrections", 0) or 0) - int(previous_summary.get("total_corrections", 0) or 0),
1186
+ "protocol_compliance_delta": round(current_protocol - previous_protocol, 1) if isinstance(current_protocol, (int, float)) and isinstance(previous_protocol, (int, float)) else None,
1187
+ }
1188
+
1189
+
569
1190
  def _build_period_summary(target_date: str, synthesis: dict, *, kind: str, window_days: int) -> dict:
570
1191
  target_day = datetime.strptime(target_date, "%Y-%m-%d")
571
1192
  window_start = (target_day - timedelta(days=max(0, window_days - 1))).strftime("%Y-%m-%d")
@@ -575,6 +1196,8 @@ def _build_period_summary(target_date: str, synthesis: dict, *, kind: str, windo
575
1196
  else target_day.strftime("%Y-%m")
576
1197
  )
577
1198
  syntheses = _load_period_syntheses(target_date, window_days=window_days)
1199
+ extractions = _load_period_extractions(target_date, window_days=window_days)
1200
+ applied_logs = _load_period_applied_logs(target_date, window_days=window_days)
578
1201
  if not any(item.get("date") == target_date for item in syntheses):
579
1202
  syntheses.append(synthesis)
580
1203
 
@@ -611,6 +1234,10 @@ def _build_period_summary(target_date: str, synthesis: dict, *, kind: str, windo
611
1234
  {"title": title, "count": count}
612
1235
  for title, count in agenda_counter.most_common(6)
613
1236
  ]
1237
+ protocol_summary = _aggregate_protocol_summary(extractions)
1238
+ delivery_metrics = _aggregate_delivery_metrics(applied_logs)
1239
+ previous_summary = _load_previous_period_summary(kind, label)
1240
+ project_pulse = _build_project_pulse(top_projects, previous_summary)
614
1241
 
615
1242
  summary_parts = [f"{len(syntheses)} Deep Sleep run(s)"]
616
1243
  if top_projects:
@@ -619,9 +1246,11 @@ def _build_period_summary(target_date: str, synthesis: dict, *, kind: str, windo
619
1246
  summary_parts.append(f"recurring pattern: {top_patterns[0]['pattern']}")
620
1247
  if avg_trust is not None:
621
1248
  summary_parts.append(f"avg trust {avg_trust:.1f}")
1249
+ if protocol_summary.get("overall_compliance_pct") is not None:
1250
+ summary_parts.append(f"protocol {protocol_summary['overall_compliance_pct']:.1f}%")
622
1251
  summary = " | ".join(summary_parts)
623
1252
 
624
- return {
1253
+ period_summary = {
625
1254
  "kind": kind,
626
1255
  "label": label,
627
1256
  "window_days": window_days,
@@ -633,11 +1262,15 @@ def _build_period_summary(target_date: str, synthesis: dict, *, kind: str, windo
633
1262
  "avg_trust_score": avg_trust,
634
1263
  "total_corrections": total_corrections,
635
1264
  "top_projects": top_projects,
1265
+ "project_pulse": project_pulse,
636
1266
  "top_patterns": top_patterns,
637
1267
  "recurring_agenda": recurring_agenda,
1268
+ "protocol_summary": protocol_summary,
1269
+ "delivery_metrics": delivery_metrics,
638
1270
  "summary": summary,
639
1271
  }
640
-
1272
+ period_summary["trend"] = _build_period_trend(period_summary, previous_summary)
1273
+ return period_summary
641
1274
 
642
1275
  def _render_period_summary_markdown(summary: dict) -> str:
643
1276
  lines = [
@@ -656,6 +1289,47 @@ def _render_period_summary_markdown(summary: dict) -> str:
656
1289
  lines.append(f"> {summary['summary']}")
657
1290
  lines.append("")
658
1291
 
1292
+ protocol_summary = summary.get("protocol_summary") or {}
1293
+ if protocol_summary:
1294
+ lines.append("## Protocol Compliance")
1295
+ lines.append("")
1296
+ overall = protocol_summary.get("overall_compliance_pct")
1297
+ if overall is not None:
1298
+ lines.append(f"- Overall compliance: {overall:.1f}%")
1299
+ guard = protocol_summary.get("guard_check", {})
1300
+ heartbeat = protocol_summary.get("heartbeat", {})
1301
+ change_log = protocol_summary.get("change_log", {})
1302
+ if guard:
1303
+ lines.append(
1304
+ f"- guard_check: {guard.get('executed', 0)}/{guard.get('required', 0)}"
1305
+ + (f" ({guard['compliance_pct']:.1f}%)" if guard.get("compliance_pct") is not None else "")
1306
+ )
1307
+ if heartbeat:
1308
+ lines.append(
1309
+ f"- heartbeat with context: {heartbeat.get('with_context', 0)}/{heartbeat.get('total', 0)}"
1310
+ + (f" ({heartbeat['compliance_pct']:.1f}%)" if heartbeat.get("compliance_pct") is not None else "")
1311
+ )
1312
+ if change_log:
1313
+ lines.append(
1314
+ f"- change_log after edits: {change_log.get('logged', 0)}/{change_log.get('edits', 0)}"
1315
+ + (f" ({change_log['compliance_pct']:.1f}%)" if change_log.get("compliance_pct") is not None else "")
1316
+ )
1317
+ lines.append("")
1318
+
1319
+ delivery_metrics = summary.get("delivery_metrics") or {}
1320
+ if delivery_metrics:
1321
+ lines.append("## Loop Output")
1322
+ lines.append("")
1323
+ lines.append(f"- Applied actions: {delivery_metrics.get('applied_actions', 0)}")
1324
+ lines.append(f"- Deferred actions: {delivery_metrics.get('deferred_actions', 0)}")
1325
+ lines.append(f"- Dedupe skips: {delivery_metrics.get('skipped_dedupe', 0)}")
1326
+ lines.append(f"- Engineering followups: {delivery_metrics.get('engineering_followups', 0)}")
1327
+ if delivery_metrics.get("dedupe_rate_pct") is not None:
1328
+ lines.append(f"- Dedupe rate: {delivery_metrics['dedupe_rate_pct']:.1f}%")
1329
+ if delivery_metrics.get("error_rate_pct") is not None:
1330
+ lines.append(f"- Error rate: {delivery_metrics['error_rate_pct']:.1f}%")
1331
+ lines.append("")
1332
+
659
1333
  if summary.get("top_projects"):
660
1334
  lines.append("## Top Projects")
661
1335
  lines.append("")
@@ -665,6 +1339,20 @@ def _render_period_summary_markdown(summary: dict) -> str:
665
1339
  lines.append(f" Reasons: {', '.join(item['reasons'])}")
666
1340
  lines.append("")
667
1341
 
1342
+ if summary.get("project_pulse"):
1343
+ lines.append("## Project Pulse")
1344
+ lines.append("")
1345
+ for item in summary["project_pulse"][:5]:
1346
+ delta = item.get("delta_vs_previous")
1347
+ delta_label = ""
1348
+ if isinstance(delta, (int, float)):
1349
+ delta_label = f" | Δ {delta:+.2f}"
1350
+ lines.append(
1351
+ f"- **{item['project']}** — {item.get('status', 'watch')} / {item.get('trend', 'steady')}"
1352
+ f" | score {item.get('score', 0)}{delta_label}"
1353
+ )
1354
+ lines.append("")
1355
+
668
1356
  if summary.get("top_patterns"):
669
1357
  lines.append("## Recurring Patterns")
670
1358
  lines.append("")
@@ -679,6 +1367,20 @@ def _render_period_summary_markdown(summary: dict) -> str:
679
1367
  lines.append(f"- {item['title']} ({item['count']}x)")
680
1368
  lines.append("")
681
1369
 
1370
+ trend = summary.get("trend") or {}
1371
+ if trend.get("has_previous"):
1372
+ lines.append("## Trend vs Previous")
1373
+ lines.append("")
1374
+ if trend.get("avg_mood_delta") is not None:
1375
+ lines.append(f"- Mood delta: {trend['avg_mood_delta']:+.3f}")
1376
+ if trend.get("avg_trust_delta") is not None:
1377
+ lines.append(f"- Trust delta: {trend['avg_trust_delta']:+.1f}")
1378
+ if trend.get("total_corrections_delta") is not None:
1379
+ lines.append(f"- Corrections delta: {trend['total_corrections_delta']:+d}")
1380
+ if trend.get("protocol_compliance_delta") is not None:
1381
+ lines.append(f"- Protocol delta: {trend['protocol_compliance_delta']:+.1f}%")
1382
+ lines.append("")
1383
+
682
1384
  return "\n".join(lines).rstrip() + "\n"
683
1385
 
684
1386
 
@@ -964,7 +1666,8 @@ def apply_action(action: dict, run_id: str) -> dict:
964
1666
  elif action_type == "followup_create":
965
1667
  result = create_followup(
966
1668
  description=content.get("description", content.get("title", "")),
967
- date=content.get("date", "")
1669
+ date=content.get("date", ""),
1670
+ reasoning_note=content.get("reasoning", content.get("why", "")),
968
1671
  )
969
1672
  log_entry["status"] = "applied" if result.get("success") else "error"
970
1673
  log_entry["details"] = result