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.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +16 -17
- package/package.json +1 -1
- package/src/agent_runner.py +6 -2
- package/src/cli.py +98 -4
- package/src/client_preferences.py +21 -0
- package/src/dashboard/app.py +124 -0
- package/src/dashboard/templates/dashboard.html +59 -1
- package/src/doctor/providers/runtime.py +181 -0
- package/src/scripts/deep-sleep/apply_findings.py +713 -10
- package/src/scripts/deep-sleep/synthesize-prompt.md +23 -0
- package/src/scripts/deep-sleep/synthesize.py +94 -0
- package/templates/nexo_helper.py +44 -0
|
@@ -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 (
|
|
87
|
-
|
|
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 (
|
|
108
|
-
|
|
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
|
-
|
|
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
|