superlocalmemory 3.6.11 → 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.
package/CHANGELOG.md CHANGED
@@ -5,7 +5,38 @@ 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.12] - 2026-06-14 — Distributed-ready + stability fixes
9
+
10
+ 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.
11
+
12
+ ### Added
13
+
14
+ - **`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`.
15
+ - **`slm search`** CLI command (parity with the MCP `search` tool).
16
+
17
+ ### Fixed — Distributed / LAN (#39, #40)
18
+
19
+ - **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).
20
+ - **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).
21
+ - **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.
22
+ - **The dashboard rate limit (`429 Too Many Requests`) is now configurable** and trusted LAN clients are exempt in remote mode.
23
+
24
+ ### Fixed — Stability & security
25
+
26
+ - **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.
27
+ - **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).
28
+ - **`SLM_MESH_SHARED_SECRET` is now enforced** on inbound mesh requests from non-loopback callers.
29
+ - **The cache no longer raises on a corrupted or wrong-key entry** — it degrades to a cache miss.
30
+ - **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).
31
+ - **Mode B no longer silently falls back to Mode A** when using a keyless local LLM endpoint.
32
+ - **Memory search no longer errors on punctuation** (`?`, `-`, quotes, etc.).
33
+ - **Mode B honors the configured LLM endpoint and timeout** in summarization and consolidation (previously hardcoded to localhost).
34
+ - **Math-health dashboard reports real status** instead of always showing green.
35
+ - 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.
36
+
37
+ ### Removed
38
+
39
+ - 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
40
 
10
41
  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
42
 
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.12",
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.12"
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")
@@ -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 = (