superlocalmemory 3.8.3 → 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 +42 -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 +67 -68
- 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 +200 -31
- 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
|
@@ -15,6 +15,7 @@ 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()
|
|
@@ -997,45 +998,43 @@ async def forget_memory(request: Request, fact_id: str):
|
|
|
997
998
|
request, "delete", fact_id,
|
|
998
999
|
)
|
|
999
1000
|
try:
|
|
1000
|
-
conn = get_db_connection()
|
|
1001
|
-
conn.row_factory = dict_factory
|
|
1002
|
-
cursor = conn.cursor()
|
|
1003
|
-
cursor.execute(
|
|
1004
|
-
"SELECT fact_id, content, importance, confidence, "
|
|
1005
|
-
" canonical_entities_json, embedding, created_at "
|
|
1006
|
-
"FROM atomic_facts WHERE fact_id = ? AND profile_id = ?",
|
|
1007
|
-
(fact_id, active_profile),
|
|
1008
|
-
)
|
|
1009
|
-
row = cursor.fetchone()
|
|
1010
|
-
if not row:
|
|
1011
|
-
conn.close()
|
|
1012
|
-
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
1001
|
from datetime import datetime, timezone
|
|
1023
|
-
archived_at = datetime.now(timezone.utc).isoformat()
|
|
1024
1002
|
import uuid as _uuid
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
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
|
+
)
|
|
1039
1038
|
engine._hooks.run_post("delete", hook_context)
|
|
1040
1039
|
return {"success": True, "fact_id": fact_id, "archived_at": archived_at}
|
|
1041
1040
|
except HTTPException:
|
|
@@ -1067,39 +1066,39 @@ async def merge_memory(request: Request, fact_id: str):
|
|
|
1067
1066
|
raise HTTPException(400, "'into' exceeds 200-char limit")
|
|
1068
1067
|
if kept == fact_id:
|
|
1069
1068
|
raise HTTPException(400, "Cannot merge a fact into itself")
|
|
1070
|
-
conn = get_db_connection()
|
|
1071
|
-
conn.row_factory = dict_factory
|
|
1072
|
-
cursor = conn.cursor()
|
|
1073
|
-
# Both must belong to the active profile.
|
|
1074
|
-
cursor.execute(
|
|
1075
|
-
"SELECT fact_id FROM atomic_facts "
|
|
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
1069
|
from datetime import datetime, timezone
|
|
1087
1070
|
merged_at = datetime.now(timezone.utc).isoformat()
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
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
|
+
)
|
|
1103
1102
|
engine._hooks.run_post("delete", hook_context)
|
|
1104
1103
|
return {
|
|
1105
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
|
|
@@ -596,7 +596,9 @@ def _validate_provider_url(url: str, client_host: str) -> str | None:
|
|
|
596
596
|
host = p.hostname or ""
|
|
597
597
|
if host.lower() in ("169.254.169.254", "metadata.google.internal", "metadata"):
|
|
598
598
|
return "Cloud metadata endpoints are not allowed"
|
|
599
|
-
|
|
599
|
+
from superlocalmemory.server.loopback import is_loopback as _is_loopback_host
|
|
600
|
+
|
|
601
|
+
if _is_loopback_host(client_host):
|
|
600
602
|
return None # local dashboard may target its own local/LAN endpoints
|
|
601
603
|
# SLM_REMOTE residue (#40): an allowlisted LAN dashboard is trusted exactly
|
|
602
604
|
# like the loopback one and may probe its own LAN LLM endpoint. This does
|
|
@@ -1808,7 +1810,7 @@ async def update_core_memory_block(block_id: str, request: Request):
|
|
|
1808
1810
|
)
|
|
1809
1811
|
|
|
1810
1812
|
from superlocalmemory.server.routes.helpers import DB_PATH, get_active_profile
|
|
1811
|
-
import
|
|
1813
|
+
from superlocalmemory.storage.memory_write import memory_write
|
|
1812
1814
|
from datetime import datetime, timezone
|
|
1813
1815
|
|
|
1814
1816
|
if not DB_PATH.exists():
|
|
@@ -1827,46 +1829,38 @@ async def update_core_memory_block(block_id: str, request: Request):
|
|
|
1827
1829
|
content_preview=str(content),
|
|
1828
1830
|
)
|
|
1829
1831
|
|
|
1830
|
-
conn = sqlite3.connect(str(DB_PATH))
|
|
1831
|
-
conn.row_factory = sqlite3.Row
|
|
1832
|
-
|
|
1833
|
-
# Verify block exists
|
|
1834
|
-
existing = conn.execute(
|
|
1835
|
-
"SELECT block_id, profile_id, block_type, version "
|
|
1836
|
-
"FROM core_memory_blocks WHERE block_id = ? AND profile_id = ?",
|
|
1837
|
-
(block_id, pid),
|
|
1838
|
-
).fetchone()
|
|
1839
|
-
|
|
1840
|
-
if not existing:
|
|
1841
|
-
conn.close()
|
|
1842
|
-
return JSONResponse(
|
|
1843
|
-
{"error": f"Block {block_id} not found"},
|
|
1844
|
-
status_code=404,
|
|
1845
|
-
)
|
|
1846
|
-
|
|
1847
|
-
existing_dict = dict(existing)
|
|
1848
|
-
new_version = existing_dict["version"] + 1
|
|
1849
1832
|
now = datetime.now(timezone.utc).isoformat()
|
|
1833
|
+
# memory_write: process write lock + busy_timeout.
|
|
1834
|
+
# SELECT + UPDATE + read-back are atomic inside the same connection.
|
|
1835
|
+
with memory_write(DB_PATH) as conn:
|
|
1836
|
+
# Verify block exists
|
|
1837
|
+
existing = conn.execute(
|
|
1838
|
+
"SELECT block_id, profile_id, block_type, version "
|
|
1839
|
+
"FROM core_memory_blocks WHERE block_id = ? AND profile_id = ?",
|
|
1840
|
+
(block_id, pid),
|
|
1841
|
+
).fetchone()
|
|
1850
1842
|
|
|
1851
|
-
|
|
1852
|
-
|
|
1853
|
-
"version = ?, compiled_by = 'manual', updated_at = ? "
|
|
1854
|
-
"WHERE block_id = ? AND profile_id = ?",
|
|
1855
|
-
(content, len(content), new_version, now, block_id, pid),
|
|
1856
|
-
)
|
|
1857
|
-
conn.commit()
|
|
1843
|
+
if not existing:
|
|
1844
|
+
raise HTTPException(status_code=404, detail=f"Block {block_id} not found")
|
|
1858
1845
|
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
|
|
1862
|
-
|
|
1863
|
-
|
|
1864
|
-
|
|
1865
|
-
|
|
1866
|
-
|
|
1846
|
+
new_version = dict(existing)["version"] + 1
|
|
1847
|
+
conn.execute(
|
|
1848
|
+
"UPDATE core_memory_blocks SET content = ?, char_count = ?, "
|
|
1849
|
+
"version = ?, compiled_by = 'manual', updated_at = ? "
|
|
1850
|
+
"WHERE block_id = ? AND profile_id = ?",
|
|
1851
|
+
(content, len(content), new_version, now, block_id, pid),
|
|
1852
|
+
)
|
|
1853
|
+
# Read back updated block while connection is still open.
|
|
1854
|
+
updated = conn.execute(
|
|
1855
|
+
"SELECT block_id, block_type, content, char_count, version, "
|
|
1856
|
+
"compiled_by, updated_at FROM core_memory_blocks "
|
|
1857
|
+
"WHERE block_id = ? AND profile_id = ?",
|
|
1858
|
+
(block_id, pid),
|
|
1859
|
+
).fetchone()
|
|
1860
|
+
updated_dict = dict(updated) if updated else {"block_id": block_id, "updated": True}
|
|
1867
1861
|
|
|
1868
1862
|
authorization.complete()
|
|
1869
|
-
return
|
|
1863
|
+
return updated_dict
|
|
1870
1864
|
except HTTPException:
|
|
1871
1865
|
raise
|
|
1872
1866
|
except Exception as e:
|
|
@@ -1990,7 +1984,7 @@ async def run_forgetting(request: Request):
|
|
|
1990
1984
|
profile = body.get("profile", "")
|
|
1991
1985
|
|
|
1992
1986
|
from superlocalmemory.server.routes.helpers import get_active_profile, DB_PATH
|
|
1993
|
-
import
|
|
1987
|
+
from superlocalmemory.storage.memory_write import memory_write as _memory_write
|
|
1994
1988
|
pid = _resolve_mutation_profile(profile)
|
|
1995
1989
|
_require_manage_for_profile(request, pid)
|
|
1996
1990
|
|
|
@@ -2003,59 +1997,55 @@ async def run_forgetting(request: Request):
|
|
|
2003
1997
|
source_agent_id="http-forgetting-run",
|
|
2004
1998
|
profile_id=pid,
|
|
2005
1999
|
)
|
|
2006
|
-
conn = _sqlite3.connect(str(DB_PATH))
|
|
2007
|
-
conn.row_factory = _sqlite3.Row
|
|
2008
2000
|
|
|
2001
|
+
# memory_write: process write lock + busy_timeout — all UPDATEs atomic.
|
|
2009
2002
|
updated = 0
|
|
2010
2003
|
try:
|
|
2011
|
-
|
|
2012
|
-
|
|
2013
|
-
|
|
2014
|
-
"UPDATE fact_retention "
|
|
2015
|
-
"SET retention_score = MAX(0.0, retention_score * 0.9), "
|
|
2016
|
-
" last_computed_at = datetime('now') "
|
|
2017
|
-
"WHERE profile_id = ? "
|
|
2018
|
-
"AND lifecycle_zone NOT IN ('archive', 'forgotten')",
|
|
2019
|
-
(pid,),
|
|
2020
|
-
)
|
|
2021
|
-
updated = conn.total_changes
|
|
2022
|
-
|
|
2023
|
-
# Transition zones based on new retention scores
|
|
2024
|
-
zone_thresholds = [
|
|
2025
|
-
("forgotten", 0.05),
|
|
2026
|
-
("archive", 0.15),
|
|
2027
|
-
("cold", 0.35),
|
|
2028
|
-
("warm", 0.65),
|
|
2029
|
-
]
|
|
2030
|
-
for zone, threshold in zone_thresholds:
|
|
2004
|
+
with _memory_write(DB_PATH) as conn:
|
|
2005
|
+
# Apply Ebbinghaus decay: reduce retention for facts not accessed recently
|
|
2006
|
+
# Formula: retention *= exp(-0.1) for each cycle (simplified batch decay)
|
|
2031
2007
|
conn.execute(
|
|
2032
2008
|
"UPDATE fact_retention "
|
|
2033
|
-
"SET
|
|
2009
|
+
"SET retention_score = MAX(0.0, retention_score * 0.9), "
|
|
2010
|
+
" last_computed_at = datetime('now') "
|
|
2034
2011
|
"WHERE profile_id = ? "
|
|
2035
|
-
"AND retention_score < ? "
|
|
2036
2012
|
"AND lifecycle_zone NOT IN ('archive', 'forgotten')",
|
|
2037
|
-
(
|
|
2013
|
+
(pid,),
|
|
2038
2014
|
)
|
|
2015
|
+
updated = conn.total_changes
|
|
2016
|
+
|
|
2017
|
+
# Transition zones based on new retention scores
|
|
2018
|
+
zone_thresholds = [
|
|
2019
|
+
("forgotten", 0.05),
|
|
2020
|
+
("archive", 0.15),
|
|
2021
|
+
("cold", 0.35),
|
|
2022
|
+
("warm", 0.65),
|
|
2023
|
+
]
|
|
2024
|
+
for zone, threshold in zone_thresholds:
|
|
2025
|
+
conn.execute(
|
|
2026
|
+
"UPDATE fact_retention "
|
|
2027
|
+
"SET lifecycle_zone = ? "
|
|
2028
|
+
"WHERE profile_id = ? "
|
|
2029
|
+
"AND retention_score < ? "
|
|
2030
|
+
"AND lifecycle_zone NOT IN ('archive', 'forgotten')",
|
|
2031
|
+
(zone, pid, threshold),
|
|
2032
|
+
)
|
|
2039
2033
|
|
|
2040
|
-
|
|
2041
|
-
|
|
2042
|
-
|
|
2043
|
-
|
|
2044
|
-
|
|
2045
|
-
|
|
2046
|
-
|
|
2047
|
-
|
|
2048
|
-
|
|
2049
|
-
from superlocalmemory.core.lifecycle_state import reconcile_profile_lifecycle
|
|
2050
|
-
reconcile_profile_lifecycle(conn, pid)
|
|
2034
|
+
# Ensure high-retention facts are active
|
|
2035
|
+
conn.execute(
|
|
2036
|
+
"UPDATE fact_retention "
|
|
2037
|
+
"SET lifecycle_zone = 'active' "
|
|
2038
|
+
"WHERE profile_id = ? AND retention_score >= 0.65 "
|
|
2039
|
+
"AND lifecycle_zone NOT IN ('archive', 'forgotten')",
|
|
2040
|
+
(pid,),
|
|
2041
|
+
)
|
|
2051
2042
|
|
|
2052
|
-
|
|
2043
|
+
from superlocalmemory.core.lifecycle_state import reconcile_profile_lifecycle
|
|
2044
|
+
reconcile_profile_lifecycle(conn, pid)
|
|
2053
2045
|
except Exception as exc:
|
|
2054
2046
|
logger.exception("run_forgetting decay failed")
|
|
2055
|
-
conn.close()
|
|
2056
2047
|
return {"success": False, "error": "internal error"}
|
|
2057
2048
|
|
|
2058
|
-
conn.close()
|
|
2059
2049
|
authorization.complete()
|
|
2060
2050
|
return {"success": True, "facts_decayed": updated, "profile": pid}
|
|
2061
2051
|
except HTTPException:
|