mcp-memex 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,7 @@
1
+ {
2
+ "permissions": {
3
+ "allow": [
4
+ "Bash(gh pr *)"
5
+ ]
6
+ }
7
+ }
@@ -0,0 +1,11 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ *.egg-info/
4
+ dist/
5
+ build/
6
+ .venv/
7
+ venv/
8
+ .env
9
+ *.db
10
+ *.db-shm
11
+ *.db-wal
@@ -0,0 +1,32 @@
1
+ ## memex: Session Memory
2
+
3
+ At the **start of every session**, call `mem_load` with a brief hint about
4
+ what you're working on. This gives you context from past sessions so you
5
+ don't rediscover things you already know.
6
+
7
+ ```
8
+ mem_load(hint="<what you're about to work on>", files=["<relevant files>"])
9
+ ```
10
+
11
+ During a session, call `mem_save` whenever you:
12
+ - Finish a meaningful task
13
+ - Make an architectural or design decision
14
+ - Discover something surprising or easy to get wrong
15
+
16
+ ```
17
+ mem_save(
18
+ task="One-sentence summary of what was done",
19
+ files=["list", "of", "files", "touched"],
20
+ decisions=["Any design decisions made"],
21
+ warnings=["Anything surprising or easy to get wrong"],
22
+ tags=["short", "labels"],
23
+ notes="Any extra freeform context"
24
+ )
25
+ ```
26
+
27
+ Other tools:
28
+ - `mem_search(query)` — find past work on a specific topic
29
+ - `mem_list()` — see all stored entries
30
+ - `mem_delete(id)` — remove stale entries
31
+
32
+ Memory is stored locally in ~/.memex/ as SQLite — no LLMs, no network.
@@ -0,0 +1,36 @@
1
+ # Contributing to memex
2
+
3
+ Thanks for wanting to help. Here's everything you need to get started.
4
+
5
+ ## Setup
6
+
7
+ ```bash
8
+ git clone https://github.com/pragneshbagary/memex.git
9
+ cd memex
10
+ python3 -m pip install -e ".[dev]"
11
+ ```
12
+
13
+ The only runtime dependency is `mcp`. No build tools, no database to spin up.
14
+
15
+ ## Where to start
16
+
17
+ Check the [`good first issue`](https://github.com/pragneshbagary/memex/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22) label — these are self-contained and well-scoped.
18
+
19
+ The two main files:
20
+
21
+ | File | What it contains |
22
+ |------|-----------------|
23
+ | `memex/server.py` | The MCP server and all five tools (`mem_save`, `mem_load`, etc.) |
24
+ | `memex/cli.py` | The `memex` CLI (`install`, `remove`, `list`, `search`) |
25
+
26
+ ## Submitting a PR
27
+
28
+ 1. Fork the repo and create a branch
29
+ 2. Make your change
30
+ 3. Make sure `memex install` and `memex list` still work
31
+ 4. Open a PR — describe what you changed and why
32
+
33
+
34
+ ## Questions
35
+
36
+ Open an issue or start a discussion. Happy to help.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Pragnesh Bagary
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,127 @@
1
+ Metadata-Version: 2.4
2
+ Name: mcp-memex
3
+ Version: 0.1.0
4
+ Summary: Persistent session memory for Claude Code — local SQLite, no LLMs, no network
5
+ Project-URL: Repository, https://github.com/pragneshbagary/memex
6
+ Project-URL: Issues, https://github.com/pragneshbagary/memex/issues
7
+ License: MIT
8
+ License-File: LICENSE
9
+ Requires-Python: >=3.10
10
+ Requires-Dist: mcp>=1.0
11
+ Description-Content-Type: text/markdown
12
+
13
+ # memex
14
+
15
+ Every Claude Code session starts without knowing what you built last time — which files you changed, what decisions you made, what broke. You re-explain. Claude re-reads. You start over.
16
+
17
+ **memex keeps a structured log of every session. Claude reads it at the start of the next one.**
18
+
19
+ No cloud. No LLM extraction. No cost per save. Just local SQLite and two commands to install.
20
+
21
+ ## Install
22
+
23
+ ```bash
24
+ pip install memex
25
+ memex install
26
+ ```
27
+
28
+ Restart Claude Code. That's it.
29
+
30
+ ## What gets saved
31
+
32
+ Each entry has structure — not a blob of text:
33
+
34
+ ```python
35
+ mem_save(
36
+ task="Replaced JWT auth with session cookies",
37
+ files=["src/auth.py", "src/middleware.py"],
38
+ decisions=["Session cookies over JWT — simpler, no token refresh needed"],
39
+ warnings=["Redis required — app won't start without REDIS_URL set"],
40
+ tags=["auth", "sessions"]
41
+ )
42
+ ```
43
+
44
+ Claude calls `mem_save` when it finishes something meaningful. You can also just tell it: *"save what we just did."*
45
+
46
+ ## What Claude sees at session start
47
+
48
+ ```
49
+ === memex: session context (db: my_project.db) ===
50
+
51
+ --- Recent (2) ---
52
+ [1] #4 — 2026-06-14T10:32
53
+ Task : Replaced JWT auth with session cookies
54
+ Files : src/auth.py, src/middleware.py
55
+ Decision : Session cookies over JWT — simpler, no token refresh needed
56
+ Warning : Redis required — app won't start without REDIS_URL set
57
+ Tags : auth, sessions
58
+
59
+ [2] #3 — 2026-06-13T15:10
60
+ Task : Added rate limiting to /api/login
61
+ Files : src/middleware.py
62
+ Warning : Rate limiter is in-memory per process — resets on restart
63
+ Tags : auth, rate-limiting
64
+ ```
65
+
66
+ Claude calls `mem_load` automatically at the start of every session. It returns the most recent entries plus any entries that match what you're working on — by keyword and by file path.
67
+
68
+ ## Browse from the terminal
69
+
70
+ You don't need to be inside Claude to look at your history:
71
+
72
+ ```bash
73
+ memex list # recent entries for this project
74
+ memex list --tag auth # filter by tag
75
+ memex search "rate limit" # full-text search
76
+ ```
77
+
78
+ ## Five MCP tools
79
+
80
+ | Tool | What it does |
81
+ |------|--------------|
82
+ | `mem_load` | Called at session start — returns recent + relevant entries |
83
+ | `mem_save` | Saves a structured entry after meaningful work |
84
+ | `mem_search` | Full-text search across all entries |
85
+ | `mem_list` | Lists entries, optionally filtered by tag |
86
+ | `mem_delete` | Removes a stale entry by id |
87
+
88
+ ## Why not just CLAUDE.md?
89
+
90
+ `CLAUDE.md` is for static project documentation — architecture, conventions, how to run tests. It doesn't change much and it isn't session-aware.
91
+
92
+ memex captures what's changing session to session: what you built yesterday, the decision you made this morning, the warning you discovered an hour ago. It's the difference between *"here's the project"* and *"here's what happened last time."*
93
+
94
+ ## Memory is scoped per project
95
+
96
+ Each project gets its own SQLite database at `~/.memex/<project>.db` based on the working directory. Sessions from different projects never mix.
97
+
98
+ ## Configuration
99
+
100
+ Set these in the MCP `env` block in `~/.claude.json` if you need to override defaults:
101
+
102
+ | Variable | Default | Description |
103
+ |----------|---------|-------------|
104
+ | `MEMEX_DIR` | `~/.memex` | Where DBs are stored |
105
+ | `MEMEX_GLOBAL` | `0` | Set to `1` to share one DB across all projects |
106
+ | `MEMEX_RECENT` | `5` | Max recent entries loaded per session |
107
+ | `MEMEX_MATCHED` | `5` | Max FTS-matched entries loaded per session |
108
+
109
+ ## Uninstall
110
+
111
+ ```bash
112
+ memex remove
113
+ pip uninstall memex
114
+ ```
115
+
116
+ Memory DBs are kept at `~/.memex/` — delete that directory manually if you want to wipe everything.
117
+
118
+ ## Requirements
119
+
120
+ - Python 3.10+
121
+ - `mcp` package (installed automatically)
122
+ - SQLite with FTS5 (standard since Python 3.8)
123
+ - Claude Code CLI
124
+
125
+ ## License
126
+
127
+ MIT
@@ -0,0 +1,115 @@
1
+ # memex
2
+
3
+ Every Claude Code session starts without knowing what you built last time — which files you changed, what decisions you made, what broke. You re-explain. Claude re-reads. You start over.
4
+
5
+ **memex keeps a structured log of every session. Claude reads it at the start of the next one.**
6
+
7
+ No cloud. No LLM extraction. No cost per save. Just local SQLite and two commands to install.
8
+
9
+ ## Install
10
+
11
+ ```bash
12
+ pip install memex
13
+ memex install
14
+ ```
15
+
16
+ Restart Claude Code. That's it.
17
+
18
+ ## What gets saved
19
+
20
+ Each entry has structure — not a blob of text:
21
+
22
+ ```python
23
+ mem_save(
24
+ task="Replaced JWT auth with session cookies",
25
+ files=["src/auth.py", "src/middleware.py"],
26
+ decisions=["Session cookies over JWT — simpler, no token refresh needed"],
27
+ warnings=["Redis required — app won't start without REDIS_URL set"],
28
+ tags=["auth", "sessions"]
29
+ )
30
+ ```
31
+
32
+ Claude calls `mem_save` when it finishes something meaningful. You can also just tell it: *"save what we just did."*
33
+
34
+ ## What Claude sees at session start
35
+
36
+ ```
37
+ === memex: session context (db: my_project.db) ===
38
+
39
+ --- Recent (2) ---
40
+ [1] #4 — 2026-06-14T10:32
41
+ Task : Replaced JWT auth with session cookies
42
+ Files : src/auth.py, src/middleware.py
43
+ Decision : Session cookies over JWT — simpler, no token refresh needed
44
+ Warning : Redis required — app won't start without REDIS_URL set
45
+ Tags : auth, sessions
46
+
47
+ [2] #3 — 2026-06-13T15:10
48
+ Task : Added rate limiting to /api/login
49
+ Files : src/middleware.py
50
+ Warning : Rate limiter is in-memory per process — resets on restart
51
+ Tags : auth, rate-limiting
52
+ ```
53
+
54
+ Claude calls `mem_load` automatically at the start of every session. It returns the most recent entries plus any entries that match what you're working on — by keyword and by file path.
55
+
56
+ ## Browse from the terminal
57
+
58
+ You don't need to be inside Claude to look at your history:
59
+
60
+ ```bash
61
+ memex list # recent entries for this project
62
+ memex list --tag auth # filter by tag
63
+ memex search "rate limit" # full-text search
64
+ ```
65
+
66
+ ## Five MCP tools
67
+
68
+ | Tool | What it does |
69
+ |------|--------------|
70
+ | `mem_load` | Called at session start — returns recent + relevant entries |
71
+ | `mem_save` | Saves a structured entry after meaningful work |
72
+ | `mem_search` | Full-text search across all entries |
73
+ | `mem_list` | Lists entries, optionally filtered by tag |
74
+ | `mem_delete` | Removes a stale entry by id |
75
+
76
+ ## Why not just CLAUDE.md?
77
+
78
+ `CLAUDE.md` is for static project documentation — architecture, conventions, how to run tests. It doesn't change much and it isn't session-aware.
79
+
80
+ memex captures what's changing session to session: what you built yesterday, the decision you made this morning, the warning you discovered an hour ago. It's the difference between *"here's the project"* and *"here's what happened last time."*
81
+
82
+ ## Memory is scoped per project
83
+
84
+ Each project gets its own SQLite database at `~/.memex/<project>.db` based on the working directory. Sessions from different projects never mix.
85
+
86
+ ## Configuration
87
+
88
+ Set these in the MCP `env` block in `~/.claude.json` if you need to override defaults:
89
+
90
+ | Variable | Default | Description |
91
+ |----------|---------|-------------|
92
+ | `MEMEX_DIR` | `~/.memex` | Where DBs are stored |
93
+ | `MEMEX_GLOBAL` | `0` | Set to `1` to share one DB across all projects |
94
+ | `MEMEX_RECENT` | `5` | Max recent entries loaded per session |
95
+ | `MEMEX_MATCHED` | `5` | Max FTS-matched entries loaded per session |
96
+
97
+ ## Uninstall
98
+
99
+ ```bash
100
+ memex remove
101
+ pip uninstall memex
102
+ ```
103
+
104
+ Memory DBs are kept at `~/.memex/` — delete that directory manually if you want to wipe everything.
105
+
106
+ ## Requirements
107
+
108
+ - Python 3.10+
109
+ - `mcp` package (installed automatically)
110
+ - SQLite with FTS5 (standard since Python 3.8)
111
+ - Claude Code CLI
112
+
113
+ ## License
114
+
115
+ MIT
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
@@ -0,0 +1,266 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ memex CLI — wire memex into Claude Code and browse memories from the terminal.
4
+
5
+ Usage:
6
+ memex install # global (~/.claude.json)
7
+ memex install --local # this project only (.claude.json)
8
+ memex remove # remove from config
9
+ memex list [--tag TAG] # show recent entries for this project
10
+ memex search QUERY # search entries for this project
11
+ memex version # print version
12
+ """
13
+
14
+ import argparse
15
+ import json
16
+ import os
17
+ import re
18
+ import sqlite3
19
+ import sys
20
+ from pathlib import Path
21
+
22
+ from memex import __version__
23
+
24
+ GLOBAL_CONFIG = Path.home() / ".claude.json"
25
+ LOCAL_CONFIG = Path.cwd() / ".claude.json"
26
+
27
+ CLAUDE_MD_SNIPPET = """
28
+ ## memex: Session Memory
29
+
30
+ At the **start of every session**, call `mem_load` with a brief hint about
31
+ what you're working on. This gives you context from past sessions so you
32
+ don't rediscover things you already know.
33
+
34
+ ```
35
+ mem_load(hint="<what you're about to work on>", files=["<relevant files>"])
36
+ ```
37
+
38
+ During a session, call `mem_save` whenever you:
39
+ - Finish a meaningful task
40
+ - Make an architectural or design decision
41
+ - Discover something surprising or easy to get wrong
42
+
43
+ ```
44
+ mem_save(
45
+ task="One-sentence summary of what was done",
46
+ files=["list", "of", "files", "touched"],
47
+ decisions=["Any design decisions made"],
48
+ warnings=["Anything surprising or easy to get wrong"],
49
+ tags=["short", "labels"],
50
+ notes="Any extra freeform context"
51
+ )
52
+ ```
53
+
54
+ Other tools:
55
+ - `mem_search(query)` — find past work on a specific topic
56
+ - `mem_list()` — see all stored entries
57
+ - `mem_delete(id)` — remove stale entries
58
+
59
+ Memory is stored locally in ~/.memex/ as SQLite — no LLMs, no network.
60
+ """.strip()
61
+
62
+
63
+ # ---------------------------------------------------------------------------
64
+ # Helpers
65
+ # ---------------------------------------------------------------------------
66
+
67
+ def _load_json(path: Path) -> dict:
68
+ if path.exists():
69
+ try:
70
+ return json.loads(path.read_text())
71
+ except json.JSONDecodeError:
72
+ print(f" ⚠ Could not parse {path} — treating as empty")
73
+ return {}
74
+
75
+
76
+ def _save_json(path: Path, data: dict) -> None:
77
+ path.write_text(json.dumps(data, indent=2) + "\n")
78
+
79
+
80
+ def _db_path() -> Path:
81
+ db_dir = Path(os.environ.get("MEMEX_DIR", Path.home() / ".memex"))
82
+ if os.environ.get("MEMEX_GLOBAL", "0") == "1":
83
+ return db_dir / "global.db"
84
+ cwd = os.environ.get("MEMEX_PROJECT", os.getcwd())
85
+ safe = re.sub(r"[^a-zA-Z0-9_\-]", "_", cwd).strip("_")[:120]
86
+ return db_dir / f"{safe}.db"
87
+
88
+
89
+ def _format_row(row: dict) -> str:
90
+ lines = [f"#{row['id']} — {row['timestamp'][:16]} {row['task']}"]
91
+ for field, label in (("files", "Files"), ("decisions", "Decision"), ("warnings", "Warning")):
92
+ try:
93
+ items = json.loads(row[field])
94
+ except (json.JSONDecodeError, TypeError):
95
+ items = []
96
+ for item in items:
97
+ lines.append(f" {label:<9}: {item}")
98
+ if row.get("raw"):
99
+ lines.append(f" Notes : {row['raw']}")
100
+ return "\n".join(lines)
101
+
102
+
103
+ # ---------------------------------------------------------------------------
104
+ # Commands
105
+ # ---------------------------------------------------------------------------
106
+
107
+ def install(local: bool = False) -> None:
108
+ config_path = LOCAL_CONFIG if local else GLOBAL_CONFIG
109
+ scope = "local project" if local else "global"
110
+ print(f"Installing memex ({scope})...")
111
+
112
+ config = _load_json(config_path)
113
+ config.setdefault("mcpServers", {})
114
+
115
+ config["mcpServers"]["memex"] = {
116
+ "type": "stdio",
117
+ "command": sys.executable,
118
+ "args": ["-m", "memex.server"],
119
+ }
120
+
121
+ _save_json(config_path, config)
122
+ print(f" ✓ MCP entry written: {config_path}")
123
+
124
+ claude_md = Path.cwd() / "CLAUDE.md"
125
+ if claude_md.exists():
126
+ existing = claude_md.read_text()
127
+ if "memex" in existing:
128
+ print(" ✓ CLAUDE.md already has memex section — skipped")
129
+ else:
130
+ claude_md.write_text(existing.rstrip() + "\n\n" + CLAUDE_MD_SNIPPET + "\n")
131
+ print(" ✓ Appended memex section to CLAUDE.md")
132
+ else:
133
+ claude_md.write_text(CLAUDE_MD_SNIPPET + "\n")
134
+ print(" ✓ Created CLAUDE.md")
135
+
136
+ print()
137
+ print("Done! Start a new Claude Code session — memex will be active automatically.")
138
+ print(f"Memory DB location: ~/.memex/")
139
+
140
+
141
+ def remove() -> None:
142
+ print("Removing memex...")
143
+ removed = False
144
+ for config_path in [GLOBAL_CONFIG, LOCAL_CONFIG]:
145
+ config = _load_json(config_path)
146
+ mcp_servers = config.get("mcpServers", {})
147
+ for key in ("memex", "claude-mem"):
148
+ if key in mcp_servers:
149
+ del mcp_servers[key]
150
+ _save_json(config_path, config)
151
+ print(f" ✓ Removed '{key}' from {config_path}")
152
+ removed = True
153
+
154
+ if not removed:
155
+ print(" — memex not found in any Claude Code config")
156
+
157
+ print()
158
+ print("Note: memory DBs kept at ~/.memex/ — delete manually if you want to wipe them.")
159
+
160
+
161
+ def list_entries(tag: str | None = None, limit: int = 20) -> None:
162
+ db = _db_path()
163
+ if not db.exists():
164
+ print("No memories saved for this project yet.")
165
+ return
166
+
167
+ conn = sqlite3.connect(db)
168
+ conn.row_factory = sqlite3.Row
169
+ if tag:
170
+ rows = conn.execute(
171
+ "SELECT * FROM entries WHERE tags LIKE ? ORDER BY id DESC LIMIT ?",
172
+ (f'%"{tag}"%', limit),
173
+ ).fetchall()
174
+ else:
175
+ rows = conn.execute(
176
+ "SELECT * FROM entries ORDER BY id DESC LIMIT ?", (limit,)
177
+ ).fetchall()
178
+ conn.close()
179
+
180
+ if not rows:
181
+ print(f"No entries{f' with tag {tag!r}' if tag else ''}.")
182
+ return
183
+
184
+ print(f"=== memex: {len(rows)} entries ({db.name}) ===\n")
185
+ for row in rows:
186
+ print(_format_row(dict(row)))
187
+ print()
188
+
189
+
190
+ def search_entries(query: str, limit: int = 10) -> None:
191
+ db = _db_path()
192
+ if not db.exists():
193
+ print("No memories saved for this project yet.")
194
+ return
195
+
196
+ safe = re.sub(r"[^a-zA-Z0-9 _\-]", " ", query).strip()
197
+ if not safe:
198
+ print("Query is empty after sanitisation.")
199
+ return
200
+
201
+ conn = sqlite3.connect(db)
202
+ conn.row_factory = sqlite3.Row
203
+ rows = conn.execute(
204
+ """SELECT e.* FROM entries e
205
+ JOIN entries_fts f ON f.rowid = e.id
206
+ WHERE entries_fts MATCH ?
207
+ ORDER BY rank LIMIT ?""",
208
+ (safe, limit),
209
+ ).fetchall()
210
+ conn.close()
211
+
212
+ if not rows:
213
+ print(f"No entries matched '{query}'.")
214
+ return
215
+
216
+ print(f"=== memex: {len(rows)} results for '{query}' ===\n")
217
+ for row in rows:
218
+ print(_format_row(dict(row)))
219
+ print()
220
+
221
+
222
+ # ---------------------------------------------------------------------------
223
+ # Entry point
224
+ # ---------------------------------------------------------------------------
225
+
226
+ def main() -> None:
227
+ parser = argparse.ArgumentParser(
228
+ description="memex — persistent session memory for Claude Code",
229
+ formatter_class=argparse.RawDescriptionHelpFormatter,
230
+ )
231
+ subparsers = parser.add_subparsers(dest="command", metavar="command")
232
+
233
+ install_parser = subparsers.add_parser("install", help="Install memex into Claude Code")
234
+ install_parser.add_argument("--local", action="store_true",
235
+ help="Install for this project only")
236
+
237
+ subparsers.add_parser("remove", help="Remove memex from Claude Code config")
238
+
239
+ list_parser = subparsers.add_parser("list", help="Show recent memory entries")
240
+ list_parser.add_argument("--tag", help="Filter by tag")
241
+ list_parser.add_argument("--limit", type=int, default=20, help="Max entries (default 20)")
242
+
243
+ search_parser = subparsers.add_parser("search", help="Search memory entries")
244
+ search_parser.add_argument("query", help="Search query")
245
+ search_parser.add_argument("--limit", type=int, default=10, help="Max results (default 10)")
246
+
247
+ subparsers.add_parser("version", help="Print version")
248
+
249
+ args = parser.parse_args()
250
+
251
+ if args.command == "install":
252
+ install(local=args.local)
253
+ elif args.command == "remove":
254
+ remove()
255
+ elif args.command == "list":
256
+ list_entries(tag=args.tag, limit=args.limit)
257
+ elif args.command == "search":
258
+ search_entries(query=args.query, limit=args.limit)
259
+ elif args.command == "version":
260
+ print(f"memex {__version__}")
261
+ else:
262
+ parser.print_help()
263
+
264
+
265
+ if __name__ == "__main__":
266
+ main()
@@ -0,0 +1,373 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ memex: Persistent session memory for Claude Code.
4
+ No LLMs, no embeddings — just SQLite + FTS5 + structured entries.
5
+
6
+ Tools exposed via MCP:
7
+ mem_save — save a session entry (task, decisions, warnings, files, tags)
8
+ mem_load — retrieve relevant entries for a new session
9
+ mem_search — free-text search across all entries
10
+ mem_list — list recent entries (for inspection / housekeeping)
11
+ mem_delete — remove an entry by id
12
+ """
13
+
14
+ import json
15
+ import os
16
+ import re
17
+ import sqlite3
18
+ from datetime import datetime, timezone
19
+ from pathlib import Path
20
+ from typing import Optional
21
+
22
+ from mcp.server.fastmcp import FastMCP
23
+
24
+ # ---------------------------------------------------------------------------
25
+ # Config
26
+ # ---------------------------------------------------------------------------
27
+
28
+ DB_DIR = Path(os.environ.get("MEMEX_DIR", Path.home() / ".memex"))
29
+ DB_DIR.mkdir(parents=True, exist_ok=True)
30
+
31
+ # By default, memory is scoped per-project using the CWD at server startup.
32
+ # Set MEMEX_GLOBAL=1 to share one DB across all projects.
33
+ _global = os.environ.get("MEMEX_GLOBAL", "0") == "1"
34
+ if _global:
35
+ DB_PATH = DB_DIR / "global.db"
36
+ else:
37
+ cwd = os.environ.get("MEMEX_PROJECT", os.getcwd())
38
+ safe = re.sub(r"[^a-zA-Z0-9_\-]", "_", cwd).strip("_")[:120]
39
+ DB_PATH = DB_DIR / f"{safe}.db"
40
+
41
+ MAX_LOAD_RECENT = int(os.environ.get("MEMEX_RECENT", "5"))
42
+ MAX_LOAD_MATCHED = int(os.environ.get("MEMEX_MATCHED", "5"))
43
+
44
+ # ---------------------------------------------------------------------------
45
+ # DB setup
46
+ # ---------------------------------------------------------------------------
47
+
48
+ def get_conn() -> sqlite3.Connection:
49
+ conn = sqlite3.connect(DB_PATH)
50
+ conn.row_factory = sqlite3.Row
51
+ conn.execute("PRAGMA journal_mode=WAL")
52
+ conn.execute("PRAGMA foreign_keys=ON")
53
+ return conn
54
+
55
+
56
+ def init_db() -> None:
57
+ with get_conn() as conn:
58
+ conn.executescript("""
59
+ CREATE TABLE IF NOT EXISTS entries (
60
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
61
+ project TEXT NOT NULL DEFAULT '',
62
+ timestamp TEXT NOT NULL,
63
+ task TEXT NOT NULL,
64
+ files TEXT NOT NULL DEFAULT '[]',
65
+ decisions TEXT NOT NULL DEFAULT '[]',
66
+ warnings TEXT NOT NULL DEFAULT '[]',
67
+ tags TEXT NOT NULL DEFAULT '[]',
68
+ raw TEXT NOT NULL DEFAULT ''
69
+ );
70
+
71
+ CREATE VIRTUAL TABLE IF NOT EXISTS entries_fts USING fts5(
72
+ task,
73
+ files,
74
+ decisions,
75
+ warnings,
76
+ tags,
77
+ raw,
78
+ content='entries',
79
+ content_rowid='id'
80
+ );
81
+
82
+ CREATE TRIGGER IF NOT EXISTS entries_ai AFTER INSERT ON entries BEGIN
83
+ INSERT INTO entries_fts(rowid, task, files, decisions, warnings, tags, raw)
84
+ VALUES (new.id, new.task, new.files, new.decisions, new.warnings, new.tags, new.raw);
85
+ END;
86
+
87
+ CREATE TRIGGER IF NOT EXISTS entries_ad AFTER DELETE ON entries BEGIN
88
+ INSERT INTO entries_fts(entries_fts, rowid, task, files, decisions, warnings, tags, raw)
89
+ VALUES ('delete', old.id, old.task, old.files, old.decisions, old.warnings, old.tags, old.raw);
90
+ END;
91
+
92
+ CREATE TRIGGER IF NOT EXISTS entries_au AFTER UPDATE ON entries BEGIN
93
+ INSERT INTO entries_fts(entries_fts, rowid, task, files, decisions, warnings, tags, raw)
94
+ VALUES ('delete', old.id, old.task, old.files, old.decisions, old.warnings, old.tags, old.raw);
95
+ INSERT INTO entries_fts(rowid, task, files, decisions, warnings, tags, raw)
96
+ VALUES (new.id, new.task, new.files, new.decisions, new.warnings, new.tags, new.raw);
97
+ END;
98
+ """)
99
+ # Migrate existing DBs with old column names
100
+ cols = {row[1] for row in conn.execute("PRAGMA table_info(entries)")}
101
+ if "gotchas" in cols:
102
+ conn.execute("ALTER TABLE entries RENAME COLUMN gotchas TO warnings")
103
+
104
+
105
+ init_db()
106
+
107
+ # ---------------------------------------------------------------------------
108
+ # Helpers
109
+ # ---------------------------------------------------------------------------
110
+
111
+ def _row_to_dict(row: sqlite3.Row) -> dict:
112
+ d = dict(row)
113
+ for field in ("files", "decisions", "warnings", "tags"):
114
+ try:
115
+ d[field] = json.loads(d[field])
116
+ except (json.JSONDecodeError, TypeError):
117
+ d[field] = []
118
+ return d
119
+
120
+
121
+ def _format_entry(e: dict, idx: Optional[int] = None) -> str:
122
+ prefix = f"[{idx}] " if idx is not None else ""
123
+ lines = [
124
+ f"{prefix}#{e['id']} — {e['timestamp'][:16]}",
125
+ f" Task : {e['task']}",
126
+ ]
127
+ if e.get("files"):
128
+ lines.append(f" Files : {', '.join(e['files'])}")
129
+ if e.get("decisions"):
130
+ for d in e["decisions"]:
131
+ lines.append(f" Decision : {d}")
132
+ if e.get("warnings"):
133
+ for w in e["warnings"]:
134
+ lines.append(f" Warning : {w}")
135
+ if e.get("tags"):
136
+ lines.append(f" Tags : {', '.join(e['tags'])}")
137
+ if e.get("raw"):
138
+ lines.append(f" Notes : {e['raw']}")
139
+ return "\n".join(lines)
140
+
141
+
142
+ def _now_iso() -> str:
143
+ return datetime.now(timezone.utc).isoformat(timespec="seconds")
144
+
145
+ # ---------------------------------------------------------------------------
146
+ # MCP server
147
+ # ---------------------------------------------------------------------------
148
+
149
+ mcp = FastMCP(
150
+ "memex",
151
+ instructions=(
152
+ "Persistent session memory for Claude Code. "
153
+ "Call mem_load at the start of every session. "
154
+ "Call mem_save whenever you finish a task or learn something worth keeping. "
155
+ "Use mem_search to find specific past work. "
156
+ "Use mem_list / mem_delete for housekeeping."
157
+ ),
158
+ )
159
+
160
+
161
+ @mcp.tool()
162
+ def mem_save(
163
+ task: str,
164
+ files: Optional[list[str]] = None,
165
+ decisions: Optional[list[str]] = None,
166
+ warnings: Optional[list[str]] = None,
167
+ tags: Optional[list[str]] = None,
168
+ notes: Optional[str] = None,
169
+ ) -> str:
170
+ """
171
+ Save a memory entry about work just completed.
172
+
173
+ Args:
174
+ task: One-sentence summary of what was done.
175
+ files: List of files touched / created / deleted.
176
+ decisions: Architectural or design decisions made.
177
+ warnings: Anything surprising or easy to get wrong.
178
+ tags: Short labels for later filtering (e.g. ["auth", "api"]).
179
+ notes: Any freeform extra context.
180
+
181
+ Call this:
182
+ - At the end of a significant task or session.
183
+ - Whenever you discover something worth warning a future session about.
184
+ - After making an architectural decision.
185
+ """
186
+ entry = {
187
+ "project": os.getcwd(),
188
+ "timestamp": _now_iso(),
189
+ "task": task.strip(),
190
+ "files": json.dumps(files or []),
191
+ "decisions": json.dumps(decisions or []),
192
+ "warnings": json.dumps(warnings or []),
193
+ "tags": json.dumps(tags or []),
194
+ "raw": (notes or "").strip(),
195
+ }
196
+ with get_conn() as conn:
197
+ cur = conn.execute(
198
+ """INSERT INTO entries (project, timestamp, task, files, decisions, warnings, tags, raw)
199
+ VALUES (:project, :timestamp, :task, :files, :decisions, :warnings, :tags, :raw)""",
200
+ entry,
201
+ )
202
+ new_id = cur.lastrowid
203
+ return f"✓ Saved memory #{new_id}: {task[:80]}"
204
+
205
+
206
+ @mcp.tool()
207
+ def mem_load(
208
+ hint: Optional[str] = None,
209
+ files: Optional[list[str]] = None,
210
+ ) -> str:
211
+ """
212
+ Load relevant memory entries for a new session.
213
+
214
+ Returns the N most recent entries plus any entries that match the
215
+ hint (keyword search) or overlap with the files list.
216
+
217
+ Args:
218
+ hint: Optional keyword or phrase describing the current task.
219
+ files: Optional list of files you're about to work on.
220
+
221
+ Call this at the START of every new Claude Code session.
222
+ """
223
+ with get_conn() as conn:
224
+ # 1. Recent entries
225
+ recent_rows = conn.execute(
226
+ "SELECT * FROM entries ORDER BY id DESC LIMIT ?",
227
+ (MAX_LOAD_RECENT,),
228
+ ).fetchall()
229
+ recent = [_row_to_dict(r) for r in recent_rows]
230
+ recent_ids = {e["id"] for e in recent}
231
+
232
+ matched: list[dict] = []
233
+
234
+ # 2. FTS keyword search on hint
235
+ if hint and hint.strip():
236
+ safe_hint = re.sub(r'[^a-zA-Z0-9 _\-]', ' ', hint).strip()
237
+ if safe_hint:
238
+ fts_rows = conn.execute(
239
+ """SELECT e.* FROM entries e
240
+ JOIN entries_fts f ON f.rowid = e.id
241
+ WHERE entries_fts MATCH ?
242
+ ORDER BY rank
243
+ LIMIT ?""",
244
+ (safe_hint, MAX_LOAD_MATCHED),
245
+ ).fetchall()
246
+ for r in fts_rows:
247
+ d = _row_to_dict(r)
248
+ if d["id"] not in recent_ids:
249
+ matched.append(d)
250
+ recent_ids.add(d["id"])
251
+
252
+ # 3. File-path overlap
253
+ if files:
254
+ for fpath in files[:10]:
255
+ like = f"%{fpath}%"
256
+ file_rows = conn.execute(
257
+ "SELECT * FROM entries WHERE files LIKE ? ORDER BY id DESC LIMIT 3",
258
+ (like,),
259
+ ).fetchall()
260
+ for r in file_rows:
261
+ d = _row_to_dict(r)
262
+ if d["id"] not in recent_ids:
263
+ matched.append(d)
264
+ recent_ids.add(d["id"])
265
+
266
+ if not recent and not matched:
267
+ return "No memories saved for this project yet."
268
+
269
+ parts = [f"=== memex: session context (db: {DB_PATH.name}) ===\n"]
270
+
271
+ if recent:
272
+ parts.append(f"--- Recent ({len(recent)}) ---")
273
+ for i, e in enumerate(recent):
274
+ parts.append(_format_entry(e, i + 1))
275
+
276
+ if matched:
277
+ parts.append(f"\n--- Matched your hint/files ({len(matched)}) ---")
278
+ for i, e in enumerate(matched):
279
+ parts.append(_format_entry(e, i + 1))
280
+
281
+ return "\n".join(parts)
282
+
283
+
284
+ @mcp.tool()
285
+ def mem_search(query: str, limit: int = 10) -> str:
286
+ """
287
+ Full-text search across all memory entries.
288
+
289
+ Args:
290
+ query: Keywords to search for (e.g. "JWT auth middleware").
291
+ limit: Max number of results (default 10).
292
+
293
+ Use this when you want to find past work on a specific topic.
294
+ """
295
+ safe_query = re.sub(r'[^a-zA-Z0-9 _\-]', ' ', query).strip()
296
+ if not safe_query:
297
+ return "Query is empty after sanitisation."
298
+
299
+ with get_conn() as conn:
300
+ rows = conn.execute(
301
+ """SELECT e.* FROM entries e
302
+ JOIN entries_fts f ON f.rowid = e.id
303
+ WHERE entries_fts MATCH ?
304
+ ORDER BY rank
305
+ LIMIT ?""",
306
+ (safe_query, limit),
307
+ ).fetchall()
308
+
309
+ if not rows:
310
+ return f"No entries matched '{query}'."
311
+
312
+ parts = [f"=== Search results for '{query}' ({len(rows)}) ==="]
313
+ for i, row in enumerate(rows):
314
+ parts.append(_format_entry(_row_to_dict(row), i + 1))
315
+ return "\n".join(parts)
316
+
317
+
318
+ @mcp.tool()
319
+ def mem_list(limit: int = 20, tag: Optional[str] = None) -> str:
320
+ """
321
+ List recent memory entries, optionally filtered by tag.
322
+
323
+ Args:
324
+ limit: How many entries to return (default 20).
325
+ tag: Optional tag to filter by (e.g. "auth").
326
+
327
+ Use this to review or clean up stored memories.
328
+ """
329
+ with get_conn() as conn:
330
+ if tag:
331
+ rows = conn.execute(
332
+ "SELECT * FROM entries WHERE tags LIKE ? ORDER BY id DESC LIMIT ?",
333
+ (f'%"{tag}"%', limit),
334
+ ).fetchall()
335
+ else:
336
+ rows = conn.execute(
337
+ "SELECT * FROM entries ORDER BY id DESC LIMIT ?",
338
+ (limit,),
339
+ ).fetchall()
340
+
341
+ if not rows:
342
+ msg = f"No entries" + (f" with tag '{tag}'" if tag else "") + "."
343
+ return msg
344
+
345
+ parts = [f"=== memex: {len(rows)} entries ==="]
346
+ for i, row in enumerate(rows):
347
+ parts.append(_format_entry(_row_to_dict(row), i + 1))
348
+ return "\n".join(parts)
349
+
350
+
351
+ @mcp.tool()
352
+ def mem_delete(entry_id: int) -> str:
353
+ """
354
+ Delete a memory entry by its id.
355
+
356
+ Args:
357
+ entry_id: The numeric id shown in mem_list or mem_load output.
358
+
359
+ Use this to remove outdated or incorrect entries.
360
+ """
361
+ with get_conn() as conn:
362
+ cur = conn.execute("DELETE FROM entries WHERE id = ?", (entry_id,))
363
+ if cur.rowcount == 0:
364
+ return f"No entry with id {entry_id}."
365
+ return f"✓ Deleted entry #{entry_id}."
366
+
367
+
368
+ # ---------------------------------------------------------------------------
369
+ # Entrypoint
370
+ # ---------------------------------------------------------------------------
371
+
372
+ if __name__ == "__main__":
373
+ mcp.run()
@@ -0,0 +1,22 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "mcp-memex"
7
+ version = "0.1.0"
8
+ description = "Persistent session memory for Claude Code — local SQLite, no LLMs, no network"
9
+ readme = "README.md"
10
+ license = { text = "MIT" }
11
+ requires-python = ">=3.10"
12
+ dependencies = ["mcp>=1.0"]
13
+
14
+ [project.urls]
15
+ Repository = "https://github.com/pragneshbagary/memex"
16
+ Issues = "https://github.com/pragneshbagary/memex/issues"
17
+
18
+ [tool.hatch.build.targets.wheel]
19
+ packages = ["memex"]
20
+
21
+ [project.scripts]
22
+ memex = "memex.cli:main"