superlocalmemory 3.6.9 → 3.6.10

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 +78 -10
  2. package/README.md +8 -4
  3. package/package.json +1 -1
  4. package/pyproject.toml +6 -1
  5. package/src/superlocalmemory/__init__.py +1 -1
  6. package/src/superlocalmemory/cli/compress_cmd.py +32 -70
  7. package/src/superlocalmemory/cli/optimize_cmd.py +1 -3
  8. package/src/superlocalmemory/cli/setup_wizard.py +49 -0
  9. package/src/superlocalmemory/mcp/agent_context.py +111 -0
  10. package/src/superlocalmemory/mcp/tools_active.py +7 -8
  11. package/src/superlocalmemory/mcp/tools_core.py +16 -0
  12. package/src/superlocalmemory/optimize/cache/boundary_store.py +23 -9
  13. package/src/superlocalmemory/optimize/cache/exact.py +7 -4
  14. package/src/superlocalmemory/optimize/cache/key_builder.py +13 -0
  15. package/src/superlocalmemory/optimize/cache/manager.py +70 -8
  16. package/src/superlocalmemory/optimize/cache/semantic.py +10 -5
  17. package/src/superlocalmemory/optimize/compress/prose_llmlingua.py +1 -7
  18. package/src/superlocalmemory/optimize/compress/router.py +82 -87
  19. package/src/superlocalmemory/optimize/config/__init__.py +16 -0
  20. package/src/superlocalmemory/optimize/config/defaults.py +1 -6
  21. package/src/superlocalmemory/optimize/config/schema.py +2 -19
  22. package/src/superlocalmemory/optimize/config/store.py +15 -1
  23. package/src/superlocalmemory/optimize/metrics/counters.py +15 -7
  24. package/src/superlocalmemory/optimize/proxy/_helpers.py +100 -2
  25. package/src/superlocalmemory/optimize/proxy/anthropic_surface.py +12 -0
  26. package/src/superlocalmemory/optimize/proxy/capture.py +243 -0
  27. package/src/superlocalmemory/optimize/proxy/gemini_surface.py +31 -0
  28. package/src/superlocalmemory/optimize/proxy/openai_surface.py +12 -0
  29. package/src/superlocalmemory/optimize/proxy/server.py +29 -0
  30. package/src/superlocalmemory/optimize/storage/db.py +78 -11
  31. package/src/superlocalmemory/optimize/storage/schema.py +11 -0
  32. package/src/superlocalmemory/server/routes/optimize.py +6 -8
  33. package/src/superlocalmemory/server/unified_daemon.py +26 -5
  34. package/src/superlocalmemory/ui/index.html +18 -14
  35. package/src/superlocalmemory/ui/js/auto-settings.js +3 -1
  36. package/src/superlocalmemory/ui/js/ng-shell.js +3 -0
  37. package/src/superlocalmemory/ui/js/optimize.js +9 -9
  38. package/src/superlocalmemory.egg-info/PKG-INFO +10 -5
  39. package/src/superlocalmemory.egg-info/SOURCES.txt +2 -2
  40. package/src/superlocalmemory.egg-info/requires.txt +1 -0
  41. package/src/superlocalmemory/optimize/compress/extractive_code.py +0 -311
  42. package/src/superlocalmemory/optimize/compress/extractive_json.py +0 -72
package/CHANGELOG.md CHANGED
@@ -5,20 +5,88 @@ 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.9] - 2026-06-11
8
+ ## [3.6.10] - 2026-06-14 — Optimize correctness (cache + lossless compression) · MCP per-agent identity · runtime toggles · benchmark + shadow-capture · Issue #38
9
9
 
10
- ### Fixed
11
- - **BUG-A: Health monitor no longer kills the embedding worker under memory pressure.** The watchdog now spares the embedding worker (load-bearing for recall quality) and prefers the reranker instead. The RSS budget defaults to 40% of physical RAM (floor 2500 MB) rather than a hardcoded 2500 MB, preventing thrash on machines with ≥8 GB. `SLM_RSS_BUDGET_MB` env and a new `"health"` config section give full operator control. `HealthConfig` dataclass + `SLMConfig.load()` parsing added so config.json `"health"` keys now actually take effect.
12
- - **#34: Mesh tools no longer block the daemon event loop.** All 8 async mesh tools wrapped `_ensure_registered` and `_mesh_request` in `asyncio.to_thread` so the blocking loopback HTTP calls leave the event loop (v3.6.7 in-process transport made this a self-deadlock). MCP lifespan hardened so a tool-level exception cannot propagate to uvicorn's shutdown handler. `heartbeat_active` and `registered` now return live state instead of hardcoded literals.
13
- - **#35: `session_init` now returns a `session_id`.** Clients pass it to `remember()` and `close_session()`. `store_fast` lifts `session_id` from metadata onto the `MemoryRecord` row so facts are correctly attributed. `close_session` queries the DB for the most recent session instead of chasing a phantom `_last_session_id` that was never assigned — `summary_events_created` now returns real counts.
14
- - **#36-1: HTTP MCP reachable from LAN.** New `SLM_MCP_ALLOWED_HOSTS` (opt-in, default localhost-only) overrides MCP's DNS-rebinding protection. Accepts comma-separated `host:port*` patterns or `*` to disable protection entirely on a trusted LAN.
15
- - **#36-2: `slm mcp` and the HTTP daemon no longer race for port 8765.** `ensure_daemon` now checks TCP connectivity in addition to PID file + HTTP health, detecting systemd-started daemons mid-startup before attempting a second bind. `SLM_DAEMON_PORT` is now fully wired end-to-end (previously only read in `commands.py` URL building, not in the actual uvicorn bind or `_start_daemon_subprocess`).
16
- - **#32: Docs correctly state Python 3.11+ requirement** (was "3.10 or later"). Added Ubuntu 22.04 deadsnakes install instructions and a new `docs/install-linux.md` guide.
10
+ This release makes the **Optimize** subsystem (the HTTP proxy that caches and compresses LLM API calls) correct, observable, and **independently controllable at runtime**, adds **per-agent identity** to the HTTP MCP transport, ships a **benchmark + shadow-capture** harness that proves the cache and compression behaviour, and fixes GitHub issue #38. Cache and compression remain **default-OFF** and are now separately toggleable from the dashboard.
11
+
12
+ ### Added Independent runtime control of cache vs compression
13
+
14
+ - **Cache and compression are separate switches**, both live at runtime from the dashboard enable caching only, compression only, both, or neither, without restarting the daemon. The config watchdog rebuilds the proxy hook chain in place on change.
15
+
16
+ ### Added MCP per-agent identity over HTTP (`/mcp/{agent_id}`)
17
+
18
+ - **Per-agent attribution without per-agent processes.** The HTTP MCP endpoint accepts an agent-id path segment — `http://127.0.0.1:8765/mcp/claude`, `/mcp/hermes`, `/mcp/gemini`, etc. The daemon extracts it (root_path-aware ASGI wrapper) into a per-request `ContextVar`, so `remember`, `recall`, `observe`, `delete_memory`, `update_memory`, `session_init`, and event emission all tag the correct agent. Many agents share one daemon instead of one `slm mcp` stdio process each. Bare `/mcp/` is unchanged → default `mcp_client` (backward compatible). Precedence: URL path → `SLM_AGENT_ID` env (stdio) → `mcp_client`. URL agent-ids are sanitized (charset-restricted, 64-char cap). See `docs/distributed-deployment.md`.
19
+
20
+ ### Added — Benchmark + shadow-capture (`benchmarks/optimize/`)
21
+
22
+ - **Correctness benchmark** drives the shipped cache + compression code: exact-cache replay is 100% hit + byte-identical, exact false-hit rate is **0** (the wrong-answer guard), semantic-tier wiring honours its threshold, and safe-mode compression is **lossless** for JSON/code/prose (code forwarded unchanged). Enforced in CI by `tests/optimize/test_benchmark.py`.
23
+ - **Shadow-capture mode** (`SLM_OPTIMIZE_CAPTURE=1`): pure-passthrough proxy records real `{request, response, model, tokens, content_type}` exchanges to `~/.superlocalmemory/optimize_capture.jsonl` (0600, `O_NOFOLLOW`, git-ignored, 1 MB/side cap, secrets never recorded) for replay into the benchmark. No cache/compress while capturing.
24
+
25
+ ### Changed — Compression rebuilt on a research-backed layered design
26
+
27
+ - **Removed the homegrown extractive JSON/code compressors** (they truncated JSON strings, capped arrays, and stubbed code bodies — lossy and unsafe). Safe mode is now **lossless** (whitespace + compact-JSON normalization only; code untouched). Aggressive mode adds **LLMLingua-2** (`microsoft/llmlingua-2-xlm-roberta-large-meetingbank`) for **prose only** — never code, numbers, structured data, instructions, or the current turn. Dead `compress_json`/`compress_code` toggles removed.
28
+
29
+ ### Fixed — Cache correctness & observability
30
+
31
+ - **Semantic cache tier is now wired** into the proxy `get/check/set` path (was dead code); default-off, conservative 0.98 return threshold, benchmarked false-hit guard. See `docs/optimize-config.md` for threshold-tuning guidance.
32
+ - **Token accounting corrected** — input tokens (the biggest save) and real output tokens are now counted on cache hits instead of 0/estimates.
33
+ - **Cache survives machine-id changes** — the AES-GCM key/salt is persisted (0600), fixing the total-miss after VM clone / container / OS reinstall.
34
+ - **UI opt-in is real** — a `proxy_enabled` toggle now actually mounts the proxy.
35
+
36
+ ### Fixed — Issue #38 (frontend)
37
+
38
+ - **Brain page no longer stuck on "Couldn't load Brain"** on a healthy backend — pane activation fires `fetchBrain` and reads the install token from the same source as the working endpoints.
39
+ - **"Test Connection" no longer 401s on an empty API key** in no-auth (Mode B) — when the key is empty no `Authorization` header is sent.
40
+
41
+ ### Security (v3.6.10 audit, Stage 8/9)
42
+
43
+ - Capture file: `O_NOFOLLOW` + unconditional `0600` (symlink-append + TOCTOU closed); in-memory stream accumulator bounded (CWE-400); synchronous capture writes offloaded off the event loop; `extract_usage` uses an explicit provider allowlist (no silent mis-parse). URL agent-ids sanitized at the single extraction chokepoint.
44
+
45
+ ## [3.6.9] - 2026-06-11 — Full 7-layer recall quality · event-loop safety · health watchdog
46
+
47
+ This release fixes seven GitHub issues, one critical production incident, five post-implementation audit bugs, and two event-loop deadlocks introduced in v3.6.7. All 7 retrieval layers (semantic, BM25, temporal, spreading activation, Hopfield, entity graph, cross-encoder reranker) now operate at full designed quality with no capability tradeoffs.
48
+
49
+ ### Fixed — Production incident
50
+
51
+ - **BUG-A (CRITICAL): Health monitor RSS watchdog no longer kills the embedding worker.** On machines with <16 GB RAM the hardcoded 2500 MB budget caused the watchdog to kill the ~1 GB ONNX embedding worker during recall bursts. Semantic channel silently scored 0.0, recall fell back to keyword-only FTS5/BM25 ("DEGRADED MODE"), and the v3.6.8 health monitor reported false-healthy because it checked worker liveness rather than semantic signal quality. Fixed by: (1) defaulting the budget to 40% of physical RAM (floor 2500 MB) with `SLM_RSS_BUDGET_MB` env override; (2) protecting the embedder from the kill list — the reranker is targeted first as it can be recreated without losing recall quality; (3) adding `HealthConfig` dataclass and `SLMConfig.load()` parsing so `config.json` `"health"` keys now actually take effect.
52
+
53
+ ### Fixed — GitHub issues
54
+
55
+ - **#34: Mesh tools no longer deadlock the daemon.** All 8 async mesh MCP tools called blocking `urllib.urlopen` loopback HTTP against the daemon's own `/mcp` endpoint introduced in v3.6.7. From inside the event loop this self-deadlocked uvicorn's single-thread executor, causing a graceful-shutdown timeout that killed the daemon. All 8 call sites wrapped in `asyncio.to_thread`. MCP lifespan hardened so a tool-level exception cannot propagate to uvicorn's shutdown handler. `heartbeat_active` and `registered` return live state instead of hardcoded literals.
56
+ - **#35: `session_init` now returns a `session_id`.** Three spots fixed: (1) `tools_active.py` — `session_init` now passes `session_id` through to callers; (2) `engine.py` `store_fast` — lifts `session_id` from metadata onto the `MemoryRecord` row so facts are correctly attributed to sessions; (3) `close_session` — queries the DB for the most recent session instead of chasing a phantom `_last_session_id` that was never assigned, and `summary_events_created` now returns real counts.
57
+ - **#36-1: HTTP MCP now reachable from LAN hosts.** The new `SLM_MCP_ALLOWED_HOSTS` env (opt-in, default localhost-only) overrides FastMCP's DNS-rebinding protection that rejected non-`127.0.0.1` `Host` headers on LAN deployments. Accepts comma-separated `host:port*` patterns or `*`. Security default is unchanged — the endpoint stays localhost-only without explicit opt-in.
58
+ - **#36-2: `slm mcp` and the HTTP daemon no longer race for port 8765.** `ensure_daemon` now checks TCP connectivity in addition to PID file + HTTP health, detecting systemd-started daemons mid-startup before attempting a second bind. `SLM_DAEMON_PORT` is now fully wired end-to-end (previously only read in `commands.py` URL construction, ignored in the actual uvicorn bind and `_start_daemon_subprocess`). Wrapped in `try/except ValueError` so a malformed env value fails with a clear message rather than a silent zero-port bind.
59
+ - **#33 + #37: Environment variable reference table published.** All ~90 `SLM_*` variables documented in `docs/distributed-deployment.md` with type, default, scope, and example. Closes both issues.
60
+ - **#32: Python version requirement corrected.** Docs previously stated "3.10 or later" — the codebase enforces 3.11+. Updated `getting-started.md`, added Ubuntu 22.04 deadsnakes install path and a new `docs/install-linux.md`.
61
+
62
+ ### Fixed — Event-loop blocking (post-v3.6.7 audit)
63
+
64
+ The v3.6.7 in-process HTTP MCP transport changed the execution context of all MCP tools from stdio subprocess threads to async event-loop coroutines. Three core tools made blocking HTTP or I/O calls that safe in a subprocess become event-loop deadlocks in the new context.
65
+
66
+ - **`session_init` no longer blocks the event loop.** `pool_recall` called `DaemonPoolProxy.recall` which uses blocking `urllib.urlopen` internally. Wrapped in `asyncio.to_thread`.
67
+ - **`observe` no longer blocks the event loop.** `auto.capture()` is a synchronous function that makes an internal HTTP call. Wrapped in `asyncio.to_thread`.
68
+ - **`remember` no longer blocks the event loop.** Both `is_daemon_running()` (file-system check + HTTP probe) and `daemon_request()` (blocking HTTP POST) wrapped in `asyncio.to_thread`.
69
+
70
+ ### Fixed — Post-implementation audit (5 bugs)
71
+
72
+ - **`close_session` crash eliminated.** `tools_active.py` `close_session` called `engine._db._get_conn()`, a private method removed in a prior refactor. Changed to the public `engine._db.execute()` API.
73
+ - **`TransportSecuritySettings` import no longer fails on older MCP SDK versions.** The import was at module level in `unified_daemon.py`, causing an `ImportError` on MCP SDK < 1.27. Moved inside the `if _mcp_allowed:` conditional so it only loads when the HTTP transport is actually being activated.
74
+ - **`SLM_DAEMON_PORT` no longer silently becomes 0 on invalid input.** `int(os.environ.get("SLM_DAEMON_PORT", "") or 8765)` evaluated to `int("")` → `ValueError` → port 0. Wrapped in `try/except ValueError` with a fallback to 8765 and a log warning.
75
+ - **Health monitor cmdline truncation increased.** `cmdline[:80]` silently dropped long Python command lines (common with virtualenv paths), producing false "not SLM" negatives. Increased to `cmdline[:200]`.
76
+ - **Daemon `_DEFAULT_PORT` no longer crashes on malformed env.** Same `ValueError` guard added to the module-level `_DEFAULT_PORT` assignment in `cli/daemon.py`.
77
+
78
+ ### Changed — Recall performance (zero quality tradeoff)
79
+
80
+ - **SpreadingActivation: 418ms → 36ms (12×).** Neighbor lookups for each graph node are now cached across propagation iterations. The neighbor list is static within a single recall — re-querying SQL per iteration (up to ~120 queries per propagation) was pure waste. The cache is local to the call, so correctness is unchanged.
81
+ - **`fast=True` deprecated.** `fast=True` was added in v3.4.40 when SpreadingActivation took 418ms. With SA now completing in ~36ms, `fast=True` (which disables SA) is actually *slower* than `fast=False` and reduces recall quality by dropping a full retrieval channel. `MemoryEngine.recall()` now logs a `WARNING` and silently treats `fast=True` as `False`. The parameter is retained for API backward compatibility and will be removed in v3.7.x. All built-in callers already passed `fast=False`.
82
+ - Hopfield `prefilter_candidates` and entity-graph scoring candidates retain their original designed values (1000 and 100 respectively) — no quality tradeoffs were made in pursuit of latency targets.
17
83
 
18
84
  ### Added
85
+
19
86
  - `HealthConfig` dataclass in `core/config.py` and `"health"` section parsing in `SLMConfig.load()`.
20
- - `docs/distributed-deployment.md` complete guide for LXC/container/multi-machine setups, including a full ~90-entry `SLM_*` environment variable reference table (closes #33 + #37).
21
- - `docs/install-linux.md` — Ubuntu 22.04 / Debian install guide with venv, pipx, and pyenv options, plus a systemd unit template.
87
+ - `SLM_RSS_BUDGET_MB` environment variable for operator control of the health watchdog kill threshold.
88
+ - `docs/distributed-deployment.md` — complete guide for LXC/container/multi-machine setups with full `SLM_*` environment variable reference (closes #33 + #37).
89
+ - `docs/install-linux.md` — Ubuntu 22.04 / Debian install guide with venv, pipx, and pyenv paths plus a systemd unit template.
22
90
 
23
91
  ## [3.6.8] - 2026-06-11 — Runtime recall-health monitor (self-healing recall)
24
92
 
package/README.md CHANGED
@@ -33,18 +33,22 @@
33
33
  <details>
34
34
  <summary><strong>What's New in V3.6 — Optimize: SKIP, SHRINK, DISCOUNT, REMEMBER</strong> (click to expand)</summary>
35
35
 
36
- > V3.6 is the only local-first layer that SKIPS repeat LLM calls (cache: 100% saved), SHRINKS prompts 60-95% (compress: extractive + LLMLingua-2), and DISCOUNTS prefix costs (align: native KV-cache) — and remembers everything — in one install. **Your first cache hit pays for the install time. Hours of coding on repeat, minimal API cost.**
36
+ > V3.6 is the only local-first layer that SKIPS repeat LLM calls (cache: 100% saved on a hit), SHRINKS prose prompts (compress: lossless-by-default, opt-in LLMLingua-2), and DISCOUNTS prefix costs (align: native KV-cache) — and remembers everything — in one install. **Your first cache hit pays for the install time. Hours of coding on repeat, minimal API cost.**
37
+ >
38
+ > **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.
37
39
 
38
40
  ### The Three Levers
39
41
 
40
42
  | Lever | Mechanism | Saving | Off by default? |
41
43
  |-------|-----------|:------:|:---------------:|
42
- | **Cache** | Skip repeat calls — exact-match SQLite lookup, vCache-gated semantic (opt-in) | **100% on a hit** (input + output) | Cache ON, Semantic OFF |
43
- | **Compress** | Shrink prompts — extractive JSON/code (lossless) + LLMLingua-2 prose (opt-in) | **60–95% on a miss** (input only) | Safe mode ON, Aggressive OFF |
44
+ | **Cache** | Skip repeat calls — exact-match SQLite lookup (zero false hits), vCache-gated semantic (opt-in) | **100% on a hit** (input + output) | Cache ON, Semantic OFF |
45
+ | **Compress** | Shrink prompts — **safe = lossless** normalization; **aggressive = LLMLingua-2 prose only** (opt-in) | Safe: small + lossless · Aggressive: large on prose | Safe mode, Aggressive OFF |
44
46
  | **Align** | Stabilize prefix — maximize provider prefix-cache discounts | **Lossless extra** | ON when compression is ON |
45
47
 
46
48
  **Memory** (v3.5's existing engine) runs in parallel — it shapes *what is in* the prompt (relevant facts); Optimize decides *whether and how* it is sent.
47
49
 
50
+ > **Independent at runtime:** enable caching only, compression only, both, or neither — from the dashboard Optimize tab, applied live (no restart). Each AI client can also get its own memory identity over HTTP MCP via `http://127.0.0.1:8765/mcp/{agent_id}`.
51
+
48
52
  ### Quick Start
49
53
 
50
54
  ```bash
@@ -60,7 +64,7 @@ slm wrap claude
60
64
  |:--------|:-------------|
61
65
  | `slm optimize status\|on\|off\|savings` | Master Optimize control + savings report (USD/INR/tokens) |
62
66
  | `slm cache status\|clear\|invalidate\|ttl\|semantic` | Cache sub-control — exact + semantic tiers |
63
- | `slm compress status\|mode\|code\|prose\|ccr\|align` | Compression control — per-channel toggles |
67
+ | `slm compress status\|mode\|prose` | Compression control — safe (lossless) / aggressive (LLMLingua-2 prose) |
64
68
  | `slm proxy [--port] [--provider]` | Start the interception proxy (port 8765) |
65
69
  | `slm wrap <agent>` | Proxy-activate an agent — one command to start saving |
66
70
  | `slm help-optimize [topic]` | Full developer reference + per-agent setup recipes |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "superlocalmemory",
3
- "version": "3.6.9",
3
+ "version": "3.6.10",
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.9"
3
+ version = "3.6.10"
4
4
  description = "Information-geometric agent memory with mathematical guarantees"
5
5
  readme = "README.md"
6
6
  license = {text = "AGPL-3.0-or-later"}
@@ -69,6 +69,11 @@ dependencies = [
69
69
  "scikit-learn==1.8.0",
70
70
  # Vector KNN extension for the semantic channel.
71
71
  "sqlite-vec==0.1.9",
72
+ # v3.6.10: LLMLingua-2 prose compression (aggressive mode, opt-in at runtime).
73
+ # Hard dependency so `pip install superlocalmemory` ships compression-ready;
74
+ # the ~560MB model downloads on first setup/warmup (fail-open). Verified the
75
+ # pin does NOT disturb the transformers/torch/numpy pins above.
76
+ "llmlingua==0.2.2",
72
77
  ]
73
78
 
74
79
  [project.optional-dependencies]
@@ -32,7 +32,7 @@ if "OMP_NUM_THREADS" not in os.environ:
32
32
  os.environ["OMP_NUM_THREADS"] = "2"
33
33
  # ---------------------------------------------------------------------------
34
34
 
35
- __version__ = "3.6.9"
35
+ __version__ = "3.6.10"
36
36
 
37
37
  _REQUIRED_VERSIONS = {
38
38
  "sentence_transformers": "5.3.0",
@@ -2,7 +2,7 @@
2
2
  # Licensed under AGPL-3.0-or-later - see LICENSE file
3
3
  # Part of SuperLocalMemory V3 | https://qualixar.com | https://varunpratap.com
4
4
 
5
- """Handlers for ``slm compress status|mode|code|prose|ccr``."""
5
+ """Handlers for ``slm compress status|mode|prose``."""
6
6
 
7
7
  from __future__ import annotations
8
8
 
@@ -19,11 +19,6 @@ def _get_store():
19
19
  return ConfigStore()
20
20
 
21
21
 
22
- def _get_cache_db():
23
- from superlocalmemory.optimize.storage.db import CacheDB
24
- return CacheDB()
25
-
26
-
27
22
  def _write_config(**fields) -> None:
28
23
  """5-step immutable config-write."""
29
24
  store = _get_store()
@@ -44,18 +39,31 @@ def cmd_compress(args: Namespace) -> None:
44
39
  sub = getattr(args, "compress_command", None)
45
40
  _dispatch = {
46
41
  "status": cmd_compress_status,
47
- "mode": cmd_compress_mode,
48
- "code": cmd_compress_code,
49
- "prose": cmd_compress_prose,
50
- "ccr": cmd_compress_ccr,
51
- "align": cmd_compress_align,
42
+ "mode": cmd_compress_mode,
43
+ "prose": cmd_compress_prose,
44
+ # removed in v3.6.10: code, ccr, align (extractive compressors removed)
45
+ "code": _cmd_compress_removed("code"),
46
+ "ccr": _cmd_compress_removed("ccr"),
47
+ "align": _cmd_compress_removed("align"),
52
48
  }
53
49
  handler = _dispatch.get(sub or "")
54
50
  if handler:
55
51
  handler(args)
56
52
  else:
57
- print("Usage: slm compress status|mode|code|prose|ccr [options]")
53
+ print("Usage: slm compress status|mode|prose [options]")
54
+ sys.exit(0)
55
+
56
+
57
+ def _cmd_compress_removed(name: str):
58
+ def handler(args: Namespace) -> None:
59
+ print(
60
+ f"slm compress {name}: removed in SLM v3.6.10. "
61
+ f"Extractive {name} compression has been replaced by "
62
+ f"Layer 1 (lossless whitespace) + Layer 2 (LLMLingua-2 prose). "
63
+ f"Use 'slm compress prose on' to enable prose compression."
64
+ )
58
65
  sys.exit(0)
66
+ return handler
59
67
 
60
68
 
61
69
  def cmd_compress_status(args: Namespace) -> None:
@@ -68,21 +76,19 @@ def cmd_compress_status(args: Namespace) -> None:
68
76
  "status": "ok",
69
77
  "compress_enabled": cfg.compress_enabled,
70
78
  "compress_mode": cfg.compress_mode,
71
- "compress_code": cfg.compress_code,
72
79
  "compress_prose": cfg.compress_prose,
73
- "compress_ccr": cfg.compress_ccr,
80
+ "compress_protect_recent": cfg.compress_protect_recent,
74
81
  }
75
82
  print(json.dumps(data, indent=2))
76
83
  return
77
84
 
78
85
  print("Compression status:")
79
- print(f" Enabled: {'yes' if cfg.compress_enabled else 'no'}")
80
- print(f" Mode: {cfg.compress_mode}")
81
- print(f" Code: {'ON' if cfg.compress_code else 'OFF'}"
82
- " (extractive JSON/code lossless structure)")
83
- print(f" Prose: {'ON' if cfg.compress_prose else 'OFF'}")
84
- print(f" CCR: {'ON' if cfg.compress_ccr else 'OFF'}"
85
- " (reversible context retrieval)")
86
+ print(f" Enabled: {'yes' if cfg.compress_enabled else 'no'}")
87
+ print(f" Mode: {cfg.compress_mode}")
88
+ print(f" Prose (Layer 2): {'ON' if cfg.compress_prose else 'OFF'}"
89
+ " (LLMLingua-2, aggressive mode only)")
90
+ print(f" Protect recent: {cfg.compress_protect_recent} user turns")
91
+ print(" Layer 1 (lossless whitespace normalization) is always ON when enabled.")
86
92
 
87
93
 
88
94
  def cmd_compress_mode(args: Namespace) -> None:
@@ -103,23 +109,8 @@ def cmd_compress_mode(args: Namespace) -> None:
103
109
  print("Daemon hot-reload: active within 2s. No restart required.")
104
110
 
105
111
 
106
- def cmd_compress_code(args: Namespace) -> None:
107
- """Enable or disable code/JSON compression."""
108
- use_json = getattr(args, "json", False)
109
- value = getattr(args, "code_value", "on")
110
-
111
- _write_config(compress_code=(value == "on"))
112
-
113
- if use_json:
114
- print(json.dumps({"status": "ok", "compress_code": value == "on"}))
115
- return
116
-
117
- print(f"Code compression: {'ENABLED' if value == 'on' else 'DISABLED'}.")
118
- print("Daemon hot-reload: active within 2s.")
119
-
120
-
121
112
  def cmd_compress_prose(args: Namespace) -> None:
122
- """Enable or disable prose compression."""
113
+ """Enable or disable prose compression (Layer 2, LLMLingua-2)."""
123
114
  use_json = getattr(args, "json", False)
124
115
  value = getattr(args, "prose_value", "off")
125
116
 
@@ -141,39 +132,10 @@ def cmd_compress_prose(args: Namespace) -> None:
141
132
  print(json.dumps({"status": "ok", "compress_prose": value == "on"}))
142
133
  return
143
134
 
144
- print(f"Prose compression: {'ENABLED' if value == 'on' else 'DISABLED'}.")
135
+ print(f"Prose compression (LLMLingua-2): {'ENABLED' if value == 'on' else 'DISABLED'}.")
136
+ if value == "on":
137
+ print(" Requires: compress_mode=aggressive and llmlingua package installed.")
138
+ print(" Run: slm compress mode aggressive (if not already set)")
145
139
  if value == "on" and "compress_enabled" in fields:
146
140
  print(" (also enabled global compress)")
147
141
  print("Daemon hot-reload: active within 2s.")
148
-
149
-
150
- def cmd_compress_align(args: Namespace) -> None:
151
- """Enable or disable alignment compression."""
152
- use_json = getattr(args, "json", False)
153
- value = getattr(args, "align_value", "on")
154
-
155
- _write_config(compress_align=(value == "on"))
156
-
157
- if use_json:
158
- print(json.dumps({"status": "ok", "compress_align": value == "on"}))
159
- return
160
-
161
- print(f"Alignment compression: {'ENABLED' if value == 'on' else 'DISABLED'}.")
162
- print("Daemon hot-reload: active within 2s.")
163
-
164
-
165
- def cmd_compress_ccr(args: Namespace) -> None:
166
- """Enable or disable CCR (Compressed Context Retrieval)."""
167
- use_json = getattr(args, "json", False)
168
- value = getattr(args, "ccr_value", "off")
169
-
170
- _write_config(compress_ccr=(value == "on"))
171
-
172
- if use_json:
173
- print(json.dumps({"status": "ok", "compress_ccr": value == "on"}))
174
- return
175
-
176
- print(f"CCR (Compressed Context Retrieval): {'ENABLED' if value == 'on' else 'DISABLED'}.")
177
- if value == "on":
178
- print("Originals stored in llmcache.db for reversible retrieval.")
179
- print("Daemon hot-reload: active within 2s.")
@@ -97,9 +97,7 @@ def cmd_optimize_status(args: Namespace) -> None:
97
97
  f" semantic: {'OFF' if not cfg.semantic_enabled else f'{cfg.ttl.semantic_seconds}s'})")
98
98
  print(f" Compress: {'enabled' if cfg.compress_enabled else 'disabled'}"
99
99
  f" (mode: {cfg.compress_mode},"
100
- f" code: {'ON' if cfg.compress_code else 'OFF'},"
101
- f" prose: {'ON' if cfg.compress_prose else 'OFF'},"
102
- f" CCR: {'ON' if cfg.compress_ccr else 'OFF'})")
100
+ f" prose/L2: {'ON' if cfg.compress_prose else 'OFF'})")
103
101
  proxy_status = f"running on :{OPTIMIZE_DEFAULT_PORT}" if proxy_running else "not running"
104
102
  print(f" Proxy: {proxy_status}")
105
103
  print(f" Config: ~/.superlocalmemory/optimize.json (version {cfg.config_version})")
@@ -32,6 +32,8 @@ _SLM_HOME = Path(os.environ.get("SL_MEMORY_PATH", Path.home() / ".superlocalmemo
32
32
  _SETUP_MARKER = _SLM_HOME / ".setup-complete"
33
33
  _EMBED_MODEL = "nomic-ai/nomic-embed-text-v1.5"
34
34
  _RERANKER_MODEL = "cross-encoder/ms-marco-MiniLM-L-12-v2"
35
+ # v3.6.10: compulsory LLMLingua-2 prose compression model (~560MB, aggressive mode).
36
+ _COMPRESSOR_MODEL = "microsoft/llmlingua-2-xlm-roberta-large-meetingbank"
35
37
 
36
38
 
37
39
  # ---------------------------------------------------------------------------
@@ -178,6 +180,49 @@ def _download_reranker(model_name: str) -> bool:
178
180
  return False
179
181
 
180
182
 
183
+ def _download_compressor(model_name: str) -> bool:
184
+ """Download the LLMLingua-2 prose compression model (v3.6.10).
185
+
186
+ Mirrors _download_reranker: a subprocess forces the HF download with visible
187
+ progress. Fail-open — a network hiccup must NOT break setup; the model also
188
+ lazy-downloads on first use in prose_llmlingua.py.
189
+ """
190
+ print(f"\n Downloading compression model: {model_name}")
191
+ print(f" (LLMLingua-2 prose compressor, ~560MB — aggressive mode only)\n")
192
+
193
+ script = (
194
+ "from llmlingua import PromptCompressor; "
195
+ f"PromptCompressor(model_name='{model_name}', use_llmlingua2=True, "
196
+ "device_map='cpu'); "
197
+ "print('OK')"
198
+ )
199
+
200
+ try:
201
+ result = subprocess.run(
202
+ [sys.executable, "-c", script],
203
+ timeout=900, # 560MB on a slow link can exceed 5 min
204
+ capture_output=False,
205
+ text=True,
206
+ env={
207
+ **os.environ,
208
+ "CUDA_VISIBLE_DEVICES": "",
209
+ "TOKENIZERS_PARALLELISM": "false",
210
+ "TORCH_DEVICE": "cpu",
211
+ },
212
+ )
213
+ if result.returncode == 0:
214
+ print(f" ✓ Compression model ready")
215
+ return True
216
+ print(f" ✗ Compression model download failed (will lazy-download on first use)")
217
+ return False
218
+ except ImportError:
219
+ print(f" ⚠ llmlingua not installed — compression model will download on first use")
220
+ return False
221
+ except Exception as exc:
222
+ print(f" ✗ Compression model error: {exc}")
223
+ return False
224
+
225
+
181
226
  # ---------------------------------------------------------------------------
182
227
  # Verification
183
228
  # ---------------------------------------------------------------------------
@@ -393,6 +438,10 @@ def run_wizard(auto: bool = False) -> None:
393
438
  else:
394
439
  _download_reranker(_RERANKER_MODEL)
395
440
 
441
+ print()
442
+ print("─── Step 4c/10: Download Compression Model (LLMLingua-2) ───")
443
+ _download_compressor(_COMPRESSOR_MODEL)
444
+
396
445
  # -- Step 5: Daemon Configuration (v3.4.3) --
397
446
  print()
398
447
  print("─── Step 5/10: Daemon Configuration ───")
@@ -0,0 +1,111 @@
1
+ """Per-HTTP-request agent ID resolution — ContextVar home.
2
+
3
+ Kept in a standalone module so both tools_core and tools_active can import
4
+ it without creating circular dependencies (server.py → tools_core → here,
5
+ and unified_daemon.py → here independently).
6
+
7
+ Priority chain (HTTP-first, stdio-fallback):
8
+ 1. ContextVar set by _AgentIDExtractorASGI middleware from /mcp/{agent_id} URL path.
9
+ 2. SLM_AGENT_ID environment variable (stdio transport legacy).
10
+ 3. Hard-coded "mcp_client" sentinel.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import contextvars
15
+ import os
16
+ import re
17
+
18
+ _current_agent_id: contextvars.ContextVar[str] = contextvars.ContextVar(
19
+ "slm_agent_id", default="mcp_client"
20
+ )
21
+
22
+ # Agent ids arrive from an untrusted URL path segment. They are ATTRIBUTION
23
+ # metadata, never an authenticated principal — but they reach loggers, the
24
+ # agent registry, and SQL-bound attribution columns, so we hard-restrict the
25
+ # charset at the single extraction chokepoint. This neutralises log-injection
26
+ # (CRLF / ANSI), oversized ids, and any path-ish characters in one place.
27
+ _AGENT_ID_SANITIZE = re.compile(r"[^A-Za-z0-9._-]")
28
+ _AGENT_ID_MAX_LEN = 64
29
+
30
+
31
+ def sanitize_agent_id(raw: str) -> str:
32
+ """Coerce an untrusted agent-id segment to a safe, bounded token."""
33
+ return _AGENT_ID_SANITIZE.sub("_", raw)[:_AGENT_ID_MAX_LEN]
34
+
35
+
36
+ def get_current_agent_id(env_fallback: bool = True) -> str:
37
+ """Return the agent_id for the current asyncio task.
38
+
39
+ For HTTP transport the ASGI wrapper sets the ContextVar from the URL path
40
+ before the request reaches any MCP tool, so this returns the URL-derived id.
41
+ For stdio transport the ContextVar holds its default ("mcp_client") and we
42
+ fall through to the SLM_AGENT_ID env var instead.
43
+ """
44
+ ctx_id = _current_agent_id.get()
45
+ if ctx_id != "mcp_client":
46
+ return ctx_id
47
+ if env_fallback:
48
+ return os.environ.get("SLM_AGENT_ID", "mcp_client")
49
+ return "mcp_client"
50
+
51
+
52
+ class AgentIDExtractorASGI:
53
+ """ASGI wrapper that maps ``/mcp/{agent_id}`` → the agent-id ContextVar.
54
+
55
+ Mounted at ``/mcp`` in unified_daemon. IMPORTANT: Starlette's ``Mount``
56
+ (≥0.35 / 1.x) does NOT strip the mount prefix from ``scope["path"]`` — it
57
+ records the prefix in ``scope["root_path"]`` and leaves ``path`` as the full
58
+ request path (e.g. ``/mcp/claude`` with ``root_path == "/mcp"``). So we
59
+ compute the mount-relative sub-path ourselves as ``path[len(root_path):]``.
60
+
61
+ Flow for ``POST /mcp/claude``:
62
+ sub-path ``/claude`` → agent id ``claude`` → set ContextVar → rewrite the
63
+ scope path to ``{root_path}/`` so the inner FastMCP app (Starlette, route
64
+ ``/``) sees the same mount-relative ``/`` it sees for a bare ``/mcp/``.
65
+
66
+ Backward compatible: bare ``/mcp/`` has sub-path ``/`` → no agent segment →
67
+ the request passes through untouched and the ContextVar keeps its
68
+ ``"mcp_client"`` default.
69
+
70
+ Per-request isolation is guaranteed by ContextVar + ``reset(token)`` in a
71
+ ``finally``, so concurrent HTTP sessions never see each other's agent id.
72
+ """
73
+
74
+ __slots__ = ("_app",)
75
+
76
+ def __init__(self, inner) -> None:
77
+ self._app = inner
78
+
79
+ async def __call__(self, scope, receive, send):
80
+ if scope.get("type") == "http":
81
+ root_path: str = scope.get("root_path", "")
82
+ full_path: str = scope.get("path", "/")
83
+ # Mount-relative sub-path (what comes AFTER /mcp). When a root_path
84
+ # is present the request path MUST start with it (Starlette Mount
85
+ # guarantees this); if it somehow does not, treat it as no-agent and
86
+ # pass through untouched rather than mis-parsing the full path.
87
+ if root_path:
88
+ if not full_path.startswith(root_path):
89
+ await self._app(scope, receive, send)
90
+ return
91
+ subpath = full_path[len(root_path):]
92
+ else:
93
+ subpath = full_path
94
+ first = subpath.lstrip("/").split("/")[0]
95
+ if first:
96
+ first = sanitize_agent_id(first)
97
+ token = _current_agent_id.set(first)
98
+ # Rewrite the path so the inner app sees the bare mount root,
99
+ # exactly as it would for a no-agent /mcp/ request.
100
+ new_full = (root_path + "/") if root_path else "/"
101
+ new_scope = {
102
+ **scope,
103
+ "path": new_full,
104
+ "raw_path": new_full.encode(),
105
+ }
106
+ try:
107
+ await self._app(new_scope, receive, send)
108
+ finally:
109
+ _current_agent_id.reset(token)
110
+ return
111
+ await self._app(scope, receive, send)
@@ -109,15 +109,14 @@ def _sqlite_emergency_recall(
109
109
  def _get_agent_id(default: str = "mcp_client") -> str:
110
110
  """Resolve the calling agent's ID for attribution.
111
111
 
112
- Each MCP client (Claude Code, Codex, Gemini CLI, Kimi, etc.) can set
113
- the ``SLM_AGENT_ID`` env var in its MCP server config so that memories,
114
- observations, and registry entries are tagged with the actual source
115
- agent not the legacy ``"mcp_client"`` default.
116
-
117
- v3.4.39+: enables proper per-agent attribution in ``session_init``,
118
- ``observe``, and event emissions.
112
+ Priority chain (v3.6.10+):
113
+ 1. ContextVar set by HTTP URL path (/mcp/{agent_id}) HTTP transport.
114
+ 2. SLM_AGENT_ID env var stdio transport per-process identity.
115
+ 3. Provided default (legacy "mcp_client").
119
116
  """
120
- return os.environ.get("SLM_AGENT_ID", default)
117
+ from superlocalmemory.mcp.agent_context import get_current_agent_id
118
+ resolved = get_current_agent_id(env_fallback=True)
119
+ return resolved if resolved != "mcp_client" else default
121
120
 
122
121
 
123
122
  def _emit_event(event_type: str, payload: dict | None = None,
@@ -110,6 +110,10 @@ def register_core_tools(server, get_engine: Callable) -> None:
110
110
  Extracts atomic facts, resolves entities, builds graph edges,
111
111
  and indexes for 4-channel retrieval.
112
112
  """
113
+ # v3.6.10: resolve "mcp_client" sentinel → URL path (HTTP) or env var (stdio)
114
+ if agent_id == "mcp_client":
115
+ from superlocalmemory.mcp.agent_context import get_current_agent_id
116
+ agent_id = get_current_agent_id()
113
117
  meta = {
114
118
  "project": project,
115
119
  "importance": importance,
@@ -171,6 +175,10 @@ def register_core_tools(server, get_engine: Callable) -> None:
171
175
  ``CLAUDE_SESSION_ID``. Omitting it degrades to "no closed-loop
172
176
  learning for this recall" — the recall itself always works.
173
177
  """
178
+ # v3.6.10: resolve "mcp_client" sentinel → URL path (HTTP) or env var (stdio)
179
+ if agent_id == "mcp_client":
180
+ from superlocalmemory.mcp.agent_context import get_current_agent_id
181
+ agent_id = get_current_agent_id()
174
182
  import asyncio
175
183
  try:
176
184
  from superlocalmemory.mcp._daemon_proxy import choose_pool
@@ -485,6 +493,10 @@ def register_core_tools(server, get_engine: Callable) -> None:
485
493
  fact_id: Exact fact ID to delete (from recall or list_recent results).
486
494
  agent_id: Identifier of the calling agent (logged for audit).
487
495
  """
496
+ # v3.6.10: resolve "mcp_client" sentinel → URL path (HTTP) or env var (stdio)
497
+ if agent_id == "mcp_client":
498
+ from superlocalmemory.mcp.agent_context import get_current_agent_id
499
+ agent_id = get_current_agent_id()
488
500
  try:
489
501
  from superlocalmemory.core.worker_pool import WorkerPool
490
502
  pool = WorkerPool.shared()
@@ -519,6 +531,10 @@ def register_core_tools(server, get_engine: Callable) -> None:
519
531
  content: New content for the memory (cannot be empty).
520
532
  agent_id: Identifier of the calling agent (logged for audit).
521
533
  """
534
+ # v3.6.10: resolve "mcp_client" sentinel → URL path (HTTP) or env var (stdio)
535
+ if agent_id == "mcp_client":
536
+ from superlocalmemory.mcp.agent_context import get_current_agent_id
537
+ agent_id = get_current_agent_id()
522
538
  try:
523
539
  if not content or not content.strip():
524
540
  return {"success": False, "error": "content cannot be empty"}