superlocalmemory 3.8.3 → 3.8.6

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 (125) hide show
  1. package/CHANGELOG.md +76 -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 +9 -4
  32. package/src/superlocalmemory/__init__.py +1 -1
  33. package/src/superlocalmemory/access/rbac.py +68 -76
  34. package/src/superlocalmemory/cli/commands.py +158 -404
  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/component_registry.py +4 -2
  40. package/src/superlocalmemory/core/config.py +78 -0
  41. package/src/superlocalmemory/core/consolidation_engine.py +79 -73
  42. package/src/superlocalmemory/core/embeddings.py +33 -6
  43. package/src/superlocalmemory/core/engine.py +186 -60
  44. package/src/superlocalmemory/core/engine_ingestion.py +150 -63
  45. package/src/superlocalmemory/core/fact_consolidator.py +148 -30
  46. package/src/superlocalmemory/core/graph_pruner.py +436 -39
  47. package/src/superlocalmemory/core/ingestion_command.py +273 -32
  48. package/src/superlocalmemory/core/maintenance_scheduler.py +61 -1
  49. package/src/superlocalmemory/core/mutations.py +32 -10
  50. package/src/superlocalmemory/core/recall_pipeline.py +111 -74
  51. package/src/superlocalmemory/core/registry.py +5 -1
  52. package/src/superlocalmemory/core/remember_admission.py +152 -0
  53. package/src/superlocalmemory/core/remember_runtime.py +712 -0
  54. package/src/superlocalmemory/core/remote_mode.py +3 -1
  55. package/src/superlocalmemory/core/scale_engine.py +41 -18
  56. package/src/superlocalmemory/core/store_pipeline.py +18 -4
  57. package/src/superlocalmemory/encoding/entity_resolver.py +18 -11
  58. package/src/superlocalmemory/graph/cozo_backend.py +5 -5
  59. package/src/superlocalmemory/hooks/_outcome_common.py +9 -2
  60. package/src/superlocalmemory/hooks/adapter_base.py +58 -44
  61. package/src/superlocalmemory/hooks/ide_connector.py +26 -8
  62. package/src/superlocalmemory/hooks/portable_kit.py +105 -9
  63. package/src/superlocalmemory/hooks/prewarm_auth.py +21 -2
  64. package/src/superlocalmemory/infra/auth_middleware.py +3 -1
  65. package/src/superlocalmemory/infra/cloud_backup.py +26 -27
  66. package/src/superlocalmemory/infra/event_bus.py +250 -88
  67. package/src/superlocalmemory/learning/bandit.py +50 -1
  68. package/src/superlocalmemory/learning/consolidation_cycle.py +33 -16
  69. package/src/superlocalmemory/learning/entity_compiler.py +148 -132
  70. package/src/superlocalmemory/learning/memory_merge.py +97 -82
  71. package/src/superlocalmemory/learning/reward_archive.py +98 -90
  72. package/src/superlocalmemory/learning/reward_boost.py +40 -30
  73. package/src/superlocalmemory/learning/source_quality.py +38 -35
  74. package/src/superlocalmemory/mcp/_daemon_proxy.py +38 -15
  75. package/src/superlocalmemory/mcp/http_transport.py +335 -3
  76. package/src/superlocalmemory/mcp/tools_active.py +4 -41
  77. package/src/superlocalmemory/mcp/tools_core.py +26 -87
  78. package/src/superlocalmemory/mcp/tools_evolution.py +5 -10
  79. package/src/superlocalmemory/optimize/proxy/capture.py +196 -8
  80. package/src/superlocalmemory/retrieval/engine.py +15 -4
  81. package/src/superlocalmemory/retrieval/entity_channel.py +25 -1
  82. package/src/superlocalmemory/retrieval/reranker.py +130 -22
  83. package/src/superlocalmemory/retrieval/spreading_activation.py +20 -12
  84. package/src/superlocalmemory/retrieval/vector_store.py +84 -69
  85. package/src/superlocalmemory/server/loopback.py +85 -0
  86. package/src/superlocalmemory/server/origin.py +9 -4
  87. package/src/superlocalmemory/server/profile_runtime.py +14 -0
  88. package/src/superlocalmemory/server/routes/abstraction.py +2 -4
  89. package/src/superlocalmemory/server/routes/agents.py +3 -5
  90. package/src/superlocalmemory/server/routes/backup.py +6 -2
  91. package/src/superlocalmemory/server/routes/behavioral.py +11 -25
  92. package/src/superlocalmemory/server/routes/brain.py +6 -9
  93. package/src/superlocalmemory/server/routes/compliance.py +20 -23
  94. package/src/superlocalmemory/server/routes/config_api.py +83 -0
  95. package/src/superlocalmemory/server/routes/entity.py +3 -7
  96. package/src/superlocalmemory/server/routes/evolution.py +3 -5
  97. package/src/superlocalmemory/server/routes/helpers.py +57 -25
  98. package/src/superlocalmemory/server/routes/insights.py +2 -4
  99. package/src/superlocalmemory/server/routes/learning.py +2 -5
  100. package/src/superlocalmemory/server/routes/lifecycle.py +2 -4
  101. package/src/superlocalmemory/server/routes/memories.py +119 -98
  102. package/src/superlocalmemory/server/routes/mesh.py +7 -2
  103. package/src/superlocalmemory/server/routes/profiles.py +20 -21
  104. package/src/superlocalmemory/server/routes/rbac.py +0 -1
  105. package/src/superlocalmemory/server/routes/tiers.py +28 -35
  106. package/src/superlocalmemory/server/routes/timeline.py +2 -4
  107. package/src/superlocalmemory/server/routes/v3_api.py +85 -93
  108. package/src/superlocalmemory/server/unified_daemon.py +400 -140
  109. package/src/superlocalmemory/server/write_identity.py +22 -4
  110. package/src/superlocalmemory/storage/admission_codec.py +119 -0
  111. package/src/superlocalmemory/storage/admission_journal.py +728 -0
  112. package/src/superlocalmemory/storage/database.py +168 -19
  113. package/src/superlocalmemory/storage/deferred_writes.py +209 -0
  114. package/src/superlocalmemory/storage/embedding_migrator.py +19 -0
  115. package/src/superlocalmemory/storage/memory_write.py +115 -0
  116. package/src/superlocalmemory/storage/migration_runner.py +44 -0
  117. package/src/superlocalmemory/storage/migrations/M028_fact_entity_associations.py +113 -78
  118. package/src/superlocalmemory/storage/migrations/M031_dead_letter_operations.py +80 -0
  119. package/src/superlocalmemory/storage/migrations/M032_write_coordinator_admission.py +188 -0
  120. package/src/superlocalmemory/storage/read_connection.py +115 -0
  121. package/src/superlocalmemory/storage/write_coordinator.py +756 -0
  122. package/src/superlocalmemory/storage/write_lock.py +88 -0
  123. package/src/superlocalmemory/ui/index.html +1 -1
  124. package/src/superlocalmemory/ui/js/auto-settings.js +14 -1
  125. package/src/superlocalmemory/ui/js/od-settings.js +9 -3
@@ -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
@@ -367,6 +383,18 @@ def _hot_reconfigure_engine(application, new_config, *, mode_change: bool) -> No
367
383
  new_engine.close()
368
384
  raise
369
385
 
386
+ canonical_remember = getattr(
387
+ application.state,
388
+ "canonical_remember_runtime",
389
+ None,
390
+ )
391
+ if canonical_remember is not None:
392
+ try:
393
+ canonical_remember.rebind_engine(new_engine)
394
+ except BaseException:
395
+ new_engine.close()
396
+ raise
397
+
370
398
  # The profile transition barrier is exclusive here. Publish every
371
399
  # long-lived reference before closing the former engine.
372
400
  application.state.engine = new_engine
@@ -801,6 +829,29 @@ def _warm_spreading_activation(engine, runtime) -> bool:
801
829
  sa.search(query_embedding, profile_id=active_pid, top_k=7)
802
830
  else:
803
831
  sa.search(query_embedding, profile_id=active_pid, top_k=7)
832
+ # v3.8.5: pre-load the per-profile graph-metrics cache (PageRank +
833
+ # community for every fact) here in the background, so the FIRST real
834
+ # recall does not pay the ~12k-row load on its hot path. Observed as a
835
+ # large chunk of the 8-13s "cold first query" spike: the metrics cache
836
+ # was loading lazily during the user's first recall instead of at boot.
837
+ try:
838
+ sa._load_graph_metrics_cache(active_pid)
839
+ except Exception:
840
+ pass
841
+ # v3.8.5: warm the EntityGraphChannel's in-memory adjacency cache (ALL
842
+ # ~208K edges + entity maps + graph metrics, ~18 MB) at boot. This was
843
+ # the dominant cause of the "cold first query" spike (8-13s): the cache
844
+ # loaded lazily on the FIRST real recall that routed to the entity
845
+ # channel, not during warmup. Force it here via the channel's own
846
+ # search entry so it is warm before any user query.
847
+ try:
848
+ entity_channel = getattr(retr, "_entity", None)
849
+ if entity_channel is not None:
850
+ entity_channel.search(
851
+ "memory graph adjacency warmup", active_pid, top_k=5,
852
+ )
853
+ except Exception:
854
+ pass
804
855
  logger.info(
805
856
  "Spreading-activation graph pre-warmed for profile %s", active_pid,
806
857
  )
@@ -1166,6 +1217,25 @@ async def _cancel_source_quality_repair(application) -> None:
1166
1217
  pass
1167
1218
 
1168
1219
 
1220
+ def _release_canonical_remember_runtime(application, runtime=None) -> bool:
1221
+ """Release the writer lease without discarding a still-running runtime."""
1222
+ runtime = (
1223
+ runtime
1224
+ if runtime is not None
1225
+ else getattr(application.state, "canonical_remember_runtime", None)
1226
+ )
1227
+ if runtime is None:
1228
+ return True
1229
+ try:
1230
+ runtime.stop()
1231
+ except Exception as exc: # pragma: no cover - cleanup must continue
1232
+ logger.warning("canonical remember writer shutdown failed: %s", exc)
1233
+ return False
1234
+ if getattr(application.state, "canonical_remember_runtime", None) is runtime:
1235
+ application.state.canonical_remember_runtime = None
1236
+ return True
1237
+
1238
+
1169
1239
  @asynccontextmanager
1170
1240
  async def lifespan(application: FastAPI):
1171
1241
  """Initialize engine, workers, and optional services on startup."""
@@ -1173,6 +1243,7 @@ async def lifespan(application: FastAPI):
1173
1243
 
1174
1244
  engine = None
1175
1245
  config = None
1246
+ canonical_remember_runtime = None
1176
1247
 
1177
1248
  # The local dashboard obtains its short-lived browser credential from
1178
1249
  # ``/internal/token`` before its first write or token-gated read. A
@@ -1288,6 +1359,17 @@ async def lifespan(application: FastAPI):
1288
1359
  engine = MemoryEngine(config)
1289
1360
  engine.initialize()
1290
1361
 
1362
+ # 3.8.6 canonical remember boundary. Migrations have already created
1363
+ # the immutable receipt ledger and engine init has created the runtime
1364
+ # tables. Claim the daemon's writer lease, install the typed admission
1365
+ # handler, and replay crash-surviving journal entries *before* any
1366
+ # background writer or ready descriptor is published.
1367
+ from superlocalmemory.core.remember_runtime import CanonicalRememberRuntime
1368
+
1369
+ canonical_remember_runtime = CanonicalRememberRuntime.for_engine(engine)
1370
+ canonical_remember_runtime.start()
1371
+ application.state.canonical_remember_runtime = canonical_remember_runtime
1372
+
1291
1373
  # WAL is already established at DB creation (DatabaseManager._enable_wal
1292
1374
  # / schema init). Re-asserting PRAGMA journal_mode=WAL here is a
1293
1375
  # schema-level write on the shared connection that raced in-flight
@@ -1362,11 +1444,15 @@ async def lifespan(application: FastAPI):
1362
1444
  # full sort. Without them full recall takes 7-10s on
1363
1445
  # >1M edges (the SpreadingActivation 4-UNION query disk-sorts every
1364
1446
  # node's neighbor list on each call). With them: sub-second.
1447
+ #
1448
+ # Concurrency fix (v3.8.4): wrapped in memory_write() so the process
1449
+ # write lock is acquired before the connection is opened. Previously
1450
+ # the raw sqlite3.connect() bypassed get_write_lock() and had no
1451
+ # busy_timeout, which risked SQLITE_BUSY at daemon startup when other
1452
+ # writers (hook / CLI) were already active.
1365
1453
  try:
1366
- import sqlite3 as _sqlite3
1367
- _idx_conn = _sqlite3.connect(str(_memory_db))
1368
- try:
1369
- _idx_conn.execute("PRAGMA journal_mode=WAL")
1454
+ from superlocalmemory.storage.memory_write import memory_write as _mw
1455
+ with _mw(_memory_db) as _idx_conn:
1370
1456
  _idx_conn.execute(
1371
1457
  "CREATE INDEX IF NOT EXISTS idx_edges_source_weight "
1372
1458
  "ON graph_edges(profile_id, source_id, weight DESC)"
@@ -1383,10 +1469,6 @@ async def lifespan(application: FastAPI):
1383
1469
  "CREATE INDEX IF NOT EXISTS idx_assoc_target_weight "
1384
1470
  "ON association_edges(profile_id, target_fact_id, weight DESC)"
1385
1471
  )
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
1472
  except Exception as _idx_exc:
1391
1473
  logger.debug("SpreadingActivation covering indexes skipped: %s", _idx_exc)
1392
1474
 
@@ -1413,14 +1495,36 @@ async def lifespan(application: FastAPI):
1413
1495
  _embedding_warm = False
1414
1496
  def _warmup_embedder():
1415
1497
  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)
1498
+ import time as _t
1499
+ # RETRY: the retrieval engine / embedder can be created lazily a
1500
+ # moment AFTER this thread starts. The old one-shot attempt often
1501
+ # captured a None embedder and left _embedding_warm stuck False
1502
+ # forever — so /health reported not-ready even though on-demand
1503
+ # embeds worked. A stuck not-ready can make an auto-start hook
1504
+ # believe the daemon is down and spawn a DUPLICATE daemon (two
1505
+ # processes writing memory.db cross-process SQLITE_BUSY). Poll
1506
+ # until the real embedder (retrieval_eng._embedder, the same one
1507
+ # recall uses) is available, warm it, and flip the flag.
1508
+ for _attempt in range(240): # ~120s max at 0.5s steps
1509
+ try:
1510
+ _re = retrieval_eng or getattr(engine, '_retrieval_engine', None)
1511
+ embedder = getattr(_re, '_embedder', None) if _re else None
1512
+ if embedder is not None and hasattr(embedder, 'embed'):
1513
+ embedder.embed("warmup")
1514
+ _embedding_warm = True
1515
+ logger.info(
1516
+ "Embedding worker pre-warmed (model resident, "
1517
+ "keep_alive=-1)"
1518
+ )
1519
+ return
1520
+ except Exception as exc:
1521
+ logger.debug("Embedding warmup attempt %d failed: %s",
1522
+ _attempt, exc)
1523
+ _t.sleep(0.5)
1524
+ logger.warning(
1525
+ "Embedding warmup did not complete after retries; /health "
1526
+ "may report not-ready even though on-demand embeds work"
1527
+ )
1424
1528
 
1425
1529
  def _warmup_recall():
1426
1530
  """v3.4.62: Fire a full recall after embedding warms up.
@@ -1462,6 +1566,20 @@ async def lifespan(application: FastAPI):
1462
1566
  # v3.8: the --fast recalls above skip spreading activation; warm
1463
1567
  # that channel directly so the first FULL recall is not cold.
1464
1568
  _warm_spreading_activation(engine, profile_runtime)
1569
+ # v3.8.5: the fast recalls above do NOT exercise the full ranking
1570
+ # path or the agentic round, so the FIRST real user query that
1571
+ # takes the full path paid an 8-13s cold cost (ranking model +
1572
+ # graph-metrics load). Fire ONE full (fast=False) recall here in
1573
+ # the background so those load at boot, not on a user's query.
1574
+ # Best-effort; never blocks readiness.
1575
+ try:
1576
+ with profile_runtime.operation_nowait() as _fsnap:
1577
+ if _fsnap is not None:
1578
+ engine.recall(
1579
+ "memory recall performance", limit=5, fast=False,
1580
+ )
1581
+ except Exception as _fexc:
1582
+ logger.debug("Full-path warmup skipped (non-fatal): %s", _fexc)
1465
1583
  elapsed = round((_t.monotonic() - t0) * 1000)
1466
1584
  logger.info(
1467
1585
  "Recall engine pre-warmed in %dms", elapsed,
@@ -1479,6 +1597,11 @@ async def lifespan(application: FastAPI):
1479
1597
  """
1480
1598
  import time as _t
1481
1599
  from pathlib import Path as _P
1600
+ if os.environ.get("SLM_DISABLE_VS_BACKFILL") == "1":
1601
+ logger.info(
1602
+ "VS backfill disabled via SLM_DISABLE_VS_BACKFILL=1"
1603
+ )
1604
+ return
1482
1605
  for _ in range(120):
1483
1606
  if _embedding_warm:
1484
1607
  break
@@ -1510,7 +1633,42 @@ async def lifespan(application: FastAPI):
1510
1633
  continue
1511
1634
  if vs.count(pid) >= int(len(with_emb) * 0.98):
1512
1635
  continue # already complete — no-op
1513
- n = vs.rebuild_from_facts(with_emb)
1636
+ # Fix: route each upsert through db._lock with cooperative
1637
+ # yield between facts. Previously called
1638
+ # vs.rebuild_from_facts() which opens its own sqlite3
1639
+ # connection (bypassing db._lock), causing concurrent
1640
+ # backfill_missing_embeddings writes to hit SQLITE_BUSY
1641
+ # while holding db._lock — starving user writes for ~50 s.
1642
+ import os as _selfheal_os
1643
+ # Stage 2 (write-queue plan): batch upserts so the write lock
1644
+ # is acquired a FEW times with a clear release window (pause)
1645
+ # between batches — instead of thousands of rapid
1646
+ # re-acquisitions. threading.RLock is NOT fair, so on a
1647
+ # fresh boot a waiting user /remember could be starved for
1648
+ # ~50 s behind the per-fact churn. A real pause between
1649
+ # bounded batches guarantees the waiting user write wins the
1650
+ # lock promptly. One-time backfill; steady state no-ops.
1651
+ _batch = max(1, int(
1652
+ _selfheal_os.environ.get("SLM_SELFHEAL_BATCH", "50")))
1653
+ _pause = max(0.0, float(
1654
+ _selfheal_os.environ.get("SLM_SELFHEAL_BATCH_PAUSE_S", "0.05")))
1655
+ n = 0
1656
+ for _i in range(0, len(with_emb), _batch):
1657
+ _chunk = with_emb[_i:_i + _batch]
1658
+ with db._lock:
1659
+ for _fact_id, _profile_id, _embedding in _chunk:
1660
+ try:
1661
+ if vs.upsert(_fact_id, _profile_id, _embedding):
1662
+ n += 1
1663
+ except Exception as _upsert_exc:
1664
+ logger.warning(
1665
+ "VS backfill[%s]: upsert failed for %s: %s",
1666
+ pid, str(_fact_id)[:16], _upsert_exc,
1667
+ )
1668
+ # Release window: let any waiting user write through
1669
+ # before grabbing the lock again.
1670
+ if _pause > 0:
1671
+ _t.sleep(_pause)
1514
1672
  logger.info(
1515
1673
  "VS backfill[%s]: indexed %d of %d embedded facts",
1516
1674
  pid, n, len(with_emb),
@@ -1757,6 +1915,9 @@ async def lifespan(application: FastAPI):
1757
1915
 
1758
1916
  except Exception:
1759
1917
  logger.exception("Engine init failed") # auto-includes traceback
1918
+ _release_canonical_remember_runtime(
1919
+ application, canonical_remember_runtime,
1920
+ )
1760
1921
  application.state.engine = None
1761
1922
  application.state.config = None
1762
1923
 
@@ -2156,25 +2317,31 @@ async def lifespan(application: FastAPI):
2156
2317
  logger.warning("evolution cost-conn cache close failed: %s", exc)
2157
2318
 
2158
2319
  # Drop the trigram cache conn symmetrically.
2320
+ # D-02: use module-level reference (_trigram_index_mod) to avoid re-importing
2321
+ # during interpreter teardown when the learning namespace may be gone.
2159
2322
  try:
2160
- from superlocalmemory.learning import trigram_index as _ti
2161
- _ti._reset_cache_conn()
2323
+ if _trigram_index_mod is not None:
2324
+ _trigram_index_mod._reset_cache_conn()
2162
2325
  except Exception as exc: # pragma: no cover — defensive
2163
2326
  logger.warning("trigram cache conn close failed: %s", exc)
2164
2327
 
2165
2328
  # Flush the perf-log fd explicitly (the atexit hook still fires
2166
2329
  # but explicit close here is cheap insurance against uvicorn
2167
2330
  # killing the process before atexit runs).
2331
+ # D-03: use module-level reference (_perf_log_flush_fn) — a lazy import here
2332
+ # triggers macOS TCC xattr checks on the source .py at shutdown time, causing
2333
+ # PermissionError: "Operation not permitted: .../hooks/_outcome_common.py".
2168
2334
  try:
2169
- from superlocalmemory.hooks._outcome_common import _perf_log_flush
2170
- _perf_log_flush()
2335
+ if _perf_log_flush_fn is not None:
2336
+ _perf_log_flush_fn()
2171
2337
  except Exception as exc: # pragma: no cover — defensive
2172
2338
  logger.warning("perf_log flush failed: %s", exc)
2173
2339
 
2174
2340
  materializer_stopped = _stop_pending_materializer()
2341
+ canonical_writer_stopped = _release_canonical_remember_runtime(application)
2175
2342
  _profile_runtime = None
2176
2343
  _engine = None
2177
- if engine is not None and materializer_stopped:
2344
+ if engine is not None and materializer_stopped and canonical_writer_stopped:
2178
2345
  try:
2179
2346
  engine.close()
2180
2347
  except Exception:
@@ -2184,7 +2351,8 @@ async def lifespan(application: FastAPI):
2184
2351
  # object still owned by an admitted background operation; OS process
2185
2352
  # teardown is safer than racing that writer with engine.close().
2186
2353
  logger.warning(
2187
- "Engine close deferred because pending materializer is still active"
2354
+ "Engine close deferred because a write-capable background component "
2355
+ "is still active"
2188
2356
  )
2189
2357
  _cleanup_process_descriptor(
2190
2358
  getattr(application.state, "daemon_descriptor", None),
@@ -2473,7 +2641,33 @@ def _register_dashboard_routes(application: FastAPI) -> None:
2473
2641
 
2474
2642
  Extracted from api.py's create_app() to avoid duplicate MemoryEngine.
2475
2643
  """
2476
- from superlocalmemory.server.api import UI_DIR
2644
+ from superlocalmemory.server.api import UI_DIR as _source_ui_dir
2645
+
2646
+ # D-04: Copy UI assets to the data dir at daemon startup to avoid macOS
2647
+ # xattr/TCC PermissionError on source-tree files in editable installs.
2648
+ # The data dir has no quarantine attributes; the copy is idempotent (same
2649
+ # content from the same package) so concurrent daemon starts are safe.
2650
+ # Falls back to the source path with a WARNING — never crashes the daemon.
2651
+ import shutil as _shutil
2652
+ _data_ui_dir = state_path("ui")
2653
+ try:
2654
+ _data_ui_dir.mkdir(parents=True, exist_ok=True)
2655
+ if _source_ui_dir.is_dir():
2656
+ _shutil.copytree(
2657
+ str(_source_ui_dir),
2658
+ str(_data_ui_dir),
2659
+ dirs_exist_ok=True, # idempotent; safe under concurrent starts
2660
+ )
2661
+ UI_DIR = _data_ui_dir
2662
+ except Exception as _ui_copy_exc:
2663
+ # D-04 fallback: source path. Logged as WARNING (not DEBUG) so operators
2664
+ # can diagnose PermissionError on editable installs. Never silently hidden.
2665
+ logger.warning(
2666
+ "D-04: UI copy to data dir failed; serving from source path %s: %s",
2667
+ _source_ui_dir,
2668
+ _ui_copy_exc,
2669
+ )
2670
+ UI_DIR = _source_ui_dir
2477
2671
 
2478
2672
  # Rate limiting (graceful)
2479
2673
  try:
@@ -2523,7 +2717,8 @@ def _register_dashboard_routes(application: FastAPI) -> None:
2523
2717
  async def rate_limit_middleware(request, call_next):
2524
2718
  client_ip = request.client.host if request.client else "unknown"
2525
2719
  is_write = request.method in ("POST", "PUT", "DELETE", "PATCH")
2526
- loopback = client_ip in ("127.0.0.1", "::1")
2720
+ from superlocalmemory.server.loopback import is_loopback as _is_lb_rl
2721
+ loopback = _is_lb_rl(client_ip)
2527
2722
  if not loopback and is_rate_limit_exempt(client_ip):
2528
2723
  return await call_next(request)
2529
2724
  if loopback:
@@ -2715,10 +2910,17 @@ def _register_dashboard_routes(application: FastAPI) -> None:
2715
2910
  "fallback; non-loopback writes will be rejected.", _auth_exc,
2716
2911
  )
2717
2912
  try:
2718
- from superlocalmemory.hooks.prewarm_auth import is_loopback as _is_lb
2913
+ from superlocalmemory.server.loopback import is_loopback as _is_lb
2719
2914
  except Exception:
2720
- def _is_lb(h: str) -> bool:
2721
- return h in ("127.0.0.1", "::1", "localhost")
2915
+ import ipaddress as _ipa_fb
2916
+
2917
+ def _is_lb(h: str) -> bool: # type: ignore[misc]
2918
+ if not h or h.lower() == "localhost":
2919
+ return bool(h)
2920
+ try:
2921
+ return _ipa_fb.ip_address(h).is_loopback
2922
+ except ValueError:
2923
+ return False
2722
2924
 
2723
2925
  @application.middleware("http")
2724
2926
  async def _failclosed_auth(request, call_next):
@@ -2734,9 +2936,14 @@ def _register_dashboard_routes(application: FastAPI) -> None:
2734
2936
  )
2735
2937
  return await call_next(request)
2736
2938
 
2737
- # Static files
2939
+ # Static files — UI_DIR is already the effective path (data-dir or source
2940
+ # fallback) set by the D-04 copy block above; mkdir is a no-op for
2941
+ # the data-dir path (already created) and guarded in the source-fallback case.
2738
2942
  from fastapi.staticfiles import StaticFiles
2739
- UI_DIR.mkdir(exist_ok=True)
2943
+ try:
2944
+ UI_DIR.mkdir(parents=True, exist_ok=True)
2945
+ except Exception:
2946
+ pass # source path may not be writable; StaticFiles reads, not writes
2740
2947
  application.mount("/static", StaticFiles(directory=str(UI_DIR)), name="static")
2741
2948
 
2742
2949
  # Route modules
@@ -2928,6 +3135,12 @@ def _register_daemon_routes(application: FastAPI) -> None:
2928
3135
  @application.get("/health")
2929
3136
  async def health(request: Request = None):
2930
3137
  _update_activity()
3138
+ try:
3139
+ from superlocalmemory.server.recall_health import get_recall_health
3140
+
3141
+ recall_health = get_recall_health()
3142
+ except Exception:
3143
+ recall_health = {"recall_healthy": None}
2931
3144
  # Non-blocking peek: report status without forcing a re-init.
2932
3145
  engine = getattr(application.state, "engine", None)
2933
3146
  migration_result = getattr(application.state, "migration_result", None)
@@ -2938,24 +3151,40 @@ def _register_daemon_routes(application: FastAPI) -> None:
2938
3151
  migrations_ready = bool(migration_result) and not migration_failures
2939
3152
  if migration_details.get("_crash"):
2940
3153
  migrations_ready = False
3154
+ writer_runtime = getattr(
3155
+ application.state,
3156
+ "canonical_remember_runtime",
3157
+ None,
3158
+ )
2941
3159
  readiness = {
2942
3160
  "engine": engine is not None,
2943
3161
  "migrations": migrations_ready,
2944
- "retrieval": bool(_embedding_warm),
3162
+ "writer": bool(
3163
+ writer_runtime is not None
3164
+ and getattr(writer_runtime, "ready", False)
3165
+ ),
3166
+ "embedding": bool(_embedding_warm),
3167
+ "recall_health": recall_health.get("recall_healthy") is True,
2945
3168
  "migration_failures": migration_failures,
2946
3169
  }
2947
- base_ready = all((readiness["engine"], readiness["migrations"]))
2948
- fully_ready = base_ready and readiness["retrieval"]
2949
- runtime_state = (
2950
- "ready" if fully_ready else "warming" if base_ready else "not_ready"
3170
+ readiness["retrieval"] = bool(
3171
+ readiness["embedding"] and readiness["recall_health"]
2951
3172
  )
2952
- # v3.6.8: surface the recall-health verdict so a silently-degraded
2953
- # recall path (warm-but-broken embedder) is VISIBLE, never silent.
2954
- try:
2955
- from superlocalmemory.server.recall_health import get_recall_health
2956
- _recall_health = get_recall_health()
2957
- except Exception:
2958
- _recall_health = {"recall_healthy": None}
3173
+ base_ready = all((
3174
+ readiness["engine"],
3175
+ readiness["migrations"],
3176
+ readiness["writer"],
3177
+ ))
3178
+ fully_ready = base_ready and readiness["retrieval"]
3179
+ if fully_ready:
3180
+ runtime_state = "serving_full"
3181
+ elif base_ready and readiness["embedding"]:
3182
+ runtime_state = "serving_degraded"
3183
+ elif base_ready:
3184
+ runtime_state = "warming"
3185
+ else:
3186
+ runtime_state = "not_ready"
3187
+ lifecycle_state = "ready" if base_ready else "starting"
2959
3188
  identity = getattr(application.state, "daemon_descriptor", None)
2960
3189
  from superlocalmemory.server.profile_runtime import get_profile_runtime
2961
3190
 
@@ -2970,14 +3199,20 @@ def _register_daemon_routes(application: FastAPI) -> None:
2970
3199
  "ready": fully_ready,
2971
3200
  # Runtime readiness is more precise than descriptor lifecycle.
2972
3201
  # A process can be alive and identity-valid while retrieval warms.
2973
- "state": runtime_state,
3202
+ "state": lifecycle_state,
3203
+ "runtime_state": runtime_state,
2974
3204
  "version": getattr(application, 'version', 'unknown'),
2975
3205
  }
2976
3206
  # request is None only for direct internal/test calls (no HTTP client),
2977
3207
  # which are trusted; over HTTP FastAPI always injects the real Request.
3208
+ from superlocalmemory.server.loopback import is_loopback as _is_loopback_host
3209
+ from superlocalmemory.server.write_identity import _TEST_ISOLATION_ALLOWED
3210
+
2978
3211
  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",
3212
+ _trusted = (
3213
+ request is None
3214
+ or _is_loopback_host(client_host)
3215
+ or (client_host == "testclient" and _TEST_ISOLATION_ALLOWED)
2981
3216
  )
2982
3217
  if not _trusted:
2983
3218
  return public
@@ -2993,12 +3228,12 @@ def _register_daemon_routes(application: FastAPI) -> None:
2993
3228
  "embedding_warm": _embedding_warm,
2994
3229
  # v3.6.8: True iff the semantic channel actually fired on the last
2995
3230
  # health probe; includes self-heal counters.
2996
- "recall_health": _recall_health,
3231
+ "recall_health": recall_health,
2997
3232
  **(identity.public_health_fields() if identity is not None else {}),
2998
- # runtime_state must come AFTER the identity spread so the live
2999
- # readiness state wins over the descriptor's last-known lifecycle
3000
- # value (which can be "starting").
3001
- "state": runtime_state,
3233
+ # Lifecycle state remains backward compatible for daemon
3234
+ # discovery; channel degradation is exposed separately.
3235
+ "state": lifecycle_state,
3236
+ "runtime_state": runtime_state,
3002
3237
  "active_profile": profile_snapshot.profile_id,
3003
3238
  "profile_generation": profile_snapshot.generation,
3004
3239
  }
@@ -3163,11 +3398,11 @@ def _register_daemon_routes(application: FastAPI) -> None:
3163
3398
  request: Request,
3164
3399
  wait: bool = False,
3165
3400
  ):
3166
- """Persist through the durable canonical ingestion state machine.
3401
+ """Journal and commit a bounded, immediately-queryable receipt.
3167
3402
 
3168
- The default path returns after the relational/FTS projection is
3169
- queryable. ``wait=true`` materializes the same operation inline; the
3170
- background worker handles all other queryable operations.
3403
+ ``wait`` remains accepted for compatibility, but never permits inline
3404
+ enrichment on this path. The daemon materializer owns all model, graph,
3405
+ vector, and post-hook work after this response.
3171
3406
  """
3172
3407
  trusted_actor_id = _require_write_actor(request)
3173
3408
  _update_activity()
@@ -3180,14 +3415,29 @@ def _register_daemon_routes(application: FastAPI) -> None:
3180
3415
  scope = req.scope or getattr(_scope_cfg, "default_scope", "personal")
3181
3416
  shared_with = req.shared_with
3182
3417
 
3183
- try:
3184
- from superlocalmemory.core.engine_ingestion import (
3185
- build_engine_ingestion_command,
3418
+ # Keep the daemon compatibility route behind the exact RBAC/session
3419
+ # boundary used by dashboard mutations. Machine authentication proves
3420
+ # the caller may reach this daemon; WRITE permission proves the
3421
+ # authenticated dashboard user may mutate this profile. This must run
3422
+ # before either the trust pre-hook or the durable admission journal.
3423
+ from superlocalmemory.access.rbac import Permission
3424
+ from superlocalmemory.server.rbac_enforce import require_permission
3425
+
3426
+ require_permission(request, Permission.WRITE, profile=engine._profile_id)
3427
+ if scope in {"shared", "global"}:
3428
+ require_permission(request, Permission.SHARE, profile=engine._profile_id)
3429
+ runtime = getattr(application.state, "canonical_remember_runtime", None)
3430
+ if runtime is None:
3431
+ raise HTTPException(
3432
+ 503,
3433
+ detail="canonical remember writer is not ready; retry shortly",
3186
3434
  )
3187
- from superlocalmemory.core.ingestion_command import (
3188
- IngestionRequest,
3189
- IngestionState,
3435
+
3436
+ try:
3437
+ from superlocalmemory.core.remember_runtime import (
3438
+ validate_deterministic_admission,
3190
3439
  )
3440
+ from superlocalmemory.storage.admission_journal import Actor, RememberRequest
3191
3441
 
3192
3442
  meta = {}
3193
3443
  if req.tags:
@@ -3195,8 +3445,32 @@ def _register_daemon_routes(application: FastAPI) -> None:
3195
3445
  extra = getattr(req, "metadata", None)
3196
3446
  if isinstance(extra, dict):
3197
3447
  meta.update(extra)
3198
- command = build_engine_ingestion_command(engine)
3199
- ingestion_request = IngestionRequest(
3448
+
3449
+ store_config = getattr(engine._config, "store", None)
3450
+ validate_deterministic_admission(
3451
+ req.content,
3452
+ max_verbatim_chars=getattr(
3453
+ store_config,
3454
+ "max_verbatim_chars",
3455
+ 24_000,
3456
+ ),
3457
+ max_ingest_bytes=getattr(
3458
+ store_config,
3459
+ "max_ingest_bytes",
3460
+ 1_048_576,
3461
+ ),
3462
+ )
3463
+
3464
+ # Trust policy is intentionally outside both the journal and the
3465
+ # coordinator transaction. It can reject or audit a caller, but
3466
+ # cannot hold SQLite's sole writer while hooks do their work.
3467
+ engine._hooks.run_pre("store", {
3468
+ "operation": "store",
3469
+ "agent_id": trusted_actor_id,
3470
+ "profile_id": engine._profile_id,
3471
+ "content_preview": req.content[:100],
3472
+ })
3473
+ admission = RememberRequest(
3200
3474
  content=req.content,
3201
3475
  profile_id=engine._profile_id,
3202
3476
  source_type="http",
@@ -3207,93 +3481,63 @@ def _register_daemon_routes(application: FastAPI) -> None:
3207
3481
  trusted_actor_id=trusted_actor_id,
3208
3482
  session_id=req.session_id,
3209
3483
  )
3210
- # SQLite admission is usually milliseconds, but it can wait on a
3211
- # concurrent migration or writer. Keep that wait out of ASGI so
3212
- # dashboard navigation and recall stay responsive.
3213
- receipt = await asyncio.to_thread(command.submit, ingestion_request)
3214
- result = receipt
3215
- wait_budget_exhausted = False
3216
- if wait:
3217
- materialization_task = asyncio.create_task(
3218
- asyncio.to_thread(command.materialize, receipt.operation_id)
3219
- )
3220
- try:
3221
- result = await asyncio.wait_for(
3222
- asyncio.shield(materialization_task),
3223
- timeout=_REMEMBER_ENRICHMENT_WAIT_SECONDS,
3224
- )
3225
- except TimeoutError:
3226
- # The task retains the M018 lease and continues outside
3227
- # this request. Return the durable receipt honestly;
3228
- # the normal materializer can also reclaim it after a
3229
- # lease expiry if the request-owned worker dies.
3230
- wait_budget_exhausted = True
3231
-
3232
- def _log_background_materialization(task):
3233
- try:
3234
- task.result()
3235
- except Exception as exc:
3236
- logger.warning(
3237
- "bounded remember enrichment failed for %s: %s",
3238
- receipt.operation_id,
3239
- exc,
3240
- )
3241
-
3242
- materialization_task.add_done_callback(
3243
- _log_background_materialization
3244
- )
3245
- fact_ids = list(result.fact_ids)
3246
- # The queryable write is a separate durable transaction. A cold
3247
- # optional enrichment dependency (most often the local embedding
3248
- # worker) may need its bounded retry window, but must not turn an
3249
- # already-admitted fact into an HTTP 500. Keep the operation's
3250
- # failed state truthful so the daemon materializer retries it; the
3251
- # response communicates that the fact is queryable, not complete.
3252
- enrichment_deferred = (
3253
- result.state is IngestionState.FAILED and bool(fact_ids)
3484
+ actor = Actor(
3485
+ principal_id=trusted_actor_id,
3486
+ allowed_profiles=frozenset({engine._profile_id}),
3487
+ allowed_scopes=frozenset({scope}),
3254
3488
  )
3255
- if result.state is IngestionState.FAILED and not enrichment_deferred:
3256
- raise RuntimeError(result.last_error or "materialization failed")
3257
- completed = result.state is IngestionState.COMPLETE
3258
- _emit_event(
3259
- "memory.stored" if completed else "memory.queued",
3260
- payload={
3261
- "operation_id": result.operation_id,
3262
- "fact_ids": fact_ids,
3263
- "tags": req.tags or "",
3264
- "content_preview": req.content[:120],
3265
- "path": (
3266
- "remember_sync"
3267
- if completed
3268
- else "remember_sync_deferred"
3269
- if enrichment_deferred
3270
- else "remember_queryable"
3271
- ),
3272
- },
3489
+ receipt = await asyncio.to_thread(
3490
+ runtime.remember,
3491
+ admission,
3492
+ actor,
3493
+ deadline_ms=2_000,
3273
3494
  )
3495
+ payload = dict(receipt.payload)
3496
+ fact_ids = list(payload.get("fact_ids") or [])
3274
3497
  return {
3275
3498
  "ok": True,
3276
3499
  "fact_ids": fact_ids,
3277
3500
  "count": len(fact_ids),
3278
- "operation_id": result.operation_id,
3501
+ "operation_id": payload["operation_id"],
3279
3502
  # One-release compatibility alias. The durable operation ID is
3280
3503
  # opaque and replaces the integer pending.db row identifier.
3281
- "pending_id": result.operation_id,
3282
- "status": "stored" if completed else "queryable",
3283
- "materialization_state": result.state.value,
3284
- "note": (
3285
- "canonical ingestion complete"
3286
- if completed
3287
- else "queryable now; enrichment continues after the wait budget"
3288
- if wait_budget_exhausted
3289
- else "queryable now; canonical enrichment will retry"
3290
- if enrichment_deferred
3291
- else "queryable now; canonical enrichment pending"
3292
- ),
3293
- "wait_budget_exhausted": wait_budget_exhausted,
3504
+ "pending_id": payload["pending_id"],
3505
+ "status": "queryable",
3506
+ "materialization_state": payload["materialization_state"],
3507
+ "commit_sequence": payload.get("commit_sequence"),
3508
+ "note": "queryable now; canonical enrichment continues in the background",
3509
+ "wait_ignored": bool(wait),
3294
3510
  }
3295
3511
  except Exception as exc:
3296
- raise HTTPException(500, detail=str(exc))
3512
+ from superlocalmemory.core.remember_admission import AdmissionRejected
3513
+ from superlocalmemory.core.remember_runtime import CanonicalRememberUnavailable
3514
+ from superlocalmemory.storage.admission_journal import (
3515
+ AdmissionAuthorizationError,
3516
+ AdmissionPayloadError,
3517
+ IdempotencyConflict,
3518
+ )
3519
+
3520
+ if isinstance(exc, CanonicalRememberUnavailable) or (
3521
+ isinstance(exc, AdmissionRejected) and exc.retryable
3522
+ ):
3523
+ raise HTTPException(
3524
+ 503,
3525
+ detail="canonical remember is temporarily unavailable; retry shortly",
3526
+ ) from exc
3527
+ if isinstance(exc, AdmissionRejected):
3528
+ raise HTTPException(
3529
+ 422,
3530
+ detail="remember admission was rejected by deterministic policy",
3531
+ ) from exc
3532
+ if isinstance(exc, (AdmissionAuthorizationError, PermissionError)):
3533
+ raise HTTPException(403, detail="remember admission is not authorized") from exc
3534
+ if isinstance(
3535
+ exc,
3536
+ (AdmissionPayloadError, IdempotencyConflict),
3537
+ ):
3538
+ raise HTTPException(422, detail=str(exc)) from exc
3539
+ logger.exception("canonical remember admission failed")
3540
+ raise HTTPException(500, detail="canonical remember admission failed") from exc
3297
3541
 
3298
3542
  @application.post("/observe")
3299
3543
  async def observe(req: ObserveRequest, request: Request):
@@ -3781,6 +4025,17 @@ def _materialize_ingestion_one_pass(
3781
4025
  from superlocalmemory.core.ingestion_command import IngestionState
3782
4026
 
3783
4027
  command = build_engine_ingestion_command(engine)
4028
+ reap = getattr(command.repository, "reap_stuck_enriching", None)
4029
+ try:
4030
+ reaped = reap() if callable(reap) else []
4031
+ except Exception as exc:
4032
+ logger.warning("ingestion reaper failed; materializer pass continues: %s", exc)
4033
+ reaped = []
4034
+ if reaped:
4035
+ logger.warning(
4036
+ "Materializer terminalized %d exhausted ingestion operation(s)",
4037
+ len(reaped),
4038
+ )
3784
4039
  completed = failed = 0
3785
4040
  for operation in command.repository.list_materializable(
3786
4041
  limit=limit,
@@ -4007,7 +4262,12 @@ def start_server(port: int = _DEFAULT_PORT) -> None:
4007
4262
  "reachable from the network but SLM_REQUIRE_CREDENTIALS is not set "
4008
4263
  "and API-key auth may be off. A remote caller could write without "
4009
4264
  "credentials. Set SLM_REQUIRE_CREDENTIALS=1 and configure an API "
4010
- "key before exposing this instance.", bind_host,
4265
+ "key before exposing this instance. "
4266
+ "NOTE (issue #90): SLM 3.8.4 fixes IPv4-mapped loopback address "
4267
+ "normalization — if curl/dashboard writes fail with 403 from a "
4268
+ "container, upgrade to 3.8.4. Immediate workaround: set "
4269
+ "SLM_DAEMON_HOST=127.0.0.1 (IPv4 only) or configure an API key.",
4270
+ bind_host,
4011
4271
  )
4012
4272
  listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
4013
4273
  # This handles a just-closed connection in TIME_WAIT. It is safe only