superlocalmemory 4.0.1 → 4.0.2
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 +24 -0
- package/README.md +10 -11
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- 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 +1 -1
- 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 +1 -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 +1 -1
- package/plugin-src/skills/slm-cache/SKILL.md +1 -1
- package/plugin-src/skills/slm-compress/SKILL.md +1 -1
- package/plugin-src/skills/slm-governance/SKILL.md +1 -1
- package/plugin-src/skills/slm-graph/SKILL.md +1 -1
- package/plugin-src/skills/slm-loop/SKILL.md +1 -1
- package/plugin-src/skills/slm-mesh/SKILL.md +1 -1
- package/plugin-src/skills/slm-profile/SKILL.md +1 -1
- package/plugin-src/skills/slm-recall/SKILL.md +1 -1
- package/plugin-src/skills/slm-remember/SKILL.md +1 -1
- package/plugin-src/skills/slm-scope/SKILL.md +1 -1
- package/plugin-src/skills/slm-session/SKILL.md +1 -1
- package/plugin-src/skills/slm-status/SKILL.md +1 -1
- package/pyproject.toml +3 -2
- package/src/superlocalmemory/__init__.py +1 -1
- package/src/superlocalmemory/cli/commands.py +60 -1
- package/src/superlocalmemory/cli/main.py +24 -1
- package/src/superlocalmemory/compliance/gdpr.py +104 -73
- package/src/superlocalmemory/contracts/__init__.py +1 -0
- package/src/superlocalmemory/contracts/schemas/agent-experience-v1.schema.json +92 -0
- package/src/superlocalmemory/contracts/schemas/agent-integration-contract-v2.schema.json +46 -0
- package/src/superlocalmemory/contracts/schemas/cognitive-turn-receipt-v1.schema.json +59 -0
- package/src/superlocalmemory/contracts/v402.py +62 -0
- package/src/superlocalmemory/core/engine.py +10 -0
- package/src/superlocalmemory/core/recall_pipeline.py +6 -0
- package/src/superlocalmemory/core/recall_worker.py +12 -0
- package/src/superlocalmemory/core/worker_pool.py +12 -0
- package/src/superlocalmemory/hooks/hook_handlers.py +16 -0
- package/src/superlocalmemory/hooks/post_tool_outcome_hook.py +12 -6
- package/src/superlocalmemory/hooks/session_registry.py +136 -3
- package/src/superlocalmemory/hooks/user_prompt_hook.py +9 -2
- package/src/superlocalmemory/integrations/__init__.py +1 -0
- package/src/superlocalmemory/integrations/bounded_loops_v051.py +236 -0
- package/src/superlocalmemory/learning/database.py +21 -14
- package/src/superlocalmemory/mcp/_daemon_proxy.py +9 -0
- package/src/superlocalmemory/mcp/server.py +5 -0
- package/src/superlocalmemory/mcp/tools_brain.py +132 -0
- package/src/superlocalmemory/mcp/tools_core.py +25 -6
- package/src/superlocalmemory/mcp/tools_v3.py +16 -2
- package/src/superlocalmemory/retrieval/engine.py +43 -1
- package/src/superlocalmemory/retrieval/temporal_utils.py +16 -1
- package/src/superlocalmemory/retrieval/temporal_validity_filter.py +151 -0
- package/src/superlocalmemory/server/routes/brain.py +206 -1
- package/src/superlocalmemory/server/routes/helpers.py +53 -35
- package/src/superlocalmemory/server/routes/v3_api.py +118 -11
- package/src/superlocalmemory/server/unified_daemon.py +25 -0
- package/src/superlocalmemory/storage/_migration_internals.py +4 -0
- package/src/superlocalmemory/storage/_schema_version.py +2 -2
- package/src/superlocalmemory/storage/agent_experience.py +490 -0
- package/src/superlocalmemory/storage/database.py +189 -34
- package/src/superlocalmemory/storage/migration_runner.py +8 -0
- package/src/superlocalmemory/storage/migrations/M015_add_pinned_column.py +18 -0
- package/src/superlocalmemory/storage/migrations/M040_agent_experience_receipts.py +254 -0
- package/src/superlocalmemory/storage/migrations/__init__.py +2 -0
- package/src/superlocalmemory/storage/schema.py +4 -0
- package/src/superlocalmemory/ui/js/auto-settings.js +18 -14
- package/src/superlocalmemory/ui/js/brain.js +57 -1
- package/src/superlocalmemory/ui/js/od-brain.js +114 -40
- package/src/superlocalmemory/ui/js/od-settings.js +8 -1
|
@@ -61,6 +61,7 @@ from superlocalmemory.learning.database import LearningDatabase
|
|
|
61
61
|
from superlocalmemory.learning.features import FEATURE_DIM
|
|
62
62
|
from superlocalmemory.infra.data_root import canonical_data_root
|
|
63
63
|
from superlocalmemory.storage.read_connection import ReadConnectionFactory
|
|
64
|
+
from superlocalmemory.storage.agent_experience import get_profile_receipt_summary
|
|
64
65
|
from .helpers import get_active_profile
|
|
65
66
|
|
|
66
67
|
logger = logging.getLogger("superlocalmemory.routes.brain")
|
|
@@ -320,6 +321,15 @@ def _compute_learning_status(profile_id: str,
|
|
|
320
321
|
}
|
|
321
322
|
|
|
322
323
|
|
|
324
|
+
def _compute_agent_experience(profile_id: str) -> dict:
|
|
325
|
+
"""Honest, profile-scoped receipt evidence for the Living Brain.
|
|
326
|
+
|
|
327
|
+
Receipt rows record observation and verification evidence only; this read
|
|
328
|
+
model never alters retrieval, ranking, or model routing.
|
|
329
|
+
"""
|
|
330
|
+
return get_profile_receipt_summary(_learning_db_path(), profile_id)
|
|
331
|
+
|
|
332
|
+
|
|
323
333
|
def _resolve_phase(signals: int, model_active: bool,
|
|
324
334
|
sha_present: bool) -> tuple[int, str]:
|
|
325
335
|
"""LLD-02 §4.10 — phase truth, U3 enforcement.
|
|
@@ -687,6 +697,161 @@ def _compute_cross_platform() -> dict:
|
|
|
687
697
|
return out
|
|
688
698
|
|
|
689
699
|
|
|
700
|
+
def _compute_active_clients(profile_id: str) -> dict:
|
|
701
|
+
"""Return recent host presence without leaking session identifiers.
|
|
702
|
+
|
|
703
|
+
``cross_platform`` describes configured integration targets. This
|
|
704
|
+
separate read-model answers the different question users actually ask:
|
|
705
|
+
which host clients have recently interacted with this local brain?
|
|
706
|
+
"""
|
|
707
|
+
try:
|
|
708
|
+
from superlocalmemory.hooks.session_registry import active_client_summary
|
|
709
|
+
clients = active_client_summary(profile_id, within_seconds=300)
|
|
710
|
+
except Exception:
|
|
711
|
+
clients = []
|
|
712
|
+
return {
|
|
713
|
+
"is_real": True,
|
|
714
|
+
"scope": "profile",
|
|
715
|
+
"window_seconds": 300,
|
|
716
|
+
"clients": clients,
|
|
717
|
+
"source": "session_registry (ephemeral, profile-scoped)",
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
|
|
721
|
+
def _compute_feedback_loop(profile_id: str, lrn_db: LearningDatabase) -> dict:
|
|
722
|
+
"""Expose only durable feedback evidence that already drives learning."""
|
|
723
|
+
empty = {
|
|
724
|
+
"is_real": True,
|
|
725
|
+
"signals_by_type": {},
|
|
726
|
+
"explicit_signals": 0,
|
|
727
|
+
"implicit_signals": 0,
|
|
728
|
+
"settled_outcomes": 0,
|
|
729
|
+
"mean_settled_reward": None,
|
|
730
|
+
"source": "learning_signals + memory.db:action_outcomes",
|
|
731
|
+
}
|
|
732
|
+
signal_rows: list[sqlite3.Row] = []
|
|
733
|
+
try:
|
|
734
|
+
learning_conn = lrn_db.ro_connection()
|
|
735
|
+
try:
|
|
736
|
+
signal_rows = learning_conn.execute(
|
|
737
|
+
"SELECT signal_type, COUNT(*) AS count FROM learning_signals "
|
|
738
|
+
"WHERE profile_id = ? GROUP BY signal_type ORDER BY signal_type",
|
|
739
|
+
(profile_id,),
|
|
740
|
+
).fetchall()
|
|
741
|
+
finally:
|
|
742
|
+
learning_conn.close()
|
|
743
|
+
except sqlite3.Error:
|
|
744
|
+
return empty
|
|
745
|
+
by_type = {
|
|
746
|
+
str(row["signal_type"]): int(row["count"] or 0)
|
|
747
|
+
for row in signal_rows
|
|
748
|
+
}
|
|
749
|
+
explicit_types = {
|
|
750
|
+
"user_positive", "user_negative", "user_correction", "user_pin",
|
|
751
|
+
"legacy_feedback",
|
|
752
|
+
}
|
|
753
|
+
explicit = sum(count for kind, count in by_type.items() if kind in explicit_types)
|
|
754
|
+
implicit = sum(count for kind, count in by_type.items() if kind not in explicit_types)
|
|
755
|
+
settled, mean_reward = 0, None
|
|
756
|
+
db_path = _memory_db_path()
|
|
757
|
+
if db_path.exists():
|
|
758
|
+
try:
|
|
759
|
+
memory_conn = ReadConnectionFactory(db_path).open()
|
|
760
|
+
try:
|
|
761
|
+
row = memory_conn.execute(
|
|
762
|
+
"SELECT COUNT(*) AS count, AVG(reward) AS mean_reward "
|
|
763
|
+
"FROM action_outcomes WHERE profile_id = ? AND settled = 1 "
|
|
764
|
+
"AND reward IS NOT NULL",
|
|
765
|
+
(profile_id,),
|
|
766
|
+
).fetchone()
|
|
767
|
+
if row:
|
|
768
|
+
settled = int(row["count"] or 0)
|
|
769
|
+
mean_reward = (
|
|
770
|
+
float(row["mean_reward"])
|
|
771
|
+
if row["mean_reward"] is not None else None
|
|
772
|
+
)
|
|
773
|
+
finally:
|
|
774
|
+
memory_conn.close()
|
|
775
|
+
except sqlite3.Error:
|
|
776
|
+
pass
|
|
777
|
+
return {
|
|
778
|
+
**empty,
|
|
779
|
+
"signals_by_type": by_type,
|
|
780
|
+
"explicit_signals": explicit,
|
|
781
|
+
"implicit_signals": implicit,
|
|
782
|
+
"settled_outcomes": settled,
|
|
783
|
+
"mean_settled_reward": mean_reward,
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
|
|
787
|
+
def _compute_source_quality(profile_id: str) -> dict:
|
|
788
|
+
"""Summarize observed provenance quality; neutral priors are not evidence."""
|
|
789
|
+
empty = {
|
|
790
|
+
"is_real": True,
|
|
791
|
+
"observed_sources": 0,
|
|
792
|
+
"mean_quality": None,
|
|
793
|
+
"source": "learning.db:source_quality_observations",
|
|
794
|
+
}
|
|
795
|
+
db_path = _learning_db_path()
|
|
796
|
+
if not db_path.exists():
|
|
797
|
+
return empty
|
|
798
|
+
try:
|
|
799
|
+
conn = sqlite3.connect(db_path, timeout=5.0)
|
|
800
|
+
conn.row_factory = sqlite3.Row
|
|
801
|
+
try:
|
|
802
|
+
row = conn.execute(
|
|
803
|
+
"SELECT COUNT(DISTINCT source_id) AS sources, AVG(reward) AS mean_quality "
|
|
804
|
+
"FROM source_quality_observations WHERE profile_id = ?",
|
|
805
|
+
(profile_id,),
|
|
806
|
+
).fetchone()
|
|
807
|
+
finally:
|
|
808
|
+
conn.close()
|
|
809
|
+
if row is None:
|
|
810
|
+
return empty
|
|
811
|
+
return {
|
|
812
|
+
**empty,
|
|
813
|
+
"observed_sources": int(row["sources"] or 0),
|
|
814
|
+
"mean_quality": float(row["mean_quality"]) if row["mean_quality"] is not None else None,
|
|
815
|
+
}
|
|
816
|
+
except sqlite3.Error:
|
|
817
|
+
return empty
|
|
818
|
+
|
|
819
|
+
|
|
820
|
+
def _compute_graph_summary(profile_id: str) -> dict:
|
|
821
|
+
"""Return graph evidence counts, never a decorative graph surrogate."""
|
|
822
|
+
empty = {
|
|
823
|
+
"is_real": True,
|
|
824
|
+
"fact_nodes": 0,
|
|
825
|
+
"association_edges": 0,
|
|
826
|
+
"source": "memory.db:atomic_facts + association_edges",
|
|
827
|
+
}
|
|
828
|
+
db_path = _memory_db_path()
|
|
829
|
+
if not db_path.exists():
|
|
830
|
+
return empty
|
|
831
|
+
try:
|
|
832
|
+
conn = ReadConnectionFactory(db_path).open()
|
|
833
|
+
try:
|
|
834
|
+
facts = conn.execute(
|
|
835
|
+
"SELECT COUNT(*) AS count FROM atomic_facts "
|
|
836
|
+
"WHERE profile_id = ?",
|
|
837
|
+
(profile_id,),
|
|
838
|
+
).fetchone()
|
|
839
|
+
edges = conn.execute(
|
|
840
|
+
"SELECT COUNT(*) AS count FROM association_edges "
|
|
841
|
+
"WHERE profile_id = ?",
|
|
842
|
+
(profile_id,),
|
|
843
|
+
).fetchone()
|
|
844
|
+
finally:
|
|
845
|
+
conn.close()
|
|
846
|
+
return {
|
|
847
|
+
**empty,
|
|
848
|
+
"fact_nodes": int(facts["count"] or 0) if facts else 0,
|
|
849
|
+
"association_edges": int(edges["count"] or 0) if edges else 0,
|
|
850
|
+
}
|
|
851
|
+
except sqlite3.Error:
|
|
852
|
+
return empty
|
|
853
|
+
|
|
854
|
+
|
|
690
855
|
def _meta_now() -> dict:
|
|
691
856
|
return {
|
|
692
857
|
"generated_at": datetime.now(timezone.utc)
|
|
@@ -963,7 +1128,8 @@ async def get_brain(request: Request, profile_id: str | None = None) -> dict:
|
|
|
963
1128
|
|
|
964
1129
|
(
|
|
965
1130
|
preferences, learning, usage, bandit_snap, cache,
|
|
966
|
-
cross_platform, outcomes_preview, evolution,
|
|
1131
|
+
cross_platform, outcomes_preview, evolution, active_clients,
|
|
1132
|
+
feedback_loop, source_quality, graph_summary, agent_experience,
|
|
967
1133
|
) = await asyncio.gather(
|
|
968
1134
|
asyncio.to_thread(_compute_preferences, profile_id),
|
|
969
1135
|
asyncio.to_thread(_compute_learning_status, profile_id, lrn_db),
|
|
@@ -976,6 +1142,11 @@ async def get_brain(request: Request, profile_id: str | None = None) -> dict:
|
|
|
976
1142
|
_compute_evolution_timeseries, profile_id, lrn_db,
|
|
977
1143
|
days=_EVOLUTION_DEFAULT_DAYS,
|
|
978
1144
|
),
|
|
1145
|
+
asyncio.to_thread(_compute_active_clients, profile_id),
|
|
1146
|
+
asyncio.to_thread(_compute_feedback_loop, profile_id, lrn_db),
|
|
1147
|
+
asyncio.to_thread(_compute_source_quality, profile_id),
|
|
1148
|
+
asyncio.to_thread(_compute_graph_summary, profile_id),
|
|
1149
|
+
asyncio.to_thread(_compute_agent_experience, profile_id),
|
|
979
1150
|
return_exceptions=True,
|
|
980
1151
|
)
|
|
981
1152
|
|
|
@@ -986,6 +1157,26 @@ async def get_brain(request: Request, profile_id: str | None = None) -> dict:
|
|
|
986
1157
|
return fallback
|
|
987
1158
|
return value
|
|
988
1159
|
|
|
1160
|
+
active_clients = _ok(active_clients, {"is_real": True, "scope": "profile", "clients": [],
|
|
1161
|
+
"window_seconds": 300,
|
|
1162
|
+
"source": "session_registry unavailable"})
|
|
1163
|
+
feedback_loop = _ok(feedback_loop, {"is_real": True, "signals_by_type": {},
|
|
1164
|
+
"explicit_signals": 0, "implicit_signals": 0,
|
|
1165
|
+
"settled_outcomes": 0, "mean_settled_reward": None,
|
|
1166
|
+
"source": "feedback data unavailable"})
|
|
1167
|
+
source_quality = _ok(source_quality, {"is_real": True, "observed_sources": 0,
|
|
1168
|
+
"mean_quality": None,
|
|
1169
|
+
"source": "source-quality data unavailable"})
|
|
1170
|
+
graph_summary = _ok(graph_summary, {"is_real": True, "fact_nodes": 0,
|
|
1171
|
+
"association_edges": 0,
|
|
1172
|
+
"source": "graph data unavailable"})
|
|
1173
|
+
agent_experience = _ok(agent_experience, {
|
|
1174
|
+
"is_real": False, "availability": "unavailable",
|
|
1175
|
+
"experiences_total": 0, "turns_total": 0,
|
|
1176
|
+
"turns_by_state": {}, "claimed_evidence_experiences": 0,
|
|
1177
|
+
"source": "receipt data unavailable",
|
|
1178
|
+
})
|
|
1179
|
+
|
|
989
1180
|
return {
|
|
990
1181
|
"profile_id": profile_id,
|
|
991
1182
|
"preferences": _ok(preferences, {"is_real": True,
|
|
@@ -1005,6 +1196,20 @@ async def get_brain(request: Request, profile_id: str | None = None) -> dict:
|
|
|
1005
1196
|
"db_size_bytes": 0,
|
|
1006
1197
|
"entry_count": 0}),
|
|
1007
1198
|
"cross_platform": _ok(cross_platform, {}),
|
|
1199
|
+
# One canonical, transport-neutral read model for the non-technical
|
|
1200
|
+
# Living Brain. Existing top-level sections stay intact for the CLI,
|
|
1201
|
+
# MCP and older dashboard clients. This is observation only: it never
|
|
1202
|
+
# changes retrieval or model routing.
|
|
1203
|
+
"living_brain": {
|
|
1204
|
+
"is_real": True,
|
|
1205
|
+
"control_plane": "observation_only",
|
|
1206
|
+
"connected_clients": active_clients,
|
|
1207
|
+
"feedback": feedback_loop,
|
|
1208
|
+
"source_quality": source_quality,
|
|
1209
|
+
"graph": graph_summary,
|
|
1210
|
+
"agent_experience": agent_experience,
|
|
1211
|
+
"source": "local durable stores + ephemeral session registry",
|
|
1212
|
+
},
|
|
1008
1213
|
"evolution_preview": _ok(evolution, {
|
|
1009
1214
|
"is_real": True, "source": "learning_signals",
|
|
1010
1215
|
"days": _EVOLUTION_DEFAULT_DAYS, "total_signals": 0, "points": [],
|
|
@@ -8,9 +8,10 @@ Shared utilities for all route modules: DB connection, dict factory,
|
|
|
8
8
|
profile helper, validation, Pydantic models, config paths, and the
|
|
9
9
|
shared lazy engine accessor used by every engine-dependent route.
|
|
10
10
|
"""
|
|
11
|
+
|
|
12
|
+
import json
|
|
11
13
|
import logging
|
|
12
14
|
import re
|
|
13
|
-
import json
|
|
14
15
|
import sqlite3
|
|
15
16
|
import threading
|
|
16
17
|
import time
|
|
@@ -24,7 +25,6 @@ from pydantic import BaseModel, Field
|
|
|
24
25
|
from superlocalmemory.infra.data_root import DynamicStatePath, canonical_data_root
|
|
25
26
|
from superlocalmemory.storage.memory_write import memory_read, memory_write
|
|
26
27
|
|
|
27
|
-
|
|
28
28
|
_engine_logger = logging.getLogger("superlocalmemory.engine")
|
|
29
29
|
|
|
30
30
|
|
|
@@ -32,6 +32,7 @@ _engine_logger = logging.getLogger("superlocalmemory.engine")
|
|
|
32
32
|
# Version detection (shared — avoids circular import between ui.py ↔ v3_api.py)
|
|
33
33
|
# ---------------------------------------------------------------------------
|
|
34
34
|
|
|
35
|
+
|
|
35
36
|
def _get_version() -> str:
|
|
36
37
|
"""Read version from package.json / pyproject.toml / importlib.
|
|
37
38
|
|
|
@@ -43,6 +44,7 @@ def _get_version() -> str:
|
|
|
43
44
|
for depth in (5, 4):
|
|
44
45
|
try:
|
|
45
46
|
import json as _json
|
|
47
|
+
|
|
46
48
|
root = here
|
|
47
49
|
for _ in range(depth):
|
|
48
50
|
root = root.parent
|
|
@@ -55,12 +57,14 @@ def _get_version() -> str:
|
|
|
55
57
|
toml_path = root / "pyproject.toml"
|
|
56
58
|
if toml_path.exists():
|
|
57
59
|
import tomllib
|
|
60
|
+
|
|
58
61
|
with open(toml_path, "rb") as f:
|
|
59
62
|
return tomllib.load(f)["project"]["version"]
|
|
60
63
|
except Exception:
|
|
61
64
|
continue
|
|
62
65
|
try:
|
|
63
66
|
from importlib.metadata import version
|
|
67
|
+
|
|
64
68
|
return version("superlocalmemory")
|
|
65
69
|
except Exception:
|
|
66
70
|
pass
|
|
@@ -147,6 +151,7 @@ def get_engine_lazy(app_state):
|
|
|
147
151
|
try:
|
|
148
152
|
from superlocalmemory.core.config import SLMConfig
|
|
149
153
|
from superlocalmemory.core.engine import MemoryEngine
|
|
154
|
+
|
|
150
155
|
config = SLMConfig.load()
|
|
151
156
|
new_engine = MemoryEngine(config)
|
|
152
157
|
new_engine.initialize()
|
|
@@ -213,7 +218,11 @@ def log_mode_change(
|
|
|
213
218
|
|
|
214
219
|
_engine_logger.info(
|
|
215
220
|
"Mode change: %s→%s provider=%s model=%s (%s)",
|
|
216
|
-
old_mode,
|
|
221
|
+
old_mode,
|
|
222
|
+
new_mode,
|
|
223
|
+
provider,
|
|
224
|
+
model,
|
|
225
|
+
source,
|
|
217
226
|
)
|
|
218
227
|
|
|
219
228
|
|
|
@@ -277,17 +286,17 @@ def get_active_profile() -> str:
|
|
|
277
286
|
config_file = MEMORY_DIR / "profiles.json"
|
|
278
287
|
if config_file.exists():
|
|
279
288
|
try:
|
|
280
|
-
with open(config_file,
|
|
289
|
+
with open(config_file, "r") as f:
|
|
281
290
|
pconfig = json.load(f)
|
|
282
|
-
return pconfig.get(
|
|
291
|
+
return pconfig.get("active_profile", "default")
|
|
283
292
|
except (json.JSONDecodeError, IOError):
|
|
284
293
|
pass
|
|
285
|
-
return
|
|
294
|
+
return "default"
|
|
286
295
|
|
|
287
296
|
|
|
288
297
|
def validate_profile_name(name: str) -> bool:
|
|
289
298
|
"""Validate profile name (alphanumeric, underscore, hyphen only)."""
|
|
290
|
-
return bool(re.match(r
|
|
299
|
+
return bool(re.match(r"^[a-zA-Z0-9_-]+$", name))
|
|
291
300
|
|
|
292
301
|
|
|
293
302
|
# ============================================================================
|
|
@@ -308,8 +317,7 @@ def ensure_profile_in_db(name: str, description: str = "") -> None:
|
|
|
308
317
|
with memory_write(DB_PATH) as conn:
|
|
309
318
|
conn.execute("PRAGMA foreign_keys=ON")
|
|
310
319
|
conn.execute(
|
|
311
|
-
"INSERT OR IGNORE INTO profiles (profile_id, name, description) "
|
|
312
|
-
"VALUES (?, ?, ?)",
|
|
320
|
+
"INSERT OR IGNORE INTO profiles (profile_id, name, description) VALUES (?, ?, ?)",
|
|
313
321
|
(name, name, description or f"Memory profile: {name}"),
|
|
314
322
|
)
|
|
315
323
|
|
|
@@ -317,17 +325,17 @@ def ensure_profile_in_db(name: str, description: str = "") -> None:
|
|
|
317
325
|
def ensure_profile_in_json(name: str, description: str = "") -> None:
|
|
318
326
|
"""Ensure a profile entry exists in profiles.json (idempotent)."""
|
|
319
327
|
from datetime import datetime
|
|
320
|
-
|
|
328
|
+
|
|
321
329
|
config = _load_profiles_json()
|
|
322
|
-
profiles = config.get(
|
|
330
|
+
profiles = config.get("profiles", {})
|
|
323
331
|
if name not in profiles:
|
|
324
332
|
profiles[name] = {
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
333
|
+
"name": name,
|
|
334
|
+
"description": description or f"Memory profile: {name}",
|
|
335
|
+
"created_at": datetime.now(timezone.utc).isoformat(),
|
|
336
|
+
"last_used": None,
|
|
329
337
|
}
|
|
330
|
-
config[
|
|
338
|
+
config["profiles"] = profiles
|
|
331
339
|
_save_profiles_json(config)
|
|
332
340
|
|
|
333
341
|
|
|
@@ -340,31 +348,31 @@ def sync_profiles() -> list[dict]:
|
|
|
340
348
|
"""
|
|
341
349
|
db_profiles = _get_db_profiles()
|
|
342
350
|
json_config = _load_profiles_json()
|
|
343
|
-
json_profiles = json_config.get(
|
|
351
|
+
json_profiles = json_config.get("profiles", {})
|
|
344
352
|
|
|
345
353
|
# profile_id is the canonical key (PK in SQLite, FK target everywhere)
|
|
346
|
-
db_ids = {p[
|
|
354
|
+
db_ids = {p["profile_id"] for p in db_profiles}
|
|
347
355
|
json_keys = set(json_profiles.keys())
|
|
348
356
|
|
|
349
357
|
changed = False
|
|
350
358
|
|
|
351
359
|
# JSON-only → add to SQLite (fixes Dashboard-created profiles)
|
|
352
360
|
for key in json_keys - db_ids:
|
|
353
|
-
ensure_profile_in_db(key, json_profiles[key].get(
|
|
361
|
+
ensure_profile_in_db(key, json_profiles[key].get("description", ""))
|
|
354
362
|
|
|
355
363
|
# SQLite-only → add to profiles.json (fixes CLI-created profiles)
|
|
356
364
|
for pid in db_ids - json_keys:
|
|
357
|
-
db_entry = next(p for p in db_profiles if p[
|
|
365
|
+
db_entry = next(p for p in db_profiles if p["profile_id"] == pid)
|
|
358
366
|
json_profiles[pid] = {
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
367
|
+
"name": pid,
|
|
368
|
+
"description": db_entry.get("description", ""),
|
|
369
|
+
"created_at": db_entry.get("created_at", ""),
|
|
370
|
+
"last_used": db_entry.get("last_used"),
|
|
363
371
|
}
|
|
364
372
|
changed = True
|
|
365
373
|
|
|
366
374
|
if changed:
|
|
367
|
-
json_config[
|
|
375
|
+
json_config["profiles"] = json_profiles
|
|
368
376
|
_save_profiles_json(json_config)
|
|
369
377
|
|
|
370
378
|
# Return merged list from SQLite (now authoritative)
|
|
@@ -389,6 +397,12 @@ def delete_profile_from_db(name: str) -> None:
|
|
|
389
397
|
"""
|
|
390
398
|
if not DB_PATH.exists():
|
|
391
399
|
return
|
|
400
|
+
# Receipt evidence lives in learning.db, not recall's memory.db. Purge it
|
|
401
|
+
# first: if this fails the profile row must survive for a retry rather than
|
|
402
|
+
# leaving profile-scoped learning evidence orphaned.
|
|
403
|
+
from superlocalmemory.storage.agent_experience import purge_profile_receipts
|
|
404
|
+
|
|
405
|
+
purge_profile_receipts(Path(DB_PATH).parent / "learning.db", name)
|
|
392
406
|
with memory_write(DB_PATH) as conn:
|
|
393
407
|
conn.execute("PRAGMA foreign_keys=ON")
|
|
394
408
|
# Purge role grants for this workspace (no FK CASCADE covers these).
|
|
@@ -420,24 +434,24 @@ def _load_profiles_json() -> dict:
|
|
|
420
434
|
config_file = MEMORY_DIR / "profiles.json"
|
|
421
435
|
if config_file.exists():
|
|
422
436
|
try:
|
|
423
|
-
with open(config_file,
|
|
437
|
+
with open(config_file, "r") as f:
|
|
424
438
|
data = json.load(f)
|
|
425
439
|
# Handle ProfileManager array format → convert to dict format
|
|
426
|
-
if isinstance(data.get(
|
|
440
|
+
if isinstance(data.get("profiles"), list):
|
|
427
441
|
converted = {}
|
|
428
|
-
for p in data[
|
|
429
|
-
n = p.get(
|
|
442
|
+
for p in data["profiles"]:
|
|
443
|
+
n = p.get("name", "")
|
|
430
444
|
if n:
|
|
431
445
|
converted[n] = p
|
|
432
|
-
data[
|
|
433
|
-
if
|
|
434
|
-
data[
|
|
446
|
+
data["profiles"] = converted
|
|
447
|
+
if "active" in data and "active_profile" not in data:
|
|
448
|
+
data["active_profile"] = data.pop("active")
|
|
435
449
|
return data
|
|
436
450
|
except (json.JSONDecodeError, IOError):
|
|
437
451
|
pass
|
|
438
452
|
return {
|
|
439
|
-
|
|
440
|
-
|
|
453
|
+
"profiles": {"default": {"name": "default", "description": "Default memory profile"}},
|
|
454
|
+
"active_profile": "default",
|
|
441
455
|
}
|
|
442
456
|
|
|
443
457
|
|
|
@@ -445,7 +459,7 @@ def _save_profiles_json(config: dict) -> None:
|
|
|
445
459
|
"""Save profiles.json config."""
|
|
446
460
|
MEMORY_DIR.mkdir(parents=True, exist_ok=True)
|
|
447
461
|
config_file = MEMORY_DIR / "profiles.json"
|
|
448
|
-
with open(config_file,
|
|
462
|
+
with open(config_file, "w") as f:
|
|
449
463
|
json.dump(config, f, indent=2)
|
|
450
464
|
|
|
451
465
|
|
|
@@ -453,8 +467,10 @@ def _save_profiles_json(config: dict) -> None:
|
|
|
453
467
|
# Pydantic Models (shared across routes)
|
|
454
468
|
# ============================================================================
|
|
455
469
|
|
|
470
|
+
|
|
456
471
|
class SearchRequest(BaseModel):
|
|
457
472
|
"""Advanced search request model."""
|
|
473
|
+
|
|
458
474
|
query: str = Field(..., min_length=1, max_length=1000)
|
|
459
475
|
limit: int = Field(default=10, ge=1, le=100)
|
|
460
476
|
min_score: float = Field(default=0.3, ge=0.0, le=1.0)
|
|
@@ -471,11 +487,13 @@ class SearchRequest(BaseModel):
|
|
|
471
487
|
|
|
472
488
|
class ProfileSwitch(BaseModel):
|
|
473
489
|
"""Profile switching request."""
|
|
490
|
+
|
|
474
491
|
profile_name: str = Field(..., min_length=1, max_length=50)
|
|
475
492
|
|
|
476
493
|
|
|
477
494
|
class BackupConfigRequest(BaseModel):
|
|
478
495
|
"""Backup configuration update request."""
|
|
496
|
+
|
|
479
497
|
interval_hours: Optional[int] = Field(None, ge=1, le=8760)
|
|
480
498
|
max_backups: Optional[int] = Field(None, ge=1, le=100)
|
|
481
499
|
enabled: Optional[bool] = None
|
|
@@ -271,24 +271,102 @@ async def set_full_config(request: Request):
|
|
|
271
271
|
require_manage(request)
|
|
272
272
|
try:
|
|
273
273
|
body = await request.json()
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
model = body.get("model", "")
|
|
277
|
-
api_key = body.get("api_key", "")
|
|
278
|
-
|
|
279
|
-
if new_mode not in ("a", "b", "c"):
|
|
280
|
-
return JSONResponse({"error": "Invalid mode"}, status_code=400)
|
|
281
|
-
|
|
274
|
+
if not isinstance(body, dict):
|
|
275
|
+
return JSONResponse({"error": "Request body must be a JSON object"}, status_code=400)
|
|
282
276
|
from superlocalmemory.core.config import SLMConfig, EmbeddingConfig, LLMConfig
|
|
283
277
|
from superlocalmemory.storage.models import Mode
|
|
284
278
|
from superlocalmemory.server.routes.helpers import log_mode_change
|
|
279
|
+
|
|
285
280
|
config = SLMConfig.load()
|
|
286
281
|
old_mode = config.mode.value
|
|
287
282
|
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
283
|
+
def _nonblank(name: str) -> str | None:
|
|
284
|
+
value = body.get(name)
|
|
285
|
+
if value is None:
|
|
286
|
+
return None
|
|
287
|
+
if not isinstance(value, str):
|
|
288
|
+
raise ValueError(f"{name} must be a string")
|
|
289
|
+
return value.strip() or None
|
|
290
|
+
|
|
291
|
+
new_mode = (_nonblank("mode") or config.mode.value).lower()
|
|
292
|
+
provider_input = _nonblank("provider")
|
|
293
|
+
model_input = _nonblank("model")
|
|
294
|
+
api_key_input = _nonblank("api_key")
|
|
295
|
+
base_url_input = _nonblank("base_url")
|
|
296
|
+
endpoint_input = _nonblank("endpoint")
|
|
297
|
+
clear_api_key = body.get("clear_api_key") is True
|
|
298
|
+
clear_base_url = body.get("clear_base_url") is True
|
|
299
|
+
|
|
300
|
+
if clear_api_key and api_key_input:
|
|
301
|
+
return JSONResponse(
|
|
302
|
+
{"error": "api_key cannot be replaced and cleared together"},
|
|
303
|
+
status_code=400,
|
|
304
|
+
)
|
|
305
|
+
if clear_base_url and (base_url_input or endpoint_input):
|
|
306
|
+
return JSONResponse(
|
|
307
|
+
{"error": "base_url cannot be replaced and cleared together"},
|
|
308
|
+
status_code=400,
|
|
309
|
+
)
|
|
310
|
+
if base_url_input and endpoint_input and base_url_input != endpoint_input:
|
|
311
|
+
return JSONResponse(
|
|
312
|
+
{"error": "base_url and endpoint must match when both are supplied"},
|
|
313
|
+
status_code=400,
|
|
314
|
+
)
|
|
315
|
+
|
|
316
|
+
if new_mode not in ("a", "b", "c"):
|
|
317
|
+
return JSONResponse({"error": "Invalid mode"}, status_code=400)
|
|
318
|
+
|
|
319
|
+
# Field presence, not form defaults, is the persistence contract. A
|
|
320
|
+
# dashboard panel may update embeddings without sending mode/provider,
|
|
321
|
+
# and password fields deliberately reload blank. In both cases saved
|
|
322
|
+
# LLM state must remain intact unless the user supplies a replacement
|
|
323
|
+
# or explicit clear flag.
|
|
324
|
+
provider = provider_input or config.llm.provider or "none"
|
|
325
|
+
model = model_input or config.llm.model
|
|
326
|
+
_endpoint = "" if clear_base_url else (base_url_input or endpoint_input or "")
|
|
327
|
+
if (
|
|
328
|
+
not _endpoint and provider_input == "ollama"
|
|
329
|
+
and config.llm.provider != "ollama"
|
|
330
|
+
and not clear_base_url
|
|
331
|
+
):
|
|
291
332
|
_endpoint = "http://localhost:11434"
|
|
333
|
+
elif not _endpoint and not clear_base_url:
|
|
334
|
+
_endpoint = config.llm.api_base or ""
|
|
335
|
+
|
|
336
|
+
# Custom endpoints become runtime egress destinations. Apply the
|
|
337
|
+
# same boundary policy as the connection-test route before persisting.
|
|
338
|
+
if base_url_input is not None or endpoint_input is not None:
|
|
339
|
+
client = getattr(request, "client", None)
|
|
340
|
+
endpoint_error = _validate_provider_url(
|
|
341
|
+
_endpoint, getattr(client, "host", "") if client else ""
|
|
342
|
+
)
|
|
343
|
+
if endpoint_error:
|
|
344
|
+
return JSONResponse({"error": endpoint_error}, status_code=400)
|
|
345
|
+
|
|
346
|
+
# A retained cloud credential must never be redirected to a caller's
|
|
347
|
+
# newly supplied provider or endpoint. Compare canonical URL identity
|
|
348
|
+
# after validation so a harmless host-case or trailing-slash rewrite
|
|
349
|
+
# does not unexpectedly clear the key.
|
|
350
|
+
from urllib.parse import urlsplit, urlunsplit
|
|
351
|
+
|
|
352
|
+
def _endpoint_identity(url: str) -> str:
|
|
353
|
+
parsed = urlsplit(url)
|
|
354
|
+
return urlunsplit((
|
|
355
|
+
parsed.scheme.lower(), parsed.netloc.lower(),
|
|
356
|
+
parsed.path.rstrip("/"), parsed.query, "",
|
|
357
|
+
))
|
|
358
|
+
|
|
359
|
+
destination_changed = (
|
|
360
|
+
(provider_input is not None and provider_input != config.llm.provider)
|
|
361
|
+
or (
|
|
362
|
+
(base_url_input is not None or endpoint_input is not None)
|
|
363
|
+
and _endpoint_identity(_endpoint)
|
|
364
|
+
!= _endpoint_identity(config.llm.api_base or "")
|
|
365
|
+
)
|
|
366
|
+
)
|
|
367
|
+
api_key = "" if (clear_api_key or (destination_changed and not api_key_input)) else (
|
|
368
|
+
api_key_input or config.llm.api_key
|
|
369
|
+
)
|
|
292
370
|
|
|
293
371
|
# Mutate only the fields the dashboard sent — all other config blocks
|
|
294
372
|
# (forgetting, injection, retrieval, math, consolidation, scope, …) are
|
|
@@ -351,6 +429,8 @@ async def set_full_config(request: Request):
|
|
|
351
429
|
"embedding_model": config.embedding.model_name,
|
|
352
430
|
"embedding_dimension": config.embedding.dimension,
|
|
353
431
|
}
|
|
432
|
+
except ValueError as exc:
|
|
433
|
+
return JSONResponse({"error": str(exc)}, status_code=400)
|
|
354
434
|
except Exception as e:
|
|
355
435
|
return _internal_error()
|
|
356
436
|
|
|
@@ -830,6 +910,17 @@ async def recall_trace(request: Request):
|
|
|
830
910
|
limit = body.get("limit", 10)
|
|
831
911
|
window = body.get("window", "") or ""
|
|
832
912
|
as_of_raw = (body.get("as_of", "") or "").strip()
|
|
913
|
+
raw_known_as_of = body.get("known_as_of", "")
|
|
914
|
+
raw_valid_at = body.get("valid_at", "")
|
|
915
|
+
if raw_known_as_of is not None and not isinstance(raw_known_as_of, str):
|
|
916
|
+
return JSONResponse({"error": "invalid_known_as_of"}, status_code=400)
|
|
917
|
+
if raw_valid_at is not None and not isinstance(raw_valid_at, str):
|
|
918
|
+
return JSONResponse({"error": "invalid_valid_at"}, status_code=400)
|
|
919
|
+
known_as_of_raw = (raw_known_as_of or "").strip()
|
|
920
|
+
valid_at_raw = (raw_valid_at or "").strip()
|
|
921
|
+
include_unknown = body.get("include_unknown", False)
|
|
922
|
+
if not isinstance(include_unknown, bool):
|
|
923
|
+
return JSONResponse({"error": "invalid_include_unknown"}, status_code=400)
|
|
833
924
|
|
|
834
925
|
# Normalize as_of at HTTP boundary. Invalid → 400.
|
|
835
926
|
_as_of: str | None = None
|
|
@@ -840,6 +931,20 @@ async def recall_trace(request: Request):
|
|
|
840
931
|
return JSONResponse(
|
|
841
932
|
{"error": "invalid_as_of", "raw": as_of_raw}, status_code=400
|
|
842
933
|
)
|
|
934
|
+
def _normalize_named_time(raw: str, error: str) -> str | None | JSONResponse:
|
|
935
|
+
if not raw:
|
|
936
|
+
return None
|
|
937
|
+
from superlocalmemory.retrieval.temporal_utils import normalize_as_of
|
|
938
|
+
normalized = normalize_as_of(raw)
|
|
939
|
+
if normalized is None:
|
|
940
|
+
return JSONResponse({"error": error, "raw": raw}, status_code=400)
|
|
941
|
+
return normalized
|
|
942
|
+
_known_as_of = _normalize_named_time(known_as_of_raw, "invalid_known_as_of")
|
|
943
|
+
if isinstance(_known_as_of, JSONResponse):
|
|
944
|
+
return _known_as_of
|
|
945
|
+
_valid_at = _normalize_named_time(valid_at_raw, "invalid_valid_at")
|
|
946
|
+
if isinstance(_valid_at, JSONResponse):
|
|
947
|
+
return _valid_at
|
|
843
948
|
|
|
844
949
|
# Use daemon engine — already loaded, shares warm page cache.
|
|
845
950
|
# run_in_executor keeps event loop alive so browser doesn't abort.
|
|
@@ -855,6 +960,8 @@ async def recall_trace(request: Request):
|
|
|
855
960
|
lambda: engine.recall(
|
|
856
961
|
query, limit=limit, fast=False,
|
|
857
962
|
window=window or None, as_of=_as_of,
|
|
963
|
+
known_as_of=_known_as_of, valid_at=_valid_at,
|
|
964
|
+
include_unknown=include_unknown,
|
|
858
965
|
),
|
|
859
966
|
)
|
|
860
967
|
elapsed_ms = round((_time.monotonic() - t0) * 1000, 1)
|