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
@@ -2,11 +2,14 @@
2
2
  # Licensed under AGPL-3.0-or-later - see LICENSE file
3
3
  # Part of SuperLocalMemory V3
4
4
 
5
- """SuperLocalMemory V3.3 -- Hopfield Associative Memory (6th Retrieval Channel).
5
+ """SuperLocalMemory V3.3 -- Hopfield Associative Memory (6th of 6 Retrieval Channels).
6
6
 
7
7
  Modern Continuous Hopfield Network retrieval channel based on
8
8
  Ramsauer et al. (2020): "Hopfield Networks is All You Need".
9
9
 
10
+ Channel lineup (6 total): semantic, BM25, entity_graph, temporal,
11
+ spreading_activation, hopfield (this module).
12
+
10
13
  The Hopfield channel excels at pattern completion for vague/noisy queries.
11
14
  It operates on the same embedding space as the semantic channel but uses
12
15
  an energy-based attention mechanism instead of cosine similarity.
@@ -14,7 +17,7 @@ an energy-based attention mechanism instead of cosine similarity.
14
17
  Key features:
15
18
  - Full memory matrix path for stores < 10K facts
16
19
  - ANN pre-filter path for stores 10K-100K (VectorStore KNN -> Hopfield refinement)
17
- - Skip path for stores > 100K (other 5 channels are sufficient)
20
+ - Skip path for stores > 100K (other 5 non-Hopfield channels are sufficient)
18
21
  - TTL-based matrix cache to avoid rebuilding every query
19
22
  - Returns [] on any error (HR-06)
20
23
 
@@ -38,7 +41,7 @@ logger = logging.getLogger(__name__)
38
41
 
39
42
 
40
43
  class HopfieldChannel:
41
- """6th retrieval channel: Modern Hopfield associative memory.
44
+ """6th of 6 retrieval channels: Modern Hopfield associative memory.
42
45
 
43
46
  Implements the RetrievalChannel protocol::
44
47
 
@@ -48,6 +51,9 @@ class HopfieldChannel:
48
51
  computes Hopfield attention scores (softmax of scaled dot products),
49
52
  then ranks facts by similarity to the completed pattern.
50
53
 
54
+ Six-channel retrieval model: semantic, BM25, entity_graph, temporal,
55
+ spreading_activation, hopfield (this channel).
56
+
51
57
  Routing logic (per LLD Section 2.2):
52
58
  - n > skip_threshold (100K): return [] immediately
53
59
  - n > prefilter_threshold (10K): ANN pre-filter + Hopfield on subset
@@ -48,6 +48,7 @@ Part of Qualixar | Author: Varun Pratap Bhardwaj
48
48
  from __future__ import annotations
49
49
 
50
50
  import json
51
+ import ipaddress
51
52
  import logging
52
53
  import math
53
54
  import os
@@ -92,9 +93,6 @@ _MAX_ATTEMPTS = 2
92
93
  # logs; the operator must never have to guess whether reranking is running.
93
94
  _FAILURE_RELOG_INTERVAL_S = 60.0
94
95
 
95
- _ERROR_BODY_SNIPPET_CHARS = 200
96
-
97
-
98
96
  class RemoteRerankerError(RuntimeError):
99
97
  """A remote rerank request failed (transport, status, or schema)."""
100
98
 
@@ -161,6 +159,12 @@ def _validate_endpoint_url(endpoint: str) -> str | None:
161
159
  "retrieval.cross_encoder_endpoint has no host; expected something "
162
160
  "like \"http://127.0.0.1:8041/v1/rerank\"."
163
161
  )
162
+ if parsed.query or parsed.fragment:
163
+ return (
164
+ "retrieval.cross_encoder_endpoint must not include a query string "
165
+ "or fragment. Put bearer credentials in "
166
+ "SLM_CROSS_ENCODER_API_KEY and configure a clean endpoint URL."
167
+ )
164
168
  if parsed.username or parsed.password:
165
169
  # httpx logs "HTTP Request: POST <url>" at INFO using str(url), which
166
170
  # renders an embedded password in full. This module never logs the raw
@@ -173,9 +177,26 @@ def _validate_endpoint_url(endpoint: str) -> str | None:
173
177
  "retrieval.cross_encoder_api_key; it is sent as a Bearer header "
174
178
  "and never logged."
175
179
  )
180
+ if parsed.scheme == "http" and not _is_loopback_host(parsed.hostname):
181
+ return (
182
+ "retrieval.cross_encoder_endpoint must use HTTPS for non-loopback "
183
+ "hosts because recall queries and candidate memory text cross this "
184
+ "connection. Plain HTTP is allowed only for localhost/loopback."
185
+ )
176
186
  return None
177
187
 
178
188
 
189
+ def _is_loopback_host(hostname: str) -> bool:
190
+ """Return True only for literal loopback names/addresses (no DNS trust)."""
191
+ host = (hostname or "").rstrip(".").lower()
192
+ if host == "localhost" or host.endswith(".localhost"):
193
+ return True
194
+ try:
195
+ return ipaddress.ip_address(host).is_loopback
196
+ except ValueError:
197
+ return False
198
+
199
+
179
200
  def normalize_rerank_endpoint(endpoint: str) -> str:
180
201
  """Append ``/rerank`` when the URL stops at the API root.
181
202
 
@@ -208,7 +229,15 @@ def redact_endpoint(endpoint: str) -> str:
208
229
  netloc = f"{netloc}:{parsed.port}"
209
230
  if parsed.username or parsed.password:
210
231
  netloc = f"***@{netloc}"
211
- return urlunparse(parsed._replace(netloc=netloc))
232
+ return urlunparse(parsed._replace(netloc=netloc, query="", fragment=""))
233
+
234
+
235
+ def _redact_remote_text(text: str) -> str:
236
+ """Remove recognized secrets and PII before a remote trust boundary."""
237
+ from superlocalmemory.core.pii import redact_pii_text
238
+ from superlocalmemory.core.security_primitives import redact_secrets
239
+
240
+ return redact_pii_text(redact_secrets(str(text), aggression="high"))
212
241
 
213
242
 
214
243
  # ---------------------------------------------------------------------------
@@ -240,15 +269,14 @@ def parse_rerank_response(payload: Any, expected: int) -> list[float]:
240
269
  index = _coerce_index(item, position, expected)
241
270
  if scores[index] is not None:
242
271
  raise RemoteRerankerError(
243
- f"rerank endpoint returned index {index} more than once"
272
+ "rerank endpoint returned a duplicate document index"
244
273
  )
245
274
  scores[index] = _coerce_score(item, index)
246
275
 
247
276
  missing = [i for i, s in enumerate(scores) if s is None]
248
277
  if missing:
249
278
  raise RemoteRerankerError(
250
- f"rerank endpoint returned no score for document index(es) "
251
- f"{missing[:5]}{'…' if len(missing) > 5 else ''}"
279
+ "rerank endpoint omitted one or more document scores"
252
280
  )
253
281
  return [float(s) for s in scores] # type: ignore[arg-type]
254
282
 
@@ -264,8 +292,8 @@ def _extract_results_array(payload: Any) -> list[Any]:
264
292
  results = payload.get("results")
265
293
  if results is None:
266
294
  raise RemoteRerankerError(
267
- f"rerank response has no 'results' array (keys: "
268
- f"{sorted(payload)[:8]}). Is cross_encoder_endpoint pointing at a "
295
+ "rerank response has no 'results' array. Is "
296
+ "cross_encoder_endpoint pointing at a "
269
297
  f"rerank route and not, say, /v1/embeddings?"
270
298
  )
271
299
  if not isinstance(results, list):
@@ -280,12 +308,12 @@ def _coerce_index(item: dict, position: int, expected: int) -> int:
280
308
  raw = item.get("index", position)
281
309
  if isinstance(raw, bool) or not isinstance(raw, int):
282
310
  raise RemoteRerankerError(
283
- f"rerank result #{position} has non-integer index {raw!r}"
311
+ f"rerank result #{position} has a non-integer index"
284
312
  )
285
313
  if not 0 <= raw < expected:
286
314
  raise RemoteRerankerError(
287
- f"rerank result #{position} has out-of-range index {raw} "
288
- f"(sent {expected} documents)"
315
+ f"rerank result #{position} has an out-of-range index "
316
+ f"for a {expected}-document request"
289
317
  )
290
318
  return raw
291
319
 
@@ -297,18 +325,18 @@ def _coerce_score(item: dict, index: int) -> float:
297
325
  if isinstance(raw, bool) or not isinstance(raw, (int, float)):
298
326
  raise RemoteRerankerError(
299
327
  f"rerank result for index {index} has non-numeric "
300
- f"{key}={raw!r}"
328
+ f"{key}"
301
329
  )
302
330
  value = float(raw)
303
331
  if not math.isfinite(value):
304
332
  raise RemoteRerankerError(
305
333
  f"rerank result for index {index} has non-finite "
306
- f"{key}={raw!r}"
334
+ f"{key}"
307
335
  )
308
336
  return value
309
337
  raise RemoteRerankerError(
310
338
  f"rerank result for index {index} has neither 'relevance_score' nor "
311
- f"'score' (keys: {sorted(item)[:8]})"
339
+ "'score'"
312
340
  )
313
341
 
314
342
 
@@ -494,8 +522,8 @@ class RemoteReranker:
494
522
  headers["Authorization"] = f"Bearer {self._api_key}"
495
523
  body = {
496
524
  "model": self._model_name,
497
- "query": query,
498
- "documents": documents,
525
+ "query": _redact_remote_text(query),
526
+ "documents": [_redact_remote_text(document) for document in documents],
499
527
  }
500
528
 
501
529
  last_error: RemoteRerankerError | None = None
@@ -534,12 +562,9 @@ class RemoteReranker:
534
562
  f"followed — configure the final URL directly."
535
563
  )
536
564
  if resp.status_code >= 400:
537
- snippet = raw[:_ERROR_BODY_SNIPPET_CHARS].decode(
538
- "utf-8", "replace",
539
- )
540
565
  message = (
541
- f"HTTP {resp.status_code} from {self.safe_endpoint}: "
542
- f"{snippet}"
566
+ f"HTTP {resp.status_code} from {self.safe_endpoint}; "
567
+ "response body suppressed"
543
568
  )
544
569
  if resp.status_code >= 500:
545
570
  raise _RetryableRemoteError(message)
@@ -433,7 +433,38 @@ class CrossEncoderReranker:
433
433
 
434
434
  @staticmethod
435
435
  def _readline_with_timeout(stream: Any, timeout_seconds: float) -> str:
436
- """Read a line from stream with timeout. Returns '' on timeout."""
436
+ """Read a line from stream with timeout. Returns '' on timeout.
437
+
438
+ Prefer a deadline-driven selector poll of the stream's file descriptor
439
+ (POSIX pipes). That path never spawns a helper thread, so a hung
440
+ worker cannot leak reader threads or pin the pipe FD across timeouts.
441
+ A thread fallback remains only for streams without a usable fileno
442
+ (unit-test mocks) and for Windows, where selectors cannot wait on
443
+ pipes.
444
+ """
445
+ import selectors
446
+
447
+ timeout_seconds = max(0.0, float(timeout_seconds))
448
+ fd: int | None
449
+ try:
450
+ raw_fd = stream.fileno()
451
+ fd = raw_fd if isinstance(raw_fd, int) else None
452
+ except (AttributeError, OSError, ValueError, TypeError):
453
+ fd = None
454
+
455
+ # Windows select()/selectors only accept sockets, not subprocess pipes.
456
+ if fd is not None and sys.platform != "win32":
457
+ try:
458
+ with selectors.DefaultSelector() as sel:
459
+ sel.register(fd, selectors.EVENT_READ)
460
+ events = sel.select(timeout=timeout_seconds)
461
+ if not events:
462
+ return ""
463
+ line = stream.readline()
464
+ return line if line else ""
465
+ except (OSError, ValueError):
466
+ return ""
467
+
437
468
  result_container: list[str] = []
438
469
  error_container: list[Exception] = []
439
470
 
@@ -14,7 +14,7 @@ from __future__ import annotations
14
14
 
15
15
  import logging
16
16
  import math
17
- from datetime import datetime
17
+ from datetime import datetime, timezone
18
18
  from typing import TYPE_CHECKING
19
19
 
20
20
  from dateutil.parser import ParserError, parse as dateutil_parse
@@ -30,6 +30,18 @@ logger = logging.getLogger(__name__)
30
30
  _MAX_PROXIMITY_DAYS: float = 365.0
31
31
 
32
32
 
33
+ def _as_utc(dt: datetime | None) -> datetime | None:
34
+ """Coerce a datetime to timezone-aware UTC (naive values are assumed UTC).
35
+
36
+ Keeps proximity and interval comparisons from mixing naive and aware values.
37
+ """
38
+ if dt is None:
39
+ return None
40
+ if dt.tzinfo is None:
41
+ return dt.replace(tzinfo=timezone.utc)
42
+ return dt.astimezone(timezone.utc)
43
+
44
+
33
45
  def _parse_iso(s: str | None) -> datetime | None:
34
46
  if not s:
35
47
  return None
@@ -39,11 +51,11 @@ def _parse_iso(s: str | None) -> datetime | None:
39
51
  # across thousands of events — that was ~2.6s of the recall. dateutil is
40
52
  # now only the fallback for non-ISO strings.
41
53
  try:
42
- return datetime.fromisoformat(s.replace("Z", "+00:00"))
54
+ return _as_utc(datetime.fromisoformat(s.replace("Z", "+00:00")))
43
55
  except (ValueError, TypeError):
44
56
  pass
45
57
  try:
46
- return dateutil_parse(s)
58
+ return _as_utc(dateutil_parse(s))
47
59
  except (ParserError, ValueError, OverflowError, TypeError):
48
60
  return None
49
61
 
@@ -95,6 +107,7 @@ class TemporalChannel:
95
107
  query_dt = _parse_iso(dates.get("referenced_date"))
96
108
  if query_dt is None:
97
109
  query_dt = self._try_parse(query)
110
+ query_dt = _as_utc(query_dt)
98
111
 
99
112
  # Strategy 1: Entity-temporal metadata search
100
113
  # "When did Alice...?" → find all temporal events for Alice
@@ -0,0 +1,107 @@
1
+ # Copyright (c) 2026 Varun Pratap Bhardwaj / Qualixar
2
+ # Licensed under AGPL-3.0-or-later - see LICENSE file
3
+ # Part of SuperLocalMemory V4 | https://qualixar.com | https://varunpratap.com
4
+
5
+ """Temporal utility helpers for bi-temporal retrieval (Phase 4b).
6
+
7
+ STORED TIMESTAMP FORMAT (empirically verified, Phase 4b):
8
+ The fact_temporal_validity table stores system_expired_at and valid_until
9
+ via datetime.now(timezone.utc).isoformat() which produces:
10
+ "YYYY-MM-DDTHH:MM:SS.microseconds+00:00" (Python isoformat, +00:00 suffix)
11
+
12
+ normalize_as_of() outputs canonical datetime.isoformat() (preserving any
13
+ sub-second precision, same +00:00 suffix as the stored values) so that SQL
14
+ string comparisons ("system_expired_at <= as_of") are lexicographically
15
+ correct AND the inclusive boundary round-trips (audit P0/CRIT-1):
16
+
17
+ "2026-03-01T00:00:00.123456+00:00" <= "2026-06-01T00:00:00+00:00"
18
+ → first 10 chars "2026-03-01" < "2026-06-01" → TRUE ✓
19
+
20
+ At sub-second boundary (same second, stored has micros):
21
+ "2024-01-01T12:00:00.123456+00:00" <= "2024-01-01T12:00:00+00:00"
22
+ → at pos 19: '.' (46) > '+' (43) → FALSE
23
+ Interpretation: stored supersession is 0.123456s AFTER as_of → fact
24
+ was still valid at that exact second. Semantically correct.
25
+
26
+ Contrast with SQLite strftime('%Y-%m-%dT%H:%M:%SZ', 'now') → "Z" suffix.
27
+ If as_of used "Z" suffix and stored uses "+00:00":
28
+ "2024-01-01T12:00:00.123456+00:00" <= "2024-01-01T12:00:00Z"
29
+ → at pos 19: '.' (46) < 'Z' (90) → TRUE
30
+ This would WRONGLY demote a fact whose supersession happened 0.123456s
31
+ AFTER as_of. Using "+00:00" avoids this error.
32
+ """
33
+
34
+ from __future__ import annotations
35
+
36
+ from datetime import datetime, timezone
37
+ from typing import Optional
38
+
39
+
40
+ def normalize_as_of(s: object) -> Optional[str]:
41
+ """Parse and normalize an as_of timestamp string to UTC ISO 8601.
42
+
43
+ Accepts:
44
+ - "2024-01-01T12:00:00Z" → "2024-01-01T12:00:00+00:00"
45
+ - "2024-01-01T12:00:00+05:30" → "2024-01-01T06:30:00+00:00"
46
+ - "2024-01-01" → "2024-01-01T00:00:00+00:00"
47
+ - "2024-01-01T12:00:00" → "2024-01-01T12:00:00+00:00" (naive → UTC)
48
+ - "2024-01-01T12:00:00+00:00" → "2024-01-01T12:00:00+00:00"
49
+
50
+ Returns None (not empty string) on invalid input so callers can
51
+ distinguish "no as_of" from "bad as_of" and reject or ignore accordingly.
52
+
53
+ Output format: canonical UTC ISO-8601 via datetime.isoformat():
54
+ - "YYYY-MM-DDTHH:MM:SS+00:00", OR
55
+ "YYYY-MM-DDTHH:MM:SS.ffffff+00:00" when the input carries sub-second
56
+ precision.
57
+ - Audit P0 (CRIT-1): sub-second precision is PRESERVED (not stripped) so
58
+ a caller can round-trip a real stored system_expired_at / valid_until
59
+ value — always written with microseconds by datetime.now(UTC).isoformat()
60
+ — and hit the inclusive boundary (`system_expired_at <= as_of`) EXACTLY.
61
+ Stripping microseconds made that boundary unreachable and silently
62
+ wrong on both temporal axes.
63
+ - +00:00 suffix matches the stored format so SQL string comparisons are
64
+ lexicographically correct (see module docstring for analysis).
65
+
66
+ Requirements:
67
+ Python >=3.11 (fromisoformat handles "Z" suffix natively). The
68
+ fallback .replace("Z", "+00:00") is kept for defensive compatibility.
69
+
70
+ Args:
71
+ s: Raw as_of string from user/HTTP/MCP/CLI input. May be None or
72
+ non-string; both return None (treated as "no as_of" by callers).
73
+
74
+ Returns:
75
+ Normalized UTC string "YYYY-MM-DDTHH:MM:SS+00:00", or None on error.
76
+ """
77
+ if not s or not isinstance(s, str):
78
+ return None
79
+ s = s.strip()
80
+ if not s:
81
+ return None
82
+
83
+ # Try ISO 8601 parse. Python 3.11 handles "Z" natively; the replace()
84
+ # makes this safe on any 3.x that might be used in tests.
85
+ for candidate in (s, s.replace("Z", "+00:00")):
86
+ try:
87
+ dt = datetime.fromisoformat(candidate)
88
+ # Naive datetime → assume UTC (documented assumption).
89
+ if dt.tzinfo is None:
90
+ dt = dt.replace(tzinfo=timezone.utc)
91
+ else:
92
+ dt = dt.astimezone(timezone.utc)
93
+ # Audit P0: isoformat() preserves microseconds when present so the
94
+ # value round-trips against microsecond-bearing stored timestamps.
95
+ return dt.isoformat()
96
+ except ValueError:
97
+ continue
98
+
99
+ # Date-only "YYYY-MM-DD" — treat as midnight UTC.
100
+ try:
101
+ dt = datetime.strptime(s[:10], "%Y-%m-%d").replace(tzinfo=timezone.utc)
102
+ return dt.isoformat()
103
+ except ValueError:
104
+ return None
105
+
106
+
107
+ __all__ = ("normalize_as_of",)