superlocalmemory 3.6.10 → 3.6.12

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 (32) hide show
  1. package/CHANGELOG.md +65 -0
  2. package/README.md +62 -6
  3. package/package.json +1 -1
  4. package/pyproject.toml +1 -1
  5. package/skills/slm-optimize/README.md +55 -0
  6. package/skills/slm-optimize/SKILL.md +139 -0
  7. package/src/superlocalmemory/cli/commands.py +1 -0
  8. package/src/superlocalmemory/cli/daemon.py +0 -407
  9. package/src/superlocalmemory/cli/main.py +3 -1
  10. package/src/superlocalmemory/core/context_cache.py +4 -1
  11. package/src/superlocalmemory/core/fact_consolidator.py +4 -1
  12. package/src/superlocalmemory/core/remote_mode.py +197 -0
  13. package/src/superlocalmemory/core/summarizer.py +4 -1
  14. package/src/superlocalmemory/llm/backbone.py +7 -1
  15. package/src/superlocalmemory/mcp/agent_context.py +7 -3
  16. package/src/superlocalmemory/mcp/server.py +4 -0
  17. package/src/superlocalmemory/mcp/tools_core.py +13 -1
  18. package/src/superlocalmemory/mcp/tools_mesh.py +14 -6
  19. package/src/superlocalmemory/mcp/tools_optimize.py +304 -0
  20. package/src/superlocalmemory/mesh/broker.py +15 -4
  21. package/src/superlocalmemory/optimize/compress/router.py +9 -4
  22. package/src/superlocalmemory/optimize/storage/db.py +40 -2
  23. package/src/superlocalmemory/server/api.py +11 -3
  24. package/src/superlocalmemory/server/routes/mesh.py +13 -0
  25. package/src/superlocalmemory/server/routes/token.py +14 -2
  26. package/src/superlocalmemory/server/routes/v3_api.py +83 -17
  27. package/src/superlocalmemory/server/ui.py +15 -4
  28. package/src/superlocalmemory/server/unified_daemon.py +96 -160
  29. package/src/superlocalmemory/storage/database.py +10 -1
  30. package/src/superlocalmemory/ui/js/auto-settings.js +24 -0
  31. package/src/superlocalmemory.egg-info/PKG-INFO +63 -7
  32. package/src/superlocalmemory.egg-info/SOURCES.txt +2 -0
@@ -408,410 +408,3 @@ def _wait_for_workers_dead(timeout: int = 10) -> None:
408
408
  time.sleep(0.5)
409
409
 
410
410
  logger.warning("Some SLM workers still alive after %ds timeout", timeout)
411
-
412
-
413
- # ---------------------------------------------------------------------------
414
- # Server: HTTP request handler with engine singleton
415
- # ---------------------------------------------------------------------------
416
-
417
- _engine = None
418
- _last_activity = time.monotonic()
419
-
420
- # ---------------------------------------------------------------------------
421
- # V3.3.28: Observation debounce buffer.
422
- #
423
- # When 20+ file edits arrive in quick succession (from parallel AI agents,
424
- # git checkout, or batch sed), we buffer observations for _OBSERVE_DEBOUNCE_SEC
425
- # seconds and deduplicate by content hash. This reduces 20 observations → 1-3
426
- # batches, each processed by the singleton engine (1 embedding worker).
427
- # ---------------------------------------------------------------------------
428
-
429
- _OBSERVE_DEBOUNCE_SEC = float(os.environ.get("SLM_OBSERVE_DEBOUNCE_SEC", "3.0"))
430
- _observe_buffer: list[str] = []
431
- _observe_seen: set[str] = set() # content hashes for dedup within window
432
- _observe_lock = threading.Lock()
433
- _observe_timer: threading.Timer | None = None
434
-
435
-
436
- def _flush_observe_buffer() -> None:
437
- """Process all buffered observations as a single batch."""
438
- global _observe_timer
439
- with _observe_lock:
440
- if not _observe_buffer:
441
- return
442
- batch = list(_observe_buffer)
443
- _observe_buffer.clear()
444
- _observe_seen.clear()
445
- _observe_timer = None
446
-
447
- # Process each unique observation (already deduped)
448
- engine = _get_engine()
449
- from superlocalmemory.hooks.auto_capture import AutoCapture
450
- auto = AutoCapture(engine=engine)
451
-
452
- for content in batch:
453
- try:
454
- decision = auto.evaluate(content)
455
- if decision.capture:
456
- auto.capture(content, category=decision.category)
457
- except Exception as exc:
458
- # Swallow per-observation to protect the batch, but log so
459
- # a pattern of dropped observations is visible.
460
- logger.warning("observation dropped during batch: %s", exc)
461
-
462
- logger.info("Observe debounce: processed %d observations (from buffer)", len(batch))
463
-
464
-
465
- def _enqueue_observation(content: str) -> dict:
466
- """Add an observation to the debounce buffer. Returns immediate response."""
467
- global _observe_timer
468
- import hashlib
469
- content_hash = hashlib.md5(content.encode()).hexdigest()
470
-
471
- with _observe_lock:
472
- if content_hash in _observe_seen:
473
- return {"captured": False, "reason": "duplicate within debounce window"}
474
-
475
- _observe_seen.add(content_hash)
476
- _observe_buffer.append(content)
477
- buf_size = len(_observe_buffer)
478
-
479
- # Reset debounce timer
480
- if _observe_timer is not None:
481
- _observe_timer.cancel()
482
- _observe_timer = threading.Timer(_OBSERVE_DEBOUNCE_SEC, _flush_observe_buffer)
483
- _observe_timer.daemon = True
484
- _observe_timer.start()
485
-
486
- return {"captured": True, "queued": True, "buffer_size": buf_size,
487
- "debounce_sec": _OBSERVE_DEBOUNCE_SEC}
488
-
489
-
490
- def _get_engine():
491
- global _engine
492
- if _engine is None:
493
- from superlocalmemory.core.config import SLMConfig
494
- from superlocalmemory.core.engine import MemoryEngine
495
-
496
- config = SLMConfig.load()
497
- _engine = MemoryEngine(config)
498
- _engine.initialize()
499
-
500
- # Force reranker warmup (blocking — daemon can afford to wait)
501
- retrieval_eng = getattr(_engine, '_retrieval_engine', None)
502
- if retrieval_eng:
503
- reranker = getattr(retrieval_eng, '_reranker', None)
504
- if reranker and hasattr(reranker, 'warmup_sync'):
505
- reranker.warmup_sync(timeout=120)
506
-
507
- logger.info("Daemon engine initialized and warm")
508
- return _engine
509
-
510
-
511
- class DaemonHandler(BaseHTTPRequestHandler):
512
- """Lightweight HTTP handler for daemon requests."""
513
-
514
- def log_message(self, format, *args):
515
- """Suppress default access logging."""
516
- pass
517
-
518
- def _send_json(self, status: int, data: dict) -> None:
519
- self.send_response(status)
520
- self.send_header("Content-Type", "application/json")
521
- self.end_headers()
522
- self.wfile.write(json.dumps(data).encode())
523
-
524
- def _read_body(self) -> dict:
525
- length = int(self.headers.get("Content-Length", 0))
526
- if length == 0:
527
- return {}
528
- return json.loads(self.rfile.read(length).decode())
529
-
530
- def do_GET(self) -> None:
531
- global _last_activity
532
- _last_activity = time.monotonic()
533
-
534
- if self.path == "/health":
535
- self._send_json(200, {"status": "ok", "pid": os.getpid()})
536
- return
537
-
538
- if self.path.startswith("/recall"):
539
- try:
540
- # Parse query from URL params
541
- from urllib.parse import urlparse, parse_qs
542
- params = parse_qs(urlparse(self.path).query)
543
- query = params.get("q", [""])[0]
544
- limit = int(params.get("limit", ["20"])[0])
545
-
546
- # S9-DASH-02: session_id for outcome-queue enqueue.
547
- # Priority: ?session_id= query arg > X-SLM-Session-Id
548
- # header > synthetic "cli:<ts>". Without any of these
549
- # the recall still works — it just doesn't produce a
550
- # pending_outcome (hook-based signals can't match).
551
- session_id = params.get("session_id", [""])[0]
552
- if not session_id:
553
- session_id = self.headers.get("X-SLM-Session-Id", "")
554
- if not session_id:
555
- import time as _t
556
- session_id = f"http:{int(_t.time() * 1000)}"
557
-
558
- engine = _get_engine()
559
- raw_fast = params.get("fast", ["false"])[0]
560
- fast = raw_fast.lower() in ("true", "1")
561
- response = engine.recall(
562
- query, limit=limit, session_id=session_id, fast=fast,
563
- )
564
- # Return the same field shape as recall_worker._handle_recall,
565
- # so MCP processes that proxy through the daemon get recall_trace-
566
- # compatible data without a second round trip.
567
- memory_ids = list({
568
- r.fact.memory_id for r in response.results[:limit]
569
- if r.fact.memory_id
570
- })
571
- memory_map = (
572
- engine._db.get_memory_content_batch(memory_ids)
573
- if memory_ids else {}
574
- )
575
- results = []
576
- for r in response.results[:limit]:
577
- fact_type = getattr(r.fact, "fact_type", None)
578
- lifecycle = getattr(r.fact, "lifecycle", None)
579
- # v3.5.1: sanitize control chars that break JSON (newlines, tabs in content).
580
- clean = r.fact.content.replace("\r", " ").replace("\n", " ").replace("\t", " ")
581
- sc_raw = memory_map.get(r.fact.memory_id, "")
582
- sc_clean = sc_raw.replace("\r", " ").replace("\n", " ").replace("\t", " ") if sc_raw else ""
583
- results.append({
584
- "fact_id": r.fact.fact_id,
585
- "memory_id": r.fact.memory_id,
586
- "content": clean,
587
- "source_content": sc_clean,
588
- "score": round(r.score, 4),
589
- "confidence": round(r.confidence, 4),
590
- "trust_score": round(r.trust_score, 4),
591
- "channel_scores": {
592
- k: round(v, 4)
593
- for k, v in (r.channel_scores or {}).items()
594
- },
595
- "fact_type": fact_type.value
596
- if fact_type and hasattr(fact_type, "value") else "",
597
- "lifecycle": lifecycle.value
598
- if lifecycle and hasattr(lifecycle, "value") else "",
599
- "access_count": getattr(r.fact, "access_count", 0),
600
- "evidence_chain": list(
601
- getattr(r, "evidence_chain", []) or []
602
- ),
603
- })
604
- self._send_json(200, {
605
- "ok": True,
606
- "query": query,
607
- "query_type": response.query_type,
608
- "result_count": len(results),
609
- "retrieval_time_ms": round(response.retrieval_time_ms, 1),
610
- "channel_weights": {
611
- k: round(v, 3)
612
- for k, v in (response.channel_weights or {}).items()
613
- },
614
- "total_candidates": getattr(response, "total_candidates", 0),
615
- "results": results,
616
- "count": len(results), # backward compat alias
617
- })
618
- except Exception as exc:
619
- self._send_json(500, {"error": str(exc)})
620
- return
621
-
622
- if self.path == "/list":
623
- try:
624
- engine = _get_engine()
625
- facts = engine.list_facts(limit=50)
626
- items = [
627
- {"content": f.content[:100], "fact_type": getattr(f.fact_type, 'value', str(f.fact_type)),
628
- "created_at": (f.created_at or "")[:19], "fact_id": f.fact_id}
629
- for f in facts
630
- ]
631
- self._send_json(200, {"results": items, "count": len(items)})
632
- except Exception as exc:
633
- self._send_json(500, {"error": str(exc)})
634
- return
635
-
636
- if self.path == "/status":
637
- engine = _get_engine()
638
- uptime = time.monotonic() - _server_start_time
639
- self._send_json(200, {
640
- "status": "running", "pid": os.getpid(),
641
- "uptime_s": round(uptime),
642
- "mode": engine._config.mode.value,
643
- "fact_count": engine.fact_count,
644
- "idle_s": round(time.monotonic() - _last_activity),
645
- })
646
- return
647
-
648
- self._send_json(404, {"error": "not found"})
649
-
650
- def do_POST(self) -> None:
651
- global _last_activity
652
- _last_activity = time.monotonic()
653
-
654
- if self.path == "/remember":
655
- try:
656
- body = self._read_body()
657
- content = body.get("content", "")
658
- tags = body.get("tags", "")
659
- extra_meta = body.get("metadata") or {}
660
- if not content:
661
- self._send_json(400, {"error": "content required"})
662
- return
663
-
664
- engine = _get_engine()
665
- metadata = {"tags": tags} if tags else {}
666
- if isinstance(extra_meta, dict):
667
- metadata.update(extra_meta)
668
- fact_ids = engine.store(content, metadata=metadata)
669
- self._send_json(200, {
670
- "ok": True,
671
- "fact_ids": fact_ids,
672
- "count": len(fact_ids),
673
- })
674
- except Exception as exc:
675
- self._send_json(500, {"error": str(exc)})
676
- return
677
-
678
- if self.path == "/observe":
679
- try:
680
- body = self._read_body()
681
- content = body.get("content", "")
682
- if not content:
683
- self._send_json(400, {"error": "content required"})
684
- return
685
-
686
- # V3.3.28: Debounced observation processing.
687
- # Buffers observations for 3s, deduplicates, processes as batch.
688
- # Returns immediately — the actual capture happens asynchronously
689
- # via the debounce timer, using the singleton engine.
690
- result = _enqueue_observation(content)
691
- self._send_json(200, result)
692
- except Exception as exc:
693
- self._send_json(500, {"error": str(exc)})
694
- return
695
-
696
- if self.path == "/stop":
697
- self._send_json(200, {"status": "stopping"})
698
- Thread(target=_shutdown_server, daemon=True).start()
699
- return
700
-
701
- self._send_json(404, {"error": "not found"})
702
-
703
-
704
- # ---------------------------------------------------------------------------
705
- # Server lifecycle
706
- # ---------------------------------------------------------------------------
707
-
708
- _server: HTTPServer | None = None
709
- _server_start_time = time.monotonic()
710
-
711
-
712
- def _shutdown_server() -> None:
713
- global _engine, _server
714
- try:
715
- _flush_observe_buffer()
716
- except Exception as exc:
717
- logger.warning("flush observe buffer on shutdown failed: %s", exc)
718
- time.sleep(0.5)
719
- if _engine is not None:
720
- try:
721
- _engine.close()
722
- except Exception as exc:
723
- logger.warning("engine close on shutdown failed: %s", exc)
724
- _engine = None
725
- if _server is not None:
726
- _server.shutdown()
727
- _PID_FILE.unlink(missing_ok=True)
728
- _PORT_FILE.unlink(missing_ok=True)
729
-
730
-
731
- def _idle_watchdog(timeout: int) -> None:
732
- """Auto-shutdown after idle timeout."""
733
- global _last_activity
734
- while True:
735
- time.sleep(30)
736
- idle = time.monotonic() - _last_activity
737
- if idle > timeout:
738
- logger.info("Daemon idle for %ds, shutting down", int(idle))
739
- _shutdown_server()
740
- os._exit(0)
741
-
742
-
743
- def start_server(port: int = _DEFAULT_PORT, idle_timeout: int | None = None) -> None:
744
- """Start the daemon HTTP server. Blocks until stopped."""
745
- global _server, _server_start_time, _last_activity
746
-
747
- idle_timeout = idle_timeout or int(os.environ.get(
748
- "SLM_DAEMON_IDLE_TIMEOUT", str(_DEFAULT_IDLE_TIMEOUT),
749
- ))
750
-
751
- # Banner is advisory — a broken data dir must never prevent the daemon
752
- # from starting, so the swallow here is intentional.
753
- try:
754
- from superlocalmemory import __version__ as _slm_ver
755
- from superlocalmemory.cli.version_banner import check_and_emit_upgrade_banner
756
- check_and_emit_upgrade_banner(_slm_ver)
757
- except Exception as exc:
758
- logger.warning("upgrade banner on daemon start failed: %s", exc)
759
-
760
- # Apply the v3.4.26 data-dir migration now — the daemon is the
761
- # authoritative holder of the DB, so this is the right place to do
762
- # it unconditionally (``migrate`` is idempotent).
763
- try:
764
- from pathlib import Path as _P
765
- from superlocalmemory.migrations.v3_4_25_to_v3_4_26 import (
766
- is_ready as _is_ready, migrate as _migrate,
767
- )
768
- _data = _P(os.environ.get("SLM_DATA_DIR")
769
- or _P.home() / ".superlocalmemory")
770
- if not _is_ready(_data):
771
- _migrate(_data)
772
- except Exception as exc:
773
- logger.warning("v3.4.26 migration on daemon start failed: %s", exc)
774
-
775
- # Write PID + port files
776
- _PID_FILE.parent.mkdir(parents=True, exist_ok=True)
777
- _PID_FILE.write_text(str(os.getpid()))
778
- _PORT_FILE.write_text(str(port))
779
-
780
- # Handle SIGTERM for graceful shutdown
781
- signal.signal(signal.SIGTERM, lambda *_: _shutdown_server() or os._exit(0))
782
-
783
- # Pre-warm engine (this is the cold start — daemon absorbs it once)
784
- logger.info("Daemon starting — warming engine...")
785
- _get_engine()
786
- logger.info("Engine warm. Daemon ready on port %d (idle timeout: %ds)", port, idle_timeout)
787
-
788
- _server_start_time = time.monotonic()
789
- _last_activity = time.monotonic()
790
-
791
- # Start idle watchdog
792
- Thread(target=_idle_watchdog, args=(idle_timeout,), daemon=True, name="idle-watchdog").start()
793
-
794
- # Start HTTP server
795
- # SO_REUSEADDR must be set on the class BEFORE __init__ calls bind()
796
- HTTPServer.allow_reuse_address = True
797
- _server = HTTPServer(("127.0.0.1", port), DaemonHandler)
798
- try:
799
- _server.serve_forever()
800
- except KeyboardInterrupt:
801
- pass
802
- finally:
803
- _shutdown_server()
804
-
805
-
806
- # ---------------------------------------------------------------------------
807
- # CLI entry point
808
- # ---------------------------------------------------------------------------
809
-
810
- if __name__ == "__main__":
811
- logging.basicConfig(level=logging.INFO, format="%(asctime)s %(message)s")
812
- if "--start" in sys.argv:
813
- start_server()
814
- elif "--stop" in sys.argv:
815
- stop_daemon()
816
- else:
817
- print("Usage: python -m superlocalmemory.cli.daemon --start|--stop")
@@ -183,7 +183,9 @@ def main() -> None:
183
183
  help="Wait for completion (default: async background processing)",
184
184
  )
185
185
 
186
- recall_p = sub.add_parser("recall", help="Semantic search with 4-channel retrieval")
186
+ # v3.6.12 (parity-3): `search` is an alias of `recall` so the CLI has the
187
+ # same search verb the MCP exposes (handlers dict maps both to cmd_recall).
188
+ recall_p = sub.add_parser("recall", aliases=["search"], help="Semantic search with 4-channel retrieval")
187
189
  recall_p.add_argument("query", help="Search query")
188
190
  recall_p.add_argument("--limit", type=int, default=10, help="Max results (default 10)")
189
191
  recall_p.add_argument("--json", action="store_true", help="Output structured JSON (agent-native)")
@@ -205,7 +205,10 @@ class ContextCache:
205
205
  Does NOT run LRU sweep inline (PERF-01-07) — that's a background
206
206
  task on the daemon.
207
207
  """
208
- content = redact_secrets(entry.content)[:MAX_CONTENT_CHARS]
208
+ # v3.6.12 (redact-1): scrub dashboard/cached content at HIGH aggression
209
+ # so Bearer/GitHub-PAT/Anthropic/OpenAI/GENERIC_KEY patterns are caught
210
+ # (the default 'normal' skipped them, leaking those shapes to the UI).
211
+ content = redact_secrets(entry.content, aggression="high")[:MAX_CONTENT_CHARS]
209
212
  fact_ids_json = json.dumps(list(entry.fact_ids))
210
213
  byte_size = (
211
214
  len(content.encode("utf-8"))
@@ -367,7 +367,10 @@ def _summarize_with_ollama(
367
367
  if config and hasattr(config, 'llm'):
368
368
  api_base = getattr(config.llm, 'api_base', api_base) or api_base
369
369
  model = getattr(config.llm, 'model', model) or model
370
- timeout = getattr(config.llm, 'timeout', timeout) or timeout
370
+ # v3.6.12 (modeb-4): the LLMConfig field is `timeout_seconds`, not
371
+ # `timeout` — the old read always missed and silently used 30s.
372
+ timeout = getattr(config.llm, 'timeout_seconds', None) or \
373
+ getattr(config.llm, 'timeout', None) or timeout
371
374
 
372
375
  fact_texts = "\n".join(f"- {f['content']}" for f in facts[:_MAX_CLUSTER_SIZE])
373
376
  prompt = (
@@ -0,0 +1,197 @@
1
+ # Copyright (c) 2026 Varun Pratap Bhardwaj / Qualixar
2
+ # Licensed under AGPL-3.0-or-later - see LICENSE file
3
+ # Part of SuperLocalMemory V3 | https://qualixar.com | https://varunpratap.com
4
+
5
+ """Distributed / LAN deployment mode — the single ``SLM_REMOTE`` switch.
6
+
7
+ SuperLocalMemory historically assumes every dashboard browser, MCP client,
8
+ and API caller lives on ``127.0.0.1``. That assumption breaks three things
9
+ for users who deploy SLM on a server and reach it across a LAN (issue #39):
10
+
11
+ 1. ``/internal/token`` refuses any non-loopback client → Brain page can't
12
+ fetch the install token → "Couldn't load Brain".
13
+ 2. The MCP Streamable-HTTP transport is **stateful** — every call must
14
+ replay the ``Mcp-Session-Id`` from the ``initialize`` handshake. A
15
+ gateway/hub that forwards a tool call without replaying it gets
16
+ ``-32600 Session not found``.
17
+ 3. Dashboard CSRF origin checks only accept loopback origins.
18
+
19
+ ``SLM_REMOTE=1`` flips all three assumptions at once, **default OFF** so the
20
+ loopback-only security posture is unchanged for the 99% local case. LAN
21
+ access is still gated by an explicit IP allowlist (``SLM_MCP_ALLOWED_HOSTS``)
22
+ — remote mode alone does not throw the doors open.
23
+
24
+ Granular overrides (each implied by ``SLM_REMOTE=1`` but usable alone):
25
+ * ``SLM_MCP_STATELESS=1`` — stateless MCP transport only (gateway fix),
26
+ without opening the dashboard token endpoint.
27
+
28
+ Security note (WORSTCASE): stateless MCP drops per-session isolation, and
29
+ serving the install token to a LAN host lets any allowlisted machine read
30
+ the brain. Keep the allowlist specific (never blanket ``*`` unless the
31
+ network is fully trusted) — see ``docs/distributed-deployment.md``.
32
+ """
33
+
34
+ from __future__ import annotations
35
+
36
+ import ipaddress
37
+ import os
38
+
39
+ _TRUTHY = frozenset({"1", "true", "yes", "on"})
40
+
41
+
42
+ def _is_truthy(value: str | None) -> bool:
43
+ return bool(value) and value.strip().lower() in _TRUTHY
44
+
45
+
46
+ def is_remote_mode() -> bool:
47
+ """True iff ``SLM_REMOTE`` opts this daemon into LAN/distributed mode."""
48
+ return _is_truthy(os.environ.get("SLM_REMOTE"))
49
+
50
+
51
+ def mcp_stateless() -> bool:
52
+ """True iff the MCP transport should run stateless (no session id required).
53
+
54
+ Enabled by ``SLM_REMOTE=1`` (umbrella) or ``SLM_MCP_STATELESS=1`` (granular).
55
+ Stateless mode lets any gateway/hub forward ``tools/call`` without replaying
56
+ the ``Mcp-Session-Id`` handshake — the fix for issue #39 Issue 3.
57
+ """
58
+ return is_remote_mode() or _is_truthy(os.environ.get("SLM_MCP_STATELESS"))
59
+
60
+
61
+ def _allowlist_entries() -> list[str]:
62
+ """Trusted-client allowlist, from ``SLM_MCP_ALLOWED_HOSTS``.
63
+
64
+ Reuses the existing LAN allowlist the user already sets for MCP DNS-rebinding
65
+ protection so there is ONE place to configure trusted hosts. Entries are
66
+ comma-separated and may be: ``*`` (any), an exact IP, a CIDR block
67
+ (``192.168.1.0/24``), or a prefix wildcard (``192.168.*``). A trailing
68
+ ``:port`` / ``:*`` (host-header style) is ignored for client-IP matching.
69
+ """
70
+ raw = os.environ.get("SLM_MCP_ALLOWED_HOSTS", "").strip()
71
+ return [e.strip() for e in raw.split(",") if e.strip()]
72
+
73
+
74
+ def _strip_port(entry: str) -> str:
75
+ """Drop a trailing ``:port`` / ``:*`` host-header suffix.
76
+
77
+ Handles plain ``host[:port]`` and CIDR ``a.b.c.d/n[:port]`` (v3.6.12 lan-1:
78
+ a CIDR written with a host-header port suffix used to fail ip_network() and
79
+ silently deny ALL clients). Bracketless IPv6 literals (≥2 colons, no '/')
80
+ are left untouched.
81
+ """
82
+ e = entry.strip()
83
+ if "/" in e:
84
+ # CIDR — strip anything after the network prefix (a stray :port/:*)
85
+ return e.partition(":")[0]
86
+ if e.count(":") == 1: # host:port or host:* (IPv4 / hostname)
87
+ return e.split(":", 1)[0]
88
+ return e
89
+
90
+
91
+ def _host_matches(entry: str, client_host: str, client_ip) -> bool:
92
+ host = _strip_port(entry).strip()
93
+ if not host:
94
+ return False
95
+ if host == "*":
96
+ return True
97
+ if "/" in host and client_ip is not None:
98
+ try:
99
+ return client_ip in ipaddress.ip_network(host, strict=False)
100
+ except ValueError:
101
+ return False
102
+ if host.endswith("*"):
103
+ # STRING prefix match (not CIDR). client_host is always the numeric
104
+ # socket peer IP (never a resolvable hostname), and a dotted prefix like
105
+ # "192.168." rejects "192.1680.x". Prefer CIDR (192.168.0.0/16) for
106
+ # unambiguous network matching; wildcards are a convenience.
107
+ return client_host.startswith(host[:-1])
108
+ return host == client_host
109
+
110
+
111
+ def is_lan_client_allowed(client_host: str) -> bool:
112
+ """True iff remote mode is ON and ``client_host`` is in the trusted allowlist.
113
+
114
+ Loopback is handled separately by callers — this governs *non*-loopback LAN
115
+ clients only. Returns False whenever remote mode is off or the allowlist is
116
+ empty, so the default posture stays loopback-only.
117
+ """
118
+ if not is_remote_mode() or not client_host:
119
+ return False
120
+ entries = _allowlist_entries()
121
+ if not entries:
122
+ return False
123
+ try:
124
+ client_ip = ipaddress.ip_address(client_host)
125
+ except ValueError:
126
+ client_ip = None
127
+ return any(_host_matches(e, client_host, client_ip) for e in entries)
128
+
129
+
130
+ def is_remote_origin_allowed(origin: str) -> bool:
131
+ """True iff remote mode is ON and ``origin``'s host is in the allowlist.
132
+
133
+ ``origin`` is a full URL (``http://192.168.50.144:8765``). Empty origin is
134
+ not this function's concern (loopback callers handle that). Used to relax
135
+ the dashboard CSRF origin guard for trusted LAN dashboards.
136
+ """
137
+ if not is_remote_mode() or not origin:
138
+ return False
139
+ # Extract host from scheme://host[:port]
140
+ rest = origin.split("://", 1)[-1]
141
+ host = rest.split("/", 1)[0]
142
+ # Strip a trailing :port (IPv4/hostname); leave bracketed IPv6 alone.
143
+ if host.startswith("["):
144
+ host = host.split("]", 1)[0].lstrip("[")
145
+ elif host.count(":") == 1:
146
+ host = host.split(":", 1)[0]
147
+ return is_lan_client_allowed(host)
148
+
149
+
150
+ def _env_int(name: str, default: int) -> int:
151
+ """Read a positive int from env, falling back to ``default`` on any error."""
152
+ raw = os.environ.get(name, "").strip()
153
+ if not raw:
154
+ return default
155
+ try:
156
+ val = int(raw)
157
+ except ValueError:
158
+ return default
159
+ return val if val > 0 else default
160
+
161
+
162
+ def rate_limit_config() -> tuple[int, int, int]:
163
+ """(write_max, read_max, window_seconds) for the dashboard rate limiter.
164
+
165
+ Issue #40 Issue 3: the limiter was hardcoded (30 writes / 120 reads per 60s)
166
+ with no way to raise it for distributed/LAN debugging, so a remote browser
167
+ that retried a failing Brain load hit ``429 Too Many Requests``. These are
168
+ now tunable via ``SLM_RATE_LIMIT_WRITE`` / ``SLM_RATE_LIMIT_READ`` /
169
+ ``SLM_RATE_LIMIT_WINDOW`` (defaults unchanged for the local case).
170
+ """
171
+ write_max = _env_int("SLM_RATE_LIMIT_WRITE", 30)
172
+ read_max = _env_int("SLM_RATE_LIMIT_READ", 120)
173
+ window = _env_int("SLM_RATE_LIMIT_WINDOW", 60)
174
+ return write_max, read_max, window
175
+
176
+
177
+ def is_rate_limit_exempt(client_host: str) -> bool:
178
+ """True iff ``client_host`` should bypass the dashboard rate limiter.
179
+
180
+ Loopback is always exempt (the dashboard polls itself rapidly). In remote
181
+ mode, an allowlisted LAN client is the user's own remote browser doing the
182
+ same rapid reads, so it is exempt too — otherwise normal dashboard polling
183
+ trips the limiter (issue #40 Issue 3).
184
+ """
185
+ if client_host in ("127.0.0.1", "::1", "localhost"):
186
+ return True
187
+ return is_lan_client_allowed(client_host)
188
+
189
+
190
+ __all__ = (
191
+ "is_remote_mode",
192
+ "mcp_stateless",
193
+ "is_lan_client_allowed",
194
+ "is_remote_origin_allowed",
195
+ "rate_limit_config",
196
+ "is_rate_limit_exempt",
197
+ )
@@ -124,8 +124,11 @@ class Summarizer:
124
124
  """
125
125
  import httpx
126
126
  model = getattr(self._config.llm, 'model', None) or "llama3.1:8b"
127
+ # v3.6.12 (modeb-2): honor the configured endpoint instead of hardcoding
128
+ # localhost:11434, so a remote/non-default Ollama host works in Mode B.
129
+ _base = (getattr(self._config.llm, 'api_base', '') or "http://localhost:11434").rstrip("/")
127
130
  with httpx.Client(timeout=httpx.Timeout(30.0)) as client:
128
- resp = client.post("http://localhost:11434/api/generate", json={
131
+ resp = client.post(f"{_base}/api/generate", json={
129
132
  "model": model,
130
133
  "prompt": prompt,
131
134
  "stream": False,
@@ -138,7 +138,13 @@ class LLMBackbone:
138
138
  return False
139
139
  if self._provider == "ollama":
140
140
  return True
141
- return bool(self._api_key)
141
+ # v3.6.12 (modeb-1): a custom local OpenAI-compatible endpoint
142
+ # (llama.cpp, LM Studio, vLLM) needs NO API key — _build_openai already
143
+ # omits the Authorization header when the key is empty. Treat a
144
+ # configured base_url as sufficient, otherwise Mode B silently falls
145
+ # back to Mode A extraction for keyless local endpoints.
146
+ _base = getattr(self, "_base_url", "") or getattr(self, "_api_base", "")
147
+ return bool(self._api_key) or bool(_base)
142
148
 
143
149
  @property
144
150
  def provider(self) -> str: