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.
Files changed (93) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/README.md +3 -2
  3. package/package.json +1 -1
  4. package/plugin/.claude-plugin/plugin.json +1 -1
  5. package/plugin/CLAUDE.md +3 -3
  6. package/plugin/agents/slm-governance-advisor.md +1 -1
  7. package/plugin/agents/slm-loop-runner.md +1 -1
  8. package/plugin/agents/slm-memory-advisor.md +1 -1
  9. package/plugin/agents/slm-optimize-advisor.md +1 -1
  10. package/plugin/requirements.txt +1 -1
  11. package/plugin/skills/slm-cache/SKILL.md +1 -1
  12. package/plugin/skills/slm-compress/SKILL.md +1 -1
  13. package/plugin/skills/slm-governance/SKILL.md +1 -1
  14. package/plugin/skills/slm-graph/SKILL.md +1 -1
  15. package/plugin/skills/slm-loop/SKILL.md +1 -1
  16. package/plugin/skills/slm-mesh/SKILL.md +1 -1
  17. package/plugin/skills/slm-profile/SKILL.md +1 -1
  18. package/plugin/skills/slm-recall/SKILL.md +1 -1
  19. package/plugin/skills/slm-remember/SKILL.md +1 -1
  20. package/plugin/skills/slm-scope/SKILL.md +1 -1
  21. package/plugin/skills/slm-session/SKILL.md +1 -1
  22. package/plugin/skills/slm-status/SKILL.md +1 -1
  23. package/plugin-src/rules/AGENTS.md +1 -1
  24. package/plugin-src/skills/slm-cache/SKILL.md +1 -1
  25. package/plugin-src/skills/slm-compress/SKILL.md +1 -1
  26. package/plugin-src/skills/slm-graph/SKILL.md +1 -1
  27. package/plugin-src/skills/slm-recall/SKILL.md +1 -1
  28. package/plugin-src/skills/slm-remember/SKILL.md +1 -1
  29. package/plugin-src/skills/slm-session/SKILL.md +1 -1
  30. package/plugin-src/skills/slm-status/SKILL.md +1 -1
  31. package/pyproject.toml +1 -1
  32. package/src/superlocalmemory/__init__.py +1 -1
  33. package/src/superlocalmemory/access/rbac.py +68 -76
  34. package/src/superlocalmemory/cli/commands.py +19 -0
  35. package/src/superlocalmemory/cli/ingest_cmd.py +11 -1
  36. package/src/superlocalmemory/cli/main.py +30 -0
  37. package/src/superlocalmemory/cli/pending_store.py +39 -14
  38. package/src/superlocalmemory/core/backend_orchestrator.py +93 -0
  39. package/src/superlocalmemory/core/config.py +78 -0
  40. package/src/superlocalmemory/core/consolidation_engine.py +79 -73
  41. package/src/superlocalmemory/core/engine.py +92 -11
  42. package/src/superlocalmemory/core/fact_consolidator.py +148 -30
  43. package/src/superlocalmemory/core/graph_pruner.py +436 -39
  44. package/src/superlocalmemory/core/ingestion_command.py +160 -31
  45. package/src/superlocalmemory/core/maintenance_scheduler.py +61 -1
  46. package/src/superlocalmemory/core/recall_pipeline.py +3 -0
  47. package/src/superlocalmemory/core/registry.py +5 -1
  48. package/src/superlocalmemory/core/remote_mode.py +3 -1
  49. package/src/superlocalmemory/core/scale_engine.py +41 -18
  50. package/src/superlocalmemory/core/store_pipeline.py +18 -4
  51. package/src/superlocalmemory/encoding/entity_resolver.py +18 -11
  52. package/src/superlocalmemory/hooks/_outcome_common.py +9 -2
  53. package/src/superlocalmemory/hooks/adapter_base.py +58 -44
  54. package/src/superlocalmemory/hooks/ide_connector.py +26 -8
  55. package/src/superlocalmemory/hooks/portable_kit.py +105 -9
  56. package/src/superlocalmemory/hooks/prewarm_auth.py +21 -2
  57. package/src/superlocalmemory/infra/auth_middleware.py +3 -1
  58. package/src/superlocalmemory/infra/cloud_backup.py +26 -27
  59. package/src/superlocalmemory/infra/event_bus.py +250 -88
  60. package/src/superlocalmemory/learning/consolidation_cycle.py +33 -16
  61. package/src/superlocalmemory/learning/entity_compiler.py +148 -132
  62. package/src/superlocalmemory/learning/memory_merge.py +97 -82
  63. package/src/superlocalmemory/learning/reward_archive.py +98 -90
  64. package/src/superlocalmemory/learning/reward_boost.py +40 -30
  65. package/src/superlocalmemory/mcp/http_transport.py +335 -3
  66. package/src/superlocalmemory/retrieval/engine.py +7 -1
  67. package/src/superlocalmemory/retrieval/entity_channel.py +25 -1
  68. package/src/superlocalmemory/retrieval/reranker.py +98 -15
  69. package/src/superlocalmemory/retrieval/spreading_activation.py +20 -12
  70. package/src/superlocalmemory/retrieval/vector_store.py +84 -69
  71. package/src/superlocalmemory/server/loopback.py +91 -0
  72. package/src/superlocalmemory/server/origin.py +9 -4
  73. package/src/superlocalmemory/server/routes/backup.py +6 -2
  74. package/src/superlocalmemory/server/routes/behavioral.py +6 -12
  75. package/src/superlocalmemory/server/routes/compliance.py +20 -23
  76. package/src/superlocalmemory/server/routes/config_api.py +83 -0
  77. package/src/superlocalmemory/server/routes/helpers.py +24 -13
  78. package/src/superlocalmemory/server/routes/memories.py +67 -68
  79. package/src/superlocalmemory/server/routes/mesh.py +7 -2
  80. package/src/superlocalmemory/server/routes/profiles.py +20 -21
  81. package/src/superlocalmemory/server/routes/rbac.py +0 -1
  82. package/src/superlocalmemory/server/routes/tiers.py +42 -30
  83. package/src/superlocalmemory/server/routes/v3_api.py +67 -77
  84. package/src/superlocalmemory/server/unified_daemon.py +200 -31
  85. package/src/superlocalmemory/server/write_identity.py +22 -4
  86. package/src/superlocalmemory/storage/database.py +109 -19
  87. package/src/superlocalmemory/storage/deferred_writes.py +153 -0
  88. package/src/superlocalmemory/storage/embedding_migrator.py +19 -0
  89. package/src/superlocalmemory/storage/memory_write.py +119 -0
  90. package/src/superlocalmemory/storage/migration_runner.py +7 -0
  91. package/src/superlocalmemory/storage/migrations/M028_fact_entity_associations.py +113 -78
  92. package/src/superlocalmemory/storage/migrations/M031_dead_letter_operations.py +80 -0
  93. 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 sqlite3 as _sqlite3
1367
- _idx_conn = _sqlite3.connect(str(_memory_db))
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
- try:
1417
- embedder = getattr(retrieval_eng, '_embedder', None) if retrieval_eng else None
1418
- if embedder and hasattr(embedder, 'embed'):
1419
- embedder.embed("warmup")
1420
- _embedding_warm = True
1421
- logger.info("Embedding worker pre-warmed (model resident, keep_alive=-1)")
1422
- except Exception as exc:
1423
- logger.warning("Embedding warmup failed: %s", exc)
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
- n = vs.rebuild_from_facts(with_emb)
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
- from superlocalmemory.learning import trigram_index as _ti
2161
- _ti._reset_cache_conn()
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
- from superlocalmemory.hooks._outcome_common import _perf_log_flush
2170
- _perf_log_flush()
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 = client_ip in ("127.0.0.1", "::1")
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.hooks.prewarm_auth import is_loopback as _is_lb
2865
+ from superlocalmemory.server.loopback import is_loopback as _is_lb
2719
2866
  except Exception:
2720
- def _is_lb(h: str) -> bool:
2721
- return h in ("127.0.0.1", "::1", "localhost")
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
- UI_DIR.mkdir(exist_ok=True)
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 = request is None or client_host in (
2980
- "127.0.0.1", "::1", "localhost", "testclient",
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.", bind_host,
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 in ("127.0.0.1", "::1", "localhost"):
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(403, detail="Authenticated write capability required")
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 in ("127.0.0.1", "::1", "localhost")
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(403, detail="Authenticated mutation capability required")
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
- self._lock = threading.Lock()
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
- def execute(self, sql: str, params: tuple[Any, ...] = ()) -> list[sqlite3.Row]:
235
- """Execute SQL with automatic retry on SQLITE_BUSY.
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
- Uses shared conn inside transaction, else per-call with retry.
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(self) -> int:
1310
- """Delete expired cache entries. Returns count deleted."""
1311
- before = self.execute(
1312
- "SELECT COUNT(*) AS c FROM activation_cache "
1313
- "WHERE expires_at < datetime('now')"
1314
- )
1315
- count = int(before[0]["c"]) if before else 0
1316
- self.execute(
1317
- "DELETE FROM activation_cache WHERE expires_at < datetime('now')"
1318
- )
1319
- return count
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."""