superlocalmemory 3.8.13 → 4.0.0

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 (212) hide show
  1. package/ATTRIBUTION.md +4 -4
  2. package/CHANGELOG.md +113 -121
  3. package/README.md +65 -63
  4. package/docs/pi-dev-integration.md +1 -1
  5. package/package.json +6 -1
  6. package/plugin/.claude-plugin/plugin.json +1 -1
  7. package/plugin/CLAUDE.md +3 -3
  8. package/plugin/agents/slm-governance-advisor.md +1 -1
  9. package/plugin/agents/slm-loop-runner.md +1 -1
  10. package/plugin/agents/slm-memory-advisor.md +1 -1
  11. package/plugin/agents/slm-optimize-advisor.md +1 -1
  12. package/plugin/requirements.txt +1 -1
  13. package/plugin/skills/slm-cache/SKILL.md +1 -1
  14. package/plugin/skills/slm-compress/SKILL.md +1 -1
  15. package/plugin/skills/slm-governance/SKILL.md +1 -1
  16. package/plugin/skills/slm-graph/SKILL.md +1 -1
  17. package/plugin/skills/slm-loop/SKILL.md +1 -1
  18. package/plugin/skills/slm-mesh/SKILL.md +1 -1
  19. package/plugin/skills/slm-profile/SKILL.md +1 -1
  20. package/plugin/skills/slm-recall/SKILL.md +1 -1
  21. package/plugin/skills/slm-remember/SKILL.md +1 -1
  22. package/plugin/skills/slm-scope/SKILL.md +1 -1
  23. package/plugin/skills/slm-session/SKILL.md +1 -1
  24. package/plugin/skills/slm-status/SKILL.md +1 -1
  25. package/plugin-src/rules/AGENTS.md +1 -1
  26. package/plugin-src/skills/slm-cache/SKILL.md +1 -1
  27. package/plugin-src/skills/slm-compress/SKILL.md +1 -1
  28. package/plugin-src/skills/slm-governance/SKILL.md +248 -0
  29. package/plugin-src/skills/slm-graph/SKILL.md +1 -1
  30. package/plugin-src/skills/slm-loop/SKILL.md +99 -0
  31. package/plugin-src/skills/slm-mesh/SKILL.md +282 -0
  32. package/plugin-src/skills/slm-profile/SKILL.md +148 -0
  33. package/plugin-src/skills/slm-recall/SKILL.md +1 -1
  34. package/plugin-src/skills/slm-remember/SKILL.md +1 -1
  35. package/plugin-src/skills/slm-scope/SKILL.md +176 -0
  36. package/plugin-src/skills/slm-session/SKILL.md +1 -1
  37. package/plugin-src/skills/slm-status/SKILL.md +1 -1
  38. package/pyproject.toml +11 -4
  39. package/src/superlocalmemory/__init__.py +1 -1
  40. package/src/superlocalmemory/cli/commands.py +125 -11
  41. package/src/superlocalmemory/cli/daemon.py +5 -1
  42. package/src/superlocalmemory/cli/main.py +35 -2
  43. package/src/superlocalmemory/cli/ops_cmd.py +281 -0
  44. package/src/superlocalmemory/cli/setup_wizard.py +1 -1
  45. package/src/superlocalmemory/compliance/audit.py +65 -0
  46. package/src/superlocalmemory/compliance/eu_ai_act.py +27 -57
  47. package/src/superlocalmemory/compliance/gdpr.py +416 -20
  48. package/src/superlocalmemory/compliance/retention.py +74 -22
  49. package/src/superlocalmemory/compliance/scheduler.py +78 -9
  50. package/src/superlocalmemory/core/actor_context.py +166 -0
  51. package/src/superlocalmemory/core/admission.py +549 -0
  52. package/src/superlocalmemory/core/backend_orchestrator.py +23 -10
  53. package/src/superlocalmemory/core/config.py +202 -24
  54. package/src/superlocalmemory/core/consolidation_engine.py +13 -13
  55. package/src/superlocalmemory/core/context_cache.py +28 -0
  56. package/src/superlocalmemory/core/embeddings.py +64 -2
  57. package/src/superlocalmemory/core/engine.py +7 -2
  58. package/src/superlocalmemory/core/engine_ingestion.py +65 -3
  59. package/src/superlocalmemory/core/engine_wiring.py +36 -9
  60. package/src/superlocalmemory/core/ingest_policy.py +38 -0
  61. package/src/superlocalmemory/core/maintenance.py +255 -0
  62. package/src/superlocalmemory/core/modes.py +40 -13
  63. package/src/superlocalmemory/core/mutations.py +437 -44
  64. package/src/superlocalmemory/core/operation_policy.py +92 -0
  65. package/src/superlocalmemory/core/operation_policy_registry.py +542 -0
  66. package/src/superlocalmemory/core/operation_request.py +127 -0
  67. package/src/superlocalmemory/core/ops_remediation.py +542 -0
  68. package/src/superlocalmemory/core/recall_pipeline.py +7 -0
  69. package/src/superlocalmemory/core/remember_runtime.py +202 -4
  70. package/src/superlocalmemory/core/remote_mode.py +20 -5
  71. package/src/superlocalmemory/core/store_pipeline.py +150 -0
  72. package/src/superlocalmemory/core/topic_signature.py +19 -4
  73. package/src/superlocalmemory/core/transactions/__init__.py +78 -0
  74. package/src/superlocalmemory/core/transactions/concrete_owners.py +597 -0
  75. package/src/superlocalmemory/core/transactions/erasure.py +825 -0
  76. package/src/superlocalmemory/core/transactions/manifest.py +255 -0
  77. package/src/superlocalmemory/core/transactions/manifest_key.py +155 -0
  78. package/src/superlocalmemory/core/transactions/obligations.py +272 -0
  79. package/src/superlocalmemory/core/transactions/owners.py +114 -0
  80. package/src/superlocalmemory/core/transactions/reconciler.py +285 -0
  81. package/src/superlocalmemory/core/transactions/service.py +330 -0
  82. package/src/superlocalmemory/core/worker_pool.py +33 -5
  83. package/src/superlocalmemory/encoding/cognitive_consolidator.py +70 -28
  84. package/src/superlocalmemory/encoding/emotional.py +75 -14
  85. package/src/superlocalmemory/encoding/scene_builder.py +115 -13
  86. package/src/superlocalmemory/encoding/temporal_parser.py +4 -0
  87. package/src/superlocalmemory/evolution/blind_verifier.py +11 -4
  88. package/src/superlocalmemory/evolution/evolution_store.py +244 -4
  89. package/src/superlocalmemory/evolution/llm_dispatch.py +40 -0
  90. package/src/superlocalmemory/evolution/model_selection.py +18 -3
  91. package/src/superlocalmemory/evolution/mutation_generator.py +3 -0
  92. package/src/superlocalmemory/evolution/skill_activator.py +270 -0
  93. package/src/superlocalmemory/evolution/skill_evolver.py +281 -59
  94. package/src/superlocalmemory/evolution/types.py +30 -8
  95. package/src/superlocalmemory/graph/cozo_backend.py +17 -9
  96. package/src/superlocalmemory/hooks/auto_invoker.py +2 -1
  97. package/src/superlocalmemory/hooks/auto_recall.py +64 -30
  98. package/src/superlocalmemory/hooks/codex_assets.py +14 -1
  99. package/src/superlocalmemory/infra/backup.py +434 -7
  100. package/src/superlocalmemory/infra/process_reaper.py +18 -0
  101. package/src/superlocalmemory/infra/self_heal.py +401 -0
  102. package/src/superlocalmemory/learning/feedback.py +52 -9
  103. package/src/superlocalmemory/loops/engine.py +10 -0
  104. package/src/superlocalmemory/mcp/_daemon_proxy.py +3 -0
  105. package/src/superlocalmemory/mcp/http_transport.py +30 -331
  106. package/src/superlocalmemory/mcp/profiles.py +5 -0
  107. package/src/superlocalmemory/mcp/resources.py +8 -0
  108. package/src/superlocalmemory/mcp/server.py +51 -4
  109. package/src/superlocalmemory/mcp/shared.py +19 -0
  110. package/src/superlocalmemory/mcp/tools_active.py +25 -4
  111. package/src/superlocalmemory/mcp/tools_code_graph.py +26 -18
  112. package/src/superlocalmemory/mcp/tools_context.py +50 -8
  113. package/src/superlocalmemory/mcp/tools_core.py +69 -21
  114. package/src/superlocalmemory/mcp/tools_evolution.py +9 -2
  115. package/src/superlocalmemory/mcp/tools_learning.py +21 -10
  116. package/src/superlocalmemory/mcp/tools_loops.py +29 -18
  117. package/src/superlocalmemory/mcp/tools_mesh.py +8 -0
  118. package/src/superlocalmemory/mcp/tools_ops.py +115 -0
  119. package/src/superlocalmemory/mcp/tools_optimize.py +4 -0
  120. package/src/superlocalmemory/mcp/tools_v28.py +10 -3
  121. package/src/superlocalmemory/mcp/tools_v3.py +34 -14
  122. package/src/superlocalmemory/mcp/tools_v33.py +18 -33
  123. package/src/superlocalmemory/mesh/broker.py +124 -46
  124. package/src/superlocalmemory/mesh/broker_security.py +470 -0
  125. package/src/superlocalmemory/mesh/discovery.py +365 -0
  126. package/src/superlocalmemory/mesh/lock_protocol.py +313 -0
  127. package/src/superlocalmemory/mesh/node_identity.py +97 -0
  128. package/src/superlocalmemory/mesh/outbox_remote.py +429 -0
  129. package/src/superlocalmemory/mesh/remote_sync.py +511 -28
  130. package/src/superlocalmemory/mesh/state_sync.py +286 -0
  131. package/src/superlocalmemory/optimize/config/store.py +45 -0
  132. package/src/superlocalmemory/parameterization/cross_project.py +12 -0
  133. package/src/superlocalmemory/parameterization/prompt_injector.py +13 -11
  134. package/src/superlocalmemory/parameterization/prompt_lifecycle.py +8 -2
  135. package/src/superlocalmemory/parameterization/workflow_miner.py +17 -0
  136. package/src/superlocalmemory/retrieval/ann_index.py +5 -0
  137. package/src/superlocalmemory/retrieval/bm25_channel.py +49 -2
  138. package/src/superlocalmemory/retrieval/engine.py +19 -4
  139. package/src/superlocalmemory/retrieval/fusion.py +4 -1
  140. package/src/superlocalmemory/retrieval/hopfield_channel.py +9 -3
  141. package/src/superlocalmemory/retrieval/remote_reranker.py +47 -22
  142. package/src/superlocalmemory/retrieval/reranker.py +32 -1
  143. package/src/superlocalmemory/retrieval/temporal_channel.py +16 -3
  144. package/src/superlocalmemory/retrieval/temporal_utils.py +107 -0
  145. package/src/superlocalmemory/retrieval/temporal_validity_filter.py +155 -42
  146. package/src/superlocalmemory/retrieval/vector_store.py +214 -8
  147. package/src/superlocalmemory/server/api.py +5 -5
  148. package/src/superlocalmemory/server/egress_policy.py +258 -0
  149. package/src/superlocalmemory/server/rbac_enforce.py +32 -0
  150. package/src/superlocalmemory/server/route_mutations.py +20 -0
  151. package/src/superlocalmemory/server/routes/compliance.py +153 -7
  152. package/src/superlocalmemory/server/routes/data_io.py +43 -2
  153. package/src/superlocalmemory/server/routes/events.py +15 -0
  154. package/src/superlocalmemory/server/routes/memories.py +56 -3
  155. package/src/superlocalmemory/server/routes/mesh.py +82 -1
  156. package/src/superlocalmemory/server/routes/mesh_lock.py +54 -0
  157. package/src/superlocalmemory/server/routes/mesh_state.py +63 -0
  158. package/src/superlocalmemory/server/routes/v3_api.py +50 -27
  159. package/src/superlocalmemory/server/routes/ws.py +86 -0
  160. package/src/superlocalmemory/server/ui.py +6 -6
  161. package/src/superlocalmemory/server/unified_daemon.py +942 -119
  162. package/src/superlocalmemory/storage/_migration_internals.py +568 -0
  163. package/src/superlocalmemory/storage/_schema_version.py +110 -0
  164. package/src/superlocalmemory/storage/database.py +329 -24
  165. package/src/superlocalmemory/storage/embedding_migrator.py +246 -51
  166. package/src/superlocalmemory/storage/erasure_fence.py +45 -0
  167. package/src/superlocalmemory/storage/generation_fence.py +63 -0
  168. package/src/superlocalmemory/storage/migration_runner.py +140 -417
  169. package/src/superlocalmemory/storage/migrations/M009_model_lineage.py +40 -0
  170. package/src/superlocalmemory/storage/migrations/M033_projection_transactions.py +148 -0
  171. package/src/superlocalmemory/storage/migrations/M034_obligation_integrity.py +58 -0
  172. package/src/superlocalmemory/storage/migrations/M035_erasure_receipts.py +113 -0
  173. package/src/superlocalmemory/storage/migrations/M036_vector_row_map.py +107 -0
  174. package/src/superlocalmemory/storage/migrations/M037_manifest_hmac_version.py +162 -0
  175. package/src/superlocalmemory/storage/migrations/{M033_learning_feedback_channel.py → M038_learning_feedback_channel.py} +3 -3
  176. package/src/superlocalmemory/storage/migrations/M039_scene_fact_members.py +137 -0
  177. package/src/superlocalmemory/storage/migrations/__init__.py +4 -2
  178. package/src/superlocalmemory/storage/schema.py +67 -0
  179. package/src/superlocalmemory/storage/write_coordinator.py +125 -0
  180. package/src/superlocalmemory/trust/scorer.py +28 -4
  181. package/src/superlocalmemory/ui/index.html +14 -3
  182. package/src/superlocalmemory/ui/js/auto-settings.js +12 -1
  183. package/src/superlocalmemory/ui/js/brain.js +6 -4
  184. package/src/superlocalmemory/ui/js/compliance.js +66 -12
  185. package/src/superlocalmemory/ui/js/dashboard.js +13 -3
  186. package/src/superlocalmemory/ui/js/feedback.js +8 -2
  187. package/src/superlocalmemory/ui/js/lifecycle.js +7 -1
  188. package/src/superlocalmemory/ui/js/modal.js +272 -5
  189. package/src/superlocalmemory/ui/js/od-backup.js +9 -2
  190. package/src/superlocalmemory/ui/js/od-compliance-ext.js +301 -0
  191. package/src/superlocalmemory/ui/js/od-operations.js +154 -23
  192. package/src/superlocalmemory/ui/js/od-ops-health.js +417 -0
  193. package/src/superlocalmemory/ui/js/od-optimize.js +35 -21
  194. package/src/superlocalmemory/ui/js/od-team.js +9 -2
  195. package/src/superlocalmemory/ui/js/optimize.js +13 -16
  196. package/src/superlocalmemory/ui/js/profiles.js +7 -3
  197. package/src/superlocalmemory/ui/js/settings.js +7 -1
  198. package/src/superlocalmemory/vector/lancedb_backend.py +19 -9
  199. package/src/superlocalmemory/attribution/mathematical_dna.py +0 -235
  200. package/src/superlocalmemory/cli/post_install.py +0 -114
  201. package/src/superlocalmemory/core/clock_monitor.py +0 -45
  202. package/src/superlocalmemory/core/db_pool.py +0 -80
  203. package/src/superlocalmemory/core/error_catalog.py +0 -113
  204. package/src/superlocalmemory/core/loop_watchdog.py +0 -56
  205. package/src/superlocalmemory/core/priority_queue.py +0 -61
  206. package/src/superlocalmemory/core/pruning_engine.py +0 -216
  207. package/src/superlocalmemory/core/queue_dispatcher.py +0 -73
  208. package/src/superlocalmemory/core/slmignore.py +0 -125
  209. package/src/superlocalmemory/infra/heartbeat_monitor.py +0 -140
  210. package/src/superlocalmemory/infra/webhook_dispatcher.py +0 -247
  211. package/src/superlocalmemory/learning/quantization_scheduler.py +0 -320
  212. package/src/superlocalmemory/storage/access_control.py +0 -182
@@ -151,14 +151,26 @@ def build_immediate_admission_handler(
151
151
  from datetime import UTC, datetime
152
152
 
153
153
  from superlocalmemory.core.ingest_gate import apply_ingest_gate
154
+ from superlocalmemory.core.ingest_policy import scrub_secrets_for_ingest
154
155
  from superlocalmemory.storage.models import AtomicFact, FactType, MemoryRecord
155
156
 
157
+ # Secrets are ALWAYS scrubbed at this shared queryable-write chokepoint,
158
+ # which both the canonical HTTP /remember runtime and the canonical_store
159
+ # Python/CLI path funnel through. A credential must never persist verbatim
160
+ # in any durable representation (memory rows, fact rows), regardless of
161
+ # ingress surface. The scrub is idempotent: content already scrubbed by an
162
+ # upstream caller is returned unchanged.
163
+ scrub = scrub_secrets_for_ingest(request.content)
164
+ content = scrub.content
165
+ if scrub.redacted:
166
+ logger.info("secret scrub: redacted credential material on queryable write")
167
+
156
168
  metadata = dict(request.metadata)
157
169
  metadata["ingestion_operation_id"] = operation_id
158
170
  if request.session_id:
159
171
  metadata.setdefault("session_id", request.session_id)
160
172
  gate = apply_ingest_gate(
161
- request.content,
173
+ content,
162
174
  max_verbatim_chars=max_verbatim_chars,
163
175
  max_ingest_bytes=max_ingest_bytes,
164
176
  )
@@ -225,7 +237,7 @@ def build_immediate_admission_handler(
225
237
  record = MemoryRecord(
226
238
  memory_id=memory_id,
227
239
  profile_id=request.profile_id,
228
- content=request.content,
240
+ content=content,
229
241
  session_id=request.session_id,
230
242
  session_date=observation_date,
231
243
  speaker=request.speaker,
@@ -302,6 +314,15 @@ def canonical_store(
302
314
  error=ValueError("content rejected by local admission policy"),
303
315
  )
304
316
  return []
317
+ # Secrets are ALWAYS scrubbed pre-admission (not opt-in): a credential must
318
+ # never persist verbatim in any durable or queryable representation
319
+ # (facts, receipts, journal, exports, backups, mesh).
320
+ from superlocalmemory.core.ingest_policy import scrub_secrets_for_ingest
321
+
322
+ _secret_scrub = scrub_secrets_for_ingest(content)
323
+ if _secret_scrub.redacted:
324
+ content = _secret_scrub.content
325
+ logger.info("secret scrub: redacted credential material on ingest")
305
326
  # C4: opt-in PII redaction. When enabled (config.pii_redaction or
306
327
  # SLM_PII_REDACTION), scrub personal identifiers BEFORE the content is
307
328
  # extracted, embedded, or persisted — nothing sensitive ever reaches disk.
@@ -426,7 +447,19 @@ def build_engine_ingestion_command(engine: MemoryEngine) -> IngestionCommand:
426
447
  )
427
448
 
428
449
  def validate_admission(request: IngestionRequest) -> None:
429
- """Apply trust policy before the journal or canonical transaction."""
450
+ """Apply trust policy before the journal or canonical transaction.
451
+
452
+ V4 Phase 4 addition: evaluate the OperationPolicyRegistry AFTER the
453
+ trust hook and BEFORE the coordinator transaction. This evaluation is
454
+ CPU-only (dict lookup + frozenset operations, no I/O) and runs in the
455
+ validate_admission slot which is already outside the SQLite writer.
456
+
457
+ The default registry allows every REMEMBER from an OWNER via the
458
+ INTERNAL transport in local mode — zero new rejections on the current
459
+ happy path. A denial raises ValueError, which propagates through
460
+ IngestionCommand.submit() to the caller exactly as existing slot
461
+ violations do.
462
+ """
430
463
  engine._hooks.run_pre("store", {
431
464
  "operation": "store",
432
465
  "agent_id": request.trusted_actor_id,
@@ -434,6 +467,35 @@ def build_engine_ingestion_command(engine: MemoryEngine) -> IngestionCommand:
434
467
  "content_preview": request.content[:100],
435
468
  })
436
469
 
470
+ # Phase 4: additive policy check — runs after the trust hook.
471
+ # Imports are lazy to avoid import-order coupling at module init.
472
+ from superlocalmemory.core.actor_context import ActorContext, ActorRole, Transport
473
+ from superlocalmemory.core.operation_policy_registry import _DEFAULT_REGISTRY
474
+ from superlocalmemory.core.operation_request import OperationKind
475
+
476
+ # A missing actor is rejected by the existing admission check with its
477
+ # canonical message; the policy layer is strictly additive and must not
478
+ # preempt that rejection, so it only evaluates when an actor is present.
479
+ if request.trusted_actor_id:
480
+ _actor = ActorContext(
481
+ # trusted_actor_id is already server-validated by the caller;
482
+ # it is NEVER taken from the request body here.
483
+ principal_id=request.trusted_actor_id,
484
+ roles=frozenset({ActorRole.OWNER}),
485
+ active_profile_id=request.profile_id,
486
+ transport=Transport.INTERNAL,
487
+ client_host="", # in-process: always local
488
+ )
489
+ _decision = _DEFAULT_REGISTRY.evaluate(
490
+ OperationKind.REMEMBER,
491
+ _actor,
492
+ "local", # internal Python-API path is always local/single-user
493
+ )
494
+ if not _decision.allowed:
495
+ raise ValueError(
496
+ f"operation policy denied REMEMBER: {_decision.reason}"
497
+ )
498
+
437
499
  def resume_checkpoint(operation: IngestionOperation) -> MaterializationResult:
438
500
  """Repair only stages whose writes have an idempotent natural key."""
439
501
  state = dict(operation.derivation_state)
@@ -264,7 +264,10 @@ def init_encoding(
264
264
  db, embedder, llm, config.encoding,
265
265
  )
266
266
  observation_builder = ObservationBuilder(db)
267
- scene_builder = SceneBuilder(db, embedder)
267
+ # V3.2: VectorStore (Phase 1) -- sqlite-vec KNN. Scene assignment also
268
+ # consumes it, so initialize it before the encoding component is wired.
269
+ vector_store = _init_vector_store(config)
270
+ scene_builder = SceneBuilder(db, embedder, vector_store=vector_store)
268
271
  entropy_gate = EntropyGate(
269
272
  embedder, config.encoding.entropy_threshold,
270
273
  )
@@ -276,9 +279,6 @@ def init_encoding(
276
279
  db, config.math.sheaf_contradiction_threshold,
277
280
  )
278
281
 
279
- # V3.2: VectorStore (Phase 1) -- sqlite-vec KNN
280
- vector_store = _init_vector_store(config)
281
-
282
282
  # V3.2: AccessLog (Phase 1) -- fact access tracking
283
283
  access_log = _init_access_log(db)
284
284
 
@@ -486,6 +486,25 @@ def _init_spreading_activation(
486
486
  return None
487
487
 
488
488
 
489
+ def _init_prompt_injector(config: SLMConfig, db: DatabaseManager) -> Any | None:
490
+ """Construct a profile-scoped PromptInjector for soft-prompt injection (P1-8).
491
+
492
+ Returns None when parameterization is disabled or construction fails.
493
+ Failure is fail-soft — soft-prompt absence must never block auto-invoke.
494
+ """
495
+ if not hasattr(config, "parameterization") or not config.parameterization.enabled:
496
+ return None
497
+ try:
498
+ from superlocalmemory.parameterization.prompt_injector import PromptInjector
499
+ from superlocalmemory.parameterization.soft_prompt_generator import SoftPromptGenerator
500
+
501
+ generator = SoftPromptGenerator(config.parameterization)
502
+ return PromptInjector(db=db, generator=generator, config=config.parameterization)
503
+ except Exception as exc:
504
+ logger.warning("PromptInjector init failed — soft-prompt injection disabled: %s", exc)
505
+ return None
506
+
507
+
489
508
  def _init_auto_invoker(
490
509
  config: SLMConfig,
491
510
  db: DatabaseManager,
@@ -493,20 +512,28 @@ def _init_auto_invoker(
493
512
  trust_scorer: Any,
494
513
  embedder: Any,
495
514
  ) -> Any | None:
496
- """Create AutoInvoker for Phase 2 multi-signal retrieval."""
515
+ """Create AutoInvoker for Phase 2 multi-signal retrieval.
516
+
517
+ V3.3 (P1-8): A profile-scoped PromptInjector is constructed and wired in
518
+ so stored behavioral soft prompts are prepended to every session context.
519
+ The injector is fail-soft — its absence never blocks auto-invoke.
520
+ """
497
521
  if not hasattr(config, "auto_invoke") or not config.auto_invoke.enabled:
498
522
  return None
499
523
  try:
500
524
  from superlocalmemory.hooks.auto_invoker import AutoInvoker
525
+
526
+ prompt_injector = _init_prompt_injector(config, db)
501
527
  return AutoInvoker(
502
528
  db=db,
503
529
  vector_store=vector_store,
504
530
  trust_scorer=trust_scorer,
505
531
  embedder=embedder,
506
532
  config=config.auto_invoke,
533
+ prompt_injector=prompt_injector,
507
534
  )
508
535
  except Exception as exc:
509
- logger.debug("AutoInvoker init failed: %s", exc)
536
+ logger.warning("AutoInvoker init failed — auto-invoke disabled: %s", exc)
510
537
  return None
511
538
 
512
539
 
@@ -580,9 +607,9 @@ def init_retrieval(
580
607
  trust_scorer: Any,
581
608
  vector_store: Any = None,
582
609
  ) -> Any:
583
- """Create the RetrievalEngine — five candidate producers (semantic, BM25,
584
- temporal, spreading_activation, hopfield) plus the entity graph used for
585
- post-fusion score enhancement. Returns it."""
610
+ """Create the RetrievalEngine — six candidate producers (semantic, BM25,
611
+ entity_graph, temporal, spreading_activation, hopfield) fused via RRF.
612
+ Returns the engine."""
586
613
  from superlocalmemory.retrieval.engine import RetrievalEngine
587
614
  from superlocalmemory.retrieval.semantic_channel import SemanticChannel
588
615
  from superlocalmemory.retrieval.bm25_channel import BM25Channel
@@ -0,0 +1,38 @@
1
+ """Pre-admission scrub policy that removes secret material from content.
2
+
3
+ A single ordered stage that redacts secrets BEFORE content can reach any durable
4
+ or queryable representation (atomic facts, memory records, ingestion receipts,
5
+ journal, exports, backups, mesh). Unlike PII redaction — an opt-in operator
6
+ policy — secret scrubbing is unconditional: a credential persisted verbatim at
7
+ rest is always incorrect.
8
+
9
+ The detector is the ``security_primitives.redact_secrets`` redactor; this module
10
+ wraps it in an immutable result so callers can log the fact of redaction without
11
+ ever handling the secret value.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ from dataclasses import dataclass
16
+
17
+ from superlocalmemory.core.security_primitives import redact_secrets
18
+
19
+
20
+ @dataclass(frozen=True, slots=True)
21
+ class ScrubResult:
22
+ """Outcome of a pre-admission scrub. Never carries the original secret."""
23
+
24
+ content: str
25
+ redacted: bool
26
+
27
+
28
+ def scrub_secrets_for_ingest(content: str) -> ScrubResult:
29
+ """Redact secret material from ``content`` before durable admission.
30
+
31
+ Returns a :class:`ScrubResult` whose ``content`` is safe to persist. When
32
+ nothing matched, ``content`` is returned unchanged and ``redacted`` is False,
33
+ so the caller can keep the original object and skip a log line.
34
+ """
35
+ if not content:
36
+ return ScrubResult(content=content, redacted=False)
37
+ scrubbed = redact_secrets(content)
38
+ return ScrubResult(content=scrubbed, redacted=scrubbed != content)
@@ -36,6 +36,12 @@ _BACKFILL_BURN_IN_STEPS = 50
36
36
  _LANGEVIN_DIM = 8
37
37
  _MAX_NORM = 0.99
38
38
 
39
+ # ELC zone vocabulary: EbbinghausCurve returns 'archive'/'forgotten' but
40
+ # atomic_facts.lifecycle CHECK only allows 'active|warm|cold|archived'.
41
+ # Remap at write boundary so ELC never triggers IntegrityError.
42
+ _VALID_LIFECYCLE_ZONES: frozenset[str] = frozenset({"active", "warm", "cold", "archived"})
43
+ _ELC_ZONE_REMAP: dict[str, str] = {"archive": "archived", "forgotten": "archived"}
44
+
39
45
 
40
46
  def _age_days(created_at: str | None) -> float:
41
47
  """Age in days from an ISO timestamp.
@@ -105,6 +111,86 @@ def _seed_langevin_position(
105
111
  return (direction / norm * r_eq).tolist()
106
112
 
107
113
 
114
+ def close_stale_sessions(
115
+ db: DatabaseManager,
116
+ profile_id: str = "default",
117
+ *,
118
+ idle_hours: float = 24.0,
119
+ max_per_pass: int = 50,
120
+ ) -> int:
121
+ """Close application sessions idle longer than ``idle_hours``.
122
+
123
+ Nothing auto-calls ``close_session`` except the MCP tool — un-closed
124
+ sessions never get temporal summaries. This maintenance pass finds
125
+ sessions whose newest fact is older than the idle window and closes
126
+ them via ``run_close_session``.
127
+
128
+ Properties:
129
+ - Idempotent: already-summarised sessions are skipped (no double write).
130
+ - Bounded: at most ``max_per_pass`` sessions closed per call.
131
+ - Only sessions with entity-linked facts (summarisable) are selected.
132
+
133
+ Returns:
134
+ Number of sessions successfully summarised in this pass.
135
+ """
136
+ if idle_hours <= 0 or max_per_pass <= 0:
137
+ return 0
138
+
139
+ from superlocalmemory.core.store_pipeline import (
140
+ _session_already_summarised,
141
+ run_close_session,
142
+ )
143
+
144
+ cutoff = (datetime.now(UTC) - timedelta(hours=float(idle_hours))).isoformat()
145
+ # Over-fetch slightly so already-closed rows in the window do not starve
146
+ # the bounded close budget.
147
+ fetch_limit = max(int(max_per_pass) * 3, int(max_per_pass))
148
+ try:
149
+ rows = db.execute(
150
+ """
151
+ SELECT session_id, MAX(created_at) AS last_at
152
+ FROM atomic_facts
153
+ WHERE profile_id = ?
154
+ AND session_id IS NOT NULL
155
+ AND session_id != ''
156
+ AND canonical_entities_json IS NOT NULL
157
+ AND canonical_entities_json != '[]'
158
+ GROUP BY session_id
159
+ HAVING MAX(created_at) < ?
160
+ ORDER BY last_at ASC
161
+ LIMIT ?
162
+ """,
163
+ (profile_id, cutoff, fetch_limit),
164
+ )
165
+ except Exception as exc: # pragma: no cover — defensive
166
+ logger.debug("stale session query failed: %s", exc)
167
+ return 0
168
+
169
+ closed = 0
170
+ for row in rows:
171
+ if closed >= int(max_per_pass):
172
+ break
173
+ d = dict(row)
174
+ sid = str(d.get("session_id") or "")
175
+ if not sid:
176
+ continue
177
+ if _session_already_summarised(db, profile_id, sid):
178
+ continue
179
+ try:
180
+ n = run_close_session(sid, profile_id, db=db)
181
+ except Exception as exc:
182
+ logger.warning("stale session close failed for %s: %s", sid, exc)
183
+ continue
184
+ if n > 0:
185
+ closed += 1
186
+ if closed:
187
+ logger.info(
188
+ "Closed %d stale session(s) (idle > %.1fh, profile=%s)",
189
+ closed, idle_hours, profile_id,
190
+ )
191
+ return closed
192
+
193
+
108
194
  def run_maintenance(
109
195
  db: DatabaseManager,
110
196
  config: SLMConfig,
@@ -130,13 +216,30 @@ def run_maintenance(
130
216
  "langevin_backfilled": 0,
131
217
  "langevin_updated": 0,
132
218
  "fisher_coupled": 0,
219
+ "fisher_posterior_updated": 0, # P1-9: Fisher bayesian_update on access
220
+ "ebbinghaus_coupled": 0, # Phase 5: Ebbinghaus-Langevin coupling
133
221
  "sheaf_checked": 0,
134
222
  "entity_summaries_consolidated": 0, # V3.4.40
135
223
  "orphan_metadata_gc": 0, # v3.6.4 (P1-3)
136
224
  "expansion_backfilled": 0, # T3b
137
225
  "embeddings_backfilled": 0, # v3.8.x NULL-embedding self-heal
226
+ "stale_sessions_closed": 0, # v4: orphaned application sessions
138
227
  }
139
228
 
229
+ # Close idle application sessions so temporal summaries exist even when
230
+ # clients never call close_session. Bounded + idempotent; fail-soft.
231
+ try:
232
+ idle_hours = float(getattr(config, "session_idle_close_hours", 24.0) or 24.0)
233
+ max_close = int(getattr(config, "session_idle_close_max_per_pass", 50) or 50)
234
+ counts["stale_sessions_closed"] = close_stale_sessions(
235
+ db,
236
+ profile_id,
237
+ idle_hours=idle_hours,
238
+ max_per_pass=max_close,
239
+ )
240
+ except Exception as exc: # pragma: no cover — defensive
241
+ logger.debug("stale session close skipped: %s", exc)
242
+
140
243
  # P1-3 (embeddings-vector-02): sweep orphaned embedding_metadata left by
141
244
  # any FK-off delete path, so the semantic channel never maps to dead facts.
142
245
  # Runs before the early-return so it sweeps even for empty profiles.
@@ -330,6 +433,158 @@ def run_maintenance(
330
433
  except Exception as exc:
331
434
  logger.warning("Fisher-Langevin coupling failed: %s", exc)
332
435
 
436
+ # 1c. Fisher posterior update (P1-9): tighten variance per new access event.
437
+ # Access-delta semantics: apply one Bayesian update per net-new access since
438
+ # the last maintenance run. Zero new accesses → variance unchanged.
439
+ # This prevents idle-corpus drift that tick-based updates would cause.
440
+ # Inline schema migration: adds fisher_last_applied_access when absent.
441
+ # Gate: config.math.fisher_bayesian_update (default True).
442
+ if config.math.fisher_bayesian_update:
443
+ try:
444
+ import json as _json
445
+ from superlocalmemory.math.fisher import FisherRaoMetric
446
+
447
+ # Inline migration — harmless no-op if column already exists.
448
+ try:
449
+ db.execute(
450
+ "ALTER TABLE atomic_facts "
451
+ "ADD COLUMN fisher_last_applied_access INTEGER NOT NULL DEFAULT 0"
452
+ )
453
+ except Exception:
454
+ pass # already migrated on a previous run
455
+
456
+ frm = FisherRaoMetric(temperature=config.math.fisher_temperature)
457
+ posterior_count = 0
458
+ for f in facts:
459
+ if f.fisher_variance is None:
460
+ continue
461
+ rows = db.execute(
462
+ "SELECT access_count, fisher_last_applied_access "
463
+ "FROM atomic_facts WHERE fact_id = ?",
464
+ (f.fact_id,),
465
+ )
466
+ if not rows:
467
+ continue
468
+ r = dict(rows[0])
469
+ acc = r.get("access_count") or 0
470
+ last_applied = r.get("fisher_last_applied_access") or 0
471
+ delta = acc - last_applied
472
+ if delta <= 0:
473
+ continue # no new accesses — variance unchanged this run
474
+ # Apply min(delta, 100) unit-information Bayesian updates.
475
+ # One update per access: 1/v_new = 1/v_old + 1 (unit obs_var).
476
+ current_var = list(f.fisher_variance)
477
+ dim = len(current_var)
478
+ obs_var = [1.0] * dim
479
+ applied = min(delta, 100)
480
+ for _ in range(applied):
481
+ current_var = frm.bayesian_update(current_var, obs_var)
482
+ # Single atomic write: variance + watermark together. Advance the
483
+ # watermark only by the number of updates ACTUALLY applied (not to
484
+ # acc), so accesses beyond the per-run cap are applied on subsequent
485
+ # runs instead of being silently dropped.
486
+ db.execute(
487
+ "UPDATE atomic_facts "
488
+ "SET fisher_variance = ?, fisher_last_applied_access = ? "
489
+ "WHERE fact_id = ?",
490
+ (_json.dumps(current_var), last_applied + applied, f.fact_id),
491
+ )
492
+ # Refresh in-memory so step 1d ELC sees the updated variance.
493
+ f.fisher_variance = current_var
494
+ posterior_count += 1
495
+ counts["fisher_posterior_updated"] = posterior_count
496
+ except Exception as exc:
497
+ logger.warning("Fisher posterior update failed: %s", exc)
498
+
499
+ # 1d. Ebbinghaus-Langevin coupling (Phase 5 — P1-ELC): combine forgetting
500
+ # drift with Fisher-Langevin dynamics to produce a unified lifecycle state.
501
+ # Updates the lifecycle zone of each fact based on Ebbinghaus retention.
502
+ # NOTE: this step overwrites the Langevin-only lifecycle set in step 1b.
503
+ # The Ebbinghaus zone is intentionally authoritative when ELC is ON.
504
+ # Gate: config.math.ebbinghaus_langevin_coupling_enabled (default False).
505
+ if config.math.ebbinghaus_langevin_coupling_enabled:
506
+ try:
507
+ from superlocalmemory.dynamics.ebbinghaus_langevin_coupling import (
508
+ EbbinghausLangevinCoupling,
509
+ )
510
+ from superlocalmemory.dynamics.fisher_langevin_coupling import (
511
+ FisherLangevinCoupling,
512
+ )
513
+ from superlocalmemory.math.ebbinghaus import EbbinghausCurve
514
+ from superlocalmemory.math.langevin import LangevinDynamics
515
+
516
+ ebbinghaus = EbbinghausCurve(config.forgetting)
517
+ langevin = LangevinDynamics(
518
+ dim=_LANGEVIN_DIM,
519
+ dt=config.math.langevin_dt,
520
+ temperature=config.math.langevin_temperature,
521
+ )
522
+ fisher_coupling = FisherLangevinCoupling(
523
+ base_temperature=config.math.langevin_temperature,
524
+ )
525
+ coupling = EbbinghausLangevinCoupling(
526
+ ebbinghaus, langevin, fisher_coupling, config.forgetting,
527
+ )
528
+ import numpy as np
529
+
530
+ # Build fact_id → last_accessed_at lookup from fact_retention.
531
+ # Using real last-access time (not created_at) so hot facts are not
532
+ # mis-classified as forgotten due to old creation timestamps.
533
+ if facts:
534
+ retention_rows = db.execute(
535
+ "SELECT fact_id, last_accessed_at FROM fact_retention "
536
+ "WHERE fact_id IN ({})".format(",".join("?" * len(facts))),
537
+ tuple(f.fact_id for f in facts),
538
+ )
539
+ else:
540
+ retention_rows = []
541
+ last_accessed_map: dict[str, str | None] = {
542
+ dict(r)["fact_id"]: dict(r)["last_accessed_at"]
543
+ for r in retention_rows
544
+ }
545
+
546
+ elc_count = 0
547
+ for f in facts:
548
+ if f.fisher_variance is None or f.langevin_position is None:
549
+ continue
550
+ # Prefer real last-access timestamp; fall back to created_at.
551
+ raw_ts = last_accessed_map.get(f.fact_id) or f.created_at
552
+ hours_since = _age_days(raw_ts) * 24.0
553
+ state = coupling.compute_coupled_state(
554
+ fact_id=f.fact_id,
555
+ fisher_variance=np.asarray(f.fisher_variance, dtype=np.float64),
556
+ langevin_radius=float(np.linalg.norm(f.langevin_position)),
557
+ access_count=f.access_count,
558
+ importance=f.importance,
559
+ confirmation_count=f.evidence_count,
560
+ emotional_salience=0.0,
561
+ hours_since_last_access=hours_since,
562
+ )
563
+ # Remap ELC zone vocabulary to atomic_facts CHECK constraint.
564
+ # EbbinghausCurve returns 'archive'/'forgotten'; schema only allows
565
+ # 'active|warm|cold|archived'.
566
+ zone = _ELC_ZONE_REMAP.get(state.lifecycle_zone, state.lifecycle_zone)
567
+ if zone not in _VALID_LIFECYCLE_ZONES:
568
+ logger.warning(
569
+ "ELC returned unknown lifecycle zone %r for fact %s — skipping",
570
+ state.lifecycle_zone, f.fact_id,
571
+ )
572
+ continue
573
+ # Count fact as processed regardless of whether we write.
574
+ elc_count += 1
575
+ # Skip write when zone hasn't changed — avoids O(N) UPDATEs per tick.
576
+ current_zone = (
577
+ f.lifecycle.value
578
+ if hasattr(f.lifecycle, "value")
579
+ else str(f.lifecycle)
580
+ )
581
+ if zone == current_zone:
582
+ continue
583
+ db.update_fact(f.fact_id, {"lifecycle": zone})
584
+ counts["ebbinghaus_coupled"] = elc_count
585
+ except Exception as exc:
586
+ logger.warning("Ebbinghaus-Langevin coupling failed: %s", exc)
587
+
333
588
  # 2. Sheaf batch consistency on recent facts (last 24h)
334
589
  if config.math.sheaf_at_encoding:
335
590
  try:
@@ -2,12 +2,15 @@
2
2
  # Licensed under AGPL-3.0-or-later - see LICENSE file
3
3
  # Part of SuperLocalMemory V3 | https://qualixar.com | https://varunpratap.com
4
4
 
5
- """SuperLocalMemory V3 Mode System.
5
+ """SuperLocalMemory V4 mode capability descriptors.
6
6
 
7
7
  Three operating modes with clear capability boundaries.
8
- Mode A: EU AI Act FULL compliance (zero LLM).
9
- Mode B: EU AI Act FULL (local LLM only).
10
- Mode C: UNRESTRICTED best models, full power, 90%+ target.
8
+ Mode A: deterministic local extraction and local inference.
9
+ Mode B: local LLM enrichment and local inference.
10
+ Mode C: configured provider-assisted inference.
11
+
12
+ An operating mode does not determine EU AI Act compliance. That assessment
13
+ depends on the deployment, intended use, operator role, and applicable duties.
11
14
 
12
15
  Part of Qualixar | Author: Varun Pratap Bhardwaj
13
16
  """
@@ -41,12 +44,20 @@ class ModeCapabilities:
41
44
  embedding_dimension: int # Expected embedding dimension
42
45
 
43
46
  # Compliance
44
- eu_ai_act_compliant: bool # Full EU AI Act compliance?
47
+ eu_ai_act_compliant: bool | None # None: requires deployment assessment
45
48
  data_stays_local: bool # Does ALL data stay on device?
46
49
 
47
50
  # Description
48
51
  description: str = ""
49
52
 
53
+ @property
54
+ def data_locality_label(self) -> str:
55
+ """UI/API locality label derived from the mode record (single source).
56
+
57
+ Never invent a parallel mode→label map in the dashboard — consume this.
58
+ """
59
+ return "local-only" if self.data_stays_local else "provider-assisted"
60
+
50
61
 
51
62
  # ---------------------------------------------------------------------------
52
63
  # Mode Definitions
@@ -63,13 +74,13 @@ MODE_A = ModeCapabilities(
63
74
  cloud_reranker=False,
64
75
  cloud_embeddings=False,
65
76
  embedding_dimension=768,
66
- eu_ai_act_compliant=True,
77
+ eu_ai_act_compliant=None,
67
78
  data_stays_local=True,
68
79
  description=(
69
80
  "Local Guardian — Zero LLM, zero cloud. "
70
81
  "Uses nomic-embed-text-v1.5 encoder (768d, 8K context) for embeddings. "
71
- "spaCy + rules for extraction. ONNX cross-encoder reranking (~200MB). "
72
- "Full EU AI Act compliance. Target: 65%+"
82
+ "Deterministic rules for extraction and a local PyTorch cross-encoder. "
83
+ "EU AI Act classification requires deployment assessment."
73
84
  ),
74
85
  )
75
86
 
@@ -84,13 +95,13 @@ MODE_B = ModeCapabilities(
84
95
  cloud_reranker=False,
85
96
  cloud_embeddings=False,
86
97
  embedding_dimension=768,
87
- eu_ai_act_compliant=True,
98
+ eu_ai_act_compliant=None,
88
99
  data_stays_local=True,
89
100
  description=(
90
101
  "Smart Local — Local Ollama LLM (Phi-3, Llama 3.2). "
91
102
  "LLM-quality extraction and classification, fully local. "
92
- "ONNX cross-encoder reranking (~200MB). "
93
- "No cloud, no data export. EU AI Act compliant. Target: 75-80%"
103
+ "Local PyTorch cross-encoder reranking. No configured cloud inference. "
104
+ "EU AI Act classification requires deployment assessment."
94
105
  ),
95
106
  )
96
107
 
@@ -105,12 +116,13 @@ MODE_C = ModeCapabilities(
105
116
  cloud_reranker=True,
106
117
  cloud_embeddings=True,
107
118
  embedding_dimension=3072,
108
- eu_ai_act_compliant=False,
119
+ eu_ai_act_compliant=None,
109
120
  data_stays_local=False,
110
121
  description=(
111
122
  "FULL POWER — UNRESTRICTED. Best embeddings (text-embedding-3-large, 3072-dim). "
112
123
  "Best configured cloud LLMs (e.g. GPT-5, Claude Opus 4). Agentic multi-round retrieval. "
113
- "Cohere reranker option. No EU restriction. Target: 90%+"
124
+ "Cohere reranker option. Cloud processing requires deployment-specific "
125
+ "privacy, contractual, and EU AI Act assessment."
114
126
  ),
115
127
  )
116
128
 
@@ -121,6 +133,21 @@ def get_capabilities(mode: Mode) -> ModeCapabilities:
121
133
  return _map[mode]
122
134
 
123
135
 
136
+ def dashboard_mode_fields(mode: Mode | str) -> dict[str, object]:
137
+ """Fields the dashboard API must surface from the mode record.
138
+
139
+ Keeps UI locality claims bound to :class:`ModeCapabilities` so Mode C
140
+ cannot be labeled local-only by a divergent hardcode.
141
+ """
142
+ if isinstance(mode, str):
143
+ mode = Mode(mode.strip().lower())
144
+ caps = get_capabilities(mode)
145
+ return {
146
+ "data_locality_label": caps.data_locality_label,
147
+ "data_stays_local": caps.data_stays_local,
148
+ }
149
+
150
+
124
151
  def validate_mode_config(mode: Mode, *, has_ollama: bool = False, has_cloud_llm: bool = False) -> list[str]:
125
152
  """Validate that required services are available for the chosen mode.
126
153