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
|
@@ -72,6 +72,22 @@ from superlocalmemory.learning.source_quality import (
|
|
|
72
72
|
repair_historical_source_quality,
|
|
73
73
|
)
|
|
74
74
|
|
|
75
|
+
# D-02 fix: import at module load so the reference survives interpreter teardown.
|
|
76
|
+
# A lazy import inside the shutdown path races the namespace-cleanup phase on
|
|
77
|
+
# CPython and can raise ImportError: "cannot import name 'trigram_index'".
|
|
78
|
+
try:
|
|
79
|
+
from superlocalmemory.learning import trigram_index as _trigram_index_mod
|
|
80
|
+
except ImportError: # pragma: no cover — defensive for stripped installs
|
|
81
|
+
_trigram_index_mod = None # type: ignore[assignment]
|
|
82
|
+
|
|
83
|
+
# D-03 fix: move this import to module top so the already-compiled .pyc object
|
|
84
|
+
# is used at shutdown time. A lazy import of a source .py during interpreter
|
|
85
|
+
# shutdown on macOS triggers TCC xattr checks and can raise PermissionError.
|
|
86
|
+
try:
|
|
87
|
+
from superlocalmemory.hooks._outcome_common import _perf_log_flush as _perf_log_flush_fn
|
|
88
|
+
except ImportError: # pragma: no cover — defensive for stripped installs
|
|
89
|
+
_perf_log_flush_fn = None # type: ignore[assignment]
|
|
90
|
+
|
|
75
91
|
logger = logging.getLogger("superlocalmemory.unified_daemon")
|
|
76
92
|
|
|
77
93
|
_DEFAULT_PORT = 8765
|
|
@@ -801,6 +817,29 @@ def _warm_spreading_activation(engine, runtime) -> bool:
|
|
|
801
817
|
sa.search(query_embedding, profile_id=active_pid, top_k=7)
|
|
802
818
|
else:
|
|
803
819
|
sa.search(query_embedding, profile_id=active_pid, top_k=7)
|
|
820
|
+
# v3.8.5: pre-load the per-profile graph-metrics cache (PageRank +
|
|
821
|
+
# community for every fact) here in the background, so the FIRST real
|
|
822
|
+
# recall does not pay the ~12k-row load on its hot path. Observed as a
|
|
823
|
+
# large chunk of the 8-13s "cold first query" spike: the metrics cache
|
|
824
|
+
# was loading lazily during the user's first recall instead of at boot.
|
|
825
|
+
try:
|
|
826
|
+
sa._load_graph_metrics_cache(active_pid)
|
|
827
|
+
except Exception:
|
|
828
|
+
pass
|
|
829
|
+
# v3.8.5: warm the EntityGraphChannel's in-memory adjacency cache (ALL
|
|
830
|
+
# ~208K edges + entity maps + graph metrics, ~18 MB) at boot. This was
|
|
831
|
+
# the dominant cause of the "cold first query" spike (8-13s): the cache
|
|
832
|
+
# loaded lazily on the FIRST real recall that routed to the entity
|
|
833
|
+
# channel, not during warmup. Force it here via the channel's own
|
|
834
|
+
# search entry so it is warm before any user query.
|
|
835
|
+
try:
|
|
836
|
+
entity_channel = getattr(retr, "_entity", None)
|
|
837
|
+
if entity_channel is not None:
|
|
838
|
+
entity_channel.search(
|
|
839
|
+
"memory graph adjacency warmup", active_pid, top_k=5,
|
|
840
|
+
)
|
|
841
|
+
except Exception:
|
|
842
|
+
pass
|
|
804
843
|
logger.info(
|
|
805
844
|
"Spreading-activation graph pre-warmed for profile %s", active_pid,
|
|
806
845
|
)
|
|
@@ -1362,11 +1401,15 @@ async def lifespan(application: FastAPI):
|
|
|
1362
1401
|
# full sort. Without them full recall takes 7-10s on
|
|
1363
1402
|
# >1M edges (the SpreadingActivation 4-UNION query disk-sorts every
|
|
1364
1403
|
# node's neighbor list on each call). With them: sub-second.
|
|
1404
|
+
#
|
|
1405
|
+
# Concurrency fix (v3.8.4): wrapped in memory_write() so the process
|
|
1406
|
+
# write lock is acquired before the connection is opened. Previously
|
|
1407
|
+
# the raw sqlite3.connect() bypassed get_write_lock() and had no
|
|
1408
|
+
# busy_timeout, which risked SQLITE_BUSY at daemon startup when other
|
|
1409
|
+
# writers (hook / CLI) were already active.
|
|
1365
1410
|
try:
|
|
1366
|
-
import
|
|
1367
|
-
|
|
1368
|
-
try:
|
|
1369
|
-
_idx_conn.execute("PRAGMA journal_mode=WAL")
|
|
1411
|
+
from superlocalmemory.storage.memory_write import memory_write as _mw
|
|
1412
|
+
with _mw(_memory_db) as _idx_conn:
|
|
1370
1413
|
_idx_conn.execute(
|
|
1371
1414
|
"CREATE INDEX IF NOT EXISTS idx_edges_source_weight "
|
|
1372
1415
|
"ON graph_edges(profile_id, source_id, weight DESC)"
|
|
@@ -1383,10 +1426,6 @@ async def lifespan(application: FastAPI):
|
|
|
1383
1426
|
"CREATE INDEX IF NOT EXISTS idx_assoc_target_weight "
|
|
1384
1427
|
"ON association_edges(profile_id, target_fact_id, weight DESC)"
|
|
1385
1428
|
)
|
|
1386
|
-
finally:
|
|
1387
|
-
# CP-09: close even if an execute() raises, so the connection
|
|
1388
|
-
# (and its file handle / shared DB lock) never leaks.
|
|
1389
|
-
_idx_conn.close()
|
|
1390
1429
|
except Exception as _idx_exc:
|
|
1391
1430
|
logger.debug("SpreadingActivation covering indexes skipped: %s", _idx_exc)
|
|
1392
1431
|
|
|
@@ -1413,14 +1452,36 @@ async def lifespan(application: FastAPI):
|
|
|
1413
1452
|
_embedding_warm = False
|
|
1414
1453
|
def _warmup_embedder():
|
|
1415
1454
|
global _embedding_warm
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1455
|
+
import time as _t
|
|
1456
|
+
# RETRY: the retrieval engine / embedder can be created lazily a
|
|
1457
|
+
# moment AFTER this thread starts. The old one-shot attempt often
|
|
1458
|
+
# captured a None embedder and left _embedding_warm stuck False
|
|
1459
|
+
# forever — so /health reported not-ready even though on-demand
|
|
1460
|
+
# embeds worked. A stuck not-ready can make an auto-start hook
|
|
1461
|
+
# believe the daemon is down and spawn a DUPLICATE daemon (two
|
|
1462
|
+
# processes writing memory.db → cross-process SQLITE_BUSY). Poll
|
|
1463
|
+
# until the real embedder (retrieval_eng._embedder, the same one
|
|
1464
|
+
# recall uses) is available, warm it, and flip the flag.
|
|
1465
|
+
for _attempt in range(240): # ~120s max at 0.5s steps
|
|
1466
|
+
try:
|
|
1467
|
+
_re = retrieval_eng or getattr(engine, '_retrieval_engine', None)
|
|
1468
|
+
embedder = getattr(_re, '_embedder', None) if _re else None
|
|
1469
|
+
if embedder is not None and hasattr(embedder, 'embed'):
|
|
1470
|
+
embedder.embed("warmup")
|
|
1471
|
+
_embedding_warm = True
|
|
1472
|
+
logger.info(
|
|
1473
|
+
"Embedding worker pre-warmed (model resident, "
|
|
1474
|
+
"keep_alive=-1)"
|
|
1475
|
+
)
|
|
1476
|
+
return
|
|
1477
|
+
except Exception as exc:
|
|
1478
|
+
logger.debug("Embedding warmup attempt %d failed: %s",
|
|
1479
|
+
_attempt, exc)
|
|
1480
|
+
_t.sleep(0.5)
|
|
1481
|
+
logger.warning(
|
|
1482
|
+
"Embedding warmup did not complete after retries; /health "
|
|
1483
|
+
"may report not-ready even though on-demand embeds work"
|
|
1484
|
+
)
|
|
1424
1485
|
|
|
1425
1486
|
def _warmup_recall():
|
|
1426
1487
|
"""v3.4.62: Fire a full recall after embedding warms up.
|
|
@@ -1462,6 +1523,20 @@ async def lifespan(application: FastAPI):
|
|
|
1462
1523
|
# v3.8: the --fast recalls above skip spreading activation; warm
|
|
1463
1524
|
# that channel directly so the first FULL recall is not cold.
|
|
1464
1525
|
_warm_spreading_activation(engine, profile_runtime)
|
|
1526
|
+
# v3.8.5: the fast recalls above do NOT exercise the full ranking
|
|
1527
|
+
# path or the agentic round, so the FIRST real user query that
|
|
1528
|
+
# takes the full path paid an 8-13s cold cost (ranking model +
|
|
1529
|
+
# graph-metrics load). Fire ONE full (fast=False) recall here in
|
|
1530
|
+
# the background so those load at boot, not on a user's query.
|
|
1531
|
+
# Best-effort; never blocks readiness.
|
|
1532
|
+
try:
|
|
1533
|
+
with profile_runtime.operation_nowait() as _fsnap:
|
|
1534
|
+
if _fsnap is not None:
|
|
1535
|
+
engine.recall(
|
|
1536
|
+
"memory recall performance", limit=5, fast=False,
|
|
1537
|
+
)
|
|
1538
|
+
except Exception as _fexc:
|
|
1539
|
+
logger.debug("Full-path warmup skipped (non-fatal): %s", _fexc)
|
|
1465
1540
|
elapsed = round((_t.monotonic() - t0) * 1000)
|
|
1466
1541
|
logger.info(
|
|
1467
1542
|
"Recall engine pre-warmed in %dms", elapsed,
|
|
@@ -1479,6 +1554,11 @@ async def lifespan(application: FastAPI):
|
|
|
1479
1554
|
"""
|
|
1480
1555
|
import time as _t
|
|
1481
1556
|
from pathlib import Path as _P
|
|
1557
|
+
if os.environ.get("SLM_DISABLE_VS_BACKFILL") == "1":
|
|
1558
|
+
logger.info(
|
|
1559
|
+
"VS backfill disabled via SLM_DISABLE_VS_BACKFILL=1"
|
|
1560
|
+
)
|
|
1561
|
+
return
|
|
1482
1562
|
for _ in range(120):
|
|
1483
1563
|
if _embedding_warm:
|
|
1484
1564
|
break
|
|
@@ -1510,7 +1590,42 @@ async def lifespan(application: FastAPI):
|
|
|
1510
1590
|
continue
|
|
1511
1591
|
if vs.count(pid) >= int(len(with_emb) * 0.98):
|
|
1512
1592
|
continue # already complete — no-op
|
|
1513
|
-
|
|
1593
|
+
# Fix: route each upsert through db._lock with cooperative
|
|
1594
|
+
# yield between facts. Previously called
|
|
1595
|
+
# vs.rebuild_from_facts() which opens its own sqlite3
|
|
1596
|
+
# connection (bypassing db._lock), causing concurrent
|
|
1597
|
+
# backfill_missing_embeddings writes to hit SQLITE_BUSY
|
|
1598
|
+
# while holding db._lock — starving user writes for ~50 s.
|
|
1599
|
+
import os as _selfheal_os
|
|
1600
|
+
# Stage 2 (write-queue plan): batch upserts so the write lock
|
|
1601
|
+
# is acquired a FEW times with a clear release window (pause)
|
|
1602
|
+
# between batches — instead of thousands of rapid
|
|
1603
|
+
# re-acquisitions. threading.RLock is NOT fair, so on a
|
|
1604
|
+
# fresh boot a waiting user /remember could be starved for
|
|
1605
|
+
# ~50 s behind the per-fact churn. A real pause between
|
|
1606
|
+
# bounded batches guarantees the waiting user write wins the
|
|
1607
|
+
# lock promptly. One-time backfill; steady state no-ops.
|
|
1608
|
+
_batch = max(1, int(
|
|
1609
|
+
_selfheal_os.environ.get("SLM_SELFHEAL_BATCH", "50")))
|
|
1610
|
+
_pause = max(0.0, float(
|
|
1611
|
+
_selfheal_os.environ.get("SLM_SELFHEAL_BATCH_PAUSE_S", "0.05")))
|
|
1612
|
+
n = 0
|
|
1613
|
+
for _i in range(0, len(with_emb), _batch):
|
|
1614
|
+
_chunk = with_emb[_i:_i + _batch]
|
|
1615
|
+
with db._lock:
|
|
1616
|
+
for _fact_id, _profile_id, _embedding in _chunk:
|
|
1617
|
+
try:
|
|
1618
|
+
if vs.upsert(_fact_id, _profile_id, _embedding):
|
|
1619
|
+
n += 1
|
|
1620
|
+
except Exception as _upsert_exc:
|
|
1621
|
+
logger.warning(
|
|
1622
|
+
"VS backfill[%s]: upsert failed for %s: %s",
|
|
1623
|
+
pid, str(_fact_id)[:16], _upsert_exc,
|
|
1624
|
+
)
|
|
1625
|
+
# Release window: let any waiting user write through
|
|
1626
|
+
# before grabbing the lock again.
|
|
1627
|
+
if _pause > 0:
|
|
1628
|
+
_t.sleep(_pause)
|
|
1514
1629
|
logger.info(
|
|
1515
1630
|
"VS backfill[%s]: indexed %d of %d embedded facts",
|
|
1516
1631
|
pid, n, len(with_emb),
|
|
@@ -2156,18 +2271,23 @@ async def lifespan(application: FastAPI):
|
|
|
2156
2271
|
logger.warning("evolution cost-conn cache close failed: %s", exc)
|
|
2157
2272
|
|
|
2158
2273
|
# Drop the trigram cache conn symmetrically.
|
|
2274
|
+
# D-02: use module-level reference (_trigram_index_mod) to avoid re-importing
|
|
2275
|
+
# during interpreter teardown when the learning namespace may be gone.
|
|
2159
2276
|
try:
|
|
2160
|
-
|
|
2161
|
-
|
|
2277
|
+
if _trigram_index_mod is not None:
|
|
2278
|
+
_trigram_index_mod._reset_cache_conn()
|
|
2162
2279
|
except Exception as exc: # pragma: no cover — defensive
|
|
2163
2280
|
logger.warning("trigram cache conn close failed: %s", exc)
|
|
2164
2281
|
|
|
2165
2282
|
# Flush the perf-log fd explicitly (the atexit hook still fires
|
|
2166
2283
|
# but explicit close here is cheap insurance against uvicorn
|
|
2167
2284
|
# killing the process before atexit runs).
|
|
2285
|
+
# D-03: use module-level reference (_perf_log_flush_fn) — a lazy import here
|
|
2286
|
+
# triggers macOS TCC xattr checks on the source .py at shutdown time, causing
|
|
2287
|
+
# PermissionError: "Operation not permitted: .../hooks/_outcome_common.py".
|
|
2168
2288
|
try:
|
|
2169
|
-
|
|
2170
|
-
|
|
2289
|
+
if _perf_log_flush_fn is not None:
|
|
2290
|
+
_perf_log_flush_fn()
|
|
2171
2291
|
except Exception as exc: # pragma: no cover — defensive
|
|
2172
2292
|
logger.warning("perf_log flush failed: %s", exc)
|
|
2173
2293
|
|
|
@@ -2473,7 +2593,33 @@ def _register_dashboard_routes(application: FastAPI) -> None:
|
|
|
2473
2593
|
|
|
2474
2594
|
Extracted from api.py's create_app() to avoid duplicate MemoryEngine.
|
|
2475
2595
|
"""
|
|
2476
|
-
from superlocalmemory.server.api import UI_DIR
|
|
2596
|
+
from superlocalmemory.server.api import UI_DIR as _source_ui_dir
|
|
2597
|
+
|
|
2598
|
+
# D-04: Copy UI assets to the data dir at daemon startup to avoid macOS
|
|
2599
|
+
# xattr/TCC PermissionError on source-tree files in editable installs.
|
|
2600
|
+
# The data dir has no quarantine attributes; the copy is idempotent (same
|
|
2601
|
+
# content from the same package) so concurrent daemon starts are safe.
|
|
2602
|
+
# Falls back to the source path with a WARNING — never crashes the daemon.
|
|
2603
|
+
import shutil as _shutil
|
|
2604
|
+
_data_ui_dir = state_path("ui")
|
|
2605
|
+
try:
|
|
2606
|
+
_data_ui_dir.mkdir(parents=True, exist_ok=True)
|
|
2607
|
+
if _source_ui_dir.is_dir():
|
|
2608
|
+
_shutil.copytree(
|
|
2609
|
+
str(_source_ui_dir),
|
|
2610
|
+
str(_data_ui_dir),
|
|
2611
|
+
dirs_exist_ok=True, # idempotent; safe under concurrent starts
|
|
2612
|
+
)
|
|
2613
|
+
UI_DIR = _data_ui_dir
|
|
2614
|
+
except Exception as _ui_copy_exc:
|
|
2615
|
+
# D-04 fallback: source path. Logged as WARNING (not DEBUG) so operators
|
|
2616
|
+
# can diagnose PermissionError on editable installs. Never silently hidden.
|
|
2617
|
+
logger.warning(
|
|
2618
|
+
"D-04: UI copy to data dir failed; serving from source path %s: %s",
|
|
2619
|
+
_source_ui_dir,
|
|
2620
|
+
_ui_copy_exc,
|
|
2621
|
+
)
|
|
2622
|
+
UI_DIR = _source_ui_dir
|
|
2477
2623
|
|
|
2478
2624
|
# Rate limiting (graceful)
|
|
2479
2625
|
try:
|
|
@@ -2523,7 +2669,8 @@ def _register_dashboard_routes(application: FastAPI) -> None:
|
|
|
2523
2669
|
async def rate_limit_middleware(request, call_next):
|
|
2524
2670
|
client_ip = request.client.host if request.client else "unknown"
|
|
2525
2671
|
is_write = request.method in ("POST", "PUT", "DELETE", "PATCH")
|
|
2526
|
-
loopback
|
|
2672
|
+
from superlocalmemory.server.loopback import is_loopback as _is_lb_rl
|
|
2673
|
+
loopback = _is_lb_rl(client_ip)
|
|
2527
2674
|
if not loopback and is_rate_limit_exempt(client_ip):
|
|
2528
2675
|
return await call_next(request)
|
|
2529
2676
|
if loopback:
|
|
@@ -2715,10 +2862,17 @@ def _register_dashboard_routes(application: FastAPI) -> None:
|
|
|
2715
2862
|
"fallback; non-loopback writes will be rejected.", _auth_exc,
|
|
2716
2863
|
)
|
|
2717
2864
|
try:
|
|
2718
|
-
from superlocalmemory.
|
|
2865
|
+
from superlocalmemory.server.loopback import is_loopback as _is_lb
|
|
2719
2866
|
except Exception:
|
|
2720
|
-
|
|
2721
|
-
|
|
2867
|
+
import ipaddress as _ipa_fb
|
|
2868
|
+
|
|
2869
|
+
def _is_lb(h: str) -> bool: # type: ignore[misc]
|
|
2870
|
+
if not h or h.lower() == "localhost":
|
|
2871
|
+
return bool(h)
|
|
2872
|
+
try:
|
|
2873
|
+
return _ipa_fb.ip_address(h).is_loopback
|
|
2874
|
+
except ValueError:
|
|
2875
|
+
return False
|
|
2722
2876
|
|
|
2723
2877
|
@application.middleware("http")
|
|
2724
2878
|
async def _failclosed_auth(request, call_next):
|
|
@@ -2734,9 +2888,14 @@ def _register_dashboard_routes(application: FastAPI) -> None:
|
|
|
2734
2888
|
)
|
|
2735
2889
|
return await call_next(request)
|
|
2736
2890
|
|
|
2737
|
-
# Static files
|
|
2891
|
+
# Static files — UI_DIR is already the effective path (data-dir or source
|
|
2892
|
+
# fallback) set by the D-04 copy block above; mkdir is a no-op for
|
|
2893
|
+
# the data-dir path (already created) and guarded in the source-fallback case.
|
|
2738
2894
|
from fastapi.staticfiles import StaticFiles
|
|
2739
|
-
|
|
2895
|
+
try:
|
|
2896
|
+
UI_DIR.mkdir(parents=True, exist_ok=True)
|
|
2897
|
+
except Exception:
|
|
2898
|
+
pass # source path may not be writable; StaticFiles reads, not writes
|
|
2740
2899
|
application.mount("/static", StaticFiles(directory=str(UI_DIR)), name="static")
|
|
2741
2900
|
|
|
2742
2901
|
# Route modules
|
|
@@ -2975,9 +3134,14 @@ def _register_daemon_routes(application: FastAPI) -> None:
|
|
|
2975
3134
|
}
|
|
2976
3135
|
# request is None only for direct internal/test calls (no HTTP client),
|
|
2977
3136
|
# which are trusted; over HTTP FastAPI always injects the real Request.
|
|
3137
|
+
from superlocalmemory.server.loopback import is_loopback as _is_loopback_host
|
|
3138
|
+
from superlocalmemory.server.write_identity import _TEST_ISOLATION_ALLOWED
|
|
3139
|
+
|
|
2978
3140
|
client_host = request.client.host if (request and request.client) else ""
|
|
2979
|
-
_trusted =
|
|
2980
|
-
|
|
3141
|
+
_trusted = (
|
|
3142
|
+
request is None
|
|
3143
|
+
or _is_loopback_host(client_host)
|
|
3144
|
+
or (client_host == "testclient" and _TEST_ISOLATION_ALLOWED)
|
|
2981
3145
|
)
|
|
2982
3146
|
if not _trusted:
|
|
2983
3147
|
return public
|
|
@@ -4007,7 +4171,12 @@ def start_server(port: int = _DEFAULT_PORT) -> None:
|
|
|
4007
4171
|
"reachable from the network but SLM_REQUIRE_CREDENTIALS is not set "
|
|
4008
4172
|
"and API-key auth may be off. A remote caller could write without "
|
|
4009
4173
|
"credentials. Set SLM_REQUIRE_CREDENTIALS=1 and configure an API "
|
|
4010
|
-
"key before exposing this instance."
|
|
4174
|
+
"key before exposing this instance. "
|
|
4175
|
+
"NOTE (issue #90): SLM 3.8.4 fixes IPv4-mapped loopback address "
|
|
4176
|
+
"normalization — if curl/dashboard writes fail with 403 from a "
|
|
4177
|
+
"container, upgrade to 3.8.4. Immediate workaround: set "
|
|
4178
|
+
"SLM_DAEMON_HOST=127.0.0.1 (IPv4 only) or configure an API key.",
|
|
4179
|
+
bind_host,
|
|
4011
4180
|
)
|
|
4012
4181
|
listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
4013
4182
|
# This handles a just-closed connection in TIME_WAIT. It is safe only
|
|
@@ -18,6 +18,8 @@ from typing import Any
|
|
|
18
18
|
|
|
19
19
|
from fastapi import HTTPException, Request
|
|
20
20
|
|
|
21
|
+
from superlocalmemory.server.loopback import is_loopback as _is_loopback_host
|
|
22
|
+
|
|
21
23
|
|
|
22
24
|
# H-04 (3.7.9): the test-client auth bypass must be impossible in a real daemon
|
|
23
25
|
# even if SLM_TEST_ISOLATION=1 leaks into a production environment. Anchor it to
|
|
@@ -89,7 +91,7 @@ def require_write_actor(
|
|
|
89
91
|
# missing peer address (e.g. behind a proxy that strips it) must not be
|
|
90
92
|
# trusted just because an install token is presented. require_http_mutation_actor
|
|
91
93
|
# already excludes "" from its loopback set; keep the two paths consistent.
|
|
92
|
-
if is_test_client or client_host
|
|
94
|
+
if is_test_client or _is_loopback_host(client_host):
|
|
93
95
|
from superlocalmemory.core.engine_ingestion import local_trusted_actor_id
|
|
94
96
|
|
|
95
97
|
return local_trusted_actor_id(actor_kind)
|
|
@@ -103,7 +105,15 @@ def require_write_actor(
|
|
|
103
105
|
).hexdigest()
|
|
104
106
|
return f"api-key:{actor_kind}:{fingerprint}"
|
|
105
107
|
|
|
106
|
-
raise HTTPException(
|
|
108
|
+
raise HTTPException(
|
|
109
|
+
403,
|
|
110
|
+
detail=(
|
|
111
|
+
"Write rejected: this origin requires an API key (X-SLM-API-Key). "
|
|
112
|
+
"The install token (X-Install-Token) is accepted only from loopback "
|
|
113
|
+
"(127.x.x.x / ::1 / localhost). If calling from a container or "
|
|
114
|
+
"remote host, configure SLM_API_KEY and present it as X-SLM-API-Key."
|
|
115
|
+
),
|
|
116
|
+
)
|
|
107
117
|
|
|
108
118
|
|
|
109
119
|
def require_http_mutation_actor(
|
|
@@ -134,7 +144,7 @@ def require_http_mutation_actor(
|
|
|
134
144
|
|
|
135
145
|
client_host = request.client.host if request.client else ""
|
|
136
146
|
is_test_client = client_host == "testclient" and _TEST_ISOLATION_ALLOWED
|
|
137
|
-
loopback = client_host
|
|
147
|
+
loopback = _is_loopback_host(client_host)
|
|
138
148
|
# H-01: the loopback trusted-actor bypass is suppressed when the operator
|
|
139
149
|
# opts into SLM_REQUIRE_CREDENTIALS; in-process tests keep the bypass.
|
|
140
150
|
if is_test_client or (loopback and not _REQUIRE_CREDENTIALS):
|
|
@@ -153,7 +163,15 @@ def require_http_mutation_actor(
|
|
|
153
163
|
).hexdigest()
|
|
154
164
|
return f"mesh-secret:{fingerprint}"
|
|
155
165
|
|
|
156
|
-
raise HTTPException(
|
|
166
|
+
raise HTTPException(
|
|
167
|
+
403,
|
|
168
|
+
detail=(
|
|
169
|
+
"Mutation rejected: present one of X-SLM-Daemon-Capability, "
|
|
170
|
+
"X-Install-Token (loopback only), or X-SLM-API-Key. "
|
|
171
|
+
"Uncredentialed writes are accepted only from loopback. "
|
|
172
|
+
"See SLM_API_KEY in the deployment guide for network access."
|
|
173
|
+
),
|
|
174
|
+
)
|
|
157
175
|
|
|
158
176
|
|
|
159
177
|
def authenticated_request_actor(
|
|
@@ -18,6 +18,7 @@ from pathlib import Path
|
|
|
18
18
|
from types import ModuleType
|
|
19
19
|
from typing import Any, Generator
|
|
20
20
|
|
|
21
|
+
from superlocalmemory.storage.write_lock import get_write_lock
|
|
21
22
|
from superlocalmemory.storage.models import (
|
|
22
23
|
AtomicFact, CanonicalEntity, ConsolidationAction, ConsolidationActionType,
|
|
23
24
|
EdgeType, EntityAlias, EntityProfile, FactType, GraphEdge,
|
|
@@ -140,7 +141,17 @@ class DatabaseManager:
|
|
|
140
141
|
def __init__(self, db_path: str | Path) -> None:
|
|
141
142
|
self.db_path = Path(db_path)
|
|
142
143
|
self.db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
143
|
-
|
|
144
|
+
# Shared write-serialisation lock for this db_path.
|
|
145
|
+
# get_write_lock() returns the SAME RLock for every caller that
|
|
146
|
+
# passes the same resolved path, so DatabaseManager, VectorStore,
|
|
147
|
+
# adapter_base, consolidation, and all other in-process writers
|
|
148
|
+
# all share ONE lock → zero cross-connection SQLite WAL contention.
|
|
149
|
+
#
|
|
150
|
+
# RLock (re-entrant) is required: the self-heal backfill pattern
|
|
151
|
+
# with db._lock: # acquires write lock (count: 1→2)
|
|
152
|
+
# vs.upsert(...) # re-acquires same lock (count: 2→3)
|
|
153
|
+
# is safe because the same thread re-enters the RLock.
|
|
154
|
+
self._lock = get_write_lock(self.db_path)
|
|
144
155
|
# Transaction connections are thread-affine in sqlite3. A manager is
|
|
145
156
|
# shared across HTTP, materializer, and worker threads, so a process-
|
|
146
157
|
# global connection slot lets another thread accidentally execute on
|
|
@@ -155,6 +166,20 @@ class DatabaseManager:
|
|
|
155
166
|
conn.execute(f"PRAGMA busy_timeout={_BUSY_TIMEOUT_MS}") # FIRST — so WAL pragma below uses configured timeout
|
|
156
167
|
conn.execute("PRAGMA journal_mode=WAL")
|
|
157
168
|
conn.execute("PRAGMA foreign_keys=ON")
|
|
169
|
+
# Fix D: synchronous=NORMAL is safe under WAL — the WAL ensures
|
|
170
|
+
# atomicity independently of the fsync level. Removing the
|
|
171
|
+
# fsync-on-every-commit penalty halves write latency under the
|
|
172
|
+
# 14 000+ edge-write bursts the materialiser produces per pass.
|
|
173
|
+
# Trade-off: on a power loss between COMMIT and WAL-checkpoint
|
|
174
|
+
# the last committed write to memory.db may be lost. Acceptable
|
|
175
|
+
# for an LLM memory system; NOT acceptable for financial records.
|
|
176
|
+
conn.execute("PRAGMA synchronous=NORMAL")
|
|
177
|
+
# Fix D: reduce WAL auto-checkpoint from the default 1000 to 400
|
|
178
|
+
# frames. Smaller checkpoints run more frequently and complete
|
|
179
|
+
# faster, preventing the WAL file growing unboundedly during
|
|
180
|
+
# high-ingestion bursts (which triggered checkpoint-starvation
|
|
181
|
+
# amplifying the lock storm).
|
|
182
|
+
conn.execute("PRAGMA wal_autocheckpoint=400")
|
|
158
183
|
conn.commit()
|
|
159
184
|
finally:
|
|
160
185
|
conn.close()
|
|
@@ -231,15 +256,21 @@ class DatabaseManager:
|
|
|
231
256
|
self._txn_state.conn = None
|
|
232
257
|
conn.close()
|
|
233
258
|
|
|
234
|
-
|
|
235
|
-
|
|
259
|
+
# DML prefixes that require the single-writer lock when executed outside
|
|
260
|
+
# a transaction() context. Checked case-insensitively against the first
|
|
261
|
+
# word of the stripped SQL statement.
|
|
262
|
+
_DML_PREFIXES: frozenset[str] = frozenset({
|
|
263
|
+
"INSERT", "UPDATE", "DELETE", "REPLACE", "UPSERT",
|
|
264
|
+
"CREATE", "DROP", "ALTER",
|
|
265
|
+
})
|
|
236
266
|
|
|
237
|
-
|
|
238
|
-
"""
|
|
239
|
-
transaction_conn = getattr(self._txn_state, "conn", None)
|
|
240
|
-
if transaction_conn is not None:
|
|
241
|
-
return transaction_conn.execute(sql, params).fetchall()
|
|
267
|
+
def _execute_one(self, sql: str, params: tuple[Any, ...]) -> list[sqlite3.Row]:
|
|
268
|
+
"""Open a per-call connection, execute, commit, close — with retry.
|
|
242
269
|
|
|
270
|
+
Never called when inside a transaction() context (that path uses the
|
|
271
|
+
context's existing connection directly). Factored out of execute() so
|
|
272
|
+
the RLock acquisition logic stays in one place.
|
|
273
|
+
"""
|
|
243
274
|
last_error: Exception | None = None
|
|
244
275
|
for attempt in range(_MAX_RETRIES):
|
|
245
276
|
conn = self._connect()
|
|
@@ -264,6 +295,42 @@ class DatabaseManager:
|
|
|
264
295
|
logger.warning("DB operation failed after %d retries: %s", _MAX_RETRIES, last_error)
|
|
265
296
|
raise last_error # type: ignore[misc]
|
|
266
297
|
|
|
298
|
+
def execute(self, sql: str, params: tuple[Any, ...] = ()) -> list[sqlite3.Row]:
|
|
299
|
+
"""Execute SQL with automatic retry on SQLITE_BUSY.
|
|
300
|
+
|
|
301
|
+
Fix B — single-writer serialisation:
|
|
302
|
+
• Inside a transaction(): uses the existing connection directly (no
|
|
303
|
+
lock acquisition — the transaction() context manager already holds
|
|
304
|
+
_lock for the duration of the whole transaction).
|
|
305
|
+
• Outside a transaction(), for DML (INSERT/UPDATE/DELETE/…): acquires
|
|
306
|
+
_lock before opening a per-call connection. This ensures that
|
|
307
|
+
concurrent callers — including background workers that bypass
|
|
308
|
+
transaction() entirely — do not race at the SQLite WAL layer.
|
|
309
|
+
• Outside a transaction(), for SELECTs: no lock needed — WAL mode
|
|
310
|
+
allows concurrent readers without stalling writers.
|
|
311
|
+
|
|
312
|
+
ORDERING INVARIANT: the _txn_state.conn check MUST come before the
|
|
313
|
+
lock acquisition. Reversing the order would deadlock threads that
|
|
314
|
+
call execute() from inside transaction() because threading.RLock is
|
|
315
|
+
re-entrant per-thread but a re-entering thread inside transaction()
|
|
316
|
+
would still try to re-acquire here (lock is already held by the same
|
|
317
|
+
thread, so RLock re-enters safely — but the old threading.Lock would
|
|
318
|
+
have deadlocked; that is exactly why we changed to RLock).
|
|
319
|
+
"""
|
|
320
|
+
# Fast path: already inside a transaction — use its connection directly.
|
|
321
|
+
transaction_conn = getattr(self._txn_state, "conn", None)
|
|
322
|
+
if transaction_conn is not None:
|
|
323
|
+
return transaction_conn.execute(sql, params).fetchall()
|
|
324
|
+
|
|
325
|
+
# Determine if this is a write operation that needs serialisation.
|
|
326
|
+
first_word = sql.strip().upper().split(None, 1)[0] if sql.strip() else ""
|
|
327
|
+
if first_word in self._DML_PREFIXES:
|
|
328
|
+
with self._lock:
|
|
329
|
+
return self._execute_one(sql, params)
|
|
330
|
+
else:
|
|
331
|
+
# Read-only path: concurrent reads are safe in WAL mode.
|
|
332
|
+
return self._execute_one(sql, params)
|
|
333
|
+
|
|
267
334
|
def store_memory(self, record: MemoryRecord) -> str:
|
|
268
335
|
"""Persist a raw memory record. Returns memory_id."""
|
|
269
336
|
_scope = getattr(record, 'scope', None) or 'personal'
|
|
@@ -1306,17 +1373,40 @@ class DatabaseManager:
|
|
|
1306
1373
|
)
|
|
1307
1374
|
return [dict(r) for r in rows]
|
|
1308
1375
|
|
|
1309
|
-
def cleanup_activation_cache(
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1376
|
+
def cleanup_activation_cache(
|
|
1377
|
+
self, batch_size: int = 5000, max_batches: int = 500,
|
|
1378
|
+
) -> int:
|
|
1379
|
+
"""Delete expired activation_cache rows in bounded batches.
|
|
1380
|
+
|
|
1381
|
+
Wired into MaintenanceScheduler. Historically NEITHER cleanup path was
|
|
1382
|
+
ever called, so activation_cache grew without bound — observed 83,518
|
|
1383
|
+
rows on a real DB, all expired, oldest ~3.5 months old. That bloats the
|
|
1384
|
+
table and its ``idx_actcache_expires`` index and slows every cache
|
|
1385
|
+
INSERT OR REPLACE / lookup.
|
|
1386
|
+
|
|
1387
|
+
Batched so clearing a large backlog never holds the write lock for one
|
|
1388
|
+
long DELETE: each batch commits and yields, letting remember/materialize
|
|
1389
|
+
writers interleave. ``idx_actcache_expires`` makes the predicate
|
|
1390
|
+
index-backed. Steady state (30-min cycle) deletes only one cycle's
|
|
1391
|
+
worth, so the loop exits after a single small batch.
|
|
1392
|
+
"""
|
|
1393
|
+
total_deleted = 0
|
|
1394
|
+
for _ in range(max_batches):
|
|
1395
|
+
remaining = self.execute(
|
|
1396
|
+
"SELECT COUNT(*) AS c FROM activation_cache "
|
|
1397
|
+
"WHERE expires_at < datetime('now')"
|
|
1398
|
+
)
|
|
1399
|
+
n = int(remaining[0]["c"]) if remaining else 0
|
|
1400
|
+
if n <= 0:
|
|
1401
|
+
break
|
|
1402
|
+
self.execute(
|
|
1403
|
+
"DELETE FROM activation_cache WHERE cache_id IN ("
|
|
1404
|
+
" SELECT cache_id FROM activation_cache "
|
|
1405
|
+
" WHERE expires_at < datetime('now') LIMIT ?)",
|
|
1406
|
+
(batch_size,),
|
|
1407
|
+
)
|
|
1408
|
+
total_deleted += min(n, batch_size)
|
|
1409
|
+
return total_deleted
|
|
1320
1410
|
|
|
1321
1411
|
def store_fact_importance(self, entry: dict) -> None:
|
|
1322
1412
|
"""Persist fact importance scores."""
|