superlocalmemory 3.8.2 → 3.8.5
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 +57 -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 +1 -1
- package/src/superlocalmemory/__init__.py +1 -1
- package/src/superlocalmemory/access/rbac.py +68 -76
- package/src/superlocalmemory/cli/commands.py +19 -0
- 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/config.py +78 -0
- package/src/superlocalmemory/core/consolidation_engine.py +79 -73
- package/src/superlocalmemory/core/engine.py +92 -11
- 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 +160 -31
- package/src/superlocalmemory/core/maintenance_scheduler.py +61 -1
- package/src/superlocalmemory/core/recall_pipeline.py +3 -0
- package/src/superlocalmemory/core/registry.py +5 -1
- 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/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/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/mcp/http_transport.py +335 -3
- package/src/superlocalmemory/retrieval/engine.py +7 -1
- package/src/superlocalmemory/retrieval/entity_channel.py +25 -1
- package/src/superlocalmemory/retrieval/reranker.py +98 -15
- package/src/superlocalmemory/retrieval/spreading_activation.py +20 -12
- package/src/superlocalmemory/retrieval/vector_store.py +84 -69
- package/src/superlocalmemory/server/loopback.py +91 -0
- package/src/superlocalmemory/server/origin.py +9 -4
- package/src/superlocalmemory/server/routes/backup.py +6 -2
- package/src/superlocalmemory/server/routes/behavioral.py +6 -12
- package/src/superlocalmemory/server/routes/compliance.py +20 -23
- package/src/superlocalmemory/server/routes/config_api.py +83 -0
- package/src/superlocalmemory/server/routes/helpers.py +24 -13
- package/src/superlocalmemory/server/routes/memories.py +139 -91
- 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 +42 -30
- package/src/superlocalmemory/server/routes/v3_api.py +67 -77
- package/src/superlocalmemory/server/unified_daemon.py +283 -39
- package/src/superlocalmemory/server/write_identity.py +22 -4
- package/src/superlocalmemory/storage/database.py +109 -19
- package/src/superlocalmemory/storage/deferred_writes.py +153 -0
- package/src/superlocalmemory/storage/embedding_migrator.py +19 -0
- package/src/superlocalmemory/storage/memory_write.py +119 -0
- package/src/superlocalmemory/storage/migration_runner.py +7 -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/write_lock.py +88 -0
- package/src/superlocalmemory/ui/js/core.js +6 -1
|
@@ -15,10 +15,30 @@ from .helpers import (
|
|
|
15
15
|
get_db_connection, dict_factory, get_active_profile, get_engine_lazy,
|
|
16
16
|
SearchRequest, DB_PATH, MEMORY_DIR,
|
|
17
17
|
)
|
|
18
|
+
from superlocalmemory.storage.memory_write import memory_write
|
|
18
19
|
|
|
19
20
|
logger = logging.getLogger("superlocalmemory.routes.memories")
|
|
20
21
|
router = APIRouter()
|
|
21
22
|
|
|
23
|
+
# v3.8.3: GENEROUS latency budget for recall. SLM's value is quality recall
|
|
24
|
+
# under heavy multi-agent load, so semantic recall is given ample time to
|
|
25
|
+
# finish — the keyword fallback is a LAST-RESORT safety net for a genuine hang
|
|
26
|
+
# (e.g. a wedged embedder), NOT an aggressive speed cutoff. Only if recall
|
|
27
|
+
# exceeds this budget do we serve the fast keyword search so the caller ALWAYS
|
|
28
|
+
# gets a result instead of hanging forever. Tune with SLM_SEARCH_RECALL_TIMEOUT_S.
|
|
29
|
+
# The dashboard's fetch timeout is set ABOVE this so the browser waits for the
|
|
30
|
+
# quality result rather than aborting early.
|
|
31
|
+
_DEFAULT_RECALL_BUDGET_S = 25.0
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _search_recall_timeout_s() -> float:
|
|
35
|
+
import os
|
|
36
|
+
try:
|
|
37
|
+
v = float(os.environ.get("SLM_SEARCH_RECALL_TIMEOUT_S", ""))
|
|
38
|
+
return v if v > 0 else _DEFAULT_RECALL_BUDGET_S
|
|
39
|
+
except (TypeError, ValueError):
|
|
40
|
+
return _DEFAULT_RECALL_BUDGET_S
|
|
41
|
+
|
|
22
42
|
|
|
23
43
|
def _internal_error(detail: str = "Internal server error") -> HTTPException:
|
|
24
44
|
"""SEC-H-02: log the full traceback server-side; return a generic message.
|
|
@@ -526,35 +546,65 @@ async def search_memories(request: Request, body: SearchRequest):
|
|
|
526
546
|
_window = getattr(body, "window", None) or ""
|
|
527
547
|
if not _window and getattr(body, "date_from", None) and getattr(body, "date_to", None):
|
|
528
548
|
_window = f"{body.date_from}..{body.date_to}"
|
|
529
|
-
|
|
549
|
+
# v3.8.3: bound the synchronous recall. Under a concurrent
|
|
550
|
+
# maintenance pass or a busy embedder it can run tens of seconds
|
|
551
|
+
# and the browser aborts the fetch. If it exceeds the budget we
|
|
552
|
+
# fall through to the fast keyword search below, so the dashboard
|
|
553
|
+
# ALWAYS returns instead of failing with an abort.
|
|
554
|
+
_recall_future = loop.run_in_executor(
|
|
530
555
|
None,
|
|
531
556
|
lambda: engine.recall(
|
|
532
557
|
body.query, limit=body.limit, fast=True,
|
|
533
558
|
window=_window or None,
|
|
534
559
|
),
|
|
535
560
|
)
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
"
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
561
|
+
# A run_in_executor thread cannot be cancelled, and wait_for() on it
|
|
562
|
+
# blocks until the thread finishes (defeating the timeout). So poll
|
|
563
|
+
# the future without blocking the event loop and give up at the
|
|
564
|
+
# deadline — the orphaned recall completes in the background and its
|
|
565
|
+
# result is discarded. This is what bounds dashboard-search latency.
|
|
566
|
+
_budget = _search_recall_timeout_s()
|
|
567
|
+
_deadline = loop.time() + _budget
|
|
568
|
+
while not _recall_future.done() and loop.time() < _deadline:
|
|
569
|
+
await asyncio.sleep(0.05)
|
|
570
|
+
if _recall_future.done():
|
|
571
|
+
response = _recall_future.result()
|
|
572
|
+
else:
|
|
573
|
+
# Ensure the orphaned future's eventual result/exception is
|
|
574
|
+
# retrieved so asyncio doesn't log "never retrieved".
|
|
575
|
+
_recall_future.add_done_callback(
|
|
576
|
+
lambda f: (f.cancelled() or f.exception())
|
|
577
|
+
)
|
|
578
|
+
logger.warning(
|
|
579
|
+
"search_memories: semantic recall exceeded %.0fs budget for "
|
|
580
|
+
"%r — serving keyword fallback",
|
|
581
|
+
_budget, (body.query or "")[:80],
|
|
582
|
+
)
|
|
583
|
+
response = None
|
|
584
|
+
if response is not None:
|
|
585
|
+
elapsed_ms = round((_time.monotonic() - t0) * 1000, 1)
|
|
586
|
+
from superlocalmemory.server.recall_serializer import (
|
|
587
|
+
recall_response_metadata,
|
|
588
|
+
serialize_recall_response,
|
|
589
|
+
)
|
|
590
|
+
results, no_confident_match = serialize_recall_response(
|
|
591
|
+
response,
|
|
592
|
+
limit=body.limit,
|
|
593
|
+
per_fact_max=300,
|
|
594
|
+
total_max=max(300, body.limit * 300),
|
|
595
|
+
)
|
|
596
|
+
return {
|
|
597
|
+
"query": body.query,
|
|
598
|
+
"results": results,
|
|
599
|
+
"total": len(results),
|
|
600
|
+
"query_type": getattr(response, "query_type", "semantic"),
|
|
601
|
+
"retrieval_time_ms": elapsed_ms,
|
|
602
|
+
"no_confident_match": no_confident_match,
|
|
603
|
+
**recall_response_metadata(response),
|
|
604
|
+
}
|
|
605
|
+
# recall timed out — fall through to the fast keyword search.
|
|
606
|
+
|
|
607
|
+
# Fallback: direct DB text search (engine not ready OR recall over budget)
|
|
558
608
|
conn = get_db_connection()
|
|
559
609
|
conn.row_factory = dict_factory
|
|
560
610
|
cursor = conn.cursor()
|
|
@@ -948,45 +998,43 @@ async def forget_memory(request: Request, fact_id: str):
|
|
|
948
998
|
request, "delete", fact_id,
|
|
949
999
|
)
|
|
950
1000
|
try:
|
|
951
|
-
conn = get_db_connection()
|
|
952
|
-
conn.row_factory = dict_factory
|
|
953
|
-
cursor = conn.cursor()
|
|
954
|
-
cursor.execute(
|
|
955
|
-
"SELECT fact_id, content, importance, confidence, "
|
|
956
|
-
" canonical_entities_json, embedding, created_at "
|
|
957
|
-
"FROM atomic_facts WHERE fact_id = ? AND profile_id = ?",
|
|
958
|
-
(fact_id, active_profile),
|
|
959
|
-
)
|
|
960
|
-
row = cursor.fetchone()
|
|
961
|
-
if not row:
|
|
962
|
-
conn.close()
|
|
963
|
-
raise HTTPException(status_code=404, detail="Memory not found")
|
|
964
|
-
# Archive copy — payload_json small enough for the canonical row.
|
|
965
|
-
payload = {
|
|
966
|
-
"fact_id": row["fact_id"],
|
|
967
|
-
"content": row["content"],
|
|
968
|
-
"canonical_entities_json": row.get("canonical_entities_json"),
|
|
969
|
-
"importance": row.get("importance"),
|
|
970
|
-
"confidence": row.get("confidence"),
|
|
971
|
-
"created_at": row.get("created_at"),
|
|
972
|
-
}
|
|
973
1001
|
from datetime import datetime, timezone
|
|
974
|
-
archived_at = datetime.now(timezone.utc).isoformat()
|
|
975
1002
|
import uuid as _uuid
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
1003
|
+
archived_at = datetime.now(timezone.utc).isoformat()
|
|
1004
|
+
archive_id = str(_uuid.uuid4())
|
|
1005
|
+
# memory_write: write lock (in-process) + busy_timeout (cross-process).
|
|
1006
|
+
# SELECT + INSERT + UPDATE are atomic inside the same connection.
|
|
1007
|
+
with memory_write(DB_PATH) as conn:
|
|
1008
|
+
conn.row_factory = dict_factory
|
|
1009
|
+
row = conn.execute(
|
|
1010
|
+
"SELECT fact_id, content, importance, confidence, "
|
|
1011
|
+
" canonical_entities_json, embedding, created_at "
|
|
1012
|
+
"FROM atomic_facts WHERE fact_id = ? AND profile_id = ?",
|
|
1013
|
+
(fact_id, active_profile),
|
|
1014
|
+
).fetchone()
|
|
1015
|
+
if not row:
|
|
1016
|
+
raise HTTPException(status_code=404, detail="Memory not found")
|
|
1017
|
+
# Archive copy — payload_json small enough for the canonical row.
|
|
1018
|
+
payload = {
|
|
1019
|
+
"fact_id": row["fact_id"],
|
|
1020
|
+
"content": row["content"],
|
|
1021
|
+
"canonical_entities_json": row.get("canonical_entities_json"),
|
|
1022
|
+
"importance": row.get("importance"),
|
|
1023
|
+
"confidence": row.get("confidence"),
|
|
1024
|
+
"created_at": row.get("created_at"),
|
|
1025
|
+
}
|
|
1026
|
+
conn.execute(
|
|
1027
|
+
"INSERT INTO memory_archive "
|
|
1028
|
+
"(archive_id, fact_id, profile_id, payload_json, archived_at, reason) "
|
|
1029
|
+
"VALUES (?, ?, ?, ?, ?, ?)",
|
|
1030
|
+
(archive_id, fact_id, active_profile,
|
|
1031
|
+
_json.dumps(payload), archived_at, "user_forget_dashboard"),
|
|
1032
|
+
)
|
|
1033
|
+
conn.execute(
|
|
1034
|
+
"UPDATE atomic_facts SET archive_status = 'archived' "
|
|
1035
|
+
"WHERE fact_id = ?",
|
|
1036
|
+
(fact_id,),
|
|
1037
|
+
)
|
|
990
1038
|
engine._hooks.run_post("delete", hook_context)
|
|
991
1039
|
return {"success": True, "fact_id": fact_id, "archived_at": archived_at}
|
|
992
1040
|
except HTTPException:
|
|
@@ -1018,39 +1066,39 @@ async def merge_memory(request: Request, fact_id: str):
|
|
|
1018
1066
|
raise HTTPException(400, "'into' exceeds 200-char limit")
|
|
1019
1067
|
if kept == fact_id:
|
|
1020
1068
|
raise HTTPException(400, "Cannot merge a fact into itself")
|
|
1021
|
-
conn = get_db_connection()
|
|
1022
|
-
conn.row_factory = dict_factory
|
|
1023
|
-
cursor = conn.cursor()
|
|
1024
|
-
# Both must belong to the active profile.
|
|
1025
|
-
cursor.execute(
|
|
1026
|
-
"SELECT fact_id FROM atomic_facts "
|
|
1027
|
-
"WHERE fact_id IN (?, ?) AND profile_id = ?",
|
|
1028
|
-
(fact_id, kept, active_profile),
|
|
1029
|
-
)
|
|
1030
|
-
found = {r["fact_id"] for r in cursor.fetchall()}
|
|
1031
|
-
if fact_id not in found or kept not in found:
|
|
1032
|
-
conn.close()
|
|
1033
|
-
raise HTTPException(
|
|
1034
|
-
404,
|
|
1035
|
-
"Both fact_ids must exist in the active profile",
|
|
1036
|
-
)
|
|
1037
1069
|
from datetime import datetime, timezone
|
|
1038
1070
|
merged_at = datetime.now(timezone.utc).isoformat()
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1071
|
+
# memory_write: write lock (in-process) + busy_timeout (cross-process).
|
|
1072
|
+
# SELECT + INSERT + UPDATE are atomic inside the same connection.
|
|
1073
|
+
with memory_write(DB_PATH) as conn:
|
|
1074
|
+
conn.row_factory = dict_factory
|
|
1075
|
+
# Both must belong to the active profile.
|
|
1076
|
+
found = {
|
|
1077
|
+
r["fact_id"]
|
|
1078
|
+
for r in conn.execute(
|
|
1079
|
+
"SELECT fact_id FROM atomic_facts "
|
|
1080
|
+
"WHERE fact_id IN (?, ?) AND profile_id = ?",
|
|
1081
|
+
(fact_id, kept, active_profile),
|
|
1082
|
+
).fetchall()
|
|
1083
|
+
}
|
|
1084
|
+
if fact_id not in found or kept not in found:
|
|
1085
|
+
raise HTTPException(
|
|
1086
|
+
404,
|
|
1087
|
+
"Both fact_ids must exist in the active profile",
|
|
1088
|
+
)
|
|
1089
|
+
conn.execute(
|
|
1090
|
+
"INSERT INTO memory_merge_log "
|
|
1091
|
+
"(kept_fact_id, merged_fact_id, profile_id, reason, merged_at) "
|
|
1092
|
+
"VALUES (?, ?, ?, ?, ?)",
|
|
1093
|
+
(kept, fact_id, active_profile,
|
|
1094
|
+
"user_merge_dashboard", merged_at),
|
|
1095
|
+
)
|
|
1096
|
+
conn.execute(
|
|
1097
|
+
"UPDATE atomic_facts "
|
|
1098
|
+
"SET merged_into = ?, archive_status = 'archived' "
|
|
1099
|
+
"WHERE fact_id = ?",
|
|
1100
|
+
(kept, fact_id),
|
|
1101
|
+
)
|
|
1054
1102
|
engine._hooks.run_post("delete", hook_context)
|
|
1055
1103
|
return {
|
|
1056
1104
|
"success": True,
|
|
@@ -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":
|
|
@@ -10,6 +10,7 @@ All connections use WAL mode + busy_timeout for concurrency safety.
|
|
|
10
10
|
"""
|
|
11
11
|
|
|
12
12
|
import logging
|
|
13
|
+
import os
|
|
13
14
|
import re
|
|
14
15
|
import sqlite3
|
|
15
16
|
from contextlib import contextmanager
|
|
@@ -19,6 +20,7 @@ from fastapi import APIRouter, HTTPException, Request
|
|
|
19
20
|
from pydantic import BaseModel, Field
|
|
20
21
|
|
|
21
22
|
from superlocalmemory.server.route_mutations import authorize_route_mutation
|
|
23
|
+
from superlocalmemory.storage.memory_write import memory_write
|
|
22
24
|
|
|
23
25
|
from .helpers import DB_PATH, get_active_profile
|
|
24
26
|
|
|
@@ -34,12 +36,24 @@ class PinRequest(BaseModel):
|
|
|
34
36
|
reason: str = Field(default="", max_length=_MAX_REASON_LENGTH)
|
|
35
37
|
|
|
36
38
|
|
|
39
|
+
def _busy_ms() -> int:
|
|
40
|
+
try:
|
|
41
|
+
return max(0, int(os.environ.get("SLM_DB_BUSY_TIMEOUT_MS", "10000")))
|
|
42
|
+
except (TypeError, ValueError):
|
|
43
|
+
return 10000
|
|
44
|
+
|
|
45
|
+
|
|
37
46
|
@contextmanager
|
|
38
47
|
def _db():
|
|
39
|
-
"""Context-managed DB connection with WAL + busy_timeout.
|
|
40
|
-
|
|
48
|
+
"""Context-managed DB connection with WAL + busy_timeout (READ paths only).
|
|
49
|
+
|
|
50
|
+
Write paths (pin, unpin) use ``memory_write()`` directly to also acquire
|
|
51
|
+
the process write lock and prevent in-process SQLITE_BUSY races.
|
|
52
|
+
"""
|
|
53
|
+
ms = _busy_ms()
|
|
54
|
+
conn = sqlite3.connect(str(DB_PATH), timeout=ms / 1000.0)
|
|
41
55
|
conn.execute("PRAGMA journal_mode=WAL")
|
|
42
|
-
conn.execute("PRAGMA busy_timeout=
|
|
56
|
+
conn.execute(f"PRAGMA busy_timeout={ms}")
|
|
43
57
|
conn.row_factory = sqlite3.Row
|
|
44
58
|
try:
|
|
45
59
|
yield conn
|
|
@@ -154,21 +168,20 @@ async def pin_fact_route(
|
|
|
154
168
|
fact_id=body.fact_id,
|
|
155
169
|
)
|
|
156
170
|
|
|
157
|
-
|
|
158
|
-
|
|
171
|
+
# memory_write: process write lock + busy_timeout.
|
|
172
|
+
# SELECT + INSERT + lifecycle update are atomic inside the same connection.
|
|
173
|
+
try:
|
|
174
|
+
with memory_write(DB_PATH) as conn:
|
|
159
175
|
# Verify fact exists in this profile
|
|
160
|
-
|
|
161
|
-
c.execute(
|
|
176
|
+
if conn.execute(
|
|
162
177
|
"SELECT fact_id FROM atomic_facts "
|
|
163
178
|
"WHERE fact_id = ? AND profile_id = ?",
|
|
164
179
|
(body.fact_id, profile_id),
|
|
165
|
-
)
|
|
166
|
-
if c.fetchone() is None:
|
|
180
|
+
).fetchone() is None:
|
|
167
181
|
raise HTTPException(
|
|
168
182
|
status_code=404,
|
|
169
183
|
detail=f"Fact {body.fact_id[:8]}... not found",
|
|
170
184
|
)
|
|
171
|
-
|
|
172
185
|
now = datetime.now(UTC).isoformat()
|
|
173
186
|
conn.execute(
|
|
174
187
|
"INSERT OR REPLACE INTO pinned_facts "
|
|
@@ -180,16 +193,15 @@ async def pin_fact_route(
|
|
|
180
193
|
set_fact_lifecycle_zone(
|
|
181
194
|
conn, [body.fact_id], "active", profile_id=profile_id,
|
|
182
195
|
)
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
) from None
|
|
196
|
+
authorization.complete()
|
|
197
|
+
return {"success": True, "message": f"Fact {body.fact_id[:8]}... pinned"}
|
|
198
|
+
except HTTPException:
|
|
199
|
+
raise
|
|
200
|
+
except Exception as exc:
|
|
201
|
+
logger.error("pin_fact failed: %s", exc, exc_info=True)
|
|
202
|
+
raise HTTPException(
|
|
203
|
+
status_code=500, detail="Failed to pin fact",
|
|
204
|
+
) from None
|
|
193
205
|
|
|
194
206
|
|
|
195
207
|
@router.post("/api/tiers/unpin")
|
|
@@ -213,17 +225,17 @@ async def unpin_fact_route(
|
|
|
213
225
|
fact_id=body.fact_id,
|
|
214
226
|
)
|
|
215
227
|
|
|
216
|
-
|
|
217
|
-
|
|
228
|
+
# memory_write: process write lock + busy_timeout.
|
|
229
|
+
try:
|
|
230
|
+
with memory_write(DB_PATH) as conn:
|
|
218
231
|
conn.execute(
|
|
219
232
|
"DELETE FROM pinned_facts WHERE fact_id = ? AND profile_id = ?",
|
|
220
233
|
(body.fact_id, profile_id),
|
|
221
234
|
)
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
) from None
|
|
235
|
+
authorization.complete()
|
|
236
|
+
return {"success": True, "unpinned": True}
|
|
237
|
+
except Exception as exc:
|
|
238
|
+
logger.error("unpin_fact failed: %s", exc, exc_info=True)
|
|
239
|
+
raise HTTPException(
|
|
240
|
+
status_code=500, detail="Failed to unpin fact",
|
|
241
|
+
) from None
|