superlocalmemory 3.8.5 → 3.8.7
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 +47 -0
- package/README.md +3 -3
- 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 -1
- package/src/superlocalmemory/__init__.py +1 -1
- package/src/superlocalmemory/cli/commands.py +139 -404
- package/src/superlocalmemory/core/backend_orchestrator.py +7 -1
- package/src/superlocalmemory/core/component_registry.py +4 -2
- package/src/superlocalmemory/core/embeddings.py +33 -6
- package/src/superlocalmemory/core/engine.py +94 -49
- package/src/superlocalmemory/core/engine_ingestion.py +150 -63
- package/src/superlocalmemory/core/ingestion_command.py +133 -21
- package/src/superlocalmemory/core/mutations.py +32 -10
- package/src/superlocalmemory/core/recall_pipeline.py +111 -77
- package/src/superlocalmemory/core/remember_admission.py +152 -0
- package/src/superlocalmemory/core/remember_runtime.py +712 -0
- package/src/superlocalmemory/graph/cozo_backend.py +5 -5
- package/src/superlocalmemory/learning/bandit.py +50 -1
- package/src/superlocalmemory/learning/source_quality.py +38 -35
- package/src/superlocalmemory/mcp/_daemon_proxy.py +38 -15
- 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 +8 -3
- package/src/superlocalmemory/retrieval/reranker.py +35 -10
- package/src/superlocalmemory/server/loopback.py +7 -13
- 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/behavioral.py +5 -13
- package/src/superlocalmemory/server/routes/brain.py +6 -9
- 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 +44 -23
- 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 +122 -100
- package/src/superlocalmemory/server/routes/tiers.py +3 -22
- package/src/superlocalmemory/server/routes/timeline.py +2 -4
- package/src/superlocalmemory/server/routes/v3_api.py +18 -16
- package/src/superlocalmemory/server/unified_daemon.py +200 -109
- package/src/superlocalmemory/storage/admission_codec.py +119 -0
- package/src/superlocalmemory/storage/admission_journal.py +728 -0
- package/src/superlocalmemory/storage/database.py +59 -0
- package/src/superlocalmemory/storage/deferred_writes.py +67 -11
- package/src/superlocalmemory/storage/memory_write.py +8 -12
- package/src/superlocalmemory/storage/migration_runner.py +37 -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/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
|
@@ -8,7 +8,7 @@ from __future__ import annotations
|
|
|
8
8
|
|
|
9
9
|
from fastapi import APIRouter, HTTPException, Query, Request
|
|
10
10
|
|
|
11
|
-
from .helpers import get_active_profile, require_engine
|
|
11
|
+
from .helpers import get_active_profile, get_read_connection, require_engine
|
|
12
12
|
|
|
13
13
|
router = APIRouter(prefix="/api/entity", tags=["entity"])
|
|
14
14
|
|
|
@@ -82,9 +82,7 @@ def list_entities(
|
|
|
82
82
|
profile = profile or get_active_profile()
|
|
83
83
|
_require_read(request, profile)
|
|
84
84
|
|
|
85
|
-
|
|
86
|
-
conn = sqlite3.connect(str(engine._config.db_path))
|
|
87
|
-
conn.row_factory = sqlite3.Row
|
|
85
|
+
conn = get_read_connection(engine._config.db_path)
|
|
88
86
|
try:
|
|
89
87
|
where = ["ce.profile_id = ?"]
|
|
90
88
|
params: list[object] = [profile]
|
|
@@ -158,9 +156,7 @@ def get_entity(
|
|
|
158
156
|
_require_read(request, profile)
|
|
159
157
|
|
|
160
158
|
import json
|
|
161
|
-
|
|
162
|
-
conn = sqlite3.connect(str(engine._config.db_path))
|
|
163
|
-
conn.row_factory = sqlite3.Row
|
|
159
|
+
conn = get_read_connection(engine._config.db_path)
|
|
164
160
|
try:
|
|
165
161
|
# Search by canonical_name (case-insensitive)
|
|
166
162
|
row = conn.execute("""
|
|
@@ -15,6 +15,7 @@ from fastapi import APIRouter, Request
|
|
|
15
15
|
from pydantic import BaseModel
|
|
16
16
|
|
|
17
17
|
from superlocalmemory.server.config_file import read_config, update_config
|
|
18
|
+
from superlocalmemory.storage.read_connection import ReadConnectionFactory
|
|
18
19
|
|
|
19
20
|
from .helpers import MEMORY_DIR, get_active_profile
|
|
20
21
|
|
|
@@ -261,12 +262,9 @@ def evolution_lineage(request: Request, skill_name: str = ""):
|
|
|
261
262
|
_require_read(request)
|
|
262
263
|
conn = None
|
|
263
264
|
try:
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
db_path = str(MEMORY_DIR / "memory.db")
|
|
265
|
+
db_path = MEMORY_DIR / "memory.db"
|
|
267
266
|
profile_id = get_active_profile()
|
|
268
|
-
conn =
|
|
269
|
-
conn.row_factory = _sqlite3.Row
|
|
267
|
+
conn = ReadConnectionFactory(db_path).open()
|
|
270
268
|
|
|
271
269
|
if skill_name:
|
|
272
270
|
rows = conn.execute(
|
|
@@ -22,7 +22,7 @@ from fastapi import HTTPException, Request
|
|
|
22
22
|
from pydantic import BaseModel, Field
|
|
23
23
|
|
|
24
24
|
from superlocalmemory.infra.data_root import DynamicStatePath, canonical_data_root
|
|
25
|
-
from superlocalmemory.storage.memory_write import memory_write
|
|
25
|
+
from superlocalmemory.storage.memory_write import memory_read, memory_write
|
|
26
26
|
|
|
27
27
|
|
|
28
28
|
_engine_logger = logging.getLogger("superlocalmemory.engine")
|
|
@@ -217,24 +217,48 @@ def log_mode_change(
|
|
|
217
217
|
)
|
|
218
218
|
|
|
219
219
|
|
|
220
|
-
|
|
221
|
-
"""
|
|
220
|
+
class _RouteReadConnection:
|
|
221
|
+
"""Compatibility wrapper for legacy route callers that close manually.
|
|
222
222
|
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
223
|
+
New routes should prefer ``with memory_read(path)``. A few shared route
|
|
224
|
+
callers still expect ``get_db_connection()`` to return a connection they
|
|
225
|
+
can close themselves, so this wrapper preserves that contract without
|
|
226
|
+
reopening canonical ``memory.db`` in writable mode.
|
|
227
227
|
"""
|
|
228
|
-
|
|
228
|
+
|
|
229
|
+
def __init__(self, db_path: Path) -> None:
|
|
230
|
+
object.__setattr__(self, "_snapshot", memory_read(db_path))
|
|
231
|
+
object.__setattr__(self, "_connection", self._snapshot.__enter__())
|
|
232
|
+
object.__setattr__(self, "_closed", False)
|
|
233
|
+
|
|
234
|
+
def __getattr__(self, name: str):
|
|
235
|
+
return getattr(self._connection, name)
|
|
236
|
+
|
|
237
|
+
def __setattr__(self, name: str, value) -> None:
|
|
238
|
+
if name.startswith("_"):
|
|
239
|
+
object.__setattr__(self, name, value)
|
|
240
|
+
else:
|
|
241
|
+
setattr(self._connection, name, value)
|
|
242
|
+
|
|
243
|
+
def close(self) -> None:
|
|
244
|
+
if not self._closed:
|
|
245
|
+
self._snapshot.__exit__(None, None, None)
|
|
246
|
+
object.__setattr__(self, "_closed", True)
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def get_read_connection(db_path: Path = DB_PATH) -> _RouteReadConnection:
|
|
250
|
+
"""Return a legacy-compatible, physically read-only canonical connection."""
|
|
251
|
+
if not db_path.exists():
|
|
229
252
|
raise HTTPException(
|
|
230
253
|
status_code=500,
|
|
231
|
-
detail="Memory database not found. Run 'slm init' to initialize."
|
|
254
|
+
detail="Memory database not found. Run 'slm init' to initialize.",
|
|
232
255
|
)
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
256
|
+
return _RouteReadConnection(db_path)
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def get_db_connection() -> _RouteReadConnection:
|
|
260
|
+
"""Return the shared dashboard read connection for canonical ``memory.db``."""
|
|
261
|
+
return get_read_connection(DB_PATH)
|
|
238
262
|
|
|
239
263
|
|
|
240
264
|
def dict_factory(cursor: sqlite3.Cursor, row: tuple) -> dict:
|
|
@@ -380,18 +404,15 @@ def _get_db_profiles() -> list[dict]:
|
|
|
380
404
|
"""Read all profiles from SQLite."""
|
|
381
405
|
if not DB_PATH.exists():
|
|
382
406
|
return []
|
|
383
|
-
conn = sqlite3.connect(str(DB_PATH))
|
|
384
|
-
conn.row_factory = sqlite3.Row
|
|
385
407
|
try:
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
408
|
+
with memory_read(DB_PATH) as conn:
|
|
409
|
+
rows = conn.execute(
|
|
410
|
+
"SELECT profile_id, name, description, created_at, last_used "
|
|
411
|
+
"FROM profiles ORDER BY name"
|
|
412
|
+
).fetchall()
|
|
413
|
+
return [dict(r) for r in rows]
|
|
391
414
|
except sqlite3.OperationalError:
|
|
392
415
|
return []
|
|
393
|
-
finally:
|
|
394
|
-
conn.close()
|
|
395
416
|
|
|
396
417
|
|
|
397
418
|
def _load_profiles_json() -> dict:
|
|
@@ -17,7 +17,7 @@ from typing import Any, Callable
|
|
|
17
17
|
|
|
18
18
|
from fastapi import APIRouter, Query
|
|
19
19
|
from fastapi.responses import JSONResponse
|
|
20
|
-
from superlocalmemory.server.routes.helpers import DB_PATH, get_active_profile
|
|
20
|
+
from superlocalmemory.server.routes.helpers import DB_PATH, get_active_profile, get_read_connection
|
|
21
21
|
|
|
22
22
|
logger = logging.getLogger(__name__)
|
|
23
23
|
|
|
@@ -31,9 +31,7 @@ def _get_conn(profile: str = "") -> tuple[sqlite3.Connection | None, str]:
|
|
|
31
31
|
pid = profile or get_active_profile()
|
|
32
32
|
if not DB_PATH.exists():
|
|
33
33
|
return None, pid
|
|
34
|
-
|
|
35
|
-
conn.row_factory = sqlite3.Row
|
|
36
|
-
return conn, pid
|
|
34
|
+
return get_read_connection(DB_PATH), pid
|
|
37
35
|
|
|
38
36
|
|
|
39
37
|
# ── Action Handlers ───────────────────────────────────────────────
|
|
@@ -12,7 +12,6 @@ Uses V3 learning modules: FeedbackCollector, EngagementTracker, AdaptiveLearner.
|
|
|
12
12
|
import logging
|
|
13
13
|
import shutil
|
|
14
14
|
import sqlite3
|
|
15
|
-
from contextlib import closing
|
|
16
15
|
from datetime import datetime, timezone
|
|
17
16
|
from pathlib import Path
|
|
18
17
|
|
|
@@ -30,6 +29,7 @@ from .learning_telemetry import (
|
|
|
30
29
|
from .learning_telemetry import (
|
|
31
30
|
sqlite_status as _sqlite_status,
|
|
32
31
|
)
|
|
32
|
+
from superlocalmemory.storage.memory_write import memory_read
|
|
33
33
|
|
|
34
34
|
logger = logging.getLogger("superlocalmemory.routes.learning")
|
|
35
35
|
router = APIRouter()
|
|
@@ -667,12 +667,9 @@ def get_patterns():
|
|
|
667
667
|
|
|
668
668
|
# Graph intelligence contribution to learning (v3.4.1)
|
|
669
669
|
try:
|
|
670
|
-
import sqlite3 as _sqlite3
|
|
671
|
-
|
|
672
670
|
from superlocalmemory.server.routes.helpers import DB_PATH
|
|
673
671
|
if DB_PATH.exists():
|
|
674
|
-
with
|
|
675
|
-
conn.row_factory = _sqlite3.Row
|
|
672
|
+
with memory_read(DB_PATH) as conn:
|
|
676
673
|
row = conn.execute(
|
|
677
674
|
"SELECT COUNT(*) AS cnt, "
|
|
678
675
|
"COUNT(DISTINCT community_id) AS communities, "
|
|
@@ -7,13 +7,12 @@
|
|
|
7
7
|
Routes: /api/lifecycle/status, /api/lifecycle/compact
|
|
8
8
|
Uses V3 compliance.lifecycle.LifecycleManager.
|
|
9
9
|
"""
|
|
10
|
-
import json
|
|
11
10
|
import logging
|
|
12
11
|
import sqlite3
|
|
13
12
|
|
|
14
13
|
from fastapi import APIRouter, Request
|
|
15
14
|
|
|
16
|
-
from .helpers import get_active_profile, get_engine_lazy,
|
|
15
|
+
from .helpers import DB_PATH, get_active_profile, get_engine_lazy, get_read_connection
|
|
17
16
|
from superlocalmemory.server.route_mutations import authorize_route_mutation
|
|
18
17
|
|
|
19
18
|
logger = logging.getLogger("superlocalmemory.routes.lifecycle")
|
|
@@ -36,8 +35,7 @@ async def lifecycle_status():
|
|
|
36
35
|
|
|
37
36
|
try:
|
|
38
37
|
profile = get_active_profile()
|
|
39
|
-
conn =
|
|
40
|
-
conn.row_factory = sqlite3.Row
|
|
38
|
+
conn = get_read_connection(DB_PATH)
|
|
41
39
|
|
|
42
40
|
# V3.3: Use fact_retention.lifecycle_zone (Ebbinghaus-driven, authoritative)
|
|
43
41
|
# Falls back to atomic_facts.lifecycle for pre-3.3 databases
|
|
@@ -7,18 +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
|
-
from superlocalmemory.storage.memory_write import memory_write
|
|
19
23
|
|
|
20
24
|
logger = logging.getLogger("superlocalmemory.routes.memories")
|
|
21
25
|
router = APIRouter()
|
|
26
|
+
_IDEMPOTENCY_KEY = re.compile(r"^[A-Za-z0-9._:-]{1,256}$")
|
|
22
27
|
|
|
23
28
|
# v3.8.3: GENEROUS latency budget for recall. SLM's value is quality recall
|
|
24
29
|
# under heavy multi-agent load, so semantic recall is given ample time to
|
|
@@ -57,6 +62,58 @@ def _get_engine(request: Request):
|
|
|
57
62
|
return get_engine_lazy(request.app.state)
|
|
58
63
|
|
|
59
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
|
+
|
|
60
117
|
def _authorize_memory_mutation(
|
|
61
118
|
request: Request,
|
|
62
119
|
operation: str,
|
|
@@ -726,13 +783,21 @@ async def get_clusters(request: Request):
|
|
|
726
783
|
unclustered = 0
|
|
727
784
|
|
|
728
785
|
conn.close()
|
|
729
|
-
return {
|
|
786
|
+
return {
|
|
787
|
+
"clusters": clusters,
|
|
788
|
+
"total_clusters": len(clusters),
|
|
789
|
+
"unclustered_count": unclustered,
|
|
790
|
+
}
|
|
730
791
|
except Exception:
|
|
731
792
|
raise _internal_error("Cluster error")
|
|
732
793
|
|
|
733
794
|
|
|
734
795
|
@router.get("/api/clusters/{cluster_id}")
|
|
735
|
-
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
|
+
):
|
|
736
801
|
"""Get detailed view of a specific cluster (scene)."""
|
|
737
802
|
try:
|
|
738
803
|
conn = get_db_connection()
|
|
@@ -974,14 +1039,18 @@ async def delete_memory(request: Request, fact_id: str):
|
|
|
974
1039
|
fact_id,
|
|
975
1040
|
trusted_actor_id=hook_context["agent_id"],
|
|
976
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),
|
|
977
1046
|
)
|
|
978
1047
|
if not result.get("ok"):
|
|
979
1048
|
raise HTTPException(status_code=404, detail="Memory not found")
|
|
980
1049
|
return {"success": True, "deleted": fact_id}
|
|
981
1050
|
except HTTPException:
|
|
982
1051
|
raise
|
|
983
|
-
except Exception:
|
|
984
|
-
raise
|
|
1052
|
+
except Exception as exc:
|
|
1053
|
+
raise _canonical_mutation_error(exc, "Delete error")
|
|
985
1054
|
|
|
986
1055
|
|
|
987
1056
|
@router.post("/api/memories/{fact_id}/forget")
|
|
@@ -993,54 +1062,25 @@ async def forget_memory(request: Request, fact_id: str):
|
|
|
993
1062
|
The fact's payload is ALSO copied into ``memory_archive`` so a
|
|
994
1063
|
future ``slm restore`` can bring it back.
|
|
995
1064
|
"""
|
|
996
|
-
import json as _json
|
|
997
1065
|
engine, active_profile, hook_context = _authorize_memory_mutation(
|
|
998
1066
|
request, "delete", fact_id,
|
|
1067
|
+
run_pre_hook=False,
|
|
999
1068
|
)
|
|
1000
1069
|
try:
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
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
|
-
)
|
|
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),
|
|
1075
|
+
)
|
|
1076
|
+
if not result.get("ok"):
|
|
1077
|
+
raise HTTPException(status_code=404, detail="Memory not found")
|
|
1038
1078
|
engine._hooks.run_post("delete", hook_context)
|
|
1039
|
-
return {"success": True, "fact_id": fact_id, "archived_at": archived_at}
|
|
1079
|
+
return {"success": True, "fact_id": fact_id, "archived_at": result["archived_at"]}
|
|
1040
1080
|
except HTTPException:
|
|
1041
1081
|
raise
|
|
1042
|
-
except Exception:
|
|
1043
|
-
raise
|
|
1082
|
+
except Exception as exc:
|
|
1083
|
+
raise _canonical_mutation_error(exc, "Forget error")
|
|
1044
1084
|
|
|
1045
1085
|
|
|
1046
1086
|
@router.post("/api/memories/{fact_id}/merge")
|
|
@@ -1055,6 +1095,7 @@ async def merge_memory(request: Request, fact_id: str):
|
|
|
1055
1095
|
"""
|
|
1056
1096
|
engine, active_profile, hook_context = _authorize_memory_mutation(
|
|
1057
1097
|
request, "delete", fact_id,
|
|
1098
|
+
run_pre_hook=False,
|
|
1058
1099
|
)
|
|
1059
1100
|
try:
|
|
1060
1101
|
body = await request.json()
|
|
@@ -1066,50 +1107,26 @@ async def merge_memory(request: Request, fact_id: str):
|
|
|
1066
1107
|
raise HTTPException(400, "'into' exceeds 200-char limit")
|
|
1067
1108
|
if kept == fact_id:
|
|
1068
1109
|
raise HTTPException(400, "Cannot merge a fact into itself")
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
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
|
-
)
|
|
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),
|
|
1116
|
+
)
|
|
1117
|
+
if not result.get("ok"):
|
|
1118
|
+
raise HTTPException(404, "Both fact_ids must exist in the active profile")
|
|
1102
1119
|
engine._hooks.run_post("delete", hook_context)
|
|
1103
1120
|
return {
|
|
1104
1121
|
"success": True,
|
|
1105
1122
|
"merged": fact_id,
|
|
1106
1123
|
"into": kept,
|
|
1107
|
-
"merged_at": merged_at,
|
|
1124
|
+
"merged_at": result["merged_at"],
|
|
1108
1125
|
}
|
|
1109
1126
|
except HTTPException:
|
|
1110
1127
|
raise
|
|
1111
|
-
except Exception:
|
|
1112
|
-
raise
|
|
1128
|
+
except Exception as exc:
|
|
1129
|
+
raise _canonical_mutation_error(exc, "Merge error")
|
|
1113
1130
|
|
|
1114
1131
|
|
|
1115
1132
|
@router.patch("/api/memories/{fact_id}")
|
|
@@ -1135,14 +1152,16 @@ async def edit_memory(request: Request, fact_id: str):
|
|
|
1135
1152
|
new_content,
|
|
1136
1153
|
trusted_actor_id=hook_context["agent_id"],
|
|
1137
1154
|
source_agent_id="dashboard",
|
|
1155
|
+
canonical_runtime=_canonical_mutation_runtime(request),
|
|
1156
|
+
idempotency_key=_mutation_idempotency_key(request),
|
|
1138
1157
|
)
|
|
1139
1158
|
if not result.get("ok"):
|
|
1140
1159
|
raise HTTPException(status_code=404, detail="Memory not found")
|
|
1141
1160
|
return {"success": True, "fact_id": fact_id, "content": new_content}
|
|
1142
1161
|
except HTTPException:
|
|
1143
1162
|
raise
|
|
1144
|
-
except Exception:
|
|
1145
|
-
raise
|
|
1163
|
+
except Exception as exc:
|
|
1164
|
+
raise _canonical_mutation_error(exc, "Edit error")
|
|
1146
1165
|
|
|
1147
1166
|
|
|
1148
1167
|
_VALID_SCOPES = ("personal", "shared", "global")
|
|
@@ -1158,7 +1177,6 @@ async def set_memory_scope(request: Request, fact_id: str):
|
|
|
1158
1177
|
belong to the active profile (a caller cannot re-scope another profile's
|
|
1159
1178
|
fact). This is the write side of multi-scope sharing from the dashboard.
|
|
1160
1179
|
"""
|
|
1161
|
-
import json as _json
|
|
1162
1180
|
try:
|
|
1163
1181
|
body = await request.json()
|
|
1164
1182
|
scope = (body.get("scope") or "").strip().lower()
|
|
@@ -1179,26 +1197,30 @@ async def set_memory_scope(request: Request, fact_id: str):
|
|
|
1179
1197
|
if scope != "shared":
|
|
1180
1198
|
shared_list = []
|
|
1181
1199
|
|
|
1182
|
-
engine, active_profile,
|
|
1200
|
+
engine, active_profile, hook_context = _authorize_memory_mutation(
|
|
1183
1201
|
request, "update", fact_id, run_pre_hook=False,
|
|
1184
1202
|
)
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
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),
|
|
1189
1215
|
)
|
|
1190
|
-
if not
|
|
1216
|
+
if not result.get("ok"):
|
|
1191
1217
|
raise HTTPException(404, detail="Memory not found in this profile")
|
|
1192
|
-
|
|
1193
|
-
engine._db.update_fact(fact_id, {
|
|
1194
|
-
"scope": scope,
|
|
1195
|
-
"shared_with": _json.dumps(shared_list),
|
|
1196
|
-
}, profile_id=active_profile)
|
|
1218
|
+
engine._hooks.run_post("update", hook_context)
|
|
1197
1219
|
return {
|
|
1198
1220
|
"success": True, "fact_id": fact_id, "scope": scope,
|
|
1199
1221
|
"shared_with": shared_list, "active_profile": active_profile,
|
|
1200
1222
|
}
|
|
1201
1223
|
except HTTPException:
|
|
1202
1224
|
raise
|
|
1203
|
-
except Exception:
|
|
1204
|
-
raise
|
|
1225
|
+
except Exception as exc:
|
|
1226
|
+
raise _canonical_mutation_error(exc, "Scope update error")
|
|
@@ -10,7 +10,6 @@ All connections use WAL mode + busy_timeout for concurrency safety.
|
|
|
10
10
|
"""
|
|
11
11
|
|
|
12
12
|
import logging
|
|
13
|
-
import os
|
|
14
13
|
import re
|
|
15
14
|
import sqlite3
|
|
16
15
|
from contextlib import contextmanager
|
|
@@ -20,7 +19,7 @@ from fastapi import APIRouter, HTTPException, Request
|
|
|
20
19
|
from pydantic import BaseModel, Field
|
|
21
20
|
|
|
22
21
|
from superlocalmemory.server.route_mutations import authorize_route_mutation
|
|
23
|
-
from superlocalmemory.storage.memory_write import memory_write
|
|
22
|
+
from superlocalmemory.storage.memory_write import memory_read, memory_write
|
|
24
23
|
|
|
25
24
|
from .helpers import DB_PATH, get_active_profile
|
|
26
25
|
|
|
@@ -36,29 +35,11 @@ class PinRequest(BaseModel):
|
|
|
36
35
|
reason: str = Field(default="", max_length=_MAX_REASON_LENGTH)
|
|
37
36
|
|
|
38
37
|
|
|
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
|
-
|
|
46
38
|
@contextmanager
|
|
47
39
|
def _db():
|
|
48
|
-
"""
|
|
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)
|
|
55
|
-
conn.execute("PRAGMA journal_mode=WAL")
|
|
56
|
-
conn.execute(f"PRAGMA busy_timeout={ms}")
|
|
57
|
-
conn.row_factory = sqlite3.Row
|
|
58
|
-
try:
|
|
40
|
+
"""Yield a short-lived query-only snapshot for tier dashboard reads."""
|
|
41
|
+
with memory_read(DB_PATH) as conn:
|
|
59
42
|
yield conn
|
|
60
|
-
finally:
|
|
61
|
-
conn.close()
|
|
62
43
|
|
|
63
44
|
|
|
64
45
|
def _validate_profile(profile_id: str) -> str:
|
|
@@ -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]
|