lean-memory-console 0.2.0__py3-none-any.whl
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/__init__.py +3 -0
- lean_memory_console/app.py +142 -0
- lean_memory_console/cli.py +134 -0
- lean_memory_console/config.py +121 -0
- lean_memory_console/deploy/__init__.py +0 -0
- lean_memory_console/deploy/docker-compose.yml +36 -0
- lean_memory_console/engine.py +377 -0
- lean_memory_console/events.py +133 -0
- lean_memory_console/inspect_sql.py +321 -0
- lean_memory_console/mcp_tools.py +137 -0
- lean_memory_console/observe_mcp.py +73 -0
- lean_memory_console/routes/__init__.py +0 -0
- lean_memory_console/routes/data.py +53 -0
- lean_memory_console/routes/mcp.py +186 -0
- lean_memory_console/routes/review.py +115 -0
- lean_memory_console/routes/views.py +153 -0
- lean_memory_console-0.2.0.dist-info/METADATA +14 -0
- lean_memory_console-0.2.0.dist-info/RECORD +20 -0
- lean_memory_console-0.2.0.dist-info/WHEEL +4 -0
- lean_memory_console-0.2.0.dist-info/entry_points.txt +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
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""Console configuration: data-root resolution, namespace sanitizer mirror,
|
|
2
|
+
reserved-namespace guard, and mode-specific config loading (spec §5, §7, §10).
|
|
3
|
+
|
|
4
|
+
SAFE_NS_RE and sanitize_namespace mirror the engine's private sanitizer
|
|
5
|
+
(memory.py:38, 70-71). The mirror is guarded by the fail-loud tripwire in
|
|
6
|
+
inspect_sql.py (spec §13) so engine drift turns the suite red.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
import re
|
|
13
|
+
import secrets
|
|
14
|
+
import sys
|
|
15
|
+
from dataclasses import dataclass, field
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
# Mirror of engine memory.py:38 _SAFE_NS = re.compile(r"[^A-Za-z0-9_.-]")
|
|
19
|
+
SAFE_NS_RE: re.Pattern = re.compile(r"[^A-Za-z0-9_.-]")
|
|
20
|
+
|
|
21
|
+
DEFAULT_DATA_ROOT = Path("~/.lean_memory")
|
|
22
|
+
DEFAULT_PORT = 8377
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def sanitize_namespace(name: str) -> str:
|
|
26
|
+
"""Mirror of memory.py:70-71 safe = _SAFE_NS.sub("_", name) or "default"."""
|
|
27
|
+
return SAFE_NS_RE.sub("_", name) or "default"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def is_reserved_namespace(name: str) -> bool:
|
|
31
|
+
"""True when the sanitized namespace begins with '_' (collides with sidecars
|
|
32
|
+
like _events.db); the data plane rejects these (spec §5)."""
|
|
33
|
+
return sanitize_namespace(name).startswith("_")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def ns_db_path(data_root: Path, namespace: str) -> Path:
|
|
37
|
+
"""The engine store file for a namespace: root / f'{safe}.db'."""
|
|
38
|
+
return data_root / f"{sanitize_namespace(namespace)}.db"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def resolve_data_root(cli_root: str | None) -> Path:
|
|
42
|
+
"""One rule, both commands (spec §10): --root > LM_DATA_ROOT > ~/.lean_memory.
|
|
43
|
+
expanduser is applied; this function never creates the directory."""
|
|
44
|
+
if cli_root:
|
|
45
|
+
return Path(cli_root).expanduser()
|
|
46
|
+
env = os.environ.get("LM_DATA_ROOT")
|
|
47
|
+
if env:
|
|
48
|
+
return Path(env).expanduser()
|
|
49
|
+
return DEFAULT_DATA_ROOT.expanduser()
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def parse_allowed_hosts(raw: str | None) -> list[str]:
|
|
53
|
+
"""Parse LM_MCP_ALLOWED_HOSTS (comma-separated host patterns) into a list.
|
|
54
|
+
|
|
55
|
+
Whitespace around each entry is stripped and empty entries dropped, so
|
|
56
|
+
"a:*, b:*" and "a:*,,b:*" both yield ["a:*", "b:*"]. None/empty → []. The
|
|
57
|
+
patterns are consumed verbatim by the MCP transport-security allow-list
|
|
58
|
+
(exact match or a trailing ":*" port wildcard — see routes/mcp.py)."""
|
|
59
|
+
if not raw:
|
|
60
|
+
return []
|
|
61
|
+
return [part.strip() for part in raw.split(",") if part.strip()]
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass
|
|
65
|
+
class ConsoleConfig:
|
|
66
|
+
data_root: Path
|
|
67
|
+
mode: str # "local" | "docker"
|
|
68
|
+
api_key: str | None = None
|
|
69
|
+
port: int = DEFAULT_PORT
|
|
70
|
+
models: str = "auto" # "auto" | "stub"
|
|
71
|
+
session_token: str | None = None
|
|
72
|
+
# Docker mode only: extra Host patterns the MCP mount accepts, ADDED to the
|
|
73
|
+
# loopback defaults. Set via LM_MCP_ALLOWED_HOSTS so a container published on
|
|
74
|
+
# a LAN IP/hostname (the shipped compose flow, no reverse proxy) is reachable.
|
|
75
|
+
mcp_allowed_hosts: list[str] = field(default_factory=list)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def load_config(
|
|
79
|
+
mode: str, cli_root: str | None = None, port: int | None = None
|
|
80
|
+
) -> ConsoleConfig:
|
|
81
|
+
"""Build a ConsoleConfig for the given mode.
|
|
82
|
+
|
|
83
|
+
docker: LM_API_KEY is required — a missing key raises SystemExit(2) with a
|
|
84
|
+
clear message. No per-launch session token.
|
|
85
|
+
local: a fresh random session_token is minted (embedded in the tokened URL).
|
|
86
|
+
"""
|
|
87
|
+
data_root = resolve_data_root(cli_root)
|
|
88
|
+
models = os.environ.get("LM_CONSOLE_MODELS", "auto")
|
|
89
|
+
if models not in ("auto", "stub"):
|
|
90
|
+
models = "auto"
|
|
91
|
+
resolved_port = port if port is not None else DEFAULT_PORT
|
|
92
|
+
|
|
93
|
+
if mode == "docker":
|
|
94
|
+
api_key = os.environ.get("LM_API_KEY")
|
|
95
|
+
if not api_key:
|
|
96
|
+
print(
|
|
97
|
+
"LM_API_KEY is required in Docker mode; refusing to boot.",
|
|
98
|
+
file=sys.stderr,
|
|
99
|
+
)
|
|
100
|
+
raise SystemExit(2)
|
|
101
|
+
return ConsoleConfig(
|
|
102
|
+
data_root=data_root,
|
|
103
|
+
mode="docker",
|
|
104
|
+
api_key=api_key,
|
|
105
|
+
port=resolved_port,
|
|
106
|
+
models=models,
|
|
107
|
+
session_token=None,
|
|
108
|
+
mcp_allowed_hosts=parse_allowed_hosts(
|
|
109
|
+
os.environ.get("LM_MCP_ALLOWED_HOSTS")
|
|
110
|
+
),
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
# local mode
|
|
114
|
+
return ConsoleConfig(
|
|
115
|
+
data_root=data_root,
|
|
116
|
+
mode="local",
|
|
117
|
+
api_key=None,
|
|
118
|
+
port=resolved_port,
|
|
119
|
+
models=models,
|
|
120
|
+
session_token=secrets.token_urlsafe(24),
|
|
121
|
+
)
|
|
File without changes
|
|
@@ -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:
|