superlocalmemory 4.0.5 → 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 (42) hide show
  1. package/CHANGELOG.md +57 -0
  2. package/README.md +10 -6
  3. package/package.json +3 -1
  4. package/pyproject.toml +1 -1
  5. package/src/superlocalmemory/__init__.py +1 -1
  6. package/src/superlocalmemory/access/rbac.py +106 -0
  7. package/src/superlocalmemory/brain/truth.py +80 -10
  8. package/src/superlocalmemory/cli/__main__.py +17 -0
  9. package/src/superlocalmemory/cli/commands.py +14 -3
  10. package/src/superlocalmemory/cli/gdpr_cmd.py +779 -0
  11. package/src/superlocalmemory/cli/gdpr_io.py +109 -0
  12. package/src/superlocalmemory/cli/main.py +76 -0
  13. package/src/superlocalmemory/code_graph/extractors/__init__.py +17 -0
  14. package/src/superlocalmemory/code_graph/graph_store.py +180 -3
  15. package/src/superlocalmemory/code_graph/parser.py +280 -100
  16. package/src/superlocalmemory/compliance/gdpr.py +358 -0
  17. package/src/superlocalmemory/core/config.py +44 -1
  18. package/src/superlocalmemory/core/engine_wiring.py +5 -1
  19. package/src/superlocalmemory/core/maintenance.py +43 -1
  20. package/src/superlocalmemory/core/recall_worker.py +33 -12
  21. package/src/superlocalmemory/infra/backup.py +138 -0
  22. package/src/superlocalmemory/infra/backup_obligations.py +423 -0
  23. package/src/superlocalmemory/learning/engagement.py +165 -0
  24. package/src/superlocalmemory/mcp/tools_code_graph.py +31 -4
  25. package/src/superlocalmemory/mcp/tools_v3.py +20 -6
  26. package/src/superlocalmemory/retrieval/engine.py +21 -0
  27. package/src/superlocalmemory/retrieval/remote_reranker.py +108 -11
  28. package/src/superlocalmemory/server/routes/brain.py +283 -15
  29. package/src/superlocalmemory/server/routes/learning.py +13 -25
  30. package/src/superlocalmemory/server/routes/v3_api.py +171 -60
  31. package/src/superlocalmemory/storage/database.py +36 -0
  32. package/src/superlocalmemory/storage/models.py +12 -4
  33. package/src/superlocalmemory/summaries/__init__.py +37 -0
  34. package/src/superlocalmemory/summaries/base.py +108 -0
  35. package/src/superlocalmemory/summaries/daily_reflection.py +293 -0
  36. package/src/superlocalmemory/summaries/project_work_log.py +424 -0
  37. package/src/superlocalmemory/summaries/session_summary.py +307 -0
  38. package/src/superlocalmemory/ui/css/design-system.css +76 -1
  39. package/src/superlocalmemory/ui/index.html +28 -11
  40. package/src/superlocalmemory/ui/js/od-agents.js +49 -5
  41. package/src/superlocalmemory/ui/js/od-brain.js +257 -77
  42. package/src/superlocalmemory/ui/js/od-graph.js +147 -6
@@ -44,25 +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
59
+
54
60
  from superlocalmemory import __version__
55
61
  from superlocalmemory.brain import BrainTruthService
56
-
57
62
  from superlocalmemory.core.security_primitives import (
58
63
  redact_secrets,
59
64
  verify_install_token,
60
65
  )
66
+ from superlocalmemory.infra.data_root import canonical_data_root
61
67
  from superlocalmemory.learning.database import LearningDatabase
62
68
  from superlocalmemory.learning.features import FEATURE_DIM
63
- from superlocalmemory.infra.data_root import canonical_data_root
64
- from superlocalmemory.storage.read_connection import ReadConnectionFactory
65
69
  from superlocalmemory.storage.agent_experience import get_profile_receipt_summary
70
+ from superlocalmemory.storage.read_connection import ReadConnectionFactory
71
+
66
72
  from .helpers import get_active_profile
67
73
 
68
74
  logger = logging.getLogger("superlocalmemory.routes.brain")
@@ -84,6 +90,17 @@ _VERSION: str = __version__
84
90
  # NOTE: do NOT add the literal forbidden-key strings here — the U4 grep
85
91
  # guard runs over this file.
86
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
+
87
104
  # Memory directory (home-dir based). Always resolved at call time so that
88
105
  # tests can override via monkeypatch on ``_learning_db_path``.
89
106
  _MEMORY_DIR_DEFAULT = None # test-only compatibility override
@@ -596,7 +613,8 @@ def _adapter_last_sync_ago(adapter_name: str) -> int | None:
596
613
  honest empty rather than a fabricated number.
597
614
  """
598
615
  try:
599
- from datetime import datetime as _dt, timezone as _tz
616
+ from datetime import datetime as _dt
617
+ from datetime import timezone as _tz
600
618
  memory_db = _memory_dir() / "memory.db"
601
619
  if not memory_db.exists():
602
620
  return None
@@ -620,6 +638,166 @@ def _adapter_last_sync_ago(adapter_name: str) -> int | None:
620
638
  return None
621
639
 
622
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
+
623
801
  def _compute_cross_platform() -> dict:
624
802
  """Section ``cross_platform`` — live status per injection target.
625
803
 
@@ -700,26 +878,93 @@ def _compute_cross_platform() -> dict:
700
878
  out["mcp"] = {"active": True, "tool": "mcp__slm"}
701
879
  # CLI is trivially active on any install.
702
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()
703
885
  return out
704
886
 
705
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
+
706
935
  def _compute_active_clients(profile_id: str) -> dict:
707
- """Return recent host presence without leaking session identifiers.
936
+ """Return recent host presence with honest telemetry-gap detection.
708
937
 
709
938
  ``cross_platform`` describes configured integration targets. This
710
- separate read-model answers the different question users actually ask:
711
- 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
712
948
  """
949
+ reg_status, newest_ago = _registry_staleness()
950
+
951
+ clients: list[dict] = []
952
+ registry_ok = True
713
953
  try:
714
954
  from superlocalmemory.hooks.session_registry import active_client_summary
715
955
  clients = active_client_summary(profile_id, within_seconds=300)
716
- except Exception:
717
- 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
+
718
961
  return {
719
- "is_real": True,
962
+ "is_real": registry_ok,
720
963
  "scope": "profile",
721
964
  "window_seconds": 300,
722
965
  "clients": clients,
966
+ "registry_status": reg_status,
967
+ "newest_entry_seconds_ago": newest_ago,
723
968
  "source": "session_registry (ephemeral, profile-scoped)",
724
969
  }
725
970
 
@@ -1140,7 +1385,7 @@ async def get_brain(request: Request, profile_id: str | None = None) -> dict:
1140
1385
  preferences, learning, usage, bandit_snap, cache,
1141
1386
  cross_platform, outcomes_preview, evolution, active_clients,
1142
1387
  feedback_loop, source_quality, graph_summary, agent_experience,
1143
- brain_truth,
1388
+ brain_truth, bounded_loops,
1144
1389
  ) = await asyncio.gather(
1145
1390
  asyncio.to_thread(_compute_preferences, profile_id),
1146
1391
  asyncio.to_thread(_compute_learning_status, profile_id, lrn_db),
@@ -1159,6 +1404,7 @@ async def get_brain(request: Request, profile_id: str | None = None) -> dict:
1159
1404
  asyncio.to_thread(_compute_graph_summary, profile_id),
1160
1405
  asyncio.to_thread(_compute_agent_experience, profile_id),
1161
1406
  asyncio.to_thread(truth_service.snapshot, profile_id),
1407
+ asyncio.to_thread(_compute_bounded_loops),
1162
1408
  return_exceptions=True,
1163
1409
  )
1164
1410
 
@@ -1169,9 +1415,15 @@ async def get_brain(request: Request, profile_id: str | None = None) -> dict:
1169
1415
  return fallback
1170
1416
  return value
1171
1417
 
1172
- active_clients = _ok(active_clients, {"is_real": True, "scope": "profile", "clients": [],
1173
- "window_seconds": 300,
1174
- "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
+ })
1175
1427
  feedback_loop = _ok(feedback_loop, {"is_real": True, "signals_by_type": {},
1176
1428
  "explicit_signals": 0, "implicit_signals": 0,
1177
1429
  "settled_outcomes": 0, "mean_settled_reward": None,
@@ -1248,6 +1500,21 @@ async def get_brain(request: Request, profile_id: str | None = None) -> dict:
1248
1500
  "shadow_preview": _compute_shadow_preview(profile_id),
1249
1501
  "evolution_cost_preview": _compute_evolution_cost_preview(profile_id),
1250
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
+ }),
1251
1518
  "meta": _meta_now(),
1252
1519
  }
1253
1520
 
@@ -1261,7 +1528,8 @@ def _compute_outcome_queue_stats(profile_id: str) -> dict:
1261
1528
  """
1262
1529
  try:
1263
1530
  from superlocalmemory.learning.outcome_queue import (
1264
- get_counters, queue_size,
1531
+ get_counters,
1532
+ queue_size,
1265
1533
  )
1266
1534
  counters = get_counters()
1267
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