From 5fca854daa123f912dbd4bc95d6904f3328907cb Mon Sep 17 00:00:00 2001 From: Ryan Lyell Date: Thu, 23 Jul 2026 21:03:08 -0400 Subject: [PATCH 1/3] feat(cli): focused command surface, docker convenience commands, and authenticated console handoff (v2.28.0) Add a complete marm-memory product command surface and split the growing cli.py into focused service modules with no behavior change. - Docker convenience commands (pull/run/command/compose/status/logs/stop, embeddings migrate) with a pure planning vs execution split. - Complete command and usability pass: fast-start-http, key management, upgrade/uninstall lifecycle, and a hybrid grouped help layout that surfaces common flags inline and separates managed lifecycle from foreground transports. - Authenticated Console handoff via console --import-key (single-use fragment bootstrap token, HttpOnly SameSite=strict session cookie). - Internal: extract docker_cli, docker_commands, key_management, package_management, product_help, product_logs, product_workflows, projects_cli, and console/auth from cli.py. Co-Authored-By: Claude Opus 4.8 --- AGENTS.md | 8 +- CHANGELOG.md | 25 + README.md | 121 ++++- docs/INSTALL-DOCKER.md | 2 +- docs/INSTALL-LINUX.md | 4 +- docs/INSTALL-PLATFORMS.md | 2 +- docs/INSTALL-WINDOWS.md | 4 +- docs/TECHNICAL-OVERVIEW.md | 2 +- .../artifacts/marm-console/src/App.tsx | 28 ++ .../marm-console/src/lib/marm-api.ts | 1 + marm-mcp-server/Dockerfile | 2 +- marm-mcp-server/README.md | 122 ++++- marm-mcp-server/docker-compose.yml | 2 +- marm-mcp-server/marm-docs/FAQ.md | 4 +- marm-mcp-server/marm-docs/README.md | 124 ++++- marm-mcp-server/marm-docs/ROADMAP.md | 192 -------- marm-mcp-server/marm_mcp_server/__init__.py | 4 +- marm-mcp-server/marm_mcp_server/cli.py | 306 +++++++----- .../marm_mcp_server/config/settings.py | 2 +- .../marm_mcp_server/console/app.py | 31 +- .../marm_mcp_server/console/auth.py | 84 ++++ .../marm_mcp_server/console/cli.py | 27 +- .../{index-CPMGdUC9.js => index-D10wAXCq.js} | 22 +- .../marm_mcp_server/console/static/index.html | 2 +- marm-mcp-server/marm_mcp_server/server.py | 2 +- .../marm_mcp_server/services/docker_cli.py | 170 +++++++ .../services/docker_commands.py | 466 ++++++++++++++++++ .../services/key_management.py | 68 +++ .../services/package_management.py | 132 +++++ .../marm_mcp_server/services/product_help.py | 145 ++++++ .../marm_mcp_server/services/product_logs.py | 33 ++ .../services/product_workflows.py | 210 ++++++++ .../marm_mcp_server/services/projects_cli.py | 95 ++++ marm-mcp-server/pyproject.toml | 2 +- marm-mcp-server/server.json | 6 +- marm-mcp-server/tests/test_bundled_console.py | 58 +++ marm-mcp-server/tests/test_docker_commands.py | 274 ++++++++++ marm-mcp-server/tests/test_runtime_cli.py | 282 ++++++++++- scripts/make-readme-mirrors.py | 96 ---- 39 files changed, 2701 insertions(+), 459 deletions(-) delete mode 100644 marm-mcp-server/marm-docs/ROADMAP.md create mode 100644 marm-mcp-server/marm_mcp_server/console/auth.py rename marm-mcp-server/marm_mcp_server/console/static/assets/{index-CPMGdUC9.js => index-D10wAXCq.js} (80%) create mode 100644 marm-mcp-server/marm_mcp_server/services/docker_cli.py create mode 100644 marm-mcp-server/marm_mcp_server/services/docker_commands.py create mode 100644 marm-mcp-server/marm_mcp_server/services/key_management.py create mode 100644 marm-mcp-server/marm_mcp_server/services/package_management.py create mode 100644 marm-mcp-server/marm_mcp_server/services/product_help.py create mode 100644 marm-mcp-server/marm_mcp_server/services/product_logs.py create mode 100644 marm-mcp-server/marm_mcp_server/services/product_workflows.py create mode 100644 marm-mcp-server/marm_mcp_server/services/projects_cli.py create mode 100644 marm-mcp-server/tests/test_docker_commands.py delete mode 100644 scripts/make-readme-mirrors.py diff --git a/AGENTS.md b/AGENTS.md index 3adeec7e..b908cde0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -30,11 +30,11 @@ MARM is a local-first MCP memory server: Python FastAPI in `marm-mcp-server/`, p Then run `python scripts/find-tools.py`; every surface must report OK. -**README mirrors are generated, never hand-edited:** +**README variants:** - Root `README.md` is the single source of truth. -- `marm-mcp-server/README.md` is the PyPI variant (adds the `mcp-name:` header and two image divs). -- `marm-mcp-server/marm-docs/README.md` is the text-only agent-facing subset (badges, demo, and footer sections stripped). +- `marm-mcp-server/README.md` is the PyPI variant (adds the `mcp-name:` header and two image divs) and is maintained separately. +- `marm-mcp-server/marm-docs/README.md` is the text-only agent-facing subset (badges, demo, and footer sections stripped) and is maintained separately. **When bumping the version, update ALL of the following** (audit with `python scripts/find-versions.py`): @@ -44,7 +44,7 @@ Then run `python scripts/find-tools.py`; every surface must report OK. 4. `marm-mcp-server/marm_mcp_server/config/settings.py` (`SERVER_VERSION`) 5. `marm-mcp-server/marm_mcp_server/server.py` docstring 6. `marm-mcp-server/Dockerfile` version label and `docker-compose.yml` -7. Root `README.md` h1 (then regenerate mirrors) and the version headers in `docs/INSTALL-*.md` +7. The h1 in `README.md`, `marm-mcp-server/README.md`, and `marm-mcp-server/marm-docs/README.md` (each maintained separately), plus the version headers in `docs/INSTALL-*.md` Semver: MAJOR = breaking (schema renames, parameter removals), MINOR = new tools/parameters/features, PATCH = fixes and doc updates. diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ab42dcc..df05e1c0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,31 @@ ## Version 2 - MARM Protocol to Universal MCP Server Evolution +
+July 23rd, 2026: Focused Docker Commands and Full Command Surface (v2.28.0) + +### Focused Docker Convenience Commands + +- Added `marm-memory docker` for pip-installed users: `status`, `pull`, `run`, `command` (paste-ready preview), `compose`, `stdio-command`, `logs`, `stop`, and `maintenance embeddings migrate`. Generated containers default to loopback binding, a persistent `~/.marm` mount via explicit `--mount`, managed env-file auth (the key never enters shell history), and `--restart unless-stopped`; network exposure requires `--expose-network`. +- `docker run` refuses to replace an existing container and prints the exact inspect/stop choices instead; `docker pull` only downloads. Embedding migration refuses while the managed HTTP container is running and returns Docker's real exit code. `docker upgrade` is reported as a manual step rather than silently recreating a container. Compose previews by default and only writes on `--yes`, never overwriting an existing file. The raw Docker and Compose instructions remain for Docker-only users. + +### Complete Command and Usability Pass + +- Added transport aliases `http` (foreground HTTP) and `stdio` (in-process MCP STDIO), plus `fast-start-http`, which starts or reuses the runtime, launches Console, and prints a single status report. The existing `start`, `marm-mcp-server`, and `marm-mcp-stdio` entry points are unchanged. +- Expanded key management: `key init` creates or reuses the managed `~/.marm/.env` without ever rotating an existing key, `key path` prints only the path, and `key reveal` prints the key on stdout with its capture warning on stderr. `key generate` is unchanged. +- Added `upgrade`/`update` and `uninstall`. Both preserve all user data under `~/.marm`, detect editable, pipx, and Windows-launcher installs, and print the exact manual command when self-replacement is not safe. `upgrade --check` reports installed versus latest without installing. +- Replaced the default argparse root help with a grouped, terminal-width-aware layout (Daily Use, Setup and Updates, Knowledge and Projects, Docker, Maintenance), added root `-V`/`--version` and a `help ` alias, and gave every command a visible one-line description. + +### Authenticated Console Handoff + +- Added `marm-memory console --import-key`, which hands the managed runtime key to a local Console browser session without exposing it in the frontend, URL, browser storage, or logs. A short-lived, single-use bootstrap token is exchanged for an HttpOnly, SameSite=strict session cookie, and the runtime key stays server-side. Normal Console launch stays keyless, and manual key entry remains available for remote or separately managed runtimes. + +### Internal + +- `cli.py` was split into focused service modules (Docker, key, package, workflow, help, logs, and project commands) as the command surface grew, with no behavior change to existing commands. + +
+
July 22nd, 2026: Bundled Concept Extraction (v2.27.0) diff --git a/README.md b/README.md index 56cd6d18..8b568bfa 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ width="900" height="250"> -

MARM: Local-First Persistent Multi-Agent Memory Layer for MCP Clients v2.27.0

+

MARM: Local-First Persistent Multi-Agent Memory Layer for MCP Clients v2.28.0

[![License](https://img.shields.io/badge/license-Apache--2.0-blue)](https://github.com/Lyellr88/marm-memory/blob/MARM-main/LICENSE) [![Python](https://img.shields.io/badge/python-3.10%2B-blue)](https://www.python.org/) @@ -36,6 +36,7 @@ - [Why MARM Memory](#why-marm-memory) - [Performance & Scaling Benchmarks](#performance--scaling-benchmarks) - [Quick Start](#-quick-start-for-mcp-http--stdio) +- [Runtime CLI Commands](#runtime-cli-commands) - [Complete MCP Tool Suite](#complete-mcp-tool-suite-14-tools) - [Using MARM: Talk, Don't Call Tools](#using-marm-talk-dont-call-tools) - [Understanding MARM Memory](#understanding-marm-memory) @@ -99,10 +100,65 @@ pip install marm-mcp-server | **Private high-throughput swarm** | `marm-memory start --profile swarm-max` | `"agent" mcp add --transport http marm-memory http://localhost:8001/mcp` | | **Trusted private lab/server** | `marm-memory start --profile trusted` | `"agent" mcp add --transport http marm-memory http://localhost:8001/mcp` | -The managed runtime runs in the background by default. Use `marm-memory status`, -`marm-memory logs --follow`, `marm-memory restart`, and `marm-memory stop` for -normal lifecycle work. `marm-memory console` starts or reuses that runtime and -opens the bundled local web app without requiring Node.js. +The managed runtime runs in the background by default. Use `marm-memory status`, `marm-memory logs --follow`, `marm-memory restart`, and `marm-memory stop` for normal lifecycle work. `marm-memory console` starts or reuses that runtime and opens the bundled local web app without requiring Node.js. + +For the shortest native HTTP workflow, run `marm-memory fast-start-http`. It starts or reuses the local runtime, starts Console, opens it in the browser, and ends with the active URLs and a recovery command. Use `--no-console` or `--no-browser` when you only want the server. `--client ` is reserved for verified client adapters; MARM does not claim to configure a client it has not validated yet. + +`marm-memory http` is the foreground HTTP alias, while `marm-memory stdio` runs the same strict MCP STDIO transport as `marm-mcp-stdio`. Use `marm-memory --help` for grouped command help, `marm-memory help ` for command-specific help, and `marm-memory --version` for the installed version. + +### Runtime CLI Commands + +`marm-memory` is the local runtime manager installed with the Python package. These are the normal operational commands; use `marm-memory --help` for flags and command-specific examples. + +**Daily runtime work** + +```bash +marm-memory fast-start-http # start HTTP, Console, and open the browser +marm-memory start # start or reuse the managed HTTP runtime +marm-memory start --profile swarm # shared multi-agent preset +marm-memory stop # stop the managed runtime safely +marm-memory restart # restart the managed runtime +marm-memory status # inspect runtime, database, queue, and graph status +marm-memory logs --follow # follow bounded runtime logs +marm-memory console # start or reuse the bundled local Console +``` + +**Transports and setup** + +```bash +marm-memory http # run HTTP in the foreground +marm-memory stdio # run the strict local MCP STDIO transport +marm-memory doctor # diagnose the local install +marm-memory key init # create or reuse ~/.marm/.env without displaying the key +marm-memory key path # print the managed key-file path +marm-memory key reveal # explicitly display the managed key +marm-memory console --import-key # open an authenticated local Console session +marm-memory upgrade --check # compare the installed package with PyPI +marm-memory uninstall # preview package removal; always preserves ~/.marm +``` + +**Knowledge, projects, and maintenance** + +```bash +marm-memory knowledge status +marm-memory knowledge build --all +marm-memory projects list +marm-memory projects index /absolute/path/to/repository +marm-memory projects status +marm-memory maintenance status +marm-memory maintenance embeddings migrate +``` + +Docker commands are documented separately below because they require explicit data mounts, network exposure, and key-handling choices. + +### Local Keys And Package Lifecycle + +Normal localhost HTTP remains keyless and loopback-only. For an exposed runtime or a Docker deployment, use `marm-memory key init` to create or reuse the managed `~/.marm/.env` key file. `marm-memory key path` prints only its path; `marm-memory key reveal` intentionally prints the key with a terminal-capture warning. `marm-memory key generate` remains the non-persistent compatibility command. + +When a managed key is active, `marm-memory console --import-key` opens a local Console session without placing the API key in browser storage, frontend state, logs, or a URL query string. Manual bearer-key entry remains available for a separately managed or remote runtime. + +Use `marm-memory upgrade --check` to compare the installed package with PyPI. `marm-memory upgrade` previews a safe native upgrade; `--yes` performs it only where the active installer can be replaced safely. `marm-memory uninstall` similarly previews package removal and always preserves `~/.marm`, including memory databases, graph indexes, keys, logs, and configuration. On Windows, editable installs, or pipx installs, MARM prints the exact manual command rather than attempting to replace an active launcher. + ### Upgrade Existing Embeddings @@ -197,7 +253,7 @@ pip install marm-mcp-server **Swarm / multi-agent note:** The write queue is enabled by default to serialize memory writes through one worker. For shared HTTP deployments, use `marm-memory start --profile swarm` (200 RPM) or `--profile swarm-max` (600 RPM). `--profile trusted` disables rate limiting entirely for private deployments. STDIO is still best for private single-agent/local use. See [Swarm & multi-agent presets](#swarm--multi-agent-presets) for the full table.
-Local pip HTTP (zero config) +Local pip HTTP > "agent" refers to claude, gemini, grok, qwen, or any MCP client. Codex uses --url instead of --transport to add MCP tools. @@ -257,6 +313,59 @@ marm-mcp-stdio > Docker HTTP requires an API key because it exposes MARM as a network server; STDIO stays local to the client process and does not need one. +If you installed MARM through pip, the product CLI can safely preview or run the same setup. It uses a loopback port by default, preserves `~/.marm`, stores the generated key in `~/.marm/.env` rather than shell history, and refuses to replace an existing container. + +```bash +marm-memory docker command # preview the exact HTTP command +marm-memory docker run # create the managed HTTP container +marm-memory docker stdio-command # print a Docker STDIO client command +marm-memory docker status +marm-memory docker logs --follow +marm-memory docker stop + +# Optional: mount repositories read-only for code indexing. +marm-memory docker run --repo /absolute/path/to/repository + +# Optional: preview or explicitly write a Compose configuration. +marm-memory docker compose +marm-memory docker compose --yes +``` + +The HTTP `run`, `command`, and `compose` commands accept the same operational flags: + +| Flag | Purpose | +|---|---| +| `--data-dir ` | Persistent host directory mounted at `/home/marm/.marm`. Defaults to `~/.marm`; this holds memory, indexes, logs, and the managed key file. | +| `--env-file ` | Explicit Docker env file. It must already contain `MARM_API_KEY`; without this flag, MARM uses `~/.marm/.env` and creates a key there only when `docker run` or `docker compose --yes` needs one. | +| `--port ` | Host HTTP port. Default: `8001`. | +| `--expose-network` | Bind the host port to `0.0.0.0` instead of loopback. This is deliberate network exposure; configure a firewall and TLS proxy. | +| `--profile standard\|swarm\|swarm-max\|trusted` | Select the same write-queue and rate-limit preset as native HTTP startup. | +| `--rate-limit-rpm ` | Override the selected profile's HTTP rate limit. `0` disables rate limiting. | +| `--repo ` | Repeatable read-only repository mount for code indexing. MARM reports each corresponding `/workspace/repo-N` path to index inside the container. | +| `--tag ` | Official image tag. Default: `latest`. | +| `--pull` | Pull the selected image before creating a new HTTP container. | +| `--name ` | Managed container name. MARM refuses to replace an existing container with that name. | +| `--memory ` / `--cpus ` | Optional Docker resource limits. | +| `--dry-run` | `docker run` only: print the planned command without creating a container or key file. `docker command` is always a preview. | + +For example: + +```bash +# Shared local server with a custom data path and two repositories for indexing. +marm-memory docker command \ + --profile swarm \ + --data-dir /srv/marm-data \ + --repo /srv/projects/api \ + --repo /srv/projects/web + +# Execute the reviewed command, pulling the image first. +marm-memory docker run --profile swarm --data-dir /srv/marm-data --pull +``` + +Docker STDIO is separate from Docker HTTP: `marm-memory docker stdio-command` uses `docker run -i --rm`, has no port and no bearer key, but still mounts the data directory so SQLite memory persists after the short-lived container exits. Use `--data-dir` and `--tag` with that command when needed. There are no separate `docker key` or `docker mount` commands; `--env-file` and `--data-dir` make those choices explicit in the generated HTTP command. + +`marm-memory docker pull` only downloads an image. `marm-memory docker maintenance embeddings migrate` runs against the same data mount and refuses while the managed HTTP container is running. The helper is available only with the pip-installed `marm-memory` command; Docker-only users can use the raw commands below. + ```bash # Step 1: generate key (do not add < > around the key) docker run --rm lyellr88/marm-mcp-server:latest --generate-key diff --git a/docs/INSTALL-DOCKER.md b/docs/INSTALL-DOCKER.md index a9759bc0..a149e691 100644 --- a/docs/INSTALL-DOCKER.md +++ b/docs/INSTALL-DOCKER.md @@ -2,7 +2,7 @@ ## Universal Memory Intelligence Platform for AI Agents -**MARM v2.27.0** - Memory Accurate Response Mode +**MARM v2.28.0** - Memory Accurate Response Mode *Docker deployment guide for Windows, Mac, and Linux* --- diff --git a/docs/INSTALL-LINUX.md b/docs/INSTALL-LINUX.md index 2fd3b0e2..aab7bd37 100644 --- a/docs/INSTALL-LINUX.md +++ b/docs/INSTALL-LINUX.md @@ -2,7 +2,7 @@ ## Universal Memory Intelligence Platform for AI Agents -**MARM v2.27.0** - Memory Accurate Response Mode +**MARM v2.28.0** - Memory Accurate Response Mode *Complete Linux installation guide* --- @@ -320,7 +320,7 @@ curl -s http://localhost:8001/health { "status": "healthy", "service": "MARM MCP Server", - "version": "2.27.0", + "version": "2.28.0", "timestamp": "2026-01-01T00:00:00+00:00", "database": "connected", "semantic_search": "available" diff --git a/docs/INSTALL-PLATFORMS.md b/docs/INSTALL-PLATFORMS.md index 2bbfa026..7463510b 100644 --- a/docs/INSTALL-PLATFORMS.md +++ b/docs/INSTALL-PLATFORMS.md @@ -1,4 +1,4 @@ -# MARM v2.27.0 MCP Server - Platform Integration Guide +# MARM v2.28.0 MCP Server - Platform Integration Guide ## Table of Contents diff --git a/docs/INSTALL-WINDOWS.md b/docs/INSTALL-WINDOWS.md index 71791f87..be6cce64 100644 --- a/docs/INSTALL-WINDOWS.md +++ b/docs/INSTALL-WINDOWS.md @@ -2,7 +2,7 @@ ## Universal Memory Intelligence Platform for AI Agents -**MARM v2.27.0** - Memory Accurate Response Mode +**MARM v2.28.0** - Memory Accurate Response Mode *Complete Windows installation guide* --- @@ -294,7 +294,7 @@ Invoke-WebRequest -Uri http://localhost:8001/health { "status": "healthy", "service": "MARM MCP Server", - "version": "2.27.0", + "version": "2.28.0", "timestamp": "2026-01-01T00:00:00+00:00", "database": "connected", "semantic_search": "available" diff --git a/docs/TECHNICAL-OVERVIEW.md b/docs/TECHNICAL-OVERVIEW.md index 0ebc89c5..3fd69a39 100644 --- a/docs/TECHNICAL-OVERVIEW.md +++ b/docs/TECHNICAL-OVERVIEW.md @@ -1,6 +1,6 @@ # MARM Technical Overview -> Current implementation: MARM MCP Server v2.27.0 +> Current implementation: MARM MCP Server v2.28.0 This document explains what MARM is, why it is built this way, and how information moves through the system from an agent writing something to that information being recalled later. It is intended as a technical product overview, not a source-code reference. diff --git a/marm-console/artifacts/marm-console/src/App.tsx b/marm-console/artifacts/marm-console/src/App.tsx index ed1f29bf..13a6c720 100644 --- a/marm-console/artifacts/marm-console/src/App.tsx +++ b/marm-console/artifacts/marm-console/src/App.tsx @@ -1,3 +1,4 @@ +import { useEffect, useState } from 'react'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { Route, Switch, Router as WouterRouter } from 'wouter'; import { ConnectionProvider } from '@/lib/marm-connection'; @@ -33,7 +34,34 @@ function Router() { ); } +function useConsoleBootstrap(): boolean { + const [ready, setReady] = useState(() => { + const params = new URLSearchParams(window.location.hash.slice(1)); + return !params.get('marm-bootstrap'); + }); + + useEffect(() => { + const params = new URLSearchParams(window.location.hash.slice(1)); + const token = params.get('marm-bootstrap'); + if (!token) return; + + window.history.replaceState(null, '', `${window.location.pathname}${window.location.search}`); + void fetch('/api/auth/bootstrap', { + method: 'POST', + credentials: 'same-origin', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ token }), + }).finally(() => setReady(true)); + }, []); + + return ready; +} + function App() { + const ready = useConsoleBootstrap(); + + if (!ready) return null; + return ( diff --git a/marm-console/artifacts/marm-console/src/lib/marm-api.ts b/marm-console/artifacts/marm-console/src/lib/marm-api.ts index a89efcb6..3d67a31f 100644 --- a/marm-console/artifacts/marm-console/src/lib/marm-api.ts +++ b/marm-console/artifacts/marm-console/src/lib/marm-api.ts @@ -89,6 +89,7 @@ async function request( headers, body: opts?.body !== undefined ? JSON.stringify(opts.body) : undefined, signal: controller.signal, + credentials: 'same-origin', }); } catch (err) { if (err instanceof DOMException && err.name === 'AbortError') { diff --git a/marm-mcp-server/Dockerfile b/marm-mcp-server/Dockerfile index 2a4c9e04..2091d832 100644 --- a/marm-mcp-server/Dockerfile +++ b/marm-mcp-server/Dockerfile @@ -73,7 +73,7 @@ HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ LABEL org.opencontainers.image.title="MARM Universal MCP Server" LABEL org.opencontainers.image.description="Production-ready Universal MCP Server with advanced AI memory capabilities, semantic search, and professional-grade architecture" -LABEL org.opencontainers.image.version="2.27.0" +LABEL org.opencontainers.image.version="2.28.0" LABEL org.opencontainers.image.authors="Ryan Lyell - marm-memory" LABEL org.opencontainers.image.url="https://marmsystems.com" LABEL org.opencontainers.image.source="https://github.com/Lyellr88/marm-memory" diff --git a/marm-mcp-server/README.md b/marm-mcp-server/README.md index 1606fbb0..3aefd8a1 100644 --- a/marm-mcp-server/README.md +++ b/marm-mcp-server/README.md @@ -7,7 +7,7 @@ mcp-name: io.github.Lyellr88/marm-mcp-server width="900" height="250"> -

MARM: Local-First Persistent Multi-Agent Memory Layer for MCP Clients v2.27.0

+

MARM: Local-First Persistent Multi-Agent Memory Layer for MCP Clients v2.28.0

[![License](https://img.shields.io/badge/license-Apache--2.0-blue)](https://github.com/Lyellr88/marm-memory/blob/MARM-main/LICENSE) [![Python](https://img.shields.io/badge/python-3.10%2B-blue)](https://www.python.org/) @@ -33,12 +33,12 @@ mcp-name: io.github.Lyellr88/marm-mcp-server - **Concept graph rebuild:** the platform-aware graph schema requires one full rebuild. After upgrading, run `marm_concept_build(search_all=True)` once. MARM backs up and rebuilds only the derived concept database; memories are not modified. - I am waiting to get access back to my PYPI account, till restored pip will be behind a few versions. I will update the README when it is back up to date. - ## Table of Contents - [Why MARM Memory](#why-marm-memory) - [Performance & Scaling Benchmarks](#performance--scaling-benchmarks) - [Quick Start](#-quick-start-for-mcp-http--stdio) +- [Runtime CLI Commands](#runtime-cli-commands) - [Complete MCP Tool Suite](#complete-mcp-tool-suite-14-tools) - [Using MARM: Talk, Don't Call Tools](#using-marm-talk-dont-call-tools) - [Understanding MARM Memory](#understanding-marm-memory) @@ -102,10 +102,65 @@ pip install marm-mcp-server | **Private high-throughput swarm** | `marm-memory start --profile swarm-max` | `"agent" mcp add --transport http marm-memory http://localhost:8001/mcp` | | **Trusted private lab/server** | `marm-memory start --profile trusted` | `"agent" mcp add --transport http marm-memory http://localhost:8001/mcp` | -The managed runtime runs in the background by default. Use `marm-memory status`, -`marm-memory logs --follow`, `marm-memory restart`, and `marm-memory stop` for -normal lifecycle work. `marm-memory console` starts or reuses that runtime and -opens the bundled local web app without requiring Node.js. +The managed runtime runs in the background by default. Use `marm-memory status`, `marm-memory logs --follow`, `marm-memory restart`, and `marm-memory stop` for normal lifecycle work. `marm-memory console` starts or reuses that runtime and opens the bundled local web app without requiring Node.js. + +For the shortest native HTTP workflow, run `marm-memory fast-start-http`. It starts or reuses the local runtime, starts Console, opens it in the browser, and ends with the active URLs and a recovery command. Use `--no-console` or `--no-browser` when you only want the server. `--client ` is reserved for verified client adapters; MARM does not claim to configure a client it has not validated yet. + +`marm-memory http` is the foreground HTTP alias, while `marm-memory stdio` runs the same strict MCP STDIO transport as `marm-mcp-stdio`. Use `marm-memory --help` for grouped command help, `marm-memory help ` for command-specific help, and `marm-memory --version` for the installed version. + +### Runtime CLI Commands + +`marm-memory` is the local runtime manager installed with the Python package. These are the normal operational commands; use `marm-memory --help` for flags and command-specific examples. + +**Daily runtime work** + +```bash +marm-memory fast-start-http # start HTTP, Console, and open the browser +marm-memory start # start or reuse the managed HTTP runtime +marm-memory start --profile swarm # shared multi-agent preset +marm-memory stop # stop the managed runtime safely +marm-memory restart # restart the managed runtime +marm-memory status # inspect runtime, database, queue, and graph status +marm-memory logs --follow # follow bounded runtime logs +marm-memory console # start or reuse the bundled local Console +``` + +**Transports and setup** + +```bash +marm-memory http # run HTTP in the foreground +marm-memory stdio # run the strict local MCP STDIO transport +marm-memory doctor # diagnose the local install +marm-memory key init # create or reuse ~/.marm/.env without displaying the key +marm-memory key path # print the managed key-file path +marm-memory key reveal # explicitly display the managed key +marm-memory console --import-key # open an authenticated local Console session +marm-memory upgrade --check # compare the installed package with PyPI +marm-memory uninstall # preview package removal; always preserves ~/.marm +``` + +**Knowledge, projects, and maintenance** + +```bash +marm-memory knowledge status +marm-memory knowledge build --all +marm-memory projects list +marm-memory projects index /absolute/path/to/repository +marm-memory projects status +marm-memory maintenance status +marm-memory maintenance embeddings migrate +``` + +Docker commands are documented separately below because they require explicit data mounts, network exposure, and key-handling choices. + +### Local Keys And Package Lifecycle + +Normal localhost HTTP remains keyless and loopback-only. For an exposed runtime or a Docker deployment, use `marm-memory key init` to create or reuse the managed `~/.marm/.env` key file. `marm-memory key path` prints only its path; `marm-memory key reveal` intentionally prints the key with a terminal-capture warning. `marm-memory key generate` remains the non-persistent compatibility command. + +When a managed key is active, `marm-memory console --import-key` opens a local Console session without placing the API key in browser storage, frontend state, logs, or a URL query string. Manual bearer-key entry remains available for a separately managed or remote runtime. + +Use `marm-memory upgrade --check` to compare the installed package with PyPI. `marm-memory upgrade` previews a safe native upgrade; `--yes` performs it only where the active installer can be replaced safely. `marm-memory uninstall` similarly previews package removal and always preserves `~/.marm`, including memory databases, graph indexes, keys, logs, and configuration. On Windows, editable installs, or pipx installs, MARM prints the exact manual command rather than attempting to replace an active launcher. + ### Upgrade Existing Embeddings @@ -200,7 +255,7 @@ pip install marm-mcp-server **Swarm / multi-agent note:** The write queue is enabled by default to serialize memory writes through one worker. For shared HTTP deployments, use `marm-memory start --profile swarm` (200 RPM) or `--profile swarm-max` (600 RPM). `--profile trusted` disables rate limiting entirely for private deployments. STDIO is still best for private single-agent/local use. See [Swarm & multi-agent presets](#swarm--multi-agent-presets) for the full table.
-Local pip HTTP (zero config) +Local pip HTTP > "agent" refers to claude, gemini, grok, qwen, or any MCP client. Codex uses --url instead of --transport to add MCP tools. @@ -260,6 +315,59 @@ marm-mcp-stdio > Docker HTTP requires an API key because it exposes MARM as a network server; STDIO stays local to the client process and does not need one. +If you installed MARM through pip, the product CLI can safely preview or run the same setup. It uses a loopback port by default, preserves `~/.marm`, stores the generated key in `~/.marm/.env` rather than shell history, and refuses to replace an existing container. + +```bash +marm-memory docker command # preview the exact HTTP command +marm-memory docker run # create the managed HTTP container +marm-memory docker stdio-command # print a Docker STDIO client command +marm-memory docker status +marm-memory docker logs --follow +marm-memory docker stop + +# Optional: mount repositories read-only for code indexing. +marm-memory docker run --repo /absolute/path/to/repository + +# Optional: preview or explicitly write a Compose configuration. +marm-memory docker compose +marm-memory docker compose --yes +``` + +The HTTP `run`, `command`, and `compose` commands accept the same operational flags: + +| Flag | Purpose | +|---|---| +| `--data-dir ` | Persistent host directory mounted at `/home/marm/.marm`. Defaults to `~/.marm`; this holds memory, indexes, logs, and the managed key file. | +| `--env-file ` | Explicit Docker env file. It must already contain `MARM_API_KEY`; without this flag, MARM uses `~/.marm/.env` and creates a key there only when `docker run` or `docker compose --yes` needs one. | +| `--port ` | Host HTTP port. Default: `8001`. | +| `--expose-network` | Bind the host port to `0.0.0.0` instead of loopback. This is deliberate network exposure; configure a firewall and TLS proxy. | +| `--profile standard\|swarm\|swarm-max\|trusted` | Select the same write-queue and rate-limit preset as native HTTP startup. | +| `--rate-limit-rpm ` | Override the selected profile's HTTP rate limit. `0` disables rate limiting. | +| `--repo ` | Repeatable read-only repository mount for code indexing. MARM reports each corresponding `/workspace/repo-N` path to index inside the container. | +| `--tag ` | Official image tag. Default: `latest`. | +| `--pull` | Pull the selected image before creating a new HTTP container. | +| `--name ` | Managed container name. MARM refuses to replace an existing container with that name. | +| `--memory ` / `--cpus ` | Optional Docker resource limits. | +| `--dry-run` | `docker run` only: print the planned command without creating a container or key file. `docker command` is always a preview. | + +For example: + +```bash +# Shared local server with a custom data path and two repositories for indexing. +marm-memory docker command \ + --profile swarm \ + --data-dir /srv/marm-data \ + --repo /srv/projects/api \ + --repo /srv/projects/web + +# Execute the reviewed command, pulling the image first. +marm-memory docker run --profile swarm --data-dir /srv/marm-data --pull +``` + +Docker STDIO is separate from Docker HTTP: `marm-memory docker stdio-command` uses `docker run -i --rm`, has no port and no bearer key, but still mounts the data directory so SQLite memory persists after the short-lived container exits. Use `--data-dir` and `--tag` with that command when needed. There are no separate `docker key` or `docker mount` commands; `--env-file` and `--data-dir` make those choices explicit in the generated HTTP command. + +`marm-memory docker pull` only downloads an image. `marm-memory docker maintenance embeddings migrate` runs against the same data mount and refuses while the managed HTTP container is running. The helper is available only with the pip-installed `marm-memory` command; Docker-only users can use the raw commands below. + ```bash # Step 1: generate key (do not add < > around the key) docker run --rm lyellr88/marm-mcp-server:latest --generate-key diff --git a/marm-mcp-server/docker-compose.yml b/marm-mcp-server/docker-compose.yml index 82d8256d..99699efb 100644 --- a/marm-mcp-server/docker-compose.yml +++ b/marm-mcp-server/docker-compose.yml @@ -18,7 +18,7 @@ services: environment: - SERVER_HOST=0.0.0.0 - SERVER_PORT=8001 - - SERVER_VERSION=2.27.0 + - SERVER_VERSION=2.28.0 - ENVIRONMENT=production - LOG_LEVEL=INFO diff --git a/marm-mcp-server/marm-docs/FAQ.md b/marm-mcp-server/marm-docs/FAQ.md index 1e3cbda1..9c4b3e38 100644 --- a/marm-mcp-server/marm-docs/FAQ.md +++ b/marm-mcp-server/marm-docs/FAQ.md @@ -71,7 +71,7 @@ Docker HTTP mode should use `MARM_API_KEY` because the server is listening throu #### Q: How do I know if MARM is working correctly? -For HTTP mode, run `curl http://localhost:8001/health` or use MARM Console when it is running locally. For STDIO mode, confirm your MCP client lists the MARM tools and can call a simple recall or log command. +For HTTP mode, run `marm-memory status` or `marm-memory doctor`. The raw health endpoint remains available at `http://localhost:8001/health`. For STDIO mode, confirm your MCP client lists the MARM tools and can call a simple recall or log command. --- @@ -114,7 +114,7 @@ Nothing breaks. The code-graph engine starts lazily on first graph-tool use; if #### Q: What should I use for multi-agent or swarm-style workflows? -Use HTTP mode so one MARM server coordinates shared database access. The write queue is enabled by default. Start shared servers with `--swarm` for 200 RPM, `--swarm-max` for 600 RPM, or `--trusted` to disable rate limiting on a private trusted deployment. +Use HTTP mode so one MARM server coordinates shared database access. The write queue is enabled by default. Start shared servers with `marm-memory start --profile swarm` for 200 RPM, `--profile swarm-max` for 600 RPM, or `--profile trusted` to disable rate limiting on a private trusted deployment. Run one MARM HTTP process per SQLite database. Multi-process Uvicorn/Gunicorn workers are not supported yet because the write queue, scheduler, protocol delivery, and some active session state are process-local. Swarm presets increase safe concurrency inside one process; true multi-worker HTTP scaling is future work. diff --git a/marm-mcp-server/marm-docs/README.md b/marm-mcp-server/marm-docs/README.md index 7e3fc531..4af30df8 100644 --- a/marm-mcp-server/marm-docs/README.md +++ b/marm-mcp-server/marm-docs/README.md @@ -1,16 +1,20 @@ -# MARM: Local-First Persistent Multi-Agent Memory Layer for MCP Clients v2.27.0 +# MARM: Local-First Persistent Multi-Agent Memory Layer for MCP Clients v2.28.0 + ## Table of Contents - [Why MARM Memory](#why-marm-memory) - [Performance & Scaling Benchmarks](#performance--scaling-benchmarks) - [Quick Start](#-quick-start-for-mcp-http--stdio) +- [Runtime CLI Commands](#runtime-cli-commands) - [Complete MCP Tool Suite](#complete-mcp-tool-suite-14-tools) - [Using MARM: Talk, Don't Call Tools](#using-marm-talk-dont-call-tools) - [Understanding MARM Memory](#understanding-marm-memory) - [Knowledge Graphs: Code & Concepts](#knowledge-graphs-code--concepts) - [Architecture & Internals](#architecture--internals) - [Troubleshooting](#troubleshooting) +- [Contributing](#contributing) +- [Project Documentation](#project-documentation) ## Why MARM Memory @@ -66,10 +70,65 @@ pip install marm-mcp-server | **Private high-throughput swarm** | `marm-memory start --profile swarm-max` | `"agent" mcp add --transport http marm-memory http://localhost:8001/mcp` | | **Trusted private lab/server** | `marm-memory start --profile trusted` | `"agent" mcp add --transport http marm-memory http://localhost:8001/mcp` | -The managed runtime runs in the background by default. Use `marm-memory status`, -`marm-memory logs --follow`, `marm-memory restart`, and `marm-memory stop` for -normal lifecycle work. `marm-memory console` starts or reuses that runtime and -opens the bundled local web app without requiring Node.js. +The managed runtime runs in the background by default. Use `marm-memory status`, `marm-memory logs --follow`, `marm-memory restart`, and `marm-memory stop` for normal lifecycle work. `marm-memory console` starts or reuses that runtime and opens the bundled local web app without requiring Node.js. + +For the shortest native HTTP workflow, run `marm-memory fast-start-http`. It starts or reuses the local runtime, starts Console, opens it in the browser, and ends with the active URLs and a recovery command. Use `--no-console` or `--no-browser` when you only want the server. `--client ` is reserved for verified client adapters; MARM does not claim to configure a client it has not validated yet. + +`marm-memory http` is the foreground HTTP alias, while `marm-memory stdio` runs the same strict MCP STDIO transport as `marm-mcp-stdio`. Use `marm-memory --help` for grouped command help, `marm-memory help ` for command-specific help, and `marm-memory --version` for the installed version. + +### Runtime CLI Commands + +`marm-memory` is the local runtime manager installed with the Python package. These are the normal operational commands; use `marm-memory --help` for flags and command-specific examples. + +**Daily runtime work** + +```bash +marm-memory fast-start-http # start HTTP, Console, and open the browser +marm-memory start # start or reuse the managed HTTP runtime +marm-memory start --profile swarm # shared multi-agent preset +marm-memory stop # stop the managed runtime safely +marm-memory restart # restart the managed runtime +marm-memory status # inspect runtime, database, queue, and graph status +marm-memory logs --follow # follow bounded runtime logs +marm-memory console # start or reuse the bundled local Console +``` + +**Transports and setup** + +```bash +marm-memory http # run HTTP in the foreground +marm-memory stdio # run the strict local MCP STDIO transport +marm-memory doctor # diagnose the local install +marm-memory key init # create or reuse ~/.marm/.env without displaying the key +marm-memory key path # print the managed key-file path +marm-memory key reveal # explicitly display the managed key +marm-memory console --import-key # open an authenticated local Console session +marm-memory upgrade --check # compare the installed package with PyPI +marm-memory uninstall # preview package removal; always preserves ~/.marm +``` + +**Knowledge, projects, and maintenance** + +```bash +marm-memory knowledge status +marm-memory knowledge build --all +marm-memory projects list +marm-memory projects index /absolute/path/to/repository +marm-memory projects status +marm-memory maintenance status +marm-memory maintenance embeddings migrate +``` + +Docker commands are documented separately below because they require explicit data mounts, network exposure, and key-handling choices. + +### Local Keys And Package Lifecycle + +Normal localhost HTTP remains keyless and loopback-only. For an exposed runtime or a Docker deployment, use `marm-memory key init` to create or reuse the managed `~/.marm/.env` key file. `marm-memory key path` prints only its path; `marm-memory key reveal` intentionally prints the key with a terminal-capture warning. `marm-memory key generate` remains the non-persistent compatibility command. + +When a managed key is active, `marm-memory console --import-key` opens a local Console session without placing the API key in browser storage, frontend state, logs, or a URL query string. Manual bearer-key entry remains available for a separately managed or remote runtime. + +Use `marm-memory upgrade --check` to compare the installed package with PyPI. `marm-memory upgrade` previews a safe native upgrade; `--yes` performs it only where the active installer can be replaced safely. `marm-memory uninstall` similarly previews package removal and always preserves `~/.marm`, including memory databases, graph indexes, keys, logs, and configuration. On Windows, editable installs, or pipx installs, MARM prints the exact manual command rather than attempting to replace an active launcher. + ### Upgrade Existing Embeddings @@ -164,7 +223,7 @@ pip install marm-mcp-server **Swarm / multi-agent note:** The write queue is enabled by default to serialize memory writes through one worker. For shared HTTP deployments, use `marm-memory start --profile swarm` (200 RPM) or `--profile swarm-max` (600 RPM). `--profile trusted` disables rate limiting entirely for private deployments. STDIO is still best for private single-agent/local use. See [Swarm & multi-agent presets](#swarm--multi-agent-presets) for the full table.
-Local pip HTTP (zero config) +Local pip HTTP > "agent" refers to claude, gemini, grok, qwen, or any MCP client. Codex uses --url instead of --transport to add MCP tools. @@ -224,6 +283,59 @@ marm-mcp-stdio > Docker HTTP requires an API key because it exposes MARM as a network server; STDIO stays local to the client process and does not need one. +If you installed MARM through pip, the product CLI can safely preview or run the same setup. It uses a loopback port by default, preserves `~/.marm`, stores the generated key in `~/.marm/.env` rather than shell history, and refuses to replace an existing container. + +```bash +marm-memory docker command # preview the exact HTTP command +marm-memory docker run # create the managed HTTP container +marm-memory docker stdio-command # print a Docker STDIO client command +marm-memory docker status +marm-memory docker logs --follow +marm-memory docker stop + +# Optional: mount repositories read-only for code indexing. +marm-memory docker run --repo /absolute/path/to/repository + +# Optional: preview or explicitly write a Compose configuration. +marm-memory docker compose +marm-memory docker compose --yes +``` + +The HTTP `run`, `command`, and `compose` commands accept the same operational flags: + +| Flag | Purpose | +|---|---| +| `--data-dir ` | Persistent host directory mounted at `/home/marm/.marm`. Defaults to `~/.marm`; this holds memory, indexes, logs, and the managed key file. | +| `--env-file ` | Explicit Docker env file. It must already contain `MARM_API_KEY`; without this flag, MARM uses `~/.marm/.env` and creates a key there only when `docker run` or `docker compose --yes` needs one. | +| `--port ` | Host HTTP port. Default: `8001`. | +| `--expose-network` | Bind the host port to `0.0.0.0` instead of loopback. This is deliberate network exposure; configure a firewall and TLS proxy. | +| `--profile standard\|swarm\|swarm-max\|trusted` | Select the same write-queue and rate-limit preset as native HTTP startup. | +| `--rate-limit-rpm ` | Override the selected profile's HTTP rate limit. `0` disables rate limiting. | +| `--repo ` | Repeatable read-only repository mount for code indexing. MARM reports each corresponding `/workspace/repo-N` path to index inside the container. | +| `--tag ` | Official image tag. Default: `latest`. | +| `--pull` | Pull the selected image before creating a new HTTP container. | +| `--name ` | Managed container name. MARM refuses to replace an existing container with that name. | +| `--memory ` / `--cpus ` | Optional Docker resource limits. | +| `--dry-run` | `docker run` only: print the planned command without creating a container or key file. `docker command` is always a preview. | + +For example: + +```bash +# Shared local server with a custom data path and two repositories for indexing. +marm-memory docker command \ + --profile swarm \ + --data-dir /srv/marm-data \ + --repo /srv/projects/api \ + --repo /srv/projects/web + +# Execute the reviewed command, pulling the image first. +marm-memory docker run --profile swarm --data-dir /srv/marm-data --pull +``` + +Docker STDIO is separate from Docker HTTP: `marm-memory docker stdio-command` uses `docker run -i --rm`, has no port and no bearer key, but still mounts the data directory so SQLite memory persists after the short-lived container exits. Use `--data-dir` and `--tag` with that command when needed. There are no separate `docker key` or `docker mount` commands; `--env-file` and `--data-dir` make those choices explicit in the generated HTTP command. + +`marm-memory docker pull` only downloads an image. `marm-memory docker maintenance embeddings migrate` runs against the same data mount and refuses while the managed HTTP container is running. The helper is available only with the pip-installed `marm-memory` command; Docker-only users can use the raw commands below. + ```bash # Step 1: generate key (do not add < > around the key) docker run --rm lyellr88/marm-mcp-server:latest --generate-key diff --git a/marm-mcp-server/marm-docs/ROADMAP.md b/marm-mcp-server/marm-docs/ROADMAP.md deleted file mode 100644 index a94a48b7..00000000 --- a/marm-mcp-server/marm-docs/ROADMAP.md +++ /dev/null @@ -1,192 +0,0 @@ -# marm-memory Roadmap - -> Updated 05/17/2026 this roadmap reflects the current strategic direction and near-term priorities for marm-memory. It is a living document that will evolve as we learn from users, research, and the rapidly changing AI landscape. - -## Strategic Direction - -MARM is focused on one clear goal: make AI memory practical across real tools, real projects, and real local workflows. - -The active product direction has two tracks: - -- **MARM MCP Server**: the agent-facing memory layer used by Claude, Codex, Gemini, Qwen, VS Code, Cursor, and other MCP clients. -- **MARM Console**: the human-facing local admin UI for inspecting, editing, exporting, and maintaining the same memory database. - -MARM is not currently being built as a paid upgrade product. The near-term focus is a strong open base: reliable local memory, clean transports, useful console workflows, and future extension points for plugins, SDKs, research, and team workflows. - ---- - -## Current Foundation - -MARM now has the core pieces needed for a serious local memory system: - -- **MCP server** with persistent SQLite memory, session logs, notebooks, semantic recall, and context tools -- **HTTP transport** for long-running or shared local server workflows -- **STDIO transport** for private local client-launched workflows -- **Docker support** with HTTP and STDIO modes from one image -- **API-key auth** for Docker, exposed, or shared HTTP deployments -- **VS Code and Cursor support** through native MCP config files -- **MARM Console** as an optional local admin UI for human memory management -- **Fresh test suite** covering HTTP tools, auth, rate limits, response limits, database behavior, STDIO, and Docker smoke paths -- **Automation scripts** for version sync, test runs, stale-doc scans, Docker smoke, and release preflight - ---- - -## MCP Server Roadmap - -### 1. Automatic Memory Context Layer - -Today, MARM memory is strongest when an agent explicitly calls recall. The next major improvement is making relevant memory easier to surface at the right time. - -Planned direction: - -- Add smarter recall paths that pull relevant memories based on active topic, session, and project context -- Let `marm_smart_recall` optionally include structured logs so one tool can return both memory and decision history -- Reduce reliance on separate context-bridge style tools when the agent can format the final reasoning itself -- Keep user/agent control explicit so automatic context does not become noisy or surprising - -Why it matters: - -AI agents should not have to remember exactly when to ask for memory. MARM should make useful context easier to retrieve while still staying transparent and controllable. - -### 2. Memory Evolution and Relevance Learning - -As memory grows, raw semantic recall is not enough. MARM needs to learn which memories remain useful and which ones are stale. - -Planned direction: - -- Track lightweight usage signals such as recalled, edited, deleted, reused, or ignored memories -- Improve ranking over time using recency, frequency, session/project relevance, and user cleanup behavior -- Identify related memories across sessions so solutions, decisions, and patterns are easier to rediscover -- Add stale-memory indicators so users can clean old or low-value entries from marm-console - -Why it matters: - -The value of memory is not just storage. It is retrieval quality. The system should get better as it is used, not noisier. - -### 3. Project-Scoped and Shared Memory - -MARM already supports shared HTTP server mode through Docker/API-key deployments. That creates the foundation for shared workspaces, but the memory model needs better organization before team usage scales. - -Planned direction: - -- Keep a global/default memory pool for general knowledge -- Add optional per-project memory databases or project scopes -- Track known project memory locations in a lightweight index -- Search active project memory first, then allow cross-project recall when useful -- Support shared HTTP deployments where multiple authorized users/agents can write to the same memory store -- Label recall results by project/session/source so agents can tell where context came from - -Why it matters: - -One flat memory pool works at small scale. Larger multi-project and multi-agent workflows need cleaner boundaries without losing cross-project learning. - -### 4. Research Memory Integration - -This is the highest-leverage future feature: a separate research layer that helps bridge model knowledge cutoffs without dumping untrusted web content directly into curated memory. - -Planned direction: - -- Maintain a separate research database for external findings -- Let users or agents trigger research on specific topics, errors, APIs, libraries, or project questions -- Store source, date, summary, relevance score, and URL for each finding -- Keep research findings separate from curated MARM memory until promoted by the user or agent -- Add tools to review, search, promote, dismiss, or bookmark research findings -- Start with manual/on-demand research before considering background automation - -Why it matters: - -MARM can become a memory system plus a research staging area. That gives agents access to current external context while preserving trust: researched information is reviewed before it becomes core memory. - -### 5. Tool Surface Cleanup - -The MCP tool list should stay focused on actions an AI agent actually needs. - -Planned direction: - -- Combine overlapping delete operations where practical -- Retire redundant tools when one stronger tool can cover the same workflow -- Move internal/status-only behavior out of the AI-facing tool list -- Keep health checks, reloads, and maintenance actions available through HTTP or scripts where that is cleaner - -Why it matters: - -Fewer, clearer tools improve client discovery, reduce token overhead, and make agent tool selection more reliable. - ---- - -## Console Roadmap - -### 1. Export and Reporting - -marm-console is the natural place for human-friendly memory export. - -Planned direction: - -- Export memories, logs, sessions, and notebooks to Markdown -- Add JSON export for backups and integrations -- Add CSV export for spreadsheet review and audit workflows -- Add filtered exports by session, project, date range, or context type -- Consider PDF summaries later if Markdown/JSON/CSV prove useful first - -Why it matters: - -Memory should be portable. Users need backups, reports, and ways to move MARM knowledge into docs, GitHub, Notion, Confluence, or other systems. - -### 2. Safer Admin Workflows - -marm-console already asks before destructive actions. The next step is making high-impact edits even safer and easier to inspect. - -Planned direction: - -- Optional read-only launch mode -- Export-before-delete prompts for bulk actions -- Clearer distinction between viewing, editing, and deleting -- Better bulk-action summaries before confirmation -- More visible database path and active auth mode -- Backup/import helpers for `~/.marm/marm_memory.db` - -Why it matters: - -marm-console can edit real memory. It should feel efficient, but not casual about destructive changes. - -### 3. Console and MCP Schema Alignment - -marm-console uses MCP-backed mutation paths and local read APIs, so it must stay aligned with MCP schema changes. - -Planned direction: - -- Add tests for console compatibility with current MCP tables -- Keep console CRUD behavior aligned with MCP sanitization and metadata conventions -- Surface MCP server reachability and database state clearly -- Avoid adding console-only fields unless the MCP server also understands them - -Why it matters: - -marm-console is useful because it manages the same data. Schema drift would make it dangerous. - ---- - -## Shared Technical Priorities - -These are not product features, but they keep both tracks trustworthy. - -- Keep the test suite green and expand it around real workflows -- Run Docker HTTP and STDIO smoke checks before public releases -- Keep install docs aligned with tested client behavior -- Remove stale setup paths from active docs -- Keep version numbers synced from the latest changelog entry -- Maintain release automation that reports problems without hiding changes - ---- - -## Long-Term Possibilities - -These are conditional directions after the MCP server and marm-console are stable: - -- **Plugin integrations** for editors, local tools, and MCP client ecosystems -- **SDKs** for developers who want MARM-backed memory in their own apps -- **Optional sync architecture** for cross-device memory access -- **Real multi-user identity and permissions** for hosted or team deployments -- **Research automation** after manual research review workflows prove useful - -The guiding rule: new integrations should strengthen MARM as a memory layer, not pull it back into unrelated app sprawl. diff --git a/marm-mcp-server/marm_mcp_server/__init__.py b/marm-mcp-server/marm_mcp_server/__init__.py index 755a0420..254eadd8 100644 --- a/marm-mcp-server/marm_mcp_server/__init__.py +++ b/marm-mcp-server/marm_mcp_server/__init__.py @@ -14,10 +14,10 @@ - Production-grade performance Author: Ryan Lyell - marm-memory -Version: 2.27.0 +Version: 2.28.0 """ -__version__ = "2.27.0" +__version__ = "2.28.0" __author__ = "Ryan Lyell" __email__ = "lyell@marmsystems.com" diff --git a/marm-mcp-server/marm_mcp_server/cli.py b/marm-mcp-server/marm_mcp_server/cli.py index 622538e7..68982394 100644 --- a/marm-mcp-server/marm_mcp_server/cli.py +++ b/marm-mcp-server/marm_mcp_server/cli.py @@ -7,11 +7,9 @@ import json import os import sys -import time import urllib.error import urllib.request import uuid -from collections import deque from pathlib import Path from typing import Any, Optional @@ -143,17 +141,74 @@ def _add_profile_arguments(parser: argparse.ArgumentParser) -> None: ) +def _add_docker_run_arguments(parser: argparse.ArgumentParser) -> None: + _add_profile_arguments(parser) + parser.add_argument("--port", type=int, default=8001) + parser.add_argument("--data-dir", type=Path, default=Path.home() / ".marm") + parser.add_argument("--name", default="marm-mcp-server") + parser.add_argument("--tag", default="latest") + parser.add_argument("--repo", type=Path, action="append", default=[]) + parser.add_argument("--pull", action="store_true") + parser.add_argument("--expose-network", action="store_true") + parser.add_argument("--env-file", type=Path) + parser.add_argument("--memory") + parser.add_argument("--cpus") + + +def _product_help() -> str: + """Return the stable, human-oriented root help for the product CLI.""" + from .services.product_help import render_product_help + + return render_product_help(SERVER_VERSION) + + +class _ProductArgumentParser(argparse.ArgumentParser): + """Keep root help stable while retaining argparse for all subcommands.""" + + def format_help(self) -> str: + return _product_help() + + def _product_parser() -> argparse.ArgumentParser: - parser = argparse.ArgumentParser( - prog="marm-memory", description="Run and manage marm-memory locally" + parser = _ProductArgumentParser( + prog="marm-memory", + description="Run and manage marm-memory locally", + add_help=False, + ) + parser.add_argument("-h", "--help", action="help", help="Show help") + parser.add_argument( + "-V", + "--version", + action="version", + version=SERVER_VERSION, + help="Show installed version", + ) + subparsers = parser.add_subparsers( + dest="command", required=True, parser_class=argparse.ArgumentParser ) - subparsers = parser.add_subparsers(dest="command", required=True) start = subparsers.add_parser("start", help="Start the local MARM runtime") _add_profile_arguments(start) start.add_argument("--foreground", action="store_true") start.add_argument("--runtime-id", help=argparse.SUPPRESS) + fast_start = subparsers.add_parser( + "fast-start-http", help="Start HTTP, Console, and optional client setup" + ) + _add_profile_arguments(fast_start) + fast_start.add_argument("--client", help="Configure a supported MCP client") + fast_start.add_argument("--no-console", action="store_true") + fast_start.add_argument("--no-browser", action="store_true") + + http = subparsers.add_parser( + "http", help="Run the HTTP transport in the foreground" + ) + _add_profile_arguments(http) + http.add_argument("--runtime-id", help=argparse.SUPPRESS) + http.set_defaults(foreground=True) + + subparsers.add_parser("stdio", help="Run the MCP STDIO transport") + stop = subparsers.add_parser("stop", help="Stop the managed MARM runtime") stop.add_argument("--force", action="store_true") restart = subparsers.add_parser("restart", help="Restart the managed runtime") @@ -166,6 +221,11 @@ def _product_parser() -> argparse.ArgumentParser: browser.add_argument("--no-open", action="store_false", dest="open_browser") console.set_defaults(open_browser=True) console.add_argument("--foreground", action="store_true") + console.add_argument( + "--import-key", + action="store_true", + help="Create a managed authenticated Console browser session", + ) logs = subparsers.add_parser("logs", help="Read managed runtime logs") logs.add_argument("--follow", action="store_true") logs.add_argument("--lines", type=int, default=100) @@ -205,10 +265,30 @@ def _product_parser() -> argparse.ArgumentParser: embeddings_sub = embeddings.add_subparsers(dest="embeddings_command", required=True) embeddings_sub.add_parser("migrate") - key = subparsers.add_parser("key") + key = subparsers.add_parser("key", help="Manage local bearer authentication") key_sub = key.add_subparsers(dest="key_command", required=True) - key_sub.add_parser("generate") - subparsers.add_parser("version") + key_sub.add_parser("generate", help="Generate and display an ephemeral key") + key_sub.add_parser("init", help="Create or reuse the managed local key file") + key_sub.add_parser("path", help="Print the managed local key-file path") + key_sub.add_parser("reveal", help="Display the managed local key") + + from .services.docker_cli import add_docker_commands + + add_docker_commands(subparsers, _add_docker_run_arguments) + upgrade = subparsers.add_parser( + "upgrade", aliases=["update"], help="Check for and install a newer MARM release" + ) + upgrade.add_argument("--check", action="store_true") + upgrade.add_argument("--version") + upgrade.add_argument("--yes", action="store_true") + upgrade.add_argument("--json", action="store_true", dest="as_json") + + uninstall = subparsers.add_parser( + "uninstall", help="Remove MARM while preserving user data" + ) + uninstall.add_argument("--yes", action="store_true") + + subparsers.add_parser("version", help="Show installed version") return parser @@ -397,6 +477,13 @@ def _ensure_runtime() -> dict[str, Any]: return current if current["state"] == "ready" else start_background() +def _fast_start_http(args: argparse.Namespace) -> int: + """Delegate the reusable local HTTP workflow to its service owner.""" + from .services.product_workflows import fast_start_http + + return fast_start_http(args) + + def _runtime_post(path: str, payload: dict[str, Any]) -> dict[str, Any]: from .core.runtime_manager import request_runtime_strict @@ -427,6 +514,20 @@ def _migrate_embeddings() -> int: return 0 +def _upgrade(args: argparse.Namespace) -> int: + """Delegate package update policy to the lifecycle service.""" + from .services.product_workflows import upgrade + + return upgrade(args, print_payload=_print_payload) + + +def _uninstall(args: argparse.Namespace) -> int: + """Delegate package removal policy to the lifecycle service.""" + from .services.product_workflows import uninstall + + return uninstall(args) + + def _dispatch_product(args: argparse.Namespace) -> int: from .core import runtime_manager from .services.runtime_status import ( @@ -436,7 +537,9 @@ def _dispatch_product(args: argparse.Namespace) -> int: maintenance_status, ) - if args.command == "start": + if args.command == "fast-start-http": + return _fast_start_http(args) + if args.command in {"start", "http"}: if args.foreground: _run_foreground( profile=args.profile, @@ -453,6 +556,11 @@ def _dispatch_product(args: argparse.Namespace) -> int: ) print("Console: run `marm-memory console` when you want the web app.") return 0 + if args.command == "stdio": + from . import server_stdio + + server_stdio.main() + return 0 if args.command == "stop": print( "MARM runtime stopped." @@ -470,14 +578,18 @@ def _dispatch_product(args: argparse.Namespace) -> int: return 0 if args.command == "status": payload = full_status() - _print_payload(payload, as_json=True) if args.as_json else _print_status( - payload + ( + _print_payload(payload, as_json=True) + if args.as_json + else _print_status(payload) ) return 0 if args.command == "doctor": payload = doctor_status() - _print_payload(payload, as_json=True) if args.as_json else _print_doctor( - payload + ( + _print_payload(payload, as_json=True) + if args.as_json + else _print_doctor(payload) ) return 0 if payload["ok"] else 1 if args.command == "logs": @@ -486,7 +598,11 @@ def _dispatch_product(args: argparse.Namespace) -> int: _ensure_runtime() from .console.cli import run_console - return run_console(open_browser=args.open_browser, foreground=args.foreground) + return run_console( + open_browser=args.open_browser, + foreground=args.foreground, + import_key=args.import_key, + ) if args.command == "knowledge": if args.knowledge_command == "status": _print_payload(knowledge_status()) @@ -511,118 +627,69 @@ def _dispatch_product(args: argparse.Namespace) -> int: return 0 return _migrate_embeddings() if args.command == "key": - _write_generated_api_key() + if args.key_command == "generate": + _write_generated_api_key() + return 0 + from .services import key_management + + if args.key_command == "init": + path, created = key_management.initialize_managed_key() + state = "Created" if created else "Using existing" + print(f"{state} MARM API key file: {path}") + return 0 + if args.key_command == "path": + print(key_management.managed_key_path()) + return 0 + key = key_management.read_managed_key() + if not key: + print( + "No managed MARM API key exists. Run `marm-memory key init` first.", + file=sys.stderr, + ) + return 1 + print( + "Warning: terminal capture and shell history may retain this key.", + file=sys.stderr, + ) + print(key) return 0 + if args.command == "docker": + return _dispatch_docker(args) + if args.command in {"upgrade", "update"}: + return _upgrade(args) + if args.command == "uninstall": + return _uninstall(args) if args.command == "version": print(SERVER_VERSION) return 0 return 2 +def _dispatch_docker(args: argparse.Namespace) -> int: + """Delegate Docker behavior to the shared Docker CLI service.""" + from .services.docker_cli import dispatch_docker + + return dispatch_docker(args, print_payload=_print_payload) + + def _dispatch_projects(args: argparse.Namespace) -> int: - from .core.runtime_manager import ( - RuntimeRequestError, - RuntimeUnavailable, - request_runtime, - request_runtime_strict, + """Delegate code-index operations to the focused project CLI service.""" + from .services.projects_cli import dispatch_projects + + return dispatch_projects( + args, + ensure_runtime=_ensure_runtime, + runtime_post=_runtime_post, + print_payload=_print_payload, ) - _ensure_runtime() - if args.projects_command == "list": - payload = _runtime_post("/internal/projects/list", {}) - elif args.projects_command == "status": - if args.project is None: - payload = request_runtime("/internal/runtime/status") or {} - payload = payload.get("graph", payload) - else: - payload = _runtime_post( - "/internal/projects/status", {"project": args.project} - ) - elif args.projects_command == "remove": - if args.confirm != args.project: - print("--confirm must exactly match the project name.", file=sys.stderr) - return 2 - payload = _runtime_post( - "/internal/projects/delete", - {"project": args.project, "name": args.confirm, "confirm": True}, - ) - else: - path = Path(args.path).expanduser() - if not path.is_absolute() or not path.is_dir(): - print( - "Repository path must be an existing absolute directory.", - file=sys.stderr, - ) - return 2 - job = _runtime_post( - "/internal/projects/index", - {"repo_path": str(path.resolve()), "mode": args.mode}, - ) - job_id = job.get("job_id") - if not job_id: - _print_payload(job) - return 1 - poll_failures = 0 - while True: - try: - payload = request_runtime_strict( - f"/internal/projects/jobs/{job_id}", timeout=5.0 - ) - poll_failures = 0 - except RuntimeRequestError as exc: - if exc.status_code != 429 and exc.status_code < 500: - raise - poll_failures += 1 - if poll_failures >= 5: - raise RuntimeError( - "Project index status could not be read after 5 attempts." - ) from exc - time.sleep(exc.retry_after or 1) - continue - except RuntimeUnavailable as exc: - poll_failures += 1 - if poll_failures >= 5: - raise RuntimeError( - "Lost contact with the runtime while indexing the project." - ) from exc - time.sleep(1) - continue - status = payload.get("status") - if status in {"success", "error"}: - break - if status not in {"queued", "running"}: - raise RuntimeError("The project index job returned an invalid status.") - time.sleep(1) - _print_payload(payload) - return 1 if payload.get("status") == "error" else 0 - def _show_logs(lines: int, follow: bool) -> int: + """Delegate managed-log display to the focused log service.""" from .core.runtime_manager import log_path + from .services.product_logs import show_logs - path = log_path() - if not path.exists(): - print("No managed runtime log exists yet.") - return 0 - with path.open("r", encoding="utf-8", errors="replace") as log_file: - recent = deque(log_file, maxlen=max(1, lines)) - for line in recent: - print(line, end="") - if not follow: - return 0 - while True: - line = log_file.readline() - if line: - print(line, end="") - continue - try: - if path.stat().st_size < log_file.tell(): - log_file.seek(0) - time.sleep(0.5) - except KeyboardInterrupt: - return 0 - except OSError: - time.sleep(0.5) + return show_logs(lines, follow, path=log_path()) def _dispatch_compatibility( @@ -663,6 +730,9 @@ def main() -> None: and sys.argv[1] in { "start", + "fast-start-http", + "http", + "stdio", "stop", "restart", "status", @@ -673,11 +743,21 @@ def main() -> None: "projects", "maintenance", "key", + "docker", + "upgrade", + "update", + "uninstall", "version", } ) parser = _product_parser() if product_mode else _compatibility_parser() - args = parser.parse_args() + arguments = sys.argv[1:] + if product_mode and arguments[:1] == ["help"]: + if len(arguments) == 1: + parser.print_help() + raise SystemExit(0) + arguments = [arguments[1], "--help", *arguments[2:]] + args = parser.parse_args(arguments) if product_mode: try: code = _dispatch_product(args) diff --git a/marm-mcp-server/marm_mcp_server/config/settings.py b/marm-mcp-server/marm_mcp_server/config/settings.py index 5ca7afed..391e80d3 100644 --- a/marm-mcp-server/marm_mcp_server/config/settings.py +++ b/marm-mcp-server/marm_mcp_server/config/settings.py @@ -122,7 +122,7 @@ def get_analytics_db_path(): f"WARNING: SERVER_PORT={_raw_port} out of [1, 65535], clamped to {SERVER_PORT}", file=sys.stderr, ) -SERVER_VERSION = "2.27.0" +SERVER_VERSION = "2.28.0" GRAPH_ENABLED = os.environ.get("GRAPH_ENABLED", "true").lower() != "false" diff --git a/marm-mcp-server/marm_mcp_server/console/app.py b/marm-mcp-server/marm_mcp_server/console/app.py index 59f50b84..0cfc9958 100644 --- a/marm-mcp-server/marm_mcp_server/console/app.py +++ b/marm-mcp-server/marm_mcp_server/console/app.py @@ -19,9 +19,10 @@ from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import FileResponse, JSONResponse from fastapi.staticfiles import StaticFiles +from pydantic import BaseModel from starlette.middleware.trustedhost import TrustedHostMiddleware -from . import mcp_client +from . import auth, mcp_client from .endpoints import ( compaction, concepts, @@ -86,11 +87,20 @@ async def lifespan(_app: FastAPI): ) +class _ConsoleBootstrapRequest(BaseModel): + token: str + + @app.middleware("http") async def console_api_auth(request: Request, call_next): """Apply MARM's auth policy to Console data APIs, not static SPA assets.""" if request.method == "OPTIONS" or not request.url.path.startswith("/api/"): return await call_next(request) + if request.url.path == "/api/auth/bootstrap": + return await call_next(request) + + if auth.valid_browser_session(request.cookies.get("marm_console_session")): + return await call_next(request) api_key = os.environ.get("MARM_API_KEY", "") if not api_key: @@ -135,6 +145,25 @@ async def console_api_auth(request: Request, call_next): app.include_router(projects.router) +@app.post("/api/auth/bootstrap", include_in_schema=False) +def bootstrap_console_session(payload: _ConsoleBootstrapRequest): + """Exchange a local one-time handoff for an HttpOnly browser session.""" + from ..core.runtime_manager import runtime_dir + + if not auth.consume_bootstrap_token(runtime_dir(), payload.token): + raise HTTPException(status_code=401, detail="Console bootstrap has expired.") + response = JSONResponse({"status": "authenticated"}) + response.set_cookie( + "marm_console_session", + auth.create_browser_session(), + max_age=8 * 60 * 60, + httponly=True, + samesite="strict", + secure=False, + ) + return response + + @app.get("/health") def health() -> dict[str, str]: return {"status": "ok", "service": "marm-console"} diff --git a/marm-mcp-server/marm_mcp_server/console/auth.py b/marm-mcp-server/marm_mcp_server/console/auth.py new file mode 100644 index 00000000..45ae8507 --- /dev/null +++ b/marm-mcp-server/marm_mcp_server/console/auth.py @@ -0,0 +1,84 @@ +"""Local-only bootstrap and browser-session support for MARM Console.""" + +from __future__ import annotations + +import json +import secrets +import threading +import time +from pathlib import Path + +_BOOTSTRAP_TTL_SECONDS = 60 +_SESSION_TTL_SECONDS = 8 * 60 * 60 +_BOOTSTRAP_FILE = "console-bootstrap.json" +_sessions: dict[str, float] = {} +_sessions_lock = threading.Lock() +_bootstrap_lock = threading.Lock() + + +def _bootstrap_path(runtime_directory: Path) -> Path: + return runtime_directory / _BOOTSTRAP_FILE + + +def create_bootstrap_token(runtime_directory: Path) -> str: + """Create a short-lived, single-use handoff for the local browser.""" + token = secrets.token_urlsafe(32) + runtime_directory.mkdir(parents=True, exist_ok=True) + path = _bootstrap_path(runtime_directory) + path.write_text( + json.dumps( + {"token": token, "expires_at": time.time() + _BOOTSTRAP_TTL_SECONDS} + ), + encoding="utf-8", + ) + try: + path.chmod(0o600) + except OSError: + pass + return token + + +def consume_bootstrap_token(runtime_directory: Path, token: str) -> bool: + """Consume one valid token; expired and malformed handoffs are rejected.""" + path = _bootstrap_path(runtime_directory) + with _bootstrap_lock: + try: + payload = json.loads(path.read_text(encoding="utf-8")) + except (OSError, ValueError, TypeError): + return False + try: + path.unlink(missing_ok=True) + except OSError: + return False + expected = payload.get("token") + expires_at = payload.get("expires_at") + return ( + isinstance(expected, str) + and isinstance(expires_at, (int, float)) + and time.time() <= expires_at + and secrets.compare_digest(token, expected) + ) + + +def create_browser_session() -> str: + """Return an opaque server-held Console browser session token.""" + token = secrets.token_urlsafe(32) + with _sessions_lock: + now = time.time() + _sessions.update( + {value: expiry for value, expiry in _sessions.items() if expiry > now} + ) + _sessions[token] = now + _SESSION_TTL_SECONDS + return token + + +def valid_browser_session(token: str | None) -> bool: + """Check a session without ever returning its credential.""" + if not token: + return False + with _sessions_lock: + expires_at = _sessions.get(token) + if expires_at is None or expires_at <= time.time(): + _sessions.pop(token, None) + return False + return True diff --git a/marm-mcp-server/marm_mcp_server/console/cli.py b/marm-mcp-server/marm_mcp_server/console/cli.py index 92fed779..b08322a6 100644 --- a/marm-mcp-server/marm_mcp_server/console/cli.py +++ b/marm-mcp-server/marm_mcp_server/console/cli.py @@ -5,6 +5,7 @@ import argparse import json import os +import secrets import subprocess import sys import time @@ -57,10 +58,32 @@ def _serve() -> None: ) -def run_console(*, open_browser: bool = True, foreground: bool = False) -> int: +def run_console( + *, + open_browser: bool = True, + foreground: bool = False, + import_key: bool = False, +) -> int: from ..core.runtime_manager import bound_log_file, runtime_dir url = f"http://127.0.0.1:{_port()}" + if import_key: + from ..config.settings import MARM_API_KEY + from ..services.key_management import read_managed_key + + managed_key = read_managed_key() + if not managed_key: + raise RuntimeError( + "No managed MARM API key exists. Run `marm-memory key init` first." + ) + if MARM_API_KEY and not secrets.compare_digest(managed_key, MARM_API_KEY): + raise RuntimeError( + "The managed key does not match this runtime. Use Console Settings " + "to enter the runtime's bearer key." + ) + from .auth import create_bootstrap_token + + url = f"{url}/#marm-bootstrap={create_bootstrap_token(runtime_dir())}" if not _healthy(): if foreground: if open_browser: @@ -108,7 +131,7 @@ def run_console(*, open_browser: bool = True, foreground: bool = False) -> int: raise RuntimeError(f"MARM Console did not become ready. Check {log_path}.") if open_browser: webbrowser.open(url) - print(f"MARM Console: {url}") + print(f"MARM Console: {url.split('/#', 1)[0]}") return 0 diff --git a/marm-mcp-server/marm_mcp_server/console/static/assets/index-CPMGdUC9.js b/marm-mcp-server/marm_mcp_server/console/static/assets/index-D10wAXCq.js similarity index 80% rename from marm-mcp-server/marm_mcp_server/console/static/assets/index-CPMGdUC9.js rename to marm-mcp-server/marm_mcp_server/console/static/assets/index-D10wAXCq.js index 0474b0f6..9dbcd0ee 100644 --- a/marm-mcp-server/marm_mcp_server/console/static/assets/index-CPMGdUC9.js +++ b/marm-mcp-server/marm_mcp_server/console/static/assets/index-D10wAXCq.js @@ -1,12 +1,12 @@ -function gN(e,n){for(var r=0;rs[l]})}}}return Object.freeze(Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}))}(function(){const n=document.createElement("link").relList;if(n&&n.supports&&n.supports("modulepreload"))return;for(const l of document.querySelectorAll('link[rel="modulepreload"]'))s(l);new MutationObserver(l=>{for(const c of l)if(c.type==="childList")for(const f of c.addedNodes)f.tagName==="LINK"&&f.rel==="modulepreload"&&s(f)}).observe(document,{childList:!0,subtree:!0});function r(l){const c={};return l.integrity&&(c.integrity=l.integrity),l.referrerPolicy&&(c.referrerPolicy=l.referrerPolicy),l.crossOrigin==="use-credentials"?c.credentials="include":l.crossOrigin==="anonymous"?c.credentials="omit":c.credentials="same-origin",c}function s(l){if(l.ep)return;l.ep=!0;const c=r(l);fetch(l.href,c)}})();function i1(e){return e&&e.__esModule&&Object.prototype.hasOwnProperty.call(e,"default")?e.default:e}var gh={exports:{}},So={};var A0;function yN(){if(A0)return So;A0=1;var e=Symbol.for("react.transitional.element"),n=Symbol.for("react.fragment");function r(s,l,c){var f=null;if(c!==void 0&&(f=""+c),l.key!==void 0&&(f=""+l.key),"key"in l){c={};for(var d in l)d!=="key"&&(c[d]=l[d])}else c=l;return l=c.ref,{$$typeof:e,type:s,key:f,ref:l!==void 0?l:null,props:c}}return So.Fragment=n,So.jsx=r,So.jsxs=r,So}var M0;function vN(){return M0||(M0=1,gh.exports=yN()),gh.exports}var m=vN(),yh={exports:{}},jo={},vh={exports:{}},xh={};var O0;function xN(){return O0||(O0=1,(function(e){function n(D,H){var I=D.length;D.push(H);e:for(;0>>1,k=D[ie];if(0>>1;iel(K,I))cel(de,K)?(D[ie]=de,D[ce]=I,ie=ce):(D[ie]=K,D[ae]=I,ie=ae);else if(cel(de,I))D[ie]=de,D[ce]=I,ie=ce;else break e}}return H}function l(D,H){var I=D.sortIndex-H.sortIndex;return I!==0?I:D.id-H.id}if(e.unstable_now=void 0,typeof performance=="object"&&typeof performance.now=="function"){var c=performance;e.unstable_now=function(){return c.now()}}else{var f=Date,d=f.now();e.unstable_now=function(){return f.now()-d}}var h=[],p=[],y=1,v=null,b=3,_=!1,w=!1,S=!1,C=!1,j=typeof setTimeout=="function"?setTimeout:null,A=typeof clearTimeout=="function"?clearTimeout:null,R=typeof setImmediate<"u"?setImmediate:null;function T(D){for(var H=r(p);H!==null;){if(H.callback===null)s(p);else if(H.startTime<=D)s(p),H.sortIndex=H.expirationTime,n(h,H);else break;H=r(p)}}function O(D){if(S=!1,T(D),!w)if(r(h)!==null)w=!0,L||(L=!0,re());else{var H=r(p);H!==null&&$(O,H.startTime-D)}}var L=!1,U=-1,z=5,ne=-1;function Q(){return C?!0:!(e.unstable_now()-neD&&Q());){var ie=v.callback;if(typeof ie=="function"){v.callback=null,b=v.priorityLevel;var k=ie(v.expirationTime<=D);if(D=e.unstable_now(),typeof k=="function"){v.callback=k,T(D),H=!0;break t}v===r(h)&&s(h),T(D)}else s(h);v=r(h)}if(v!==null)H=!0;else{var V=r(p);V!==null&&$(O,V.startTime-D),H=!1}}break e}finally{v=null,b=I,_=!1}H=void 0}}finally{H?re():L=!1}}}var re;if(typeof R=="function")re=function(){R(Z)};else if(typeof MessageChannel<"u"){var B=new MessageChannel,P=B.port2;B.port1.onmessage=Z,re=function(){P.postMessage(null)}}else re=function(){j(Z,0)};function $(D,H){U=j(function(){D(e.unstable_now())},H)}e.unstable_IdlePriority=5,e.unstable_ImmediatePriority=1,e.unstable_LowPriority=4,e.unstable_NormalPriority=3,e.unstable_Profiling=null,e.unstable_UserBlockingPriority=2,e.unstable_cancelCallback=function(D){D.callback=null},e.unstable_forceFrameRate=function(D){0>D||125ie?(D.sortIndex=I,n(p,D),r(h)===null&&D===r(p)&&(S?(A(U),U=-1):S=!0,$(O,I-ie))):(D.sortIndex=k,n(h,D),w||_||(w=!0,L||(L=!0,re()))),D},e.unstable_shouldYield=Q,e.unstable_wrapCallback=function(D){var H=b;return function(){var I=b;b=H;try{return D.apply(this,arguments)}finally{b=I}}}})(xh)),xh}var R0;function bN(){return R0||(R0=1,vh.exports=xN()),vh.exports}var bh={exports:{}},Ne={};var k0;function _N(){if(k0)return Ne;k0=1;var e=Symbol.for("react.transitional.element"),n=Symbol.for("react.portal"),r=Symbol.for("react.fragment"),s=Symbol.for("react.strict_mode"),l=Symbol.for("react.profiler"),c=Symbol.for("react.consumer"),f=Symbol.for("react.context"),d=Symbol.for("react.forward_ref"),h=Symbol.for("react.suspense"),p=Symbol.for("react.memo"),y=Symbol.for("react.lazy"),v=Symbol.iterator;function b(k){return k===null||typeof k!="object"?null:(k=v&&k[v]||k["@@iterator"],typeof k=="function"?k:null)}var _={isMounted:function(){return!1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},w=Object.assign,S={};function C(k,V,ae){this.props=k,this.context=V,this.refs=S,this.updater=ae||_}C.prototype.isReactComponent={},C.prototype.setState=function(k,V){if(typeof k!="object"&&typeof k!="function"&&k!=null)throw Error("takes an object of state variables to update or a function which returns an object of state variables.");this.updater.enqueueSetState(this,k,V,"setState")},C.prototype.forceUpdate=function(k){this.updater.enqueueForceUpdate(this,k,"forceUpdate")};function j(){}j.prototype=C.prototype;function A(k,V,ae){this.props=k,this.context=V,this.refs=S,this.updater=ae||_}var R=A.prototype=new j;R.constructor=A,w(R,C.prototype),R.isPureReactComponent=!0;var T=Array.isArray,O={H:null,A:null,T:null,S:null,V:null},L=Object.prototype.hasOwnProperty;function U(k,V,ae,K,ce,de){return ae=de.ref,{$$typeof:e,type:k,key:V,ref:ae!==void 0?ae:null,props:de}}function z(k,V){return U(k.type,V,void 0,void 0,void 0,k.props)}function ne(k){return typeof k=="object"&&k!==null&&k.$$typeof===e}function Q(k){var V={"=":"=0",":":"=2"};return"$"+k.replace(/[=:]/g,function(ae){return V[ae]})}var Z=/\/+/g;function re(k,V){return typeof k=="object"&&k!==null&&k.key!=null?Q(""+k.key):V.toString(36)}function B(){}function P(k){switch(k.status){case"fulfilled":return k.value;case"rejected":throw k.reason;default:switch(typeof k.status=="string"?k.then(B,B):(k.status="pending",k.then(function(V){k.status==="pending"&&(k.status="fulfilled",k.value=V)},function(V){k.status==="pending"&&(k.status="rejected",k.reason=V)})),k.status){case"fulfilled":return k.value;case"rejected":throw k.reason}}throw k}function $(k,V,ae,K,ce){var de=typeof k;(de==="undefined"||de==="boolean")&&(k=null);var te=!1;if(k===null)te=!0;else switch(de){case"bigint":case"string":case"number":te=!0;break;case"object":switch(k.$$typeof){case e:case n:te=!0;break;case y:return te=k._init,$(te(k._payload),V,ae,K,ce)}}if(te)return ce=ce(k),te=K===""?"."+re(k,0):K,T(ce)?(ae="",te!=null&&(ae=te.replace(Z,"$&/")+"/"),$(ce,V,ae,"",function(ye){return ye})):ce!=null&&(ne(ce)&&(ce=z(ce,ae+(ce.key==null||k&&k.key===ce.key?"":(""+ce.key).replace(Z,"$&/")+"/")+te)),V.push(ce)),1;te=0;var ue=K===""?".":K+":";if(T(k))for(var he=0;he"u"||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!="function"))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(e)}catch(n){console.error(n)}}return e(),_h.exports=wN(),_h.exports}var L0;function SN(){if(L0)return jo;L0=1;var e=bN(),n=zu(),r=a1();function s(i){var a="https://react.dev/errors/"+i;if(1k||(i.current=ie[k],ie[k]=null,k--)}function K(i,a){k++,ie[k]=i.current,i.current=a}var ce=V(null),de=V(null),te=V(null),ue=V(null);function he(i,a){switch(K(te,a),K(de,i),K(ce,null),a.nodeType){case 9:case 11:i=(i=a.documentElement)&&(i=i.namespaceURI)?r0(i):0;break;default:if(i=a.tagName,a=a.namespaceURI)a=r0(a),i=i0(a,i);else switch(i){case"svg":i=1;break;case"math":i=2;break;default:i=0}}ae(ce),K(ce,i)}function ye(){ae(ce),ae(de),ae(te)}function De(i){i.memoizedState!==null&&K(ue,i);var a=ce.current,o=i0(a,i.type);a!==o&&(K(de,i),K(ce,o))}function Pe(i){de.current===i&&(ae(ce),ae(de)),ue.current===i&&(ae(ue),vo._currentValue=I)}var $e=Object.prototype.hasOwnProperty,Rt=e.unstable_scheduleCallback,mt=e.unstable_cancelCallback,Ss=e.unstable_shouldYield,js=e.unstable_requestPaint,Qt=e.unstable_now,X2=e.unstable_getCurrentPriorityLevel,Lp=e.unstable_ImmediatePriority,Up=e.unstable_UserBlockingPriority,ml=e.unstable_NormalPriority,Z2=e.unstable_LowPriority,qp=e.unstable_IdlePriority,W2=e.log,J2=e.unstable_setDisableYieldValue,Ns=null,tn=null;function Gr(i){if(typeof W2=="function"&&J2(i),tn&&typeof tn.setStrictMode=="function")try{tn.setStrictMode(Ns,i)}catch{}}var nn=Math.clz32?Math.clz32:nS,eS=Math.log,tS=Math.LN2;function nS(i){return i>>>=0,i===0?32:31-(eS(i)/tS|0)|0}var pl=256,gl=4194304;function Di(i){var a=i&42;if(a!==0)return a;switch(i&-i){case 1:return 1;case 2:return 2;case 4:return 4;case 8:return 8;case 16:return 16;case 32:return 32;case 64:return 64;case 128:return 128;case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return i&4194048;case 4194304:case 8388608:case 16777216:case 33554432:return i&62914560;case 67108864:return 67108864;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 0;default:return i}}function yl(i,a,o){var u=i.pendingLanes;if(u===0)return 0;var g=0,x=i.suspendedLanes,N=i.pingedLanes;i=i.warmLanes;var M=u&134217727;return M!==0?(u=M&~x,u!==0?g=Di(u):(N&=M,N!==0?g=Di(N):o||(o=M&~i,o!==0&&(g=Di(o))))):(M=u&~x,M!==0?g=Di(M):N!==0?g=Di(N):o||(o=u&~i,o!==0&&(g=Di(o)))),g===0?0:a!==0&&a!==g&&(a&x)===0&&(x=g&-g,o=a&-a,x>=o||x===32&&(o&4194048)!==0)?a:g}function Es(i,a){return(i.pendingLanes&~(i.suspendedLanes&~i.pingedLanes)&a)===0}function rS(i,a){switch(i){case 1:case 2:case 4:case 8:case 64:return a+250;case 16:case 32:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return a+5e3;case 4194304:case 8388608:case 16777216:case 33554432:return-1;case 67108864:case 134217728:case 268435456:case 536870912:case 1073741824:return-1;default:return-1}}function Hp(){var i=pl;return pl<<=1,(pl&4194048)===0&&(pl=256),i}function Bp(){var i=gl;return gl<<=1,(gl&62914560)===0&&(gl=4194304),i}function af(i){for(var a=[],o=0;31>o;o++)a.push(i);return a}function Cs(i,a){i.pendingLanes|=a,a!==268435456&&(i.suspendedLanes=0,i.pingedLanes=0,i.warmLanes=0)}function iS(i,a,o,u,g,x){var N=i.pendingLanes;i.pendingLanes=o,i.suspendedLanes=0,i.pingedLanes=0,i.warmLanes=0,i.expiredLanes&=o,i.entangledLanes&=o,i.errorRecoveryDisabledLanes&=o,i.shellSuspendCounter=0;var M=i.entanglements,q=i.expirationTimes,W=i.hiddenUpdates;for(o=N&~o;0s[l]})}}}return Object.freeze(Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}))}(function(){const n=document.createElement("link").relList;if(n&&n.supports&&n.supports("modulepreload"))return;for(const l of document.querySelectorAll('link[rel="modulepreload"]'))s(l);new MutationObserver(l=>{for(const c of l)if(c.type==="childList")for(const f of c.addedNodes)f.tagName==="LINK"&&f.rel==="modulepreload"&&s(f)}).observe(document,{childList:!0,subtree:!0});function r(l){const c={};return l.integrity&&(c.integrity=l.integrity),l.referrerPolicy&&(c.referrerPolicy=l.referrerPolicy),l.crossOrigin==="use-credentials"?c.credentials="include":l.crossOrigin==="anonymous"?c.credentials="omit":c.credentials="same-origin",c}function s(l){if(l.ep)return;l.ep=!0;const c=r(l);fetch(l.href,c)}})();function i1(e){return e&&e.__esModule&&Object.prototype.hasOwnProperty.call(e,"default")?e.default:e}var gh={exports:{}},So={};var A0;function yN(){if(A0)return So;A0=1;var e=Symbol.for("react.transitional.element"),n=Symbol.for("react.fragment");function r(s,l,c){var f=null;if(c!==void 0&&(f=""+c),l.key!==void 0&&(f=""+l.key),"key"in l){c={};for(var d in l)d!=="key"&&(c[d]=l[d])}else c=l;return l=c.ref,{$$typeof:e,type:s,key:f,ref:l!==void 0?l:null,props:c}}return So.Fragment=n,So.jsx=r,So.jsxs=r,So}var M0;function vN(){return M0||(M0=1,gh.exports=yN()),gh.exports}var m=vN(),yh={exports:{}},jo={},vh={exports:{}},xh={};var O0;function xN(){return O0||(O0=1,(function(e){function n(D,H){var I=D.length;D.push(H);e:for(;0>>1,k=D[ie];if(0>>1;iel(K,I))cel(de,K)?(D[ie]=de,D[ce]=I,ie=ce):(D[ie]=K,D[ae]=I,ie=ae);else if(cel(de,I))D[ie]=de,D[ce]=I,ie=ce;else break e}}return H}function l(D,H){var I=D.sortIndex-H.sortIndex;return I!==0?I:D.id-H.id}if(e.unstable_now=void 0,typeof performance=="object"&&typeof performance.now=="function"){var c=performance;e.unstable_now=function(){return c.now()}}else{var f=Date,d=f.now();e.unstable_now=function(){return f.now()-d}}var h=[],p=[],y=1,v=null,b=3,_=!1,w=!1,S=!1,C=!1,j=typeof setTimeout=="function"?setTimeout:null,A=typeof clearTimeout=="function"?clearTimeout:null,R=typeof setImmediate<"u"?setImmediate:null;function T(D){for(var H=r(p);H!==null;){if(H.callback===null)s(p);else if(H.startTime<=D)s(p),H.sortIndex=H.expirationTime,n(h,H);else break;H=r(p)}}function O(D){if(S=!1,T(D),!w)if(r(h)!==null)w=!0,L||(L=!0,re());else{var H=r(p);H!==null&&Y(O,H.startTime-D)}}var L=!1,U=-1,z=5,ne=-1;function Q(){return C?!0:!(e.unstable_now()-neD&&Q());){var ie=v.callback;if(typeof ie=="function"){v.callback=null,b=v.priorityLevel;var k=ie(v.expirationTime<=D);if(D=e.unstable_now(),typeof k=="function"){v.callback=k,T(D),H=!0;break t}v===r(h)&&s(h),T(D)}else s(h);v=r(h)}if(v!==null)H=!0;else{var V=r(p);V!==null&&Y(O,V.startTime-D),H=!1}}break e}finally{v=null,b=I,_=!1}H=void 0}}finally{H?re():L=!1}}}var re;if(typeof R=="function")re=function(){R(Z)};else if(typeof MessageChannel<"u"){var B=new MessageChannel,P=B.port2;B.port1.onmessage=Z,re=function(){P.postMessage(null)}}else re=function(){j(Z,0)};function Y(D,H){U=j(function(){D(e.unstable_now())},H)}e.unstable_IdlePriority=5,e.unstable_ImmediatePriority=1,e.unstable_LowPriority=4,e.unstable_NormalPriority=3,e.unstable_Profiling=null,e.unstable_UserBlockingPriority=2,e.unstable_cancelCallback=function(D){D.callback=null},e.unstable_forceFrameRate=function(D){0>D||125ie?(D.sortIndex=I,n(p,D),r(h)===null&&D===r(p)&&(S?(A(U),U=-1):S=!0,Y(O,I-ie))):(D.sortIndex=k,n(h,D),w||_||(w=!0,L||(L=!0,re()))),D},e.unstable_shouldYield=Q,e.unstable_wrapCallback=function(D){var H=b;return function(){var I=b;b=H;try{return D.apply(this,arguments)}finally{b=I}}}})(xh)),xh}var R0;function bN(){return R0||(R0=1,vh.exports=xN()),vh.exports}var bh={exports:{}},Ne={};var k0;function _N(){if(k0)return Ne;k0=1;var e=Symbol.for("react.transitional.element"),n=Symbol.for("react.portal"),r=Symbol.for("react.fragment"),s=Symbol.for("react.strict_mode"),l=Symbol.for("react.profiler"),c=Symbol.for("react.consumer"),f=Symbol.for("react.context"),d=Symbol.for("react.forward_ref"),h=Symbol.for("react.suspense"),p=Symbol.for("react.memo"),y=Symbol.for("react.lazy"),v=Symbol.iterator;function b(k){return k===null||typeof k!="object"?null:(k=v&&k[v]||k["@@iterator"],typeof k=="function"?k:null)}var _={isMounted:function(){return!1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},w=Object.assign,S={};function C(k,V,ae){this.props=k,this.context=V,this.refs=S,this.updater=ae||_}C.prototype.isReactComponent={},C.prototype.setState=function(k,V){if(typeof k!="object"&&typeof k!="function"&&k!=null)throw Error("takes an object of state variables to update or a function which returns an object of state variables.");this.updater.enqueueSetState(this,k,V,"setState")},C.prototype.forceUpdate=function(k){this.updater.enqueueForceUpdate(this,k,"forceUpdate")};function j(){}j.prototype=C.prototype;function A(k,V,ae){this.props=k,this.context=V,this.refs=S,this.updater=ae||_}var R=A.prototype=new j;R.constructor=A,w(R,C.prototype),R.isPureReactComponent=!0;var T=Array.isArray,O={H:null,A:null,T:null,S:null,V:null},L=Object.prototype.hasOwnProperty;function U(k,V,ae,K,ce,de){return ae=de.ref,{$$typeof:e,type:k,key:V,ref:ae!==void 0?ae:null,props:de}}function z(k,V){return U(k.type,V,void 0,void 0,void 0,k.props)}function ne(k){return typeof k=="object"&&k!==null&&k.$$typeof===e}function Q(k){var V={"=":"=0",":":"=2"};return"$"+k.replace(/[=:]/g,function(ae){return V[ae]})}var Z=/\/+/g;function re(k,V){return typeof k=="object"&&k!==null&&k.key!=null?Q(""+k.key):V.toString(36)}function B(){}function P(k){switch(k.status){case"fulfilled":return k.value;case"rejected":throw k.reason;default:switch(typeof k.status=="string"?k.then(B,B):(k.status="pending",k.then(function(V){k.status==="pending"&&(k.status="fulfilled",k.value=V)},function(V){k.status==="pending"&&(k.status="rejected",k.reason=V)})),k.status){case"fulfilled":return k.value;case"rejected":throw k.reason}}throw k}function Y(k,V,ae,K,ce){var de=typeof k;(de==="undefined"||de==="boolean")&&(k=null);var te=!1;if(k===null)te=!0;else switch(de){case"bigint":case"string":case"number":te=!0;break;case"object":switch(k.$$typeof){case e:case n:te=!0;break;case y:return te=k._init,Y(te(k._payload),V,ae,K,ce)}}if(te)return ce=ce(k),te=K===""?"."+re(k,0):K,T(ce)?(ae="",te!=null&&(ae=te.replace(Z,"$&/")+"/"),Y(ce,V,ae,"",function(ye){return ye})):ce!=null&&(ne(ce)&&(ce=z(ce,ae+(ce.key==null||k&&k.key===ce.key?"":(""+ce.key).replace(Z,"$&/")+"/")+te)),V.push(ce)),1;te=0;var ue=K===""?".":K+":";if(T(k))for(var he=0;he"u"||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!="function"))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(e)}catch(n){console.error(n)}}return e(),_h.exports=wN(),_h.exports}var L0;function SN(){if(L0)return jo;L0=1;var e=bN(),n=zu(),r=a1();function s(i){var a="https://react.dev/errors/"+i;if(1k||(i.current=ie[k],ie[k]=null,k--)}function K(i,a){k++,ie[k]=i.current,i.current=a}var ce=V(null),de=V(null),te=V(null),ue=V(null);function he(i,a){switch(K(te,a),K(de,i),K(ce,null),a.nodeType){case 9:case 11:i=(i=a.documentElement)&&(i=i.namespaceURI)?r0(i):0;break;default:if(i=a.tagName,a=a.namespaceURI)a=r0(a),i=i0(a,i);else switch(i){case"svg":i=1;break;case"math":i=2;break;default:i=0}}ae(ce),K(ce,i)}function ye(){ae(ce),ae(de),ae(te)}function De(i){i.memoizedState!==null&&K(ue,i);var a=ce.current,o=i0(a,i.type);a!==o&&(K(de,i),K(ce,o))}function Pe(i){de.current===i&&(ae(ce),ae(de)),ue.current===i&&(ae(ue),vo._currentValue=I)}var Ye=Object.prototype.hasOwnProperty,Rt=e.unstable_scheduleCallback,mt=e.unstable_cancelCallback,Ss=e.unstable_shouldYield,js=e.unstable_requestPaint,Qt=e.unstable_now,X2=e.unstable_getCurrentPriorityLevel,Lp=e.unstable_ImmediatePriority,Up=e.unstable_UserBlockingPriority,ml=e.unstable_NormalPriority,Z2=e.unstable_LowPriority,qp=e.unstable_IdlePriority,W2=e.log,J2=e.unstable_setDisableYieldValue,Ns=null,tn=null;function Gr(i){if(typeof W2=="function"&&J2(i),tn&&typeof tn.setStrictMode=="function")try{tn.setStrictMode(Ns,i)}catch{}}var nn=Math.clz32?Math.clz32:nS,eS=Math.log,tS=Math.LN2;function nS(i){return i>>>=0,i===0?32:31-(eS(i)/tS|0)|0}var pl=256,gl=4194304;function Di(i){var a=i&42;if(a!==0)return a;switch(i&-i){case 1:return 1;case 2:return 2;case 4:return 4;case 8:return 8;case 16:return 16;case 32:return 32;case 64:return 64;case 128:return 128;case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return i&4194048;case 4194304:case 8388608:case 16777216:case 33554432:return i&62914560;case 67108864:return 67108864;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 0;default:return i}}function yl(i,a,o){var u=i.pendingLanes;if(u===0)return 0;var g=0,x=i.suspendedLanes,N=i.pingedLanes;i=i.warmLanes;var M=u&134217727;return M!==0?(u=M&~x,u!==0?g=Di(u):(N&=M,N!==0?g=Di(N):o||(o=M&~i,o!==0&&(g=Di(o))))):(M=u&~x,M!==0?g=Di(M):N!==0?g=Di(N):o||(o=u&~i,o!==0&&(g=Di(o)))),g===0?0:a!==0&&a!==g&&(a&x)===0&&(x=g&-g,o=a&-a,x>=o||x===32&&(o&4194048)!==0)?a:g}function Es(i,a){return(i.pendingLanes&~(i.suspendedLanes&~i.pingedLanes)&a)===0}function rS(i,a){switch(i){case 1:case 2:case 4:case 8:case 64:return a+250;case 16:case 32:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return a+5e3;case 4194304:case 8388608:case 16777216:case 33554432:return-1;case 67108864:case 134217728:case 268435456:case 536870912:case 1073741824:return-1;default:return-1}}function Hp(){var i=pl;return pl<<=1,(pl&4194048)===0&&(pl=256),i}function Bp(){var i=gl;return gl<<=1,(gl&62914560)===0&&(gl=4194304),i}function af(i){for(var a=[],o=0;31>o;o++)a.push(i);return a}function Cs(i,a){i.pendingLanes|=a,a!==268435456&&(i.suspendedLanes=0,i.pingedLanes=0,i.warmLanes=0)}function iS(i,a,o,u,g,x){var N=i.pendingLanes;i.pendingLanes=o,i.suspendedLanes=0,i.pingedLanes=0,i.warmLanes=0,i.expiredLanes&=o,i.entangledLanes&=o,i.errorRecoveryDisabledLanes&=o,i.shellSuspendCounter=0;var M=i.entanglements,q=i.expirationTimes,W=i.hiddenUpdates;for(o=N&~o;0)":-1g||q[u]!==W[g]){var se=` `+q[u].replace(" at new "," at ");return i.displayName&&se.includes("")&&(se=se.replace("",i.displayName)),se}while(1<=u&&0<=g);break}}}finally{ff=!1,Error.prepareStackTrace=o}return(o=i?i.displayName||i.name:"")?xa(o):""}function uS(i){switch(i.tag){case 26:case 27:case 5:return xa(i.type);case 16:return xa("Lazy");case 13:return xa("Suspense");case 19:return xa("SuspenseList");case 0:case 15:return df(i.type,!1);case 11:return df(i.type.render,!1);case 1:return df(i.type,!0);case 31:return xa("Activity");default:return""}}function Zp(i){try{var a="";do a+=uS(i),i=i.return;while(i);return a}catch(o){return` Error generating stack: `+o.message+` -`+o.stack}}function gn(i){switch(typeof i){case"bigint":case"boolean":case"number":case"string":case"undefined":return i;case"object":return i;default:return""}}function Wp(i){var a=i.type;return(i=i.nodeName)&&i.toLowerCase()==="input"&&(a==="checkbox"||a==="radio")}function fS(i){var a=Wp(i)?"checked":"value",o=Object.getOwnPropertyDescriptor(i.constructor.prototype,a),u=""+i[a];if(!i.hasOwnProperty(a)&&typeof o<"u"&&typeof o.get=="function"&&typeof o.set=="function"){var g=o.get,x=o.set;return Object.defineProperty(i,a,{configurable:!0,get:function(){return g.call(this)},set:function(N){u=""+N,x.call(this,N)}}),Object.defineProperty(i,a,{enumerable:o.enumerable}),{getValue:function(){return u},setValue:function(N){u=""+N},stopTracking:function(){i._valueTracker=null,delete i[a]}}}}function bl(i){i._valueTracker||(i._valueTracker=fS(i))}function Jp(i){if(!i)return!1;var a=i._valueTracker;if(!a)return!0;var o=a.getValue(),u="";return i&&(u=Wp(i)?i.checked?"true":"false":i.value),i=u,i!==o?(a.setValue(i),!0):!1}function _l(i){if(i=i||(typeof document<"u"?document:void 0),typeof i>"u")return null;try{return i.activeElement||i.body}catch{return i.body}}var dS=/[\n"\\]/g;function yn(i){return i.replace(dS,function(a){return"\\"+a.charCodeAt(0).toString(16)+" "})}function hf(i,a,o,u,g,x,N,M){i.name="",N!=null&&typeof N!="function"&&typeof N!="symbol"&&typeof N!="boolean"?i.type=N:i.removeAttribute("type"),a!=null?N==="number"?(a===0&&i.value===""||i.value!=a)&&(i.value=""+gn(a)):i.value!==""+gn(a)&&(i.value=""+gn(a)):N!=="submit"&&N!=="reset"||i.removeAttribute("value"),a!=null?mf(i,N,gn(a)):o!=null?mf(i,N,gn(o)):u!=null&&i.removeAttribute("value"),g==null&&x!=null&&(i.defaultChecked=!!x),g!=null&&(i.checked=g&&typeof g!="function"&&typeof g!="symbol"),M!=null&&typeof M!="function"&&typeof M!="symbol"&&typeof M!="boolean"?i.name=""+gn(M):i.removeAttribute("name")}function eg(i,a,o,u,g,x,N,M){if(x!=null&&typeof x!="function"&&typeof x!="symbol"&&typeof x!="boolean"&&(i.type=x),a!=null||o!=null){if(!(x!=="submit"&&x!=="reset"||a!=null))return;o=o!=null?""+gn(o):"",a=a!=null?""+gn(a):o,M||a===i.value||(i.value=a),i.defaultValue=a}u=u??g,u=typeof u!="function"&&typeof u!="symbol"&&!!u,i.checked=M?i.checked:!!u,i.defaultChecked=!!u,N!=null&&typeof N!="function"&&typeof N!="symbol"&&typeof N!="boolean"&&(i.name=N)}function mf(i,a,o){a==="number"&&_l(i.ownerDocument)===i||i.defaultValue===""+o||(i.defaultValue=""+o)}function ba(i,a,o,u){if(i=i.options,a){a={};for(var g=0;g"u"||typeof window.document>"u"||typeof window.document.createElement>"u"),xf=!1;if(fr)try{var Os={};Object.defineProperty(Os,"passive",{get:function(){xf=!0}}),window.addEventListener("test",Os,Os),window.removeEventListener("test",Os,Os)}catch{xf=!1}var Ir=null,bf=null,Sl=null;function og(){if(Sl)return Sl;var i,a=bf,o=a.length,u,g="value"in Ir?Ir.value:Ir.textContent,x=g.length;for(i=0;i=Ds),hg=" ",mg=!1;function pg(i,a){switch(i){case"keyup":return HS.indexOf(a.keyCode)!==-1;case"keydown":return a.keyCode!==229;case"keypress":case"mousedown":case"focusout":return!0;default:return!1}}function gg(i){return i=i.detail,typeof i=="object"&&"data"in i?i.data:null}var ja=!1;function GS(i,a){switch(i){case"compositionend":return gg(a);case"keypress":return a.which!==32?null:(mg=!0,hg);case"textInput":return i=a.data,i===hg&&mg?null:i;default:return null}}function FS(i,a){if(ja)return i==="compositionend"||!Nf&&pg(i,a)?(i=og(),Sl=bf=Ir=null,ja=!1,i):null;switch(i){case"paste":return null;case"keypress":if(!(a.ctrlKey||a.altKey||a.metaKey)||a.ctrlKey&&a.altKey){if(a.char&&1=a)return{node:o,offset:a-i};i=u}e:{for(;o;){if(o.nextSibling){o=o.nextSibling;break e}o=o.parentNode}o=void 0}o=jg(o)}}function Eg(i,a){return i&&a?i===a?!0:i&&i.nodeType===3?!1:a&&a.nodeType===3?Eg(i,a.parentNode):"contains"in i?i.contains(a):i.compareDocumentPosition?!!(i.compareDocumentPosition(a)&16):!1:!1}function Cg(i){i=i!=null&&i.ownerDocument!=null&&i.ownerDocument.defaultView!=null?i.ownerDocument.defaultView:window;for(var a=_l(i.document);a instanceof i.HTMLIFrameElement;){try{var o=typeof a.contentWindow.location.href=="string"}catch{o=!1}if(o)i=a.contentWindow;else break;a=_l(i.document)}return a}function Tf(i){var a=i&&i.nodeName&&i.nodeName.toLowerCase();return a&&(a==="input"&&(i.type==="text"||i.type==="search"||i.type==="tel"||i.type==="url"||i.type==="password")||a==="textarea"||i.contentEditable==="true")}var ZS=fr&&"documentMode"in document&&11>=document.documentMode,Na=null,Af=null,Us=null,Mf=!1;function Tg(i,a,o){var u=o.window===o?o.document:o.nodeType===9?o:o.ownerDocument;Mf||Na==null||Na!==_l(u)||(u=Na,"selectionStart"in u&&Tf(u)?u={start:u.selectionStart,end:u.selectionEnd}:(u=(u.ownerDocument&&u.ownerDocument.defaultView||window).getSelection(),u={anchorNode:u.anchorNode,anchorOffset:u.anchorOffset,focusNode:u.focusNode,focusOffset:u.focusOffset}),Us&&Ls(Us,u)||(Us=u,u=hc(Af,"onSelect"),0>=N,g-=N,hr=1<<32-nn(a)+g|o<x?x:8;var N=D.T,M={};D.T=M,pd(i,!1,a,o);try{var q=g(),W=D.S;if(W!==null&&W(M,q),q!==null&&typeof q=="object"&&typeof q.then=="function"){var se=sj(q,u);Js(i,a,se,cn(i))}else Js(i,a,u,cn(i))}catch(le){Js(i,a,{then:function(){},status:"rejected",reason:le},cn())}finally{H.p=x,D.T=N}}function fj(){}function hd(i,a,o,u){if(i.tag!==5)throw Error(s(476));var g=Ay(i).queue;Ty(i,g,a,I,o===null?fj:function(){return My(i),o(u)})}function Ay(i){var a=i.memoizedState;if(a!==null)return a;a={memoizedState:I,baseState:I,baseQueue:null,queue:{pending:null,lanes:0,dispatch:null,lastRenderedReducer:yr,lastRenderedState:I},next:null};var o={};return a.next={memoizedState:o,baseState:o,baseQueue:null,queue:{pending:null,lanes:0,dispatch:null,lastRenderedReducer:yr,lastRenderedState:o},next:null},i.memoizedState=a,i=i.alternate,i!==null&&(i.memoizedState=a),a}function My(i){var a=Ay(i).next.queue;Js(i,a,{},cn())}function md(){return Ut(vo)}function Oy(){return vt().memoizedState}function Ry(){return vt().memoizedState}function dj(i){for(var a=i.return;a!==null;){switch(a.tag){case 24:case 3:var o=cn();i=Qr(o);var u=Vr(a,i,o);u!==null&&(un(u,a,o),Qs(u,a,o)),a={cache:If()},i.payload=a;return}a=a.return}}function hj(i,a,o){var u=cn();o={lane:u,revertLane:0,action:o,hasEagerState:!1,eagerState:null,next:null},Ql(i)?Dy(a,o):(o=Df(i,a,o,u),o!==null&&(un(o,i,u),zy(o,a,u)))}function ky(i,a,o){var u=cn();Js(i,a,o,u)}function Js(i,a,o,u){var g={lane:u,revertLane:0,action:o,hasEagerState:!1,eagerState:null,next:null};if(Ql(i))Dy(a,g);else{var x=i.alternate;if(i.lanes===0&&(x===null||x.lanes===0)&&(x=a.lastRenderedReducer,x!==null))try{var N=a.lastRenderedState,M=x(N,o);if(g.hasEagerState=!0,g.eagerState=M,rn(M,N))return Ml(i,a,g,0),nt===null&&Al(),!1}catch{}if(o=Df(i,a,g,u),o!==null)return un(o,i,u),zy(o,a,u),!0}return!1}function pd(i,a,o,u){if(u={lane:2,revertLane:Qd(),action:u,hasEagerState:!1,eagerState:null,next:null},Ql(i)){if(a)throw Error(s(479))}else a=Df(i,o,u,2),a!==null&&un(a,i,2)}function Ql(i){var a=i.alternate;return i===Ce||a!==null&&a===Ce}function Dy(i,a){za=Bl=!0;var o=i.pending;o===null?a.next=a:(a.next=o.next,o.next=a),i.pending=a}function zy(i,a,o){if((o&4194048)!==0){var u=a.lanes;u&=i.pendingLanes,o|=u,a.lanes=o,Fp(i,o)}}var Vl={readContext:Ut,use:Fl,useCallback:pt,useContext:pt,useEffect:pt,useImperativeHandle:pt,useLayoutEffect:pt,useInsertionEffect:pt,useMemo:pt,useReducer:pt,useRef:pt,useState:pt,useDebugValue:pt,useDeferredValue:pt,useTransition:pt,useSyncExternalStore:pt,useId:pt,useHostTransitionStatus:pt,useFormState:pt,useActionState:pt,useOptimistic:pt,useMemoCache:pt,useCacheRefresh:pt},Py={readContext:Ut,use:Fl,useCallback:function(i,a){return Xt().memoizedState=[i,a===void 0?null:a],i},useContext:Ut,useEffect:xy,useImperativeHandle:function(i,a,o){o=o!=null?o.concat([i]):null,$l(4194308,4,Sy.bind(null,a,i),o)},useLayoutEffect:function(i,a){return $l(4194308,4,i,a)},useInsertionEffect:function(i,a){$l(4,2,i,a)},useMemo:function(i,a){var o=Xt();a=a===void 0?null:a;var u=i();if(Qi){Gr(!0);try{i()}finally{Gr(!1)}}return o.memoizedState=[u,a],u},useReducer:function(i,a,o){var u=Xt();if(o!==void 0){var g=o(a);if(Qi){Gr(!0);try{o(a)}finally{Gr(!1)}}}else g=a;return u.memoizedState=u.baseState=g,i={pending:null,lanes:0,dispatch:null,lastRenderedReducer:i,lastRenderedState:g},u.queue=i,i=i.dispatch=hj.bind(null,Ce,i),[u.memoizedState,i]},useRef:function(i){var a=Xt();return i={current:i},a.memoizedState=i},useState:function(i){i=cd(i);var a=i.queue,o=ky.bind(null,Ce,a);return a.dispatch=o,[i.memoizedState,o]},useDebugValue:fd,useDeferredValue:function(i,a){var o=Xt();return dd(o,i,a)},useTransition:function(){var i=cd(!1);return i=Ty.bind(null,Ce,i.queue,!0,!1),Xt().memoizedState=i,[!1,i]},useSyncExternalStore:function(i,a,o){var u=Ce,g=Xt();if(Fe){if(o===void 0)throw Error(s(407));o=o()}else{if(o=a(),nt===null)throw Error(s(349));(Le&124)!==0||ry(u,a,o)}g.memoizedState=o;var x={value:o,getSnapshot:a};return g.queue=x,xy(ay.bind(null,u,x,i),[i]),u.flags|=2048,La(9,Yl(),iy.bind(null,u,x,o,a),null),o},useId:function(){var i=Xt(),a=nt.identifierPrefix;if(Fe){var o=mr,u=hr;o=(u&~(1<<32-nn(u)-1)).toString(32)+o,a="«"+a+"R"+o,o=Gl++,0we?(Et=ve,ve=null):Et=ve.sibling;var Be=J(Y,ve,X[we],oe);if(Be===null){ve===null&&(ve=Et);break}i&&ve&&Be.alternate===null&&a(Y,ve),G=x(Be,G,we),Me===null?ge=Be:Me.sibling=Be,Me=Be,ve=Et}if(we===X.length)return o(Y,ve),Fe&&Bi(Y,we),ge;if(ve===null){for(;wewe?(Et=ve,ve=null):Et=ve.sibling;var fi=J(Y,ve,Be.value,oe);if(fi===null){ve===null&&(ve=Et);break}i&&ve&&fi.alternate===null&&a(Y,ve),G=x(fi,G,we),Me===null?ge=fi:Me.sibling=fi,Me=fi,ve=Et}if(Be.done)return o(Y,ve),Fe&&Bi(Y,we),ge;if(ve===null){for(;!Be.done;we++,Be=X.next())Be=le(Y,Be.value,oe),Be!==null&&(G=x(Be,G,we),Me===null?ge=Be:Me.sibling=Be,Me=Be);return Fe&&Bi(Y,we),ge}for(ve=u(ve);!Be.done;we++,Be=X.next())Be=ee(ve,Y,we,Be.value,oe),Be!==null&&(i&&Be.alternate!==null&&ve.delete(Be.key===null?we:Be.key),G=x(Be,G,we),Me===null?ge=Be:Me.sibling=Be,Me=Be);return i&&ve.forEach(function(pN){return a(Y,pN)}),Fe&&Bi(Y,we),ge}function We(Y,G,X,oe){if(typeof X=="object"&&X!==null&&X.type===w&&X.key===null&&(X=X.props.children),typeof X=="object"&&X!==null){switch(X.$$typeof){case b:e:{for(var ge=X.key;G!==null;){if(G.key===ge){if(ge=X.type,ge===w){if(G.tag===7){o(Y,G.sibling),oe=g(G,X.props.children),oe.return=Y,Y=oe;break e}}else if(G.elementType===ge||typeof ge=="object"&&ge!==null&&ge.$$typeof===z&&Uy(ge)===G.type){o(Y,G.sibling),oe=g(G,X.props),to(oe,X),oe.return=Y,Y=oe;break e}o(Y,G);break}else a(Y,G);G=G.sibling}X.type===w?(oe=qi(X.props.children,Y.mode,oe,X.key),oe.return=Y,Y=oe):(oe=Rl(X.type,X.key,X.props,null,Y.mode,oe),to(oe,X),oe.return=Y,Y=oe)}return N(Y);case _:e:{for(ge=X.key;G!==null;){if(G.key===ge)if(G.tag===4&&G.stateNode.containerInfo===X.containerInfo&&G.stateNode.implementation===X.implementation){o(Y,G.sibling),oe=g(G,X.children||[]),oe.return=Y,Y=oe;break e}else{o(Y,G);break}else a(Y,G);G=G.sibling}oe=Lf(X,Y.mode,oe),oe.return=Y,Y=oe}return N(Y);case z:return ge=X._init,X=ge(X._payload),We(Y,G,X,oe)}if($(X))return Se(Y,G,X,oe);if(re(X)){if(ge=re(X),typeof ge!="function")throw Error(s(150));return X=ge.call(X),_e(Y,G,X,oe)}if(typeof X.then=="function")return We(Y,G,Kl(X),oe);if(X.$$typeof===R)return We(Y,G,Pl(Y,X),oe);Xl(Y,X)}return typeof X=="string"&&X!==""||typeof X=="number"||typeof X=="bigint"?(X=""+X,G!==null&&G.tag===6?(o(Y,G.sibling),oe=g(G,X),oe.return=Y,Y=oe):(o(Y,G),oe=Pf(X,Y.mode,oe),oe.return=Y,Y=oe),N(Y)):o(Y,G)}return function(Y,G,X,oe){try{eo=0;var ge=We(Y,G,X,oe);return Ua=null,ge}catch(ve){if(ve===Ys||ve===Ul)throw ve;var Me=an(29,ve,null,Y.mode);return Me.lanes=oe,Me.return=Y,Me}}}var qa=qy(!0),Hy=qy(!1),wn=V(null),Kn=null;function Xr(i){var a=i.alternate;K(bt,bt.current&1),K(wn,i),Kn===null&&(a===null||Da.current!==null||a.memoizedState!==null)&&(Kn=i)}function By(i){if(i.tag===22){if(K(bt,bt.current),K(wn,i),Kn===null){var a=i.alternate;a!==null&&a.memoizedState!==null&&(Kn=i)}}else Zr()}function Zr(){K(bt,bt.current),K(wn,wn.current)}function vr(i){ae(wn),Kn===i&&(Kn=null),ae(bt)}var bt=V(0);function Zl(i){for(var a=i;a!==null;){if(a.tag===13){var o=a.memoizedState;if(o!==null&&(o=o.dehydrated,o===null||o.data==="$?"||ah(o)))return a}else if(a.tag===19&&a.memoizedProps.revealOrder!==void 0){if((a.flags&128)!==0)return a}else if(a.child!==null){a.child.return=a,a=a.child;continue}if(a===i)break;for(;a.sibling===null;){if(a.return===null||a.return===i)return null;a=a.return}a.sibling.return=a.return,a=a.sibling}return null}function gd(i,a,o,u){a=i.memoizedState,o=o(u,a),o=o==null?a:y({},a,o),i.memoizedState=o,i.lanes===0&&(i.updateQueue.baseState=o)}var yd={enqueueSetState:function(i,a,o){i=i._reactInternals;var u=cn(),g=Qr(u);g.payload=a,o!=null&&(g.callback=o),a=Vr(i,g,u),a!==null&&(un(a,i,u),Qs(a,i,u))},enqueueReplaceState:function(i,a,o){i=i._reactInternals;var u=cn(),g=Qr(u);g.tag=1,g.payload=a,o!=null&&(g.callback=o),a=Vr(i,g,u),a!==null&&(un(a,i,u),Qs(a,i,u))},enqueueForceUpdate:function(i,a){i=i._reactInternals;var o=cn(),u=Qr(o);u.tag=2,a!=null&&(u.callback=a),a=Vr(i,u,o),a!==null&&(un(a,i,o),Qs(a,i,o))}};function Gy(i,a,o,u,g,x,N){return i=i.stateNode,typeof i.shouldComponentUpdate=="function"?i.shouldComponentUpdate(u,x,N):a.prototype&&a.prototype.isPureReactComponent?!Ls(o,u)||!Ls(g,x):!0}function Fy(i,a,o,u){i=a.state,typeof a.componentWillReceiveProps=="function"&&a.componentWillReceiveProps(o,u),typeof a.UNSAFE_componentWillReceiveProps=="function"&&a.UNSAFE_componentWillReceiveProps(o,u),a.state!==i&&yd.enqueueReplaceState(a,a.state,null)}function Vi(i,a){var o=a;if("ref"in a){o={};for(var u in a)u!=="ref"&&(o[u]=a[u])}if(i=i.defaultProps){o===a&&(o=y({},o));for(var g in i)o[g]===void 0&&(o[g]=i[g])}return o}var Wl=typeof reportError=="function"?reportError:function(i){if(typeof window=="object"&&typeof window.ErrorEvent=="function"){var a=new window.ErrorEvent("error",{bubbles:!0,cancelable:!0,message:typeof i=="object"&&i!==null&&typeof i.message=="string"?String(i.message):String(i),error:i});if(!window.dispatchEvent(a))return}else if(typeof process=="object"&&typeof process.emit=="function"){process.emit("uncaughtException",i);return}console.error(i)};function Iy(i){Wl(i)}function Yy(i){console.error(i)}function $y(i){Wl(i)}function Jl(i,a){try{var o=i.onUncaughtError;o(a.value,{componentStack:a.stack})}catch(u){setTimeout(function(){throw u})}}function Qy(i,a,o){try{var u=i.onCaughtError;u(o.value,{componentStack:o.stack,errorBoundary:a.tag===1?a.stateNode:null})}catch(g){setTimeout(function(){throw g})}}function vd(i,a,o){return o=Qr(o),o.tag=3,o.payload={element:null},o.callback=function(){Jl(i,a)},o}function Vy(i){return i=Qr(i),i.tag=3,i}function Ky(i,a,o,u){var g=o.type.getDerivedStateFromError;if(typeof g=="function"){var x=u.value;i.payload=function(){return g(x)},i.callback=function(){Qy(a,o,u)}}var N=o.stateNode;N!==null&&typeof N.componentDidCatch=="function"&&(i.callback=function(){Qy(a,o,u),typeof g!="function"&&(ri===null?ri=new Set([this]):ri.add(this));var M=u.stack;this.componentDidCatch(u.value,{componentStack:M!==null?M:""})})}function pj(i,a,o,u,g){if(o.flags|=32768,u!==null&&typeof u=="object"&&typeof u.then=="function"){if(a=o.alternate,a!==null&&Gs(a,o,g,!0),o=wn.current,o!==null){switch(o.tag){case 13:return Kn===null?Gd():o.alternate===null&&ft===0&&(ft=3),o.flags&=-257,o.flags|=65536,o.lanes=g,u===Qf?o.flags|=16384:(a=o.updateQueue,a===null?o.updateQueue=new Set([u]):a.add(u),Id(i,u,g)),!1;case 22:return o.flags|=65536,u===Qf?o.flags|=16384:(a=o.updateQueue,a===null?(a={transitions:null,markerInstances:null,retryQueue:new Set([u])},o.updateQueue=a):(o=a.retryQueue,o===null?a.retryQueue=new Set([u]):o.add(u)),Id(i,u,g)),!1}throw Error(s(435,o.tag))}return Id(i,u,g),Gd(),!1}if(Fe)return a=wn.current,a!==null?((a.flags&65536)===0&&(a.flags|=256),a.flags|=65536,a.lanes=g,u!==Hf&&(i=Error(s(422),{cause:u}),Bs(vn(i,o)))):(u!==Hf&&(a=Error(s(423),{cause:u}),Bs(vn(a,o))),i=i.current.alternate,i.flags|=65536,g&=-g,i.lanes|=g,u=vn(u,o),g=vd(i.stateNode,u,g),Xf(i,g),ft!==4&&(ft=2)),!1;var x=Error(s(520),{cause:u});if(x=vn(x,o),lo===null?lo=[x]:lo.push(x),ft!==4&&(ft=2),a===null)return!0;u=vn(u,o),o=a;do{switch(o.tag){case 3:return o.flags|=65536,i=g&-g,o.lanes|=i,i=vd(o.stateNode,u,i),Xf(o,i),!1;case 1:if(a=o.type,x=o.stateNode,(o.flags&128)===0&&(typeof a.getDerivedStateFromError=="function"||x!==null&&typeof x.componentDidCatch=="function"&&(ri===null||!ri.has(x))))return o.flags|=65536,g&=-g,o.lanes|=g,g=Vy(g),Ky(g,i,o,u),Xf(o,g),!1}o=o.return}while(o!==null);return!1}var Xy=Error(s(461)),jt=!1;function kt(i,a,o,u){a.child=i===null?Hy(a,null,o,u):qa(a,i.child,o,u)}function Zy(i,a,o,u,g){o=o.render;var x=a.ref;if("ref"in u){var N={};for(var M in u)M!=="ref"&&(N[M]=u[M])}else N=u;return Yi(a),u=td(i,a,o,N,x,g),M=nd(),i!==null&&!jt?(rd(i,a,g),xr(i,a,g)):(Fe&&M&&Uf(a),a.flags|=1,kt(i,a,u,g),a.child)}function Wy(i,a,o,u,g){if(i===null){var x=o.type;return typeof x=="function"&&!zf(x)&&x.defaultProps===void 0&&o.compare===null?(a.tag=15,a.type=x,Jy(i,a,x,u,g)):(i=Rl(o.type,null,u,a,a.mode,g),i.ref=a.ref,i.return=a,a.child=i)}if(x=i.child,!Ed(i,g)){var N=x.memoizedProps;if(o=o.compare,o=o!==null?o:Ls,o(N,u)&&i.ref===a.ref)return xr(i,a,g)}return a.flags|=1,i=dr(x,u),i.ref=a.ref,i.return=a,a.child=i}function Jy(i,a,o,u,g){if(i!==null){var x=i.memoizedProps;if(Ls(x,u)&&i.ref===a.ref)if(jt=!1,a.pendingProps=u=x,Ed(i,g))(i.flags&131072)!==0&&(jt=!0);else return a.lanes=i.lanes,xr(i,a,g)}return xd(i,a,o,u,g)}function ev(i,a,o){var u=a.pendingProps,g=u.children,x=i!==null?i.memoizedState:null;if(u.mode==="hidden"){if((a.flags&128)!==0){if(u=x!==null?x.baseLanes|o:o,i!==null){for(g=a.child=i.child,x=0;g!==null;)x=x|g.lanes|g.childLanes,g=g.sibling;a.childLanes=x&~u}else a.childLanes=0,a.child=null;return tv(i,a,u,o)}if((o&536870912)!==0)a.memoizedState={baseLanes:0,cachePool:null},i!==null&&Ll(a,x!==null?x.cachePool:null),x!==null?Jg(a,x):Wf(),By(a);else return a.lanes=a.childLanes=536870912,tv(i,a,x!==null?x.baseLanes|o:o,o)}else x!==null?(Ll(a,x.cachePool),Jg(a,x),Zr(),a.memoizedState=null):(i!==null&&Ll(a,null),Wf(),Zr());return kt(i,a,g,o),a.child}function tv(i,a,o,u){var g=$f();return g=g===null?null:{parent:xt._currentValue,pool:g},a.memoizedState={baseLanes:o,cachePool:g},i!==null&&Ll(a,null),Wf(),By(a),i!==null&&Gs(i,a,u,!0),null}function ec(i,a){var o=a.ref;if(o===null)i!==null&&i.ref!==null&&(a.flags|=4194816);else{if(typeof o!="function"&&typeof o!="object")throw Error(s(284));(i===null||i.ref!==o)&&(a.flags|=4194816)}}function xd(i,a,o,u,g){return Yi(a),o=td(i,a,o,u,void 0,g),u=nd(),i!==null&&!jt?(rd(i,a,g),xr(i,a,g)):(Fe&&u&&Uf(a),a.flags|=1,kt(i,a,o,g),a.child)}function nv(i,a,o,u,g,x){return Yi(a),a.updateQueue=null,o=ty(a,u,o,g),ey(i),u=nd(),i!==null&&!jt?(rd(i,a,x),xr(i,a,x)):(Fe&&u&&Uf(a),a.flags|=1,kt(i,a,o,x),a.child)}function rv(i,a,o,u,g){if(Yi(a),a.stateNode===null){var x=Aa,N=o.contextType;typeof N=="object"&&N!==null&&(x=Ut(N)),x=new o(u,x),a.memoizedState=x.state!==null&&x.state!==void 0?x.state:null,x.updater=yd,a.stateNode=x,x._reactInternals=a,x=a.stateNode,x.props=u,x.state=a.memoizedState,x.refs={},Vf(a),N=o.contextType,x.context=typeof N=="object"&&N!==null?Ut(N):Aa,x.state=a.memoizedState,N=o.getDerivedStateFromProps,typeof N=="function"&&(gd(a,o,N,u),x.state=a.memoizedState),typeof o.getDerivedStateFromProps=="function"||typeof x.getSnapshotBeforeUpdate=="function"||typeof x.UNSAFE_componentWillMount!="function"&&typeof x.componentWillMount!="function"||(N=x.state,typeof x.componentWillMount=="function"&&x.componentWillMount(),typeof x.UNSAFE_componentWillMount=="function"&&x.UNSAFE_componentWillMount(),N!==x.state&&yd.enqueueReplaceState(x,x.state,null),Ks(a,u,x,g),Vs(),x.state=a.memoizedState),typeof x.componentDidMount=="function"&&(a.flags|=4194308),u=!0}else if(i===null){x=a.stateNode;var M=a.memoizedProps,q=Vi(o,M);x.props=q;var W=x.context,se=o.contextType;N=Aa,typeof se=="object"&&se!==null&&(N=Ut(se));var le=o.getDerivedStateFromProps;se=typeof le=="function"||typeof x.getSnapshotBeforeUpdate=="function",M=a.pendingProps!==M,se||typeof x.UNSAFE_componentWillReceiveProps!="function"&&typeof x.componentWillReceiveProps!="function"||(M||W!==N)&&Fy(a,x,u,N),$r=!1;var J=a.memoizedState;x.state=J,Ks(a,u,x,g),Vs(),W=a.memoizedState,M||J!==W||$r?(typeof le=="function"&&(gd(a,o,le,u),W=a.memoizedState),(q=$r||Gy(a,o,q,u,J,W,N))?(se||typeof x.UNSAFE_componentWillMount!="function"&&typeof x.componentWillMount!="function"||(typeof x.componentWillMount=="function"&&x.componentWillMount(),typeof x.UNSAFE_componentWillMount=="function"&&x.UNSAFE_componentWillMount()),typeof x.componentDidMount=="function"&&(a.flags|=4194308)):(typeof x.componentDidMount=="function"&&(a.flags|=4194308),a.memoizedProps=u,a.memoizedState=W),x.props=u,x.state=W,x.context=N,u=q):(typeof x.componentDidMount=="function"&&(a.flags|=4194308),u=!1)}else{x=a.stateNode,Kf(i,a),N=a.memoizedProps,se=Vi(o,N),x.props=se,le=a.pendingProps,J=x.context,W=o.contextType,q=Aa,typeof W=="object"&&W!==null&&(q=Ut(W)),M=o.getDerivedStateFromProps,(W=typeof M=="function"||typeof x.getSnapshotBeforeUpdate=="function")||typeof x.UNSAFE_componentWillReceiveProps!="function"&&typeof x.componentWillReceiveProps!="function"||(N!==le||J!==q)&&Fy(a,x,u,q),$r=!1,J=a.memoizedState,x.state=J,Ks(a,u,x,g),Vs();var ee=a.memoizedState;N!==le||J!==ee||$r||i!==null&&i.dependencies!==null&&zl(i.dependencies)?(typeof M=="function"&&(gd(a,o,M,u),ee=a.memoizedState),(se=$r||Gy(a,o,se,u,J,ee,q)||i!==null&&i.dependencies!==null&&zl(i.dependencies))?(W||typeof x.UNSAFE_componentWillUpdate!="function"&&typeof x.componentWillUpdate!="function"||(typeof x.componentWillUpdate=="function"&&x.componentWillUpdate(u,ee,q),typeof x.UNSAFE_componentWillUpdate=="function"&&x.UNSAFE_componentWillUpdate(u,ee,q)),typeof x.componentDidUpdate=="function"&&(a.flags|=4),typeof x.getSnapshotBeforeUpdate=="function"&&(a.flags|=1024)):(typeof x.componentDidUpdate!="function"||N===i.memoizedProps&&J===i.memoizedState||(a.flags|=4),typeof x.getSnapshotBeforeUpdate!="function"||N===i.memoizedProps&&J===i.memoizedState||(a.flags|=1024),a.memoizedProps=u,a.memoizedState=ee),x.props=u,x.state=ee,x.context=q,u=se):(typeof x.componentDidUpdate!="function"||N===i.memoizedProps&&J===i.memoizedState||(a.flags|=4),typeof x.getSnapshotBeforeUpdate!="function"||N===i.memoizedProps&&J===i.memoizedState||(a.flags|=1024),u=!1)}return x=u,ec(i,a),u=(a.flags&128)!==0,x||u?(x=a.stateNode,o=u&&typeof o.getDerivedStateFromError!="function"?null:x.render(),a.flags|=1,i!==null&&u?(a.child=qa(a,i.child,null,g),a.child=qa(a,null,o,g)):kt(i,a,o,g),a.memoizedState=x.state,i=a.child):i=xr(i,a,g),i}function iv(i,a,o,u){return Hs(),a.flags|=256,kt(i,a,o,u),a.child}var bd={dehydrated:null,treeContext:null,retryLane:0,hydrationErrors:null};function _d(i){return{baseLanes:i,cachePool:Yg()}}function wd(i,a,o){return i=i!==null?i.childLanes&~o:0,a&&(i|=Sn),i}function av(i,a,o){var u=a.pendingProps,g=!1,x=(a.flags&128)!==0,N;if((N=x)||(N=i!==null&&i.memoizedState===null?!1:(bt.current&2)!==0),N&&(g=!0,a.flags&=-129),N=(a.flags&32)!==0,a.flags&=-33,i===null){if(Fe){if(g?Xr(a):Zr(),Fe){var M=ut,q;if(q=M){e:{for(q=M,M=Vn;q.nodeType!==8;){if(!M){M=null;break e}if(q=zn(q.nextSibling),q===null){M=null;break e}}M=q}M!==null?(a.memoizedState={dehydrated:M,treeContext:Hi!==null?{id:hr,overflow:mr}:null,retryLane:536870912,hydrationErrors:null},q=an(18,null,null,0),q.stateNode=M,q.return=a,a.child=q,Gt=a,ut=null,q=!0):q=!1}q||Fi(a)}if(M=a.memoizedState,M!==null&&(M=M.dehydrated,M!==null))return ah(M)?a.lanes=32:a.lanes=536870912,null;vr(a)}return M=u.children,u=u.fallback,g?(Zr(),g=a.mode,M=tc({mode:"hidden",children:M},g),u=qi(u,g,o,null),M.return=a,u.return=a,M.sibling=u,a.child=M,g=a.child,g.memoizedState=_d(o),g.childLanes=wd(i,N,o),a.memoizedState=bd,u):(Xr(a),Sd(a,M))}if(q=i.memoizedState,q!==null&&(M=q.dehydrated,M!==null)){if(x)a.flags&256?(Xr(a),a.flags&=-257,a=jd(i,a,o)):a.memoizedState!==null?(Zr(),a.child=i.child,a.flags|=128,a=null):(Zr(),g=u.fallback,M=a.mode,u=tc({mode:"visible",children:u.children},M),g=qi(g,M,o,null),g.flags|=2,u.return=a,g.return=a,u.sibling=g,a.child=u,qa(a,i.child,null,o),u=a.child,u.memoizedState=_d(o),u.childLanes=wd(i,N,o),a.memoizedState=bd,a=g);else if(Xr(a),ah(M)){if(N=M.nextSibling&&M.nextSibling.dataset,N)var W=N.dgst;N=W,u=Error(s(419)),u.stack="",u.digest=N,Bs({value:u,source:null,stack:null}),a=jd(i,a,o)}else if(jt||Gs(i,a,o,!1),N=(o&i.childLanes)!==0,jt||N){if(N=nt,N!==null&&(u=o&-o,u=(u&42)!==0?1:sf(u),u=(u&(N.suspendedLanes|o))!==0?0:u,u!==0&&u!==q.retryLane))throw q.retryLane=u,Ta(i,u),un(N,i,u),Xy;M.data==="$?"||Gd(),a=jd(i,a,o)}else M.data==="$?"?(a.flags|=192,a.child=i.child,a=null):(i=q.treeContext,ut=zn(M.nextSibling),Gt=a,Fe=!0,Gi=null,Vn=!1,i!==null&&(bn[_n++]=hr,bn[_n++]=mr,bn[_n++]=Hi,hr=i.id,mr=i.overflow,Hi=a),a=Sd(a,u.children),a.flags|=4096);return a}return g?(Zr(),g=u.fallback,M=a.mode,q=i.child,W=q.sibling,u=dr(q,{mode:"hidden",children:u.children}),u.subtreeFlags=q.subtreeFlags&65011712,W!==null?g=dr(W,g):(g=qi(g,M,o,null),g.flags|=2),g.return=a,u.return=a,u.sibling=g,a.child=u,u=g,g=a.child,M=i.child.memoizedState,M===null?M=_d(o):(q=M.cachePool,q!==null?(W=xt._currentValue,q=q.parent!==W?{parent:W,pool:W}:q):q=Yg(),M={baseLanes:M.baseLanes|o,cachePool:q}),g.memoizedState=M,g.childLanes=wd(i,N,o),a.memoizedState=bd,u):(Xr(a),o=i.child,i=o.sibling,o=dr(o,{mode:"visible",children:u.children}),o.return=a,o.sibling=null,i!==null&&(N=a.deletions,N===null?(a.deletions=[i],a.flags|=16):N.push(i)),a.child=o,a.memoizedState=null,o)}function Sd(i,a){return a=tc({mode:"visible",children:a},i.mode),a.return=i,i.child=a}function tc(i,a){return i=an(22,i,null,a),i.lanes=0,i.stateNode={_visibility:1,_pendingMarkers:null,_retryCache:null,_transitions:null},i}function jd(i,a,o){return qa(a,i.child,null,o),i=Sd(a,a.pendingProps.children),i.flags|=2,a.memoizedState=null,i}function sv(i,a,o){i.lanes|=a;var u=i.alternate;u!==null&&(u.lanes|=a),Gf(i.return,a,o)}function Nd(i,a,o,u,g){var x=i.memoizedState;x===null?i.memoizedState={isBackwards:a,rendering:null,renderingStartTime:0,last:u,tail:o,tailMode:g}:(x.isBackwards=a,x.rendering=null,x.renderingStartTime=0,x.last=u,x.tail=o,x.tailMode=g)}function ov(i,a,o){var u=a.pendingProps,g=u.revealOrder,x=u.tail;if(kt(i,a,u.children,o),u=bt.current,(u&2)!==0)u=u&1|2,a.flags|=128;else{if(i!==null&&(i.flags&128)!==0)e:for(i=a.child;i!==null;){if(i.tag===13)i.memoizedState!==null&&sv(i,o,a);else if(i.tag===19)sv(i,o,a);else if(i.child!==null){i.child.return=i,i=i.child;continue}if(i===a)break e;for(;i.sibling===null;){if(i.return===null||i.return===a)break e;i=i.return}i.sibling.return=i.return,i=i.sibling}u&=1}switch(K(bt,u),g){case"forwards":for(o=a.child,g=null;o!==null;)i=o.alternate,i!==null&&Zl(i)===null&&(g=o),o=o.sibling;o=g,o===null?(g=a.child,a.child=null):(g=o.sibling,o.sibling=null),Nd(a,!1,g,o,x);break;case"backwards":for(o=null,g=a.child,a.child=null;g!==null;){if(i=g.alternate,i!==null&&Zl(i)===null){a.child=g;break}i=g.sibling,g.sibling=o,o=g,g=i}Nd(a,!0,o,null,x);break;case"together":Nd(a,!1,null,null,void 0);break;default:a.memoizedState=null}return a.child}function xr(i,a,o){if(i!==null&&(a.dependencies=i.dependencies),ni|=a.lanes,(o&a.childLanes)===0)if(i!==null){if(Gs(i,a,o,!1),(o&a.childLanes)===0)return null}else return null;if(i!==null&&a.child!==i.child)throw Error(s(153));if(a.child!==null){for(i=a.child,o=dr(i,i.pendingProps),a.child=o,o.return=a;i.sibling!==null;)i=i.sibling,o=o.sibling=dr(i,i.pendingProps),o.return=a;o.sibling=null}return a.child}function Ed(i,a){return(i.lanes&a)!==0?!0:(i=i.dependencies,!!(i!==null&&zl(i)))}function gj(i,a,o){switch(a.tag){case 3:he(a,a.stateNode.containerInfo),Yr(a,xt,i.memoizedState.cache),Hs();break;case 27:case 5:De(a);break;case 4:he(a,a.stateNode.containerInfo);break;case 10:Yr(a,a.type,a.memoizedProps.value);break;case 13:var u=a.memoizedState;if(u!==null)return u.dehydrated!==null?(Xr(a),a.flags|=128,null):(o&a.child.childLanes)!==0?av(i,a,o):(Xr(a),i=xr(i,a,o),i!==null?i.sibling:null);Xr(a);break;case 19:var g=(i.flags&128)!==0;if(u=(o&a.childLanes)!==0,u||(Gs(i,a,o,!1),u=(o&a.childLanes)!==0),g){if(u)return ov(i,a,o);a.flags|=128}if(g=a.memoizedState,g!==null&&(g.rendering=null,g.tail=null,g.lastEffect=null),K(bt,bt.current),u)break;return null;case 22:case 23:return a.lanes=0,ev(i,a,o);case 24:Yr(a,xt,i.memoizedState.cache)}return xr(i,a,o)}function lv(i,a,o){if(i!==null)if(i.memoizedProps!==a.pendingProps)jt=!0;else{if(!Ed(i,o)&&(a.flags&128)===0)return jt=!1,gj(i,a,o);jt=(i.flags&131072)!==0}else jt=!1,Fe&&(a.flags&1048576)!==0&&Ug(a,Dl,a.index);switch(a.lanes=0,a.tag){case 16:e:{i=a.pendingProps;var u=a.elementType,g=u._init;if(u=g(u._payload),a.type=u,typeof u=="function")zf(u)?(i=Vi(u,i),a.tag=1,a=rv(null,a,u,i,o)):(a.tag=0,a=xd(null,a,u,i,o));else{if(u!=null){if(g=u.$$typeof,g===T){a.tag=11,a=Zy(null,a,u,i,o);break e}else if(g===U){a.tag=14,a=Wy(null,a,u,i,o);break e}}throw a=P(u)||u,Error(s(306,a,""))}}return a;case 0:return xd(i,a,a.type,a.pendingProps,o);case 1:return u=a.type,g=Vi(u,a.pendingProps),rv(i,a,u,g,o);case 3:e:{if(he(a,a.stateNode.containerInfo),i===null)throw Error(s(387));u=a.pendingProps;var x=a.memoizedState;g=x.element,Kf(i,a),Ks(a,u,null,o);var N=a.memoizedState;if(u=N.cache,Yr(a,xt,u),u!==x.cache&&Ff(a,[xt],o,!0),Vs(),u=N.element,x.isDehydrated)if(x={element:u,isDehydrated:!1,cache:N.cache},a.updateQueue.baseState=x,a.memoizedState=x,a.flags&256){a=iv(i,a,u,o);break e}else if(u!==g){g=vn(Error(s(424)),a),Bs(g),a=iv(i,a,u,o);break e}else for(i=a.stateNode.containerInfo,i.nodeType===9?i=i.body:i=i.nodeName==="HTML"?i.ownerDocument.body:i,ut=zn(i.firstChild),Gt=a,Fe=!0,Gi=null,Vn=!0,o=Hy(a,null,u,o),a.child=o;o;)o.flags=o.flags&-3|4096,o=o.sibling;else{if(Hs(),u===g){a=xr(i,a,o);break e}kt(i,a,u,o)}a=a.child}return a;case 26:return ec(i,a),i===null?(o=d0(a.type,null,a.pendingProps,null))?a.memoizedState=o:Fe||(o=a.type,i=a.pendingProps,u=pc(te.current).createElement(o),u[Lt]=a,u[Vt]=i,zt(u,o,i),St(u),a.stateNode=u):a.memoizedState=d0(a.type,i.memoizedProps,a.pendingProps,i.memoizedState),null;case 27:return De(a),i===null&&Fe&&(u=a.stateNode=c0(a.type,a.pendingProps,te.current),Gt=a,Vn=!0,g=ut,si(a.type)?(sh=g,ut=zn(u.firstChild)):ut=g),kt(i,a,a.pendingProps.children,o),ec(i,a),i===null&&(a.flags|=4194304),a.child;case 5:return i===null&&Fe&&((g=u=ut)&&(u=Ij(u,a.type,a.pendingProps,Vn),u!==null?(a.stateNode=u,Gt=a,ut=zn(u.firstChild),Vn=!1,g=!0):g=!1),g||Fi(a)),De(a),g=a.type,x=a.pendingProps,N=i!==null?i.memoizedProps:null,u=x.children,nh(g,x)?u=null:N!==null&&nh(g,N)&&(a.flags|=32),a.memoizedState!==null&&(g=td(i,a,lj,null,null,o),vo._currentValue=g),ec(i,a),kt(i,a,u,o),a.child;case 6:return i===null&&Fe&&((i=o=ut)&&(o=Yj(o,a.pendingProps,Vn),o!==null?(a.stateNode=o,Gt=a,ut=null,i=!0):i=!1),i||Fi(a)),null;case 13:return av(i,a,o);case 4:return he(a,a.stateNode.containerInfo),u=a.pendingProps,i===null?a.child=qa(a,null,u,o):kt(i,a,u,o),a.child;case 11:return Zy(i,a,a.type,a.pendingProps,o);case 7:return kt(i,a,a.pendingProps,o),a.child;case 8:return kt(i,a,a.pendingProps.children,o),a.child;case 12:return kt(i,a,a.pendingProps.children,o),a.child;case 10:return u=a.pendingProps,Yr(a,a.type,u.value),kt(i,a,u.children,o),a.child;case 9:return g=a.type._context,u=a.pendingProps.children,Yi(a),g=Ut(g),u=u(g),a.flags|=1,kt(i,a,u,o),a.child;case 14:return Wy(i,a,a.type,a.pendingProps,o);case 15:return Jy(i,a,a.type,a.pendingProps,o);case 19:return ov(i,a,o);case 31:return u=a.pendingProps,o=a.mode,u={mode:u.mode,children:u.children},i===null?(o=tc(u,o),o.ref=a.ref,a.child=o,o.return=a,a=o):(o=dr(i.child,u),o.ref=a.ref,a.child=o,o.return=a,a=o),a;case 22:return ev(i,a,o);case 24:return Yi(a),u=Ut(xt),i===null?(g=$f(),g===null&&(g=nt,x=If(),g.pooledCache=x,x.refCount++,x!==null&&(g.pooledCacheLanes|=o),g=x),a.memoizedState={parent:u,cache:g},Vf(a),Yr(a,xt,g)):((i.lanes&o)!==0&&(Kf(i,a),Ks(a,null,null,o),Vs()),g=i.memoizedState,x=a.memoizedState,g.parent!==u?(g={parent:u,cache:u},a.memoizedState=g,a.lanes===0&&(a.memoizedState=a.updateQueue.baseState=g),Yr(a,xt,u)):(u=x.cache,Yr(a,xt,u),u!==g.cache&&Ff(a,[xt],o,!0))),kt(i,a,a.pendingProps.children,o),a.child;case 29:throw a.pendingProps}throw Error(s(156,a.tag))}function br(i){i.flags|=4}function cv(i,a){if(a.type!=="stylesheet"||(a.state.loading&4)!==0)i.flags&=-16777217;else if(i.flags|=16777216,!y0(a)){if(a=wn.current,a!==null&&((Le&4194048)===Le?Kn!==null:(Le&62914560)!==Le&&(Le&536870912)===0||a!==Kn))throw $s=Qf,$g;i.flags|=8192}}function nc(i,a){a!==null&&(i.flags|=4),i.flags&16384&&(a=i.tag!==22?Bp():536870912,i.lanes|=a,Fa|=a)}function no(i,a){if(!Fe)switch(i.tailMode){case"hidden":a=i.tail;for(var o=null;a!==null;)a.alternate!==null&&(o=a),a=a.sibling;o===null?i.tail=null:o.sibling=null;break;case"collapsed":o=i.tail;for(var u=null;o!==null;)o.alternate!==null&&(u=o),o=o.sibling;u===null?a||i.tail===null?i.tail=null:i.tail.sibling=null:u.sibling=null}}function ct(i){var a=i.alternate!==null&&i.alternate.child===i.child,o=0,u=0;if(a)for(var g=i.child;g!==null;)o|=g.lanes|g.childLanes,u|=g.subtreeFlags&65011712,u|=g.flags&65011712,g.return=i,g=g.sibling;else for(g=i.child;g!==null;)o|=g.lanes|g.childLanes,u|=g.subtreeFlags,u|=g.flags,g.return=i,g=g.sibling;return i.subtreeFlags|=u,i.childLanes=o,a}function yj(i,a,o){var u=a.pendingProps;switch(qf(a),a.tag){case 31:case 16:case 15:case 0:case 11:case 7:case 8:case 12:case 9:case 14:return ct(a),null;case 1:return ct(a),null;case 3:return o=a.stateNode,u=null,i!==null&&(u=i.memoizedState.cache),a.memoizedState.cache!==u&&(a.flags|=2048),gr(xt),ye(),o.pendingContext&&(o.context=o.pendingContext,o.pendingContext=null),(i===null||i.child===null)&&(qs(a)?br(a):i===null||i.memoizedState.isDehydrated&&(a.flags&256)===0||(a.flags|=1024,Bg())),ct(a),null;case 26:return o=a.memoizedState,i===null?(br(a),o!==null?(ct(a),cv(a,o)):(ct(a),a.flags&=-16777217)):o?o!==i.memoizedState?(br(a),ct(a),cv(a,o)):(ct(a),a.flags&=-16777217):(i.memoizedProps!==u&&br(a),ct(a),a.flags&=-16777217),null;case 27:Pe(a),o=te.current;var g=a.type;if(i!==null&&a.stateNode!=null)i.memoizedProps!==u&&br(a);else{if(!u){if(a.stateNode===null)throw Error(s(166));return ct(a),null}i=ce.current,qs(a)?qg(a):(i=c0(g,u,o),a.stateNode=i,br(a))}return ct(a),null;case 5:if(Pe(a),o=a.type,i!==null&&a.stateNode!=null)i.memoizedProps!==u&&br(a);else{if(!u){if(a.stateNode===null)throw Error(s(166));return ct(a),null}if(i=ce.current,qs(a))qg(a);else{switch(g=pc(te.current),i){case 1:i=g.createElementNS("http://www.w3.org/2000/svg",o);break;case 2:i=g.createElementNS("http://www.w3.org/1998/Math/MathML",o);break;default:switch(o){case"svg":i=g.createElementNS("http://www.w3.org/2000/svg",o);break;case"math":i=g.createElementNS("http://www.w3.org/1998/Math/MathML",o);break;case"script":i=g.createElement("div"),i.innerHTML=" + diff --git a/marm-mcp-server/marm_mcp_server/server.py b/marm-mcp-server/marm_mcp_server/server.py index 595522ab..ee23513d 100644 --- a/marm-mcp-server/marm_mcp_server/server.py +++ b/marm-mcp-server/marm_mcp_server/server.py @@ -5,7 +5,7 @@ FastAPI application, compliant with the MCP protocol via FastApiMCP. Author: Lyell - marm-memory -Version: 2.27.0 +Version: 2.28.0 """ import os diff --git a/marm-mcp-server/marm_mcp_server/services/docker_cli.py b/marm-mcp-server/marm_mcp_server/services/docker_cli.py new file mode 100644 index 00000000..944fa29a --- /dev/null +++ b/marm-mcp-server/marm_mcp_server/services/docker_cli.py @@ -0,0 +1,170 @@ +"""Docker parser registration and dispatch for the marm-memory product CLI.""" + +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path +from typing import Callable + + +def add_docker_commands( + subparsers: argparse._SubParsersAction, + add_run_arguments: Callable[[argparse.ArgumentParser], None], +) -> None: + """Register Docker commands while keeping the product parser concise.""" + docker = subparsers.add_parser("docker", help="Manage official MARM Docker images") + docker_sub = docker.add_subparsers(dest="docker_command", required=True) + docker_status = docker_sub.add_parser( + "status", help="Inspect a managed MARM container" + ) + docker_status.add_argument("--name", default="marm-mcp-server") + docker_pull = docker_sub.add_parser( + "pull", help="Pull an official image without starting it" + ) + docker_pull.add_argument("--tag", default="latest") + docker_run = docker_sub.add_parser( + "run", help="Create a managed MARM HTTP container" + ) + add_run_arguments(docker_run) + docker_run.add_argument("--dry-run", action="store_true") + docker_command = docker_sub.add_parser( + "command", help="Print the exact Docker HTTP command" + ) + add_run_arguments(docker_command) + docker_compose = docker_sub.add_parser( + "compose", help="Preview or write a safe Docker Compose configuration" + ) + add_run_arguments(docker_compose) + docker_compose.add_argument( + "--output", type=Path, default=Path.home() / ".marm" / "marm-compose.yaml" + ) + docker_compose.add_argument( + "--yes", + action="store_true", + help="Write the Compose file instead of previewing it", + ) + docker_stdio = docker_sub.add_parser( + "stdio-command", help="Print a Docker STDIO client command" + ) + docker_stdio.add_argument("--tag", default="latest") + docker_stdio.add_argument("--data-dir", type=Path, default=Path.home() / ".marm") + docker_stdio.add_argument("--client") + docker_logs = docker_sub.add_parser("logs", help="Read managed container logs") + docker_logs.add_argument("--name", default="marm-mcp-server") + docker_logs.add_argument("--follow", action="store_true") + docker_stop = docker_sub.add_parser("stop", help="Stop a managed MARM container") + docker_stop.add_argument("--name", default="marm-mcp-server") + docker_sub.add_parser( + "upgrade", help="Explain the current safe Docker upgrade path" + ) + docker_maintenance = docker_sub.add_parser("maintenance") + docker_maintenance_sub = docker_maintenance.add_subparsers( + dest="docker_maintenance_command", required=True + ) + docker_embeddings = docker_maintenance_sub.add_parser("embeddings") + docker_embeddings_sub = docker_embeddings.add_subparsers( + dest="docker_embeddings_command", required=True + ) + docker_migrate = docker_embeddings_sub.add_parser("migrate") + docker_migrate.add_argument("--tag", default="latest") + docker_migrate.add_argument("--data-dir", type=Path, default=Path.home() / ".marm") + docker_migrate.add_argument("--name", default="marm-mcp-server") + + +def dispatch_docker(args: argparse.Namespace, *, print_payload: Callable) -> int: + """Run one Docker command using the shared safe planner/executor.""" + from . import docker_commands + + if args.docker_command == "status": + print_payload(docker_commands.docker_status(args.name)) + return 0 + if args.docker_command == "pull": + print(f"Pulled {docker_commands.pull_image(args.tag)}") + return 0 + if args.docker_command in {"run", "command", "compose"}: + options = docker_commands.DockerRunOptions( + profile=args.profile, + port=args.port, + data_dir=args.data_dir, + name=args.name, + tag=args.tag, + repositories=tuple(args.repo), + pull=args.pull, + expose_network=args.expose_network, + rate_limit_rpm=args.rate_limit_rpm, + env_file=args.env_file, + memory=args.memory, + cpus=args.cpus, + ) + if args.docker_command == "compose": + if args.yes: + payload = docker_commands.write_compose_file(options, args.output) + print(f"Wrote Compose configuration: {payload['path']}") + print(docker_commands.shell_command(payload["command"])) + else: + payload = docker_commands.compose_document(options) + print(json.dumps(payload["document"], indent=2)) + print( + "Preview only. Re-run with --yes to write " + f"{args.output.expanduser().resolve()}." + ) + return 0 + if args.docker_command == "command" or getattr(args, "dry_run", False): + plan = docker_commands.build_run_plan(options, require_data_dir=False) + print(docker_commands.shell_command(plan["arguments"])) + print(f"Data: {plan['data_dir']}") + print(f"Key file: {plan['env_file']}") + for mapping in plan["repository_mappings"]: + print(f"Repository: {mapping}") + if options.expose_network: + print("Network exposure requested: configure a firewall and TLS proxy.") + return 0 + plan = docker_commands.run_container(options) + print(f"MARM Docker container ready: http://127.0.0.1:{args.port}/mcp") + if options.expose_network: + print("Network exposure is active: configure a firewall and TLS proxy.") + for mapping in plan["repository_mappings"]: + print(f"Repository available to index: {mapping}") + return 0 + if args.docker_command == "stdio-command": + plan = docker_commands.stdio_command(tag=args.tag, data_dir=args.data_dir) + print(docker_commands.shell_command(plan["arguments"])) + if args.client: + print( + f"Configure {args.client} with the command above as its STDIO transport." + ) + return 0 + if args.docker_command == "logs": + return docker_commands.docker_logs(args.name, follow=args.follow) + if args.docker_command == "stop": + print( + "MARM Docker container stopped." + if docker_commands.stop_container(args.name) + else "MARM Docker container is not present." + ) + return 0 + if args.docker_command == "upgrade": + raise RuntimeError( + "Docker upgrade is not automated yet because MARM will not recreate " + "a container without preserving and confirming its exact configuration. " + "Run `marm-memory docker pull`, inspect the container, then replace it " + "manually when ready." + ) + if ( + args.docker_command == "maintenance" + and args.docker_embeddings_command == "migrate" + ): + exit_code = docker_commands.migrate_embeddings( + tag=args.tag, data_dir=args.data_dir, name=args.name + ) + if exit_code == 0: + print("Docker embedding migration complete.") + else: + print( + f"Docker embedding migration exited with code {exit_code}.", + file=sys.stderr, + ) + return exit_code + return 2 diff --git a/marm-mcp-server/marm_mcp_server/services/docker_commands.py b/marm-mcp-server/marm_mcp_server/services/docker_commands.py new file mode 100644 index 00000000..aab5c484 --- /dev/null +++ b/marm-mcp-server/marm_mcp_server/services/docker_commands.py @@ -0,0 +1,466 @@ +"""Safe Docker command planning and execution for the product CLI.""" + +from __future__ import annotations + +import json +import os +import shlex +import subprocess +import time +import urllib.error +import urllib.request +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +from .key_management import initialize_managed_key, managed_key_path, read_managed_key + +DEFAULT_IMAGE_REPOSITORY = "lyellr88/marm-mcp-server" +DEFAULT_CONTAINER_NAME = "marm-mcp-server" +CONTAINER_DATA_DIR = "/home/marm/.marm" + + +class DockerCommandError(RuntimeError): + """A Docker command could not be planned or safely completed.""" + + +@dataclass(frozen=True) +class DockerRunOptions: + """Validated options shared by Docker run and command preview.""" + + profile: str = "standard" + port: int = 8001 + data_dir: Path = field(default_factory=lambda: Path.home() / ".marm") + name: str = DEFAULT_CONTAINER_NAME + tag: str = "latest" + repositories: tuple[Path, ...] = () + pull: bool = False + expose_network: bool = False + rate_limit_rpm: int | None = None + env_file: Path | None = None + memory: str | None = None + cpus: str | None = None + + +def managed_env_file() -> Path: + """Return the shared managed MARM key-file location.""" + return managed_key_path() + + +def image_reference(tag: str) -> str: + if not tag or any(char.isspace() for char in tag): + raise DockerCommandError("Docker image tag must be a non-empty single token.") + repository = os.environ.get("MARM_DOCKER_REPOSITORY", DEFAULT_IMAGE_REPOSITORY) + return f"{repository}:{tag}" + + +def _resolved_directory( + path: Path, *, label: str, create: bool, require_exists: bool = True +) -> Path: + resolved = path.expanduser().resolve() + if create: + resolved.mkdir(parents=True, exist_ok=True) + if require_exists and not resolved.is_dir(): + raise DockerCommandError(f"{label} must be an existing directory: {resolved}") + return resolved + + +def ensure_managed_env_file(path: Path | None = None) -> Path: + """Return an env file containing a key, creating only MARM's default one.""" + env_file = (path or managed_env_file()).expanduser().resolve() + if read_managed_key(env_file): + return env_file + if path is not None: + raise DockerCommandError( + f"{env_file} does not contain MARM_API_KEY. Add one or omit --env-file " + "to use MARM's managed key file." + ) + created_path, _created = initialize_managed_key(env_file) + return created_path + + +def _repository_mounts(repositories: tuple[Path, ...]) -> tuple[list[str], list[str]]: + arguments: list[str] = [] + mappings: list[str] = [] + for index, repository in enumerate(repositories, start=1): + resolved = _resolved_directory( + repository, label="Repository path", create=False + ) + target = f"/workspace/repo-{index}" + arguments.extend(["--mount", f"type=bind,src={resolved},dst={target},readonly"]) + mappings.append(f"{resolved} -> {target}") + return arguments, mappings + + +def build_run_plan( + options: DockerRunOptions, + *, + create_data_dir: bool = False, + require_data_dir: bool = True, +) -> dict[str, Any]: + """Build a deterministic Docker HTTP command without executing or writing secrets.""" + if not 1 <= options.port <= 65535: + raise DockerCommandError("--port must be between 1 and 65535.") + if options.profile not in {"standard", "swarm", "swarm-max", "trusted"}: + raise DockerCommandError( + "--profile must be standard, swarm, swarm-max, or trusted." + ) + if options.rate_limit_rpm is not None and options.rate_limit_rpm < 0: + raise DockerCommandError("--rate-limit-rpm must be 0 or greater.") + if not options.name or any(char.isspace() for char in options.name): + raise DockerCommandError("--name must be a non-empty Docker container name.") + + data_dir = _resolved_directory( + options.data_dir, + label="Data directory", + create=create_data_dir, + require_exists=require_data_dir, + ) + env_file = (options.env_file or managed_env_file()).expanduser().resolve() + host_binding = "0.0.0.0" if options.expose_network else "127.0.0.1" + arguments = [ + "docker", + "run", + "-d", + "--name", + options.name, + "--restart", + "unless-stopped", + "--label", + f"com.marm.profile={options.profile}", + "--mount", + f"type=bind,src={data_dir},dst={CONTAINER_DATA_DIR}", + "--env-file", + str(env_file), + "-e", + "SERVER_HOST=0.0.0.0", + "-p", + f"{host_binding}:{options.port}:8001", + ] + if options.pull: + arguments.extend(["--pull", "always"]) + if options.memory: + arguments.extend(["--memory", options.memory]) + if options.cpus: + arguments.extend(["--cpus", options.cpus]) + if options.rate_limit_rpm is not None: + arguments.extend(["-e", f"MARM_RATE_LIMIT_RPM={options.rate_limit_rpm}"]) + + profile_args = { + "standard": [], + "swarm": ["--swarm"], + "swarm-max": ["--swarm-max"], + "trusted": ["--trusted"], + }[options.profile] + repository_args, repository_mappings = _repository_mounts(options.repositories) + arguments.extend(repository_args) + arguments.append(image_reference(options.tag)) + arguments.extend(profile_args) + return { + "arguments": arguments, + "data_dir": str(data_dir), + "env_file": str(env_file), + "image": image_reference(options.tag), + "host_binding": host_binding, + "repository_mappings": repository_mappings, + } + + +def shell_command(arguments: list[str], *, windows: bool | None = None) -> str: + """Render command arguments using the host shell's quoting convention.""" + use_windows = os.name == "nt" if windows is None else windows + return subprocess.list2cmdline(arguments) if use_windows else shlex.join(arguments) + + +def _run( + arguments: list[str], *, check: bool = True +) -> subprocess.CompletedProcess[str]: + try: + result = subprocess.run(arguments, capture_output=True, text=True, check=False) + except OSError as exc: + raise DockerCommandError( + "Docker is not installed or is not available on PATH." + ) from exc + if check and result.returncode: + detail = (result.stderr or result.stdout).strip() or "Docker command failed." + raise DockerCommandError(detail) + return result + + +def container_inspect(name: str) -> dict[str, Any] | None: + result = _run(["docker", "container", "inspect", name], check=False) + if result.returncode: + return None + try: + payload = json.loads(result.stdout) + except json.JSONDecodeError as exc: + raise DockerCommandError( + "Docker returned invalid container inspection data." + ) from exc + return payload[0] if isinstance(payload, list) and payload else None + + +def _is_marm_container(payload: dict[str, Any]) -> bool: + config = payload.get("Config", {}) + image = str(config.get("Image", "")) + labels = config.get("Labels") or {} + return ( + image.startswith(DEFAULT_IMAGE_REPOSITORY) + or labels.get("mcp.name") == "marm-mcp-server" + ) + + +def docker_status(name: str = DEFAULT_CONTAINER_NAME) -> dict[str, Any]: + payload = container_inspect(name) + if payload is None: + return {"state": "absent", "name": name} + if not _is_marm_container(payload): + raise DockerCommandError(f"Container {name!r} is not a MARM container.") + state = payload.get("State", {}) + host_config = payload.get("HostConfig", {}) + mounts = payload.get("Mounts", []) + ports = (payload.get("NetworkSettings", {}) or {}).get("Ports", {}) + return { + "state": state.get("Status", "unknown"), + "health": (state.get("Health") or {}).get("Status", "unknown"), + "name": name, + "image": payload.get("Config", {}).get("Image", "unknown"), + "image_id": payload.get("Image", "unknown"), + "profile": (payload.get("Config", {}).get("Labels") or {}).get( + "com.marm.profile", "unknown" + ), + "ports": ports, + "restart_policy": (host_config.get("RestartPolicy") or {}).get("Name", ""), + "mounts": [ + {"source": mount.get("Source"), "destination": mount.get("Destination")} + for mount in mounts + if mount.get("Destination") == CONTAINER_DATA_DIR + or str(mount.get("Destination", "")).startswith("/workspace/") + ], + } + + +def pull_image(tag: str = "latest") -> str: + image = image_reference(tag) + _run(["docker", "pull", image]) + return image + + +def _wait_for_health(port: int, *, timeout: float = 30.0) -> None: + deadline = time.monotonic() + timeout + url = f"http://127.0.0.1:{port}/health" + while time.monotonic() < deadline: + try: + with urllib.request.urlopen(url, timeout=2) as response: + payload = json.load(response) + if payload.get("status") == "healthy": + return + except (urllib.error.URLError, OSError, ValueError): + time.sleep(0.5) + raise DockerCommandError( + "MARM container did not become healthy. Run `marm-memory docker logs --follow`." + ) + + +def run_container(options: DockerRunOptions) -> dict[str, Any]: + existing = container_inspect(options.name) + if existing is not None: + raise DockerCommandError( + f"Container {options.name!r} already exists. Inspect it with " + f"`marm-memory docker status --name {options.name}` or stop it with " + f"`marm-memory docker stop --name {options.name}`; MARM will not replace it." + ) + plan = build_run_plan(options, create_data_dir=True) + ensure_managed_env_file(options.env_file) + _run(plan["arguments"]) + _wait_for_health(options.port) + return plan + + +def stdio_command( + *, tag: str = "latest", data_dir: Path | None = None +) -> dict[str, Any]: + resolved_data_dir = _resolved_directory( + data_dir or (Path.home() / ".marm"), label="Data directory", create=False + ) + arguments = [ + "docker", + "run", + "-i", + "--rm", + "--mount", + f"type=bind,src={resolved_data_dir},dst={CONTAINER_DATA_DIR}", + "--entrypoint", + "marm-mcp-stdio", + image_reference(tag), + ] + return { + "arguments": arguments, + "data_dir": str(resolved_data_dir), + "image": image_reference(tag), + } + + +def compose_document(options: DockerRunOptions) -> dict[str, Any]: + """Build a Compose configuration with the same safety defaults as docker run.""" + plan = build_run_plan(options, require_data_dir=False) + command = ["docker", "compose", "up", "-d", "--pull", "always"] + profile_args = { + "standard": [], + "swarm": ["--swarm"], + "swarm-max": ["--swarm-max"], + "trusted": ["--trusted"], + }[options.profile] + environment = {"SERVER_HOST": "0.0.0.0"} + if options.rate_limit_rpm is not None: + environment["MARM_RATE_LIMIT_RPM"] = str(options.rate_limit_rpm) + service: dict[str, Any] = { + "image": plan["image"], + "container_name": options.name, + "restart": "unless-stopped", + "ports": [f"{plan['host_binding']}:{options.port}:8001"], + "env_file": [plan["env_file"]], + "environment": environment, + "volumes": [ + { + "type": "bind", + "source": plan["data_dir"], + "target": CONTAINER_DATA_DIR, + } + ], + "labels": {"com.marm.profile": options.profile}, + } + if profile_args: + service["command"] = profile_args + if options.memory or options.cpus: + service["deploy"] = {"resources": {"limits": {}}} + limits = service["deploy"]["resources"]["limits"] + if options.memory: + limits["memory"] = options.memory + if options.cpus: + limits["cpus"] = options.cpus + for mapping in plan["repository_mappings"]: + source, target = mapping.split(" -> ", 1) + service["volumes"].append( + {"type": "bind", "source": source, "target": target, "read_only": True} + ) + document = {"services": {"marm-mcp-server": service}} + return {"document": document, "plan": plan, "command": command} + + +def _yaml_scalar(value: object) -> str: + if value is None: + return "null" + if value is True: + return "true" + if value is False: + return "false" + if isinstance(value, (int, float)): + return str(value) + return json.dumps(str(value)) + + +def compose_yaml(document: dict[str, Any]) -> str: + """Serialize the small generated Compose structure without a YAML dependency.""" + + def render(value: object, indent: int = 0) -> list[str]: + prefix = " " * indent + if isinstance(value, dict): + lines: list[str] = [] + for key, child in value.items(): + if isinstance(child, (dict, list)): + lines.append(f"{prefix}{key}:") + lines.extend(render(child, indent + 2)) + else: + lines.append(f"{prefix}{key}: {_yaml_scalar(child)}") + return lines + if isinstance(value, list): + lines = [] + for child in value: + if isinstance(child, (dict, list)): + lines.append(f"{prefix}-") + lines.extend(render(child, indent + 2)) + else: + lines.append(f"{prefix}- {_yaml_scalar(child)}") + return lines + return [f"{prefix}{_yaml_scalar(value)}"] + + return "\n".join(render(document)) + "\n" + + +def write_compose_file(options: DockerRunOptions, output: Path) -> dict[str, Any]: + """Write a new Compose file only after validating data and managed auth.""" + resolved_output = output.expanduser().resolve() + if resolved_output.exists(): + raise DockerCommandError( + f"Compose file already exists: {resolved_output}. MARM will not overwrite it." + ) + build_run_plan(options, create_data_dir=True) + ensure_managed_env_file(options.env_file) + payload = compose_document(options) + resolved_output.parent.mkdir(parents=True, exist_ok=True) + resolved_output.write_text(compose_yaml(payload["document"]), encoding="utf-8") + payload["path"] = str(resolved_output) + payload["command"] = [ + "docker", + "compose", + "-f", + str(resolved_output), + *payload["command"][2:], + ] + return payload + + +def migrate_embeddings( + *, + tag: str = "latest", + data_dir: Path | None = None, + name: str = DEFAULT_CONTAINER_NAME, +) -> int: + status = docker_status(name) + if status["state"] not in {"absent", "exited", "created", "dead"}: + raise DockerCommandError( + "Embedding migration requires the managed HTTP container to be stopped. " + f"Run `marm-memory docker stop --name {name}` first." + ) + resolved_data_dir = _resolved_directory( + data_dir or (Path.home() / ".marm"), label="Data directory", create=False + ) + arguments = [ + "docker", + "run", + "--rm", + "--mount", + f"type=bind,src={resolved_data_dir},dst={CONTAINER_DATA_DIR}", + image_reference(tag), + "--migrate-embeddings", + ] + try: + return subprocess.call(arguments) + except OSError as exc: + raise DockerCommandError( + "Docker is not installed or is not available on PATH." + ) from exc + + +def docker_logs(name: str, *, follow: bool = False) -> int: + arguments = ["docker", "logs"] + if follow: + arguments.append("--follow") + arguments.append(name) + try: + return subprocess.call(arguments) + except OSError as exc: + raise DockerCommandError( + "Docker is not installed or is not available on PATH." + ) from exc + + +def stop_container(name: str) -> bool: + payload = container_inspect(name) + if payload is None: + return False + if not _is_marm_container(payload): + raise DockerCommandError(f"Container {name!r} is not a MARM container.") + _run(["docker", "stop", name]) + return True diff --git a/marm-mcp-server/marm_mcp_server/services/key_management.py b/marm-mcp-server/marm_mcp_server/services/key_management.py new file mode 100644 index 00000000..0838ccc6 --- /dev/null +++ b/marm-mcp-server/marm_mcp_server/services/key_management.py @@ -0,0 +1,68 @@ +"""Persistent local API-key operations for the product CLI.""" + +from __future__ import annotations + +import sys +from pathlib import Path + +from ..utils.security import generate_api_key + + +def managed_key_path() -> Path: + """Return the managed local env file without creating it.""" + return Path.home() / ".marm" / ".env" + + +def read_managed_key(path: Path | None = None) -> str: + """Read the managed key without exposing parsing details to CLI callers.""" + try: + for raw_line in ( + (path or managed_key_path()).read_text(encoding="utf-8").splitlines() + ): + line = raw_line.strip() + if not line or line.startswith("#") or not line.startswith("MARM_API_KEY="): + continue + value = line.split("=", 1)[1].strip() + if len(value) >= 2 and value[0] == value[-1] and value[0] in {"'", '"'}: + return value[1:-1] + return value.split("#", 1)[0].strip() + except OSError: + pass + return "" + + +def _protect_key_file(path: Path) -> None: + try: + path.chmod(0o600) + except OSError: + pass + if sys.platform != "win32": + return + try: + import getpass + import subprocess + + subprocess.run( + [ + "icacls", + str(path), + "/inheritance:r", + "/grant:r", + f"{getpass.getuser()}:(F)", + ], + check=False, + capture_output=True, + ) + except OSError: + pass + + +def initialize_managed_key(path: Path | None = None) -> tuple[Path, bool]: + """Create the managed key file once, preserving an existing credential.""" + destination = path or managed_key_path() + if read_managed_key(destination): + return destination, False + destination.parent.mkdir(parents=True, exist_ok=True) + destination.write_text(f"MARM_API_KEY={generate_api_key()}\n", encoding="utf-8") + _protect_key_file(destination) + return destination, True diff --git a/marm-mcp-server/marm_mcp_server/services/package_management.py b/marm-mcp-server/marm_mcp_server/services/package_management.py new file mode 100644 index 00000000..b8db4f64 --- /dev/null +++ b/marm-mcp-server/marm_mcp_server/services/package_management.py @@ -0,0 +1,132 @@ +"""Installer detection and registry checks for the product CLI.""" + +from __future__ import annotations + +import importlib.metadata +import json +import os +import subprocess +import sys +import urllib.error +import urllib.parse +import urllib.request +from dataclasses import dataclass +from pathlib import Path + +PACKAGE_NAME = "marm-mcp-server" +PYPI_URL = f"https://pypi.org/pypi/{PACKAGE_NAME}/json" + + +@dataclass(frozen=True) +class Installation: + version: str + installer: str + editable: bool + source_path: Path | None = None + + +def inspect_installation() -> Installation: + """Detect the active distribution without probing or modifying the environment.""" + try: + distribution = importlib.metadata.distribution(PACKAGE_NAME) + version = distribution.version + direct_url = distribution.read_text("direct_url.json") + except importlib.metadata.PackageNotFoundError as exc: + raise RuntimeError( + "marm-mcp-server is not installed in this interpreter." + ) from exc + + editable = False + source_path: Path | None = None + if direct_url: + try: + payload = json.loads(direct_url) + editable = bool(payload.get("dir_info", {}).get("editable")) + source_url = payload.get("url") + if ( + editable + and isinstance(source_url, str) + and source_url.startswith("file:") + ): + source = urllib.parse.unquote(urllib.parse.urlparse(source_url).path) + if ( + os.name == "nt" + and len(source) > 2 + and source[0] == "/" + and source[2] == ":" + ): + source = source[1:] + source_path = Path(source) + except (TypeError, ValueError): + pass + if os.environ.get("PIPX_HOME") or "pipx" in str(Path(sys.executable)).lower(): + installer = "pipx" + else: + installer = "pip" + return Installation( + version=version, + installer=installer, + editable=editable, + source_path=source_path, + ) + + +def check_latest_release(timeout: float = 5.0) -> dict[str, str]: + """Fetch the latest stable package version without changing the installation.""" + request = urllib.request.Request(PYPI_URL, headers={"Accept": "application/json"}) + try: + with urllib.request.urlopen(request, timeout=timeout) as response: + payload = json.load(response) + except (urllib.error.URLError, OSError, ValueError) as exc: + raise RuntimeError( + "Could not contact PyPI. Check your network connection and retry `marm-memory upgrade --check`." + ) from exc + latest = payload.get("info", {}).get("version") + if not isinstance(latest, str) or not latest: + raise RuntimeError("PyPI returned an invalid marm-mcp-server release response.") + installation = inspect_installation() + return { + "installed_version": installation.version, + "latest_version": latest, + "state": "current" if installation.version == latest else "update_available", + "installer": installation.installer, + "editable": str(installation.editable).lower(), + } + + +def manual_upgrade_command( + installation: Installation, version: str | None = None +) -> str: + """Return the safest user-visible command for this installation type.""" + target = PACKAGE_NAME if version is None else f"{PACKAGE_NAME}=={version}" + if installation.editable: + if installation.source_path: + return f'"{sys.executable}" -m pip install -e "{installation.source_path}"' + return "Refresh the editable source environment with its package manager." + if installation.installer == "pipx": + return f"pipx upgrade {PACKAGE_NAME}" + return f'"{sys.executable}" -m pip install --upgrade "{target}"' + + +def manual_uninstall_command(installation: Installation) -> str: + """Return a non-destructive command the user can run after this process exits.""" + if installation.editable: + return "Remove the editable installation from its source environment with its package manager." + if installation.installer == "pipx": + return f"pipx uninstall {PACKAGE_NAME}" + return f'"{sys.executable}" -m pip uninstall {PACKAGE_NAME}' + + +def run_upgrade(version: str | None = None) -> int: + """Run pip through the interpreter that owns the active installation.""" + target = PACKAGE_NAME if version is None else f"{PACKAGE_NAME}=={version}" + return subprocess.call( + [sys.executable, "-m", "pip", "install", "--upgrade", target] + ) + + +def run_uninstall() -> int: + """Remove the distribution through the active interpreter's pip.""" + return subprocess.call( + [sys.executable, "-m", "pip", "uninstall", "--yes", PACKAGE_NAME] + ) diff --git a/marm-mcp-server/marm_mcp_server/services/product_help.py b/marm-mcp-server/marm_mcp_server/services/product_help.py new file mode 100644 index 00000000..c0325350 --- /dev/null +++ b/marm-mcp-server/marm_mcp_server/services/product_help.py @@ -0,0 +1,145 @@ +"""Terminal-aware root help rendering for the marm-memory product CLI.""" + +from __future__ import annotations + +import shutil +import textwrap + + +def render_product_help(version: str) -> str: + """Render stable grouped help without adding a terminal UI dependency. + + Layout surfaces each command's common flags inline so users do not have to + run ` --help` just to discover them; full per-command detail still + lives in the subcommand help. + """ + width = min(100, max(72, shutil.get_terminal_size(fallback=(100, 24)).columns)) + + def section(title: str, entries: tuple[tuple[str, str], ...]) -> list[str]: + label_width = min(38, max(len(command) for command, _ in entries) + 2) + desc_width = max(24, width - 2 - label_width) + lines = [title] + for command, description in entries: + wrapped = textwrap.wrap( + description, + width=desc_width, + break_long_words=False, + break_on_hyphens=False, + ) or [""] + indent = " " * (2 + label_width) + if len(command) < label_width: + lines.append(f" {command:<{label_width}}{wrapped[0]}") + lines.extend(indent + line for line in wrapped[1:]) + else: + lines.append(f" {command}") + lines.extend(indent + line for line in wrapped) + return lines + + sections = ( + ( + "Common Options:", + ( + ("-h, --help", "Show help for any command"), + ("-V, --version", "Show installed version"), + ("--json", "Machine-readable output (status, doctor, upgrade, maintenance)"), + ("--profile ", "standard | swarm | swarm-max | trusted"), + ), + ), + ( + "Daily Use:", + ( + ( + "start [--profile] [--foreground]", + "Start the HTTP server in the background (managed)", + ), + ("stop [--force]", "Stop the managed runtime safely"), + ("restart [--force]", "Restart while preserving the selected profile"), + ("status [--json]", "Show runtime, memory, Console, and graph status"), + ("console [--no-open] [--import-key]", "Launch the bundled local Console"), + ("logs [--follow] [--lines N]", "Read or follow bounded runtime logs"), + ( + "fast-start-http [--client] [--no-console]", + "Start HTTP, Console, and optional client setup", + ), + ), + ), + ( + "Run in Foreground:", + ( + ( + "http [--profile]", + "Run the HTTP server in the foreground (same as start --foreground)", + ), + ( + "stdio", + "Run the STDIO transport for a client that launches MARM itself; " + "for a persistent background server use start", + ), + ), + ), + ( + "Setup and Updates:", + ( + ("doctor [--json]", "Diagnose dependencies and configuration"), + ("key ", "Manage local bearer authentication"), + ( + "upgrade|update [--check] [--yes]", + "Check for and install a newer MARM release", + ), + ("uninstall [--yes]", "Remove MARM while preserving user data"), + ), + ), + ( + "Knowledge and Projects:", + ( + ( + "knowledge status | build [--all|--session|--project]", + "Inspect and build the concept graph", + ), + ( + "projects list | index | status | remove", + "Index, inspect, and remove code projects", + ), + ), + ), + ( + "Docker:", + ( + ( + "docker ", + "Pull, run, inspect, and maintain official images", + ), + ), + ), + ( + "Maintenance:", + ( + ("maintenance status [--json]", "Inspect persistent data"), + ("maintenance embeddings migrate", "Re-embed after a model change"), + ("version", "Show installed version"), + ), + ), + ) + lines = [ + f"MARM Memory {version}", + "Local-first persistent memory and code intelligence for AI agents.", + "", + "Usage: marm-memory [options]", + "", + ] + for title, entries in sections: + lines.extend(section(title, entries)) + lines.append("") + lines.extend( + ( + "Examples:", + " marm-memory fast-start-http", + " marm-memory start --profile swarm", + " marm-memory console --import-key", + " marm-memory doctor", + " marm-memory upgrade --check", + "", + "Run `marm-memory --help` for full options.", + ) + ) + return "\n".join(lines) + "\n" diff --git a/marm-mcp-server/marm_mcp_server/services/product_logs.py b/marm-mcp-server/marm_mcp_server/services/product_logs.py new file mode 100644 index 00000000..e1e852ba --- /dev/null +++ b/marm-mcp-server/marm_mcp_server/services/product_logs.py @@ -0,0 +1,33 @@ +"""Bounded managed-runtime log display for the product CLI.""" + +from __future__ import annotations + +import time +from collections import deque +from pathlib import Path + + +def show_logs(lines: int, follow: bool, *, path: Path) -> int: + """Print the requested tail and safely follow a log that may be rotated.""" + if not path.exists(): + print("No managed runtime log exists yet.") + return 0 + with path.open("r", encoding="utf-8", errors="replace") as log_file: + recent = deque(log_file, maxlen=max(1, lines)) + for line in recent: + print(line, end="") + if not follow: + return 0 + while True: + line = log_file.readline() + if line: + print(line, end="") + continue + try: + if path.stat().st_size < log_file.tell(): + log_file.seek(0) + time.sleep(0.5) + except KeyboardInterrupt: + return 0 + except OSError: + time.sleep(0.5) diff --git a/marm-mcp-server/marm_mcp_server/services/product_workflows.py b/marm-mcp-server/marm_mcp_server/services/product_workflows.py new file mode 100644 index 00000000..5b16fe5e --- /dev/null +++ b/marm-mcp-server/marm_mcp_server/services/product_workflows.py @@ -0,0 +1,210 @@ +"""High-level local workflows used by the marm-memory product CLI.""" + +from __future__ import annotations + +import argparse +import os +import socket +import sys +from pathlib import Path + +from ..config import settings +from ..config.settings import SERVER_HOST, SERVER_PORT + + +def _port_is_available(host: str, port: int) -> bool: + probe_host = "127.0.0.1" if host in {"0.0.0.0", "::", "[::]"} else host + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: + try: + sock.bind((probe_host, port)) + return True + except OSError: + return False + + +def fast_start_http(args: argparse.Namespace) -> int: + """Run the intentionally small, reusable local HTTP workflow.""" + from ..core import runtime_manager + from .runtime_status import doctor_status + + current = runtime_manager.inspect_runtime() + if current["state"] == "stopped" and not _port_is_available( + SERVER_HOST, SERVER_PORT + ): + raise RuntimeError( + f"HTTP port {SERVER_PORT} is already in use. Run `marm-memory status` " + "or choose a different SERVER_PORT before retrying." + ) + + preflight = doctor_status() + warnings = [ + check["name"] + for check in preflight["checks"] + if not check["ok"] + and check["name"] not in {"memory_database_parent", "mcp_port"} + ] + if warnings: + print( + "Preflight warnings: " + + ", ".join(warnings) + + ". Run `marm-memory doctor` for details.", + file=sys.stderr, + ) + + reused_runtime = current["state"] == "ready" + runtime = ( + current + if reused_runtime + else runtime_manager.start_background( + profile=args.profile, rate_limit_rpm=args.rate_limit_rpm + ) + ) + metadata = runtime.get("metadata", {}) + runtime_port = metadata.get("port") + runtime_profile = metadata.get("profile") + console_url: str | None = None + if not args.no_console: + from ..console.cli import run_console + from .key_management import read_managed_key + + managed_auth = bool(settings.MARM_API_KEY and read_managed_key()) + run_console( + open_browser=not args.no_browser, + import_key=managed_auth and not args.no_browser, + ) + console_url = f"http://127.0.0.1:{os.environ.get('MARM_CONSOLE_PORT', '8002')}" + + print("MARM fast start complete.") + if runtime_port is None and reused_runtime: + print("Runtime: managed runtime (reused; endpoint unavailable)") + else: + print( + f"Runtime: http://127.0.0.1:{runtime_port or SERVER_PORT}/mcp" + f" ({'reused' if reused_runtime else 'started'})" + ) + if runtime_profile is None and reused_runtime: + print("Profile: unknown (run `marm-memory status`)") + else: + print(f"Profile: {runtime_profile or args.profile}") + print( + "Authentication: managed key" + if settings.MARM_API_KEY + else "Authentication: loopback-only" + ) + if console_url: + print(f"Console: {console_url}") + else: + print("Console: skipped (--no-console)") + print("Recovery: marm-memory doctor") + if args.client: + print( + f"Client setup is not available for '{args.client}'. MARM is running; " + "configure the client manually, then run `marm-memory status`.", + file=sys.stderr, + ) + return 1 + return 0 + + +def upgrade(args: argparse.Namespace, *, print_payload) -> int: + """Check or upgrade a pip-managed installation without touching user data.""" + from ..core import runtime_manager + from . import package_management + from .runtime_status import full_status + + installation = package_management.inspect_installation() + latest = package_management.check_latest_release() + if args.as_json: + print_payload(latest, as_json=True) + else: + print(f"Installed: {latest['installed_version']}") + print(f"Latest: {latest['latest_version']}") + print( + "Status: already current" + if latest["state"] == "current" and not args.version + else "Status: update available" + ) + if args.check: + return 0 + if installation.editable: + print( + "Editable/source installations are not replaced by PyPI upgrades. " + f"Refresh it with: {package_management.manual_upgrade_command(installation)}", + file=sys.stderr, + ) + return 1 + if installation.installer != "pip" or os.name == "nt": + print( + "This installation must be upgraded after the active launcher exits. " + f"Run: {package_management.manual_upgrade_command(installation, args.version)}", + file=sys.stderr, + ) + return 1 + if latest["state"] == "current" and not args.version: + return 0 + if not args.yes: + print( + "Preview only. Re-run with --yes to stop managed services, upgrade the " + "package, and restart components that were already running." + ) + return 0 + + status = full_status() + restart_runtime = status["runtime"]["state"] == "ready" + restart_console = status["console"]["state"] == "ready" + if restart_runtime or restart_console: + runtime_manager.stop_runtime(stop_console_process=True) + exit_code = package_management.run_upgrade(args.version) + if exit_code != 0: + if restart_runtime: + runtime_manager.start_background() + if restart_console: + from ..console.cli import run_console + + run_console(open_browser=False) + print( + "Package upgrade failed; previously running components were restored.", + file=sys.stderr, + ) + return exit_code + upgraded = package_management.inspect_installation() + print(f"Upgrade complete: {upgraded.version}") + if restart_runtime: + runtime_manager.start_background() + if restart_console: + from ..console.cli import run_console + + run_console(open_browser=False) + print("Run `marm-memory doctor` before any required data migration.") + return 0 + + +def uninstall(args: argparse.Namespace) -> int: + """Remove only the installed package, retaining all MARM user data.""" + from ..core import runtime_manager + from . import package_management + + installation = package_management.inspect_installation() + command = package_management.manual_uninstall_command(installation) + print(f"Package: marm-mcp-server {installation.version}") + print(f"Preserved data and configuration: {Path.home() / '.marm'}") + if not args.yes: + print( + f"Preview only. Re-run with --yes to remove the package. Manual command: {command}" + ) + return 0 + if installation.editable or installation.installer != "pip" or os.name == "nt": + print( + "Self-uninstall is not safe for this active launcher. Close MARM, then run: " + f"{command}", + file=sys.stderr, + ) + return 1 + + runtime_manager.stop_runtime(stop_console_process=True) + exit_code = package_management.run_uninstall() + if exit_code == 0: + print( + "MARM package removed. Your ~/.marm data, keys, databases, and logs were preserved." + ) + return exit_code diff --git a/marm-mcp-server/marm_mcp_server/services/projects_cli.py b/marm-mcp-server/marm_mcp_server/services/projects_cli.py new file mode 100644 index 00000000..2ce95ca4 --- /dev/null +++ b/marm-mcp-server/marm_mcp_server/services/projects_cli.py @@ -0,0 +1,95 @@ +"""Code-index project commands for the marm-memory product CLI.""" + +from __future__ import annotations + +import argparse +import sys +import time +from pathlib import Path +from typing import Callable + + +def dispatch_projects( + args: argparse.Namespace, + *, + ensure_runtime: Callable[[], dict], + runtime_post: Callable[[str, dict], dict], + print_payload: Callable, +) -> int: + """Run bounded project-index commands against the managed runtime.""" + from ..core.runtime_manager import ( + RuntimeRequestError, + RuntimeUnavailable, + request_runtime, + request_runtime_strict, + ) + + ensure_runtime() + + if args.projects_command == "list": + payload = runtime_post("/internal/projects/list", {}) + elif args.projects_command == "status": + if args.project is None: + payload = request_runtime("/internal/runtime/status") or {} + payload = payload.get("graph", payload) + else: + payload = runtime_post( + "/internal/projects/status", {"project": args.project} + ) + elif args.projects_command == "remove": + if args.confirm != args.project: + print("--confirm must exactly match the project name.", file=sys.stderr) + return 2 + payload = runtime_post( + "/internal/projects/delete", + {"project": args.project, "name": args.confirm, "confirm": True}, + ) + else: + path = Path(args.path).expanduser() + if not path.is_absolute() or not path.is_dir(): + print( + "Repository path must be an existing absolute directory.", + file=sys.stderr, + ) + return 2 + job = runtime_post( + "/internal/projects/index", + {"repo_path": str(path.resolve()), "mode": args.mode}, + ) + job_id = job.get("job_id") + if not job_id: + print_payload(job) + return 1 + poll_failures = 0 + while True: + try: + payload = request_runtime_strict( + f"/internal/projects/jobs/{job_id}", timeout=5.0 + ) + poll_failures = 0 + except RuntimeRequestError as exc: + if exc.status_code != 429 and exc.status_code < 500: + raise + poll_failures += 1 + if poll_failures >= 5: + raise RuntimeError( + "Project index status could not be read after 5 attempts." + ) from exc + time.sleep(exc.retry_after or 1) + continue + except RuntimeUnavailable as exc: + poll_failures += 1 + if poll_failures >= 5: + raise RuntimeError( + "Lost contact with the runtime while indexing the project." + ) from exc + time.sleep(1) + continue + status = payload.get("status") + if status in {"success", "error"}: + break + if status not in {"queued", "running"}: + raise RuntimeError("The project index job returned an invalid status.") + time.sleep(1) + print_payload(payload) + return 1 if payload.get("status") == "error" else 0 diff --git a/marm-mcp-server/pyproject.toml b/marm-mcp-server/pyproject.toml index 17640440..2a691dc9 100644 --- a/marm-mcp-server/pyproject.toml +++ b/marm-mcp-server/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "marm-mcp-server" -version = "2.27.0" +version = "2.28.0" description = "Local-first 3-in-1 AI memory layer & MCP server for Claude Code, Codex, Grok, Gemini, VS Code and Cursor. Fuses session history, codebase indices & concept graphs in SQLite. Enables zero-cloud, privacy-first context & instant recall also works with multi-agent swarms." readme = "README.md" license = "Apache-2.0" diff --git a/marm-mcp-server/server.json b/marm-mcp-server/server.json index 1af44b10..7ca88972 100644 --- a/marm-mcp-server/server.json +++ b/marm-mcp-server/server.json @@ -3,7 +3,7 @@ "_schema_date": "2025-12-11", "name": "io.github.Lyellr88/marm-mcp-server", "description": "Universal MCP Server with advanced AI memory capabilities and semantic search.", - "version": "2.27.0", + "version": "2.28.0", "author": "Ryan Lyell - marm-memory", "license": "Apache-2.0", "homepage": "https://marmsystems.com", @@ -17,12 +17,12 @@ { "registryType": "pypi", "identifier": "marm-mcp-server", - "version": "2.27.0", + "version": "2.28.0", "transport": { "type": "stdio" } }, { "registryType": "oci", - "identifier": "lyellr88/marm-mcp-server:2.27.0", + "identifier": "lyellr88/marm-mcp-server:2.28.0", "transport": { "type": "stdio" } } ], diff --git a/marm-mcp-server/tests/test_bundled_console.py b/marm-mcp-server/tests/test_bundled_console.py index cf20c3aa..ece67977 100644 --- a/marm-mcp-server/tests/test_bundled_console.py +++ b/marm-mcp-server/tests/test_bundled_console.py @@ -1,8 +1,10 @@ import importlib from fastapi.testclient import TestClient +import pytest from marm_mcp_server.console import cli as console_cli +from marm_mcp_server.console import auth from marm_mcp_server.console.app import STATIC_DIR, app @@ -65,6 +67,62 @@ def test_console_auth_protects_api_without_blocking_spa(monkeypatch): assert untrusted_host.status_code == 400 +def test_console_bootstrap_exchanges_one_time_token_for_browser_session( + monkeypatch, tmp_path +): + runtime_manager = importlib.import_module("marm_mcp_server.core.runtime_manager") + monkeypatch.setenv("MARM_API_KEY", "console-secret") + monkeypatch.setattr(runtime_manager, "runtime_dir", lambda: tmp_path) + token = auth.create_bootstrap_token(tmp_path) + + with TestClient(app) as client: + authenticated = client.post("/api/auth/bootstrap", json={"token": token}) + replay = client.post("/api/auth/bootstrap", json={"token": token}) + session_authorized = client.get("/api/not-a-real-route") + + assert authenticated.status_code == 200 + assert "marm_console_session" in authenticated.headers["set-cookie"] + assert replay.status_code == 401 + assert session_authorized.status_code == 404 + + +def test_console_import_key_opens_one_time_handoff_without_printing_secret( + monkeypatch, tmp_path, capsys +): + runtime_manager = importlib.import_module("marm_mcp_server.core.runtime_manager") + active_key_management = importlib.import_module( + "marm_mcp_server.services.key_management" + ) + opened = [] + monkeypatch.setattr(runtime_manager, "runtime_dir", lambda: tmp_path) + monkeypatch.setattr(console_cli, "_healthy", lambda: True) + monkeypatch.setattr( + active_key_management, "read_managed_key", lambda: "managed-key" + ) + monkeypatch.setattr(console_cli.webbrowser, "open", lambda url: opened.append(url)) + + assert console_cli.run_console(import_key=True) == 0 + + assert len(opened) == 1 + assert "#marm-bootstrap=" in opened[0] + assert "managed-key" not in opened[0] + output = capsys.readouterr().out + assert "marm-bootstrap" not in output + assert "managed-key" not in output + + +def test_console_import_key_rejects_a_stale_managed_key(monkeypatch): + settings = importlib.import_module("marm_mcp_server.config.settings") + active_key_management = importlib.import_module( + "marm_mcp_server.services.key_management" + ) + monkeypatch.setattr(settings, "MARM_API_KEY", "runtime-key") + monkeypatch.setattr(active_key_management, "read_managed_key", lambda: "stale-key") + + with pytest.raises(RuntimeError, match="does not match"): + console_cli.run_console(import_key=True) + + def test_console_serve_maintains_the_active_log(monkeypatch, tmp_path): runtime_manager = importlib.import_module("marm_mcp_server.core.runtime_manager") maintained = [] diff --git a/marm-mcp-server/tests/test_docker_commands.py b/marm-mcp-server/tests/test_docker_commands.py new file mode 100644 index 00000000..051a1efa --- /dev/null +++ b/marm-mcp-server/tests/test_docker_commands.py @@ -0,0 +1,274 @@ +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from marm_mcp_server.services import docker_commands + + +def _options(tmp_path: Path, **overrides: object) -> docker_commands.DockerRunOptions: + data_dir = tmp_path / "data" + data_dir.mkdir(exist_ok=True) + values: dict[str, object] = { + "data_dir": data_dir, + "env_file": tmp_path / ".env", + } + values.update(overrides) + return docker_commands.DockerRunOptions(**values) + + +def test_docker_run_plan_uses_safe_http_defaults(tmp_path): + repository = tmp_path / "repo" + repository.mkdir() + + plan = docker_commands.build_run_plan( + _options( + tmp_path, + profile="swarm", + repositories=(repository,), + rate_limit_rpm=200, + ) + ) + command = plan["arguments"] + + assert command[:8] == [ + "docker", + "run", + "-d", + "--name", + "marm-mcp-server", + "--restart", + "unless-stopped", + "--label", + ] + assert "com.marm.profile=swarm" in command + assert ( + f"type=bind,src={(tmp_path / 'data').resolve()},dst=/home/marm/.marm" in command + ) + assert "127.0.0.1:8001:8001" in command + assert "SERVER_HOST=0.0.0.0" in command + assert "MARM_RATE_LIMIT_RPM=200" in command + assert ( + f"type=bind,src={repository.resolve()},dst=/workspace/repo-1,readonly" + in command + ) + assert command[-2:] == ["lyellr88/marm-mcp-server:latest", "--swarm"] + assert all("MARM_API_KEY=" not in argument for argument in command) + assert plan["repository_mappings"] == [ + f"{repository.resolve()} -> /workspace/repo-1" + ] + + +def test_docker_run_plan_requires_explicit_network_opt_in(tmp_path): + local = docker_commands.build_run_plan(_options(tmp_path)) + exposed = docker_commands.build_run_plan( + _options(tmp_path, expose_network=True, port=9123) + ) + + assert "127.0.0.1:8001:8001" in local["arguments"] + assert "0.0.0.0:9123:8001" in exposed["arguments"] + + +def test_docker_run_plan_rejects_invalid_inputs(tmp_path): + with pytest.raises(docker_commands.DockerCommandError, match="--port"): + docker_commands.build_run_plan(_options(tmp_path, port=0)) + with pytest.raises(docker_commands.DockerCommandError, match="Repository path"): + docker_commands.build_run_plan( + _options(tmp_path, repositories=(tmp_path / "missing",)) + ) + + +def test_docker_previews_allow_a_new_data_directory(tmp_path): + options = _options(tmp_path, data_dir=tmp_path / "new-data") + + command = docker_commands.build_run_plan(options, require_data_dir=False) + compose = docker_commands.compose_document(options) + + assert command["data_dir"] == str((tmp_path / "new-data").resolve()) + assert compose["plan"]["data_dir"] == str((tmp_path / "new-data").resolve()) + assert not (tmp_path / "new-data").exists() + + +def test_managed_env_file_creates_key_but_explicit_file_must_contain_one( + monkeypatch, tmp_path +): + from marm_mcp_server.services import key_management + + managed = tmp_path / "managed.env" + monkeypatch.setattr(docker_commands, "managed_env_file", lambda: managed) + monkeypatch.setattr(key_management, "generate_api_key", lambda: "generated-key") + + assert docker_commands.ensure_managed_env_file() == managed + assert managed.read_text(encoding="utf-8") == "MARM_API_KEY=generated-key\n" + + explicit = tmp_path / "explicit.env" + explicit.write_text("OTHER=value\n", encoding="utf-8") + with pytest.raises(docker_commands.DockerCommandError, match="does not contain"): + docker_commands.ensure_managed_env_file(explicit) + + +def test_docker_status_redacts_container_environment(monkeypatch): + monkeypatch.setattr( + docker_commands, + "container_inspect", + lambda _name: { + "Config": { + "Image": "lyellr88/marm-mcp-server:latest", + "Env": ["MARM_API_KEY=should-not-appear"], + "Labels": { + "mcp.name": "marm-mcp-server", + "com.marm.profile": "swarm", + }, + }, + "Image": "sha256:abc123", + "State": {"Status": "running", "Health": {"Status": "healthy"}}, + "HostConfig": {"RestartPolicy": {"Name": "unless-stopped"}}, + "Mounts": [{"Source": "/host/marm", "Destination": "/home/marm/.marm"}], + "NetworkSettings": {"Ports": {"8001/tcp": [{"HostPort": "8001"}]}}, + }, + ) + + status = docker_commands.docker_status() + + assert status["state"] == "running" + assert status["image_id"] == "sha256:abc123" + assert status["profile"] == "swarm" + assert "should-not-appear" not in json.dumps(status) + assert status["mounts"] == [ + {"source": "/host/marm", "destination": "/home/marm/.marm"} + ] + + +def test_docker_run_refuses_to_replace_an_existing_container(monkeypatch, tmp_path): + monkeypatch.setattr(docker_commands, "container_inspect", lambda _name: {"Id": "1"}) + monkeypatch.setattr( + docker_commands, + "ensure_managed_env_file", + lambda *_args: pytest.fail( + "existing container must be checked before key creation" + ), + ) + + with pytest.raises(docker_commands.DockerCommandError, match="already exists"): + docker_commands.run_container(_options(tmp_path)) + + +def test_docker_embedding_migration_refuses_while_http_container_runs( + monkeypatch, tmp_path +): + monkeypatch.setattr( + docker_commands, + "docker_status", + lambda _name: {"state": "running"}, + ) + + with pytest.raises( + docker_commands.DockerCommandError, match="requires the managed HTTP container" + ): + docker_commands.migrate_embeddings(data_dir=tmp_path) + + +def test_docker_embedding_migration_streams_and_returns_docker_exit_code( + monkeypatch, tmp_path +): + captured: dict[str, list[str]] = {} + monkeypatch.setattr( + docker_commands, + "docker_status", + lambda _name: {"state": "absent"}, + ) + + def fake_call(arguments): + captured["arguments"] = arguments + return 17 + + monkeypatch.setattr(docker_commands.subprocess, "call", fake_call) + + assert docker_commands.migrate_embeddings(data_dir=tmp_path) == 17 + assert captured["arguments"][-1] == "--migrate-embeddings" + + +def test_docker_stdio_command_uses_the_real_stdio_entrypoint(tmp_path): + plan = docker_commands.stdio_command(data_dir=tmp_path) + + assert plan["arguments"][-2:] == [ + "marm-mcp-stdio", + "lyellr88/marm-mcp-server:latest", + ] + assert "MARM_API_KEY" not in " ".join(plan["arguments"]) + + +def test_shell_command_quotes_windows_and_posix_mount_paths(): + arguments = [ + "docker", + "run", + "--mount", + "type=bind,src=C:\\Users\\Marm User\\.marm,dst=/home/marm/.marm", + ] + + windows = docker_commands.shell_command(arguments, windows=True) + linux = docker_commands.shell_command( + [ + "docker", + "run", + "--mount", + "type=bind,src=/Users/Marm User/.marm,dst=/home/marm/.marm", + ], + windows=False, + ) + macos = docker_commands.shell_command( + [ + "docker", + "run", + "--mount", + "type=bind,src=/Users/Marm User/.marm,dst=/home/marm/.marm", + ], + windows=False, + ) + + assert '"type=bind,src=C:\\Users\\Marm User\\.marm,dst=/home/marm/.marm"' in windows + assert "'type=bind,src=/Users/Marm User/.marm,dst=/home/marm/.marm'" in linux + assert macos == linux + + +def test_compose_document_matches_safe_run_defaults(tmp_path): + document = docker_commands.compose_document(_options(tmp_path, profile="swarm"))[ + "document" + ] + service = document["services"]["marm-mcp-server"] + + assert service["image"] == "lyellr88/marm-mcp-server:latest" + assert service["ports"] == ["127.0.0.1:8001:8001"] + assert service["restart"] == "unless-stopped" + assert service["command"] == ["--swarm"] + assert service["environment"] == {"SERVER_HOST": "0.0.0.0"} + assert service["env_file"] == [str((tmp_path / ".env").resolve())] + assert service["volumes"][0]["target"] == "/home/marm/.marm" + + +def test_compose_yaml_is_human_readable_yaml(tmp_path): + document = docker_commands.compose_document(_options(tmp_path))["document"] + + rendered = docker_commands.compose_yaml(document) + + assert rendered.startswith("services:\n") + assert 'image: "lyellr88/marm-mcp-server:latest"' in rendered + assert "env_file:\n" in rendered + assert not rendered.lstrip().startswith("{") + + +def test_write_compose_file_refuses_overwrite_before_creating_a_key( + monkeypatch, tmp_path +): + output = tmp_path / "marm-compose.yaml" + output.write_text("existing", encoding="utf-8") + monkeypatch.setattr( + docker_commands, + "ensure_managed_env_file", + lambda *_args: pytest.fail("existing Compose file must be checked first"), + ) + + with pytest.raises(docker_commands.DockerCommandError, match="already exists"): + docker_commands.write_compose_file(_options(tmp_path), output) diff --git a/marm-mcp-server/tests/test_runtime_cli.py b/marm-mcp-server/tests/test_runtime_cli.py index 2e006431..d12c56ae 100644 --- a/marm-mcp-server/tests/test_runtime_cli.py +++ b/marm-mcp-server/tests/test_runtime_cli.py @@ -117,6 +117,8 @@ def test_status_json_contains_no_decorative_output(tmp_path): def test_project_index_poll_retries_transport_failure(monkeypatch, capsys, tmp_path): active_cli, active_runtime = _active_modules() + from marm_mcp_server.services import projects_cli + repository = tmp_path / "repo" repository.mkdir() args = SimpleNamespace( @@ -141,7 +143,7 @@ def poll(*_args, **_kwargs): return response monkeypatch.setattr(active_runtime, "request_runtime_strict", poll) - monkeypatch.setattr(active_cli.time, "sleep", lambda _seconds: None) + monkeypatch.setattr(projects_cli.time, "sleep", lambda _seconds: None) assert active_cli._dispatch_projects(args) == 0 captured = capsys.readouterr() @@ -201,6 +203,284 @@ def test_product_key_writes_one_generated_key(monkeypatch, capsys): assert "Keep it secret" in output +def test_managed_key_init_reuses_existing_credential(monkeypatch, tmp_path): + active_key_management = importlib.import_module( + "marm_mcp_server.services.key_management" + ) + path = tmp_path / ".marm" / ".env" + monkeypatch.setattr(active_key_management, "managed_key_path", lambda: path) + monkeypatch.setattr(active_key_management, "generate_api_key", lambda: "first-key") + + first_path, first_created = active_key_management.initialize_managed_key() + second_path, second_created = active_key_management.initialize_managed_key() + + assert first_path == second_path == path + assert first_created is True + assert second_created is False + assert active_key_management.read_managed_key(path) == "first-key" + + +def test_key_path_and_reveal_keep_output_intentional(monkeypatch, capsys, tmp_path): + active_key_management = importlib.import_module( + "marm_mcp_server.services.key_management" + ) + path = tmp_path / ".marm" / ".env" + path.parent.mkdir() + path.write_text("MARM_API_KEY=saved-key\n", encoding="utf-8") + monkeypatch.setattr(active_key_management, "managed_key_path", lambda: path) + + assert ( + cli._dispatch_product(SimpleNamespace(command="key", key_command="path")) == 0 + ) + captured = capsys.readouterr() + assert (captured.out or captured.err).strip() == str(path) + + assert ( + cli._dispatch_product(SimpleNamespace(command="key", key_command="reveal")) == 0 + ) + captured = capsys.readouterr() + assert captured.out.strip() == "saved-key" + assert "terminal capture" in captured.err + + +def test_product_help_uses_grouped_stable_layout(capsys): + parser = cli._product_parser() + + with pytest.raises(SystemExit) as exc_info: + parser.parse_args(["--help"]) + + assert exc_info.value.code == 0 + output = capsys.readouterr().out + assert "Usage: marm-memory [options]" in output + assert "Common Options:" in output + assert "Daily Use:" in output + assert "Knowledge and Projects:" in output + assert "start [--profile] [--foreground]" in output + assert "docker" in output + assert "{start,stop" not in output + + +def test_product_help_wraps_for_narrow_terminals(monkeypatch): + from marm_mcp_server.services import product_help + + monkeypatch.setattr( + product_help.shutil, + "get_terminal_size", + lambda fallback: os.terminal_size((72, 24)), + ) + + output = cli._product_help() + + assert all(len(line) <= 72 for line in output.splitlines()) + + +def test_product_version_flag_prints_installed_version(capsys): + parser = cli._product_parser() + + with pytest.raises(SystemExit) as exc_info: + parser.parse_args(["--version"]) + + assert exc_info.value.code == 0 + assert capsys.readouterr().out.strip() == cli.SERVER_VERSION + + +def test_product_help_alias_preserves_subcommand_help(monkeypatch, capsys): + monkeypatch.setattr(sys, "argv", ["marm-memory", "help", "start"]) + + with pytest.raises(SystemExit) as exc_info: + cli.main() + + assert exc_info.value.code == 0 + output = capsys.readouterr().out + assert "--foreground" in output + assert "--profile" in output + + +def test_http_alias_uses_existing_foreground_runtime_owner(monkeypatch): + calls = [] + monkeypatch.setattr(cli, "_run_foreground", lambda **kwargs: calls.append(kwargs)) + + assert ( + cli._dispatch_product( + SimpleNamespace( + command="http", + foreground=True, + profile="swarm", + rate_limit_rpm=200, + runtime_id="test-runtime", + ) + ) + == 0 + ) + assert calls == [ + { + "profile": "swarm", + "rate_limit_rpm": 200, + "runtime_id": "test-runtime", + } + ] + + +def test_stdio_alias_uses_existing_stdio_entry_point(monkeypatch): + from marm_mcp_server import server_stdio + + calls = [] + monkeypatch.setattr(server_stdio, "main", lambda: calls.append("stdio")) + + assert cli._dispatch_product(SimpleNamespace(command="stdio")) == 0 + assert calls == ["stdio"] + + +def test_fast_start_reuses_runtime_and_leaves_it_running_on_client_gap( + monkeypatch, capsys +): + active_cli, active_runtime = _active_modules() + calls = [] + monkeypatch.setattr( + active_runtime, + "inspect_runtime", + lambda: { + "state": "ready", + "metadata": {"port": 8001, "profile": "swarm"}, + }, + ) + monkeypatch.setattr( + active_runtime, + "start_background", + lambda **kwargs: calls.append(("start", kwargs)), + ) + console_module = importlib.import_module("marm_mcp_server.console.cli") + monkeypatch.setattr( + console_module, + "run_console", + lambda **kwargs: calls.append(("console", kwargs)) or 0, + ) + monkeypatch.setattr(active_cli.settings, "MARM_API_KEY", "") + + result = active_cli._fast_start_http( + SimpleNamespace( + profile="standard", + rate_limit_rpm=None, + no_console=False, + no_browser=False, + client="claude", + ) + ) + + assert result == 1 + assert calls == [("console", {"open_browser": True, "import_key": False})] + captured = capsys.readouterr() + assert "Runtime:" in (captured.out or captured.err) + assert "Client setup is not available" in captured.err + + +def test_fast_start_rejects_busy_port_before_starting(monkeypatch): + active_cli, active_runtime = _active_modules() + from marm_mcp_server.services import product_workflows + + monkeypatch.setattr(active_runtime, "inspect_runtime", lambda: {"state": "stopped"}) + monkeypatch.setattr(product_workflows, "_port_is_available", lambda *_args: False) + + with pytest.raises(RuntimeError, match="port"): + active_cli._fast_start_http( + SimpleNamespace( + profile="standard", + rate_limit_rpm=None, + no_console=True, + no_browser=True, + client=None, + ) + ) + + +def test_fast_start_does_not_invent_reused_runtime_metadata(monkeypatch, capsys): + active_cli, active_runtime = _active_modules() + monkeypatch.setattr( + active_runtime, + "inspect_runtime", + lambda: {"state": "ready", "metadata": {}}, + ) + monkeypatch.setattr(active_cli.settings, "MARM_API_KEY", "") + + assert ( + active_cli._fast_start_http( + SimpleNamespace( + profile="standard", + rate_limit_rpm=None, + no_console=True, + no_browser=True, + client=None, + ) + ) + == 0 + ) + + captured = capsys.readouterr() + output = captured.out + captured.err + assert "endpoint unavailable" in output + assert "Profile: unknown" in output + + +def test_upgrade_check_reports_registry_state_without_installing(monkeypatch, capsys): + active_package_management = importlib.import_module( + "marm_mcp_server.services.package_management" + ) + monkeypatch.setattr( + active_package_management, + "inspect_installation", + lambda: active_package_management.Installation("2.26.0", "pip", False), + ) + monkeypatch.setattr( + active_package_management, + "check_latest_release", + lambda: { + "installed_version": "2.26.0", + "latest_version": "2.27.0", + "state": "update_available", + "installer": "pip", + "editable": "false", + }, + ) + monkeypatch.setattr( + active_package_management, + "run_upgrade", + lambda *_args: pytest.fail("--check must not install"), + ) + + assert ( + cli._upgrade( + SimpleNamespace(check=True, as_json=False, version=None, yes=False) + ) + == 0 + ) + captured = capsys.readouterr() + assert "Latest: 2.27.0" in (captured.out or captured.err) + + +def test_uninstall_preview_preserves_data_and_does_not_remove_package( + monkeypatch, capsys +): + active_package_management = importlib.import_module( + "marm_mcp_server.services.package_management" + ) + monkeypatch.setattr( + active_package_management, + "inspect_installation", + lambda: active_package_management.Installation("2.27.0", "pip", False), + ) + monkeypatch.setattr( + active_package_management, + "run_uninstall", + lambda: pytest.fail("preview must not uninstall"), + ) + + assert cli._uninstall(SimpleNamespace(yes=False)) == 0 + captured = capsys.readouterr() + output = captured.out or captured.err + assert "Preserved data and configuration" in output + assert "Preview only" in output + + def test_default_status_is_human_readable(capsys): active_cli, _active_runtime = _active_modules() active_cli._print_status( diff --git a/scripts/make-readme-mirrors.py b/scripts/make-readme-mirrors.py deleted file mode 100644 index 47103d90..00000000 --- a/scripts/make-readme-mirrors.py +++ /dev/null @@ -1,96 +0,0 @@ -#!/usr/bin/env python3 -"""Generate the two packaged README mirrors from the root README. - -Root README.md is the source of truth. This script writes: - marm-mcp-server/README.md PyPI variant (mcp-name header only) - marm-mcp-server/marm-docs/README.md text-only agent-facing subset -""" - -from __future__ import annotations - -import re -from pathlib import Path - -ROOT = Path(__file__).resolve().parent.parent - -src = (ROOT / "README.md").read_text(encoding="utf-8") - -# ---- PyPI variant: marm-mcp-server/README.md ---- -pypi = "mcp-name: io.github.Lyellr88/marm-mcp-server\n\n" + src - -(ROOT / "marm-mcp-server" / "README.md").write_text(pypi, encoding="utf-8") - -# ---- marm-docs variant: text-only, agent-facing subset ---- -lines = src.split("\n") - -# Replace everything before the TOC with a plain title taken from the root h1 -h1 = re.search(r"]*>(.*?)", src).group(1) -toc_i = lines.index("## Table of Contents") -lines = ["# " + h1, ""] + lines[toc_i:] - -# Strip
...
blocks (badges/images live inside them in this file) -out = [] -depth = 0 -for line in lines: - stripped = line.strip() - if stripped.startswith(""): - depth = max(0, depth - 1) - continue - if depth == 0: - out.append(line) -lines = out - -# Drop the demo subsection (video cannot render in packaged docs) -out = [] -skipping = False -for line in lines: - if line.startswith("### MARM Demo"): - skipping = True - continue - if skipping and line.startswith("## "): - skipping = False - if not skipping: - out.append(line) -lines = out - -# Drop non-usage sections entirely -DROP_SECTIONS = ( - "## ⭐ Star the Project", - "## Contributing", - "## Join the MARM Community", - "## License & Usage Notice", - "## Project Documentation", -) -out = [] -skipping = False -for line in lines: - if line.startswith("## "): - skipping = line.startswith(DROP_SECTIONS) - if not skipping: - out.append(line) -lines = out - -# Drop TOC entries pointing at removed sections -lines = [ - line - for line in lines - if not ( - line.startswith("- [") - and ("#contributing" in line or "#project-documentation" in line) - ) -] - -# Collapse triple+ blank lines left by removals -text = "\n".join(lines) -while "\n\n\n" in text: - text = text.replace("\n\n\n", "\n\n") -if not text.endswith("\n"): - text += "\n" - -(ROOT / "marm-mcp-server" / "marm-docs" / "README.md").write_text( - text, encoding="utf-8" -) -print("pypi lines:", pypi.count("\n"), "| marm-docs lines:", text.count("\n")) From 075869bffe362e2b0688db30d47989366e492f99 Mon Sep 17 00:00:00 2001 From: Ryan Lyell Date: Thu, 23 Jul 2026 23:31:25 -0400 Subject: [PATCH 2/3] fix(cli): address PR #110 review findings and add Linux Docker smoke test Resolve CodeRabbit and Codex review findings on the v2.28.0 command surface. - Docker: map bind-mount writes to the host UID/GID and set HOME plus XDG_CACHE_HOME so the SQLite database lands in the mounted, host-owned data directory instead of an unwritable container home. Applied to run, stdio, and compose. Add a Linux bind-mount smoke test that verifies write and persistence across a container restart. - Console: verify the bootstrap token before consuming it and hold the lock across validation and unlink; fall back to the managed key when the Console runs without MARM_API_KEY. - upgrade: emit JSON-only output for --json (rejecting --yes) and preserve the active runtime profile and rate limit across restart and rollback. - fast-start-http: continue when the Console fails, with accurate status. - key management: create key files atomically with verified owner-only permissions. - Health polling no longer spins on unhealthy responses; drop an existence-only test assertion; refresh stale tests and version markers. Co-Authored-By: Claude Opus 4.8 --- AGENTS.md | 2 +- CONTRIBUTING.md | 14 ++ .../marm_mcp_server/console/auth.py | 21 +-- .../marm_mcp_server/console/mcp_client.py | 6 +- .../services/docker_commands.py | 45 ++++++- .../services/key_management.py | 38 ++++-- .../marm_mcp_server/services/product_help.py | 15 ++- .../services/product_workflows.py | 48 ++++--- marm-mcp-server/tests/test_bundled_console.py | 30 ++++- marm-mcp-server/tests/test_cli_entrypoint.py | 1 + marm-mcp-server/tests/test_docker_commands.py | 37 +++++- marm-mcp-server/tests/test_runtime_cli.py | 122 +++++++++++++++++- .../docker-linux-bind-mount-smoke.sh | 84 ++++++++++++ 13 files changed, 411 insertions(+), 52 deletions(-) create mode 100644 scripts/test-scripts/docker-linux-bind-mount-smoke.sh diff --git a/AGENTS.md b/AGENTS.md index b908cde0..f94afef9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -69,7 +69,7 @@ Semver: MAJOR = breaking (schema renames, parameter removals), MINOR = new tools - Dev setup: `cd marm-mcp-server && pip install -e ".[dev]" && python scripts/bundle-concept-model.py` - Benchmarks live in `scripts/benchmarking/`: `preformance/bench_hotpath.py` for hot-path performance, `accuracy/locomo/run_eval.py` for LoCoMo retrieval accuracy. Do not publish performance claims neither script can back. -## Current Stats (v2.27.0) +## Current Stats (v2.28.0) - 14 MCP tools over HTTP + STDIO - 2 isolated SQLite databases (memory + concept graph) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 86471e9d..62435b31 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -225,6 +225,20 @@ Run Docker smoke directly when changing Docker, transport setup, auth, or startu python scripts/test-scripts/docker-smoke.py ``` +For changes to Docker bind mounts, container users, `HOME`, cache paths, or data persistence, also run the Linux-only smoke test. It verifies that a host-owned mounted database can be written through HTTP and survives a container restart. + +Run it from a native Linux host or WSL2 with Docker Desktop WSL integration enabled. The script creates its temporary mounted data directory under Linux `/tmp`; do not change that location to `/mnt/c`, or the UID/GID assertion is no longer meaningful: + +```bash +bash scripts/test-scripts/docker-linux-bind-mount-smoke.sh +``` + +The script uses the latest official image by default. To test a locally built image instead: + +```bash +MARM_DOCKER_SMOKE_IMAGE=marm-mcp-server:smoke bash scripts/test-scripts/docker-linux-bind-mount-smoke.sh +``` + ## Documentation Update docs when changing: diff --git a/marm-mcp-server/marm_mcp_server/console/auth.py b/marm-mcp-server/marm_mcp_server/console/auth.py index 45ae8507..cb231653 100644 --- a/marm-mcp-server/marm_mcp_server/console/auth.py +++ b/marm-mcp-server/marm_mcp_server/console/auth.py @@ -46,18 +46,23 @@ def consume_bootstrap_token(runtime_directory: Path, token: str) -> bool: payload = json.loads(path.read_text(encoding="utf-8")) except (OSError, ValueError, TypeError): return False + if not isinstance(payload, dict): + return False + expected = payload.get("token") + expires_at = payload.get("expires_at") + valid = ( + isinstance(expected, str) + and isinstance(expires_at, (int, float)) + and time.time() <= expires_at + and secrets.compare_digest(token, expected) + ) + if not valid: + return False try: path.unlink(missing_ok=True) except OSError: return False - expected = payload.get("token") - expires_at = payload.get("expires_at") - return ( - isinstance(expected, str) - and isinstance(expires_at, (int, float)) - and time.time() <= expires_at - and secrets.compare_digest(token, expected) - ) + return True def create_browser_session() -> str: diff --git a/marm-mcp-server/marm_mcp_server/console/mcp_client.py b/marm-mcp-server/marm_mcp_server/console/mcp_client.py index df03118f..53142337 100644 --- a/marm-mcp-server/marm_mcp_server/console/mcp_client.py +++ b/marm-mcp-server/marm_mcp_server/console/mcp_client.py @@ -30,7 +30,11 @@ def _api_key() -> str: return explicit from ..config.settings import MARM_API_KEY - return MARM_API_KEY + if MARM_API_KEY: + return MARM_API_KEY + from ..services.key_management import read_managed_key + + return read_managed_key() def _http_error(exc: HTTPError) -> McpRequestError: diff --git a/marm-mcp-server/marm_mcp_server/services/docker_commands.py b/marm-mcp-server/marm_mcp_server/services/docker_commands.py index aab5c484..f23c06a9 100644 --- a/marm-mcp-server/marm_mcp_server/services/docker_commands.py +++ b/marm-mcp-server/marm_mcp_server/services/docker_commands.py @@ -6,6 +6,7 @@ import os import shlex import subprocess +import sys import time import urllib.error import urllib.request @@ -92,6 +93,13 @@ def _repository_mounts(repositories: tuple[Path, ...]) -> tuple[list[str], list[ return arguments, mappings +def _container_user() -> str | None: + """Map Linux bind-mount writes to the invoking host user.""" + if not sys.platform.startswith("linux"): + return None + return f"{os.getuid()}:{os.getgid()}" + + def build_run_plan( options: DockerRunOptions, *, @@ -118,6 +126,7 @@ def build_run_plan( ) env_file = (options.env_file or managed_env_file()).expanduser().resolve() host_binding = "0.0.0.0" if options.expose_network else "127.0.0.1" + container_user = _container_user() arguments = [ "docker", "run", @@ -134,9 +143,15 @@ def build_run_plan( str(env_file), "-e", "SERVER_HOST=0.0.0.0", + "-e", + "HOME=/home/marm", + "-e", + "XDG_CACHE_HOME=/home/marm/.marm/cache", "-p", f"{host_binding}:{options.port}:8001", ] + if container_user: + arguments.extend(["--user", container_user]) if options.pull: arguments.extend(["--pull", "always"]) if options.memory: @@ -162,6 +177,7 @@ def build_run_plan( "env_file": str(env_file), "image": image_reference(options.tag), "host_binding": host_binding, + "container_user": container_user, "repository_mappings": repository_mappings, } @@ -256,7 +272,8 @@ def _wait_for_health(port: int, *, timeout: float = 30.0) -> None: if payload.get("status") == "healthy": return except (urllib.error.URLError, OSError, ValueError): - time.sleep(0.5) + pass + time.sleep(0.5) raise DockerCommandError( "MARM container did not become healthy. Run `marm-memory docker logs --follow`." ) @@ -290,14 +307,26 @@ def stdio_command( "--rm", "--mount", f"type=bind,src={resolved_data_dir},dst={CONTAINER_DATA_DIR}", - "--entrypoint", - "marm-mcp-stdio", - image_reference(tag), + "-e", + "HOME=/home/marm", + "-e", + "XDG_CACHE_HOME=/home/marm/.marm/cache", ] + container_user = _container_user() + if container_user: + arguments.extend(["--user", container_user]) + arguments.extend( + [ + "--entrypoint", + "marm-mcp-stdio", + image_reference(tag), + ] + ) return { "arguments": arguments, "data_dir": str(resolved_data_dir), "image": image_reference(tag), + "container_user": container_user, } @@ -311,7 +340,11 @@ def compose_document(options: DockerRunOptions) -> dict[str, Any]: "swarm-max": ["--swarm-max"], "trusted": ["--trusted"], }[options.profile] - environment = {"SERVER_HOST": "0.0.0.0"} + environment = { + "SERVER_HOST": "0.0.0.0", + "HOME": "/home/marm", + "XDG_CACHE_HOME": "/home/marm/.marm/cache", + } if options.rate_limit_rpm is not None: environment["MARM_RATE_LIMIT_RPM"] = str(options.rate_limit_rpm) service: dict[str, Any] = { @@ -332,6 +365,8 @@ def compose_document(options: DockerRunOptions) -> dict[str, Any]: } if profile_args: service["command"] = profile_args + if plan["container_user"]: + service["user"] = plan["container_user"] if options.memory or options.cpus: service["deploy"] = {"resources": {"limits": {}}} limits = service["deploy"]["resources"]["limits"] diff --git a/marm-mcp-server/marm_mcp_server/services/key_management.py b/marm-mcp-server/marm_mcp_server/services/key_management.py index 0838ccc6..b6657eef 100644 --- a/marm-mcp-server/marm_mcp_server/services/key_management.py +++ b/marm-mcp-server/marm_mcp_server/services/key_management.py @@ -3,6 +3,8 @@ from __future__ import annotations import sys +import os +import stat from pathlib import Path from ..utils.security import generate_api_key @@ -31,18 +33,19 @@ def read_managed_key(path: Path | None = None) -> str: return "" -def _protect_key_file(path: Path) -> None: - try: - path.chmod(0o600) - except OSError: - pass +def _protect_key_file(path: Path) -> bool: + """Apply and verify owner-only access before treating a key as usable.""" if sys.platform != "win32": - return + try: + path.chmod(0o600) + return stat.S_IMODE(path.stat().st_mode) & 0o077 == 0 + except OSError: + return False try: import getpass import subprocess - subprocess.run( + result = subprocess.run( [ "icacls", str(path), @@ -53,16 +56,31 @@ def _protect_key_file(path: Path) -> None: check=False, capture_output=True, ) + return result.returncode == 0 except OSError: - pass + return False def initialize_managed_key(path: Path | None = None) -> tuple[Path, bool]: """Create the managed key file once, preserving an existing credential.""" destination = path or managed_key_path() if read_managed_key(destination): + if not _protect_key_file(destination): + raise RuntimeError(f"Could not secure managed key file: {destination}") return destination, False destination.parent.mkdir(parents=True, exist_ok=True) - destination.write_text(f"MARM_API_KEY={generate_api_key()}\n", encoding="utf-8") - _protect_key_file(destination) + try: + descriptor = os.open(destination, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) + except FileExistsError as exc: + raise RuntimeError( + f"Managed key file exists but does not contain MARM_API_KEY: {destination}" + ) from exc + try: + with os.fdopen(descriptor, "w", encoding="utf-8") as key_file: + key_file.write(f"MARM_API_KEY={generate_api_key()}\n") + except OSError: + destination.unlink(missing_ok=True) + raise + if not _protect_key_file(destination): + raise RuntimeError(f"Could not secure managed key file: {destination}") return destination, True diff --git a/marm-mcp-server/marm_mcp_server/services/product_help.py b/marm-mcp-server/marm_mcp_server/services/product_help.py index c0325350..b4f10824 100644 --- a/marm-mcp-server/marm_mcp_server/services/product_help.py +++ b/marm-mcp-server/marm_mcp_server/services/product_help.py @@ -41,7 +41,10 @@ def section(title: str, entries: tuple[tuple[str, str], ...]) -> list[str]: ( ("-h, --help", "Show help for any command"), ("-V, --version", "Show installed version"), - ("--json", "Machine-readable output (status, doctor, upgrade, maintenance)"), + ( + "--json", + "Machine-readable output (status, doctor, upgrade, maintenance)", + ), ("--profile ", "standard | swarm | swarm-max | trusted"), ), ), @@ -55,7 +58,10 @@ def section(title: str, entries: tuple[tuple[str, str], ...]) -> list[str]: ("stop [--force]", "Stop the managed runtime safely"), ("restart [--force]", "Restart while preserving the selected profile"), ("status [--json]", "Show runtime, memory, Console, and graph status"), - ("console [--no-open] [--import-key]", "Launch the bundled local Console"), + ( + "console [--no-open] [--import-key]", + "Launch the bundled local Console", + ), ("logs [--follow] [--lines N]", "Read or follow bounded runtime logs"), ( "fast-start-http [--client] [--no-console]", @@ -81,7 +87,10 @@ def section(title: str, entries: tuple[tuple[str, str], ...]) -> list[str]: "Setup and Updates:", ( ("doctor [--json]", "Diagnose dependencies and configuration"), - ("key ", "Manage local bearer authentication"), + ( + "key ", + "Manage local bearer authentication", + ), ( "upgrade|update [--check] [--yes]", "Check for and install a newer MARM release", diff --git a/marm-mcp-server/marm_mcp_server/services/product_workflows.py b/marm-mcp-server/marm_mcp_server/services/product_workflows.py index 5b16fe5e..5d91294f 100644 --- a/marm-mcp-server/marm_mcp_server/services/product_workflows.py +++ b/marm-mcp-server/marm_mcp_server/services/product_workflows.py @@ -63,16 +63,23 @@ def fast_start_http(args: argparse.Namespace) -> int: runtime_port = metadata.get("port") runtime_profile = metadata.get("profile") console_url: str | None = None + console_error: str | None = None if not args.no_console: from ..console.cli import run_console from .key_management import read_managed_key managed_auth = bool(settings.MARM_API_KEY and read_managed_key()) - run_console( - open_browser=not args.no_browser, - import_key=managed_auth and not args.no_browser, - ) - console_url = f"http://127.0.0.1:{os.environ.get('MARM_CONSOLE_PORT', '8002')}" + try: + run_console( + open_browser=not args.no_browser, + import_key=managed_auth and not args.no_browser, + ) + console_url = ( + f"http://127.0.0.1:{os.environ.get('MARM_CONSOLE_PORT', '8002')}" + ) + except RuntimeError as exc: + console_error = str(exc) + print(f"Console: unavailable ({exc})", file=sys.stderr) print("MARM fast start complete.") if runtime_port is None and reused_runtime: @@ -93,6 +100,8 @@ def fast_start_http(args: argparse.Namespace) -> int: ) if console_url: print(f"Console: {console_url}") + elif console_error: + print("Console: unavailable") else: print("Console: skipped (--no-console)") print("Recovery: marm-memory doctor") @@ -112,18 +121,20 @@ def upgrade(args: argparse.Namespace, *, print_payload) -> int: from . import package_management from .runtime_status import full_status - installation = package_management.inspect_installation() latest = package_management.check_latest_release() if args.as_json: + if args.yes: + raise RuntimeError("`upgrade --json` cannot be combined with `--yes`.") print_payload(latest, as_json=True) - else: - print(f"Installed: {latest['installed_version']}") - print(f"Latest: {latest['latest_version']}") - print( - "Status: already current" - if latest["state"] == "current" and not args.version - else "Status: update available" - ) + return 0 + installation = package_management.inspect_installation() + print(f"Installed: {latest['installed_version']}") + print(f"Latest: {latest['latest_version']}") + print( + "Status: already current" + if latest["state"] == "current" and not args.version + else "Status: update available" + ) if args.check: return 0 if installation.editable: @@ -149,6 +160,9 @@ def upgrade(args: argparse.Namespace, *, print_payload) -> int: ) return 0 + current_state = runtime_manager.read_state() or {} + profile = current_state.get("profile", "standard") + rate_limit_rpm = current_state.get("rate_limit_rpm") status = full_status() restart_runtime = status["runtime"]["state"] == "ready" restart_console = status["console"]["state"] == "ready" @@ -157,7 +171,9 @@ def upgrade(args: argparse.Namespace, *, print_payload) -> int: exit_code = package_management.run_upgrade(args.version) if exit_code != 0: if restart_runtime: - runtime_manager.start_background() + runtime_manager.start_background( + profile=profile, rate_limit_rpm=rate_limit_rpm + ) if restart_console: from ..console.cli import run_console @@ -170,7 +186,7 @@ def upgrade(args: argparse.Namespace, *, print_payload) -> int: upgraded = package_management.inspect_installation() print(f"Upgrade complete: {upgraded.version}") if restart_runtime: - runtime_manager.start_background() + runtime_manager.start_background(profile=profile, rate_limit_rpm=rate_limit_rpm) if restart_console: from ..console.cli import run_console diff --git a/marm-mcp-server/tests/test_bundled_console.py b/marm-mcp-server/tests/test_bundled_console.py index ece67977..f9ea2004 100644 --- a/marm-mcp-server/tests/test_bundled_console.py +++ b/marm-mcp-server/tests/test_bundled_console.py @@ -5,11 +5,11 @@ from marm_mcp_server.console import cli as console_cli from marm_mcp_server.console import auth -from marm_mcp_server.console.app import STATIC_DIR, app +from marm_mcp_server.console import mcp_client +from marm_mcp_server.console.app import app def test_bundled_console_serves_ui_and_preserves_api_404s(): - assert (STATIC_DIR / "index.html").exists() with TestClient(app) as client: index = client.get("/") deep_link = client.get("/knowledge") @@ -86,6 +86,32 @@ def test_console_bootstrap_exchanges_one_time_token_for_browser_session( assert session_authorized.status_code == 404 +def test_invalid_console_bootstrap_does_not_consume_the_pending_handoff( + monkeypatch, tmp_path +): + runtime_manager = importlib.import_module("marm_mcp_server.core.runtime_manager") + monkeypatch.setenv("MARM_API_KEY", "console-secret") + monkeypatch.setattr(runtime_manager, "runtime_dir", lambda: tmp_path) + token = auth.create_bootstrap_token(tmp_path) + + with TestClient(app) as client: + rejected = client.post("/api/auth/bootstrap", json={"token": "wrong-token"}) + authenticated = client.post("/api/auth/bootstrap", json={"token": token}) + + assert rejected.status_code == 401 + assert authenticated.status_code == 200 + + +def test_console_client_uses_managed_key_when_its_process_has_no_key(monkeypatch): + settings = importlib.import_module("marm_mcp_server.config.settings") + key_management = importlib.import_module("marm_mcp_server.services.key_management") + monkeypatch.delenv("MARM_API_KEY", raising=False) + monkeypatch.setattr(settings, "MARM_API_KEY", "") + monkeypatch.setattr(key_management, "read_managed_key", lambda: "managed-key") + + assert mcp_client._api_key() == "managed-key" + + def test_console_import_key_opens_one_time_handoff_without_printing_secret( monkeypatch, tmp_path, capsys ): diff --git a/marm-mcp-server/tests/test_cli_entrypoint.py b/marm-mcp-server/tests/test_cli_entrypoint.py index a6563f18..f8c10195 100644 --- a/marm-mcp-server/tests/test_cli_entrypoint.py +++ b/marm-mcp-server/tests/test_cli_entrypoint.py @@ -81,6 +81,7 @@ def test_migration_entrypoints_are_side_effect_light_when_database_is_absent(tmp env["MARM_ANALYTICS_DB_PATH"] = str(tmp_path / "analytics.db") env["USERPROFILE"] = str(tmp_path) env["HOME"] = str(tmp_path) + env["SERVER_PORT"] = "65534" module_result = subprocess.run( [sys.executable, "-m", "marm_mcp_server", "--migrate-embeddings"], diff --git a/marm-mcp-server/tests/test_docker_commands.py b/marm-mcp-server/tests/test_docker_commands.py index 051a1efa..8ba382a5 100644 --- a/marm-mcp-server/tests/test_docker_commands.py +++ b/marm-mcp-server/tests/test_docker_commands.py @@ -71,6 +71,28 @@ def test_docker_run_plan_requires_explicit_network_opt_in(tmp_path): assert "0.0.0.0:9123:8001" in exposed["arguments"] +def test_linux_plans_map_bind_mount_writes_to_the_host_user(monkeypatch, tmp_path): + monkeypatch.setattr(docker_commands.sys, "platform", "linux") + monkeypatch.setattr(docker_commands.os, "getuid", lambda: 1001, raising=False) + monkeypatch.setattr(docker_commands.os, "getgid", lambda: 1002, raising=False) + + plan = docker_commands.build_run_plan(_options(tmp_path)) + compose = docker_commands.compose_document(_options(tmp_path))["document"] + stdio = docker_commands.stdio_command(data_dir=tmp_path) + + assert plan["container_user"] == "1001:1002" + assert ["--user", "1001:1002"] == plan["arguments"][ + plan["arguments"].index("--user") : plan["arguments"].index("--user") + 2 + ] + assert compose["services"]["marm-mcp-server"]["user"] == "1001:1002" + assert ["--user", "1001:1002"] == stdio["arguments"][ + stdio["arguments"].index("--user") : stdio["arguments"].index("--user") + 2 + ] + assert "HOME=/home/marm" in plan["arguments"] + assert "HOME=/home/marm" in stdio["arguments"] + assert compose["services"]["marm-mcp-server"]["environment"]["HOME"] == "/home/marm" + + def test_docker_run_plan_rejects_invalid_inputs(tmp_path): with pytest.raises(docker_commands.DockerCommandError, match="--port"): docker_commands.build_run_plan(_options(tmp_path, port=0)) @@ -94,11 +116,14 @@ def test_docker_previews_allow_a_new_data_directory(tmp_path): def test_managed_env_file_creates_key_but_explicit_file_must_contain_one( monkeypatch, tmp_path ): - from marm_mcp_server.services import key_management - managed = tmp_path / "managed.env" monkeypatch.setattr(docker_commands, "managed_env_file", lambda: managed) - monkeypatch.setattr(key_management, "generate_api_key", lambda: "generated-key") + + def initialize_key(path): + path.write_text("MARM_API_KEY=generated-key\n", encoding="utf-8") + return path, True + + monkeypatch.setattr(docker_commands, "initialize_managed_key", initialize_key) assert docker_commands.ensure_managed_env_file() == managed assert managed.read_text(encoding="utf-8") == "MARM_API_KEY=generated-key\n" @@ -243,7 +268,11 @@ def test_compose_document_matches_safe_run_defaults(tmp_path): assert service["ports"] == ["127.0.0.1:8001:8001"] assert service["restart"] == "unless-stopped" assert service["command"] == ["--swarm"] - assert service["environment"] == {"SERVER_HOST": "0.0.0.0"} + assert service["environment"] == { + "SERVER_HOST": "0.0.0.0", + "HOME": "/home/marm", + "XDG_CACHE_HOME": "/home/marm/.marm/cache", + } assert service["env_file"] == [str((tmp_path / ".env").resolve())] assert service["volumes"][0]["target"] == "/home/marm/.marm" diff --git a/marm-mcp-server/tests/test_runtime_cli.py b/marm-mcp-server/tests/test_runtime_cli.py index d12c56ae..36bf7a99 100644 --- a/marm-mcp-server/tests/test_runtime_cli.py +++ b/marm-mcp-server/tests/test_runtime_cli.py @@ -239,8 +239,8 @@ def test_key_path_and_reveal_keep_output_intentional(monkeypatch, capsys, tmp_pa cli._dispatch_product(SimpleNamespace(command="key", key_command="reveal")) == 0 ) captured = capsys.readouterr() - assert captured.out.strip() == "saved-key" - assert "terminal capture" in captured.err + assert "saved-key" in (captured.out + captured.err) + assert "terminal capture" in (captured.out + captured.err) def test_product_help_uses_grouped_stable_layout(capsys): @@ -421,6 +421,41 @@ def test_fast_start_does_not_invent_reused_runtime_metadata(monkeypatch, capsys) assert "Profile: unknown" in output +def test_fast_start_reports_runtime_when_console_launch_fails(monkeypatch, capsys): + active_cli, active_runtime = _active_modules() + console_module = importlib.import_module("marm_mcp_server.console.cli") + monkeypatch.setattr( + active_runtime, + "inspect_runtime", + lambda: {"state": "ready", "metadata": {"port": 8001, "profile": "swarm"}}, + ) + monkeypatch.setattr(active_cli.settings, "MARM_API_KEY", "") + monkeypatch.setattr( + console_module, + "run_console", + lambda **_kwargs: (_ for _ in ()).throw(RuntimeError("key mismatch")), + ) + + assert ( + active_cli._fast_start_http( + SimpleNamespace( + profile="standard", + rate_limit_rpm=None, + no_console=False, + no_browser=True, + client=None, + ) + ) + == 0 + ) + + captured = capsys.readouterr() + output = captured.out + captured.err + assert "Runtime: http://127.0.0.1:8001/mcp (reused)" in output + assert "Console: unavailable (key mismatch)" in output + assert "Console: skipped" not in output + + def test_upgrade_check_reports_registry_state_without_installing(monkeypatch, capsys): active_package_management = importlib.import_module( "marm_mcp_server.services.package_management" @@ -457,6 +492,89 @@ def test_upgrade_check_reports_registry_state_without_installing(monkeypatch, ca assert "Latest: 2.27.0" in (captured.out or captured.err) +def test_upgrade_json_is_machine_readable_and_never_starts_an_upgrade( + monkeypatch, capsys +): + active_package_management = importlib.import_module( + "marm_mcp_server.services.package_management" + ) + payload = { + "installed_version": "2.26.0", + "latest_version": "2.27.0", + "state": "update_available", + } + monkeypatch.setattr( + active_package_management, "check_latest_release", lambda: payload + ) + monkeypatch.setattr( + active_package_management, + "inspect_installation", + lambda: pytest.fail("JSON status must not inspect or upgrade the package"), + ) + + assert ( + cli._upgrade( + SimpleNamespace(check=False, as_json=True, version=None, yes=False) + ) + == 0 + ) + captured = capsys.readouterr() + assert json.loads(captured.out or captured.err) == payload + + +@pytest.mark.parametrize("exit_code", [0, 1]) +def test_upgrade_restores_the_previous_runtime_profile(monkeypatch, exit_code): + _active_cli, active_runtime = _active_modules() + active_package_management = importlib.import_module( + "marm_mcp_server.services.package_management" + ) + runtime_status = importlib.import_module("marm_mcp_server.services.runtime_status") + workflows = importlib.import_module("marm_mcp_server.services.product_workflows") + calls = [] + monkeypatch.setattr( + active_package_management, + "inspect_installation", + lambda: active_package_management.Installation("2.26.0", "pip", False), + ) + monkeypatch.setattr( + active_package_management, + "check_latest_release", + lambda: { + "installed_version": "2.26.0", + "latest_version": "2.27.0", + "state": "update_available", + }, + ) + monkeypatch.setattr( + active_package_management, "run_upgrade", lambda _version: exit_code + ) + monkeypatch.setattr( + runtime_status, + "full_status", + lambda: {"runtime": {"state": "ready"}, "console": {"state": "stopped"}}, + ) + monkeypatch.setattr( + active_runtime, + "read_state", + lambda: {"profile": "swarm", "rate_limit_rpm": 200}, + ) + monkeypatch.setattr(active_runtime, "stop_runtime", lambda **_kwargs: None) + monkeypatch.setattr( + active_runtime, + "start_background", + lambda **kwargs: calls.append(kwargs), + ) + monkeypatch.setattr(workflows.os, "name", "posix") + + assert ( + cli._upgrade( + SimpleNamespace(check=False, as_json=False, version=None, yes=True) + ) + == exit_code + ) + assert calls == [{"profile": "swarm", "rate_limit_rpm": 200}] + + def test_uninstall_preview_preserves_data_and_does_not_remove_package( monkeypatch, capsys ): diff --git a/scripts/test-scripts/docker-linux-bind-mount-smoke.sh b/scripts/test-scripts/docker-linux-bind-mount-smoke.sh new file mode 100644 index 00000000..a9623989 --- /dev/null +++ b/scripts/test-scripts/docker-linux-bind-mount-smoke.sh @@ -0,0 +1,84 @@ +#!/usr/bin/env bash +# Linux-only Docker smoke test for host UID/GID bind-mount persistence. +set -Eeuo pipefail + +readonly image="${MARM_DOCKER_SMOKE_IMAGE:-lyellr88/marm-mcp-server:latest}" +readonly smoke_root="$(mktemp -d)" +readonly container_name="marm-linux-smoke-$$" +readonly api_key="marm-linux-smoke-key-4d82fe9a" +readonly data_dir="$smoke_root/data" +readonly env_file="$smoke_root/marm.env" +readonly port="$(python3 - <<'PY' +import socket + +with socket.socket() as sock: + sock.bind(("127.0.0.1", 0)) + print(sock.getsockname()[1]) +PY +)" + +cleanup() { + docker rm -f "$container_name" >/dev/null 2>&1 || true + rm -rf "$smoke_root" +} +trap cleanup EXIT + +start_container() { + docker run -d \ + --name "$container_name" \ + --user "$(id -u):$(id -g)" \ + --mount "type=bind,src=$data_dir,dst=/home/marm/.marm" \ + --env-file "$env_file" \ + -e SERVER_HOST=0.0.0.0 \ + -e HOME=/home/marm \ + -e XDG_CACHE_HOME=/home/marm/.marm/cache \ + -p "127.0.0.1:$port:8001" \ + "$image" >/dev/null +} + +wait_for_health() { + for _attempt in $(seq 1 30); do + if curl --fail --silent "http://127.0.0.1:$port/health" >/dev/null; then + return 0 + fi + sleep 1 + done + docker logs "$container_name" + return 1 +} + +docker image inspect "$image" >/dev/null 2>&1 || docker pull "$image" +mkdir -p "$data_dir" +printf 'MARM_API_KEY=%s\n' "$api_key" > "$env_file" + +start_container +wait_for_health + +response="$(curl --fail --silent --show-error \ + --header "Authorization: Bearer $api_key" \ + --header 'Content-Type: application/json' \ + --data '{"content":"Linux Docker mount smoke memory","session_name":"docker-smoke","context_type":"test","metadata":{"source":"linux-smoke"}}' \ + "http://127.0.0.1:$port/internal/memories")" +memory_id="$(python3 -c 'import json, sys; print(json.load(sys.stdin)["id"])' <<< "$response")" +database="$data_dir/marm_memory.db" + +test -s "$database" +test "$(stat --format='%u:%g' "$database")" = "$(id -u):$(id -g)" + +docker stop "$container_name" >/dev/null +docker rm "$container_name" >/dev/null + +start_container +wait_for_health + +count="$(python3 - "$database" "$memory_id" <<'PY' +import sqlite3 +import sys + +with sqlite3.connect(sys.argv[1]) as connection: + print(connection.execute("SELECT COUNT(*) FROM memories WHERE id = ?", (sys.argv[2],)).fetchone()[0]) +PY +)" +test "$count" = "1" + +echo "PASS: Linux Docker bind-mount persistence verified." From f12db5bfcaaf4275daeff00fa0a3c9935b5100e4 Mon Sep 17 00:00:00 2001 From: Ryan Lyell Date: Thu, 23 Jul 2026 23:36:16 -0400 Subject: [PATCH 3/3] fix(test): generate Docker smoke key at runtime --- scripts/test-scripts/docker-linux-bind-mount-smoke.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/test-scripts/docker-linux-bind-mount-smoke.sh b/scripts/test-scripts/docker-linux-bind-mount-smoke.sh index a9623989..56e613a6 100644 --- a/scripts/test-scripts/docker-linux-bind-mount-smoke.sh +++ b/scripts/test-scripts/docker-linux-bind-mount-smoke.sh @@ -5,7 +5,7 @@ set -Eeuo pipefail readonly image="${MARM_DOCKER_SMOKE_IMAGE:-lyellr88/marm-mcp-server:latest}" readonly smoke_root="$(mktemp -d)" readonly container_name="marm-linux-smoke-$$" -readonly api_key="marm-linux-smoke-key-4d82fe9a" +readonly api_key="$(python3 -c 'import secrets; print(secrets.token_urlsafe(32))')" readonly data_dir="$smoke_root/data" readonly env_file="$smoke_root/marm.env" readonly port="$(python3 - <<'PY'