clsplusplus 7.3.2__tar.gz → 7.4.0__tar.gz

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 (188) hide show
  1. {clsplusplus-7.3.2 → clsplusplus-7.4.0}/PKG-INFO +1 -1
  2. {clsplusplus-7.3.2 → clsplusplus-7.4.0}/pyproject.toml +1 -1
  3. {clsplusplus-7.3.2 → clsplusplus-7.4.0}/src/clsplusplus/api.py +380 -0
  4. {clsplusplus-7.3.2 → clsplusplus-7.4.0}/src/clsplusplus/config.py +13 -0
  5. clsplusplus-7.4.0/src/clsplusplus/durable_log/__init__.py +21 -0
  6. clsplusplus-7.4.0/src/clsplusplus/durable_log/file_wal.py +181 -0
  7. clsplusplus-7.4.0/src/clsplusplus/durable_log/manager.py +149 -0
  8. clsplusplus-7.4.0/src/clsplusplus/durable_log/record.py +88 -0
  9. clsplusplus-7.4.0/src/clsplusplus/durable_log/replicas.py +77 -0
  10. clsplusplus-7.4.0/src/clsplusplus/login_activity.py +97 -0
  11. {clsplusplus-7.3.2 → clsplusplus-7.4.0}/src/clsplusplus/memory_service.py +67 -4
  12. {clsplusplus-7.3.2 → clsplusplus-7.4.0}/src/clsplusplus/models.py +3 -0
  13. {clsplusplus-7.3.2 → clsplusplus-7.4.0}/src/clsplusplus/stores/integration_store.py +28 -0
  14. {clsplusplus-7.3.2 → clsplusplus-7.4.0}/src/clsplusplus/stores/l1_indexing_store.py +64 -0
  15. {clsplusplus-7.3.2 → clsplusplus-7.4.0}/src/clsplusplus/stores/user_store.py +32 -0
  16. {clsplusplus-7.3.2 → clsplusplus-7.4.0}/src/clsplusplus.egg-info/PKG-INFO +1 -1
  17. {clsplusplus-7.3.2 → clsplusplus-7.4.0}/src/clsplusplus.egg-info/SOURCES.txt +8 -0
  18. clsplusplus-7.4.0/tests/test_admin_integrations_activity.py +289 -0
  19. clsplusplus-7.4.0/tests/test_durable_log.py +317 -0
  20. {clsplusplus-7.3.2 → clsplusplus-7.4.0}/LICENSE +0 -0
  21. {clsplusplus-7.3.2 → clsplusplus-7.4.0}/README.md +0 -0
  22. {clsplusplus-7.3.2 → clsplusplus-7.4.0}/setup.cfg +0 -0
  23. {clsplusplus-7.3.2 → clsplusplus-7.4.0}/src/clsplusplus/__init__.py +0 -0
  24. {clsplusplus-7.3.2 → clsplusplus-7.4.0}/src/clsplusplus/abuse_guard.py +0 -0
  25. {clsplusplus-7.3.2 → clsplusplus-7.4.0}/src/clsplusplus/api_usage_analytics.py +0 -0
  26. {clsplusplus-7.3.2 → clsplusplus-7.4.0}/src/clsplusplus/auth.py +0 -0
  27. {clsplusplus-7.3.2 → clsplusplus-7.4.0}/src/clsplusplus/cli.py +0 -0
  28. {clsplusplus-7.3.2 → clsplusplus-7.4.0}/src/clsplusplus/client.py +0 -0
  29. {clsplusplus-7.3.2 → clsplusplus-7.4.0}/src/clsplusplus/cost_forecast.py +0 -0
  30. {clsplusplus-7.3.2 → clsplusplus-7.4.0}/src/clsplusplus/cost_model.py +0 -0
  31. {clsplusplus-7.3.2 → clsplusplus-7.4.0}/src/clsplusplus/debug_console.py +0 -0
  32. {clsplusplus-7.3.2 → clsplusplus-7.4.0}/src/clsplusplus/demo_llm.py +0 -0
  33. {clsplusplus-7.3.2 → clsplusplus-7.4.0}/src/clsplusplus/demo_llm_calls.py +0 -0
  34. {clsplusplus-7.3.2 → clsplusplus-7.4.0}/src/clsplusplus/demo_local.py +0 -0
  35. {clsplusplus-7.3.2 → clsplusplus-7.4.0}/src/clsplusplus/email_service.py +0 -0
  36. {clsplusplus-7.3.2 → clsplusplus-7.4.0}/src/clsplusplus/embeddings.py +0 -0
  37. {clsplusplus-7.3.2 → clsplusplus-7.4.0}/src/clsplusplus/funnel_routes.py +0 -0
  38. {clsplusplus-7.3.2 → clsplusplus-7.4.0}/src/clsplusplus/geo.py +0 -0
  39. {clsplusplus-7.3.2 → clsplusplus-7.4.0}/src/clsplusplus/health_metrics.py +0 -0
  40. {clsplusplus-7.3.2 → clsplusplus-7.4.0}/src/clsplusplus/homepage_autopromote.py +0 -0
  41. {clsplusplus-7.3.2 → clsplusplus-7.4.0}/src/clsplusplus/idempotency.py +0 -0
  42. {clsplusplus-7.3.2 → clsplusplus-7.4.0}/src/clsplusplus/integration_service.py +0 -0
  43. {clsplusplus-7.3.2 → clsplusplus-7.4.0}/src/clsplusplus/integrations/__init__.py +0 -0
  44. {clsplusplus-7.3.2 → clsplusplus-7.4.0}/src/clsplusplus/integrations/autogen.py +0 -0
  45. {clsplusplus-7.3.2 → clsplusplus-7.4.0}/src/clsplusplus/integrations/crewai.py +0 -0
  46. {clsplusplus-7.3.2 → clsplusplus-7.4.0}/src/clsplusplus/integrations/langgraph.py +0 -0
  47. {clsplusplus-7.3.2 → clsplusplus-7.4.0}/src/clsplusplus/integrations/llamaindex.py +0 -0
  48. {clsplusplus-7.3.2 → clsplusplus-7.4.0}/src/clsplusplus/jwt_utils.py +0 -0
  49. {clsplusplus-7.3.2 → clsplusplus-7.4.0}/src/clsplusplus/local_routes.py +0 -0
  50. {clsplusplus-7.3.2 → clsplusplus-7.4.0}/src/clsplusplus/main.py +0 -0
  51. {clsplusplus-7.3.2 → clsplusplus-7.4.0}/src/clsplusplus/mcp_http.py +0 -0
  52. {clsplusplus-7.3.2 → clsplusplus-7.4.0}/src/clsplusplus/mcp_server.py +0 -0
  53. {clsplusplus-7.3.2 → clsplusplus-7.4.0}/src/clsplusplus/memory_cycle.py +0 -0
  54. {clsplusplus-7.3.2 → clsplusplus-7.4.0}/src/clsplusplus/memory_phase.py +0 -0
  55. {clsplusplus-7.3.2 → clsplusplus-7.4.0}/src/clsplusplus/metering_v2/__init__.py +0 -0
  56. {clsplusplus-7.3.2 → clsplusplus-7.4.0}/src/clsplusplus/metering_v2/__main__.py +0 -0
  57. {clsplusplus-7.3.2 → clsplusplus-7.4.0}/src/clsplusplus/metering_v2/billing.py +0 -0
  58. {clsplusplus-7.3.2 → clsplusplus-7.4.0}/src/clsplusplus/metering_v2/healthcheck.py +0 -0
  59. {clsplusplus-7.3.2 → clsplusplus-7.4.0}/src/clsplusplus/metering_v2/notifier.py +0 -0
  60. {clsplusplus-7.3.2 → clsplusplus-7.4.0}/src/clsplusplus/metering_v2/pricing.py +0 -0
  61. {clsplusplus-7.3.2 → clsplusplus-7.4.0}/src/clsplusplus/metering_v2/reconciler.py +0 -0
  62. {clsplusplus-7.3.2 → clsplusplus-7.4.0}/src/clsplusplus/metering_v2/schema.py +0 -0
  63. {clsplusplus-7.3.2 → clsplusplus-7.4.0}/src/clsplusplus/metering_v2/writer.py +0 -0
  64. {clsplusplus-7.3.2 → clsplusplus-7.4.0}/src/clsplusplus/metrics.py +0 -0
  65. {clsplusplus-7.3.2 → clsplusplus-7.4.0}/src/clsplusplus/middleware.py +0 -0
  66. {clsplusplus-7.3.2 → clsplusplus-7.4.0}/src/clsplusplus/namespace_resolver.py +0 -0
  67. {clsplusplus-7.3.2 → clsplusplus-7.4.0}/src/clsplusplus/oauth_server.py +0 -0
  68. {clsplusplus-7.3.2 → clsplusplus-7.4.0}/src/clsplusplus/permissions.py +0 -0
  69. {clsplusplus-7.3.2 → clsplusplus-7.4.0}/src/clsplusplus/plasticity.py +0 -0
  70. {clsplusplus-7.3.2 → clsplusplus-7.4.0}/src/clsplusplus/pricing.py +0 -0
  71. {clsplusplus-7.3.2 → clsplusplus-7.4.0}/src/clsplusplus/pricing_models.py +0 -0
  72. {clsplusplus-7.3.2 → clsplusplus-7.4.0}/src/clsplusplus/pricing_store.py +0 -0
  73. {clsplusplus-7.3.2 → clsplusplus-7.4.0}/src/clsplusplus/prompt_log.py +0 -0
  74. {clsplusplus-7.3.2 → clsplusplus-7.4.0}/src/clsplusplus/rate_limit.py +0 -0
  75. {clsplusplus-7.3.2 → clsplusplus-7.4.0}/src/clsplusplus/razorpay_service.py +0 -0
  76. {clsplusplus-7.3.2 → clsplusplus-7.4.0}/src/clsplusplus/rbac_service.py +0 -0
  77. {clsplusplus-7.3.2 → clsplusplus-7.4.0}/src/clsplusplus/reconsolidation.py +0 -0
  78. {clsplusplus-7.3.2 → clsplusplus-7.4.0}/src/clsplusplus/resilience.py +0 -0
  79. {clsplusplus-7.3.2 → clsplusplus-7.4.0}/src/clsplusplus/sleep_cycle.py +0 -0
  80. {clsplusplus-7.3.2 → clsplusplus-7.4.0}/src/clsplusplus/stores/__init__.py +0 -0
  81. {clsplusplus-7.3.2 → clsplusplus-7.4.0}/src/clsplusplus/stores/base.py +0 -0
  82. {clsplusplus-7.3.2 → clsplusplus-7.4.0}/src/clsplusplus/stores/chat_session_store.py +0 -0
  83. {clsplusplus-7.3.2 → clsplusplus-7.4.0}/src/clsplusplus/stores/l0_working_buffer.py +0 -0
  84. {clsplusplus-7.3.2 → clsplusplus-7.4.0}/src/clsplusplus/stores/l2_schema_graph.py +0 -0
  85. {clsplusplus-7.3.2 → clsplusplus-7.4.0}/src/clsplusplus/stores/l3_deep_recess.py +0 -0
  86. {clsplusplus-7.3.2 → clsplusplus-7.4.0}/src/clsplusplus/stores/l3_postgres.py +0 -0
  87. {clsplusplus-7.3.2 → clsplusplus-7.4.0}/src/clsplusplus/stores/oauth_store.py +0 -0
  88. {clsplusplus-7.3.2 → clsplusplus-7.4.0}/src/clsplusplus/stores/rbac_store.py +0 -0
  89. {clsplusplus-7.3.2 → clsplusplus-7.4.0}/src/clsplusplus/stores/waitlist_store.py +0 -0
  90. {clsplusplus-7.3.2 → clsplusplus-7.4.0}/src/clsplusplus/stores/web_events_store.py +0 -0
  91. {clsplusplus-7.3.2 → clsplusplus-7.4.0}/src/clsplusplus/stripe_service.py +0 -0
  92. {clsplusplus-7.3.2 → clsplusplus-7.4.0}/src/clsplusplus/subscription_watchdog.py +0 -0
  93. {clsplusplus-7.3.2 → clsplusplus-7.4.0}/src/clsplusplus/temporal.py +0 -0
  94. {clsplusplus-7.3.2 → clsplusplus-7.4.0}/src/clsplusplus/test_suite.py +0 -0
  95. {clsplusplus-7.3.2 → clsplusplus-7.4.0}/src/clsplusplus/tier_resolver.py +0 -0
  96. {clsplusplus-7.3.2 → clsplusplus-7.4.0}/src/clsplusplus/tiers.py +0 -0
  97. {clsplusplus-7.3.2 → clsplusplus-7.4.0}/src/clsplusplus/topical_resonance.py +0 -0
  98. {clsplusplus-7.3.2 → clsplusplus-7.4.0}/src/clsplusplus/tracer.py +0 -0
  99. {clsplusplus-7.3.2 → clsplusplus-7.4.0}/src/clsplusplus/usage.py +0 -0
  100. {clsplusplus-7.3.2 → clsplusplus-7.4.0}/src/clsplusplus/user_embeddings.py +0 -0
  101. {clsplusplus-7.3.2 → clsplusplus-7.4.0}/src/clsplusplus/user_pulse.py +0 -0
  102. {clsplusplus-7.3.2 → clsplusplus-7.4.0}/src/clsplusplus/user_service.py +0 -0
  103. {clsplusplus-7.3.2 → clsplusplus-7.4.0}/src/clsplusplus/waitlist_service.py +0 -0
  104. {clsplusplus-7.3.2 → clsplusplus-7.4.0}/src/clsplusplus/webhook_dispatcher.py +0 -0
  105. {clsplusplus-7.3.2 → clsplusplus-7.4.0}/src/clsplusplus/weblab.py +0 -0
  106. {clsplusplus-7.3.2 → clsplusplus-7.4.0}/src/clsplusplus/weblab_watcher.py +0 -0
  107. {clsplusplus-7.3.2 → clsplusplus-7.4.0}/src/clsplusplus/window_limits.py +0 -0
  108. {clsplusplus-7.3.2 → clsplusplus-7.4.0}/src/clsplusplus/write_metrics.py +0 -0
  109. {clsplusplus-7.3.2 → clsplusplus-7.4.0}/src/clsplusplus.egg-info/dependency_links.txt +0 -0
  110. {clsplusplus-7.3.2 → clsplusplus-7.4.0}/src/clsplusplus.egg-info/entry_points.txt +0 -0
  111. {clsplusplus-7.3.2 → clsplusplus-7.4.0}/src/clsplusplus.egg-info/requires.txt +0 -0
  112. {clsplusplus-7.3.2 → clsplusplus-7.4.0}/src/clsplusplus.egg-info/top_level.txt +0 -0
  113. {clsplusplus-7.3.2 → clsplusplus-7.4.0}/tests/test_abuse_guard.py +0 -0
  114. {clsplusplus-7.3.2 → clsplusplus-7.4.0}/tests/test_admin.py +0 -0
  115. {clsplusplus-7.3.2 → clsplusplus-7.4.0}/tests/test_admin_seed.py +0 -0
  116. {clsplusplus-7.3.2 → clsplusplus-7.4.0}/tests/test_api.py +0 -0
  117. {clsplusplus-7.3.2 → clsplusplus-7.4.0}/tests/test_api_comprehensive.py +0 -0
  118. {clsplusplus-7.3.2 → clsplusplus-7.4.0}/tests/test_api_endpoints.py +0 -0
  119. {clsplusplus-7.3.2 → clsplusplus-7.4.0}/tests/test_api_usage_analytics.py +0 -0
  120. {clsplusplus-7.3.2 → clsplusplus-7.4.0}/tests/test_auth.py +0 -0
  121. {clsplusplus-7.3.2 → clsplusplus-7.4.0}/tests/test_auth_me_api_key.py +0 -0
  122. {clsplusplus-7.3.2 → clsplusplus-7.4.0}/tests/test_autogen_memory.py +0 -0
  123. {clsplusplus-7.3.2 → clsplusplus-7.4.0}/tests/test_billing_e2e.py +0 -0
  124. {clsplusplus-7.3.2 → clsplusplus-7.4.0}/tests/test_burst_hardening.py +0 -0
  125. {clsplusplus-7.3.2 → clsplusplus-7.4.0}/tests/test_client_sdk.py +0 -0
  126. {clsplusplus-7.3.2 → clsplusplus-7.4.0}/tests/test_config.py +0 -0
  127. {clsplusplus-7.3.2 → clsplusplus-7.4.0}/tests/test_cost_forecast.py +0 -0
  128. {clsplusplus-7.3.2 → clsplusplus-7.4.0}/tests/test_crewai_provider.py +0 -0
  129. {clsplusplus-7.3.2 → clsplusplus-7.4.0}/tests/test_cross_llm_memory.py +0 -0
  130. {clsplusplus-7.3.2 → clsplusplus-7.4.0}/tests/test_debug_console.py +0 -0
  131. {clsplusplus-7.3.2 → clsplusplus-7.4.0}/tests/test_demo_llm.py +0 -0
  132. {clsplusplus-7.3.2 → clsplusplus-7.4.0}/tests/test_embeddings.py +0 -0
  133. {clsplusplus-7.3.2 → clsplusplus-7.4.0}/tests/test_extension_cross_model_runtime.py +0 -0
  134. {clsplusplus-7.3.2 → clsplusplus-7.4.0}/tests/test_extension_integration.py +0 -0
  135. {clsplusplus-7.3.2 → clsplusplus-7.4.0}/tests/test_extension_ui.py +0 -0
  136. {clsplusplus-7.3.2 → clsplusplus-7.4.0}/tests/test_feedback_github.py +0 -0
  137. {clsplusplus-7.3.2 → clsplusplus-7.4.0}/tests/test_free_tier_lifecycle.py +0 -0
  138. {clsplusplus-7.3.2 → clsplusplus-7.4.0}/tests/test_full_api_coverage.py +0 -0
  139. {clsplusplus-7.3.2 → clsplusplus-7.4.0}/tests/test_funnel_metrics.py +0 -0
  140. {clsplusplus-7.3.2 → clsplusplus-7.4.0}/tests/test_geo_gating.py +0 -0
  141. {clsplusplus-7.3.2 → clsplusplus-7.4.0}/tests/test_health_metrics.py +0 -0
  142. {clsplusplus-7.3.2 → clsplusplus-7.4.0}/tests/test_idempotency.py +0 -0
  143. {clsplusplus-7.3.2 → clsplusplus-7.4.0}/tests/test_integration_service.py +0 -0
  144. {clsplusplus-7.3.2 → clsplusplus-7.4.0}/tests/test_integration_sync_cli_contract.py +0 -0
  145. {clsplusplus-7.3.2 → clsplusplus-7.4.0}/tests/test_langgraph_store.py +0 -0
  146. {clsplusplus-7.3.2 → clsplusplus-7.4.0}/tests/test_llamaindex_block.py +0 -0
  147. {clsplusplus-7.3.2 → clsplusplus-7.4.0}/tests/test_mcp_connect.py +0 -0
  148. {clsplusplus-7.3.2 → clsplusplus-7.4.0}/tests/test_mcp_oauth.py +0 -0
  149. {clsplusplus-7.3.2 → clsplusplus-7.4.0}/tests/test_mem0_failure_modes.py +0 -0
  150. {clsplusplus-7.3.2 → clsplusplus-7.4.0}/tests/test_memory_cycle.py +0 -0
  151. {clsplusplus-7.3.2 → clsplusplus-7.4.0}/tests/test_memory_phase.py +0 -0
  152. {clsplusplus-7.3.2 → clsplusplus-7.4.0}/tests/test_memory_service.py +0 -0
  153. {clsplusplus-7.3.2 → clsplusplus-7.4.0}/tests/test_memory_write_no_schema_garbage.py +0 -0
  154. {clsplusplus-7.3.2 → clsplusplus-7.4.0}/tests/test_metering_v2_healthcheck.py +0 -0
  155. {clsplusplus-7.3.2 → clsplusplus-7.4.0}/tests/test_metering_v2_notifier.py +0 -0
  156. {clsplusplus-7.3.2 → clsplusplus-7.4.0}/tests/test_metering_v2_pricing.py +0 -0
  157. {clsplusplus-7.3.2 → clsplusplus-7.4.0}/tests/test_metering_v2_reconciler.py +0 -0
  158. {clsplusplus-7.3.2 → clsplusplus-7.4.0}/tests/test_metering_v2_schema.py +0 -0
  159. {clsplusplus-7.3.2 → clsplusplus-7.4.0}/tests/test_metering_v2_writer.py +0 -0
  160. {clsplusplus-7.3.2 → clsplusplus-7.4.0}/tests/test_middleware.py +0 -0
  161. {clsplusplus-7.3.2 → clsplusplus-7.4.0}/tests/test_models.py +0 -0
  162. {clsplusplus-7.3.2 → clsplusplus-7.4.0}/tests/test_overage_billing.py +0 -0
  163. {clsplusplus-7.3.2 → clsplusplus-7.4.0}/tests/test_performance.py +0 -0
  164. {clsplusplus-7.3.2 → clsplusplus-7.4.0}/tests/test_plasticity.py +0 -0
  165. {clsplusplus-7.3.2 → clsplusplus-7.4.0}/tests/test_pricing.py +0 -0
  166. {clsplusplus-7.3.2 → clsplusplus-7.4.0}/tests/test_prototype_e2e.py +0 -0
  167. {clsplusplus-7.3.2 → clsplusplus-7.4.0}/tests/test_rate_limit.py +0 -0
  168. {clsplusplus-7.3.2 → clsplusplus-7.4.0}/tests/test_razorpay_billing.py +0 -0
  169. {clsplusplus-7.3.2 → clsplusplus-7.4.0}/tests/test_razorpay_subscription_webhooks.py +0 -0
  170. {clsplusplus-7.3.2 → clsplusplus-7.4.0}/tests/test_reconsolidation.py +0 -0
  171. {clsplusplus-7.3.2 → clsplusplus-7.4.0}/tests/test_regression.py +0 -0
  172. {clsplusplus-7.3.2 → clsplusplus-7.4.0}/tests/test_resilience.py +0 -0
  173. {clsplusplus-7.3.2 → clsplusplus-7.4.0}/tests/test_security.py +0 -0
  174. {clsplusplus-7.3.2 → clsplusplus-7.4.0}/tests/test_sleep_cycle.py +0 -0
  175. {clsplusplus-7.3.2 → clsplusplus-7.4.0}/tests/test_stores.py +0 -0
  176. {clsplusplus-7.3.2 → clsplusplus-7.4.0}/tests/test_subscription_watchdog.py +0 -0
  177. {clsplusplus-7.3.2 → clsplusplus-7.4.0}/tests/test_tier_resolver.py +0 -0
  178. {clsplusplus-7.3.2 → clsplusplus-7.4.0}/tests/test_tiers.py +0 -0
  179. {clsplusplus-7.3.2 → clsplusplus-7.4.0}/tests/test_usage.py +0 -0
  180. {clsplusplus-7.3.2 → clsplusplus-7.4.0}/tests/test_user_auth.py +0 -0
  181. {clsplusplus-7.3.2 → clsplusplus-7.4.0}/tests/test_user_embeddings.py +0 -0
  182. {clsplusplus-7.3.2 → clsplusplus-7.4.0}/tests/test_user_pulse.py +0 -0
  183. {clsplusplus-7.3.2 → clsplusplus-7.4.0}/tests/test_user_stories.py +0 -0
  184. {clsplusplus-7.3.2 → clsplusplus-7.4.0}/tests/test_waitlist.py +0 -0
  185. {clsplusplus-7.3.2 → clsplusplus-7.4.0}/tests/test_webhook_dispatcher.py +0 -0
  186. {clsplusplus-7.3.2 → clsplusplus-7.4.0}/tests/test_weblab.py +0 -0
  187. {clsplusplus-7.3.2 → clsplusplus-7.4.0}/tests/test_window_limits.py +0 -0
  188. {clsplusplus-7.3.2 → clsplusplus-7.4.0}/tests/test_write_metrics.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: clsplusplus
3
- Version: 7.3.2
3
+ Version: 7.4.0
4
4
  Summary: Brain-inspired, model-agnostic persistent memory for LLMs. Learn, recall, forget — like a brain. Works with OpenAI, Claude, Gemini, Llama.
5
5
  Author-email: AlphaForge AI Labs <contact@alphaforge.ai>
6
6
  Maintainer-email: Rajamohan Jabbala <contact@alphaforge.ai>
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "clsplusplus"
7
- version = "7.3.2"
7
+ version = "7.4.0"
8
8
  description = "Brain-inspired, model-agnostic persistent memory for LLMs. Learn, recall, forget — like a brain. Works with OpenAI, Claude, Gemini, Llama."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -1248,12 +1248,21 @@ def create_app(settings: Optional[Settings] = None) -> FastAPI:
1248
1248
  "error": "Add CLS_REDIS_URL and CLS_DATABASE_URL in Render Dashboard.",
1249
1249
  }
1250
1250
  wm = write_metrics.snapshot()
1251
+ wal = None
1252
+ _dl = getattr(memory_service, "_durable_log", None)
1253
+ if _dl is not None:
1254
+ wal = {
1255
+ "pending_reconcile": _dl.pending_reconcile(),
1256
+ "appended": _dl.appended,
1257
+ "sinks": {s.name: {"healthy": s.healthy, "lag": s.lag} for s in _dl.states()},
1258
+ }
1251
1259
  return HealthResponse(
1252
1260
  status=h["status"],
1253
1261
  stores=stores,
1254
1262
  writes=wm["total_writes"],
1255
1263
  degraded_writes=wm["degraded_writes"],
1256
1264
  degraded_write_rate=wm["degraded_write_rate"],
1265
+ wal=wal,
1257
1266
  )
1258
1267
 
1259
1268
  @app.get("/v1/health/score", response_model=HealthResponse)
@@ -1619,6 +1628,14 @@ def create_app(settings: Optional[Settings] = None) -> FastAPI:
1619
1628
  )
1620
1629
  logger.error("Login error: %s: %s", type(e).__name__, e)
1621
1630
  raise HTTPException(status_code=500, detail="Login service unavailable")
1631
+ # Real login telemetry for the admin activity dashboard (best-effort).
1632
+ from clsplusplus.login_activity import record_login
1633
+ await record_login(
1634
+ settings,
1635
+ user_id=user.get("id"),
1636
+ email=user.get("email"),
1637
+ method="password",
1638
+ )
1622
1639
  response = JSONResponse(content=user)
1623
1640
  return _set_session_cookie(response, token)
1624
1641
 
@@ -1806,6 +1823,13 @@ def create_app(settings: Optional[Settings] = None) -> FastAPI:
1806
1823
  _target = (settings.frontend_url.rstrip("/") + _safe_redirect) if settings.frontend_url else _safe_redirect
1807
1824
  response = RedirectResponse(_target)
1808
1825
  _set_session_cookie(response, token)
1826
+ from clsplusplus.login_activity import record_login
1827
+ await record_login(
1828
+ settings,
1829
+ user_id=user.get("id"),
1830
+ email=user.get("email"),
1831
+ method="google",
1832
+ )
1809
1833
  return response
1810
1834
 
1811
1835
  # ------------------------------------------------------------------
@@ -1927,6 +1951,13 @@ def create_app(settings: Optional[Settings] = None) -> FastAPI:
1927
1951
  _target = (settings.frontend_url.rstrip("/") + safe_redirect) if settings.frontend_url else safe_redirect
1928
1952
  response = RedirectResponse(_target)
1929
1953
  _set_session_cookie(response, token)
1954
+ from clsplusplus.login_activity import record_login
1955
+ await record_login(
1956
+ settings,
1957
+ user_id=user.get("id"),
1958
+ email=user.get("email"),
1959
+ method="github",
1960
+ )
1930
1961
  return response
1931
1962
 
1932
1963
  # =========================================================================
@@ -2687,6 +2718,344 @@ def create_app(settings: Optional[Settings] = None) -> FastAPI:
2687
2718
  "error": "integration data degraded",
2688
2719
  }
2689
2720
 
2721
+ @app.get("/admin/integrations/activity")
2722
+ async def admin_integrations_activity(request: Request):
2723
+ """Real per-integration ACTIVITY for the admin dashboard.
2724
+
2725
+ Every metric is computed from a real backing store; nothing is
2726
+ fabricated. A source with no attributable data returns null fields and
2727
+ an honest telemetry classification ("partial" or "none") rather than a
2728
+ made-up number. The handler is fully resilient — each backing store is
2729
+ isolated and any failure degrades that section to null/empty instead of
2730
+ returning 500.
2731
+
2732
+ Telemetry honesty contract per integration:
2733
+ - "tracked": the integration writes a dedicated L1 `source` (the
2734
+ framework adapters: crewai/langgraph/llamaindex/autogen) or has its
2735
+ own Redis counters (chrome-extension) or its own table (api-key).
2736
+ - "partial": the integration shares the generic `source="mcp"` bucket
2737
+ so volume is real but NOT separable per-integration
2738
+ (claude-remote-mcp, codex, cursor, n8n).
2739
+ - "none": no attributable signal exists for it yet.
2740
+ """
2741
+ _require_admin(request)
2742
+
2743
+ from datetime import timezone as _tz
2744
+ now = datetime.now(_tz.utc)
2745
+ _empty_window = {
2746
+ "active_users_24h": 0, "active_users_7d": 0, "logins_24h": 0,
2747
+ "total_memories": 0, "writes_24h": 0, "reads_24h": 0,
2748
+ "api_error_rate_60m": 0.0,
2749
+ }
2750
+
2751
+ def _iso(dt) -> Optional[str]:
2752
+ if dt is None:
2753
+ return None
2754
+ try:
2755
+ return dt.isoformat()
2756
+ except Exception:
2757
+ return None
2758
+
2759
+ # --- Static catalogue: ids/names MUST match the frontend dashboard. ---
2760
+ # `bucket` says where this integration's REAL write volume comes from.
2761
+ # "adapter:<source>" -> dedicated L1 source -> telemetry tracked
2762
+ # "mcp" -> shared mcp source bucket -> telemetry partial
2763
+ # "ext" -> chrome-ext redis counters-> telemetry tracked
2764
+ # "api" -> api_credentials table -> telemetry tracked
2765
+ # None -> nothing attributable -> telemetry none
2766
+ catalogue = [
2767
+ {"id": "claude-remote-mcp", "name": "Claude (Remote MCP)", "category": "mcp", "bucket": "mcp"},
2768
+ {"id": "codex", "name": "Codex", "category": "mcp", "bucket": "mcp"},
2769
+ {"id": "cursor", "name": "Cursor", "category": "mcp", "bucket": "mcp"},
2770
+ {"id": "n8n", "name": "n8n", "category": "mcp", "bucket": "mcp"},
2771
+ {"id": "crewai", "name": "CrewAI", "category": "framework", "bucket": "adapter:crewai"},
2772
+ {"id": "langgraph", "name": "LangGraph", "category": "framework", "bucket": "adapter:langgraph"},
2773
+ {"id": "llamaindex", "name": "LlamaIndex", "category": "framework", "bucket": "adapter:llamaindex"},
2774
+ {"id": "autogen", "name": "AutoGen", "category": "framework", "bucket": "adapter:autogen"},
2775
+ {"id": "chrome-extension", "name": "Chrome Extension", "category": "browser", "bucket": "ext"},
2776
+ {"id": "api-key", "name": "API Key", "category": "api", "bucket": "api"},
2777
+ ]
2778
+
2779
+ # --- Source 1: L1 writes grouped by source (real attribution). ------
2780
+ by_source: dict[str, dict] = {}
2781
+ recent_l1_writes: list[dict] = []
2782
+ total_memories: Optional[int] = None
2783
+ try:
2784
+ rows = await memory_service.l1.writes_by_source(since_days=7)
2785
+ by_source = {r["source"]: r for r in rows}
2786
+ except Exception as e:
2787
+ logger.warning("activity: l1 writes_by_source failed: %s", e)
2788
+ try:
2789
+ recent_l1_writes = await memory_service.l1.recent_writes(limit=20)
2790
+ except Exception as e:
2791
+ logger.warning("activity: l1 recent_writes failed: %s", e)
2792
+ try:
2793
+ total_memories = await memory_service.l1.count_all()
2794
+ except Exception as e:
2795
+ logger.warning("activity: l1 count_all failed: %s", e)
2796
+
2797
+ # --- Source 2: chrome-extension Redis analytics. --------------------
2798
+ ext_analytics: dict = {}
2799
+ try:
2800
+ ext_analytics = await _metrics.get_extension_analytics()
2801
+ except Exception as e:
2802
+ logger.warning("activity: extension analytics failed: %s", e)
2803
+
2804
+ # --- Source 3: api-key usage from api_credentials. -----------------
2805
+ api_activity: dict = {}
2806
+ try:
2807
+ api_activity = await _integration_store.api_key_activity(since_days=7)
2808
+ except Exception as e:
2809
+ logger.warning("activity: api_key_activity failed: %s", e)
2810
+
2811
+ # --- Source 4: aggregate usage + active counts (Redis metrics). -----
2812
+ agg: dict = {}
2813
+ active_now = 0
2814
+ dau = 0
2815
+ try:
2816
+ agg = await _metrics.get_aggregate_metrics()
2817
+ except Exception as e:
2818
+ logger.warning("activity: aggregate metrics failed: %s", e)
2819
+ try:
2820
+ active_now = await _metrics.get_active_now(window_seconds=86400)
2821
+ except Exception:
2822
+ active_now = 0
2823
+ try:
2824
+ dau = await _metrics.get_dau()
2825
+ except Exception:
2826
+ dau = 0
2827
+ active_7d = 0
2828
+ try:
2829
+ from datetime import timedelta as _td
2830
+ from clsplusplus.metrics import _redis_client as _mredis
2831
+ client = _mredis(settings.redis_url)
2832
+ seen = set()
2833
+ for d in range(7):
2834
+ day = (now - _td(days=d)).strftime("%Y-%m-%d")
2835
+ try:
2836
+ members = await client.smembers(f"cls:dau:global:{day}")
2837
+ seen.update(members or [])
2838
+ except Exception:
2839
+ continue
2840
+ active_7d = len(seen)
2841
+ except Exception:
2842
+ active_7d = 0
2843
+
2844
+ # --- Source 5: api error rate (health metrics, last 60m). ----------
2845
+ api_error_rate_60m = 0.0
2846
+ try:
2847
+ from clsplusplus.health_metrics import aggregate_health
2848
+ health = await aggregate_health(settings.redis_url, 60)
2849
+ api_error_rate_60m = float(health.get("error_rate") or 0.0)
2850
+ except Exception as e:
2851
+ logger.warning("activity: health aggregate failed: %s", e)
2852
+
2853
+ # --- Source 6: login activity (new instrumentation). ---------------
2854
+ logins_24h = 0
2855
+ recent_logins: list[dict] = []
2856
+ try:
2857
+ from clsplusplus.login_activity import logins_since, recent_logins as _recent_logins
2858
+ logins_24h = await logins_since(settings, 86400)
2859
+ recent_logins = await _recent_logins(settings, limit=20)
2860
+ except Exception as e:
2861
+ logger.warning("activity: login activity failed: %s", e)
2862
+
2863
+ # --- Build per-integration rows honestly. --------------------------
2864
+ # The shared "mcp" bucket covers all four MCP clients at once, so its
2865
+ # write volume cannot be split between them — mark each "partial" and
2866
+ # surface the SHARED bucket totals (clearly partial, not per-client).
2867
+ mcp_bucket = by_source.get("mcp")
2868
+
2869
+ def _health_from(writes, reads, last_at) -> str:
2870
+ if writes is None and reads is None:
2871
+ return "no_data"
2872
+ if (writes or 0) > 0 or (reads or 0) > 0:
2873
+ return "healthy"
2874
+ return "degraded"
2875
+
2876
+ integrations_out = []
2877
+ for c in catalogue:
2878
+ bucket = c["bucket"]
2879
+ telemetry = "none"
2880
+ unique_users = writes = reads = None
2881
+ last_activity = None
2882
+ error_rate = None
2883
+
2884
+ if bucket and bucket.startswith("adapter:"):
2885
+ src = bucket.split(":", 1)[1]
2886
+ row = by_source.get(src)
2887
+ if row:
2888
+ telemetry = "tracked"
2889
+ writes = row["writes"]
2890
+ unique_users = row["unique_namespaces"]
2891
+ reads = None # reads are not attributed per-source in L1
2892
+ last_activity = _iso(row["last_at"])
2893
+ else:
2894
+ telemetry = "tracked" # we DO track this source; just no rows
2895
+ elif bucket == "mcp":
2896
+ # Shared bucket — real volume, but not separable per client.
2897
+ telemetry = "partial"
2898
+ if mcp_bucket:
2899
+ writes = mcp_bucket["writes"]
2900
+ unique_users = mcp_bucket["unique_namespaces"]
2901
+ last_activity = _iso(mcp_bucket["last_at"])
2902
+ elif bucket == "ext":
2903
+ telemetry = "tracked"
2904
+ wau = ext_analytics.get("wau")
2905
+ msgs = ext_analytics.get("messages_captured") or {}
2906
+ if ext_analytics:
2907
+ unique_users = int(wau) if wau is not None else None
2908
+ # Captured messages are the extension's real write signal.
2909
+ writes = sum(int(v) for v in msgs.values()) if msgs else 0
2910
+ sites = ext_analytics.get("site_usage") or {}
2911
+ reads = sum(int(v) for v in sites.values()) if sites else 0
2912
+ elif bucket == "api":
2913
+ telemetry = "tracked"
2914
+ if api_activity:
2915
+ unique_users = api_activity.get("active_integrations")
2916
+ last_activity = _iso(api_activity.get("last_used_at"))
2917
+
2918
+ health = _health_from(writes, reads, last_activity)
2919
+ if telemetry == "none":
2920
+ health = "no_data"
2921
+
2922
+ integrations_out.append({
2923
+ "id": c["id"],
2924
+ "name": c["name"],
2925
+ "category": c["category"],
2926
+ "telemetry": telemetry,
2927
+ "unique_users_7d": unique_users,
2928
+ "writes_7d": writes,
2929
+ "reads_7d": reads,
2930
+ "last_activity": last_activity,
2931
+ "health": health,
2932
+ "error_rate": error_rate,
2933
+ })
2934
+
2935
+ # --- Totals. -------------------------------------------------------
2936
+ totals = {
2937
+ "active_users_24h": int(active_now or dau or 0),
2938
+ "active_users_7d": int(active_7d or 0),
2939
+ "logins_24h": int(logins_24h or 0),
2940
+ "total_memories": int(total_memories) if total_memories is not None else 0,
2941
+ "writes_24h": int(agg.get("write", 0) or 0),
2942
+ "reads_24h": int(agg.get("read", 0) or 0),
2943
+ "api_error_rate_60m": round(float(api_error_rate_60m), 2),
2944
+ }
2945
+
2946
+ # --- Issues: feedback + frustrated pulse (+ best-effort github). ----
2947
+ issues_recent: list[dict] = []
2948
+ try:
2949
+ fb_rows = await user_service.store.get_recent_feedback(limit=15)
2950
+ for f in fb_rows:
2951
+ score = f.get("score") or 0
2952
+ sentiment = f.get("sentiment")
2953
+ if sentiment == "down" or score <= 2:
2954
+ severity = "high"
2955
+ elif score == 3:
2956
+ severity = "med"
2957
+ else:
2958
+ severity = "low"
2959
+ issues_recent.append({
2960
+ "kind": "feedback",
2961
+ "title": (f.get("comment") or f.get("context") or "Feedback").strip()[:120],
2962
+ "who": f.get("email"),
2963
+ "severity": severity,
2964
+ "at": f.get("created_at"),
2965
+ "url": None,
2966
+ })
2967
+ except Exception as e:
2968
+ logger.warning("activity: recent feedback failed: %s", e)
2969
+
2970
+ try:
2971
+ from clsplusplus.user_pulse import list_frustrated_users
2972
+ frustrated = await list_frustrated_users(settings, limit=10)
2973
+ for p in frustrated:
2974
+ issues_recent.append({
2975
+ "kind": "pulse",
2976
+ "title": "User frustration detected",
2977
+ "who": p.get("user_id"),
2978
+ "severity": "high" if p.get("score", 0) >= 75 else "med",
2979
+ "at": _iso(now),
2980
+ "url": None,
2981
+ })
2982
+ except Exception as e:
2983
+ logger.warning("activity: pulse failed: %s", e)
2984
+
2985
+ # Best-effort open GitHub bug count (skip silently when no token).
2986
+ open_issues = len([i for i in issues_recent if i["severity"] == "high"])
2987
+ try:
2988
+ gh_token = getattr(settings, "github_issue_token", "")
2989
+ gh_repo = getattr(settings, "github_issue_repo", "")
2990
+ if gh_token and gh_repo:
2991
+ import httpx as _httpx
2992
+ async with _httpx.AsyncClient(timeout=5.0) as _c:
2993
+ r = await _c.get(
2994
+ f"https://api.github.com/repos/{gh_repo}/issues",
2995
+ params={"state": "open", "labels": "bug", "per_page": 5},
2996
+ headers={
2997
+ "Authorization": f"Bearer {gh_token}",
2998
+ "Accept": "application/vnd.github+json",
2999
+ },
3000
+ )
3001
+ if r.status_code == 200:
3002
+ for gi in r.json()[:5]:
3003
+ issues_recent.append({
3004
+ "kind": "github",
3005
+ "title": (gi.get("title") or "")[:120],
3006
+ "who": (gi.get("user") or {}).get("login"),
3007
+ "severity": "high",
3008
+ "at": gi.get("created_at"),
3009
+ "url": gi.get("html_url"),
3010
+ })
3011
+ except Exception as e:
3012
+ logger.warning("activity: github issues probe failed: %s", e)
3013
+
3014
+ issues_recent = issues_recent[:15]
3015
+ issues = {"open": open_issues, "recent": issues_recent}
3016
+
3017
+ # --- Activity feed: recent logins + recent memory writes. ----------
3018
+ activity: list[tuple] = []
3019
+ for lg in recent_logins:
3020
+ activity.append((
3021
+ lg.get("ts", 0),
3022
+ {
3023
+ "at": lg.get("at"),
3024
+ "user": lg.get("email") or lg.get("user_id"),
3025
+ "integration": None,
3026
+ "action": "login",
3027
+ },
3028
+ ))
3029
+ for w in recent_l1_writes:
3030
+ at_iso = _iso(w.get("at"))
3031
+ ts = 0
3032
+ try:
3033
+ ts = int(w["at"].timestamp()) if w.get("at") else 0
3034
+ except Exception:
3035
+ ts = 0
3036
+ src = w.get("source")
3037
+ integ = src if src and src != "user" else None
3038
+ activity.append((
3039
+ ts,
3040
+ {
3041
+ "at": at_iso,
3042
+ "user": w.get("namespace"),
3043
+ "integration": integ,
3044
+ "action": "write",
3045
+ },
3046
+ ))
3047
+ activity.sort(key=lambda t: t[0], reverse=True)
3048
+ activity_out = [a[1] for a in activity[:20]]
3049
+
3050
+ return {
3051
+ "generated_at": _iso(now),
3052
+ "window": "7d",
3053
+ "totals": totals,
3054
+ "integrations": integrations_out,
3055
+ "issues": issues,
3056
+ "activity": activity_out,
3057
+ }
3058
+
2690
3059
  def _require_admin(request: Request):
2691
3060
  """Helper to enforce admin access on endpoints."""
2692
3061
  if not getattr(request.state, "is_admin", False):
@@ -4017,6 +4386,17 @@ def create_app(settings: Optional[Settings] = None) -> FastAPI:
4017
4386
  # ── 1. Warm up all persisted namespaces from L1 ────────────────────
4018
4387
  await memory_service.startup_preload()
4019
4388
 
4389
+ # ── 1b. Start the durable WAL (file fsync anchor + Postgres/Redis ──
4390
+ # replicas). With it active, writes ack only after fsync and are
4391
+ # replicated off the hot path; on restart, replay re-ships any
4392
+ # records past the durable checkpoint. No-op if CLS_WAL_ENABLED
4393
+ # is false. Redis client attaches when available.
4394
+ try:
4395
+ _wal_redis = getattr(memory_service, "_redis_client", None) or getattr(app.state, "redis", None)
4396
+ memory_service.init_durable_log(redis_client=_wal_redis)
4397
+ except Exception as e:
4398
+ _api_logger.error("Durable WAL startup failed: %s", e)
4399
+
4020
4400
  # ── 2. Periodic hippocampal replay (consolidation / sleep cycle) ───
4021
4401
  async def _periodic_replay():
4022
4402
  while True:
@@ -150,6 +150,19 @@ class Settings(BaseSettings):
150
150
  # current write behavior is unchanged.
151
151
  belief_revision_on_write: bool = False # CLS_BELIEF_REVISION_ON_WRITE
152
152
 
153
+ # ---- Durable write-ahead log (WAL) --------------------------------------
154
+ # Durability contract: a write is acked ONLY after it is fsync'd to the
155
+ # local WAL. Redis + Postgres are async replicas fed from the WAL; if either
156
+ # is unreachable the record is already durable on disk and the background
157
+ # reconciler retries until both confirm, then the record is checkpointed.
158
+ wal_enabled: bool = True # CLS_WAL_ENABLED
159
+ wal_dir: str = "./cls_wal" # CLS_WAL_DIR (mount a persistent disk here in prod)
160
+ wal_fsync: str = "always" # CLS_WAL_FSYNC: "always" (zero-loss) | "everysec"
161
+ wal_segment_max_bytes: int = 64 * 1024 * 1024 # CLS_WAL_SEGMENT_MAX_BYTES (rotate at 64MB)
162
+ wal_redis_stream: str = "cls:wal" # CLS_WAL_REDIS_STREAM
163
+ wal_reconcile_interval_seconds: float = 1.0 # CLS_WAL_RECONCILE_INTERVAL_SECONDS
164
+ wal_reconcile_batch: int = 256 # CLS_WAL_RECONCILE_BATCH
165
+
153
166
  # Working buffer
154
167
  l0_capacity_tokens: int = 4096
155
168
  l0_ttl_seconds: int = 300
@@ -0,0 +1,21 @@
1
+ """Durable write-ahead log for CLS++.
2
+
3
+ Durability contract: a memory write is acknowledged ONLY after its record has
4
+ been fsync'd to the local WAL. Redis and Postgres are asynchronous replicas fed
5
+ from the WAL — if either is unreachable the record is already durable on disk and
6
+ the background reconciler retries replication until both confirm, after which the
7
+ record is checkpointed and its segment can be truncated.
8
+
9
+ Components:
10
+ - WalRecord the serialized unit of durability (length-prefixed + CRC32)
11
+ - FileWal append + fsync, segmented, crash-safe replay, checkpoint
12
+ - Replica (protocol) an async sink (Redis stream, Postgres outbox) fed from the WAL
13
+ - DurableLog orchestrator: hot-path append/ack + concurrent replication
14
+ + background reconciler + startup replay
15
+ """
16
+
17
+ from clsplusplus.durable_log.record import WalRecord
18
+ from clsplusplus.durable_log.file_wal import FileWal
19
+ from clsplusplus.durable_log.manager import DurableLog, Replica, ReplicaState
20
+
21
+ __all__ = ["WalRecord", "FileWal", "DurableLog", "Replica", "ReplicaState"]
@@ -0,0 +1,181 @@
1
+ """Local file write-ahead log — the durability anchor.
2
+
3
+ A write is durable the instant ``append()`` returns with ``fsync="always"``:
4
+ the framed record is written and ``os.fsync`` forces it to stable storage before
5
+ we ack the caller. Segments rotate at a size cap; a monotonic ``seq`` fixes apply
6
+ order; a checkpoint records the highest seq confirmed by all replicas so fully
7
+ drained segments can be deleted.
8
+
9
+ This file knows nothing about Redis or Postgres — it is the on-disk source of
10
+ truth they replicate from.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import os
16
+ import threading
17
+ import time
18
+ from typing import Iterator, Optional
19
+
20
+ from clsplusplus.durable_log.record import WalRecord, iter_frames
21
+
22
+ _SEG_PREFIX = "wal-"
23
+ _SEG_SUFFIX = ".log"
24
+ _CHECKPOINT = "checkpoint"
25
+
26
+
27
+ def _seg_name(start_seq: int) -> str:
28
+ return f"{_SEG_PREFIX}{start_seq:020d}{_SEG_SUFFIX}"
29
+
30
+
31
+ def _seg_start(name: str) -> int:
32
+ return int(name[len(_SEG_PREFIX): -len(_SEG_SUFFIX)])
33
+
34
+
35
+ class FileWal:
36
+ def __init__(self, directory: str, *, fsync: str = "always",
37
+ segment_max_bytes: int = 64 * 1024 * 1024):
38
+ self.dir = directory
39
+ self.fsync_mode = fsync
40
+ self.segment_max_bytes = segment_max_bytes
41
+ self._lock = threading.Lock()
42
+ self._fh = None # current segment file handle (ab)
43
+ self._cur_start = 0 # start seq of current segment
44
+ self._cur_bytes = 0
45
+ self._next_seq = 1
46
+ self._last_fsync = 0.0
47
+ os.makedirs(self.dir, exist_ok=True)
48
+ self._open_tail()
49
+
50
+ # -- lifecycle ----------------------------------------------------------
51
+ def _segments(self) -> list[str]:
52
+ names = [f for f in os.listdir(self.dir)
53
+ if f.startswith(_SEG_PREFIX) and f.endswith(_SEG_SUFFIX)]
54
+ return sorted(names, key=_seg_start)
55
+
56
+ def _open_tail(self) -> None:
57
+ segs = self._segments()
58
+ if not segs:
59
+ self._cur_start = 1
60
+ self._next_seq = 1
61
+ path = os.path.join(self.dir, _seg_name(self._cur_start))
62
+ self._fh = open(path, "ab", buffering=0)
63
+ self._cur_bytes = 0
64
+ return
65
+ # Resume the last segment; recover next_seq by scanning intact frames.
66
+ last = segs[-1]
67
+ self._cur_start = _seg_start(last)
68
+ path = os.path.join(self.dir, last)
69
+ with open(path, "rb") as f:
70
+ buf = f.read()
71
+ max_seq = self._cur_start - 1
72
+ good_bytes = 0
73
+ for rec, size in iter_frames(buf):
74
+ max_seq = rec.seq
75
+ good_bytes += size
76
+ # Truncate any torn tail so future appends are frame-aligned.
77
+ if good_bytes != len(buf):
78
+ with open(path, "r+b") as f:
79
+ f.truncate(good_bytes)
80
+ self._next_seq = max_seq + 1
81
+ self._cur_bytes = good_bytes
82
+ self._fh = open(path, "ab", buffering=0)
83
+
84
+ def close(self) -> None:
85
+ with self._lock:
86
+ if self._fh is not None:
87
+ self._fh.flush()
88
+ os.fsync(self._fh.fileno())
89
+ self._fh.close()
90
+ self._fh = None
91
+
92
+ # -- append (the ack gate) ---------------------------------------------
93
+ def append(self, record: WalRecord) -> WalRecord:
94
+ """Assign seq, frame, write, and fsync. Returns the stamped record.
95
+
96
+ When this returns under fsync="always", the record is on stable storage.
97
+ Blocking; callers on the event loop should use ``asyncio.to_thread``.
98
+ """
99
+ with self._lock:
100
+ record.seq = self._next_seq
101
+ frame = record.encode()
102
+ if self._cur_bytes and self._cur_bytes + len(frame) > self.segment_max_bytes:
103
+ self._rotate()
104
+ self._fh.write(frame)
105
+ self._cur_bytes += len(frame)
106
+ self._next_seq += 1
107
+ self._maybe_fsync()
108
+ return record
109
+
110
+ def _rotate(self) -> None:
111
+ self._fh.flush()
112
+ os.fsync(self._fh.fileno())
113
+ self._fh.close()
114
+ self._cur_start = self._next_seq
115
+ path = os.path.join(self.dir, _seg_name(self._cur_start))
116
+ self._fh = open(path, "ab", buffering=0)
117
+ self._cur_bytes = 0
118
+
119
+ def _maybe_fsync(self) -> None:
120
+ if self.fsync_mode == "always":
121
+ self._fh.flush()
122
+ os.fsync(self._fh.fileno())
123
+ return
124
+ # "everysec": flush now, fsync at most once per second.
125
+ self._fh.flush()
126
+ now = time.time()
127
+ if now - self._last_fsync >= 1.0:
128
+ os.fsync(self._fh.fileno())
129
+ self._last_fsync = now
130
+
131
+ # -- replay -------------------------------------------------------------
132
+ def read_from(self, after_seq: int = 0) -> Iterator[WalRecord]:
133
+ """Yield every intact record with seq > after_seq, in seq order."""
134
+ for name in self._segments():
135
+ path = os.path.join(self.dir, name)
136
+ with open(path, "rb") as f:
137
+ buf = f.read()
138
+ for rec, _ in iter_frames(buf):
139
+ if rec.seq > after_seq:
140
+ yield rec
141
+
142
+ @property
143
+ def next_seq(self) -> int:
144
+ return self._next_seq
145
+
146
+ # -- checkpoint / truncation -------------------------------------------
147
+ def read_checkpoint(self) -> int:
148
+ path = os.path.join(self.dir, _CHECKPOINT)
149
+ try:
150
+ with open(path, "r") as f:
151
+ return int(f.read().strip() or "0")
152
+ except (FileNotFoundError, ValueError):
153
+ return 0
154
+
155
+ def checkpoint(self, confirmed_seq: int) -> None:
156
+ """Persist the highest seq confirmed by ALL replicas, then delete any
157
+ segment whose records are entirely <= confirmed_seq (keep the tail).
158
+ """
159
+ with self._lock:
160
+ path = os.path.join(self.dir, _CHECKPOINT)
161
+ tmp = path + ".tmp"
162
+ with open(tmp, "w") as f:
163
+ f.write(str(confirmed_seq))
164
+ f.flush()
165
+ os.fsync(f.fileno())
166
+ os.replace(tmp, path)
167
+ self._truncate_below(confirmed_seq)
168
+
169
+ def _truncate_below(self, confirmed_seq: int) -> None:
170
+ segs = self._segments()
171
+ # A segment is fully drained if the NEXT segment starts at or below
172
+ # confirmed_seq+1. Never delete the current (tail) segment.
173
+ for i in range(len(segs) - 1):
174
+ nxt_start = _seg_start(segs[i + 1])
175
+ if nxt_start <= confirmed_seq + 1:
176
+ try:
177
+ os.remove(os.path.join(self.dir, segs[i]))
178
+ except FileNotFoundError:
179
+ pass
180
+ else:
181
+ break