nexo-brain 7.23.13 → 7.24.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 +13 -11
- package/bin/nexo-brain.js +42 -235
- package/package.json +1 -1
- package/src/automation_supervisor.py +1 -1
- package/src/cli.py +255 -9
- package/src/cognitive_control_observatory.py +224 -0
- package/src/dashboard/app.py +26 -9
- package/src/db/__init__.py +2 -0
- package/src/db/_learnings.py +1 -1
- package/src/db/_memory_v2.py +107 -1
- package/src/db/_protocol.py +2 -2
- package/src/db/_reminders.py +132 -4
- package/src/db/_schema.py +2 -2
- package/src/events_bus.py +4 -5
- package/src/learning_resolver.py +419 -0
- package/src/lifecycle_events.py +9 -9
- package/src/local_context/api.py +67 -5
- package/src/local_context/usage_events.py +24 -0
- package/src/memory_observation_processor.py +28 -0
- package/src/memory_retrieval.py +5 -5
- package/src/operator_language.py +2 -0
- package/src/plugins/backup.py +1 -1
- package/src/plugins/cortex.py +21 -21
- package/src/plugins/episodic_memory.py +11 -11
- package/src/plugins/goal_engine.py +3 -3
- package/src/plugins/personal_scripts.py +75 -0
- package/src/plugins/protocol.py +10 -1
- package/src/pre_answer_router.py +116 -0
- package/src/r_catalog.py +4 -5
- package/src/saved_not_used_audit.py +31 -31
- package/src/script_registry.py +444 -1
- package/src/scripts/deep-sleep/apply_findings.py +79 -17
- package/src/scripts/nexo-daily-self-audit.py +46 -13
- package/src/scripts/nexo-email-migrate-config.py +2 -2
- package/src/scripts/nexo-email-monitor.py +19 -19
- package/src/scripts/nexo-followup-hygiene.py +40 -8
- package/src/scripts/nexo-followup-runner.py +31 -31
- package/src/scripts/nexo-inbox-hook.sh +1 -1
- package/src/scripts/nexo-learning-validator.py +24 -3
- package/src/server.py +73 -1
- package/src/system_catalog.py +31 -31
- package/src/tools_learnings.py +96 -65
- package/src/tools_memory_v2.py +2 -2
- package/src/tools_sessions.py +25 -7
- package/templates/core-prompts/postmortem-consolidator.md +3 -3
- package/templates/core-prompts/r17-promise-debt-injection.md +1 -1
- package/templates/core-prompts/server-mcp-instructions.md +6 -6
- package/tool-enforcement-map.json +143 -13
|
@@ -33,6 +33,7 @@ if str(NEXO_CODE) not in sys.path:
|
|
|
33
33
|
import db as nexo_db
|
|
34
34
|
import paths
|
|
35
35
|
from cognitive_paths import resolve_cognitive_db
|
|
36
|
+
from learning_resolver import resolve_learning_candidate
|
|
36
37
|
|
|
37
38
|
def _resolve_nexo_db() -> Path:
|
|
38
39
|
candidates = [
|
|
@@ -161,21 +162,24 @@ def _top_followups_by_impact(limit: int = 5) -> list[dict]:
|
|
|
161
162
|
conn.close()
|
|
162
163
|
return []
|
|
163
164
|
impact_factors_sql = ", impact_factors" if "impact_factors" in columns else ""
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
165
|
+
owner_sql = ", owner" if "owner" in columns else ""
|
|
166
|
+
rows = []
|
|
167
|
+
for row in conn.execute(
|
|
168
|
+
f"""SELECT id, description, date, priority, impact_score, status{owner_sql}{impact_factors_sql}
|
|
169
|
+
FROM followups
|
|
170
|
+
ORDER BY
|
|
171
|
+
CASE WHEN COALESCE(impact_score, 0) > 0 THEN 0 ELSE 1 END ASC,
|
|
172
|
+
COALESCE(impact_score, 0) DESC,
|
|
173
|
+
CASE WHEN date IS NULL OR date = '' THEN 1 ELSE 0 END ASC,
|
|
174
|
+
date ASC
|
|
175
|
+
LIMIT ?""",
|
|
176
|
+
(max(20, int(limit) * 4),),
|
|
177
|
+
).fetchall():
|
|
178
|
+
item = dict(row)
|
|
179
|
+
if nexo_db.followup_lifecycle_lane(item) == "active":
|
|
180
|
+
rows.append(item)
|
|
181
|
+
if len(rows) >= max(1, int(limit)):
|
|
182
|
+
break
|
|
179
183
|
conn.close()
|
|
180
184
|
except Exception:
|
|
181
185
|
return []
|
|
@@ -501,6 +505,18 @@ def _find_learning_match(category: str, title: str, content: str) -> dict | None
|
|
|
501
505
|
return candidates[0]
|
|
502
506
|
|
|
503
507
|
|
|
508
|
+
def _learning_by_id(learning_id: int) -> dict | None:
|
|
509
|
+
if not learning_id or not NEXO_DB.exists():
|
|
510
|
+
return None
|
|
511
|
+
conn = sqlite3.connect(str(NEXO_DB))
|
|
512
|
+
conn.row_factory = sqlite3.Row
|
|
513
|
+
try:
|
|
514
|
+
row = conn.execute("SELECT * FROM learnings WHERE id = ?", (int(learning_id),)).fetchone()
|
|
515
|
+
return dict(row) if row else None
|
|
516
|
+
finally:
|
|
517
|
+
conn.close()
|
|
518
|
+
|
|
519
|
+
|
|
504
520
|
def _update_learning_row(learning_id: int, updates: dict[str, object]) -> None:
|
|
505
521
|
if not updates:
|
|
506
522
|
return
|
|
@@ -545,7 +561,34 @@ def add_learning(category: str, title: str, content: str) -> dict:
|
|
|
545
561
|
if not NEXO_DB.exists():
|
|
546
562
|
return {"success": False, "error": "nexo.db not found"}
|
|
547
563
|
try:
|
|
548
|
-
|
|
564
|
+
resolution = resolve_learning_candidate(
|
|
565
|
+
category=category,
|
|
566
|
+
title=title,
|
|
567
|
+
content=content,
|
|
568
|
+
source_authority="deep_sleep",
|
|
569
|
+
)
|
|
570
|
+
if resolution["action"] == "reject":
|
|
571
|
+
return {"success": False, "error": resolution["reason"], "outcome": "rejected_learning"}
|
|
572
|
+
if resolution["action"] == "conflict_review":
|
|
573
|
+
return _flag_learning_contradiction(
|
|
574
|
+
{
|
|
575
|
+
"id": resolution.get("target_id"),
|
|
576
|
+
"title": resolution.get("target_title"),
|
|
577
|
+
"_similarity": resolution.get("similarity", 0.0),
|
|
578
|
+
},
|
|
579
|
+
category,
|
|
580
|
+
title,
|
|
581
|
+
content,
|
|
582
|
+
)
|
|
583
|
+
|
|
584
|
+
canonical_supersedes_id = int(resolution.get("target_id") or 0) if resolution["action"] == "supersede" else 0
|
|
585
|
+
existing = None if canonical_supersedes_id else _find_learning_match(category, title, content)
|
|
586
|
+
if resolution["action"] == "merge" and int(resolution.get("target_id") or 0):
|
|
587
|
+
row = _learning_by_id(int(resolution["target_id"]))
|
|
588
|
+
if row:
|
|
589
|
+
existing = dict(row)
|
|
590
|
+
existing["_similarity"] = float(resolution.get("similarity") or 1.0)
|
|
591
|
+
existing["_contradiction"] = False
|
|
549
592
|
if existing:
|
|
550
593
|
similarity = existing.get("_similarity", 0.0)
|
|
551
594
|
if existing.get("_contradiction"):
|
|
@@ -631,7 +674,21 @@ def add_learning(category: str, title: str, content: str) -> dict:
|
|
|
631
674
|
learning_id = cursor.lastrowid
|
|
632
675
|
conn.commit()
|
|
633
676
|
conn.close()
|
|
634
|
-
|
|
677
|
+
if canonical_supersedes_id:
|
|
678
|
+
try:
|
|
679
|
+
nexo_db.supersede_learning(
|
|
680
|
+
int(canonical_supersedes_id),
|
|
681
|
+
int(learning_id),
|
|
682
|
+
f"Superseded by Deep Sleep canonical learning #{learning_id}.",
|
|
683
|
+
)
|
|
684
|
+
except Exception:
|
|
685
|
+
pass
|
|
686
|
+
return {
|
|
687
|
+
"success": True,
|
|
688
|
+
"id": learning_id,
|
|
689
|
+
"outcome": "new_learning" if not canonical_supersedes_id else "superseding_learning",
|
|
690
|
+
"supersedes_id": canonical_supersedes_id,
|
|
691
|
+
}
|
|
635
692
|
except Exception as e:
|
|
636
693
|
return {"success": False, "error": str(e)}
|
|
637
694
|
|
|
@@ -677,6 +734,11 @@ def create_followup(description: str, date: str = "", reasoning_note: str = "",
|
|
|
677
734
|
)
|
|
678
735
|
if followup_result.get("error"):
|
|
679
736
|
return {"success": False, "error": followup_result["error"]}
|
|
737
|
+
if desired_status.lower() == "archived":
|
|
738
|
+
conn = sqlite3.connect(str(NEXO_DB))
|
|
739
|
+
conn.execute("UPDATE followups SET status = ? WHERE id = ?", ("archived", fid))
|
|
740
|
+
conn.commit()
|
|
741
|
+
conn.close()
|
|
680
742
|
if desired_status != "PENDING":
|
|
681
743
|
nexo_db.add_followup_note(
|
|
682
744
|
fid,
|
|
@@ -75,6 +75,7 @@ from constants import AUTOMATION_SUBPROCESS_TIMEOUT
|
|
|
75
75
|
from core_prompts import render_core_prompt
|
|
76
76
|
from cognitive_paths import audit_cognitive_db_paths, resolve_cognitive_db
|
|
77
77
|
import db as nexo_db
|
|
78
|
+
from learning_resolver import applies_overlap, looks_contradictory, resolve_learning_candidate
|
|
78
79
|
from public_evolution_queue import queue_public_port_candidate
|
|
79
80
|
|
|
80
81
|
try:
|
|
@@ -390,12 +391,34 @@ def _upsert_inline_learning(
|
|
|
390
391
|
return {"ok": False, "reason": "learnings_missing"}
|
|
391
392
|
|
|
392
393
|
columns = _table_columns(conn, "learnings")
|
|
394
|
+
resolution = resolve_learning_candidate(
|
|
395
|
+
category=category,
|
|
396
|
+
title=title,
|
|
397
|
+
content=content,
|
|
398
|
+
reasoning=reasoning,
|
|
399
|
+
prevention=prevention,
|
|
400
|
+
applies_to=applies_to,
|
|
401
|
+
priority=priority,
|
|
402
|
+
source_authority="code_test_evidence",
|
|
403
|
+
conn=conn,
|
|
404
|
+
)
|
|
405
|
+
if resolution["action"] == "reject":
|
|
406
|
+
return {"ok": False, "reason": resolution["reason"], "resolver": resolution}
|
|
407
|
+
if resolution["action"] == "conflict_review":
|
|
408
|
+
return {"ok": False, "reason": "conflict_review_required", "resolver": resolution}
|
|
409
|
+
resolver_target_id = int(resolution.get("target_id") or 0)
|
|
410
|
+
supersede_target_id = resolver_target_id if resolution["action"] == "supersede" else 0
|
|
393
411
|
rows = conn.execute(
|
|
394
412
|
"SELECT * FROM learnings WHERE COALESCE(status, 'active') != 'superseded' ORDER BY updated_at DESC, id DESC LIMIT 200"
|
|
395
413
|
).fetchall()
|
|
396
414
|
target_signature = _topic_signature(f"{title} {content}")
|
|
397
415
|
existing = None
|
|
398
416
|
for row in rows:
|
|
417
|
+
if resolution["action"] == "merge" and resolver_target_id and int(row["id"]) == resolver_target_id:
|
|
418
|
+
existing = row
|
|
419
|
+
break
|
|
420
|
+
if supersede_target_id:
|
|
421
|
+
continue
|
|
399
422
|
row_title = str(row["title"] or "").strip() if "title" in columns else ""
|
|
400
423
|
row_content = str(row["content"] or "").strip() if "content" in columns else ""
|
|
401
424
|
row_applies = str(row["applies_to"] or "").strip() if "applies_to" in columns else ""
|
|
@@ -471,6 +494,13 @@ def _upsert_inline_learning(
|
|
|
471
494
|
list(values.values()),
|
|
472
495
|
)
|
|
473
496
|
learning_id = conn.execute("SELECT last_insert_rowid()").fetchone()[0]
|
|
497
|
+
if supersede_target_id:
|
|
498
|
+
_supersede_learning_inline(
|
|
499
|
+
conn,
|
|
500
|
+
keep_id=int(learning_id),
|
|
501
|
+
retire_id=supersede_target_id,
|
|
502
|
+
note=f"Self-audit canonical resolver superseded learning #{supersede_target_id}.",
|
|
503
|
+
)
|
|
474
504
|
return {"ok": True, "action": "created", "learning_id": int(learning_id)}
|
|
475
505
|
|
|
476
506
|
|
|
@@ -681,8 +711,8 @@ def _retire_stale_audit_goals_inline(
|
|
|
681
711
|
closed_at = ?
|
|
682
712
|
WHERE goal_id = ?""",
|
|
683
713
|
(
|
|
684
|
-
"
|
|
685
|
-
f"Self-audit placeholder stale >{max_age_hours}h
|
|
714
|
+
"None. Stale placeholder removed automatically; self-audit will recreate it if the pattern reappears.",
|
|
715
|
+
f"Self-audit placeholder stale >{max_age_hours}h without open workflow runs.",
|
|
686
716
|
now_iso,
|
|
687
717
|
now_iso,
|
|
688
718
|
row["goal_id"],
|
|
@@ -853,15 +883,20 @@ def check_overdue_reminders():
|
|
|
853
883
|
def check_overdue_followups():
|
|
854
884
|
if not NEXO_DB.exists():
|
|
855
885
|
return
|
|
856
|
-
conn = sqlite3.connect(str(NEXO_DB))
|
|
857
886
|
today = datetime.now().strftime("%Y-%m-%d")
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
(today
|
|
861
|
-
|
|
862
|
-
|
|
887
|
+
try:
|
|
888
|
+
active = (nexo_db.followup_lifecycle_snapshot(limit=5000).get("lanes") or {}).get("active", [])
|
|
889
|
+
rows = [item for item in active if item.get("date") and str(item["date"]) < today]
|
|
890
|
+
rows.sort(key=lambda item: str(item.get("date") or ""))
|
|
891
|
+
except Exception:
|
|
892
|
+
conn = sqlite3.connect(str(NEXO_DB))
|
|
893
|
+
rows = conn.execute(
|
|
894
|
+
"SELECT description, date FROM followups WHERE status='PENDING' AND date < ? AND date != '' ORDER BY date",
|
|
895
|
+
(today,)
|
|
896
|
+
).fetchall()
|
|
897
|
+
conn.close()
|
|
863
898
|
if rows:
|
|
864
|
-
finding("WARN", "followups", f"{len(rows)} overdue: {', '.join(r[0][:40] for r in rows[:5])}")
|
|
899
|
+
finding("WARN", "followups", f"{len(rows)} overdue: {', '.join(str((r.get('description') if isinstance(r, dict) else r[0]) or '')[:40] for r in rows[:5])}")
|
|
865
900
|
|
|
866
901
|
|
|
867
902
|
def check_uncommitted_changes():
|
|
@@ -1156,8 +1191,6 @@ def check_learning_contradictions():
|
|
|
1156
1191
|
conn.close()
|
|
1157
1192
|
return
|
|
1158
1193
|
|
|
1159
|
-
from tools_learnings import _applies_overlap, _looks_contradictory
|
|
1160
|
-
|
|
1161
1194
|
rows = conn.execute(
|
|
1162
1195
|
"""SELECT id, title, content, applies_to
|
|
1163
1196
|
FROM learnings
|
|
@@ -1168,9 +1201,9 @@ def check_learning_contradictions():
|
|
|
1168
1201
|
contradictions: list[tuple[sqlite3.Row, sqlite3.Row]] = []
|
|
1169
1202
|
for index, left in enumerate(rows):
|
|
1170
1203
|
for right in rows[index + 1:]:
|
|
1171
|
-
if not
|
|
1204
|
+
if not applies_overlap(left["applies_to"], right["applies_to"]):
|
|
1172
1205
|
continue
|
|
1173
|
-
if not
|
|
1206
|
+
if not looks_contradictory(
|
|
1174
1207
|
f"{left['title']} {left['content']}",
|
|
1175
1208
|
f"{right['title']} {right['content']}",
|
|
1176
1209
|
):
|
|
@@ -171,8 +171,8 @@ def main(argv: list[str]) -> int:
|
|
|
171
171
|
is_default=False,
|
|
172
172
|
)
|
|
173
173
|
print(
|
|
174
|
-
f"(
|
|
175
|
-
"
|
|
174
|
+
f"(account '{args.label}' already exists — email={account.get('email')}, "
|
|
175
|
+
"normalizing the agent contract and only filling missing operator mailboxes)."
|
|
176
176
|
)
|
|
177
177
|
else:
|
|
178
178
|
account = add_email_account(
|
|
@@ -395,12 +395,12 @@ def load_config():
|
|
|
395
395
|
return cfg
|
|
396
396
|
except Exception:
|
|
397
397
|
pass
|
|
398
|
-
# v0.32.5 —
|
|
399
|
-
#
|
|
400
|
-
#
|
|
401
|
-
#
|
|
402
|
-
#
|
|
403
|
-
#
|
|
398
|
+
# v0.32.5 — return None gracefully when no email setup exists yet. Before
|
|
399
|
+
# this, FileNotFoundError bubbled up and the once-per-minute cron generated
|
|
400
|
+
# 1,440 error rows per day per client without configured email. 50 paid
|
|
401
|
+
# clients without email setup meant 72k error rows/day, false watchdog L2
|
|
402
|
+
# alerts, log noise, and possible token burn. The cron now returns without
|
|
403
|
+
# error when config is absent.
|
|
404
404
|
try:
|
|
405
405
|
with open(CONFIG_PATH) as f:
|
|
406
406
|
payload = json.load(f)
|
|
@@ -952,8 +952,8 @@ def scan_debt(db_path=EMAIL_DB_PATH, *, max_items=5):
|
|
|
952
952
|
{
|
|
953
953
|
"email_id": row["email_id"],
|
|
954
954
|
"kind": "ack",
|
|
955
|
-
"label": f"ACK
|
|
956
|
-
"detail": f"ack
|
|
955
|
+
"label": f"ACK without closure >{DEBT_SLA_HOURS}h — {row['subject'] or row['email_id']} [{row['email_id']}]",
|
|
956
|
+
"detail": f"ack since {row['last_ack_ts']}",
|
|
957
957
|
}
|
|
958
958
|
)
|
|
959
959
|
|
|
@@ -979,8 +979,8 @@ def scan_debt(db_path=EMAIL_DB_PATH, *, max_items=5):
|
|
|
979
979
|
{
|
|
980
980
|
"email_id": row["email_id"],
|
|
981
981
|
"kind": "commitment",
|
|
982
|
-
"label": f"
|
|
983
|
-
"detail": f"commitment
|
|
982
|
+
"label": f"COMMITMENT without closure >{DEBT_SLA_HOURS}h — {row['subject'] or row['email_id']} [{row['email_id']}]",
|
|
983
|
+
"detail": f"commitment since {row['last_commitment_ts']}",
|
|
984
984
|
}
|
|
985
985
|
)
|
|
986
986
|
|
|
@@ -1932,13 +1932,13 @@ def _localized_operator_escalation_email(
|
|
|
1932
1932
|
details: str,
|
|
1933
1933
|
) -> tuple[str, str]:
|
|
1934
1934
|
if _uses_spanish(operator_language):
|
|
1935
|
-
subject = f"[NEXO] Emails
|
|
1935
|
+
subject = f"[NEXO] Emails requiring manual attention ({exhausted_count})"
|
|
1936
1936
|
body = (
|
|
1937
|
-
f"
|
|
1938
|
-
f"
|
|
1939
|
-
f"
|
|
1940
|
-
"
|
|
1941
|
-
f"
|
|
1937
|
+
f"Hello {operator_name},\n\n"
|
|
1938
|
+
f"The following emails have already been attempted {MAX_EMAIL_ATTEMPTS} times "
|
|
1939
|
+
f"without succeeding (the session dies before completion):\n\n{details}\n\n"
|
|
1940
|
+
"I marked them as `needs_interactive`. "
|
|
1941
|
+
f"Open {assistant_name} Desktop and ask about the affected email so it can be resolved manually.\n\n"
|
|
1942
1942
|
f"— {assistant_name}"
|
|
1943
1943
|
)
|
|
1944
1944
|
return subject, body
|
|
@@ -2494,9 +2494,9 @@ def main():
|
|
|
2494
2494
|
return
|
|
2495
2495
|
|
|
2496
2496
|
config = load_config()
|
|
2497
|
-
# v0.32.5 — exit cleanly
|
|
2498
|
-
#
|
|
2499
|
-
#
|
|
2497
|
+
# v0.32.5 — exit cleanly when no email setup exists. Before this,
|
|
2498
|
+
# FileNotFoundError bubbled up to 1,440 errors/day per Windows client
|
|
2499
|
+
# without email. Now the monitor exits silently.
|
|
2500
2500
|
if config is None:
|
|
2501
2501
|
log.info("No email config — skipping monitor check.")
|
|
2502
2502
|
return
|
|
@@ -5,8 +5,9 @@ NEXO Followup Hygiene — Weekly cleanup of followup/reminder statuses.
|
|
|
5
5
|
|
|
6
6
|
Runs Sundays via LaunchAgent (or manually). Tasks:
|
|
7
7
|
1. Normalize dirty statuses (COMPLETED YYYY-MM-DD -> COMPLETED)
|
|
8
|
-
2.
|
|
9
|
-
3.
|
|
8
|
+
2. Move PENDING followups >14 days without updates to stale_review
|
|
9
|
+
3. Expire stale_review followups that stayed untouched for the TTL window
|
|
10
|
+
4. Generate summary of orphaned/forgotten followups for synthesis
|
|
10
11
|
|
|
11
12
|
No CLI needed — this is pure mechanical cleanup.
|
|
12
13
|
"""
|
|
@@ -32,6 +33,7 @@ COORD_DIR = paths.coordination_dir()
|
|
|
32
33
|
LOG_FILE = paths.logs_dir() / "followup-hygiene.log"
|
|
33
34
|
|
|
34
35
|
TODAY = date.today().isoformat()
|
|
36
|
+
EXPIRE_STALE_REVIEW_DAYS = int(os.environ.get("NEXO_FOLLOWUP_EXPIRE_STALE_REVIEW_DAYS", "90") or "90")
|
|
35
37
|
|
|
36
38
|
|
|
37
39
|
def log(msg):
|
|
@@ -91,7 +93,7 @@ def main():
|
|
|
91
93
|
stale = conn.execute(
|
|
92
94
|
"SELECT id, description, date, updated_at FROM followups "
|
|
93
95
|
"WHERE status NOT LIKE 'COMPLETED%' "
|
|
94
|
-
"AND status NOT IN ('DELETED','
|
|
96
|
+
"AND UPPER(COALESCE(status, '')) NOT IN ('DELETED','ARCHIVED','BLOCKED','WAITING','WAITING_EXTERNAL','NEEDS_DECISION','WAITING_USER','PARKED','STALE_REVIEW','EXPIRED','DONE') "
|
|
95
97
|
"AND date != '' AND date < ? "
|
|
96
98
|
"AND (updated_at IS NULL OR updated_at = '' OR updated_at < ?) "
|
|
97
99
|
"ORDER BY date",
|
|
@@ -106,23 +108,51 @@ def main():
|
|
|
106
108
|
for s in stale:
|
|
107
109
|
result = nexo_db.update_followup(
|
|
108
110
|
str(s["id"]),
|
|
109
|
-
status="
|
|
111
|
+
status="stale_review",
|
|
110
112
|
date=TODAY,
|
|
111
113
|
history_actor="followup-hygiene",
|
|
112
114
|
history_event="stale_triage",
|
|
113
115
|
history_note=(
|
|
114
|
-
"Weekly hygiene
|
|
116
|
+
"Weekly hygiene moved this old due followup to stale_review "
|
|
115
117
|
"instead of leaving it in the executable briefing indefinitely."
|
|
116
118
|
),
|
|
117
119
|
)
|
|
118
120
|
if not result.get("error"):
|
|
119
121
|
escalated_stale.append(str(s["id"]))
|
|
120
122
|
|
|
121
|
-
# 3.
|
|
123
|
+
# 3. Expire stale_review followups that received no decision for the TTL window
|
|
124
|
+
expire_cutoff = datetime.now().timestamp() - (max(1, EXPIRE_STALE_REVIEW_DAYS) * 24 * 60 * 60)
|
|
125
|
+
expirable = conn.execute(
|
|
126
|
+
"SELECT id, description, updated_at FROM followups "
|
|
127
|
+
"WHERE UPPER(COALESCE(status, '')) = 'STALE_REVIEW' "
|
|
128
|
+
"AND (updated_at IS NULL OR updated_at = '' OR updated_at < ?) "
|
|
129
|
+
"ORDER BY updated_at ASC",
|
|
130
|
+
(expire_cutoff,),
|
|
131
|
+
).fetchall()
|
|
132
|
+
expired_ids = []
|
|
133
|
+
if expirable:
|
|
134
|
+
log(f"Expiring {len(expirable)} stale_review followups untouched for >{EXPIRE_STALE_REVIEW_DAYS} days:")
|
|
135
|
+
for item in expirable[:10]:
|
|
136
|
+
log(f" {item['id']}: {item['description'][:60]}")
|
|
137
|
+
for item in expirable:
|
|
138
|
+
result = nexo_db.update_followup(
|
|
139
|
+
str(item["id"]),
|
|
140
|
+
status="expired",
|
|
141
|
+
history_actor="followup-hygiene",
|
|
142
|
+
history_event="expired",
|
|
143
|
+
history_note=(
|
|
144
|
+
f"Weekly hygiene expired stale_review followup after "
|
|
145
|
+
f"{EXPIRE_STALE_REVIEW_DAYS} days without movement."
|
|
146
|
+
),
|
|
147
|
+
)
|
|
148
|
+
if not result.get("error"):
|
|
149
|
+
expired_ids.append(str(item["id"]))
|
|
150
|
+
|
|
151
|
+
# 4. Orphaned followups (no date, no recent update)
|
|
122
152
|
orphans = conn.execute(
|
|
123
153
|
"SELECT id, description FROM followups "
|
|
124
154
|
"WHERE status NOT LIKE 'COMPLETED%' "
|
|
125
|
-
"AND status NOT IN ('DELETED','
|
|
155
|
+
"AND UPPER(COALESCE(status, '')) NOT IN ('DELETED','ARCHIVED','BLOCKED','WAITING','WAITING_EXTERNAL','NEEDS_DECISION','WAITING_USER','PARKED','STALE_REVIEW','EXPIRED','DONE') "
|
|
126
156
|
"AND (date IS NULL OR date = '') "
|
|
127
157
|
"ORDER BY id"
|
|
128
158
|
).fetchall()
|
|
@@ -141,9 +171,11 @@ def main():
|
|
|
141
171
|
"dirty_normalized": dirty_f + dirty_r,
|
|
142
172
|
"stale_count": len(stale) if stale else 0,
|
|
143
173
|
"stale_escalated_count": len(escalated_stale),
|
|
174
|
+
"expired_count": len(expired_ids),
|
|
144
175
|
"orphan_count": len(orphans) if orphans else 0,
|
|
145
176
|
"stale_ids": [s["id"] for s in stale[:20]] if stale else [],
|
|
146
177
|
"stale_escalated_ids": escalated_stale[:20],
|
|
178
|
+
"expired_ids": expired_ids[:20],
|
|
147
179
|
"orphan_ids": [o["id"] for o in orphans[:20]] if orphans else [],
|
|
148
180
|
}
|
|
149
181
|
|
|
@@ -151,7 +183,7 @@ def main():
|
|
|
151
183
|
summary_file.parent.mkdir(parents=True, exist_ok=True)
|
|
152
184
|
summary_file.write_text(json.dumps(summary, indent=2))
|
|
153
185
|
|
|
154
|
-
log(f"Summary: {dirty_f + dirty_r} normalized, {len(escalated_stale)} stale escalated, {len(orphans) if orphans else 0} orphans")
|
|
186
|
+
log(f"Summary: {dirty_f + dirty_r} normalized, {len(escalated_stale)} stale escalated, {len(expired_ids)} expired, {len(orphans) if orphans else 0} orphans")
|
|
155
187
|
log("=== Followup Hygiene complete ===")
|
|
156
188
|
|
|
157
189
|
|
|
@@ -73,7 +73,7 @@ RESULTS_FILE = data_dir() / "followup-runner-results.json"
|
|
|
73
73
|
CLI_TIMEOUT = AUTOMATION_SUBPROCESS_TIMEOUT
|
|
74
74
|
LOCK_FILE = LOG_DIR / "followup-runner.lock"
|
|
75
75
|
MAX_FOLLOWUPS_PER_RUN = 5 # Focus: Opus can actually execute 5, not 30
|
|
76
|
-
COOLDOWN_DAYS = 3 # Don't retry
|
|
76
|
+
COOLDOWN_DAYS = 3 # Don't retry waiting_user/stale_review/blocked for 3 days
|
|
77
77
|
STALE_FOLLOWUP_TRIAGE_DAYS = 14
|
|
78
78
|
MAX_STALE_TRIAGE_PER_RUN = 8
|
|
79
79
|
MAX_NEEDS_OPERATOR_BRIEFING = 12
|
|
@@ -134,7 +134,7 @@ def _history_has_recent_movement(history, *, days: int = STALE_FOLLOWUP_TRIAGE_D
|
|
|
134
134
|
|
|
135
135
|
def _is_stale_followup_for_triage(followup: dict) -> bool:
|
|
136
136
|
status = str(followup.get("status") or "").strip().lower()
|
|
137
|
-
if status in {"needs_decision", "waiting_user", "blocked", "waiting"}:
|
|
137
|
+
if status in {"needs_decision", "waiting_user", "blocked", "waiting", "stale_review"}:
|
|
138
138
|
return False
|
|
139
139
|
if _followup_days_overdue(str(followup.get("date") or "")) < STALE_FOLLOWUP_TRIAGE_DAYS:
|
|
140
140
|
return False
|
|
@@ -148,7 +148,7 @@ def _is_in_cooldown(fu_id: str, state: dict) -> bool:
|
|
|
148
148
|
if not last:
|
|
149
149
|
return False
|
|
150
150
|
last_status = last.get("status", "")
|
|
151
|
-
if last_status not in ("needs_decision", "blocked"):
|
|
151
|
+
if last_status not in ("needs_decision", "waiting_user", "stale_review", "blocked"):
|
|
152
152
|
return False
|
|
153
153
|
last_date_str = last.get("date", "")
|
|
154
154
|
if not last_date_str:
|
|
@@ -298,17 +298,17 @@ def get_all_active_followups(state: dict) -> dict:
|
|
|
298
298
|
conn = sqlite3.connect(str(NEXO_DB))
|
|
299
299
|
conn.row_factory = sqlite3.Row
|
|
300
300
|
try:
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
)
|
|
301
|
+
snapshot = nexo_db.followup_lifecycle_snapshot(limit=5000)
|
|
302
|
+
rows = [
|
|
303
|
+
item for item in (snapshot.get("lanes") or {}).get("active", [])
|
|
304
|
+
if not str(item.get("description") or "").startswith("[Abandoned]")
|
|
305
|
+
]
|
|
306
|
+
rows.sort(
|
|
307
|
+
key=lambda item: (
|
|
308
|
+
{"critical": 1, "high": 2, "medium": 3, "low": 4}.get(str(item.get("priority") or "medium"), 5),
|
|
309
|
+
str(item.get("date") or "9999-12-31"),
|
|
310
|
+
)
|
|
311
|
+
)
|
|
312
312
|
|
|
313
313
|
result = {"actionable": [], "needs_operator": [], "future": [], "backlog": [], "cooled_down": [], "stale_triage": []}
|
|
314
314
|
undated_triage_budget = 2
|
|
@@ -429,7 +429,7 @@ def complete_followup_if_needed(fu_id: str, result_summary: str = ""):
|
|
|
429
429
|
return
|
|
430
430
|
try:
|
|
431
431
|
nexo_db.complete_followup(fu_id, result_summary)
|
|
432
|
-
log(f" {fu_id}:
|
|
432
|
+
log(f" {fu_id}: marked completed por el runner")
|
|
433
433
|
except Exception as exc:
|
|
434
434
|
log(f" {fu_id}: failed to mark followup as completed ({exc})")
|
|
435
435
|
|
|
@@ -488,7 +488,7 @@ def attention_reminder_id(fu_id: str) -> str:
|
|
|
488
488
|
|
|
489
489
|
|
|
490
490
|
def attention_reminder_category(status: str) -> str:
|
|
491
|
-
return "decisions" if status
|
|
491
|
+
return "decisions" if status in {"needs_decision", "waiting_user", "stale_review"} else "waiting"
|
|
492
492
|
|
|
493
493
|
|
|
494
494
|
def attention_reminder_description(
|
|
@@ -502,14 +502,14 @@ def attention_reminder_description(
|
|
|
502
502
|
detail = " ".join((summary or "").split())
|
|
503
503
|
if not detail:
|
|
504
504
|
detail = (
|
|
505
|
-
"
|
|
505
|
+
"The runner cannot close this item without operator input."
|
|
506
506
|
if _uses_spanish(operator_language)
|
|
507
507
|
else "The runner cannot close this item without operator input."
|
|
508
508
|
)
|
|
509
509
|
description = f"{fu_id}: {detail}"
|
|
510
510
|
opts_text = render_options(options)
|
|
511
511
|
if opts_text:
|
|
512
|
-
description += f" {'
|
|
512
|
+
description += f" {'Options' if _uses_spanish(operator_language) else 'Options'}: {opts_text}"
|
|
513
513
|
return description[:480]
|
|
514
514
|
|
|
515
515
|
|
|
@@ -548,7 +548,7 @@ def upsert_attention_reminder(
|
|
|
548
548
|
log(f" {fu_id}: failed to update reminder {reminder_id} ({result['error']})")
|
|
549
549
|
return
|
|
550
550
|
nexo_db.add_reminder_note(reminder_id, description, actor="followup-runner")
|
|
551
|
-
log(f" {fu_id}: reminder {reminder_id}
|
|
551
|
+
log(f" {fu_id}: reminder {reminder_id} updated for orchestrator")
|
|
552
552
|
return
|
|
553
553
|
|
|
554
554
|
result = nexo_db.create_reminder(
|
|
@@ -565,7 +565,7 @@ def upsert_attention_reminder(
|
|
|
565
565
|
f"source_followup={fu_id} status={status}",
|
|
566
566
|
actor="followup-runner",
|
|
567
567
|
)
|
|
568
|
-
log(f" {fu_id}: reminder {reminder_id}
|
|
568
|
+
log(f" {fu_id}: reminder {reminder_id} created for orchestrator")
|
|
569
569
|
|
|
570
570
|
|
|
571
571
|
def resolve_attention_reminder(fu_id: str, *, resolution: str = ""):
|
|
@@ -579,14 +579,14 @@ def resolve_attention_reminder(fu_id: str, *, resolution: str = ""):
|
|
|
579
579
|
if resolution:
|
|
580
580
|
nexo_db.add_reminder_note(
|
|
581
581
|
reminder_id,
|
|
582
|
-
f"
|
|
582
|
+
f"Resolved from {fu_id}: {resolution[:300]}",
|
|
583
583
|
actor="followup-runner",
|
|
584
584
|
)
|
|
585
585
|
result = nexo_db.complete_reminder(reminder_id)
|
|
586
586
|
if result.get("error"):
|
|
587
587
|
log(f" {fu_id}: failed to complete reminder {reminder_id} ({result['error']})")
|
|
588
588
|
return
|
|
589
|
-
log(f" {fu_id}: reminder {reminder_id}
|
|
589
|
+
log(f" {fu_id}: reminder {reminder_id} marked completed")
|
|
590
590
|
|
|
591
591
|
|
|
592
592
|
def defer_followup_after_attention(
|
|
@@ -602,7 +602,7 @@ def defer_followup_after_attention(
|
|
|
602
602
|
details = summary.strip()
|
|
603
603
|
opts_text = render_options(options)
|
|
604
604
|
if opts_text:
|
|
605
|
-
details = f"{details}\
|
|
605
|
+
details = f"{details}\nOptions: {opts_text}"
|
|
606
606
|
if details:
|
|
607
607
|
note_result = nexo_db.add_followup_note(
|
|
608
608
|
fu_id,
|
|
@@ -690,10 +690,10 @@ def get_recent_activity(hours: int = 24) -> str:
|
|
|
690
690
|
|
|
691
691
|
# Recent followup notes from the runner
|
|
692
692
|
notes = conn.execute(
|
|
693
|
-
"SELECT followup_id, note, created_at FROM
|
|
694
|
-
"WHERE actor='followup-runner' AND created_at >=
|
|
693
|
+
"SELECT item_id AS followup_id, note, created_at FROM item_history "
|
|
694
|
+
"WHERE item_type='followup' AND actor='followup-runner' AND created_at >= ? "
|
|
695
695
|
"ORDER BY created_at DESC LIMIT 10",
|
|
696
|
-
(
|
|
696
|
+
((datetime.now() - timedelta(hours=hours)).timestamp(),),
|
|
697
697
|
).fetchall()
|
|
698
698
|
if notes:
|
|
699
699
|
lines.append("\nFOLLOWUP NOTES WRITTEN (last 24h):")
|
|
@@ -821,7 +821,7 @@ def main():
|
|
|
821
821
|
update_followup_fields(
|
|
822
822
|
fid,
|
|
823
823
|
date_value=date.today().isoformat(),
|
|
824
|
-
status="
|
|
824
|
+
status="stale_review",
|
|
825
825
|
history_event="stale_triage",
|
|
826
826
|
history_note=summary,
|
|
827
827
|
)
|
|
@@ -829,10 +829,10 @@ def main():
|
|
|
829
829
|
fid,
|
|
830
830
|
summary=summary,
|
|
831
831
|
options={"a": "close obsolete", "b": "reschedule", "c": "convert to next action"},
|
|
832
|
-
status="
|
|
832
|
+
status="stale_review",
|
|
833
833
|
operator_language=_operator_language(),
|
|
834
834
|
)
|
|
835
|
-
record_attempt(state, fid, "
|
|
835
|
+
record_attempt(state, fid, "stale_review")
|
|
836
836
|
|
|
837
837
|
results = []
|
|
838
838
|
|
|
@@ -914,7 +914,7 @@ def main():
|
|
|
914
914
|
advance_recurrent(fid, recurrence, summary)
|
|
915
915
|
resolve_attention_reminder(fid, resolution=summary)
|
|
916
916
|
record_attempt(state, fid, "checked")
|
|
917
|
-
elif r["status"] in ("needs_decision", "blocked"):
|
|
917
|
+
elif r["status"] in ("needs_decision", "waiting_user", "stale_review", "blocked"):
|
|
918
918
|
defer_followup_after_attention(
|
|
919
919
|
fid,
|
|
920
920
|
summary=summary,
|
|
@@ -929,7 +929,7 @@ def main():
|
|
|
929
929
|
|
|
930
930
|
total = len(all_actionable) + len(groups["needs_operator"]) + len(groups["future"]) + len(groups["backlog"]) + len(stale_triage)
|
|
931
931
|
attention_handed_off = any(
|
|
932
|
-
r.get("needs_attention") or r["status"] in ("needs_decision", "blocked")
|
|
932
|
+
r.get("needs_attention") or r["status"] in ("needs_decision", "waiting_user", "stale_review", "blocked")
|
|
933
933
|
for r in results
|
|
934
934
|
)
|
|
935
935
|
if total > 0 or results:
|
|
@@ -65,7 +65,7 @@ if [ -n "$MESSAGES" ]; then
|
|
|
65
65
|
fi
|
|
66
66
|
|
|
67
67
|
if [ -n "$QUESTIONS" ]; then
|
|
68
|
-
echo " ⚠
|
|
68
|
+
echo " ⚠ QUESTIONS from another terminal — answer with nexo_answer:"
|
|
69
69
|
echo "$QUESTIONS" | while IFS='|' read -r qid from question; do
|
|
70
70
|
echo " Q[$qid] de [$from]: $question"
|
|
71
71
|
done
|