superlocalmemory 4.0.5 → 4.0.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/CHANGELOG.md +57 -0
  2. package/README.md +10 -6
  3. package/package.json +3 -1
  4. package/pyproject.toml +1 -1
  5. package/src/superlocalmemory/__init__.py +1 -1
  6. package/src/superlocalmemory/access/rbac.py +106 -0
  7. package/src/superlocalmemory/brain/truth.py +80 -10
  8. package/src/superlocalmemory/cli/__main__.py +17 -0
  9. package/src/superlocalmemory/cli/commands.py +14 -3
  10. package/src/superlocalmemory/cli/gdpr_cmd.py +779 -0
  11. package/src/superlocalmemory/cli/gdpr_io.py +109 -0
  12. package/src/superlocalmemory/cli/main.py +76 -0
  13. package/src/superlocalmemory/code_graph/extractors/__init__.py +17 -0
  14. package/src/superlocalmemory/code_graph/graph_store.py +180 -3
  15. package/src/superlocalmemory/code_graph/parser.py +280 -100
  16. package/src/superlocalmemory/compliance/gdpr.py +358 -0
  17. package/src/superlocalmemory/core/config.py +44 -1
  18. package/src/superlocalmemory/core/engine_wiring.py +5 -1
  19. package/src/superlocalmemory/core/maintenance.py +43 -1
  20. package/src/superlocalmemory/core/recall_worker.py +33 -12
  21. package/src/superlocalmemory/infra/backup.py +138 -0
  22. package/src/superlocalmemory/infra/backup_obligations.py +423 -0
  23. package/src/superlocalmemory/learning/engagement.py +165 -0
  24. package/src/superlocalmemory/mcp/tools_code_graph.py +31 -4
  25. package/src/superlocalmemory/mcp/tools_v3.py +20 -6
  26. package/src/superlocalmemory/retrieval/engine.py +21 -0
  27. package/src/superlocalmemory/retrieval/remote_reranker.py +108 -11
  28. package/src/superlocalmemory/server/routes/brain.py +283 -15
  29. package/src/superlocalmemory/server/routes/learning.py +13 -25
  30. package/src/superlocalmemory/server/routes/v3_api.py +171 -60
  31. package/src/superlocalmemory/storage/database.py +36 -0
  32. package/src/superlocalmemory/storage/models.py +12 -4
  33. package/src/superlocalmemory/summaries/__init__.py +37 -0
  34. package/src/superlocalmemory/summaries/base.py +108 -0
  35. package/src/superlocalmemory/summaries/daily_reflection.py +293 -0
  36. package/src/superlocalmemory/summaries/project_work_log.py +424 -0
  37. package/src/superlocalmemory/summaries/session_summary.py +307 -0
  38. package/src/superlocalmemory/ui/css/design-system.css +76 -1
  39. package/src/superlocalmemory/ui/index.html +28 -11
  40. package/src/superlocalmemory/ui/js/od-agents.js +49 -5
  41. package/src/superlocalmemory/ui/js/od-brain.js +257 -77
  42. package/src/superlocalmemory/ui/js/od-graph.js +147 -6
@@ -320,3 +320,168 @@ class EngagementTracker:
320
320
  if raw <= 0:
321
321
  return 0.0
322
322
  return raw / (raw + 20.0)
323
+
324
+
325
+ # ---------------------------------------------------------------------------
326
+ # Derive-on-read engagement — zero hot-path cost (Invariants I1, I3)
327
+ # ---------------------------------------------------------------------------
328
+
329
+ def derive_engagement_from_dbs(
330
+ memory_db_path: "Path | str",
331
+ learning_db_path: "Path | str",
332
+ profile_id: str,
333
+ ) -> "Dict[str, Any]":
334
+ """Derive engagement metrics from tables that already exist — no writes.
335
+
336
+ Source tables
337
+ -------------
338
+ memory.db : atomic_facts
339
+ store_count — live facts (lifecycle in active/warm/cold)
340
+ days_active — distinct calendar days with at least one live fact
341
+ recent_7d — facts created in the last 7 days (drives health)
342
+ learning.db : learning_signals
343
+ recall_count — COUNT(DISTINCT query) for the profile.
344
+ This is a proxy: one distinct query = one recall
345
+ session. If the same query was run N times, it
346
+ counts as 1. The table is populated by the
347
+ post_tool_outcome_hook when Claude Code surfaces
348
+ recall results; it may be empty in environments
349
+ without that hook, in which case recall_count = 0
350
+ while store_count still reflects real activity.
351
+
352
+ Invariant compliance
353
+ --------------------
354
+ I1 — zero writes, no lock acquisition on the recall/remember hot path.
355
+ I3 — no new table or row is ever created; bounded by the existing
356
+ lifecycle-management and retention systems (atomic_facts rows age
357
+ through active → warm → cold → archived via Langevin dynamics;
358
+ learning_signals rows are pruned by the retention sweep).
359
+ I6 — every field carries a named source.
360
+
361
+ health_status (plain language, non-technical)
362
+ ----------------------------------------------
363
+ "ACTIVE" — 10 or more memories added in the last 7 days
364
+ "WARM" — 3–9 memories added in the last 7 days
365
+ "COLD" — 1–2 memories added in the last 7 days
366
+ "INACTIVE" — no new memories in the last 7 days
367
+ """
368
+ memory_db_path = Path(str(memory_db_path))
369
+ learning_db_path = Path(str(learning_db_path))
370
+
371
+ store_count: int = 0
372
+ days_active: int = 0
373
+ recent_7d: int = 0
374
+ recall_count: int = 0
375
+
376
+ # ── memory.db : atomic_facts ──────────────────────────────────────────
377
+ if memory_db_path.exists():
378
+ try:
379
+ # read-only URI; never creates the file, never acquires write lock
380
+ _uri = f"{memory_db_path.resolve().as_uri()}?mode=ro"
381
+ _conn = sqlite3.connect(_uri, uri=True, timeout=1.0)
382
+ _conn.execute("PRAGMA query_only=ON")
383
+ try:
384
+ _tables = {
385
+ r[0]
386
+ for r in _conn.execute(
387
+ "SELECT name FROM sqlite_master WHERE type='table'"
388
+ ).fetchall()
389
+ }
390
+ if "atomic_facts" in _tables:
391
+ # Only live facts (archived = soft-deleted / forgotten).
392
+ # CRIT-fix-3: lifecycle filter avoids counting deleted facts
393
+ # as "stored"; a profile with 1 000 facts all archived
394
+ # should not report store_count=1000 to a non-technical user.
395
+ _r = _conn.execute(
396
+ "SELECT COUNT(*) FROM atomic_facts "
397
+ "WHERE profile_id=? AND lifecycle IN ('active','warm','cold')",
398
+ (profile_id,),
399
+ ).fetchone()
400
+ store_count = _r[0] if _r else 0
401
+
402
+ _r = _conn.execute(
403
+ "SELECT COUNT(DISTINCT SUBSTR(created_at, 1, 10)) "
404
+ "FROM atomic_facts "
405
+ "WHERE profile_id=? AND lifecycle IN ('active','warm','cold')",
406
+ (profile_id,),
407
+ ).fetchone()
408
+ days_active = _r[0] if _r else 0
409
+
410
+ _r = _conn.execute(
411
+ "SELECT COUNT(*) FROM atomic_facts "
412
+ "WHERE profile_id=? "
413
+ "AND lifecycle IN ('active','warm','cold') "
414
+ "AND created_at >= datetime('now', '-7 days')",
415
+ (profile_id,),
416
+ ).fetchone()
417
+ recent_7d = _r[0] if _r else 0
418
+ finally:
419
+ _conn.close()
420
+ except Exception:
421
+ # Any failure (locked, missing, corrupt) → keep zeros;
422
+ # health stays INACTIVE which is the honest fallback.
423
+ pass
424
+
425
+ # ── learning.db : learning_signals (recall proxy) ─────────────────────
426
+ if learning_db_path.exists():
427
+ try:
428
+ _uri = f"{learning_db_path.resolve().as_uri()}?mode=ro"
429
+ _conn = sqlite3.connect(_uri, uri=True, timeout=1.0)
430
+ _conn.execute("PRAGMA query_only=ON")
431
+ try:
432
+ _tables = {
433
+ r[0]
434
+ for r in _conn.execute(
435
+ "SELECT name FROM sqlite_master WHERE type='table'"
436
+ ).fetchall()
437
+ }
438
+ if "learning_signals" in _tables:
439
+ # CRIT-fix-1: label is "distinct queries" not raw row count.
440
+ # One query that surfaces 10 facts writes 10 rows; COUNT(*)
441
+ # would inflate the number by 10×. COUNT(DISTINCT query)
442
+ # approximates "sessions where you asked for something"
443
+ # which is the intent of recall_count for non-technical users.
444
+ _r = _conn.execute(
445
+ "SELECT COUNT(DISTINCT query) FROM learning_signals "
446
+ "WHERE profile_id=?",
447
+ (profile_id,),
448
+ ).fetchone()
449
+ recall_count = _r[0] if _r else 0
450
+ finally:
451
+ _conn.close()
452
+ except Exception:
453
+ pass
454
+
455
+ # ── health derivation (plain language) ───────────────────────────────
456
+ if recent_7d >= _ACTIVE_THRESHOLD:
457
+ health_status = "ACTIVE"
458
+ elif recent_7d >= _WARM_THRESHOLD:
459
+ health_status = "WARM"
460
+ elif recent_7d >= 1:
461
+ health_status = "COLD"
462
+ else:
463
+ health_status = "INACTIVE"
464
+
465
+ total_events = store_count # recalls add to proxy separately via recall_count
466
+ memories_per_day = (
467
+ round(store_count / days_active, 1) if days_active > 0 else 0
468
+ )
469
+ raw = (
470
+ 0.4 * recall_count
471
+ + 0.3 * store_count
472
+ + 0.1 * days_active
473
+ )
474
+ score = (raw / (raw + 20.0)) if raw > 0 else 0.0
475
+
476
+ return {
477
+ "health_status": health_status,
478
+ "days_active": days_active,
479
+ "memories_per_day": memories_per_day,
480
+ "total_events": total_events,
481
+ "recall_count": recall_count,
482
+ "store_count": store_count,
483
+ "session_count": 0, # not derivable without dedicated writes
484
+ "engagement_score": round(score, 4),
485
+ # I6 provenance — every figure is traceable to a named source table
486
+ "source": "memory.db:atomic_facts,learning.db:learning_signals",
487
+ }
@@ -204,8 +204,12 @@ def register_code_graph_tools(server, get_engine: Callable) -> None:
204
204
  if fp in file_groups:
205
205
  file_groups[fp][1].append(e)
206
206
 
207
- for fp, (ns, es, fr) in file_groups.items():
208
- store.store_file_nodes_edges(fp, ns, es, fr)
207
+ # Two-phase commit: all nodes first, then edges.
208
+ # This makes storage order-independent so cross-file CALLS edges
209
+ # (e.g., a.py calls bar() defined in b.py) are never silently
210
+ # dropped because of the file iteration order.
211
+ batch = [(fp, ns, es, fr) for fp, (ns, es, fr) in file_groups.items()]
212
+ store.commit_build_batch(batch)
209
213
 
210
214
  # Build in-memory graph
211
215
  engine = GraphEngine(store)
@@ -357,11 +361,14 @@ def register_code_graph_tools(server, get_engine: Callable) -> None:
357
361
  continue
358
362
  try:
359
363
  source = full.read_bytes()
360
- file_nodes, file_edges = parser.parse_file(
364
+ file_nodes, file_edges, file_import_map = parser.parse_file(
361
365
  Path(fp), source, lang
362
366
  )
363
367
  import hashlib
364
368
  from superlocalmemory.code_graph.models import FileRecord
369
+ from superlocalmemory.code_graph.parser import (
370
+ _clean_and_resolve_edges,
371
+ )
365
372
  fr = FileRecord(
366
373
  file_path=fp,
367
374
  content_hash=hashlib.sha256(source).hexdigest(),
@@ -371,7 +378,27 @@ def register_code_graph_tools(server, get_engine: Callable) -> None:
371
378
  edge_count=len(file_edges),
372
379
  last_indexed=time.time(),
373
380
  )
374
- store.store_file_nodes_edges(fp, file_nodes, file_edges, fr)
381
+ # Wire the resolver for the incremental path.
382
+ # parse_all has _clean_and_resolve_edges built in, but
383
+ # update_code_graph goes through parse_file which emits raw
384
+ # placeholder targets (__call__<name>). Without resolution,
385
+ # Fix B (defensive filter) drops ALL CALLS edges — silent
386
+ # data loss on every incremental update.
387
+ #
388
+ # Load the full DB node set as the resolution universe so
389
+ # Strategy 3 (global heuristic) can match cross-file calls.
390
+ db_nodes, _ = store.get_all_nodes_and_edges()
391
+ resolution_universe = list(file_nodes) + [
392
+ n for n in db_nodes if n.file_path != fp
393
+ ]
394
+ resolved_edges = _clean_and_resolve_edges(
395
+ resolution_universe,
396
+ list(file_edges),
397
+ {fp: file_import_map},
398
+ repo,
399
+ config,
400
+ )
401
+ store.store_file_nodes_edges(fp, file_nodes, resolved_edges, fr)
375
402
  except Exception as exc:
376
403
  logger.warning("Failed to update %s: %s", fp, exc)
377
404
 
@@ -55,9 +55,12 @@ def register_v3_tools(server, get_engine: Callable) -> None:
55
55
  async def set_mode(mode: str) -> dict:
56
56
  """Switch operating mode (a, b, or c).
57
57
 
58
- Mode A: Local Guardian (zero LLM, local embeddings only).
59
- Mode B: Smart Local (local Ollama LLM, on-device inference).
60
- Mode C: Full Power (configured cloud LLM provider, best accuracy).
58
+ Mode A (Local Guardian): Nothing leaves this device. No AI language
59
+ model runs. Fastest and most private.
60
+ Mode B: All data stays on this device. Uses a local Ollama AI model
61
+ to improve recall quality. Requires Ollama installed.
62
+ Mode C: Uses a cloud AI provider (OpenAI, Anthropic, …) for best
63
+ recall quality. Queries leave this device; API key required.
61
64
 
62
65
  Resets the engine to apply the new mode configuration.
63
66
 
@@ -377,8 +380,19 @@ def register_v3_tools(server, get_engine: Callable) -> None:
377
380
  def _mode_description(mode: str) -> str:
378
381
  """Human-readable capability description for a mode (never a legal claim)."""
379
382
  descriptions = {
380
- "a": "Local Guardian: zero LLM, local embeddings only",
381
- "b": "Smart Local: local Ollama LLM, on-device inference",
382
- "c": "Full Power: configured cloud LLM provider, best accuracy",
383
+ "a": (
384
+ "Local Guardian on-device only: no AI language model runs and "
385
+ "nothing leaves this device. Fastest and most private."
386
+ ),
387
+ "b": (
388
+ "Smart Local — on-device plus a local Ollama model: better recall "
389
+ "quality, and nothing leaves this device. Requires Ollama to be "
390
+ "installed and running."
391
+ ),
392
+ "c": (
393
+ "Full Power — uses a cloud AI provider (OpenAI, Anthropic, …) for "
394
+ "the best recall quality. Your queries leave this device and an "
395
+ "API key is required."
396
+ ),
383
397
  }
384
398
  return descriptions.get(mode, "Unknown mode")
@@ -1066,6 +1066,27 @@ class RetrievalEngine:
1066
1066
  if not applied:
1067
1067
  return fused, False, status
1068
1068
 
1069
+ # The worker can report applied=True while returning scores=null — the
1070
+ # subprocess answers, so the call "succeeded", but there is nothing to
1071
+ # score with. Iterating None here raised TypeError from OUTSIDE the
1072
+ # try/except above (which only wraps the rerank call itself), so the
1073
+ # error escaped into the recall path rather than degrading to the fused
1074
+ # ordering. Fail soft: reranking is a quality improvement on top of a
1075
+ # correct result set, never a correctness requirement.
1076
+ # `not scored` covers None AND an empty sequence. An empty list is the
1077
+ # same defect wearing different clothes: the worker says applied=True but
1078
+ # supplied nothing to rank with. Guarding only None would let [] through
1079
+ # to build an empty score_map, and every candidate would then be scored
1080
+ # against a degenerate min/max — silently shrinking the fused component
1081
+ # by (1 - alpha) while still reporting the rerank as applied.
1082
+ if not scored:
1083
+ logger.warning(
1084
+ "Cross-encoder worker reported applied=True with %s scores; "
1085
+ "falling back to fused ranking for this query.",
1086
+ "null" if scored is None else "empty",
1087
+ )
1088
+ return fused, False, "worker_null_scores"
1089
+
1069
1090
  score_map = {fact.fact_id: score for fact, score in scored}
1070
1091
 
1071
1092
  # Min-max normalize CE scores to [0, 1] within the batch instead of
@@ -110,12 +110,30 @@ def is_remote_cross_encoder_backend(backend: str) -> bool:
110
110
  return (backend or "").strip().lower() in REMOTE_CROSS_ENCODER_BACKENDS
111
111
 
112
112
 
113
- def validate_remote_reranker_config(backend: str, endpoint: str) -> str | None:
113
+ def validate_remote_reranker_config(
114
+ backend: str,
115
+ endpoint: str,
116
+ trust_plain_http_lan: bool = True,
117
+ ) -> str | None:
114
118
  """Return an actionable error string, or None when the pair is coherent.
115
119
 
116
120
  Covers the issue-#103 leftover directly: an endpoint configured against a
117
121
  LOCAL backend used to be dropped on the floor by ``SLMConfig.load``. It now
118
122
  produces a named error naming both keys and the exact edit to make.
123
+
124
+ Args:
125
+ backend: Value of ``retrieval.cross_encoder_backend``.
126
+ endpoint: Value of ``retrieval.cross_encoder_endpoint``.
127
+ trust_plain_http_lan: When True (the default), numeric RFC1918/ULA/
128
+ link-local addresses may use plain HTTP — the same security posture
129
+ as the local reranker, where memory text only crosses loopback.
130
+ Set to False in hardened deployments (zero-trust networks, shared
131
+ colocation) to require HTTPS for all non-loopback hosts.
132
+
133
+ Threat model note: trusting a private-LAN address does NOT prevent a
134
+ MITM attack by an adversary on the same physical LAN (e.g. via ARP
135
+ spoofing). This flag means "the LAN is under my control and I accept that
136
+ risk." It is not a claim that RFC1918 traffic is cryptographically secure.
119
137
  """
120
138
  backend = (backend or "").strip()
121
139
  endpoint = (endpoint or "").strip()
@@ -139,11 +157,23 @@ def validate_remote_reranker_config(backend: str, endpoint: str) -> str | None:
139
157
  )
140
158
  if not remote:
141
159
  return None
142
- return _validate_endpoint_url(endpoint)
143
-
144
-
145
- def _validate_endpoint_url(endpoint: str) -> str | None:
146
- """Scheme/host allow-listing for the operator-supplied rerank URL."""
160
+ return _validate_endpoint_url(endpoint, trust_plain_http_lan=trust_plain_http_lan)
161
+
162
+
163
+ def _validate_endpoint_url(
164
+ endpoint: str,
165
+ trust_plain_http_lan: bool = True,
166
+ ) -> str | None:
167
+ """Scheme/host allow-listing for the operator-supplied rerank URL.
168
+
169
+ Plain-HTTP allowances (most-to-least trusted):
170
+ 1. Loopback (127.x, ::1, localhost) — always allowed.
171
+ 2. Numeric RFC1918/ULA/link-local addresses — allowed when
172
+ ``trust_plain_http_lan`` is True (the default). Only numeric
173
+ addresses qualify; bare hostnames are never trusted because DNS is
174
+ mutable and not a trust boundary.
175
+ 3. Everything else (public IPs, bare hostnames) — always requires HTTPS.
176
+ """
147
177
  try:
148
178
  parsed = urlparse(endpoint)
149
179
  except ValueError as exc:
@@ -177,11 +207,39 @@ def _validate_endpoint_url(endpoint: str) -> str | None:
177
207
  "retrieval.cross_encoder_api_key; it is sent as a Bearer header "
178
208
  "and never logged."
179
209
  )
180
- if parsed.scheme == "http" and not _is_loopback_host(parsed.hostname):
210
+ if parsed.scheme == "http":
211
+ hostname = parsed.hostname
212
+ if _is_loopback_host(hostname):
213
+ return None # loopback always allowed regardless of trust flag
214
+ if trust_plain_http_lan and _is_private_lan_host(hostname):
215
+ # Numeric private address on an operator-trusted LAN. Threat model:
216
+ # an attacker on the same physical LAN can still MITM plain HTTP
217
+ # (ARP spoofing). This is allowed because the LAN is assumed to be
218
+ # under the operator's control. Set trust_plain_http_lan=False in
219
+ # hardened/zero-trust environments.
220
+ return None
221
+ if not _is_private_lan_host(hostname):
222
+ # Public IP, CGNAT, or a bare hostname (DNS not trusted as a
223
+ # proof of locality). Bare hostnames that happen to resolve to
224
+ # private IPs are NOT trusted: DNS can be poisoned or changed,
225
+ # so only provably-private numeric addresses are accepted.
226
+ return (
227
+ "retrieval.cross_encoder_endpoint must use HTTPS for this "
228
+ "host. Plain HTTP is allowed only for loopback "
229
+ "(127.x/::1/localhost) and numeric private-LAN addresses "
230
+ "(RFC1918: 10.x, 172.16-31.x, 192.168.x; IPv6 ULA fc00::/7; "
231
+ "link-local 169.254.x/fe80::). "
232
+ "Bare hostnames are not trusted even if they resolve to a "
233
+ "private IP — use a numeric address or configure HTTPS."
234
+ )
235
+ # Private-LAN address but trust_plain_http_lan is False (hardened mode)
181
236
  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."
237
+ "retrieval.cross_encoder_endpoint uses plain HTTP to a "
238
+ "private-LAN address. HTTPS is required because "
239
+ "retrieval.trust_plain_http_lan is set to false. "
240
+ "Either configure a TLS-terminating proxy on the reranker, or "
241
+ "set retrieval.trust_plain_http_lan=true to permit plain HTTP "
242
+ "within your private network (default for new installs)."
185
243
  )
186
244
  return None
187
245
 
@@ -197,6 +255,42 @@ def _is_loopback_host(hostname: str) -> bool:
197
255
  return False
198
256
 
199
257
 
258
+ def _is_private_lan_host(hostname: str) -> bool:
259
+ """True only for numeric private-range addresses (RFC1918, ULA, link-local).
260
+
261
+ Deliberate non-DNS: bare hostnames (e.g. ``my-reranker.lan``) return False
262
+ even if they currently resolve to a private IP. DNS is mutable and not a
263
+ trust boundary — an adversary who can influence DNS resolution can redirect
264
+ the endpoint to a public host, defeating the locality check. Only numeric
265
+ addresses are provably bound to a private range at configuration time.
266
+
267
+ Accepted ranges (Python 3.11+ ``ipaddress.is_private``):
268
+ IPv4 RFC1918: 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16
269
+ IPv4 link-local: 169.254.0.0/16
270
+ IPv6 ULA: fc00::/7 (includes fd00::/8)
271
+ IPv6 link-local: fe80::/10
272
+
273
+ Excluded ranges (not accepted for plain HTTP):
274
+ CGNAT 100.64.0.0/10 — ISP-shared address space, not operator-controlled
275
+ 172.15.0.0/8 and 172.32.0.0/8 — outside the 172.16.0.0/12 boundary
276
+ Public unicast addresses
277
+
278
+ IPv4-mapped IPv6 addresses (``::ffff:192.168.1.1``) are unwrapped to their
279
+ IPv4 equivalent before the range check, so they are handled consistently.
280
+ """
281
+ host = (hostname or "").rstrip(".").lower()
282
+ try:
283
+ addr = ipaddress.ip_address(host)
284
+ except ValueError:
285
+ # Not a numeric address — bare hostname, not provably private
286
+ return False
287
+ # Unwrap IPv4-mapped IPv6 (::ffff:192.168.1.1 → 192.168.1.1) so the
288
+ # RFC1918 check applies to the IPv4 portion.
289
+ if isinstance(addr, ipaddress.IPv6Address) and addr.ipv4_mapped is not None:
290
+ addr = addr.ipv4_mapped
291
+ return addr.is_private
292
+
293
+
200
294
  def normalize_rerank_endpoint(endpoint: str) -> str:
201
295
  """Append ``/rerank`` when the URL stops at the API root.
202
296
 
@@ -370,8 +464,11 @@ class RemoteReranker:
370
464
  api_key: str = "",
371
465
  backend: str = "openai",
372
466
  timeout_seconds: float = _DEFAULT_READ_TIMEOUT_S,
467
+ trust_plain_http_lan: bool = True,
373
468
  ) -> None:
374
- error = validate_remote_reranker_config(backend, endpoint)
469
+ error = validate_remote_reranker_config(
470
+ backend, endpoint, trust_plain_http_lan=trust_plain_http_lan,
471
+ )
375
472
  if error:
376
473
  raise RemoteRerankerConfigError(error)
377
474