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.
- mcp_memex-0.1.0/.claude/settings.local.json +7 -0
- mcp_memex-0.1.0/.gitignore +11 -0
- mcp_memex-0.1.0/CLAUDE.md +32 -0
- mcp_memex-0.1.0/CONTRIBUTING.md +36 -0
- mcp_memex-0.1.0/LICENSE +21 -0
- mcp_memex-0.1.0/PKG-INFO +127 -0
- mcp_memex-0.1.0/README.md +115 -0
- mcp_memex-0.1.0/memex/__init__.py +1 -0
- mcp_memex-0.1.0/memex/cli.py +266 -0
- mcp_memex-0.1.0/memex/server.py +373 -0
- mcp_memex-0.1.0/pyproject.toml +22 -0
|
@@ -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.
|
mcp_memex-0.1.0/LICENSE
ADDED
|
@@ -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.
|
mcp_memex-0.1.0/PKG-INFO
ADDED
|
@@ -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"
|