superlocalmemory 3.8.5 → 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 (81) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/README.md +3 -3
  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/cli/commands.py +139 -404
  34. package/src/superlocalmemory/core/component_registry.py +4 -2
  35. package/src/superlocalmemory/core/embeddings.py +33 -6
  36. package/src/superlocalmemory/core/engine.py +94 -49
  37. package/src/superlocalmemory/core/engine_ingestion.py +150 -63
  38. package/src/superlocalmemory/core/ingestion_command.py +133 -21
  39. package/src/superlocalmemory/core/mutations.py +32 -10
  40. package/src/superlocalmemory/core/recall_pipeline.py +111 -77
  41. package/src/superlocalmemory/core/remember_admission.py +152 -0
  42. package/src/superlocalmemory/core/remember_runtime.py +712 -0
  43. package/src/superlocalmemory/graph/cozo_backend.py +5 -5
  44. package/src/superlocalmemory/learning/bandit.py +50 -1
  45. package/src/superlocalmemory/learning/source_quality.py +38 -35
  46. package/src/superlocalmemory/mcp/_daemon_proxy.py +38 -15
  47. package/src/superlocalmemory/mcp/tools_active.py +4 -41
  48. package/src/superlocalmemory/mcp/tools_core.py +26 -87
  49. package/src/superlocalmemory/mcp/tools_evolution.py +5 -10
  50. package/src/superlocalmemory/optimize/proxy/capture.py +196 -8
  51. package/src/superlocalmemory/retrieval/engine.py +8 -3
  52. package/src/superlocalmemory/retrieval/reranker.py +35 -10
  53. package/src/superlocalmemory/server/loopback.py +7 -13
  54. package/src/superlocalmemory/server/profile_runtime.py +14 -0
  55. package/src/superlocalmemory/server/routes/abstraction.py +2 -4
  56. package/src/superlocalmemory/server/routes/agents.py +3 -5
  57. package/src/superlocalmemory/server/routes/behavioral.py +5 -13
  58. package/src/superlocalmemory/server/routes/brain.py +6 -9
  59. package/src/superlocalmemory/server/routes/entity.py +3 -7
  60. package/src/superlocalmemory/server/routes/evolution.py +3 -5
  61. package/src/superlocalmemory/server/routes/helpers.py +44 -23
  62. package/src/superlocalmemory/server/routes/insights.py +2 -4
  63. package/src/superlocalmemory/server/routes/learning.py +2 -5
  64. package/src/superlocalmemory/server/routes/lifecycle.py +2 -4
  65. package/src/superlocalmemory/server/routes/memories.py +122 -100
  66. package/src/superlocalmemory/server/routes/tiers.py +3 -22
  67. package/src/superlocalmemory/server/routes/timeline.py +2 -4
  68. package/src/superlocalmemory/server/routes/v3_api.py +18 -16
  69. package/src/superlocalmemory/server/unified_daemon.py +200 -109
  70. package/src/superlocalmemory/storage/admission_codec.py +119 -0
  71. package/src/superlocalmemory/storage/admission_journal.py +728 -0
  72. package/src/superlocalmemory/storage/database.py +59 -0
  73. package/src/superlocalmemory/storage/deferred_writes.py +67 -11
  74. package/src/superlocalmemory/storage/memory_write.py +8 -12
  75. package/src/superlocalmemory/storage/migration_runner.py +37 -0
  76. package/src/superlocalmemory/storage/migrations/M032_write_coordinator_admission.py +188 -0
  77. package/src/superlocalmemory/storage/read_connection.py +115 -0
  78. package/src/superlocalmemory/storage/write_coordinator.py +756 -0
  79. package/src/superlocalmemory/ui/index.html +1 -1
  80. package/src/superlocalmemory/ui/js/auto-settings.js +14 -1
  81. package/src/superlocalmemory/ui/js/od-settings.js +9 -3
@@ -11,7 +11,7 @@ import logging
11
11
  import os
12
12
  from fastapi import APIRouter, HTTPException, Request
13
13
  from fastapi.responses import JSONResponse
14
- from superlocalmemory.server.routes.helpers import SLM_VERSION
14
+ from superlocalmemory.server.routes.helpers import SLM_VERSION, get_read_connection
15
15
  from superlocalmemory.server.route_mutations import authorize_route_mutation
16
16
 
17
17
  logger = logging.getLogger(__name__)
@@ -101,13 +101,12 @@ async def dashboard(request: Request):
101
101
  active_profile = get_profile_runtime(request.app.state).snapshot.profile_id
102
102
 
103
103
  # Read stats directly from SQLite (dashboard doesn't load engine)
104
- import sqlite3
105
104
  memory_count = 0
106
105
  fact_count = 0
107
106
  db_path = config.base_dir / "memory.db"
108
107
  if db_path.exists():
109
108
  try:
110
- conn = sqlite3.connect(str(db_path))
109
+ conn = get_read_connection(db_path)
111
110
  cursor = conn.cursor()
112
111
  try:
113
112
  cursor.execute(
@@ -358,7 +357,10 @@ async def get_embedding_config(request: Request):
358
357
  """Return current embedding configuration."""
359
358
  try:
360
359
  from superlocalmemory.core.config import SLMConfig
361
- config = SLMConfig.load()
360
+ # The daemon may already be running a freshly hot-swapped config while
361
+ # a profile update is still being persisted. The dashboard must show
362
+ # that live truth, never silently replace it with disk defaults.
363
+ config = getattr(request.app.state, "config", None) or SLMConfig.load()
362
364
  emb = config.embedding
363
365
  return {
364
366
  "provider": emb.provider,
@@ -382,7 +384,7 @@ async def set_embedding_config(request: Request):
382
384
  try:
383
385
  body = await request.json()
384
386
  from superlocalmemory.core.config import SLMConfig, EmbeddingConfig
385
- config = SLMConfig.load()
387
+ config = getattr(request.app.state, "config", None) or SLMConfig.load()
386
388
 
387
389
  new_provider = body.get("provider", config.embedding.provider)
388
390
  new_model = body.get("model_name", config.embedding.model_name)
@@ -1422,7 +1424,7 @@ async def get_associations(
1422
1424
  if not DB_PATH.exists():
1423
1425
  return {"edges": [], "total": 0}
1424
1426
 
1425
- conn = sqlite3.connect(str(DB_PATH))
1427
+ conn = get_read_connection(DB_PATH)
1426
1428
  conn.row_factory = sqlite3.Row
1427
1429
 
1428
1430
  # Build query with optional type filter (parameterized)
@@ -1495,7 +1497,7 @@ async def get_association_stats(request: Request, profile: str = ""):
1495
1497
  "top_connected_facts": [],
1496
1498
  }
1497
1499
 
1498
- conn = sqlite3.connect(str(DB_PATH))
1500
+ conn = get_read_connection(DB_PATH)
1499
1501
  conn.row_factory = sqlite3.Row
1500
1502
 
1501
1503
  # Total edges
@@ -1607,7 +1609,7 @@ async def get_consolidation_status(request: Request, profile: str = ""):
1607
1609
  if not DB_PATH.exists():
1608
1610
  return result
1609
1611
 
1610
- conn = sqlite3.connect(str(DB_PATH))
1612
+ conn = get_read_connection(DB_PATH)
1611
1613
  conn.row_factory = sqlite3.Row
1612
1614
 
1613
1615
  # Last consolidation log entry
@@ -1759,7 +1761,7 @@ async def get_core_memory(request: Request, profile: str = ""):
1759
1761
  if not DB_PATH.exists():
1760
1762
  return {"blocks": [], "total_chars": 0, "char_limit": 2000}
1761
1763
 
1762
- conn = sqlite3.connect(str(DB_PATH))
1764
+ conn = get_read_connection(DB_PATH)
1763
1765
  conn.row_factory = sqlite3.Row
1764
1766
 
1765
1767
  rows = conn.execute(
@@ -1901,7 +1903,7 @@ async def get_vector_store_status(request: Request, profile: str = ""):
1901
1903
  # Count vectors in embedding_metadata
1902
1904
  if DB_PATH.exists():
1903
1905
  try:
1904
- conn = sqlite3.connect(str(DB_PATH))
1906
+ conn = get_read_connection(DB_PATH)
1905
1907
  count = conn.execute(
1906
1908
  "SELECT COUNT(*) FROM embedding_metadata WHERE profile_id = ?",
1907
1909
  (pid,),
@@ -1945,7 +1947,7 @@ async def forgetting_stats(request: Request, profile: str = ""):
1945
1947
  if not DB_PATH.exists():
1946
1948
  return {"total": total, "zones": zones}
1947
1949
 
1948
- conn = _sqlite3.connect(str(DB_PATH))
1950
+ conn = get_read_connection(DB_PATH)
1949
1951
  conn.row_factory = _sqlite3.Row
1950
1952
 
1951
1953
  try:
@@ -2071,7 +2073,7 @@ async def quantization_stats(request: Request, profile: str = ""):
2071
2073
  if not DB_PATH.exists():
2072
2074
  return {"total": total, "tiers": tiers, "compression_ratio": compression_ratio}
2073
2075
 
2074
- conn = _sqlite3.connect(str(DB_PATH))
2076
+ conn = get_read_connection(DB_PATH)
2075
2077
  conn.row_factory = _sqlite3.Row
2076
2078
 
2077
2079
  try:
@@ -2130,7 +2132,7 @@ async def ccq_blocks(request: Request, profile: str = "", limit: int = 50):
2130
2132
  if not DB_PATH.exists():
2131
2133
  return {"blocks": [], "total": 0}
2132
2134
 
2133
- conn = _sqlite3.connect(str(DB_PATH))
2135
+ conn = get_read_connection(DB_PATH)
2134
2136
  conn.row_factory = _sqlite3.Row
2135
2137
 
2136
2138
  blocks = []
@@ -2190,7 +2192,7 @@ async def get_soft_prompts(request: Request, profile: str = ""):
2190
2192
  if not DB_PATH.exists():
2191
2193
  return {"prompts": [], "total": 0, "total_tokens": 0}
2192
2194
 
2193
- conn = _sqlite3.connect(str(DB_PATH))
2195
+ conn = get_read_connection(DB_PATH)
2194
2196
  conn.row_factory = _sqlite3.Row
2195
2197
 
2196
2198
  prompts = []
@@ -2298,7 +2300,7 @@ async def get_graph_communities(request: Request, profile: str = ""):
2298
2300
  if not DB_PATH.exists():
2299
2301
  return {"communities": [], "total": 0}
2300
2302
 
2301
- conn = sqlite3.connect(str(DB_PATH))
2303
+ conn = get_read_connection(DB_PATH)
2302
2304
  conn.row_factory = sqlite3.Row
2303
2305
 
2304
2306
  # Get community member counts and average pagerank
@@ -2465,7 +2467,7 @@ async def v33_overview(request: Request, profile: str = ""):
2465
2467
  if not DB_PATH.exists():
2466
2468
  return overview
2467
2469
 
2468
- conn = _sqlite3.connect(str(DB_PATH))
2470
+ conn = get_read_connection(DB_PATH)
2469
2471
  conn.row_factory = _sqlite3.Row
2470
2472
 
2471
2473
  # Forgetting stats
@@ -383,6 +383,18 @@ def _hot_reconfigure_engine(application, new_config, *, mode_change: bool) -> No
383
383
  new_engine.close()
384
384
  raise
385
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
+
386
398
  # The profile transition barrier is exclusive here. Publish every
387
399
  # long-lived reference before closing the former engine.
388
400
  application.state.engine = new_engine
@@ -1205,6 +1217,25 @@ async def _cancel_source_quality_repair(application) -> None:
1205
1217
  pass
1206
1218
 
1207
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
+
1208
1239
  @asynccontextmanager
1209
1240
  async def lifespan(application: FastAPI):
1210
1241
  """Initialize engine, workers, and optional services on startup."""
@@ -1212,6 +1243,7 @@ async def lifespan(application: FastAPI):
1212
1243
 
1213
1244
  engine = None
1214
1245
  config = None
1246
+ canonical_remember_runtime = None
1215
1247
 
1216
1248
  # The local dashboard obtains its short-lived browser credential from
1217
1249
  # ``/internal/token`` before its first write or token-gated read. A
@@ -1327,6 +1359,17 @@ async def lifespan(application: FastAPI):
1327
1359
  engine = MemoryEngine(config)
1328
1360
  engine.initialize()
1329
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
+
1330
1373
  # WAL is already established at DB creation (DatabaseManager._enable_wal
1331
1374
  # / schema init). Re-asserting PRAGMA journal_mode=WAL here is a
1332
1375
  # schema-level write on the shared connection that raced in-flight
@@ -1872,6 +1915,9 @@ async def lifespan(application: FastAPI):
1872
1915
 
1873
1916
  except Exception:
1874
1917
  logger.exception("Engine init failed") # auto-includes traceback
1918
+ _release_canonical_remember_runtime(
1919
+ application, canonical_remember_runtime,
1920
+ )
1875
1921
  application.state.engine = None
1876
1922
  application.state.config = None
1877
1923
 
@@ -2292,9 +2338,10 @@ async def lifespan(application: FastAPI):
2292
2338
  logger.warning("perf_log flush failed: %s", exc)
2293
2339
 
2294
2340
  materializer_stopped = _stop_pending_materializer()
2341
+ canonical_writer_stopped = _release_canonical_remember_runtime(application)
2295
2342
  _profile_runtime = None
2296
2343
  _engine = None
2297
- if engine is not None and materializer_stopped:
2344
+ if engine is not None and materializer_stopped and canonical_writer_stopped:
2298
2345
  try:
2299
2346
  engine.close()
2300
2347
  except Exception:
@@ -2304,7 +2351,8 @@ async def lifespan(application: FastAPI):
2304
2351
  # object still owned by an admitted background operation; OS process
2305
2352
  # teardown is safer than racing that writer with engine.close().
2306
2353
  logger.warning(
2307
- "Engine close deferred because pending materializer is still active"
2354
+ "Engine close deferred because a write-capable background component "
2355
+ "is still active"
2308
2356
  )
2309
2357
  _cleanup_process_descriptor(
2310
2358
  getattr(application.state, "daemon_descriptor", None),
@@ -3087,6 +3135,12 @@ def _register_daemon_routes(application: FastAPI) -> None:
3087
3135
  @application.get("/health")
3088
3136
  async def health(request: Request = None):
3089
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}
3090
3144
  # Non-blocking peek: report status without forcing a re-init.
3091
3145
  engine = getattr(application.state, "engine", None)
3092
3146
  migration_result = getattr(application.state, "migration_result", None)
@@ -3097,24 +3151,40 @@ def _register_daemon_routes(application: FastAPI) -> None:
3097
3151
  migrations_ready = bool(migration_result) and not migration_failures
3098
3152
  if migration_details.get("_crash"):
3099
3153
  migrations_ready = False
3154
+ writer_runtime = getattr(
3155
+ application.state,
3156
+ "canonical_remember_runtime",
3157
+ None,
3158
+ )
3100
3159
  readiness = {
3101
3160
  "engine": engine is not None,
3102
3161
  "migrations": migrations_ready,
3103
- "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,
3104
3168
  "migration_failures": migration_failures,
3105
3169
  }
3106
- base_ready = all((readiness["engine"], readiness["migrations"]))
3107
- fully_ready = base_ready and readiness["retrieval"]
3108
- runtime_state = (
3109
- "ready" if fully_ready else "warming" if base_ready else "not_ready"
3170
+ readiness["retrieval"] = bool(
3171
+ readiness["embedding"] and readiness["recall_health"]
3110
3172
  )
3111
- # v3.6.8: surface the recall-health verdict so a silently-degraded
3112
- # recall path (warm-but-broken embedder) is VISIBLE, never silent.
3113
- try:
3114
- from superlocalmemory.server.recall_health import get_recall_health
3115
- _recall_health = get_recall_health()
3116
- except Exception:
3117
- _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"
3118
3188
  identity = getattr(application.state, "daemon_descriptor", None)
3119
3189
  from superlocalmemory.server.profile_runtime import get_profile_runtime
3120
3190
 
@@ -3129,7 +3199,8 @@ def _register_daemon_routes(application: FastAPI) -> None:
3129
3199
  "ready": fully_ready,
3130
3200
  # Runtime readiness is more precise than descriptor lifecycle.
3131
3201
  # A process can be alive and identity-valid while retrieval warms.
3132
- "state": runtime_state,
3202
+ "state": lifecycle_state,
3203
+ "runtime_state": runtime_state,
3133
3204
  "version": getattr(application, 'version', 'unknown'),
3134
3205
  }
3135
3206
  # request is None only for direct internal/test calls (no HTTP client),
@@ -3157,12 +3228,12 @@ def _register_daemon_routes(application: FastAPI) -> None:
3157
3228
  "embedding_warm": _embedding_warm,
3158
3229
  # v3.6.8: True iff the semantic channel actually fired on the last
3159
3230
  # health probe; includes self-heal counters.
3160
- "recall_health": _recall_health,
3231
+ "recall_health": recall_health,
3161
3232
  **(identity.public_health_fields() if identity is not None else {}),
3162
- # runtime_state must come AFTER the identity spread so the live
3163
- # readiness state wins over the descriptor's last-known lifecycle
3164
- # value (which can be "starting").
3165
- "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,
3166
3237
  "active_profile": profile_snapshot.profile_id,
3167
3238
  "profile_generation": profile_snapshot.generation,
3168
3239
  }
@@ -3327,11 +3398,11 @@ def _register_daemon_routes(application: FastAPI) -> None:
3327
3398
  request: Request,
3328
3399
  wait: bool = False,
3329
3400
  ):
3330
- """Persist through the durable canonical ingestion state machine.
3401
+ """Journal and commit a bounded, immediately-queryable receipt.
3331
3402
 
3332
- The default path returns after the relational/FTS projection is
3333
- queryable. ``wait=true`` materializes the same operation inline; the
3334
- 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.
3335
3406
  """
3336
3407
  trusted_actor_id = _require_write_actor(request)
3337
3408
  _update_activity()
@@ -3344,14 +3415,29 @@ def _register_daemon_routes(application: FastAPI) -> None:
3344
3415
  scope = req.scope or getattr(_scope_cfg, "default_scope", "personal")
3345
3416
  shared_with = req.shared_with
3346
3417
 
3347
- try:
3348
- from superlocalmemory.core.engine_ingestion import (
3349
- 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",
3350
3434
  )
3351
- from superlocalmemory.core.ingestion_command import (
3352
- IngestionRequest,
3353
- IngestionState,
3435
+
3436
+ try:
3437
+ from superlocalmemory.core.remember_runtime import (
3438
+ validate_deterministic_admission,
3354
3439
  )
3440
+ from superlocalmemory.storage.admission_journal import Actor, RememberRequest
3355
3441
 
3356
3442
  meta = {}
3357
3443
  if req.tags:
@@ -3359,8 +3445,32 @@ def _register_daemon_routes(application: FastAPI) -> None:
3359
3445
  extra = getattr(req, "metadata", None)
3360
3446
  if isinstance(extra, dict):
3361
3447
  meta.update(extra)
3362
- command = build_engine_ingestion_command(engine)
3363
- 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(
3364
3474
  content=req.content,
3365
3475
  profile_id=engine._profile_id,
3366
3476
  source_type="http",
@@ -3371,93 +3481,63 @@ def _register_daemon_routes(application: FastAPI) -> None:
3371
3481
  trusted_actor_id=trusted_actor_id,
3372
3482
  session_id=req.session_id,
3373
3483
  )
3374
- # SQLite admission is usually milliseconds, but it can wait on a
3375
- # concurrent migration or writer. Keep that wait out of ASGI so
3376
- # dashboard navigation and recall stay responsive.
3377
- receipt = await asyncio.to_thread(command.submit, ingestion_request)
3378
- result = receipt
3379
- wait_budget_exhausted = False
3380
- if wait:
3381
- materialization_task = asyncio.create_task(
3382
- asyncio.to_thread(command.materialize, receipt.operation_id)
3383
- )
3384
- try:
3385
- result = await asyncio.wait_for(
3386
- asyncio.shield(materialization_task),
3387
- timeout=_REMEMBER_ENRICHMENT_WAIT_SECONDS,
3388
- )
3389
- except TimeoutError:
3390
- # The task retains the M018 lease and continues outside
3391
- # this request. Return the durable receipt honestly;
3392
- # the normal materializer can also reclaim it after a
3393
- # lease expiry if the request-owned worker dies.
3394
- wait_budget_exhausted = True
3395
-
3396
- def _log_background_materialization(task):
3397
- try:
3398
- task.result()
3399
- except Exception as exc:
3400
- logger.warning(
3401
- "bounded remember enrichment failed for %s: %s",
3402
- receipt.operation_id,
3403
- exc,
3404
- )
3405
-
3406
- materialization_task.add_done_callback(
3407
- _log_background_materialization
3408
- )
3409
- fact_ids = list(result.fact_ids)
3410
- # The queryable write is a separate durable transaction. A cold
3411
- # optional enrichment dependency (most often the local embedding
3412
- # worker) may need its bounded retry window, but must not turn an
3413
- # already-admitted fact into an HTTP 500. Keep the operation's
3414
- # failed state truthful so the daemon materializer retries it; the
3415
- # response communicates that the fact is queryable, not complete.
3416
- enrichment_deferred = (
3417
- 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}),
3418
3488
  )
3419
- if result.state is IngestionState.FAILED and not enrichment_deferred:
3420
- raise RuntimeError(result.last_error or "materialization failed")
3421
- completed = result.state is IngestionState.COMPLETE
3422
- _emit_event(
3423
- "memory.stored" if completed else "memory.queued",
3424
- payload={
3425
- "operation_id": result.operation_id,
3426
- "fact_ids": fact_ids,
3427
- "tags": req.tags or "",
3428
- "content_preview": req.content[:120],
3429
- "path": (
3430
- "remember_sync"
3431
- if completed
3432
- else "remember_sync_deferred"
3433
- if enrichment_deferred
3434
- else "remember_queryable"
3435
- ),
3436
- },
3489
+ receipt = await asyncio.to_thread(
3490
+ runtime.remember,
3491
+ admission,
3492
+ actor,
3493
+ deadline_ms=2_000,
3437
3494
  )
3495
+ payload = dict(receipt.payload)
3496
+ fact_ids = list(payload.get("fact_ids") or [])
3438
3497
  return {
3439
3498
  "ok": True,
3440
3499
  "fact_ids": fact_ids,
3441
3500
  "count": len(fact_ids),
3442
- "operation_id": result.operation_id,
3501
+ "operation_id": payload["operation_id"],
3443
3502
  # One-release compatibility alias. The durable operation ID is
3444
3503
  # opaque and replaces the integer pending.db row identifier.
3445
- "pending_id": result.operation_id,
3446
- "status": "stored" if completed else "queryable",
3447
- "materialization_state": result.state.value,
3448
- "note": (
3449
- "canonical ingestion complete"
3450
- if completed
3451
- else "queryable now; enrichment continues after the wait budget"
3452
- if wait_budget_exhausted
3453
- else "queryable now; canonical enrichment will retry"
3454
- if enrichment_deferred
3455
- else "queryable now; canonical enrichment pending"
3456
- ),
3457
- "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),
3458
3510
  }
3459
3511
  except Exception as exc:
3460
- 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
3461
3541
 
3462
3542
  @application.post("/observe")
3463
3543
  async def observe(req: ObserveRequest, request: Request):
@@ -3945,6 +4025,17 @@ def _materialize_ingestion_one_pass(
3945
4025
  from superlocalmemory.core.ingestion_command import IngestionState
3946
4026
 
3947
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
+ )
3948
4039
  completed = failed = 0
3949
4040
  for operation in command.repository.list_materializable(
3950
4041
  limit=limit,