superlocalmemory 4.0.4 → 4.0.6

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.
Files changed (90) hide show
  1. package/CHANGELOG.md +90 -0
  2. package/README.md +23 -14
  3. package/ide/configs/codex-mcp.toml +2 -2
  4. package/package.json +3 -1
  5. package/plugin/.claude-plugin/plugin.json +1 -1
  6. package/plugin/.mcp.json +1 -0
  7. package/plugin/CLAUDE.md +3 -3
  8. package/plugin/agents/slm-governance-advisor.md +1 -1
  9. package/plugin/agents/slm-loop-runner.md +1 -1
  10. package/plugin/agents/slm-memory-advisor.md +1 -1
  11. package/plugin/agents/slm-optimize-advisor.md +1 -1
  12. package/plugin/requirements.txt +1 -1
  13. package/plugin/skills/slm-cache/SKILL.md +1 -1
  14. package/plugin/skills/slm-compress/SKILL.md +1 -1
  15. package/plugin/skills/slm-governance/SKILL.md +1 -1
  16. package/plugin/skills/slm-graph/SKILL.md +3 -2
  17. package/plugin/skills/slm-loop/SKILL.md +1 -1
  18. package/plugin/skills/slm-mesh/SKILL.md +1 -1
  19. package/plugin/skills/slm-profile/SKILL.md +2 -1
  20. package/plugin/skills/slm-recall/SKILL.md +1 -1
  21. package/plugin/skills/slm-remember/SKILL.md +1 -1
  22. package/plugin/skills/slm-scope/SKILL.md +1 -1
  23. package/plugin/skills/slm-session/SKILL.md +1 -1
  24. package/plugin/skills/slm-status/SKILL.md +1 -1
  25. package/plugin-src/rules/AGENTS.md +6 -5
  26. package/plugin-src/skills/slm-graph/SKILL.md +2 -1
  27. package/plugin-src/skills/slm-profile/SKILL.md +1 -0
  28. package/pyproject.toml +1 -1
  29. package/src/superlocalmemory/__init__.py +1 -1
  30. package/src/superlocalmemory/access/rbac.py +106 -0
  31. package/src/superlocalmemory/brain/__init__.py +5 -0
  32. package/src/superlocalmemory/brain/truth.py +418 -0
  33. package/src/superlocalmemory/cli/__main__.py +17 -0
  34. package/src/superlocalmemory/cli/commands.py +96 -28
  35. package/src/superlocalmemory/cli/gdpr_cmd.py +779 -0
  36. package/src/superlocalmemory/cli/gdpr_io.py +109 -0
  37. package/src/superlocalmemory/cli/main.py +88 -0
  38. package/src/superlocalmemory/code_graph/extractors/__init__.py +17 -0
  39. package/src/superlocalmemory/code_graph/graph_store.py +180 -3
  40. package/src/superlocalmemory/code_graph/parser.py +280 -100
  41. package/src/superlocalmemory/compliance/gdpr.py +358 -0
  42. package/src/superlocalmemory/core/config.py +44 -1
  43. package/src/superlocalmemory/core/context_cache.py +58 -1
  44. package/src/superlocalmemory/core/engine_wiring.py +5 -1
  45. package/src/superlocalmemory/core/maintenance.py +43 -1
  46. package/src/superlocalmemory/core/mutations.py +155 -25
  47. package/src/superlocalmemory/core/recall_pipeline.py +6 -10
  48. package/src/superlocalmemory/core/recall_worker.py +33 -12
  49. package/src/superlocalmemory/core/remember_runtime.py +271 -2
  50. package/src/superlocalmemory/core/store_pipeline.py +100 -38
  51. package/src/superlocalmemory/encoding/consolidator.py +17 -47
  52. package/src/superlocalmemory/encoding/temporal_validator.py +14 -18
  53. package/src/superlocalmemory/hooks/user_prompt_hook.py +1 -1
  54. package/src/superlocalmemory/infra/backup.py +138 -0
  55. package/src/superlocalmemory/infra/backup_obligations.py +423 -0
  56. package/src/superlocalmemory/integrations/bounded_loops_mcp.py +4 -3
  57. package/src/superlocalmemory/learning/engagement.py +165 -0
  58. package/src/superlocalmemory/mcp/profiles.py +19 -7
  59. package/src/superlocalmemory/mcp/server.py +4 -2
  60. package/src/superlocalmemory/mcp/tools_brain.py +54 -10
  61. package/src/superlocalmemory/mcp/tools_code_graph.py +31 -4
  62. package/src/superlocalmemory/mcp/tools_core.py +88 -3
  63. package/src/superlocalmemory/mcp/tools_v3.py +20 -6
  64. package/src/superlocalmemory/retrieval/engine.py +28 -10
  65. package/src/superlocalmemory/retrieval/remote_reranker.py +108 -11
  66. package/src/superlocalmemory/retrieval/temporal_validity_filter.py +119 -19
  67. package/src/superlocalmemory/server/routes/brain.py +297 -14
  68. package/src/superlocalmemory/server/routes/learning.py +13 -25
  69. package/src/superlocalmemory/server/routes/memories.py +129 -3
  70. package/src/superlocalmemory/server/routes/v3_api.py +171 -60
  71. package/src/superlocalmemory/storage/_migration_internals.py +4 -0
  72. package/src/superlocalmemory/storage/_schema_version.py +2 -2
  73. package/src/superlocalmemory/storage/correction_cases.py +670 -0
  74. package/src/superlocalmemory/storage/database.py +230 -24
  75. package/src/superlocalmemory/storage/migration_runner.py +7 -0
  76. package/src/superlocalmemory/storage/migrations/M042_correction_case_ledger.py +245 -0
  77. package/src/superlocalmemory/storage/migrations/__init__.py +2 -0
  78. package/src/superlocalmemory/storage/models.py +12 -4
  79. package/src/superlocalmemory/storage/write_coordinator.py +4 -0
  80. package/src/superlocalmemory/summaries/__init__.py +37 -0
  81. package/src/superlocalmemory/summaries/base.py +108 -0
  82. package/src/superlocalmemory/summaries/daily_reflection.py +293 -0
  83. package/src/superlocalmemory/summaries/project_work_log.py +424 -0
  84. package/src/superlocalmemory/summaries/session_summary.py +307 -0
  85. package/src/superlocalmemory/ui/css/design-system.css +76 -1
  86. package/src/superlocalmemory/ui/index.html +28 -11
  87. package/src/superlocalmemory/ui/js/brain.js +43 -7
  88. package/src/superlocalmemory/ui/js/od-agents.js +49 -5
  89. package/src/superlocalmemory/ui/js/od-brain.js +280 -84
  90. package/src/superlocalmemory/ui/js/od-graph.js +147 -6
@@ -44,24 +44,31 @@ Design notes (LLD-04 §7 hard rules):
44
44
 
45
45
  from __future__ import annotations
46
46
 
47
+ import json as _json
47
48
  import logging
49
+ import shutil
48
50
  import sqlite3
51
+ import subprocess
52
+ import threading
53
+ import time
49
54
  from datetime import datetime, timedelta, timezone
50
55
  from pathlib import Path
51
56
  from typing import Any
52
57
 
53
58
  from fastapi import APIRouter, Depends, HTTPException, Request
54
- from superlocalmemory import __version__
55
59
 
60
+ from superlocalmemory import __version__
61
+ from superlocalmemory.brain import BrainTruthService
56
62
  from superlocalmemory.core.security_primitives import (
57
63
  redact_secrets,
58
64
  verify_install_token,
59
65
  )
66
+ from superlocalmemory.infra.data_root import canonical_data_root
60
67
  from superlocalmemory.learning.database import LearningDatabase
61
68
  from superlocalmemory.learning.features import FEATURE_DIM
62
- from superlocalmemory.infra.data_root import canonical_data_root
63
- from superlocalmemory.storage.read_connection import ReadConnectionFactory
64
69
  from superlocalmemory.storage.agent_experience import get_profile_receipt_summary
70
+ from superlocalmemory.storage.read_connection import ReadConnectionFactory
71
+
65
72
  from .helpers import get_active_profile
66
73
 
67
74
  logger = logging.getLogger("superlocalmemory.routes.brain")
@@ -83,6 +90,17 @@ _VERSION: str = __version__
83
90
  # NOTE: do NOT add the literal forbidden-key strings here — the U4 grep
84
91
  # guard runs over this file.
85
92
 
93
+ # ---------------------------------------------------------------------------
94
+ # Bounded-loops detection — TTL-cached subprocess probe.
95
+ # The binary lives in a pipx venv; importlib.util.find_spec('bounded_loops')
96
+ # will NOT find it from our venv. Detect via PATH / filesystem instead.
97
+ # ---------------------------------------------------------------------------
98
+
99
+ _BL_PROBE: dict[str, object] = {}
100
+ _BL_PROBE_LOCK = threading.Lock()
101
+ _BL_PROBE_TTL = 60.0 # seconds between re-probes
102
+
103
+
86
104
  # Memory directory (home-dir based). Always resolved at call time so that
87
105
  # tests can override via monkeypatch on ``_learning_db_path``.
88
106
  _MEMORY_DIR_DEFAULT = None # test-only compatibility override
@@ -595,7 +613,8 @@ def _adapter_last_sync_ago(adapter_name: str) -> int | None:
595
613
  honest empty rather than a fabricated number.
596
614
  """
597
615
  try:
598
- from datetime import datetime as _dt, timezone as _tz
616
+ from datetime import datetime as _dt
617
+ from datetime import timezone as _tz
599
618
  memory_db = _memory_dir() / "memory.db"
600
619
  if not memory_db.exists():
601
620
  return None
@@ -619,6 +638,166 @@ def _adapter_last_sync_ago(adapter_name: str) -> int | None:
619
638
  return None
620
639
 
621
640
 
641
+ def _detect_codex_config() -> dict:
642
+ """Detect Codex integration from ``~/.codex/config.toml``.
643
+
644
+ Evidence tier: CONFIGURED — the config file contains
645
+ ``[mcp_servers.superlocalmemory]``, which proves Codex is wired to use
646
+ SLM as an MCP server. This is the same tier as Claude Code's install-
647
+ token check: file presence, not live traffic.
648
+
649
+ What we deliberately do NOT claim: that Codex is currently running, or
650
+ that the MCP connection has been used recently. For live-traffic
651
+ evidence the correct signal is ``tool_events`` rows attributed to
652
+ ``SLM_AGENT_ID='codex'`` — a check not performed here because
653
+ ``tool_events`` has no ``agent_id`` column.
654
+ """
655
+ try:
656
+ codex_config = Path.home() / ".codex" / "config.toml"
657
+ if not codex_config.exists():
658
+ return {"active": False, "reason": "codex_config_absent"}
659
+ try:
660
+ content = codex_config.read_text(encoding="utf-8", errors="replace")
661
+ except OSError:
662
+ return {"active": False, "reason": "codex_config_unreadable"}
663
+ if "[mcp_servers.superlocalmemory]" not in content:
664
+ return {"active": False, "reason": "no_slm_mcp_block"}
665
+ return {
666
+ "active": True,
667
+ "evidence_tier": "configured",
668
+ "evidence": "~/.codex/config.toml#[mcp_servers.superlocalmemory]",
669
+ }
670
+ except Exception: # pragma: no cover — defensive
671
+ return {"active": False, "reason": "detection_error"}
672
+
673
+
674
+ def _bounded_loops_probe_fresh() -> dict:
675
+ """Probe bounded-loops installation — may spawn one short subprocess.
676
+
677
+ Detection approach: ``shutil.which("bounded-loops-mcp")`` (PATH scan,
678
+ no subprocess), then ``bl --version`` with a 1.5 s timeout (see the note at the call
679
+ site: 300 ms sat too close to the measured ~226 ms and flickered).
680
+
681
+ Evidence tier: INSTALLED — the ``bounded-loops-mcp`` binary is on the
682
+ resolved PATH, meaning the pipx package is present.
683
+
684
+ What we do NOT claim: that the bridge contract ``bounded-loops.dev/
685
+ slm-bridge/v1`` is live or that any run has been observed. A full
686
+ capability handshake requires an async MCP round-trip; that is outside
687
+ the scope of this synchronous probe.
688
+
689
+ Trap: do NOT use ``importlib.util.find_spec('bounded_loops')`` — the
690
+ package lives in its own pipx venv and is not importable from this one.
691
+ """
692
+ bl_mcp_path = shutil.which("bounded-loops-mcp")
693
+ if bl_mcp_path is None:
694
+ # Cross-check: ~/.bounded-loops data dir (pipx installs leave this).
695
+ bl_data = Path.home() / ".bounded-loops"
696
+ if bl_data.is_dir():
697
+ return {
698
+ "installed": True,
699
+ "version": None,
700
+ "bridge_contract": "bounded-loops.dev/slm-bridge/v1",
701
+ "evidence_tier": "data_dir",
702
+ "evidence": "~/.bounded-loops exists",
703
+ "note": "mcp binary not on PATH; data dir detected",
704
+ "is_real": True,
705
+ "source": "filesystem",
706
+ }
707
+ return {
708
+ "installed": False,
709
+ "reason": "bounded_loops_mcp_not_in_path",
710
+ "is_real": True,
711
+ "source": "shutil.which",
712
+ }
713
+
714
+ version: str | None = None
715
+ bl_path = shutil.which("bl")
716
+ if bl_path:
717
+ try:
718
+ # 1.5s, not 0.3s. Measured on this machine: a cold
719
+ # _compute_bounded_loops() takes ~226 ms end to end, most of it this
720
+ # subprocess — a 300 ms budget leaves ~74 ms of headroom, so under
721
+ # any load the probe times out and the card silently reports
722
+ # "Version: unknown" while `bl --version` prints "bl 0.6.4" from a
723
+ # shell. A version string that flickers with machine load is worse
724
+ # than no version string, because it looks like a real finding.
725
+ # The cost of widening is bounded: this runs in a worker thread via
726
+ # asyncio.to_thread and is TTL-cached for 60s, so the worst case is
727
+ # one 1.5s thread once a minute, never on the recall/remember path.
728
+ proc = subprocess.run(
729
+ [bl_path, "--version"],
730
+ capture_output=True,
731
+ text=True,
732
+ timeout=1.5,
733
+ )
734
+ raw = (proc.stdout.strip() or proc.stderr.strip())
735
+ if raw.startswith("bl "):
736
+ version = raw[3:].strip()
737
+ except Exception: # pragma: no cover — timeout or missing binary
738
+ pass
739
+
740
+ return {
741
+ "installed": True,
742
+ "version": version,
743
+ "bridge_contract": "bounded-loops.dev/slm-bridge/v1",
744
+ "evidence_tier": "path",
745
+ "evidence": "shutil.which(bounded-loops-mcp)",
746
+ "note": "bridge capability requires a runtime MCP handshake; not probed here",
747
+ "is_real": True,
748
+ "source": "shutil.which + bl --version",
749
+ }
750
+
751
+
752
+ def _compute_bounded_loops() -> dict:
753
+ """Section ``bounded_loops`` — installation/presence status.
754
+
755
+ Wraps ``_bounded_loops_probe_fresh`` in a 60-second TTL cache so the
756
+ subprocess call is never inline on every Brain request. First call may
757
+ take up to 1.5 s (subprocess timeout); subsequent calls within the TTL
758
+ window return immediately from the cache. The probe is single-flight, so
759
+ concurrent callers share one subprocess rather than spawning one each.
760
+ """
761
+ with _BL_PROBE_LOCK:
762
+ cached = _BL_PROBE.get("result")
763
+ cached_at = float(_BL_PROBE.get("cached_at", 0.0))
764
+ if cached is not None and (time.monotonic() - cached_at) < _BL_PROBE_TTL:
765
+ return cached # type: ignore[return-value]
766
+
767
+ # SINGLE-FLIGHT: run the probe while STILL HOLDING the lock.
768
+ # The previous shape released the lock between the check and the probe,
769
+ # so every concurrent caller that arrived after a TTL expiry spawned its
770
+ # own `bl --version` subprocess — N dashboard requests meant N processes,
771
+ # each up to 1.5s. Holding the lock makes the 2nd..Nth caller wait for
772
+ # the first result instead. Blocking here is safe: this whole function
773
+ # runs in a worker thread via asyncio.to_thread, so the event loop is
774
+ # never held, and it is off the recall/remember path entirely.
775
+ try:
776
+ fresh = _bounded_loops_probe_fresh()
777
+ fresh["section_enabled"] = bool(fresh.get("installed", False))
778
+ except Exception as exc: # probe itself blew up
779
+ # Cache the FAILURE too, and label it as a failure rather than as a
780
+ # clean "not installed". Without caching it, every request would
781
+ # retry a broken probe forever; without labelling it, a broken probe
782
+ # is indistinguishable from Bounded Loops genuinely being absent —
783
+ # the silent-failure class this release exists to remove.
784
+ logger.warning("bounded-loops probe failed: %s", exc)
785
+ fresh = {
786
+ "installed": None,
787
+ "is_real": False,
788
+ "probe_failed": True,
789
+ "reason": f"probe_error:{type(exc).__name__}",
790
+ "section_enabled": False,
791
+ "source": "probe raised",
792
+ }
793
+
794
+ # Timestamp AFTER the probe so the TTL measures time since completion,
795
+ # not time since the request arrived.
796
+ _BL_PROBE["result"] = fresh
797
+ _BL_PROBE["cached_at"] = time.monotonic()
798
+ return fresh
799
+
800
+
622
801
  def _compute_cross_platform() -> dict:
623
802
  """Section ``cross_platform`` — live status per injection target.
624
803
 
@@ -699,26 +878,93 @@ def _compute_cross_platform() -> dict:
699
878
  out["mcp"] = {"active": True, "tool": "mcp__slm"}
700
879
  # CLI is trivially active on any install.
701
880
  out["cli"] = {"active": True}
881
+ # Codex: detect from ~/.codex/config.toml.
882
+ # evidence_tier="configured" — config proves SLM is wired; it does not
883
+ # prove recent MCP traffic (tool_events has no agent_id column).
884
+ out["codex"] = _detect_codex_config()
702
885
  return out
703
886
 
704
887
 
888
+ def _registry_staleness() -> tuple[str, int | None]:
889
+ """Return (registry_status, newest_entry_seconds_ago).
890
+
891
+ Reads the raw session registry file to determine whether the registry
892
+ has been written to recently, regardless of profile matching. This
893
+ distinguishes three silent-but-different states:
894
+
895
+ ``live`` — newest entry is within 2× the presence window (10 min)
896
+ ``stale`` — file exists, entries exist, but all are older than 10 min
897
+ → TELEMETRY GAP: hooks exist but are not firing
898
+ ``empty`` — file exists but has no entries
899
+ ``absent`` — file does not exist (first-run or cleaned up)
900
+ ``unknown`` — file could not be read
901
+
902
+ IMPORTANT: the window is NOT widened here. We report the gap honestly
903
+ rather than hiding it by using a larger cutoff on the query.
904
+ """
905
+ try:
906
+ from superlocalmemory.infra.data_root import state_path
907
+ registry_path = state_path(".active_sessions.json")
908
+ if not registry_path.exists():
909
+ return "absent", None
910
+ try:
911
+ raw = registry_path.read_text(encoding="utf-8")
912
+ data = _json.loads(raw)
913
+ except Exception:
914
+ return "unknown", None
915
+ if not isinstance(data, dict) or not data:
916
+ return "empty", None
917
+ now_ns = time.time_ns()
918
+ ts_vals = [
919
+ int(row.get("ts_ns", 0))
920
+ for row in data.values()
921
+ if isinstance(row, dict) and int(row.get("ts_ns", 0)) > 0
922
+ ]
923
+ if not ts_vals:
924
+ return "empty", None
925
+ newest_ns = max(ts_vals)
926
+ seconds_ago = max(0, int((now_ns - newest_ns) / 1_000_000_000))
927
+ # 2× the 300 s presence window = 600 s sane boundary.
928
+ if seconds_ago <= 600:
929
+ return "live", seconds_ago
930
+ return "stale", seconds_ago
931
+ except Exception: # pragma: no cover — defensive
932
+ return "unknown", None
933
+
934
+
705
935
  def _compute_active_clients(profile_id: str) -> dict:
706
- """Return recent host presence without leaking session identifiers.
936
+ """Return recent host presence with honest telemetry-gap detection.
707
937
 
708
938
  ``cross_platform`` describes configured integration targets. This
709
- separate read-model answers the different question users actually ask:
710
- which host clients have recently interacted with this local brain?
939
+ separate read-model answers a different question: which host clients
940
+ have recently interacted with this brain?
941
+
942
+ Three distinct states are surfaced instead of a silent empty list:
943
+ ``NO_ACTIVITY`` — registry is live; no clients in the 5-min window
944
+ ``TELEMETRY_GAP`` — registry's newest entry is > 10 min old; hooks
945
+ may be installed but are not writing presence
946
+ ``REGISTRY_ERROR`` — exception accessing the registry; reported as
947
+ ``is_real: False`` rather than an empty success
711
948
  """
949
+ reg_status, newest_ago = _registry_staleness()
950
+
951
+ clients: list[dict] = []
952
+ registry_ok = True
712
953
  try:
713
954
  from superlocalmemory.hooks.session_registry import active_client_summary
714
955
  clients = active_client_summary(profile_id, within_seconds=300)
715
- except Exception:
716
- clients = []
956
+ except Exception as exc: # distinguish failure from emptiness (Wave 4)
957
+ registry_ok = False
958
+ reg_status = "error"
959
+ logger.debug("active_clients: registry error: %s", exc)
960
+
717
961
  return {
718
- "is_real": True,
962
+ "is_real": registry_ok,
719
963
  "scope": "profile",
720
964
  "window_seconds": 300,
721
965
  "clients": clients,
966
+ "registry_status": reg_status,
967
+ "newest_entry_seconds_ago": newest_ago,
722
968
  "source": "session_registry (ephemeral, profile-scoped)",
723
969
  }
724
970
 
@@ -1130,11 +1376,16 @@ async def get_brain(request: Request, profile_id: str | None = None) -> dict:
1130
1376
  # "default" — the Brain must reflect whichever profile is active.
1131
1377
  profile_id = _authorized_profile(request, profile_id)
1132
1378
  lrn_db = LearningDatabase(_learning_db_path())
1379
+ truth_service = BrainTruthService(
1380
+ memory_db_path=_memory_dir() / "memory.db",
1381
+ learning_db_path=_learning_db_path(),
1382
+ )
1133
1383
 
1134
1384
  (
1135
1385
  preferences, learning, usage, bandit_snap, cache,
1136
1386
  cross_platform, outcomes_preview, evolution, active_clients,
1137
1387
  feedback_loop, source_quality, graph_summary, agent_experience,
1388
+ brain_truth, bounded_loops,
1138
1389
  ) = await asyncio.gather(
1139
1390
  asyncio.to_thread(_compute_preferences, profile_id),
1140
1391
  asyncio.to_thread(_compute_learning_status, profile_id, lrn_db),
@@ -1152,6 +1403,8 @@ async def get_brain(request: Request, profile_id: str | None = None) -> dict:
1152
1403
  asyncio.to_thread(_compute_source_quality, profile_id),
1153
1404
  asyncio.to_thread(_compute_graph_summary, profile_id),
1154
1405
  asyncio.to_thread(_compute_agent_experience, profile_id),
1406
+ asyncio.to_thread(truth_service.snapshot, profile_id),
1407
+ asyncio.to_thread(_compute_bounded_loops),
1155
1408
  return_exceptions=True,
1156
1409
  )
1157
1410
 
@@ -1162,9 +1415,15 @@ async def get_brain(request: Request, profile_id: str | None = None) -> dict:
1162
1415
  return fallback
1163
1416
  return value
1164
1417
 
1165
- active_clients = _ok(active_clients, {"is_real": True, "scope": "profile", "clients": [],
1166
- "window_seconds": 300,
1167
- "source": "session_registry unavailable"})
1418
+ active_clients = _ok(active_clients, {
1419
+ "is_real": False,
1420
+ "scope": "profile",
1421
+ "clients": [],
1422
+ "window_seconds": 300,
1423
+ "registry_status": "error",
1424
+ "newest_entry_seconds_ago": None,
1425
+ "source": "session_registry unavailable",
1426
+ })
1168
1427
  feedback_loop = _ok(feedback_loop, {"is_real": True, "signals_by_type": {},
1169
1428
  "explicit_signals": 0, "implicit_signals": 0,
1170
1429
  "settled_outcomes": 0, "mean_settled_reward": None,
@@ -1213,6 +1472,14 @@ async def get_brain(request: Request, profile_id: str | None = None) -> dict:
1213
1472
  "source_quality": source_quality,
1214
1473
  "graph": graph_summary,
1215
1474
  "agent_experience": agent_experience,
1475
+ # Additive v4.0.5 contract. Keep the preceding legacy keys for
1476
+ # existing dashboard/API clients; new clients should render this
1477
+ # one shared, unavailable-aware snapshot.
1478
+ "brain_truth": _ok(brain_truth, {
1479
+ "availability": "unavailable",
1480
+ "source": "BrainTruth service unavailable",
1481
+ "control_plane": "observation_only",
1482
+ }),
1216
1483
  "source": "local durable stores + ephemeral session registry",
1217
1484
  },
1218
1485
  "evolution_preview": _ok(evolution, {
@@ -1233,6 +1500,21 @@ async def get_brain(request: Request, profile_id: str | None = None) -> dict:
1233
1500
  "shadow_preview": _compute_shadow_preview(profile_id),
1234
1501
  "evolution_cost_preview": _compute_evolution_cost_preview(profile_id),
1235
1502
  "outcome_queue": _compute_outcome_queue_stats(profile_id),
1503
+ # ``bounded_loops`` — lazy-probed, TTL-cached installation status.
1504
+ # ``section_enabled`` drives the UI panel on/off decision.
1505
+ # Evidence tier: path-based (shutil.which); bridge capability not probed.
1506
+ # is_real=False and installed=None, NOT installed=False. If the probe
1507
+ # task raised, we do not know whether Bounded Loops is installed — and
1508
+ # saying "installed: False, is_real: True" asserts absence we never
1509
+ # established. That reads to the UI, and to any API consumer, exactly
1510
+ # like a machine with Bounded Loops genuinely not present.
1511
+ "bounded_loops": _ok(bounded_loops, {
1512
+ "is_real": False,
1513
+ "installed": None,
1514
+ "probe_failed": True,
1515
+ "section_enabled": False,
1516
+ "source": "bounded_loops_probe unavailable",
1517
+ }),
1236
1518
  "meta": _meta_now(),
1237
1519
  }
1238
1520
 
@@ -1246,7 +1528,8 @@ def _compute_outcome_queue_stats(profile_id: str) -> dict:
1246
1528
  """
1247
1529
  try:
1248
1530
  from superlocalmemory.learning.outcome_queue import (
1249
- get_counters, queue_size,
1531
+ get_counters,
1532
+ queue_size,
1250
1533
  )
1251
1534
  counters = get_counters()
1252
1535
  qsz = queue_size()
@@ -30,6 +30,7 @@ from .learning_telemetry import (
30
30
  sqlite_status as _sqlite_status,
31
31
  )
32
32
  from superlocalmemory.storage.memory_write import memory_read
33
+ from superlocalmemory.learning.engagement import derive_engagement_from_dbs
33
34
 
34
35
  logger = logging.getLogger("superlocalmemory.routes.learning")
35
36
  router = APIRouter()
@@ -338,31 +339,18 @@ def learning_status(request: Request):
338
339
  "signals": signal_count,
339
340
  }
340
341
 
341
- # Engagement — v3.4.8: Fixed method name (was get_engagement_stats, actual is get_stats)
342
- engagement = _get_engagement()
343
- if engagement:
344
- try:
345
- stats = engagement.get_stats(active_profile)
346
- health = engagement.get_health(active_profile)
347
- active_days = stats.get("active_days", 0)
348
- total_events = stats.get("total_events", 0)
349
- memories_per_day = (
350
- round(total_events / active_days, 1) if active_days > 0 else 0
351
- )
352
- result["engagement"] = {
353
- "health_status": health.upper(),
354
- "days_active": active_days,
355
- "memories_per_day": memories_per_day,
356
- "total_events": total_events,
357
- "recall_count": stats.get("recall_count", 0),
358
- "store_count": stats.get("store_count", 0),
359
- "session_count": stats.get("session_count", 0),
360
- "engagement_score": stats.get("engagement_score", 0),
361
- }
362
- except Exception as exc:
363
- logger.debug("engagement stats: %s", exc)
364
- result["engagement"] = None
365
- else:
342
+ # Engagement — v3.4.9: derive-on-read from existing tables (zero writes,
343
+ # satisfies I1 recall/remember latency, I3 no unbounded growth, I6 provenance).
344
+ # memory.db:atomic_facts drives store_count/days_active/health.
345
+ # learning.db:learning_signals drives recall_count (proxy: distinct queries).
346
+ try:
347
+ result["engagement"] = derive_engagement_from_dbs(
348
+ memory_db_path=MEMORY_DIR / "memory.db",
349
+ learning_db_path=LEARNING_DB,
350
+ profile_id=active_profile,
351
+ )
352
+ except Exception as exc:
353
+ logger.debug("derive_engagement_from_dbs: %s", exc)
366
354
  result["engagement"] = None
367
355
 
368
356
  # Tech preferences + workflow patterns from V3.1 behavioral store
@@ -1182,9 +1182,9 @@ async def merge_memory(request: Request, fact_id: str):
1182
1182
  raise _canonical_mutation_error(exc, "Merge error")
1183
1183
 
1184
1184
 
1185
- @router.patch("/api/memories/{fact_id}")
1185
+ @router.patch("/api/memories/{fact_id}", status_code=202)
1186
1186
  async def edit_memory(request: Request, fact_id: str):
1187
- """Edit the content of a specific memory (atomic fact)."""
1187
+ """Propose an immutable, review-required correction for one memory."""
1188
1188
  try:
1189
1189
  body = await request.json()
1190
1190
  new_content = (body.get("content") or "").strip()
@@ -1210,13 +1210,139 @@ async def edit_memory(request: Request, fact_id: str):
1210
1210
  )
1211
1211
  if not result.get("ok"):
1212
1212
  raise HTTPException(status_code=404, detail="Memory not found")
1213
- return {"success": True, "fact_id": fact_id, "content": new_content}
1213
+ if result.get("unchanged"):
1214
+ return {"success": True, "fact_id": fact_id, "content": new_content, "unchanged": True}
1215
+ correction = result["correction_case"]
1216
+ return {
1217
+ "success": True,
1218
+ "fact_id": fact_id,
1219
+ "predecessor_fact_id": result["predecessor_fact_id"],
1220
+ "successor_fact_id": result["successor_fact_id"],
1221
+ "correction_case": correction,
1222
+ "review_required": True,
1223
+ "status": "proposed",
1224
+ }
1214
1225
  except HTTPException:
1215
1226
  raise
1216
1227
  except Exception as exc:
1217
1228
  raise _canonical_mutation_error(exc, "Edit error")
1218
1229
 
1219
1230
 
1231
+ @router.post("/api/corrections/{case_id}/{action}")
1232
+ async def review_correction(request: Request, case_id: str, action: str):
1233
+ """Apply, reject, or roll back an active-profile correction case.
1234
+
1235
+ The caller authenticates through the daemon boundary. It cannot select a
1236
+ profile, fact scope, or trust tier; the canonical writer rechecks all of
1237
+ those fields in its one SQLite transaction.
1238
+ """
1239
+ try:
1240
+ body = await request.json()
1241
+ if action not in {"apply", "reject", "rollback"}:
1242
+ raise HTTPException(422, detail="action must be apply, reject, or rollback")
1243
+ expected_version = body.get("expected_version") if isinstance(body, dict) else None
1244
+ if not isinstance(expected_version, int) or isinstance(expected_version, bool):
1245
+ raise HTTPException(422, detail="expected_version must be a non-negative integer")
1246
+ if expected_version < 0:
1247
+ raise HTTPException(422, detail="expected_version must be a non-negative integer")
1248
+ event_valid_until = body.get("event_valid_until") if isinstance(body, dict) else None
1249
+ if event_valid_until is not None and not isinstance(event_valid_until, str):
1250
+ raise HTTPException(422, detail="event_valid_until must be an RFC3339 timestamp")
1251
+ if event_valid_until is not None and action != "apply":
1252
+ raise HTTPException(422, detail="event_valid_until is permitted only for apply")
1253
+ engine, active_profile, hook_context = _authorize_memory_mutation(
1254
+ request, "update", case_id, run_pre_hook=False
1255
+ )
1256
+ result = _canonical_mutation_runtime(request).transition_correction(
1257
+ active_profile,
1258
+ case_id,
1259
+ action=action,
1260
+ expected_version=expected_version,
1261
+ actor_id=hook_context["agent_id"],
1262
+ event_valid_until=event_valid_until,
1263
+ idempotency_key=_mutation_idempotency_key(request),
1264
+ )
1265
+ if not result.get("ok"):
1266
+ raise HTTPException(404, detail="Correction case not found")
1267
+ if action in {"apply", "rollback"}:
1268
+ from superlocalmemory.core.mutations import purge_profile_context_cache
1269
+
1270
+ purge_profile_context_cache(engine, active_profile)
1271
+ engine._hooks.run_post("update", hook_context)
1272
+ return {"success": True, "correction_case": result}
1273
+ except HTTPException:
1274
+ raise
1275
+ except Exception as exc:
1276
+ raise _canonical_mutation_error(exc, "Correction review error")
1277
+
1278
+
1279
+ def _correction_case_response(case) -> dict[str, object]:
1280
+ """Return review metadata only; correction ledgers never contain fact text."""
1281
+ return {
1282
+ "case_id": case.case_id,
1283
+ "profile_id": case.profile_id,
1284
+ "scope": case.scope,
1285
+ "predecessor_fact_id": case.predecessor_fact_id,
1286
+ "successor_fact_id": case.successor_fact_id,
1287
+ "reason_code": case.reason_code,
1288
+ "status": case.status,
1289
+ "version": case.version,
1290
+ "created_at": case.created_at,
1291
+ "updated_at": case.updated_at,
1292
+ "reviewed_at": case.reviewed_at,
1293
+ "applied_at": case.applied_at,
1294
+ "system_effective_at": case.system_effective_at,
1295
+ "event_valid_from": case.event_valid_from,
1296
+ "event_valid_until": case.event_valid_until,
1297
+ }
1298
+
1299
+
1300
+ def _correction_store_for(engine, active_profile: str):
1301
+ from superlocalmemory.storage.correction_cases import CorrectionCaseStore
1302
+
1303
+ return CorrectionCaseStore(
1304
+ engine._db.db_path,
1305
+ is_profile_active=lambda candidate: candidate == active_profile,
1306
+ # Read operations never invoke this callback; writes use the daemon's
1307
+ # canonical runtime, which derives the authenticated actor separately.
1308
+ is_actor_trusted=lambda _actor: False,
1309
+ )
1310
+
1311
+
1312
+ @router.get("/api/corrections")
1313
+ async def list_corrections(request: Request, limit: int = 100):
1314
+ """List bounded review metadata for the active owning profile."""
1315
+ try:
1316
+ engine, active_profile, _context = _authorize_memory_mutation(
1317
+ request, "update", "correction-list", run_pre_hook=False
1318
+ )
1319
+ cases = _correction_store_for(engine, active_profile).list_cases(active_profile, limit=limit)
1320
+ return {"success": True, "corrections": [_correction_case_response(case) for case in cases]}
1321
+ except HTTPException:
1322
+ raise
1323
+ except Exception as exc:
1324
+ raise _canonical_mutation_error(exc, "Correction list error")
1325
+
1326
+
1327
+ @router.get("/api/corrections/{case_id}")
1328
+ async def get_correction(request: Request, case_id: str):
1329
+ """Get one active-profile correction case without exposing raw memory text."""
1330
+ try:
1331
+ engine, active_profile, _context = _authorize_memory_mutation(
1332
+ request, "update", case_id, run_pre_hook=False
1333
+ )
1334
+ case = _correction_store_for(engine, active_profile).get_case(case_id)
1335
+ return {"success": True, "correction": _correction_case_response(case)}
1336
+ except HTTPException:
1337
+ raise
1338
+ except Exception as exc:
1339
+ from superlocalmemory.storage.correction_cases import CorrectionNotFoundError
1340
+
1341
+ if isinstance(exc, CorrectionNotFoundError):
1342
+ raise HTTPException(404, detail="Correction case not found") from exc
1343
+ raise _canonical_mutation_error(exc, "Correction lookup error")
1344
+
1345
+
1220
1346
  _VALID_SCOPES = ("personal", "shared", "global")
1221
1347
 
1222
1348