Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,12 @@ breaking changes only land in a major. Format loosely follows

## [Unreleased]

### Added
- **Grok Build support in `midas init`**: when the `grok` CLI is installed, Midas now uses Grok's
native `grok mcp add --scope user` flow to wire the shared store with `MIDAS_MCP_CLIENT=grok-build`.
`midas status [--json]`, `midas doctor`, `midas init --dry-run`, project-scoped mode, and
`midas uninstall` all understand `~/.grok/config.toml` / `[mcp_servers.midas]` as well.

## [1.0.0] — 2026-07-06

The 1.0 contract: the public surface — the `midas.Memory` SDK, the guard, the coding `is_forbidden`
Expand Down
7 changes: 4 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -142,9 +142,9 @@ wired to, under which scope/policy, and which clients were skipped (config paths
contents). Paste it into a bug report, or let another agent verify the setup without scraping prose.

`midas init` creates **one shared memory** (`~/.midas/memory.sqlite3`) and points the MCP clients it
detects — **Claude Code, Codex, Cursor, Claude Desktop, Windsurf, VS Code, Gemini CLI, Cline, Zed** —
at it. So all your agents read and write the **same** memory, autonomously, with no per-client paths to
keep in sync.
detects — **Claude Code, Codex, Grok Build, Cursor, Claude Desktop, Windsurf, VS Code, Gemini CLI,
Cline, Zed** — at it. So all your agents read and write the **same** memory, autonomously, with no
per-client paths to keep in sync.

Prefer a single endpoint over per-client launches? Run one server and give your clients an **MCP URL**:

Expand Down Expand Up @@ -179,6 +179,7 @@ by default — no path needed. The universal block:
| **Cursor** | `~/.cursor/mcp.json` — paste the JSON block |
| **Claude Desktop** | Settings → Developer → Edit Config (`claude_desktop_config.json`) — paste, restart |
| **Codex CLI** | `codex mcp add midas -- midas-mcp` |
| **Grok Build** | `grok mcp add --scope user midas -e MIDAS_MCP_EMBEDDER=local -e MIDAS_MCP_CLIENT=grok-build -- midas-mcp` |
| **Windsurf** | `~/.codeium/windsurf/mcp_config.json` — paste the block |
| **VS Code** | user `mcp.json` (`servers` key, `"type": "stdio"`) — `midas init` writes it |
| **Gemini CLI** | `~/.gemini/settings.json` (`mcpServers` key) — `midas init` writes it |
Expand Down
2 changes: 1 addition & 1 deletion llms.txt
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Midas

> Midas is the local memory layer for long-horizon AI agents — no LLM at ingest, $0 per message, fully local, every recall traceable to its source. Free and open source under Apache-2.0. Python SDK + MCP server (works with Claude Code, Cursor, Codex, and any MCP client) + TypeScript port.
> Midas is the local memory layer for long-horizon AI agents — no LLM at ingest, $0 per message, fully local, every recall traceable to its source. Free and open source under Apache-2.0. Python SDK + MCP server (works with Claude Code, Cursor, Codex, Grok Build, and any MCP client) + TypeScript port.

Key facts: ingest uses local embeddings only (no LLM calls, zero data egress, ~16–116 ms); recall returns the verbatim source turn, never an LLM rewrite; typed belief revision supersedes stale values; a provenance guard mechanically blocks memory-justified external or destructive actions unless user-confirmed and still current (adversarial suite: attack-success-rate 0.00, benign-pass 1.00). Retrieval measured on full public sets: LongMemEval-s recall@k 0.92, LoCoMo 0.73, BEAM 0.56→0.32 from 100K to 10M tokens; judged answer 0.84 (gpt-4o) ties LLM-at-ingest SOTA at $0 ingest. Known limitation (published): whole-conversation summarization/aggregation is out of scope by design.

Expand Down
21 changes: 15 additions & 6 deletions midas/cli.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
"""The `midas` command — one entry point for setup, serving, updating, and inspecting.

midas init create the shared memory + wire up your MCP clients (Claude Code, Codex, Cursor…)
midas init create shared memory + wire MCP clients (Claude, Codex, Grok Build…)
midas serve run the MCP server (stdio; or --http for an MCP URL all clients can share)
midas status show the store, version, and which clients are configured
midas update upgrade Midas to the latest version
midas inspect open the local Memory Inspector

**One memory, shared.** By default every client points at `~/.midas/memory.sqlite3`, so Claude Code,
Codex and Cursor all read and write the SAME memory — autonomously, with no per-client paths to keep in
sync. `midas init` does the wiring for you.
Codex, Grok Build, and Cursor all use the SAME memory — autonomously, with no per-client paths to
keep in sync. `midas init` does the wiring for you.
"""
from __future__ import annotations

Expand Down Expand Up @@ -122,8 +122,9 @@ def _merge_mcp_json(path: Path, block: dict, *, dry: bool, key: str = "mcpServer

def _cli_add(cmd: list[str], *, dry: bool, force: bool = False,
remove: list[str] | None = None) -> tuple[str, bool] | None:
"""Configure a client via its own CLI (claude/codex). None if that CLI isn't installed; otherwise
(message, ok). With force, remove any existing `midas` entry first so re-running init can't collide."""
"""Configure a client via its own CLI (Claude/Codex/Grok). None if that CLI isn't installed;
otherwise (message, ok). With force, remove any existing `midas` entry first so re-running init
can't collide."""
if not shutil.which(cmd[0]):
return None
if dry:
Expand Down Expand Up @@ -223,6 +224,10 @@ def already_wired(path: Path) -> bool:
("Codex", Path.home() / ".codex/config.toml", codex_note,
["codex", "mcp", "add", "midas", "--", "midas-mcp"],
["codex", "mcp", "remove", "midas"]),
("Grok Build", Path.home() / ".grok/config.toml", "",
["grok", "mcp", "add", "--scope", "user", "midas", *eflags(env_for("grok-build")),
"--", "midas-mcp"],
["grok", "mcp", "remove", "--scope", "user", "midas"]),
):
res = _cli_add(add_cmd, dry=dry, force=force, remove=rm_cmd)
if res is None:
Expand Down Expand Up @@ -409,7 +414,8 @@ def cmd_inspect(args: argparse.Namespace) -> int:
def _client_paths() -> list[tuple[str, Path, str]]:
"""Every client Midas knows how to check: (name, config path, JSON/TOML key of the server map)."""
paths = [("Claude Code", Path.home() / ".claude.json", "mcpServers"),
("Codex", Path.home() / ".codex/config.toml", "mcp_servers")]
("Codex", Path.home() / ".codex/config.toml", "mcp_servers"),
("Grok Build", Path.home() / ".grok/config.toml", "mcp_servers")]
paths += [(name, p, key) for name, _cid, p, key, _shape in _json_clients()]
return paths

Expand Down Expand Up @@ -688,6 +694,9 @@ def cmd_uninstall(args: argparse.Namespace) -> int:
r = _cli_remove("codex", ["codex", "mcp", "remove", "midas"])
if r:
results.append(("Codex", r))
r = _cli_remove("grok", ["grok", "mcp", "remove", "--scope", "user", "midas"])
if r:
results.append(("Grok Build", r))
from midas.hooks import uninstall_claude_hook

r = uninstall_claude_hook(Path.home() / ".claude" / "settings.json")
Expand Down
64 changes: 62 additions & 2 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,10 @@ def test_init_dry_run_creates_no_store(tmp_path) -> None:
assert rc == 0 and not db.exists()


def test_init_creates_the_store(tmp_path) -> None:
def test_init_creates_the_store(tmp_path, monkeypatch) -> None:
# Never call real client CLIs in tests.
monkeypatch.setattr(cli.shutil, "which", lambda exe: None)
_sandbox_client_paths(monkeypatch, tmp_path)
db = tmp_path / "sub" / "memory.sqlite3"
rc = cli.cmd_init(argparse.Namespace(db=str(db), dry_run=False, all=False))
assert rc == 0 and db.exists() # the shared store is created (parent dir too)
Expand All @@ -65,7 +68,7 @@ def test_init_json_receipt_dry_run(tmp_path, capsys) -> None:
assert receipt["scope_mode"] == "shared" and receipt["namespace"] is None
assert receipt["policy"]["external_or_destructive_actions_require_check_memory_use"] is True
names = {c["client"] for c in receipt["clients"]}
assert {"Claude Code", "Codex", "Cursor", "Windsurf"} <= names # skipped clients are explicit
assert {"Claude Code", "Codex", "Grok Build", "Cursor", "Windsurf"} <= names
for c in receipt["clients"]:
assert {"client", "config_path", "detected", "wired", "changed", "backup_path",
"reason"} <= set(c)
Expand Down Expand Up @@ -124,6 +127,24 @@ def test_status_json_parses_codex_toml(tmp_path, capsys, monkeypatch) -> None:
assert receipt["scope_mode"] == "shared" # no namespace anywhere → one shared pool


def test_status_json_parses_grok_build_toml(tmp_path, capsys, monkeypatch) -> None:
_sandbox_client_paths(monkeypatch, tmp_path)
grok = tmp_path / ".grok/config.toml"
grok.parent.mkdir(parents=True)
grok.write_text(
'[mcp_servers.midas]\ncommand = "midas-mcp"\n'
'[mcp_servers.midas.env]\nMIDAS_MCP_EMBEDDER = "local"\n'
'MIDAS_MCP_CLIENT = "grok-build"\nMIDAS_MCP_NAMESPACE = "auto"\n'
)
cli.cmd_status(argparse.Namespace(db=str(tmp_path / "m.sqlite3"), json=True))
receipt = json.loads(capsys.readouterr().out)
by = {c["client"]: c for c in receipt["clients"]}
assert by["Grok Build"]["wired"] and by["Grok Build"]["server_command"] == "midas-mcp"
assert by["Grok Build"]["client_id"] == "grok-build"
assert by["Grok Build"]["namespace"] == "auto"
assert receipt["scope_mode"] == "project-scoped"


def test_client_block_shapes() -> None:
env = {"MIDAS_MCP_EMBEDDER": "local"}
assert cli._client_block("std", env) == {"command": "midas-mcp", "env": env}
Expand Down Expand Up @@ -168,6 +189,45 @@ def test_init_wires_new_clients(tmp_path, capsys, monkeypatch) -> None:
assert sby["Gemini CLI"]["wired"] and sby["Gemini CLI"]["client_id"] == "gemini-cli"


def test_init_wires_grok_build_via_native_cli(tmp_path, capsys, monkeypatch) -> None:
_sandbox_client_paths(monkeypatch, tmp_path)
calls: list[list[str]] = []
monkeypatch.setattr(cli.shutil, "which", lambda exe: "/fake/grok" if exe == "grok" else None)

def fake_run(cmd, **kwargs):
calls.append(cmd)
return argparse.Namespace(returncode=0, stdout="", stderr="")

monkeypatch.setattr(cli.subprocess, "run", fake_run)
rc = cli.cmd_init(argparse.Namespace(db=str(tmp_path / "m.sqlite3"), dry_run=False,
all=False, json=True, project_scoped=True))
assert rc == 0
receipt = json.loads(capsys.readouterr().out)
grok = {c["client"]: c for c in receipt["clients"]}["Grok Build"]
assert grok["detected"] and grok["wired"] and grok["changed"]
assert grok["config_path"] == str(tmp_path / ".grok/config.toml")
assert calls == [[
"grok", "mcp", "add", "--scope", "user", "midas",
"-e", "MIDAS_MCP_EMBEDDER=local", "-e", "MIDAS_MCP_CLIENT=grok-build",
"-e", "MIDAS_MCP_NAMESPACE=auto", "--", "midas-mcp",
]]


def test_uninstall_removes_grok_build_via_native_cli(tmp_path, capsys, monkeypatch) -> None:
_sandbox_client_paths(monkeypatch, tmp_path)
calls: list[list[str]] = []
monkeypatch.setattr(cli.shutil, "which", lambda exe: "/fake/grok" if exe == "grok" else None)

def fake_run(cmd, **kwargs):
calls.append(cmd)
return argparse.Namespace(returncode=0, stdout="", stderr="")

monkeypatch.setattr(cli.subprocess, "run", fake_run)
assert cli.cmd_uninstall(argparse.Namespace(db=str(tmp_path / "m.sqlite3"), purge=False)) == 0
assert calls == [["grok", "mcp", "remove", "--scope", "user", "midas"]]
assert "Grok Build: removed" in capsys.readouterr().out


def test_uninstall_removes_from_custom_key(tmp_path, capsys, monkeypatch) -> None:
monkeypatch.setattr(cli.shutil, "which", lambda exe: None)
_sandbox_client_paths(monkeypatch, tmp_path)
Expand Down
Loading