superlocalmemory 3.6.11 → 3.6.13

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 (29) hide show
  1. package/CHANGELOG.md +38 -1
  2. package/README.md +2 -0
  3. package/package.json +1 -1
  4. package/pyproject.toml +1 -1
  5. package/src/superlocalmemory/cli/commands.py +1 -0
  6. package/src/superlocalmemory/cli/daemon.py +0 -407
  7. package/src/superlocalmemory/cli/main.py +39 -24
  8. package/src/superlocalmemory/cli/version_banner.py +6 -2
  9. package/src/superlocalmemory/core/context_cache.py +4 -1
  10. package/src/superlocalmemory/core/fact_consolidator.py +4 -1
  11. package/src/superlocalmemory/core/remote_mode.py +197 -0
  12. package/src/superlocalmemory/core/summarizer.py +4 -1
  13. package/src/superlocalmemory/llm/backbone.py +7 -1
  14. package/src/superlocalmemory/mcp/agent_context.py +7 -3
  15. package/src/superlocalmemory/mcp/tools_core.py +13 -1
  16. package/src/superlocalmemory/mcp/tools_mesh.py +14 -6
  17. package/src/superlocalmemory/mesh/broker.py +15 -4
  18. package/src/superlocalmemory/optimize/compress/router.py +9 -4
  19. package/src/superlocalmemory/optimize/storage/db.py +16 -2
  20. package/src/superlocalmemory/server/api.py +11 -3
  21. package/src/superlocalmemory/server/routes/mesh.py +13 -0
  22. package/src/superlocalmemory/server/routes/token.py +14 -2
  23. package/src/superlocalmemory/server/routes/v3_api.py +83 -17
  24. package/src/superlocalmemory/server/ui.py +15 -4
  25. package/src/superlocalmemory/server/unified_daemon.py +96 -160
  26. package/src/superlocalmemory/storage/database.py +10 -1
  27. package/src/superlocalmemory/ui/js/auto-settings.js +24 -0
  28. package/src/superlocalmemory.egg-info/PKG-INFO +3 -1
  29. package/src/superlocalmemory.egg-info/SOURCES.txt +1 -0
package/CHANGELOG.md CHANGED
@@ -5,7 +5,44 @@ All notable changes to SuperLocalMemory V3 will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
- ## [3.6.11] - 2026-06-14 — Optimize Everywhere: three surfaces (proxy · MCP tools · skill)
8
+ ## [3.6.13] - 2026-06-14 — Hotfix: MCP server failed to start after upgrade
9
+
10
+ ### Fixed
11
+
12
+ - **MCP server (`slm mcp`) failed to start in Claude Desktop / Cursor right after an upgrade** with errors like `Unexpected token 'S', "SuperLocal"... is not valid JSON`. The one-time post-upgrade banner (and the data-migration notice) were written to **stdout**, which corrupts the MCP stdio JSON-RPC stream on the first `slm mcp` launch after a version change. The banner now goes to **stderr** and is skipped entirely on the `slm mcp` path, so stdout carries only JSON-RPC. Added a regression test. (Anyone hit by this on 3.6.12: `pip install -U superlocalmemory` / `npm i -g superlocalmemory` then restart your MCP client.)
13
+
14
+ ## [3.6.12] - 2026-06-14 — Distributed-ready + stability fixes
15
+
16
+ Makes SuperLocalMemory work correctly across a LAN / distributed deployment (issues #39, #40) and fixes a set of stability and security defects. Default single-machine behavior is unchanged.
17
+
18
+ ### Added
19
+
20
+ - **`SLM_REMOTE=1` — one-switch LAN mode** (default OFF). When enabled, SLM serves the dashboard install token to allowlisted LAN clients, runs the MCP transport statelessly so a gateway/hub can forward tool calls, allows trusted LAN dashboard origins, and exempts the trusted LAN dashboard from rate limiting. LAN access stays gated by `SLM_MCP_ALLOWED_HOSTS`. Granular flags: `SLM_MCP_STATELESS=1`, and tunable rate limits `SLM_RATE_LIMIT_WRITE` / `SLM_RATE_LIMIT_READ` / `SLM_RATE_LIMIT_WINDOW`. See `docs/distributed-deployment.md`.
21
+ - **`slm search`** CLI command (parity with the MCP `search` tool).
22
+
23
+ ### Fixed — Distributed / LAN (#39, #40)
24
+
25
+ - **The Brain page now loads from a remote browser** on the LAN (the install-token endpoint serves allowlisted LAN clients in remote mode instead of being loopback-only).
26
+ - **Mesh tools no longer fail with `-32600 Session not found`** when called through an MCP gateway/hub (the transport can now run stateless so the session id need not be replayed).
27
+ - **Custom LLM endpoints (llama.cpp / LM Studio / Azure) can now be configured from the dashboard** — the Settings page shows, sends, and saves the endpoint, and switching mode actually persists.
28
+ - **The dashboard rate limit (`429 Too Many Requests`) is now configurable** and trusted LAN clients are exempt in remote mode.
29
+
30
+ ### Fixed — Stability & security
31
+
32
+ - **Authentication now fails closed.** A failure to install the auth gate could previously leave write endpoints unauthenticated; it now logs and denies non-loopback writes instead.
33
+ - **Mesh peer registration fixed** — sessions now register with the correct peer id, so heartbeat, direct messages, and inbox work reliably (previously they could silently target a non-existent peer).
34
+ - **`SLM_MESH_SHARED_SECRET` is now enforced** on inbound mesh requests from non-loopback callers.
35
+ - **The cache no longer raises on a corrupted or wrong-key entry** — it degrades to a cache miss.
36
+ - **SSRF protection** added to the provider connection test (cloud-metadata and internal addresses are blocked for remote callers; the local dashboard can still test local/LAN endpoints).
37
+ - **Mode B no longer silently falls back to Mode A** when using a keyless local LLM endpoint.
38
+ - **Memory search no longer errors on punctuation** (`?`, `-`, quotes, etc.).
39
+ - **Mode B honors the configured LLM endpoint and timeout** in summarization and consolidation (previously hardcoded to localhost).
40
+ - **Math-health dashboard reports real status** instead of always showing green.
41
+ - Mesh lock release and inbox now behave correctly (no false success, no re-listing of already-read messages); switching profile takes effect immediately for recall.
42
+
43
+ ### Removed
44
+
45
+ - Removed legacy dead code (an unused in-process daemon handler and superseded duplicate API routes) for a cleaner, more maintainable codebase. No functional change. — Optimize Everywhere: three surfaces (proxy · MCP tools · skill)
9
46
 
10
47
  Cache + compress across **every setup** — proxy, MCP tools, or skill. Five new MCP tools land directly inside `slm mcp` (no proxy, full 1M context window preserved). A new `slm-optimize` skill makes compression and routed-result caching zero-config for Claude Code users. Overclaim in prior docs fixed; three-surfaces table added.
11
48
 
package/README.md CHANGED
@@ -35,6 +35,8 @@
35
35
 
36
36
  > V3.6 is the only local-first layer that SKIPS repeat LLM calls (cache: 100% on a hit), SHRINKS tool outputs and injected context (compress: lossless-by-default, opt-in LLMLingua-2), and DISCOUNTS prefix costs (align: native KV-cache) — and remembers everything — in one install.
37
37
  >
38
+ > **v3.6.12 "Distributed-ready":** Run SLM on a server and reach it across your LAN. `SLM_REMOTE=1` (default off) lets the dashboard load from a remote browser, lets MCP gateways/hubs forward tool calls, and makes custom local LLM endpoints (llama.cpp / LM Studio / Azure) configurable right from the dashboard — plus a batch of stability and security fixes. See [`docs/distributed-deployment.md`](docs/distributed-deployment.md).
39
+ >
38
40
  > **v3.6.11 "Optimize Everywhere":** Three surfaces. **Proxy** (Surface A) — full-turn cache + compress on transport; needs `ANTHROPIC_BASE_URL`, shrinks the context window. **MCP tools** (Surface B) — `slm_compress`, `slm_retrieve`, `slm_cache_set`, `slm_cache_get`, `slm_optimize_stats`; no proxy, no window shrink, works on any Claude subscription. **Skill** (Surface C) — `slm-optimize` installs in `~/.claude/skills/`; zero-config auto-compress for large tool outputs and CLAUDE.md. No proxy, full 1M window. [See Three Surfaces →](#three-surfaces-proxy--mcp-tools--skill)
39
41
  >
40
42
  > **v3.6.10:** cache and compression are now **independent runtime switches** (cache-only, compress-only, both, or neither — toggle live from the dashboard, no restart). Compression was rebuilt to be **lossless by default** (the old string/array/code truncation is gone); aggressive mode adds LLMLingua-2 for **prose only** — never code, numbers, structured data, or the current turn.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "superlocalmemory",
3
- "version": "3.6.11",
3
+ "version": "3.6.13",
4
4
  "description": "Information-geometric agent memory with mathematical guarantees. 4-channel retrieval, Fisher-Rao similarity, zero-LLM mode, EU AI Act compliant. Works with Claude, Cursor, Windsurf, and 17+ AI tools.",
5
5
  "keywords": [
6
6
  "ai-memory",
package/pyproject.toml CHANGED
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "superlocalmemory"
3
- version = "3.6.11"
3
+ version = "3.6.13"
4
4
  description = "Information-geometric agent memory with mathematical guarantees"
5
5
  readme = "README.md"
6
6
  license = {text = "AGPL-3.0-or-later"}
@@ -114,6 +114,7 @@ def dispatch(args: Namespace) -> None:
114
114
  "list": cmd_list,
115
115
  "remember": cmd_remember,
116
116
  "recall": cmd_recall,
117
+ "search": cmd_recall, # v3.6.12 (parity-3): MCP exposes a `search` verb; give the CLI parity (recall is multi-channel incl. BM25/keyword).
117
118
  "forget": cmd_forget,
118
119
  "delete": cmd_delete,
119
120
  "update": cmd_update,
@@ -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")
@@ -79,33 +79,46 @@ def main() -> None:
79
79
  from superlocalmemory.cli.json_output import _get_version
80
80
  _ver = _get_version()
81
81
 
82
+ # v3.6.13 (CRITICAL): the `mcp` stdio transport requires stdout to carry
83
+ # ONLY JSON-RPC. The post-upgrade banner + migration notice below must NEVER
84
+ # run on that path, or the first post-upgrade `slm mcp` launch pollutes
85
+ # stdout and the MCP client (Claude Desktop / Cursor) rejects the stream as
86
+ # "not valid JSON". Skip the whole block for `mcp`; the next human `slm`
87
+ # command emits the banner (to stderr) and writes the version marker.
88
+ _is_mcp_stdio = len(sys.argv) >= 2 and sys.argv[1] == "mcp"
89
+
82
90
  # One-time post-upgrade banner — silent for fresh installs and
83
91
  # same-version runs. Guarded against I/O errors internally.
84
- from superlocalmemory.cli.version_banner import check_and_emit_upgrade_banner
85
- if check_and_emit_upgrade_banner(_ver):
86
- # First post-upgrade invocation: apply the data-dir migration if
87
- # it's safe. When the previous-version daemon is still running
88
- # we defer the next daemon start picks it up.
89
- try:
90
- import logging as _logging
91
- from pathlib import Path as _P
92
- from superlocalmemory.migrations.v3_4_25_to_v3_4_26 import (
93
- migrate_if_safe as _migrate_if_safe,
94
- )
95
- _data = _P(_os.environ.get("SLM_DATA_DIR")
96
- or _P.home() / ".superlocalmemory")
97
- _res = _migrate_if_safe(_data)
98
- if _res.get("status") == "deferred":
92
+ if not _is_mcp_stdio:
93
+ from superlocalmemory.cli.version_banner import check_and_emit_upgrade_banner
94
+ if check_and_emit_upgrade_banner(_ver):
95
+ # First post-upgrade invocation: apply the data-dir migration if
96
+ # it's safe. When the previous-version daemon is still running
97
+ # we defer — the next daemon start picks it up.
98
+ try:
99
+ import logging as _logging
100
+ from pathlib import Path as _P
101
+ from superlocalmemory.migrations.v3_4_25_to_v3_4_26 import (
102
+ migrate_if_safe as _migrate_if_safe,
103
+ )
104
+ _data = _P(_os.environ.get("SLM_DATA_DIR")
105
+ or _P.home() / ".superlocalmemory")
106
+ _res = _migrate_if_safe(_data)
107
+ if _res.get("status") == "deferred":
108
+ print(
109
+ " note: data migration deferred — the running SLM "
110
+ "daemon will apply it on its next restart.",
111
+ file=sys.stderr,
112
+ )
113
+ except Exception as _mig_exc:
114
+ import logging as _logging
115
+ _logging.getLogger(__name__).warning(
116
+ "v3.4.26 migrate_if_safe failed: %s — run `slm doctor`", _mig_exc,
117
+ )
99
118
  print(
100
- " note: data migration deferredthe running SLM "
101
- "daemon will apply it on its next restart."
119
+ " note: data migration check failed run `slm doctor` to diagnose.",
120
+ file=sys.stderr,
102
121
  )
103
- except Exception as _mig_exc:
104
- import logging as _logging
105
- _logging.getLogger(__name__).warning(
106
- "v3.4.26 migrate_if_safe failed: %s — run `slm doctor`", _mig_exc,
107
- )
108
- print(" note: data migration check failed — run `slm doctor` to diagnose.")
109
122
 
110
123
  parser = argparse.ArgumentParser(
111
124
  prog="slm",
@@ -183,7 +196,9 @@ def main() -> None:
183
196
  help="Wait for completion (default: async background processing)",
184
197
  )
185
198
 
186
- recall_p = sub.add_parser("recall", help="Semantic search with 4-channel retrieval")
199
+ # v3.6.12 (parity-3): `search` is an alias of `recall` so the CLI has the
200
+ # same search verb the MCP exposes (handlers dict maps both to cmd_recall).
201
+ recall_p = sub.add_parser("recall", aliases=["search"], help="Semantic search with 4-channel retrieval")
187
202
  recall_p.add_argument("query", help="Search query")
188
203
  recall_p.add_argument("--limit", type=int, default=10, help="Max results (default 10)")
189
204
  recall_p.add_argument("--json", action="store_true", help="Output structured JSON (agent-native)")
@@ -167,8 +167,12 @@ def check_and_emit_upgrade_banner(current: str) -> bool:
167
167
  if read_marker_version() == current:
168
168
  return False
169
169
 
170
- sys.stdout.write(_banner(prior, current))
171
- sys.stdout.flush()
170
+ # v3.6.13 (CRITICAL): write to STDERR, never stdout. On the `slm mcp`
171
+ # stdio transport, ANY stdout byte that isn't JSON-RPC corrupts the
172
+ # stream and the MCP client (e.g. Claude Desktop) rejects every
173
+ # message as "not valid JSON". The banner is a human notice → stderr.
174
+ sys.stderr.write(_banner(prior, current))
175
+ sys.stderr.flush()
172
176
  write_marker_version(current)
173
177
  return True
174
178
  finally:
@@ -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 = (