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.
- lean_memory_console-0.2.0/.gitignore +2 -0
- lean_memory_console-0.2.0/PKG-INFO +14 -0
- lean_memory_console-0.2.0/README.md +213 -0
- lean_memory_console-0.2.0/deploy/docker-compose.yml +36 -0
- lean_memory_console-0.2.0/pyproject.toml +34 -0
- lean_memory_console-0.2.0/src/lean_memory_console/__init__.py +3 -0
- lean_memory_console-0.2.0/src/lean_memory_console/app.py +142 -0
- lean_memory_console-0.2.0/src/lean_memory_console/cli.py +134 -0
- lean_memory_console-0.2.0/src/lean_memory_console/config.py +121 -0
- lean_memory_console-0.2.0/src/lean_memory_console/deploy/__init__.py +0 -0
- lean_memory_console-0.2.0/src/lean_memory_console/engine.py +377 -0
- lean_memory_console-0.2.0/src/lean_memory_console/events.py +133 -0
- lean_memory_console-0.2.0/src/lean_memory_console/inspect_sql.py +321 -0
- lean_memory_console-0.2.0/src/lean_memory_console/mcp_tools.py +137 -0
- lean_memory_console-0.2.0/src/lean_memory_console/observe_mcp.py +73 -0
- lean_memory_console-0.2.0/src/lean_memory_console/routes/__init__.py +0 -0
- lean_memory_console-0.2.0/src/lean_memory_console/routes/data.py +53 -0
- lean_memory_console-0.2.0/src/lean_memory_console/routes/mcp.py +186 -0
- lean_memory_console-0.2.0/src/lean_memory_console/routes/review.py +115 -0
- lean_memory_console-0.2.0/src/lean_memory_console/routes/views.py +153 -0
- lean_memory_console-0.2.0/tests/__init__.py +0 -0
- lean_memory_console-0.2.0/tests/conftest.py +6 -0
- lean_memory_console-0.2.0/tests/fixtures/__init__.py +0 -0
- lean_memory_console-0.2.0/tests/fixtures/build_fixture.py +139 -0
- lean_memory_console-0.2.0/tests/fixtures/data_root/.gitignore +4 -0
- lean_memory_console-0.2.0/tests/fixtures/data_root/_events.db +0 -0
- lean_memory_console-0.2.0/tests/fixtures/data_root/proj-alpha.db +0 -0
- lean_memory_console-0.2.0/tests/fixtures/data_root/proj-beta.db +0 -0
- lean_memory_console-0.2.0/tests/test_cli.py +205 -0
- lean_memory_console-0.2.0/tests/test_config.py +120 -0
- lean_memory_console-0.2.0/tests/test_data_plane.py +292 -0
- lean_memory_console-0.2.0/tests/test_deploy_artifacts.py +115 -0
- lean_memory_console-0.2.0/tests/test_engine.py +308 -0
- lean_memory_console-0.2.0/tests/test_events.py +178 -0
- lean_memory_console-0.2.0/tests/test_inspect_facts.py +134 -0
- lean_memory_console-0.2.0/tests/test_inspect_namespaces.py +95 -0
- lean_memory_console-0.2.0/tests/test_mcp_parity.py +93 -0
- lean_memory_console-0.2.0/tests/test_observe_mcp.py +112 -0
- lean_memory_console-0.2.0/tests/test_plugin_manifest.py +102 -0
- lean_memory_console-0.2.0/tests/test_readme_contract.py +31 -0
- lean_memory_console-0.2.0/tests/test_review_views.py +268 -0
- lean_memory_console-0.2.0/tests/test_views.py +242 -0
|
@@ -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,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
|