nexo-brain 7.23.13 → 7.25.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 +15 -11
- package/bin/nexo-brain.js +42 -235
- package/package.json +1 -1
- package/src/auto_update.py +30 -0
- package/src/automation_supervisor.py +1 -1
- package/src/cli.py +255 -9
- package/src/cognitive_control_observatory.py +224 -0
- package/src/crons/manifest.json +13 -0
- package/src/dashboard/app.py +26 -9
- package/src/db/__init__.py +2 -0
- package/src/db/_fts.py +38 -8
- 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 +48 -2
- package/src/doctor/providers/runtime.py +69 -0
- 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_fabric.py +536 -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 +120 -3
- 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-backup.sh +30 -0
- 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/scripts/nexo-memory-fabric.py +45 -0
- 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/src/tools_transcripts.py +50 -8
- package/src/transcript_index.py +105 -2
- package/src/transcript_utils.py +65 -13
- 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
|
@@ -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
|
|
@@ -62,6 +62,7 @@ DB_PATH = data_dir() / "nexo.db"
|
|
|
62
62
|
|
|
63
63
|
from agent_runner import AutomationBackendUnavailableError, run_automation_prompt
|
|
64
64
|
from core_prompts import render_core_prompt
|
|
65
|
+
from learning_resolver import resolve_learning_candidate
|
|
65
66
|
|
|
66
67
|
try:
|
|
67
68
|
from client_preferences import resolve_user_model as _resolve_user_model
|
|
@@ -79,12 +80,12 @@ def get_all_learnings(category: str | None = None) -> list[dict]:
|
|
|
79
80
|
conn.row_factory = sqlite3.Row
|
|
80
81
|
if category:
|
|
81
82
|
rows = conn.execute(
|
|
82
|
-
"SELECT id, category, title, content FROM learnings WHERE category = ?",
|
|
83
|
+
"SELECT id, category, title, content FROM learnings WHERE category = ? AND COALESCE(status, 'active') = 'active'",
|
|
83
84
|
(category,),
|
|
84
85
|
).fetchall()
|
|
85
86
|
else:
|
|
86
87
|
rows = conn.execute(
|
|
87
|
-
"SELECT id, category, title, content FROM learnings"
|
|
88
|
+
"SELECT id, category, title, content FROM learnings WHERE COALESCE(status, 'active') = 'active'"
|
|
88
89
|
).fetchall()
|
|
89
90
|
conn.close()
|
|
90
91
|
return [dict(r) for r in rows]
|
|
@@ -131,6 +132,12 @@ def validate_finding(finding: str, category: str | None = None) -> dict:
|
|
|
131
132
|
"recommendation": str
|
|
132
133
|
}
|
|
133
134
|
"""
|
|
135
|
+
resolver = resolve_learning_candidate(
|
|
136
|
+
category=category or "process",
|
|
137
|
+
title=(finding or "Finding").strip()[:120] or "Finding",
|
|
138
|
+
content=finding or "",
|
|
139
|
+
source_authority="code_test_evidence" if any(token in (finding or "").lower() for token in ("test", "traceback", "stack", "verified", "evidence")) else "inference",
|
|
140
|
+
)
|
|
134
141
|
learnings = get_all_learnings(category)
|
|
135
142
|
|
|
136
143
|
if not learnings:
|
|
@@ -139,6 +146,7 @@ def validate_finding(finding: str, category: str | None = None) -> dict:
|
|
|
139
146
|
"confidence": 0,
|
|
140
147
|
"matching_learnings": [],
|
|
141
148
|
"recommendation": "No learnings in DB — finding is new by default",
|
|
149
|
+
"resolver": resolver,
|
|
142
150
|
}
|
|
143
151
|
|
|
144
152
|
learnings_ref = [
|
|
@@ -168,13 +176,26 @@ def validate_finding(finding: str, category: str | None = None) -> dict:
|
|
|
168
176
|
)
|
|
169
177
|
parsed = _extract_json(result.stdout)
|
|
170
178
|
if result.returncode == 0 and parsed:
|
|
179
|
+
parsed["resolver"] = resolver
|
|
171
180
|
return parsed
|
|
172
181
|
except AutomationBackendUnavailableError:
|
|
173
182
|
pass
|
|
174
183
|
except Exception:
|
|
175
184
|
pass
|
|
176
185
|
|
|
177
|
-
|
|
186
|
+
result = _mechanical_validate(finding, learnings)
|
|
187
|
+
result["resolver"] = resolver
|
|
188
|
+
if resolver.get("action") in {"merge", "supersede", "conflict_review"}:
|
|
189
|
+
result["known"] = True
|
|
190
|
+
result["confidence"] = max(float(result.get("confidence") or 0), float(resolver.get("similarity") or 0.7))
|
|
191
|
+
result["matching_learnings"] = result.get("matching_learnings") or [{
|
|
192
|
+
"id": resolver.get("target_id"),
|
|
193
|
+
"category": category or "process",
|
|
194
|
+
"title": resolver.get("target_title"),
|
|
195
|
+
"similarity": resolver.get("similarity"),
|
|
196
|
+
}]
|
|
197
|
+
result["recommendation"] = f"Resolver action: {resolver.get('action')} ({resolver.get('reason')})"
|
|
198
|
+
return result
|
|
178
199
|
|
|
179
200
|
|
|
180
201
|
def _mechanical_validate(finding: str, learnings: list[dict]) -> dict:
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# nexo: name=memory-fabric
|
|
3
|
+
# nexo: description=Refresh transcript search, historical backup diaries, and graph links.
|
|
4
|
+
# nexo: runtime=python
|
|
5
|
+
# nexo: cron_id=memory-fabric
|
|
6
|
+
# nexo: schedule=02:35
|
|
7
|
+
# nexo: recovery_policy=catchup
|
|
8
|
+
# nexo: run_on_boot=true
|
|
9
|
+
# nexo: run_on_wake=true
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
import sys
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
RUNTIME_ROOT = Path(__file__).resolve().parents[1]
|
|
19
|
+
if str(RUNTIME_ROOT) not in sys.path:
|
|
20
|
+
sys.path.insert(0, str(RUNTIME_ROOT))
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _int_env(name: str, default: int) -> int:
|
|
24
|
+
raw = os.environ.get(name, "").strip()
|
|
25
|
+
if not raw:
|
|
26
|
+
return default
|
|
27
|
+
try:
|
|
28
|
+
return max(1, int(raw))
|
|
29
|
+
except ValueError:
|
|
30
|
+
return default
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def main() -> int:
|
|
34
|
+
import memory_fabric
|
|
35
|
+
|
|
36
|
+
result = memory_fabric.repair_memory_fabric(
|
|
37
|
+
transcript_limit=_int_env("NEXO_MEMORY_FABRIC_TRANSCRIPT_LIMIT", 1000),
|
|
38
|
+
backup_limit=_int_env("NEXO_MEMORY_FABRIC_BACKUP_LIMIT", 10000),
|
|
39
|
+
)
|
|
40
|
+
print(json.dumps(result, ensure_ascii=False, sort_keys=True))
|
|
41
|
+
return 0
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
if __name__ == "__main__":
|
|
45
|
+
raise SystemExit(main())
|
package/src/server.py
CHANGED
|
@@ -79,7 +79,7 @@ from tools_reminders_crud import (
|
|
|
79
79
|
from tools_learnings import (
|
|
80
80
|
handle_learning_add, handle_learning_search,
|
|
81
81
|
handle_learning_update, handle_learning_delete, handle_learning_list,
|
|
82
|
-
handle_learning_quality,
|
|
82
|
+
handle_learning_quality, handle_learning_resolve_candidate,
|
|
83
83
|
)
|
|
84
84
|
from tools_credentials import (
|
|
85
85
|
handle_credential_get, handle_credential_create,
|
|
@@ -1089,6 +1089,19 @@ def nexo_context_router(query: str, intent: str = "answer", limit: int = 4, curr
|
|
|
1089
1089
|
return json.dumps(result, ensure_ascii=False)
|
|
1090
1090
|
|
|
1091
1091
|
|
|
1092
|
+
@mcp.tool
|
|
1093
|
+
def nexo_cognitive_control_observatory(window_seconds: int = 86400) -> str:
|
|
1094
|
+
"""Read-only metrics for Local Context, learnings, followups and intraday memory."""
|
|
1095
|
+
from cognitive_control_observatory import build_cognitive_control_observatory
|
|
1096
|
+
|
|
1097
|
+
return json.dumps(
|
|
1098
|
+
build_cognitive_control_observatory(window_seconds=window_seconds),
|
|
1099
|
+
ensure_ascii=False,
|
|
1100
|
+
indent=2,
|
|
1101
|
+
sort_keys=True,
|
|
1102
|
+
)
|
|
1103
|
+
|
|
1104
|
+
|
|
1092
1105
|
@mcp.tool
|
|
1093
1106
|
def nexo_local_asset_get(asset_id: str) -> str:
|
|
1094
1107
|
"""Return one indexed local asset by asset id."""
|
|
@@ -1386,6 +1399,23 @@ def nexo_memory_observation_process(limit: int = 25, backfill_limit: int = 100,
|
|
|
1386
1399
|
)
|
|
1387
1400
|
|
|
1388
1401
|
|
|
1402
|
+
@mcp.tool
|
|
1403
|
+
def nexo_intraday_memory_cycle(limit: int = 20, backfill_limit: int = 20, pending_sla_seconds: int = 3600) -> str:
|
|
1404
|
+
"""Run a low-limit daytime memory observation cycle that only publishes evidence-backed intraday facts."""
|
|
1405
|
+
from memory_observation_processor import process_intraday_cycle
|
|
1406
|
+
|
|
1407
|
+
return json.dumps(
|
|
1408
|
+
process_intraday_cycle(
|
|
1409
|
+
process_limit=limit,
|
|
1410
|
+
backfill_limit=backfill_limit,
|
|
1411
|
+
pending_sla_seconds=pending_sla_seconds,
|
|
1412
|
+
),
|
|
1413
|
+
ensure_ascii=False,
|
|
1414
|
+
indent=2,
|
|
1415
|
+
sort_keys=True,
|
|
1416
|
+
)
|
|
1417
|
+
|
|
1418
|
+
|
|
1389
1419
|
@mcp.tool
|
|
1390
1420
|
def nexo_memory_observation_list(
|
|
1391
1421
|
query: str = "",
|
|
@@ -1968,6 +1998,19 @@ def nexo_followup_get(id: str) -> str:
|
|
|
1968
1998
|
return handle_followup_get(id)
|
|
1969
1999
|
|
|
1970
2000
|
|
|
2001
|
+
@mcp.tool
|
|
2002
|
+
def nexo_followup_lifecycle(limit: int = 500) -> str:
|
|
2003
|
+
"""Return followups grouped by lifecycle lane for runner, dashboard and startup parity."""
|
|
2004
|
+
from db import followup_lifecycle_snapshot
|
|
2005
|
+
|
|
2006
|
+
return json.dumps(
|
|
2007
|
+
followup_lifecycle_snapshot(limit=limit),
|
|
2008
|
+
ensure_ascii=False,
|
|
2009
|
+
indent=2,
|
|
2010
|
+
sort_keys=True,
|
|
2011
|
+
)
|
|
2012
|
+
|
|
2013
|
+
|
|
1971
2014
|
@mcp.tool
|
|
1972
2015
|
def nexo_followup_update(
|
|
1973
2016
|
id: str,
|
|
@@ -2066,6 +2109,7 @@ def nexo_learning_add(
|
|
|
2066
2109
|
review_days: int = 30,
|
|
2067
2110
|
priority: str = "medium",
|
|
2068
2111
|
supersedes_id: int = 0,
|
|
2112
|
+
source_authority: str = "explicit_instruction",
|
|
2069
2113
|
) -> str:
|
|
2070
2114
|
"""Add a new learning (resolved error, pattern, gotcha).
|
|
2071
2115
|
|
|
@@ -2079,11 +2123,39 @@ def nexo_learning_add(
|
|
|
2079
2123
|
review_days: Days until this learning should be reviewed again (default 30).
|
|
2080
2124
|
priority: critical, high, medium, low (default: medium). Critical/high never decay below floor.
|
|
2081
2125
|
supersedes_id: Existing learning ID this new canonical rule replaces (optional).
|
|
2126
|
+
source_authority: Authority tier for conflict resolution: francisco_correction, explicit_instruction, code_test_evidence, deep_sleep, inference.
|
|
2082
2127
|
"""
|
|
2083
2128
|
return handle_learning_add(
|
|
2084
2129
|
category, title, content, reasoning,
|
|
2085
2130
|
prevention=prevention, applies_to=applies_to,
|
|
2086
2131
|
review_days=review_days, priority=priority, supersedes_id=supersedes_id,
|
|
2132
|
+
source_authority=source_authority,
|
|
2133
|
+
)
|
|
2134
|
+
|
|
2135
|
+
|
|
2136
|
+
@mcp.tool
|
|
2137
|
+
def nexo_learning_resolve_candidate(
|
|
2138
|
+
category: str,
|
|
2139
|
+
title: str,
|
|
2140
|
+
content: str,
|
|
2141
|
+
reasoning: str = "",
|
|
2142
|
+
prevention: str = "",
|
|
2143
|
+
applies_to: str = "",
|
|
2144
|
+
priority: str = "medium",
|
|
2145
|
+
supersedes_id: int = 0,
|
|
2146
|
+
source_authority: str = "inference",
|
|
2147
|
+
) -> str:
|
|
2148
|
+
"""Dry-run the canonical learning resolver without creating or updating learnings."""
|
|
2149
|
+
return handle_learning_resolve_candidate(
|
|
2150
|
+
category=category,
|
|
2151
|
+
title=title,
|
|
2152
|
+
content=content,
|
|
2153
|
+
reasoning=reasoning,
|
|
2154
|
+
prevention=prevention,
|
|
2155
|
+
applies_to=applies_to,
|
|
2156
|
+
priority=priority,
|
|
2157
|
+
supersedes_id=supersedes_id,
|
|
2158
|
+
source_authority=source_authority,
|
|
2087
2159
|
)
|
|
2088
2160
|
|
|
2089
2161
|
|
package/src/system_catalog.py
CHANGED
|
@@ -262,110 +262,110 @@ def _guide_for_tool(name: str) -> dict[str, list]:
|
|
|
262
262
|
if name == "nexo_learning_add":
|
|
263
263
|
return {
|
|
264
264
|
"workflow": [
|
|
265
|
-
"
|
|
266
|
-
"
|
|
265
|
+
"Use `applies_to` if you want the pre-edit check to surface this learning before touching a concrete file, directory, or pattern.",
|
|
266
|
+
"Use `priority` (`critical`, `high`, `medium`, `low`) to mark operational severity.",
|
|
267
267
|
],
|
|
268
268
|
"examples": [
|
|
269
269
|
{
|
|
270
|
-
"title": "
|
|
271
|
-
"code": 'nexo_learning_add(category="shopify", title="
|
|
270
|
+
"title": "Minimal learning",
|
|
271
|
+
"code": 'nexo_learning_add(category="shopify", title="Pull before editing", content="Always sync before editing the live theme.")',
|
|
272
272
|
},
|
|
273
273
|
{
|
|
274
|
-
"title": "Learning
|
|
275
|
-
"code": 'nexo_learning_add(category="recambios-bmw", title="Pull
|
|
274
|
+
"title": "Learning linked to a file or pattern",
|
|
275
|
+
"code": 'nexo_learning_add(category="recambios-bmw", title="Pull before editing theme", content="The admin can touch live JSON files.", applies_to="/abs/path/templates/product.json,templates/*.json,sections/*.liquid", prevention="Run `shopify theme pull` before editing.", priority="high")',
|
|
276
276
|
},
|
|
277
277
|
],
|
|
278
278
|
"common_errors": [
|
|
279
|
-
"
|
|
280
|
-
"
|
|
281
|
-
"
|
|
279
|
+
"Using `severity` instead of `priority`.",
|
|
280
|
+
"Omitting `title`, which is required.",
|
|
281
|
+
"Omitting `applies_to` when you want the warning to appear before touching concrete files.",
|
|
282
282
|
],
|
|
283
283
|
}
|
|
284
284
|
if name == "nexo_learning_update":
|
|
285
285
|
return {
|
|
286
286
|
"workflow": [
|
|
287
|
-
"
|
|
287
|
+
"Use it to complete or harden an existing learning when you discover newly affected files, a better `prevention`, or a different priority.",
|
|
288
288
|
],
|
|
289
289
|
"examples": [
|
|
290
290
|
{
|
|
291
|
-
"title": "
|
|
292
|
-
"code": 'nexo_learning_update(id=57, applies_to="/abs/path/file.py,src/plugins/*.py", prevention="
|
|
291
|
+
"title": "Add scope to an existing learning",
|
|
292
|
+
"code": 'nexo_learning_update(id=57, applies_to="/abs/path/file.py,src/plugins/*.py", prevention="Read the schema before first use", priority="high")',
|
|
293
293
|
},
|
|
294
294
|
],
|
|
295
295
|
"common_errors": [
|
|
296
|
-
"
|
|
296
|
+
"Recreating the learning from scratch when updating the existing one is enough.",
|
|
297
297
|
],
|
|
298
298
|
}
|
|
299
299
|
if name == "nexo_reminder_get":
|
|
300
300
|
return {
|
|
301
301
|
"workflow": [
|
|
302
|
-
"
|
|
302
|
+
"Returns the `READ_TOKEN` required for `update`, `delete`, `restore`, and `note` on that reminder.",
|
|
303
303
|
],
|
|
304
304
|
"examples": [
|
|
305
305
|
{
|
|
306
|
-
"title": "
|
|
306
|
+
"title": "Read reminder and get token",
|
|
307
307
|
"code": 'nexo_reminder_get(id="R87")',
|
|
308
308
|
},
|
|
309
309
|
],
|
|
310
310
|
"common_errors": [
|
|
311
|
-
"
|
|
311
|
+
"Trying to edit or delete a reminder without calling `nexo_reminder_get` first.",
|
|
312
312
|
],
|
|
313
313
|
}
|
|
314
314
|
if name in {"nexo_reminder_update", "nexo_reminder_delete", "nexo_reminder_restore", "nexo_reminder_note"}:
|
|
315
315
|
return {
|
|
316
316
|
"workflow": [
|
|
317
|
-
"
|
|
318
|
-
f"
|
|
317
|
+
"First call `nexo_reminder_get(id=\"R87\")` to obtain `READ_TOKEN`.",
|
|
318
|
+
f"Then reuse that `READ_TOKEN` in `{name}(...)`.",
|
|
319
319
|
],
|
|
320
320
|
"examples": [
|
|
321
321
|
{
|
|
322
|
-
"title": "1.
|
|
322
|
+
"title": "1. Get token",
|
|
323
323
|
"code": 'nexo_reminder_get(id="R87")',
|
|
324
324
|
},
|
|
325
325
|
{
|
|
326
|
-
"title": "2.
|
|
326
|
+
"title": "2. Reuse READ_TOKEN",
|
|
327
327
|
"code": f'{name}(id="R87", read_token="TOKEN")',
|
|
328
328
|
},
|
|
329
329
|
],
|
|
330
330
|
"common_errors": [
|
|
331
|
-
"
|
|
332
|
-
"
|
|
331
|
+
"Calling this tool without a valid `READ_TOKEN`.",
|
|
332
|
+
"Using a `READ_TOKEN` from another reminder or an older read.",
|
|
333
333
|
],
|
|
334
334
|
}
|
|
335
335
|
if name == "nexo_followup_get":
|
|
336
336
|
return {
|
|
337
337
|
"workflow": [
|
|
338
|
-
"
|
|
338
|
+
"Returns the `READ_TOKEN` required for `update`, `delete`, `restore`, and `note` on that followup.",
|
|
339
339
|
],
|
|
340
340
|
"examples": [
|
|
341
341
|
{
|
|
342
|
-
"title": "
|
|
342
|
+
"title": "Read followup and get token",
|
|
343
343
|
"code": 'nexo_followup_get(id="NF45")',
|
|
344
344
|
},
|
|
345
345
|
],
|
|
346
346
|
"common_errors": [
|
|
347
|
-
"
|
|
347
|
+
"Trying to edit or delete a followup without calling `nexo_followup_get` first.",
|
|
348
348
|
],
|
|
349
349
|
}
|
|
350
350
|
if name in {"nexo_followup_update", "nexo_followup_delete", "nexo_followup_restore", "nexo_followup_note"}:
|
|
351
351
|
return {
|
|
352
352
|
"workflow": [
|
|
353
|
-
"
|
|
354
|
-
f"
|
|
353
|
+
"First call `nexo_followup_get(id=\"NF45\")` to obtain `READ_TOKEN`.",
|
|
354
|
+
f"Then reuse that `READ_TOKEN` in `{name}(...)`.",
|
|
355
355
|
],
|
|
356
356
|
"examples": [
|
|
357
357
|
{
|
|
358
|
-
"title": "1.
|
|
358
|
+
"title": "1. Get token",
|
|
359
359
|
"code": 'nexo_followup_get(id="NF45")',
|
|
360
360
|
},
|
|
361
361
|
{
|
|
362
|
-
"title": "2.
|
|
362
|
+
"title": "2. Reuse READ_TOKEN",
|
|
363
363
|
"code": f'{name}(id="NF45", read_token="TOKEN")',
|
|
364
364
|
},
|
|
365
365
|
],
|
|
366
366
|
"common_errors": [
|
|
367
|
-
"
|
|
368
|
-
"
|
|
367
|
+
"Calling this tool without a valid `READ_TOKEN`.",
|
|
368
|
+
"Using a `READ_TOKEN` from another followup or an older read.",
|
|
369
369
|
],
|
|
370
370
|
}
|
|
371
371
|
return {}
|