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.
- package/CHANGELOG.md +90 -0
- package/README.md +23 -14
- package/ide/configs/codex-mcp.toml +2 -2
- package/package.json +3 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.mcp.json +1 -0
- package/plugin/CLAUDE.md +3 -3
- package/plugin/agents/slm-governance-advisor.md +1 -1
- package/plugin/agents/slm-loop-runner.md +1 -1
- package/plugin/agents/slm-memory-advisor.md +1 -1
- package/plugin/agents/slm-optimize-advisor.md +1 -1
- package/plugin/requirements.txt +1 -1
- package/plugin/skills/slm-cache/SKILL.md +1 -1
- package/plugin/skills/slm-compress/SKILL.md +1 -1
- package/plugin/skills/slm-governance/SKILL.md +1 -1
- package/plugin/skills/slm-graph/SKILL.md +3 -2
- package/plugin/skills/slm-loop/SKILL.md +1 -1
- package/plugin/skills/slm-mesh/SKILL.md +1 -1
- package/plugin/skills/slm-profile/SKILL.md +2 -1
- package/plugin/skills/slm-recall/SKILL.md +1 -1
- package/plugin/skills/slm-remember/SKILL.md +1 -1
- package/plugin/skills/slm-scope/SKILL.md +1 -1
- package/plugin/skills/slm-session/SKILL.md +1 -1
- package/plugin/skills/slm-status/SKILL.md +1 -1
- package/plugin-src/rules/AGENTS.md +6 -5
- package/plugin-src/skills/slm-graph/SKILL.md +2 -1
- package/plugin-src/skills/slm-profile/SKILL.md +1 -0
- package/pyproject.toml +1 -1
- package/src/superlocalmemory/__init__.py +1 -1
- package/src/superlocalmemory/access/rbac.py +106 -0
- package/src/superlocalmemory/brain/__init__.py +5 -0
- package/src/superlocalmemory/brain/truth.py +418 -0
- package/src/superlocalmemory/cli/__main__.py +17 -0
- package/src/superlocalmemory/cli/commands.py +96 -28
- package/src/superlocalmemory/cli/gdpr_cmd.py +779 -0
- package/src/superlocalmemory/cli/gdpr_io.py +109 -0
- package/src/superlocalmemory/cli/main.py +88 -0
- package/src/superlocalmemory/code_graph/extractors/__init__.py +17 -0
- package/src/superlocalmemory/code_graph/graph_store.py +180 -3
- package/src/superlocalmemory/code_graph/parser.py +280 -100
- package/src/superlocalmemory/compliance/gdpr.py +358 -0
- package/src/superlocalmemory/core/config.py +44 -1
- package/src/superlocalmemory/core/context_cache.py +58 -1
- package/src/superlocalmemory/core/engine_wiring.py +5 -1
- package/src/superlocalmemory/core/maintenance.py +43 -1
- package/src/superlocalmemory/core/mutations.py +155 -25
- package/src/superlocalmemory/core/recall_pipeline.py +6 -10
- package/src/superlocalmemory/core/recall_worker.py +33 -12
- package/src/superlocalmemory/core/remember_runtime.py +271 -2
- package/src/superlocalmemory/core/store_pipeline.py +100 -38
- package/src/superlocalmemory/encoding/consolidator.py +17 -47
- package/src/superlocalmemory/encoding/temporal_validator.py +14 -18
- package/src/superlocalmemory/hooks/user_prompt_hook.py +1 -1
- package/src/superlocalmemory/infra/backup.py +138 -0
- package/src/superlocalmemory/infra/backup_obligations.py +423 -0
- package/src/superlocalmemory/integrations/bounded_loops_mcp.py +4 -3
- package/src/superlocalmemory/learning/engagement.py +165 -0
- package/src/superlocalmemory/mcp/profiles.py +19 -7
- package/src/superlocalmemory/mcp/server.py +4 -2
- package/src/superlocalmemory/mcp/tools_brain.py +54 -10
- package/src/superlocalmemory/mcp/tools_code_graph.py +31 -4
- package/src/superlocalmemory/mcp/tools_core.py +88 -3
- package/src/superlocalmemory/mcp/tools_v3.py +20 -6
- package/src/superlocalmemory/retrieval/engine.py +28 -10
- package/src/superlocalmemory/retrieval/remote_reranker.py +108 -11
- package/src/superlocalmemory/retrieval/temporal_validity_filter.py +119 -19
- package/src/superlocalmemory/server/routes/brain.py +297 -14
- package/src/superlocalmemory/server/routes/learning.py +13 -25
- package/src/superlocalmemory/server/routes/memories.py +129 -3
- package/src/superlocalmemory/server/routes/v3_api.py +171 -60
- package/src/superlocalmemory/storage/_migration_internals.py +4 -0
- package/src/superlocalmemory/storage/_schema_version.py +2 -2
- package/src/superlocalmemory/storage/correction_cases.py +670 -0
- package/src/superlocalmemory/storage/database.py +230 -24
- package/src/superlocalmemory/storage/migration_runner.py +7 -0
- package/src/superlocalmemory/storage/migrations/M042_correction_case_ledger.py +245 -0
- package/src/superlocalmemory/storage/migrations/__init__.py +2 -0
- package/src/superlocalmemory/storage/models.py +12 -4
- package/src/superlocalmemory/storage/write_coordinator.py +4 -0
- package/src/superlocalmemory/summaries/__init__.py +37 -0
- package/src/superlocalmemory/summaries/base.py +108 -0
- package/src/superlocalmemory/summaries/daily_reflection.py +293 -0
- package/src/superlocalmemory/summaries/project_work_log.py +424 -0
- package/src/superlocalmemory/summaries/session_summary.py +307 -0
- package/src/superlocalmemory/ui/css/design-system.css +76 -1
- package/src/superlocalmemory/ui/index.html +28 -11
- package/src/superlocalmemory/ui/js/brain.js +43 -7
- package/src/superlocalmemory/ui/js/od-agents.js +49 -5
- package/src/superlocalmemory/ui/js/od-brain.js +280 -84
- 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
|
|
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
|
|
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
|
|
710
|
-
|
|
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
|
-
|
|
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":
|
|
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, {
|
|
1166
|
-
|
|
1167
|
-
|
|
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,
|
|
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.
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
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
|
-
"""
|
|
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
|
-
|
|
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
|
|