lean-memory-console 0.2.0__tar.gz

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. lean_memory_console-0.2.0/.gitignore +2 -0
  2. lean_memory_console-0.2.0/PKG-INFO +14 -0
  3. lean_memory_console-0.2.0/README.md +213 -0
  4. lean_memory_console-0.2.0/deploy/docker-compose.yml +36 -0
  5. lean_memory_console-0.2.0/pyproject.toml +34 -0
  6. lean_memory_console-0.2.0/src/lean_memory_console/__init__.py +3 -0
  7. lean_memory_console-0.2.0/src/lean_memory_console/app.py +142 -0
  8. lean_memory_console-0.2.0/src/lean_memory_console/cli.py +134 -0
  9. lean_memory_console-0.2.0/src/lean_memory_console/config.py +121 -0
  10. lean_memory_console-0.2.0/src/lean_memory_console/deploy/__init__.py +0 -0
  11. lean_memory_console-0.2.0/src/lean_memory_console/engine.py +377 -0
  12. lean_memory_console-0.2.0/src/lean_memory_console/events.py +133 -0
  13. lean_memory_console-0.2.0/src/lean_memory_console/inspect_sql.py +321 -0
  14. lean_memory_console-0.2.0/src/lean_memory_console/mcp_tools.py +137 -0
  15. lean_memory_console-0.2.0/src/lean_memory_console/observe_mcp.py +73 -0
  16. lean_memory_console-0.2.0/src/lean_memory_console/routes/__init__.py +0 -0
  17. lean_memory_console-0.2.0/src/lean_memory_console/routes/data.py +53 -0
  18. lean_memory_console-0.2.0/src/lean_memory_console/routes/mcp.py +186 -0
  19. lean_memory_console-0.2.0/src/lean_memory_console/routes/review.py +115 -0
  20. lean_memory_console-0.2.0/src/lean_memory_console/routes/views.py +153 -0
  21. lean_memory_console-0.2.0/tests/__init__.py +0 -0
  22. lean_memory_console-0.2.0/tests/conftest.py +6 -0
  23. lean_memory_console-0.2.0/tests/fixtures/__init__.py +0 -0
  24. lean_memory_console-0.2.0/tests/fixtures/build_fixture.py +139 -0
  25. lean_memory_console-0.2.0/tests/fixtures/data_root/.gitignore +4 -0
  26. lean_memory_console-0.2.0/tests/fixtures/data_root/_events.db +0 -0
  27. lean_memory_console-0.2.0/tests/fixtures/data_root/proj-alpha.db +0 -0
  28. lean_memory_console-0.2.0/tests/fixtures/data_root/proj-beta.db +0 -0
  29. lean_memory_console-0.2.0/tests/test_cli.py +205 -0
  30. lean_memory_console-0.2.0/tests/test_config.py +120 -0
  31. lean_memory_console-0.2.0/tests/test_data_plane.py +292 -0
  32. lean_memory_console-0.2.0/tests/test_deploy_artifacts.py +115 -0
  33. lean_memory_console-0.2.0/tests/test_engine.py +308 -0
  34. lean_memory_console-0.2.0/tests/test_events.py +178 -0
  35. lean_memory_console-0.2.0/tests/test_inspect_facts.py +134 -0
  36. lean_memory_console-0.2.0/tests/test_inspect_namespaces.py +95 -0
  37. lean_memory_console-0.2.0/tests/test_mcp_parity.py +93 -0
  38. lean_memory_console-0.2.0/tests/test_observe_mcp.py +112 -0
  39. lean_memory_console-0.2.0/tests/test_plugin_manifest.py +102 -0
  40. lean_memory_console-0.2.0/tests/test_readme_contract.py +31 -0
  41. lean_memory_console-0.2.0/tests/test_review_views.py +268 -0
  42. lean_memory_console-0.2.0/tests/test_views.py +242 -0
@@ -0,0 +1,2 @@
1
+ # Built SPA (produced by ui/ via `bun run build`; never committed)
2
+ src/lean_memory_console/static/
@@ -0,0 +1,14 @@
1
+ Metadata-Version: 2.4
2
+ Name: lean-memory-console
3
+ Version: 0.2.0
4
+ Summary: Agent-first read-only verification console for lean-memory
5
+ Requires-Python: >=3.10
6
+ Requires-Dist: fastapi>=0.115
7
+ Requires-Dist: lean-memory
8
+ Requires-Dist: mcp>=1.0
9
+ Requires-Dist: uvicorn>=0.30
10
+ Provides-Extra: dev
11
+ Requires-Dist: anyio>=4; extra == 'dev'
12
+ Requires-Dist: httpx>=0.27; extra == 'dev'
13
+ Requires-Dist: pytest>=8; extra == 'dev'
14
+ Requires-Dist: pyyaml; extra == 'dev'
@@ -0,0 +1,213 @@
1
+ # lean-memory-console
2
+
3
+ A read-only verification console for [lean-memory](../README.md).
4
+
5
+ **Agents write and search; the human verifies.** Your agent (Claude Code via
6
+ the observing MCP, or any HTTP client in Docker mode) is the only writer of
7
+ memory content. You open the console **read-only over stored memory** — no
8
+ adding, editing, or deleting facts — to see the engine's invisible signals for
9
+ the first time: the ADD-only supersession spine, per-hit score decomposition,
10
+ and provenance episodes.
11
+
12
+ > The single write exception is the manual **test-search box**: it runs a real
13
+ > engine search and therefore bumps access stats (`touch()`). That is
14
+ > observability of live-search behavior, not a memory mutation.
15
+
16
+ ## Two modes
17
+
18
+ ### Local (default — zero Docker, nothing runs when the agent doesn't)
19
+
20
+ Two transient localhost processes over one shared data root:
21
+
22
+ ```bash
23
+ # 1. The observing MCP wrapper — your agent stores + searches through this.
24
+ uvx lean-memory-console mcp
25
+
26
+ # 2. The console — you open this to verify. Binds 127.0.0.1, prints a
27
+ # tokened URL, Ctrl-C to stop.
28
+ uvx lean-memory-console serve
29
+ ```
30
+
31
+ `serve` accepts `--root`, `--port`, and `--no-open` (skip the browser launch).
32
+ On load the SPA captures the token from `?token=…` — held in memory and sent as
33
+ the `X-Console-Token` header on every request — and strips it from the address
34
+ bar (`history.replaceState`). There is no cookie; a page refresh without the
35
+ token drops the session.
36
+
37
+ Via the Claude Code plugin (recommended), the MCP is wired automatically:
38
+
39
+ ```
40
+ /plugin marketplace add Wuesteon/lean-memory-console
41
+ /plugin install lean-memory
42
+ /memory:ui # launch + open the console
43
+ /memory:status # resolved root, namespaces, connect snippets
44
+ /memory:server-up # start the Docker data plane
45
+ /memory:server-down # stop it (the data volume persists)
46
+ ```
47
+
48
+ (From a local checkout you can register the marketplace with
49
+ `/plugin marketplace add ./` at the repo root instead of the GitHub slug.)
50
+
51
+ ### Docker (single-tenant, long-running, container owns /data)
52
+
53
+ ```bash
54
+ export LM_API_KEY=$(openssl rand -hex 24) # REQUIRED — the container refuses to boot without it
55
+ docker compose -f "$(lean-memory-console --print-compose-path)" up -d
56
+ ```
57
+
58
+ Then connect an agent over streamable-HTTP MCP and open the UI:
59
+
60
+ ```bash
61
+ claude mcp add --transport http lean-memory http://127.0.0.1:8377/mcp \
62
+ --header "Authorization: Bearer $LM_API_KEY"
63
+ # UI: http://127.0.0.1:8377/ (authenticate with the same LM_API_KEY)
64
+ ```
65
+
66
+ Docker mode binds `0.0.0.0` so the published port is reachable; there is **no**
67
+ local-mode Host guard and no per-launch session token. The controls are the
68
+ bearer gate (`LM_API_KEY`) plus the MCP transport-security Host allowlist. If
69
+ you reach the container over a LAN hostname/IP (no reverse proxy), list the
70
+ host(s) in `LM_MCP_ALLOWED_HOSTS` (comma-separated; `:*` matches any port) or
71
+ the `/mcp` mount 421s on its Host check — a commented example sits in the
72
+ packaged `docker-compose.yml`. The compose service builds the
73
+ `full` image target; container env is `LM_DATA_ROOT` / `LM_API_KEY` (required) /
74
+ `PORT` / `LM_CONSOLE_MODELS` / `LM_MCP_ALLOWED_HOSTS`.
75
+
76
+ A plain REST mirror exists for non-MCP agents:
77
+ `POST /v1/{namespace}/memories` and `POST /v1/{namespace}/search`.
78
+
79
+ ## The data-root rule (read this — it is the #1 trap)
80
+
81
+ The console serves **exactly one** data root and never auto-merges roots.
82
+ Resolution order, both `serve` and `mcp`:
83
+
84
+ ```
85
+ --root > $LM_DATA_ROOT > ~/.lean_memory
86
+ ```
87
+
88
+ **The ./lm_data trap.** The core `lean-memory` engine's *own* default root is
89
+ `./lm_data` (a directory in your current working directory), **not**
90
+ `~/.lean_memory`. So if you ran the engine directly (or the core stdio MCP
91
+ server pointed at the default) your memories may be under `./lm_data` while the
92
+ console defaults to `~/.lean_memory` — and you would inspect an empty root.
93
+
94
+ - `/memory:status` **prints the resolved root** and **warns** when `./lm_data`
95
+ exists in the working directory but is not the served root.
96
+ - Fix: point the console at the right root, e.g.
97
+ `uvx lean-memory-console serve --root ./lm_data`, or set
98
+ `export LM_DATA_ROOT=$(pwd)/lm_data`.
99
+
100
+ The console adds exactly one file to the data root: `_events.db` (search/add
101
+ traces). Everything else is the engine's own `<namespace>.db` files.
102
+
103
+ ## Live vs. replay: the `t_ref` rule
104
+
105
+ `t_ref` (epoch-ms) is the **world/event time** that becomes a fact's `valid_at`
106
+ and anchors the temporal supersession spine.
107
+
108
+ - **Live agents omit it.** The observing MCP fills `now`, so facts are ordered
109
+ by wall-clock ingest — correct for an agent capturing memories as they happen.
110
+ - **Replay / import supplies it.** When backfilling historical conversations,
111
+ pass the original event time as `t_ref` (epoch-ms) on every add. **Omitting
112
+ `t_ref` on historical data silently collapses the spine's ordering** — every
113
+ backfilled fact gets `now`, so supersession and point-in-time queries become
114
+ meaningless.
115
+
116
+ Rule of thumb: if you are importing anything that did not happen right now,
117
+ set `t_ref`.
118
+
119
+ ## One namespace per project
120
+
121
+ Namespaces replace tenants: the engine stores one SQLite file per namespace,
122
+ which is the isolation boundary. Use **one namespace per project/session**.
123
+
124
+ Cross-process writers on a *single* namespace (e.g. two Claude Code sessions
125
+ spawning the wrapper on the same data root) are supported but serialized by a
126
+ bounded retry-on-`SQLITE_BUSY` loop — not a lock manager. Pathological
127
+ contention degrades to retries and, eventually, a `SQLITE_BUSY` surfaced in the
128
+ event's `payload.error`. Keeping namespaces per-project avoids the contention
129
+ entirely.
130
+
131
+ Namespaces are created implicitly on first accepted `memory_add`. There is no
132
+ create-namespace step, and no delete-namespace surface (ADD-only discipline —
133
+ to remove a namespace, delete its `.db` file while nothing is running).
134
+ Namespaces whose sanitized name starts with `_` are **rejected** (the
135
+ `_events.db` sidecar is reserved); an empty name is not rejected — it sanitizes
136
+ to `default`.
137
+
138
+ ## Image size (Docker `full`)
139
+
140
+ The default Docker image is the `full` target: it installs `lean-memory[models]`
141
+ (CPU **torch** + sentence-transformers + a cross-encoder reranker), so the image
142
+ is large (~1.46 GB) and the first run downloads model weights into the mounted
143
+ HF cache volume. The CPU torch build is pinned from
144
+ `download.pytorch.org/whl/cpu` so the resolve never pulls the multi-GB CUDA
145
+ wheels. This is deliberate — stub vectors would recreate the FakeEmbedder
146
+ first-impression failure the quality gate exists to fix. A `slim` target
147
+ (~365 MB, stub embedder, no models) exists for API/UI development
148
+ (`docker build --target slim …`) but is never the documented first-run path.
149
+
150
+ ## Offline by default
151
+
152
+ The console runs fully on deterministic stub backends with no network and no
153
+ model downloads (`LM_CONSOLE_MODELS=stub`, or `auto` when `[models]` is not
154
+ importable). When scores are stub-generated the UI shows a banner saying so.
155
+
156
+ ## License
157
+
158
+ Apache-2.0
159
+
160
+ ## Manual E2E verification (pre-merge)
161
+
162
+ Run this before merge. It is a **manual** gate, not automated — steps that need
163
+ a live interactive Claude Code session are marked `[manual]` and are driven by a
164
+ human, not by tooling. Capture the observed outcome next to each step.
165
+
166
+ **Local mode (plugin + observing MCP):**
167
+
168
+ - [ ] `[manual]` `/plugin marketplace add ./` (from the repo root marketplace),
169
+ then `/plugin install lean-memory`.
170
+ Expected: plugin installs; the `lean-memory` MCP server appears in the
171
+ session's MCP list, and `/memory:ui|status|server-up|server-down` are
172
+ offered as commands.
173
+ - [ ] `[manual]` In a real Claude Code session, ask the agent to store a couple
174
+ of facts and then search them (through the observing MCP
175
+ `memory_add`/`memory_search`).
176
+ Expected: `memory_add` returns `{fact_ids, superseded_count}`;
177
+ `memory_search` returns hits whose `fact_text` matches what was stored;
178
+ rows land in `<root>/_events.db`.
179
+ - [ ] `[manual]` Run `/memory:ui`.
180
+ Expected: browser opens `http://127.0.0.1:8377/?token=…`; the address bar
181
+ loses `?token` after boot; no login screen (local mode).
182
+ - [ ] `[manual]` Verify all four pages render the resulting state:
183
+ - **Overview** — the namespace card shows fact counts, top predicates,
184
+ the 7-day adds/searches sparkline, supersession rate, and facts-per-add.
185
+ - **Memories** — the fact table lists the stored facts; opening a fact
186
+ drawer shows metadata, the supersession timeline (chain oldest→newest),
187
+ and the provenance episode.
188
+ - **Episodes** — the transcript shows the stored turns, each expanding to
189
+ the facts extracted from it.
190
+ - **Activity & Traces** — the polled feed shows the `add`/`search` events;
191
+ a search row expands to the per-hit score decomposition
192
+ (`0.6·relevance + 0.2·recency + 0.2·importance`, dense/sparse ranks,
193
+ RRF); the test-query box runs a live search and its row is labeled
194
+ `origin: ui`.
195
+
196
+ **Docker mode (HTTP data plane):**
197
+
198
+ - [ ] `[manual]` `export LM_API_KEY=$(openssl rand -hex 24)` then
199
+ `docker compose -f "$(lean-memory-console --print-compose-path)" up -d`
200
+ (or `/memory:server-up`).
201
+ Expected: container starts; boot validation passes (data root writable,
202
+ `LM_API_KEY` set, sqlite-vec loadable).
203
+ - [ ] `[manual]` `claude mcp add --transport http lean-memory
204
+ http://127.0.0.1:8377/mcp --header "Authorization: Bearer $LM_API_KEY"`.
205
+ Expected: the MCP connection registers; a store+search from the agent
206
+ succeeds and records events in the container's `/data/_events.db`.
207
+ - [ ] `[manual]` Open `http://127.0.0.1:8377/`.
208
+ Expected: the login screen prompts for the key; entering `LM_API_KEY`
209
+ authenticates; all four pages render the same as local mode, and the
210
+ Overview shows the Docker connect snippet.
211
+ - [ ] `[manual]` `/memory:server-down`.
212
+ Expected: container stops; the `lm_data` volume persists (re-`up` shows
213
+ the same memories).
@@ -0,0 +1,36 @@
1
+ # deploy/docker-compose.yml — single-tenant lean-memory-console (Docker mode).
2
+ #
3
+ # Usage:
4
+ # LM_API_KEY=$(openssl rand -hex 24) docker compose -f deploy/docker-compose.yml up -d
5
+ #
6
+ # Single source of truth (spec §9): the console CLI packages this exact file
7
+ # into the wheel; the plugin's /memory:server-up|down commands resolve it via
8
+ # docker compose -f "$(lean-memory-console --print-compose-path)" up -d
9
+ services:
10
+ console:
11
+ build:
12
+ context: ..
13
+ dockerfile: deploy/Dockerfile
14
+ target: full # full is the default first-run image (real models)
15
+ ports:
16
+ - "8377:8377"
17
+ environment:
18
+ # LM_API_KEY is required in Docker mode — compose refuses to start
19
+ # without it (the console also boot-checks it).
20
+ - LM_API_KEY=${LM_API_KEY:?set LM_API_KEY - e.g. openssl rand -hex 24}
21
+ - LM_DATA_ROOT=/data
22
+ - PORT=8377
23
+ - LM_CONSOLE_MODELS=auto
24
+ # Remote-host deployments (reached over the LAN, no reverse proxy) must
25
+ # list the hostname(s)/IP(s) the container is reached by, or the MCP mount
26
+ # 421s on the Host check. Comma-separated; ":*" matches any port. The
27
+ # bearer gate (LM_API_KEY) remains the primary access control.
28
+ # - LM_MCP_ALLOWED_HOSTS=myhost:*
29
+ volumes:
30
+ - lm_data:/data
31
+ - hf_cache:/root/.cache/huggingface
32
+ restart: unless-stopped
33
+
34
+ volumes:
35
+ lm_data:
36
+ hf_cache:
@@ -0,0 +1,34 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "lean-memory-console"
7
+ version = "0.2.0"
8
+ description = "Agent-first read-only verification console for lean-memory"
9
+ requires-python = ">=3.10"
10
+ dependencies = [
11
+ "lean-memory",
12
+ "fastapi>=0.115",
13
+ "uvicorn>=0.30",
14
+ "mcp>=1.0",
15
+ ]
16
+
17
+ [project.optional-dependencies]
18
+ dev = ["pytest>=8", "anyio>=4", "httpx>=0.27", "pyyaml"]
19
+
20
+ [project.scripts]
21
+ lean-memory-console = "lean_memory_console.cli:main"
22
+
23
+ [tool.hatch.build.targets.wheel]
24
+ packages = ["src/lean_memory_console"]
25
+
26
+ # Packaging copy lives IN-TREE (console/deploy/) so the sdist->wheel path can
27
+ # resolve it — a parent-relative ../deploy source escapes the sdist root and
28
+ # fails any pip/uvx install from sdist (v0.2.0 readiness-board finding). The
29
+ # operational file stays at repo-root deploy/; a test pins the two identical.
30
+ [tool.hatch.build.targets.wheel.force-include]
31
+ "deploy/docker-compose.yml" = "lean_memory_console/deploy/docker-compose.yml"
32
+
33
+ [tool.pytest.ini_options]
34
+ testpaths = ["tests"]
@@ -0,0 +1,3 @@
1
+ """lean-memory-console — agent-first read-only verification console."""
2
+
3
+ __version__ = "0.2.0"
@@ -0,0 +1,142 @@
1
+ """FastAPI factory for the console (both modes).
2
+
3
+ Local mode: 127.0.0.1 bind, per-launch session token (query param or
4
+ X-Console-Token header). Docker mode: Authorization: Bearer <api_key>.
5
+ Referrer-Policy: no-referrer on everything; local mode also validates the
6
+ Host header (DNS-rebinding belt-and-suspenders).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import secrets
12
+ from contextlib import AsyncExitStack, asynccontextmanager
13
+ from pathlib import Path
14
+
15
+ from fastapi import Depends, FastAPI, HTTPException, Request
16
+ from fastapi.responses import JSONResponse, RedirectResponse
17
+ from fastapi.staticfiles import StaticFiles
18
+
19
+ from .config import ConsoleConfig
20
+ from .engine import EngineGateway
21
+ from .events import EventLog
22
+ from .routes.data import build_data_router
23
+ from .routes.mcp import build_mcp_mount
24
+ from .routes.review import build_review_router
25
+ from .routes.views import build_views_router
26
+
27
+ # Loopback hostnames accepted in local mode (DNS-rebinding guard). Exact-match
28
+ # only: a startswith check would wrongly admit e.g. "localhost.attacker.com".
29
+ _ALLOWED_LOCAL_HOSTS = frozenset({"127.0.0.1", "localhost"})
30
+
31
+
32
+ def _static_dir() -> Path:
33
+ """Directory of the built SPA (populated by the UI build). A seam so the
34
+ static-mount-ordering regression can be tested hermetically regardless of
35
+ whether the UI build has materialized ``static/`` on disk."""
36
+ return Path(__file__).parent / "static"
37
+
38
+
39
+ def _ct_eq(presented: str | None, expected: str | None) -> bool:
40
+ """Constant-time string equality. False unless BOTH sides are non-None
41
+ strings (compare_digest raises on None/mismatched types), so a missing
42
+ credential or unconfigured secret never authenticates."""
43
+ if not isinstance(presented, str) or not isinstance(expected, str):
44
+ return False
45
+ return secrets.compare_digest(presented, expected)
46
+
47
+
48
+ def _is_authenticated(request: Request, config: ConsoleConfig) -> bool:
49
+ if config.mode == "docker":
50
+ header = request.headers.get("Authorization", "")
51
+ expected = f"Bearer {config.api_key}" if config.api_key else None
52
+ return _ct_eq(header, expected)
53
+ # local mode: query token OR X-Console-Token header
54
+ token = request.query_params.get("token") or request.headers.get(
55
+ "X-Console-Token"
56
+ )
57
+ return _ct_eq(token, config.session_token)
58
+
59
+
60
+ def require_auth(request: Request) -> None:
61
+ """FastAPI dependency: 401 unless valid credential is presented."""
62
+ config: ConsoleConfig = request.app.state.config
63
+ if not _is_authenticated(request, config):
64
+ raise HTTPException(status_code=401, detail="unauthorized")
65
+
66
+
67
+ def create_app(
68
+ config: ConsoleConfig,
69
+ gateway: EngineGateway,
70
+ event_log: EventLog,
71
+ ) -> FastAPI:
72
+ # In Docker mode, build the streamable-HTTP MCP mount up front so its
73
+ # once-only session manager can be started by the app lifespan below — the
74
+ # only supported start path (run() cannot be re-entered per request). Under a
75
+ # real ASGI server (uvicorn) the lifespan runs; tests hitting /mcp must use a
76
+ # context-managed TestClient so the lifespan runs there too.
77
+ mcp_mount = (
78
+ build_mcp_mount(gateway, config) if config.mode == "docker" else None
79
+ )
80
+
81
+ @asynccontextmanager
82
+ async def _lifespan(_app: FastAPI):
83
+ async with AsyncExitStack() as stack:
84
+ if mcp_mount is not None:
85
+ await stack.enter_async_context(mcp_mount.session_manager.run())
86
+ yield
87
+
88
+ app = FastAPI(title="lean-memory-console", lifespan=_lifespan)
89
+ app.state.config = config
90
+ app.state.gateway = gateway
91
+ app.state.event_log = event_log
92
+
93
+ @app.middleware("http")
94
+ async def _security(request: Request, call_next):
95
+ # Local-mode Host guard (DNS-rebinding belt-and-suspenders).
96
+ if config.mode == "local":
97
+ host = request.headers.get("host", "")
98
+ hostname = host.split(":")[0]
99
+ if hostname not in _ALLOWED_LOCAL_HOSTS:
100
+ resp = JSONResponse(
101
+ {"detail": "forbidden host"}, status_code=403
102
+ )
103
+ resp.headers["Referrer-Policy"] = "no-referrer"
104
+ return resp
105
+ response = await call_next(request)
106
+ response.headers["Referrer-Policy"] = "no-referrer"
107
+ return response
108
+
109
+ app.include_router(build_views_router())
110
+ # /views/*/review + /views/*/maintenance — per-route auth-gated, same prefix
111
+ # as views; registered before the static catch-all so its greedy "/" mount
112
+ # never shadows these paths.
113
+ app.include_router(build_review_router())
114
+ # /v1/* are POST handlers reading app.state.gateway; gate the whole router
115
+ # with the same auth dependency (local token / docker bearer).
116
+ app.include_router(
117
+ build_data_router(), dependencies=[Depends(require_auth)]
118
+ )
119
+
120
+ if mcp_mount is not None:
121
+ # A Starlette Mount("/mcp") only matches "/mcp/..." — a bare "/mcp"
122
+ # (what real MCP clients POST to, per the connect snippet) is NOT matched
123
+ # by the mount. Without a static "/" mount, Starlette's slash-redirect
124
+ # fallback used to bounce "/mcp" -> "/mcp/". But the greedy StaticFiles
125
+ # mount at "/" matches "/mcp" first and 405s every non-GET method before
126
+ # that fallback can run. Register an explicit 307 (method+body preserving)
127
+ # from "/mcp" to "/mcp/" so a bare POST reaches the bearer gate whether or
128
+ # not the SPA is present.
129
+ @app.post("/mcp", include_in_schema=False)
130
+ async def _mcp_slash_redirect() -> RedirectResponse:
131
+ return RedirectResponse(url="/mcp/", status_code=307)
132
+
133
+ app.mount("/mcp", mcp_mount)
134
+
135
+ # The SPA catch-all MUST be registered LAST so its greedy "/" mount never
136
+ # shadows /v1, /views, or /mcp (StaticFiles serves only GET/HEAD and 405s
137
+ # every other method on any path it matches).
138
+ static_dir = _static_dir()
139
+ if static_dir.is_dir():
140
+ app.mount("/", StaticFiles(directory=str(static_dir), html=True), name="spa")
141
+
142
+ return app
@@ -0,0 +1,134 @@
1
+ """`lean-memory-console` CLI: serve | mcp, plus --print-compose-path."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import importlib.resources
7
+ import os
8
+ import sys
9
+ from pathlib import Path
10
+
11
+ from .config import ConsoleConfig, load_config, resolve_data_root
12
+ from .observe_mcp import run_stdio
13
+
14
+
15
+ def _compose_path() -> Path:
16
+ """Path to the packaged docker-compose.yml.
17
+
18
+ Primary: importlib.resources over the installed package's deploy/ dir
19
+ (the wheel force-includes deploy/docker-compose.yml there). Dev fallback:
20
+ resolve the repo's deploy/docker-compose.yml relative to this file when
21
+ the packaged resource is missing (editable installs may not map the
22
+ force-include).
23
+ """
24
+ try:
25
+ res = importlib.resources.files("lean_memory_console").joinpath(
26
+ "deploy/docker-compose.yml"
27
+ )
28
+ if res.is_file():
29
+ return Path(str(res))
30
+ except (ModuleNotFoundError, FileNotFoundError):
31
+ pass
32
+ # Dev fallback: repo_root/deploy/docker-compose.yml.
33
+ # cli.py -> lean_memory_console -> src -> console -> repo_root
34
+ repo_root = Path(__file__).resolve().parents[3]
35
+ return repo_root / "deploy" / "docker-compose.yml"
36
+
37
+
38
+ def _validate_serve_root(root: Path) -> None:
39
+ if not root.exists() or not os.access(root, os.R_OK):
40
+ sys.stderr.write(
41
+ f"error: data root not readable: {root}\n"
42
+ )
43
+ raise SystemExit(2)
44
+
45
+
46
+ def _run_server(config: ConsoleConfig, no_open: bool) -> None: # pragma: no cover
47
+ """Start uvicorn (real entry; monkeypatched in tests).
48
+
49
+ Host bind is mode-dependent:
50
+ local -> 127.0.0.1 (loopback only; the app's Host guard is a second belt)
51
+ docker -> 0.0.0.0 (the container must be reachable via published ports;
52
+ the local-mode Host guard does NOT run in docker mode,
53
+ so the controls are the bearer gate (LM_API_KEY) plus
54
+ the MCP transport-security Host allowlist).
55
+ Docker mode has no per-launch session token, so there is no tokened URL to
56
+ open and the browser-open path is skipped entirely (there is no browser in
57
+ the container regardless).
58
+ """
59
+ import uvicorn
60
+
61
+ from .app import create_app
62
+ from .engine import EngineGateway
63
+ from .events import EventLog
64
+
65
+ event_log = EventLog(config.data_root)
66
+ gateway = EngineGateway(config, event_log)
67
+ app = create_app(config, gateway, event_log)
68
+ if config.mode == "docker":
69
+ host = "0.0.0.0" # noqa: S104 — intentional; see docstring (bearer + allowlist gate)
70
+ url = f"http://0.0.0.0:{config.port}/"
71
+ else:
72
+ host = "127.0.0.1"
73
+ url = f"http://127.0.0.1:{config.port}/?token={config.session_token}"
74
+ if not no_open:
75
+ import webbrowser
76
+
77
+ webbrowser.open(url)
78
+ sys.stdout.write(f"lean-memory-console serving at {url}\n")
79
+ try:
80
+ uvicorn.run(app, host=host, port=config.port, log_level="info")
81
+ finally:
82
+ gateway.close()
83
+ event_log.close()
84
+
85
+
86
+ def main(argv=None) -> int:
87
+ parser = argparse.ArgumentParser(prog="lean-memory-console")
88
+ parser.add_argument(
89
+ "--print-compose-path", action="store_true",
90
+ help="print the packaged docker-compose.yml path and exit",
91
+ )
92
+ sub = parser.add_subparsers(dest="command")
93
+
94
+ p_serve = sub.add_parser("serve", help="run the read-only console")
95
+ p_serve.add_argument("--root", default=None)
96
+ p_serve.add_argument("--port", type=int, default=None)
97
+ p_serve.add_argument("--no-open", action="store_true")
98
+ p_serve.add_argument(
99
+ "--docker",
100
+ action="store_true",
101
+ help=(
102
+ "run in Docker mode: bearer auth (LM_API_KEY required), bind 0.0.0.0, "
103
+ "register the /mcp mount. Explicit flag — no env-based magic detection."
104
+ ),
105
+ )
106
+
107
+ p_mcp = sub.add_parser("mcp", help="run the observing MCP stdio server")
108
+ p_mcp.add_argument("--root", default=None)
109
+
110
+ args = parser.parse_args(argv)
111
+
112
+ if args.print_compose_path:
113
+ sys.stdout.write(f"{_compose_path()}\n")
114
+ return 0
115
+
116
+ if args.command == "serve":
117
+ root = resolve_data_root(args.root)
118
+ _validate_serve_root(root)
119
+ # Explicit mode selection — no magic env detection. --docker selects the
120
+ # containerized entrypoint: bearer auth, 0.0.0.0 bind, /mcp mount. Boot
121
+ # validation (LM_API_KEY required -> SystemExit(2)) comes free from
122
+ # load_config("docker").
123
+ mode = "docker" if args.docker else "local"
124
+ config = load_config(mode, cli_root=args.root, port=args.port)
125
+ _run_server(config, no_open=args.no_open)
126
+ return 0
127
+
128
+ if args.command == "mcp":
129
+ config = load_config("local", cli_root=args.root)
130
+ run_stdio(config)
131
+ return 0
132
+
133
+ parser.print_help()
134
+ return 1