superlocalmemory 3.8.3 → 3.8.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 +76 -0
- package/README.md +3 -2
- 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-graph/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-session/SKILL.md +1 -1
- package/plugin-src/skills/slm-status/SKILL.md +1 -1
- package/pyproject.toml +9 -4
- package/src/superlocalmemory/__init__.py +1 -1
- package/src/superlocalmemory/access/rbac.py +68 -76
- package/src/superlocalmemory/cli/commands.py +158 -404
- package/src/superlocalmemory/cli/ingest_cmd.py +11 -1
- package/src/superlocalmemory/cli/main.py +30 -0
- package/src/superlocalmemory/cli/pending_store.py +39 -14
- package/src/superlocalmemory/core/backend_orchestrator.py +93 -0
- package/src/superlocalmemory/core/component_registry.py +4 -2
- package/src/superlocalmemory/core/config.py +78 -0
- package/src/superlocalmemory/core/consolidation_engine.py +79 -73
- package/src/superlocalmemory/core/embeddings.py +33 -6
- package/src/superlocalmemory/core/engine.py +186 -60
- package/src/superlocalmemory/core/engine_ingestion.py +150 -63
- package/src/superlocalmemory/core/fact_consolidator.py +148 -30
- package/src/superlocalmemory/core/graph_pruner.py +436 -39
- package/src/superlocalmemory/core/ingestion_command.py +273 -32
- package/src/superlocalmemory/core/maintenance_scheduler.py +61 -1
- package/src/superlocalmemory/core/mutations.py +32 -10
- package/src/superlocalmemory/core/recall_pipeline.py +111 -74
- package/src/superlocalmemory/core/registry.py +5 -1
- package/src/superlocalmemory/core/remember_admission.py +152 -0
- package/src/superlocalmemory/core/remember_runtime.py +712 -0
- package/src/superlocalmemory/core/remote_mode.py +3 -1
- package/src/superlocalmemory/core/scale_engine.py +41 -18
- package/src/superlocalmemory/core/store_pipeline.py +18 -4
- package/src/superlocalmemory/encoding/entity_resolver.py +18 -11
- package/src/superlocalmemory/graph/cozo_backend.py +5 -5
- package/src/superlocalmemory/hooks/_outcome_common.py +9 -2
- package/src/superlocalmemory/hooks/adapter_base.py +58 -44
- package/src/superlocalmemory/hooks/ide_connector.py +26 -8
- package/src/superlocalmemory/hooks/portable_kit.py +105 -9
- package/src/superlocalmemory/hooks/prewarm_auth.py +21 -2
- package/src/superlocalmemory/infra/auth_middleware.py +3 -1
- package/src/superlocalmemory/infra/cloud_backup.py +26 -27
- package/src/superlocalmemory/infra/event_bus.py +250 -88
- package/src/superlocalmemory/learning/bandit.py +50 -1
- package/src/superlocalmemory/learning/consolidation_cycle.py +33 -16
- package/src/superlocalmemory/learning/entity_compiler.py +148 -132
- package/src/superlocalmemory/learning/memory_merge.py +97 -82
- package/src/superlocalmemory/learning/reward_archive.py +98 -90
- package/src/superlocalmemory/learning/reward_boost.py +40 -30
- package/src/superlocalmemory/learning/source_quality.py +38 -35
- package/src/superlocalmemory/mcp/_daemon_proxy.py +38 -15
- package/src/superlocalmemory/mcp/http_transport.py +335 -3
- package/src/superlocalmemory/mcp/tools_active.py +4 -41
- package/src/superlocalmemory/mcp/tools_core.py +26 -87
- package/src/superlocalmemory/mcp/tools_evolution.py +5 -10
- package/src/superlocalmemory/optimize/proxy/capture.py +196 -8
- package/src/superlocalmemory/retrieval/engine.py +15 -4
- package/src/superlocalmemory/retrieval/entity_channel.py +25 -1
- package/src/superlocalmemory/retrieval/reranker.py +130 -22
- package/src/superlocalmemory/retrieval/spreading_activation.py +20 -12
- package/src/superlocalmemory/retrieval/vector_store.py +84 -69
- package/src/superlocalmemory/server/loopback.py +85 -0
- package/src/superlocalmemory/server/origin.py +9 -4
- package/src/superlocalmemory/server/profile_runtime.py +14 -0
- package/src/superlocalmemory/server/routes/abstraction.py +2 -4
- package/src/superlocalmemory/server/routes/agents.py +3 -5
- package/src/superlocalmemory/server/routes/backup.py +6 -2
- package/src/superlocalmemory/server/routes/behavioral.py +11 -25
- package/src/superlocalmemory/server/routes/brain.py +6 -9
- package/src/superlocalmemory/server/routes/compliance.py +20 -23
- package/src/superlocalmemory/server/routes/config_api.py +83 -0
- package/src/superlocalmemory/server/routes/entity.py +3 -7
- package/src/superlocalmemory/server/routes/evolution.py +3 -5
- package/src/superlocalmemory/server/routes/helpers.py +57 -25
- package/src/superlocalmemory/server/routes/insights.py +2 -4
- package/src/superlocalmemory/server/routes/learning.py +2 -5
- package/src/superlocalmemory/server/routes/lifecycle.py +2 -4
- package/src/superlocalmemory/server/routes/memories.py +119 -98
- package/src/superlocalmemory/server/routes/mesh.py +7 -2
- package/src/superlocalmemory/server/routes/profiles.py +20 -21
- package/src/superlocalmemory/server/routes/rbac.py +0 -1
- package/src/superlocalmemory/server/routes/tiers.py +28 -35
- package/src/superlocalmemory/server/routes/timeline.py +2 -4
- package/src/superlocalmemory/server/routes/v3_api.py +85 -93
- package/src/superlocalmemory/server/unified_daemon.py +400 -140
- package/src/superlocalmemory/server/write_identity.py +22 -4
- package/src/superlocalmemory/storage/admission_codec.py +119 -0
- package/src/superlocalmemory/storage/admission_journal.py +728 -0
- package/src/superlocalmemory/storage/database.py +168 -19
- package/src/superlocalmemory/storage/deferred_writes.py +209 -0
- package/src/superlocalmemory/storage/embedding_migrator.py +19 -0
- package/src/superlocalmemory/storage/memory_write.py +115 -0
- package/src/superlocalmemory/storage/migration_runner.py +44 -0
- package/src/superlocalmemory/storage/migrations/M028_fact_entity_associations.py +113 -78
- package/src/superlocalmemory/storage/migrations/M031_dead_letter_operations.py +80 -0
- package/src/superlocalmemory/storage/migrations/M032_write_coordinator_admission.py +188 -0
- package/src/superlocalmemory/storage/read_connection.py +115 -0
- package/src/superlocalmemory/storage/write_coordinator.py +756 -0
- package/src/superlocalmemory/storage/write_lock.py +88 -0
- package/src/superlocalmemory/ui/index.html +1 -1
- package/src/superlocalmemory/ui/js/auto-settings.js +14 -1
- package/src/superlocalmemory/ui/js/od-settings.js +9 -3
|
@@ -7,17 +7,23 @@ Uses V3 MemoryEngine for store/recall. Falls back to direct DB for list/graph.
|
|
|
7
7
|
"""
|
|
8
8
|
import json
|
|
9
9
|
import logging
|
|
10
|
+
import re
|
|
11
|
+
import uuid
|
|
10
12
|
from typing import Optional
|
|
11
13
|
|
|
12
14
|
from fastapi import APIRouter, HTTPException, Query, Request
|
|
13
15
|
|
|
14
16
|
from .helpers import (
|
|
15
|
-
|
|
16
|
-
|
|
17
|
+
SearchRequest,
|
|
18
|
+
dict_factory,
|
|
19
|
+
get_active_profile,
|
|
20
|
+
get_db_connection,
|
|
21
|
+
get_engine_lazy,
|
|
17
22
|
)
|
|
18
23
|
|
|
19
24
|
logger = logging.getLogger("superlocalmemory.routes.memories")
|
|
20
25
|
router = APIRouter()
|
|
26
|
+
_IDEMPOTENCY_KEY = re.compile(r"^[A-Za-z0-9._:-]{1,256}$")
|
|
21
27
|
|
|
22
28
|
# v3.8.3: GENEROUS latency budget for recall. SLM's value is quality recall
|
|
23
29
|
# under heavy multi-agent load, so semantic recall is given ample time to
|
|
@@ -56,6 +62,58 @@ def _get_engine(request: Request):
|
|
|
56
62
|
return get_engine_lazy(request.app.state)
|
|
57
63
|
|
|
58
64
|
|
|
65
|
+
def _canonical_mutation_runtime(request: Request):
|
|
66
|
+
"""Return the daemon-owned mutation boundary or fail before queueing work."""
|
|
67
|
+
runtime = getattr(request.app.state, "canonical_remember_runtime", None)
|
|
68
|
+
if runtime is None or not runtime.ready:
|
|
69
|
+
raise HTTPException(503, detail="canonical mutation writer is not ready; retry shortly")
|
|
70
|
+
return runtime
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _mutation_idempotency_key(request: Request) -> str:
|
|
74
|
+
"""Accept a client retry key without making one mandatory for the dashboard."""
|
|
75
|
+
key = request.headers.get("X-Idempotency-Key", "").strip() or str(uuid.uuid4())
|
|
76
|
+
if not _IDEMPOTENCY_KEY.fullmatch(key):
|
|
77
|
+
raise HTTPException(
|
|
78
|
+
422,
|
|
79
|
+
detail="X-Idempotency-Key must contain 1-256 safe characters",
|
|
80
|
+
)
|
|
81
|
+
return key
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _canonical_mutation_error(exc: Exception, detail: str) -> HTTPException:
|
|
85
|
+
"""Map typed mutation failures without leaking SQLite or filesystem detail."""
|
|
86
|
+
from superlocalmemory.core.remember_runtime import (
|
|
87
|
+
CanonicalMutationConflict,
|
|
88
|
+
CanonicalRememberUnavailable,
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
if isinstance(exc, CanonicalMutationConflict):
|
|
92
|
+
return HTTPException(409, detail=str(exc))
|
|
93
|
+
if isinstance(exc, CanonicalRememberUnavailable):
|
|
94
|
+
return HTTPException(
|
|
95
|
+
503,
|
|
96
|
+
detail="canonical mutation writer is temporarily unavailable",
|
|
97
|
+
)
|
|
98
|
+
return _internal_error(detail)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _mutation_runtime_or_missing_fact(
|
|
102
|
+
request: Request, engine, profile_id: str, fact_id: str,
|
|
103
|
+
):
|
|
104
|
+
"""Retain the public 404 for a missing fact without creating a local writer."""
|
|
105
|
+
runtime = getattr(request.app.state, "canonical_remember_runtime", None)
|
|
106
|
+
if runtime is not None and runtime.ready:
|
|
107
|
+
return runtime
|
|
108
|
+
rows = engine._db.execute(
|
|
109
|
+
"SELECT 1 FROM atomic_facts WHERE fact_id = ? AND profile_id = ?",
|
|
110
|
+
(fact_id, profile_id),
|
|
111
|
+
)
|
|
112
|
+
if not rows:
|
|
113
|
+
raise HTTPException(status_code=404, detail="Memory not found")
|
|
114
|
+
raise HTTPException(503, detail="canonical mutation writer is not ready; retry shortly")
|
|
115
|
+
|
|
116
|
+
|
|
59
117
|
def _authorize_memory_mutation(
|
|
60
118
|
request: Request,
|
|
61
119
|
operation: str,
|
|
@@ -725,13 +783,21 @@ async def get_clusters(request: Request):
|
|
|
725
783
|
unclustered = 0
|
|
726
784
|
|
|
727
785
|
conn.close()
|
|
728
|
-
return {
|
|
786
|
+
return {
|
|
787
|
+
"clusters": clusters,
|
|
788
|
+
"total_clusters": len(clusters),
|
|
789
|
+
"unclustered_count": unclustered,
|
|
790
|
+
}
|
|
729
791
|
except Exception:
|
|
730
792
|
raise _internal_error("Cluster error")
|
|
731
793
|
|
|
732
794
|
|
|
733
795
|
@router.get("/api/clusters/{cluster_id}")
|
|
734
|
-
async def get_cluster_detail(
|
|
796
|
+
async def get_cluster_detail(
|
|
797
|
+
request: Request,
|
|
798
|
+
cluster_id: str,
|
|
799
|
+
limit: int = Query(50, ge=1, le=200),
|
|
800
|
+
):
|
|
735
801
|
"""Get detailed view of a specific cluster (scene)."""
|
|
736
802
|
try:
|
|
737
803
|
conn = get_db_connection()
|
|
@@ -973,14 +1039,18 @@ async def delete_memory(request: Request, fact_id: str):
|
|
|
973
1039
|
fact_id,
|
|
974
1040
|
trusted_actor_id=hook_context["agent_id"],
|
|
975
1041
|
source_agent_id="dashboard",
|
|
1042
|
+
canonical_runtime=_mutation_runtime_or_missing_fact(
|
|
1043
|
+
request, engine, _active_profile, fact_id,
|
|
1044
|
+
),
|
|
1045
|
+
idempotency_key=_mutation_idempotency_key(request),
|
|
976
1046
|
)
|
|
977
1047
|
if not result.get("ok"):
|
|
978
1048
|
raise HTTPException(status_code=404, detail="Memory not found")
|
|
979
1049
|
return {"success": True, "deleted": fact_id}
|
|
980
1050
|
except HTTPException:
|
|
981
1051
|
raise
|
|
982
|
-
except Exception:
|
|
983
|
-
raise
|
|
1052
|
+
except Exception as exc:
|
|
1053
|
+
raise _canonical_mutation_error(exc, "Delete error")
|
|
984
1054
|
|
|
985
1055
|
|
|
986
1056
|
@router.post("/api/memories/{fact_id}/forget")
|
|
@@ -992,56 +1062,25 @@ async def forget_memory(request: Request, fact_id: str):
|
|
|
992
1062
|
The fact's payload is ALSO copied into ``memory_archive`` so a
|
|
993
1063
|
future ``slm restore`` can bring it back.
|
|
994
1064
|
"""
|
|
995
|
-
import json as _json
|
|
996
1065
|
engine, active_profile, hook_context = _authorize_memory_mutation(
|
|
997
1066
|
request, "delete", fact_id,
|
|
1067
|
+
run_pre_hook=False,
|
|
998
1068
|
)
|
|
999
1069
|
try:
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
" canonical_entities_json, embedding, created_at "
|
|
1006
|
-
"FROM atomic_facts WHERE fact_id = ? AND profile_id = ?",
|
|
1007
|
-
(fact_id, active_profile),
|
|
1070
|
+
engine._hooks.run_pre("delete", hook_context)
|
|
1071
|
+
result = _canonical_mutation_runtime(request).archive_fact(
|
|
1072
|
+
active_profile,
|
|
1073
|
+
fact_id,
|
|
1074
|
+
idempotency_key=_mutation_idempotency_key(request),
|
|
1008
1075
|
)
|
|
1009
|
-
|
|
1010
|
-
if not row:
|
|
1011
|
-
conn.close()
|
|
1076
|
+
if not result.get("ok"):
|
|
1012
1077
|
raise HTTPException(status_code=404, detail="Memory not found")
|
|
1013
|
-
# Archive copy — payload_json small enough for the canonical row.
|
|
1014
|
-
payload = {
|
|
1015
|
-
"fact_id": row["fact_id"],
|
|
1016
|
-
"content": row["content"],
|
|
1017
|
-
"canonical_entities_json": row.get("canonical_entities_json"),
|
|
1018
|
-
"importance": row.get("importance"),
|
|
1019
|
-
"confidence": row.get("confidence"),
|
|
1020
|
-
"created_at": row.get("created_at"),
|
|
1021
|
-
}
|
|
1022
|
-
from datetime import datetime, timezone
|
|
1023
|
-
archived_at = datetime.now(timezone.utc).isoformat()
|
|
1024
|
-
import uuid as _uuid
|
|
1025
|
-
cursor.execute(
|
|
1026
|
-
"INSERT INTO memory_archive "
|
|
1027
|
-
"(archive_id, fact_id, profile_id, payload_json, archived_at, reason) "
|
|
1028
|
-
"VALUES (?, ?, ?, ?, ?, ?)",
|
|
1029
|
-
(str(_uuid.uuid4()), fact_id, active_profile,
|
|
1030
|
-
_json.dumps(payload), archived_at, "user_forget_dashboard"),
|
|
1031
|
-
)
|
|
1032
|
-
cursor.execute(
|
|
1033
|
-
"UPDATE atomic_facts SET archive_status = 'archived' "
|
|
1034
|
-
"WHERE fact_id = ?",
|
|
1035
|
-
(fact_id,),
|
|
1036
|
-
)
|
|
1037
|
-
conn.commit()
|
|
1038
|
-
conn.close()
|
|
1039
1078
|
engine._hooks.run_post("delete", hook_context)
|
|
1040
|
-
return {"success": True, "fact_id": fact_id, "archived_at": archived_at}
|
|
1079
|
+
return {"success": True, "fact_id": fact_id, "archived_at": result["archived_at"]}
|
|
1041
1080
|
except HTTPException:
|
|
1042
1081
|
raise
|
|
1043
|
-
except Exception:
|
|
1044
|
-
raise
|
|
1082
|
+
except Exception as exc:
|
|
1083
|
+
raise _canonical_mutation_error(exc, "Forget error")
|
|
1045
1084
|
|
|
1046
1085
|
|
|
1047
1086
|
@router.post("/api/memories/{fact_id}/merge")
|
|
@@ -1056,6 +1095,7 @@ async def merge_memory(request: Request, fact_id: str):
|
|
|
1056
1095
|
"""
|
|
1057
1096
|
engine, active_profile, hook_context = _authorize_memory_mutation(
|
|
1058
1097
|
request, "delete", fact_id,
|
|
1098
|
+
run_pre_hook=False,
|
|
1059
1099
|
)
|
|
1060
1100
|
try:
|
|
1061
1101
|
body = await request.json()
|
|
@@ -1067,50 +1107,26 @@ async def merge_memory(request: Request, fact_id: str):
|
|
|
1067
1107
|
raise HTTPException(400, "'into' exceeds 200-char limit")
|
|
1068
1108
|
if kept == fact_id:
|
|
1069
1109
|
raise HTTPException(400, "Cannot merge a fact into itself")
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
"WHERE fact_id IN (?, ?) AND profile_id = ?",
|
|
1077
|
-
(fact_id, kept, active_profile),
|
|
1078
|
-
)
|
|
1079
|
-
found = {r["fact_id"] for r in cursor.fetchall()}
|
|
1080
|
-
if fact_id not in found or kept not in found:
|
|
1081
|
-
conn.close()
|
|
1082
|
-
raise HTTPException(
|
|
1083
|
-
404,
|
|
1084
|
-
"Both fact_ids must exist in the active profile",
|
|
1085
|
-
)
|
|
1086
|
-
from datetime import datetime, timezone
|
|
1087
|
-
merged_at = datetime.now(timezone.utc).isoformat()
|
|
1088
|
-
cursor.execute(
|
|
1089
|
-
"INSERT INTO memory_merge_log "
|
|
1090
|
-
"(kept_fact_id, merged_fact_id, profile_id, reason, merged_at) "
|
|
1091
|
-
"VALUES (?, ?, ?, ?, ?)",
|
|
1092
|
-
(kept, fact_id, active_profile,
|
|
1093
|
-
"user_merge_dashboard", merged_at),
|
|
1094
|
-
)
|
|
1095
|
-
cursor.execute(
|
|
1096
|
-
"UPDATE atomic_facts "
|
|
1097
|
-
"SET merged_into = ?, archive_status = 'archived' "
|
|
1098
|
-
"WHERE fact_id = ?",
|
|
1099
|
-
(kept, fact_id),
|
|
1110
|
+
engine._hooks.run_pre("delete", hook_context)
|
|
1111
|
+
result = _canonical_mutation_runtime(request).merge_fact(
|
|
1112
|
+
active_profile,
|
|
1113
|
+
fact_id,
|
|
1114
|
+
kept,
|
|
1115
|
+
idempotency_key=_mutation_idempotency_key(request),
|
|
1100
1116
|
)
|
|
1101
|
-
|
|
1102
|
-
|
|
1117
|
+
if not result.get("ok"):
|
|
1118
|
+
raise HTTPException(404, "Both fact_ids must exist in the active profile")
|
|
1103
1119
|
engine._hooks.run_post("delete", hook_context)
|
|
1104
1120
|
return {
|
|
1105
1121
|
"success": True,
|
|
1106
1122
|
"merged": fact_id,
|
|
1107
1123
|
"into": kept,
|
|
1108
|
-
"merged_at": merged_at,
|
|
1124
|
+
"merged_at": result["merged_at"],
|
|
1109
1125
|
}
|
|
1110
1126
|
except HTTPException:
|
|
1111
1127
|
raise
|
|
1112
|
-
except Exception:
|
|
1113
|
-
raise
|
|
1128
|
+
except Exception as exc:
|
|
1129
|
+
raise _canonical_mutation_error(exc, "Merge error")
|
|
1114
1130
|
|
|
1115
1131
|
|
|
1116
1132
|
@router.patch("/api/memories/{fact_id}")
|
|
@@ -1136,14 +1152,16 @@ async def edit_memory(request: Request, fact_id: str):
|
|
|
1136
1152
|
new_content,
|
|
1137
1153
|
trusted_actor_id=hook_context["agent_id"],
|
|
1138
1154
|
source_agent_id="dashboard",
|
|
1155
|
+
canonical_runtime=_canonical_mutation_runtime(request),
|
|
1156
|
+
idempotency_key=_mutation_idempotency_key(request),
|
|
1139
1157
|
)
|
|
1140
1158
|
if not result.get("ok"):
|
|
1141
1159
|
raise HTTPException(status_code=404, detail="Memory not found")
|
|
1142
1160
|
return {"success": True, "fact_id": fact_id, "content": new_content}
|
|
1143
1161
|
except HTTPException:
|
|
1144
1162
|
raise
|
|
1145
|
-
except Exception:
|
|
1146
|
-
raise
|
|
1163
|
+
except Exception as exc:
|
|
1164
|
+
raise _canonical_mutation_error(exc, "Edit error")
|
|
1147
1165
|
|
|
1148
1166
|
|
|
1149
1167
|
_VALID_SCOPES = ("personal", "shared", "global")
|
|
@@ -1159,7 +1177,6 @@ async def set_memory_scope(request: Request, fact_id: str):
|
|
|
1159
1177
|
belong to the active profile (a caller cannot re-scope another profile's
|
|
1160
1178
|
fact). This is the write side of multi-scope sharing from the dashboard.
|
|
1161
1179
|
"""
|
|
1162
|
-
import json as _json
|
|
1163
1180
|
try:
|
|
1164
1181
|
body = await request.json()
|
|
1165
1182
|
scope = (body.get("scope") or "").strip().lower()
|
|
@@ -1180,26 +1197,30 @@ async def set_memory_scope(request: Request, fact_id: str):
|
|
|
1180
1197
|
if scope != "shared":
|
|
1181
1198
|
shared_list = []
|
|
1182
1199
|
|
|
1183
|
-
engine, active_profile,
|
|
1200
|
+
engine, active_profile, hook_context = _authorize_memory_mutation(
|
|
1184
1201
|
request, "update", fact_id, run_pre_hook=False,
|
|
1185
1202
|
)
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1203
|
+
if scope in {"shared", "global"}:
|
|
1204
|
+
from superlocalmemory.access.rbac import Permission
|
|
1205
|
+
from superlocalmemory.server.rbac_enforce import require_permission
|
|
1206
|
+
|
|
1207
|
+
require_permission(request, Permission.SHARE, profile=active_profile)
|
|
1208
|
+
engine._hooks.run_pre("update", hook_context)
|
|
1209
|
+
result = _canonical_mutation_runtime(request).set_fact_scope(
|
|
1210
|
+
active_profile,
|
|
1211
|
+
fact_id,
|
|
1212
|
+
scope,
|
|
1213
|
+
shared_list,
|
|
1214
|
+
idempotency_key=_mutation_idempotency_key(request),
|
|
1190
1215
|
)
|
|
1191
|
-
if not
|
|
1216
|
+
if not result.get("ok"):
|
|
1192
1217
|
raise HTTPException(404, detail="Memory not found in this profile")
|
|
1193
|
-
|
|
1194
|
-
engine._db.update_fact(fact_id, {
|
|
1195
|
-
"scope": scope,
|
|
1196
|
-
"shared_with": _json.dumps(shared_list),
|
|
1197
|
-
}, profile_id=active_profile)
|
|
1218
|
+
engine._hooks.run_post("update", hook_context)
|
|
1198
1219
|
return {
|
|
1199
1220
|
"success": True, "fact_id": fact_id, "scope": scope,
|
|
1200
1221
|
"shared_with": shared_list, "active_profile": active_profile,
|
|
1201
1222
|
}
|
|
1202
1223
|
except HTTPException:
|
|
1203
1224
|
raise
|
|
1204
|
-
except Exception:
|
|
1205
|
-
raise
|
|
1225
|
+
except Exception as exc:
|
|
1226
|
+
raise _canonical_mutation_error(exc, "Scope update error")
|
|
@@ -90,7 +90,9 @@ def _get_broker(request: Request):
|
|
|
90
90
|
secret = getattr(broker, "_shared_secret", None)
|
|
91
91
|
if secret:
|
|
92
92
|
client_host = request.client.host if request.client else ""
|
|
93
|
-
|
|
93
|
+
from superlocalmemory.server.loopback import is_loopback as _is_loopback_host
|
|
94
|
+
|
|
95
|
+
if not _is_loopback_host(client_host):
|
|
94
96
|
import hmac
|
|
95
97
|
|
|
96
98
|
from superlocalmemory.core.security_primitives import verify_install_token
|
|
@@ -261,7 +263,10 @@ def _mesh_read_model(records: list[dict]) -> tuple[list[dict], list[dict]]:
|
|
|
261
263
|
"stale_at": stale_at.isoformat(),
|
|
262
264
|
"expires_at": expires_at.isoformat(),
|
|
263
265
|
}
|
|
264
|
-
|
|
266
|
+
# display-only — not an auth decision; is_loopback() handles IPv4-mapped forms
|
|
267
|
+
# such as "::ffff:127.0.0.1" that _LOOPBACK_HOSTS misses.
|
|
268
|
+
from superlocalmemory.server.loopback import is_loopback as _is_loopback
|
|
269
|
+
if _is_loopback(str(record.get("host") or "").lower()):
|
|
265
270
|
local.append(normalized)
|
|
266
271
|
else:
|
|
267
272
|
remote.append(normalized)
|
|
@@ -25,6 +25,7 @@ from .helpers import (
|
|
|
25
25
|
delete_profile_from_db,
|
|
26
26
|
_load_profiles_json, _save_profiles_json,
|
|
27
27
|
)
|
|
28
|
+
from superlocalmemory.storage.memory_write import memory_write
|
|
28
29
|
from superlocalmemory.server.profile_runtime import (
|
|
29
30
|
commit_daemon_profile_switch,
|
|
30
31
|
get_profile_runtime,
|
|
@@ -250,28 +251,26 @@ async def delete_profile(name: str, request: Request):
|
|
|
250
251
|
# profile being deleted (not the active one).
|
|
251
252
|
from superlocalmemory.server.rbac_enforce import require_manage as _rbac_manage
|
|
252
253
|
_rbac_manage(request, profile=name)
|
|
253
|
-
# Move data to default before deleting (bypasses CASCADE)
|
|
254
|
-
|
|
255
|
-
cursor = conn.cursor()
|
|
254
|
+
# Move data to default before deleting (bypasses CASCADE).
|
|
255
|
+
# memory_write: write lock + busy_timeout — two UPDATEs are atomic.
|
|
256
256
|
moved = 0
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
conn.close()
|
|
257
|
+
with memory_write(DB_PATH) as conn:
|
|
258
|
+
try:
|
|
259
|
+
cur = conn.execute(
|
|
260
|
+
"UPDATE atomic_facts SET profile_id = 'default' WHERE profile_id = ?",
|
|
261
|
+
(name,),
|
|
262
|
+
)
|
|
263
|
+
moved = cur.rowcount
|
|
264
|
+
except Exception:
|
|
265
|
+
pass
|
|
266
|
+
try:
|
|
267
|
+
cur2 = conn.execute(
|
|
268
|
+
"UPDATE memories SET profile_id = 'default' WHERE profile_id = ?",
|
|
269
|
+
(name,),
|
|
270
|
+
)
|
|
271
|
+
moved += cur2.rowcount
|
|
272
|
+
except Exception:
|
|
273
|
+
pass
|
|
275
274
|
|
|
276
275
|
# Delete from BOTH stores
|
|
277
276
|
delete_profile_from_db(name)
|
|
@@ -158,7 +158,6 @@ def _require_authority_over_user(request: Request, target_user_id: str) -> None:
|
|
|
158
158
|
shares at least one workspace on which the admin holds MANAGE.
|
|
159
159
|
"""
|
|
160
160
|
from superlocalmemory.access.rbac import Permission
|
|
161
|
-
from superlocalmemory.server.rbac_enforce import resolve_principal
|
|
162
161
|
|
|
163
162
|
principal = resolve_principal(request)
|
|
164
163
|
if principal["kind"] == "owner":
|
|
@@ -19,6 +19,7 @@ from fastapi import APIRouter, HTTPException, Request
|
|
|
19
19
|
from pydantic import BaseModel, Field
|
|
20
20
|
|
|
21
21
|
from superlocalmemory.server.route_mutations import authorize_route_mutation
|
|
22
|
+
from superlocalmemory.storage.memory_write import memory_read, memory_write
|
|
22
23
|
|
|
23
24
|
from .helpers import DB_PATH, get_active_profile
|
|
24
25
|
|
|
@@ -36,15 +37,9 @@ class PinRequest(BaseModel):
|
|
|
36
37
|
|
|
37
38
|
@contextmanager
|
|
38
39
|
def _db():
|
|
39
|
-
"""
|
|
40
|
-
|
|
41
|
-
conn.execute("PRAGMA journal_mode=WAL")
|
|
42
|
-
conn.execute("PRAGMA busy_timeout=5000")
|
|
43
|
-
conn.row_factory = sqlite3.Row
|
|
44
|
-
try:
|
|
40
|
+
"""Yield a short-lived query-only snapshot for tier dashboard reads."""
|
|
41
|
+
with memory_read(DB_PATH) as conn:
|
|
45
42
|
yield conn
|
|
46
|
-
finally:
|
|
47
|
-
conn.close()
|
|
48
43
|
|
|
49
44
|
|
|
50
45
|
def _validate_profile(profile_id: str) -> str:
|
|
@@ -154,21 +149,20 @@ async def pin_fact_route(
|
|
|
154
149
|
fact_id=body.fact_id,
|
|
155
150
|
)
|
|
156
151
|
|
|
157
|
-
|
|
158
|
-
|
|
152
|
+
# memory_write: process write lock + busy_timeout.
|
|
153
|
+
# SELECT + INSERT + lifecycle update are atomic inside the same connection.
|
|
154
|
+
try:
|
|
155
|
+
with memory_write(DB_PATH) as conn:
|
|
159
156
|
# Verify fact exists in this profile
|
|
160
|
-
|
|
161
|
-
c.execute(
|
|
157
|
+
if conn.execute(
|
|
162
158
|
"SELECT fact_id FROM atomic_facts "
|
|
163
159
|
"WHERE fact_id = ? AND profile_id = ?",
|
|
164
160
|
(body.fact_id, profile_id),
|
|
165
|
-
)
|
|
166
|
-
if c.fetchone() is None:
|
|
161
|
+
).fetchone() is None:
|
|
167
162
|
raise HTTPException(
|
|
168
163
|
status_code=404,
|
|
169
164
|
detail=f"Fact {body.fact_id[:8]}... not found",
|
|
170
165
|
)
|
|
171
|
-
|
|
172
166
|
now = datetime.now(UTC).isoformat()
|
|
173
167
|
conn.execute(
|
|
174
168
|
"INSERT OR REPLACE INTO pinned_facts "
|
|
@@ -180,16 +174,15 @@ async def pin_fact_route(
|
|
|
180
174
|
set_fact_lifecycle_zone(
|
|
181
175
|
conn, [body.fact_id], "active", profile_id=profile_id,
|
|
182
176
|
)
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
) from None
|
|
177
|
+
authorization.complete()
|
|
178
|
+
return {"success": True, "message": f"Fact {body.fact_id[:8]}... pinned"}
|
|
179
|
+
except HTTPException:
|
|
180
|
+
raise
|
|
181
|
+
except Exception as exc:
|
|
182
|
+
logger.error("pin_fact failed: %s", exc, exc_info=True)
|
|
183
|
+
raise HTTPException(
|
|
184
|
+
status_code=500, detail="Failed to pin fact",
|
|
185
|
+
) from None
|
|
193
186
|
|
|
194
187
|
|
|
195
188
|
@router.post("/api/tiers/unpin")
|
|
@@ -213,17 +206,17 @@ async def unpin_fact_route(
|
|
|
213
206
|
fact_id=body.fact_id,
|
|
214
207
|
)
|
|
215
208
|
|
|
216
|
-
|
|
217
|
-
|
|
209
|
+
# memory_write: process write lock + busy_timeout.
|
|
210
|
+
try:
|
|
211
|
+
with memory_write(DB_PATH) as conn:
|
|
218
212
|
conn.execute(
|
|
219
213
|
"DELETE FROM pinned_facts WHERE fact_id = ? AND profile_id = ?",
|
|
220
214
|
(body.fact_id, profile_id),
|
|
221
215
|
)
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
) from None
|
|
216
|
+
authorization.complete()
|
|
217
|
+
return {"success": True, "unpinned": True}
|
|
218
|
+
except Exception as exc:
|
|
219
|
+
logger.error("unpin_fact failed: %s", exc, exc_info=True)
|
|
220
|
+
raise HTTPException(
|
|
221
|
+
status_code=500, detail="Failed to unpin fact",
|
|
222
|
+
) from None
|
|
@@ -13,11 +13,10 @@ from __future__ import annotations
|
|
|
13
13
|
import logging
|
|
14
14
|
import re
|
|
15
15
|
import sqlite3
|
|
16
|
-
from typing import Any
|
|
17
16
|
|
|
18
17
|
from fastapi import APIRouter, Query
|
|
19
18
|
from fastapi.responses import JSONResponse
|
|
20
|
-
from superlocalmemory.server.routes.helpers import DB_PATH, get_active_profile
|
|
19
|
+
from superlocalmemory.server.routes.helpers import DB_PATH, get_active_profile, get_read_connection
|
|
21
20
|
|
|
22
21
|
logger = logging.getLogger(__name__)
|
|
23
22
|
|
|
@@ -65,8 +64,7 @@ async def get_timeline(
|
|
|
65
64
|
if not DB_PATH.exists():
|
|
66
65
|
return {"range": range, "group_by": group_by, "count": 0, "events": [], "total_available": 0, "offset": 0}
|
|
67
66
|
|
|
68
|
-
conn =
|
|
69
|
-
conn.row_factory = sqlite3.Row
|
|
67
|
+
conn = get_read_connection(DB_PATH)
|
|
70
68
|
|
|
71
69
|
try:
|
|
72
70
|
start_date = conn.execute("SELECT datetime('now', ?)", (modifier,)).fetchone()[0]
|