nexo-brain 7.12.15 → 7.13.5
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 +5 -1
- package/package.json +2 -2
- package/src/auto_close_sessions.py +37 -1
- package/src/client_sync.py +139 -0
- package/src/db/__init__.py +3 -0
- package/src/db/_protocol.py +156 -0
- package/src/db/_schema.py +23 -0
- package/src/doctor/providers/runtime.py +185 -5
- package/src/hook_guardrails.py +229 -6
- package/src/hooks/post_edit_change_log.py +136 -0
- package/src/hooks/post_tool_use.py +16 -0
- package/src/plugins/protocol.py +33 -0
- package/src/plugins/update.py +19 -0
- package/src/runtime_versioning.py +63 -0
- package/src/script_registry.py +214 -1
- package/src/scripts/nexo-daily-self-audit.py +54 -0
- package/src/scripts/nexo-email-monitor.py +1 -1
- package/src/scripts/nexo-followup-runner.py +4 -3
- package/src/tools_learnings.py +18 -2
- package/src/tools_sessions.py +43 -0
- package/src/tree_hygiene.py +8 -7
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nexo-brain",
|
|
3
|
-
"version": "7.
|
|
3
|
+
"version": "7.13.5",
|
|
4
4
|
"description": "Local cognitive runtime for Claude Code \u2014 persistent memory, overnight learning, doctor diagnostics, personal scripts, recovery-aware jobs, startup preflight, and optional dashboard/power helper.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "NEXO Brain",
|
package/README.md
CHANGED
|
@@ -18,7 +18,11 @@
|
|
|
18
18
|
|
|
19
19
|
[Watch the overview video](https://nexo-brain.com/watch/) · [Watch on YouTube](https://www.youtube.com/watch?v=i2lkGhKyVqI) · [Open the infographic](https://nexo-brain.com/assets/nexo-brain-infographic-v5.png)
|
|
20
20
|
|
|
21
|
-
Version `7.
|
|
21
|
+
Version `7.13.5` is the current packaged-runtime line. Corrective release — D.5 correction-learning enforcement is durable, doctor `--fix` explicitly repairs orphan personal schedule metadata, G5 now warns on protected `com.nexo.*.plist` unload/remove flows with the three-layer recovery path, and Codex now gets a managed `PreToolUse` guard for shell/exec_command calls plus `installation_live.codex_protocol_compliance` drift checks. Result: coordinated Desktop bundles can ship the fixed Brain without changing the Mac/Windows installation contract.
|
|
22
|
+
|
|
23
|
+
Previously in `7.13.3`: unified release — doctor now repairs orphan personal script metadata and ignores historical `versions/**` snapshots, `nexo update` prunes runtime snapshots older than two back, protocol compliance self-heals missing task-open/change-log/stale-session gaps, headless automation uses bounded timeouts, Guardian false positives are tightened, and Codex CLI config/default checks are release-gated. Result: coordinated Desktop bundles can ship the new Brain without changing the Mac/Windows installation contract.
|
|
24
|
+
|
|
25
|
+
Previously in `7.12.15`: patch release — same-version packaged updates now still run the safe maintenance path, Deep Sleep clears process locks on shutdown, sent replies are recorded in durable continuity, and personal script schedule-marker drift is surfaced during reconcile. Result: coordinated Desktop bundles can refresh Brain safely without breaking install/update parity on macOS, Windows via WSL, or Linux.
|
|
22
26
|
|
|
23
27
|
Previously in `7.12.0`: minor release — adds `nexo support-snapshot` for generic local runtime diagnostics and completes the silent-reminder hardening on the live Protocol Enforcer path. The support collector emits one JSON bundle with version/platform metadata, runtime path presence, health-check output, and recent event/operation tails, while map-driven reminders (`nexo_startup`, `nexo_smart_startup`, `nexo_heartbeat`, `nexo_reminders`, `nexo_session_diary_*`, `nexo_stop`, `nexo_task_close`, compaction checkpoint prompts) now say explicitly that silence owns the entire reminder turn.
|
|
24
28
|
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nexo-brain",
|
|
3
|
-
"version": "7.
|
|
3
|
+
"version": "7.13.5",
|
|
4
4
|
"mcpName": "io.github.wazionapps/nexo",
|
|
5
|
-
"description": "NEXO Brain
|
|
5
|
+
"description": "NEXO Brain — Shared brain for AI agents. Persistent memory, semantic RAG, natural forgetting, metacognitive guard, trust scoring, 150+ MCP tools. Works with Claude Code, Codex, Claude Desktop & any MCP client. 100% local, free.",
|
|
6
6
|
"homepage": "https://nexo-brain.com",
|
|
7
7
|
"bin": {
|
|
8
8
|
"nexo-brain": "bin/nexo-brain.js",
|
|
@@ -152,6 +152,36 @@ def promote_draft_to_diary(sid: str, draft: dict, task: str = ""):
|
|
|
152
152
|
delete_diary_draft(sid)
|
|
153
153
|
|
|
154
154
|
|
|
155
|
+
def auto_close_open_protocol_tasks(conn, sid: str, task: str = "") -> list[str]:
|
|
156
|
+
"""Close stale open protocol tasks as partial when their session is reaped."""
|
|
157
|
+
rows = conn.execute(
|
|
158
|
+
"""SELECT task_id, goal
|
|
159
|
+
FROM protocol_tasks
|
|
160
|
+
WHERE session_id = ? AND status = 'open'
|
|
161
|
+
ORDER BY opened_at ASC""",
|
|
162
|
+
(sid,),
|
|
163
|
+
).fetchall()
|
|
164
|
+
closed: list[str] = []
|
|
165
|
+
for row in rows:
|
|
166
|
+
task_id = row["task_id"]
|
|
167
|
+
goal = str(row["goal"] or "")
|
|
168
|
+
evidence = (
|
|
169
|
+
f"Auto-closed as partial because session {sid} became stale before an explicit nexo_task_close. "
|
|
170
|
+
f"Session task: {task or 'unknown'}. Open goal: {goal[:240]}"
|
|
171
|
+
)
|
|
172
|
+
conn.execute(
|
|
173
|
+
"""UPDATE protocol_tasks
|
|
174
|
+
SET status = 'partial',
|
|
175
|
+
close_evidence = ?,
|
|
176
|
+
outcome_notes = 'auto-close: stale session ended without explicit task_close',
|
|
177
|
+
closed_at = datetime('now')
|
|
178
|
+
WHERE task_id = ? AND status = 'open'""",
|
|
179
|
+
(evidence[:4000], task_id),
|
|
180
|
+
)
|
|
181
|
+
closed.append(task_id)
|
|
182
|
+
return closed
|
|
183
|
+
|
|
184
|
+
|
|
155
185
|
def main():
|
|
156
186
|
init_db()
|
|
157
187
|
conn = get_db()
|
|
@@ -161,9 +191,12 @@ def main():
|
|
|
161
191
|
print(f"[{datetime.datetime.now().isoformat(timespec='seconds')}] No stale sessions")
|
|
162
192
|
return
|
|
163
193
|
|
|
194
|
+
closed_task_ids: list[str] = []
|
|
164
195
|
for session in orphans:
|
|
165
196
|
sid = session["sid"]
|
|
166
197
|
draft = get_diary_draft(sid)
|
|
198
|
+
closed_tasks = auto_close_open_protocol_tasks(conn, sid, task=session.get("task", ""))
|
|
199
|
+
closed_task_ids.extend(closed_tasks)
|
|
167
200
|
|
|
168
201
|
if draft:
|
|
169
202
|
promote_draft_to_diary(sid, draft, task=session.get("task", ""))
|
|
@@ -196,7 +229,10 @@ def main():
|
|
|
196
229
|
os.makedirs(os.path.dirname(AUTO_CLOSE_LOG), exist_ok=True)
|
|
197
230
|
with open(AUTO_CLOSE_LOG, "a") as f:
|
|
198
231
|
ts = datetime.datetime.now().isoformat(timespec="seconds")
|
|
199
|
-
f.write(
|
|
232
|
+
f.write(
|
|
233
|
+
f"{ts} — auto-closed {len(orphans)} session(s): {[s['sid'] for s in orphans]} "
|
|
234
|
+
f"and {len(closed_task_ids)} protocol task(s): {closed_task_ids}\n"
|
|
235
|
+
)
|
|
200
236
|
|
|
201
237
|
|
|
202
238
|
if __name__ == "__main__":
|
package/src/client_sync.py
CHANGED
|
@@ -527,6 +527,11 @@ def _codex_config_path(home: Path | None = None) -> Path:
|
|
|
527
527
|
return base / ".codex" / "config.toml"
|
|
528
528
|
|
|
529
529
|
|
|
530
|
+
def _codex_hooks_path(home: Path | None = None) -> Path:
|
|
531
|
+
base = home or _user_home()
|
|
532
|
+
return base / ".codex" / "hooks.json"
|
|
533
|
+
|
|
534
|
+
|
|
530
535
|
def _toml_key(key: str) -> str:
|
|
531
536
|
if key.replace("_", "").replace("-", "").isalnum():
|
|
532
537
|
return key
|
|
@@ -632,6 +637,12 @@ def _sync_codex_managed_config(
|
|
|
632
637
|
if "reasoning_effort" in runtime_profile:
|
|
633
638
|
payload["model_reasoning_effort"] = runtime_profile.get("reasoning_effort") or ""
|
|
634
639
|
|
|
640
|
+
features = payload.setdefault("features", {})
|
|
641
|
+
if isinstance(features, dict):
|
|
642
|
+
features["codex_hooks"] = True
|
|
643
|
+
else:
|
|
644
|
+
payload["features"] = {"codex_hooks": True}
|
|
645
|
+
|
|
635
646
|
payload["initial_messages"] = [
|
|
636
647
|
{
|
|
637
648
|
"role": "system",
|
|
@@ -643,6 +654,7 @@ def _sync_codex_managed_config(
|
|
|
643
654
|
codex_table = nexo_table.setdefault("codex", {})
|
|
644
655
|
codex_table["bootstrap_managed"] = True
|
|
645
656
|
codex_table["mcp_managed"] = True
|
|
657
|
+
codex_table["hooks_managed"] = True
|
|
646
658
|
codex_table["bootstrap_bytes"] = len(bootstrap_prompt.encode("utf-8")) if bootstrap_prompt else 0
|
|
647
659
|
if runtime_profile.get("model"):
|
|
648
660
|
# Record the healed/actual model in use, not the raw (possibly Claude) profile.
|
|
@@ -670,11 +682,127 @@ def _sync_codex_managed_config(
|
|
|
670
682
|
"path": str(path),
|
|
671
683
|
"bootstrap_managed": True,
|
|
672
684
|
"mcp_managed": True,
|
|
685
|
+
"hooks_enabled": True,
|
|
673
686
|
"model": runtime_profile.get("model", ""),
|
|
674
687
|
"reasoning_effort": runtime_profile.get("reasoning_effort", "") or "",
|
|
675
688
|
}
|
|
676
689
|
|
|
677
690
|
|
|
691
|
+
CODEX_SUPPORTED_HOOK_EVENTS = {"PreToolUse"}
|
|
692
|
+
CODEX_PRETOOL_MATCHER = r"^(Bash|shell_command|exec_command)$"
|
|
693
|
+
|
|
694
|
+
|
|
695
|
+
def _codex_core_hook_specs(runtime_root: Path) -> list[dict]:
|
|
696
|
+
specs: list[dict] = []
|
|
697
|
+
for spec in _core_hook_specs(runtime_root):
|
|
698
|
+
if spec.get("event") not in CODEX_SUPPORTED_HOOK_EVENTS:
|
|
699
|
+
continue
|
|
700
|
+
item = dict(spec)
|
|
701
|
+
if item.get("event") == "PreToolUse":
|
|
702
|
+
item["matcher"] = CODEX_PRETOOL_MATCHER
|
|
703
|
+
item["statusMessage"] = "NEXO guard"
|
|
704
|
+
specs.append(item)
|
|
705
|
+
return specs
|
|
706
|
+
|
|
707
|
+
|
|
708
|
+
def _merge_codex_hooks(existing_hooks, *, runtime_root: Path, nexo_home: Path) -> tuple[dict, int]:
|
|
709
|
+
hooks_payload = dict(existing_hooks) if isinstance(existing_hooks, dict) else {}
|
|
710
|
+
hooks_dir = _resolve_hook_source_dir(runtime_root)
|
|
711
|
+
managed_count = 0
|
|
712
|
+
core_hook_specs = _codex_core_hook_specs(runtime_root)
|
|
713
|
+
current_identities_by_event = _current_core_hook_identities_by_event(core_hook_specs)
|
|
714
|
+
managed_identities_by_event = _managed_core_hook_identities_by_event(current_identities_by_event)
|
|
715
|
+
|
|
716
|
+
for event, managed_identities in managed_identities_by_event.items():
|
|
717
|
+
sections = _normalize_hook_sections(hooks_payload.get(event))
|
|
718
|
+
desired_identities = current_identities_by_event.get(event, set())
|
|
719
|
+
cleaned_sections: list[dict] = []
|
|
720
|
+
for section in sections:
|
|
721
|
+
cleaned_hooks = []
|
|
722
|
+
for hook in section["hooks"]:
|
|
723
|
+
identity = _hook_identity(hook.get("command", ""))
|
|
724
|
+
if identity in managed_identities and identity not in desired_identities:
|
|
725
|
+
continue
|
|
726
|
+
cleaned_hooks.append(hook)
|
|
727
|
+
if cleaned_hooks:
|
|
728
|
+
cleaned_sections.append(
|
|
729
|
+
{
|
|
730
|
+
"matcher": section.get("matcher", "*") or "*",
|
|
731
|
+
"hooks": cleaned_hooks,
|
|
732
|
+
}
|
|
733
|
+
)
|
|
734
|
+
if cleaned_sections:
|
|
735
|
+
hooks_payload[event] = cleaned_sections
|
|
736
|
+
elif event in hooks_payload and event in current_identities_by_event:
|
|
737
|
+
hooks_payload.pop(event, None)
|
|
738
|
+
|
|
739
|
+
for spec in core_hook_specs:
|
|
740
|
+
event = spec["event"]
|
|
741
|
+
sections = _normalize_hook_sections(hooks_payload.get(event))
|
|
742
|
+
hooks_payload[event] = sections
|
|
743
|
+
command = _render_hook_command(spec, nexo_home=nexo_home, runtime_root=runtime_root, hooks_dir=hooks_dir)
|
|
744
|
+
identity = spec["identity"]
|
|
745
|
+
matcher = spec.get("matcher") or "*"
|
|
746
|
+
|
|
747
|
+
found = False
|
|
748
|
+
for section in sections:
|
|
749
|
+
for hook in section["hooks"]:
|
|
750
|
+
if _hook_identity(hook.get("command", "")) != identity:
|
|
751
|
+
continue
|
|
752
|
+
section["matcher"] = matcher
|
|
753
|
+
hook["type"] = "command"
|
|
754
|
+
hook["command"] = command
|
|
755
|
+
if spec.get("timeout"):
|
|
756
|
+
hook["timeout"] = spec["timeout"]
|
|
757
|
+
if spec.get("statusMessage"):
|
|
758
|
+
hook["statusMessage"] = spec["statusMessage"]
|
|
759
|
+
found = True
|
|
760
|
+
managed_count += 1
|
|
761
|
+
break
|
|
762
|
+
if found:
|
|
763
|
+
break
|
|
764
|
+
|
|
765
|
+
if found:
|
|
766
|
+
continue
|
|
767
|
+
|
|
768
|
+
target = None
|
|
769
|
+
for section in sections:
|
|
770
|
+
if section.get("matcher", "*") == matcher:
|
|
771
|
+
target = section
|
|
772
|
+
break
|
|
773
|
+
if target is None:
|
|
774
|
+
target = {"matcher": matcher, "hooks": []}
|
|
775
|
+
sections.append(target)
|
|
776
|
+
|
|
777
|
+
new_hook = {"type": "command", "command": command}
|
|
778
|
+
if spec.get("timeout"):
|
|
779
|
+
new_hook["timeout"] = spec["timeout"]
|
|
780
|
+
if spec.get("statusMessage"):
|
|
781
|
+
new_hook["statusMessage"] = spec["statusMessage"]
|
|
782
|
+
target["hooks"].append(new_hook)
|
|
783
|
+
managed_count += 1
|
|
784
|
+
|
|
785
|
+
return hooks_payload, managed_count
|
|
786
|
+
|
|
787
|
+
|
|
788
|
+
def _sync_codex_hooks(path: Path, *, runtime_root: Path, nexo_home: Path) -> dict:
|
|
789
|
+
payload = _load_json_object(path)
|
|
790
|
+
action = "updated" if payload else "created"
|
|
791
|
+
payload["hooks"], managed_count = _merge_codex_hooks(
|
|
792
|
+
payload.get("hooks", {}),
|
|
793
|
+
runtime_root=runtime_root,
|
|
794
|
+
nexo_home=nexo_home,
|
|
795
|
+
)
|
|
796
|
+
_write_json_object(path, payload)
|
|
797
|
+
return {
|
|
798
|
+
"ok": True,
|
|
799
|
+
"action": action,
|
|
800
|
+
"path": str(path),
|
|
801
|
+
"managed_hook_count": managed_count,
|
|
802
|
+
"pretool_matcher": CODEX_PRETOOL_MATCHER,
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
|
|
678
806
|
def _load_json_object(path: Path) -> dict:
|
|
679
807
|
if not path.is_file():
|
|
680
808
|
return {}
|
|
@@ -1187,6 +1315,7 @@ def sync_codex(
|
|
|
1187
1315
|
)
|
|
1188
1316
|
codex_bin = shutil.which("codex")
|
|
1189
1317
|
config_path = _codex_config_path(home_path)
|
|
1318
|
+
hooks_path = _codex_hooks_path(home_path)
|
|
1190
1319
|
if not codex_bin:
|
|
1191
1320
|
result = {
|
|
1192
1321
|
"ok": True,
|
|
@@ -1210,6 +1339,11 @@ def sync_codex(
|
|
|
1210
1339
|
runtime_profile=runtime_profile,
|
|
1211
1340
|
server_config=server_config,
|
|
1212
1341
|
)
|
|
1342
|
+
result["hooks"] = _sync_codex_hooks(
|
|
1343
|
+
hooks_path,
|
|
1344
|
+
runtime_root=Path(runtime_root).expanduser() if runtime_root else _resolve_runtime_root(nexo_home_path),
|
|
1345
|
+
nexo_home=nexo_home_path,
|
|
1346
|
+
)
|
|
1213
1347
|
return result
|
|
1214
1348
|
|
|
1215
1349
|
cmd = [codex_bin, "mcp", "add", "nexo"]
|
|
@@ -1248,6 +1382,11 @@ def sync_codex(
|
|
|
1248
1382
|
runtime_profile=runtime_profile,
|
|
1249
1383
|
server_config=server_config,
|
|
1250
1384
|
)
|
|
1385
|
+
sync_result["hooks"] = _sync_codex_hooks(
|
|
1386
|
+
hooks_path,
|
|
1387
|
+
runtime_root=Path(runtime_root).expanduser() if runtime_root else _resolve_runtime_root(nexo_home_path),
|
|
1388
|
+
nexo_home=nexo_home_path,
|
|
1389
|
+
)
|
|
1251
1390
|
if result.returncode != 0:
|
|
1252
1391
|
sync_result["warning"] = (result.stderr or result.stdout or "codex mcp add failed").strip()
|
|
1253
1392
|
return sync_result
|
package/src/db/__init__.py
CHANGED
|
@@ -169,6 +169,9 @@ from db._protocol import (
|
|
|
169
169
|
create_protocol_task, get_protocol_task, close_protocol_task,
|
|
170
170
|
set_protocol_task_guard_acknowledged,
|
|
171
171
|
create_protocol_debt, resolve_protocol_debts, list_protocol_debts,
|
|
172
|
+
record_session_correction_requirement, list_session_correction_requirements,
|
|
173
|
+
session_has_open_correction_requirement, resolve_session_correction_requirements,
|
|
174
|
+
correction_requirement_summary,
|
|
172
175
|
protocol_compliance_summary,
|
|
173
176
|
create_cortex_evaluation, get_cortex_evaluation, list_cortex_evaluations,
|
|
174
177
|
cortex_evaluation_summary,
|
package/src/db/_protocol.py
CHANGED
|
@@ -2,6 +2,7 @@ from __future__ import annotations
|
|
|
2
2
|
"""NEXO DB — Protocol discipline runtime."""
|
|
3
3
|
|
|
4
4
|
import json
|
|
5
|
+
import hashlib
|
|
5
6
|
import secrets
|
|
6
7
|
import time
|
|
7
8
|
|
|
@@ -34,6 +35,12 @@ def _row_to_dict(row):
|
|
|
34
35
|
return dict(row) if row else None
|
|
35
36
|
|
|
36
37
|
|
|
38
|
+
def _correction_context_hash(session_id: str, text: str) -> str:
|
|
39
|
+
clean = " ".join(str(text or "").strip().split())[:1200]
|
|
40
|
+
digest = hashlib.sha1(f"{session_id.strip()}\0{clean}".encode("utf-8"), usedforsecurity=False)
|
|
41
|
+
return digest.hexdigest()[:20]
|
|
42
|
+
|
|
43
|
+
|
|
37
44
|
def validate_task_type(task_type: str) -> str:
|
|
38
45
|
clean_type = (task_type or "").strip()
|
|
39
46
|
if clean_type not in VALID_TASK_TYPES:
|
|
@@ -529,6 +536,155 @@ def list_protocol_debts(
|
|
|
529
536
|
return [dict(row) for row in rows]
|
|
530
537
|
|
|
531
538
|
|
|
539
|
+
def record_session_correction_requirement(
|
|
540
|
+
session_id: str,
|
|
541
|
+
correction_text: str,
|
|
542
|
+
*,
|
|
543
|
+
source: str = "heartbeat",
|
|
544
|
+
) -> dict:
|
|
545
|
+
"""Persist that a detected user correction requires a learning_add."""
|
|
546
|
+
conn = get_db()
|
|
547
|
+
clean_sid = str(session_id or "").strip()
|
|
548
|
+
if not clean_sid:
|
|
549
|
+
return {"ok": False, "error": "session_id is required"}
|
|
550
|
+
clean_text = " ".join(str(correction_text or "").strip().split())
|
|
551
|
+
context_hash = _correction_context_hash(clean_sid, clean_text)
|
|
552
|
+
conn.execute(
|
|
553
|
+
"""INSERT OR IGNORE INTO session_correction_requirements
|
|
554
|
+
(session_id, context_hash, correction_text, source)
|
|
555
|
+
VALUES (?, ?, ?, ?)""",
|
|
556
|
+
(clean_sid, context_hash, clean_text[:4000], str(source or "heartbeat").strip()[:80]),
|
|
557
|
+
)
|
|
558
|
+
conn.commit()
|
|
559
|
+
row = conn.execute(
|
|
560
|
+
"""SELECT *
|
|
561
|
+
FROM session_correction_requirements
|
|
562
|
+
WHERE session_id = ? AND context_hash = ?
|
|
563
|
+
LIMIT 1""",
|
|
564
|
+
(clean_sid, context_hash),
|
|
565
|
+
).fetchone()
|
|
566
|
+
out = _row_to_dict(row) or {}
|
|
567
|
+
out["ok"] = True
|
|
568
|
+
return out
|
|
569
|
+
|
|
570
|
+
|
|
571
|
+
def list_session_correction_requirements(
|
|
572
|
+
*,
|
|
573
|
+
session_id: str = "",
|
|
574
|
+
status: str = "open",
|
|
575
|
+
limit: int = 50,
|
|
576
|
+
) -> list[dict]:
|
|
577
|
+
conn = get_db()
|
|
578
|
+
clauses: list[str] = []
|
|
579
|
+
params: list[object] = []
|
|
580
|
+
if session_id:
|
|
581
|
+
clauses.append("session_id = ?")
|
|
582
|
+
params.append(str(session_id).strip())
|
|
583
|
+
if status:
|
|
584
|
+
clauses.append("status = ?")
|
|
585
|
+
params.append(str(status).strip())
|
|
586
|
+
where = f"WHERE {' AND '.join(clauses)}" if clauses else ""
|
|
587
|
+
rows = conn.execute(
|
|
588
|
+
f"""SELECT *
|
|
589
|
+
FROM session_correction_requirements
|
|
590
|
+
{where}
|
|
591
|
+
ORDER BY detected_at DESC, id DESC
|
|
592
|
+
LIMIT ?""",
|
|
593
|
+
params + [max(1, int(limit or 50))],
|
|
594
|
+
).fetchall()
|
|
595
|
+
return [dict(row) for row in rows]
|
|
596
|
+
|
|
597
|
+
|
|
598
|
+
def session_has_open_correction_requirement(session_id: str) -> bool:
|
|
599
|
+
if not str(session_id or "").strip():
|
|
600
|
+
return False
|
|
601
|
+
conn = get_db()
|
|
602
|
+
row = conn.execute(
|
|
603
|
+
"""SELECT 1
|
|
604
|
+
FROM session_correction_requirements
|
|
605
|
+
WHERE session_id = ? AND status = 'open'
|
|
606
|
+
LIMIT 1""",
|
|
607
|
+
(str(session_id).strip(),),
|
|
608
|
+
).fetchone()
|
|
609
|
+
return bool(row)
|
|
610
|
+
|
|
611
|
+
|
|
612
|
+
def resolve_session_correction_requirements(
|
|
613
|
+
*,
|
|
614
|
+
session_id: str = "",
|
|
615
|
+
learning_id: int | None = None,
|
|
616
|
+
) -> int:
|
|
617
|
+
"""Resolve open correction requirements after a real learning_add."""
|
|
618
|
+
conn = get_db()
|
|
619
|
+
clean_sid = str(session_id or "").strip()
|
|
620
|
+
if not clean_sid:
|
|
621
|
+
rows = conn.execute(
|
|
622
|
+
"""SELECT session_id, COUNT(*) AS total
|
|
623
|
+
FROM session_correction_requirements
|
|
624
|
+
WHERE status = 'open'
|
|
625
|
+
GROUP BY session_id
|
|
626
|
+
ORDER BY MAX(detected_at) DESC"""
|
|
627
|
+
).fetchall()
|
|
628
|
+
if len(rows) == 1:
|
|
629
|
+
clean_sid = str(rows[0]["session_id"] or "").strip()
|
|
630
|
+
else:
|
|
631
|
+
try:
|
|
632
|
+
row = conn.execute(
|
|
633
|
+
"""SELECT r.session_id
|
|
634
|
+
FROM session_correction_requirements r
|
|
635
|
+
LEFT JOIN sessions s ON s.sid = r.session_id
|
|
636
|
+
WHERE r.status = 'open'
|
|
637
|
+
ORDER BY COALESCE(s.last_heartbeat_ts, s.last_update_epoch, s.started_epoch, 0) DESC,
|
|
638
|
+
r.detected_at DESC
|
|
639
|
+
LIMIT 1"""
|
|
640
|
+
).fetchone()
|
|
641
|
+
except Exception:
|
|
642
|
+
row = None
|
|
643
|
+
clean_sid = str(row["session_id"] or "").strip() if row else ""
|
|
644
|
+
if not clean_sid:
|
|
645
|
+
return 0
|
|
646
|
+
cursor = conn.execute(
|
|
647
|
+
"""UPDATE session_correction_requirements
|
|
648
|
+
SET status = 'resolved',
|
|
649
|
+
resolved_at = datetime('now'),
|
|
650
|
+
resolved_learning_id = ?
|
|
651
|
+
WHERE session_id = ? AND status = 'open'""",
|
|
652
|
+
(int(learning_id) if learning_id else None, clean_sid),
|
|
653
|
+
)
|
|
654
|
+
conn.commit()
|
|
655
|
+
if cursor.rowcount:
|
|
656
|
+
resolve_protocol_debts(
|
|
657
|
+
session_id=clean_sid,
|
|
658
|
+
debt_types=["missing_learning_after_correction"],
|
|
659
|
+
resolution=f"Learning #{learning_id} persisted after user correction.",
|
|
660
|
+
)
|
|
661
|
+
return int(cursor.rowcount or 0)
|
|
662
|
+
|
|
663
|
+
|
|
664
|
+
def correction_requirement_summary(session_id: str = "") -> dict:
|
|
665
|
+
conn = get_db()
|
|
666
|
+
clauses: list[str] = []
|
|
667
|
+
params: list[object] = []
|
|
668
|
+
if session_id:
|
|
669
|
+
clauses.append("session_id = ?")
|
|
670
|
+
params.append(str(session_id).strip())
|
|
671
|
+
where = f"WHERE {' AND '.join(clauses)}" if clauses else ""
|
|
672
|
+
rows = conn.execute(
|
|
673
|
+
f"""SELECT status, COUNT(*) AS total
|
|
674
|
+
FROM session_correction_requirements
|
|
675
|
+
{where}
|
|
676
|
+
GROUP BY status"""
|
|
677
|
+
).fetchall()
|
|
678
|
+
counts = {str(row["status"]): int(row["total"] or 0) for row in rows}
|
|
679
|
+
return {
|
|
680
|
+
"session_id": str(session_id or "").strip(),
|
|
681
|
+
"corrections_detected": sum(counts.values()),
|
|
682
|
+
"learnings_persisted": counts.get("resolved", 0),
|
|
683
|
+
"open_requirements": counts.get("open", 0),
|
|
684
|
+
"by_status": counts,
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
|
|
532
688
|
def protocol_compliance_summary(days: int = 7) -> dict:
|
|
533
689
|
conn = get_db()
|
|
534
690
|
window = f"-{max(1, int(days))} days"
|
package/src/db/_schema.py
CHANGED
|
@@ -923,6 +923,28 @@ def _m55_cortex_critique_trace(conn):
|
|
|
923
923
|
_migrate_add_column(conn, "cortex_evaluations", "decision_mode", "TEXT DEFAULT 'heuristic'")
|
|
924
924
|
|
|
925
925
|
|
|
926
|
+
def _m56_session_correction_requirements(conn):
|
|
927
|
+
"""Track user corrections that must be turned into durable learnings."""
|
|
928
|
+
conn.execute(
|
|
929
|
+
"""CREATE TABLE IF NOT EXISTS session_correction_requirements (
|
|
930
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
931
|
+
session_id TEXT NOT NULL,
|
|
932
|
+
context_hash TEXT NOT NULL,
|
|
933
|
+
correction_text TEXT DEFAULT '',
|
|
934
|
+
source TEXT DEFAULT 'heartbeat',
|
|
935
|
+
status TEXT NOT NULL DEFAULT 'open',
|
|
936
|
+
detected_at TEXT DEFAULT (datetime('now')),
|
|
937
|
+
resolved_at TEXT DEFAULT NULL,
|
|
938
|
+
resolved_learning_id INTEGER DEFAULT NULL,
|
|
939
|
+
followup_id TEXT DEFAULT '',
|
|
940
|
+
UNIQUE(session_id, context_hash)
|
|
941
|
+
)"""
|
|
942
|
+
)
|
|
943
|
+
conn.execute("CREATE INDEX IF NOT EXISTS idx_session_correction_requirements_session ON session_correction_requirements(session_id)")
|
|
944
|
+
conn.execute("CREATE INDEX IF NOT EXISTS idx_session_correction_requirements_status ON session_correction_requirements(status)")
|
|
945
|
+
conn.execute("CREATE INDEX IF NOT EXISTS idx_session_correction_requirements_detected ON session_correction_requirements(detected_at)")
|
|
946
|
+
|
|
947
|
+
|
|
926
948
|
def _m39_hook_runs(conn):
|
|
927
949
|
"""Persist hook lifecycle observability — closes Fase 3 item 7.
|
|
928
950
|
|
|
@@ -1494,6 +1516,7 @@ MIGRATIONS = [
|
|
|
1494
1516
|
(53, "session_conversation_identity", _m53_session_conversation_identity),
|
|
1495
1517
|
(54, "continuity_snapshots", _m54_continuity_snapshots),
|
|
1496
1518
|
(55, "cortex_critique_trace", _m55_cortex_critique_trace),
|
|
1519
|
+
(56, "session_correction_requirements", _m56_session_correction_requirements),
|
|
1497
1520
|
]
|
|
1498
1521
|
|
|
1499
1522
|
|