git-memex 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- git_memex-0.2.0.dist-info/METADATA +247 -0
- git_memex-0.2.0.dist-info/RECORD +22 -0
- git_memex-0.2.0.dist-info/WHEEL +5 -0
- git_memex-0.2.0.dist-info/entry_points.txt +2 -0
- git_memex-0.2.0.dist-info/licenses/LICENSE +21 -0
- git_memex-0.2.0.dist-info/top_level.txt +1 -0
- memex/__init__.py +3 -0
- memex/__main__.py +6 -0
- memex/cli.py +355 -0
- memex/config.py +119 -0
- memex/embedder.py +118 -0
- memex/gitroot.py +43 -0
- memex/install.py +381 -0
- memex/models.py +34 -0
- memex/server.py +23 -0
- memex/service.py +320 -0
- memex/store.py +326 -0
- memex/templates/AGENTS.md +50 -0
- memex/templates/agent-rule.md +29 -0
- memex/templates/memex.mdc +34 -0
- memex/tools.py +66 -0
- memex/vectors.py +288 -0
memex/config.py
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""Configuration and path helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
DEFAULT_MODEL = "sentence-transformers/all-MiniLM-L6-v2"
|
|
11
|
+
DEFAULT_DIMENSION = 384
|
|
12
|
+
# MiniLM paraphrases of the same fact often land ~0.70–0.85. Pair with the
|
|
13
|
+
# digit-set guard so "3 days" does not merge with "7 days" (~0.93 cosine).
|
|
14
|
+
DEFAULT_DEDUP_SCORE = 0.70
|
|
15
|
+
# Soft floor for "short fact vs longer elaboration" subsumption (below near-dup).
|
|
16
|
+
DEFAULT_ELABORATION_SCORE = 0.50
|
|
17
|
+
MEMORY_DIRNAME = ".memex"
|
|
18
|
+
MODEL_FILENAME = "MODEL.json"
|
|
19
|
+
NOTES_DIRNAME = "notes"
|
|
20
|
+
DB_FILENAME = "memory.db"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True)
|
|
24
|
+
class ModelPin:
|
|
25
|
+
"""Pinned embedding model metadata written to MODEL.json."""
|
|
26
|
+
|
|
27
|
+
model: str
|
|
28
|
+
dimension: int
|
|
29
|
+
version: int = 1
|
|
30
|
+
|
|
31
|
+
def to_dict(self) -> dict:
|
|
32
|
+
return {
|
|
33
|
+
"model": self.model,
|
|
34
|
+
"dimension": self.dimension,
|
|
35
|
+
"version": self.version,
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
@classmethod
|
|
39
|
+
def from_dict(cls, data: dict) -> ModelPin:
|
|
40
|
+
return cls(
|
|
41
|
+
model=str(data["model"]),
|
|
42
|
+
dimension=int(data["dimension"]),
|
|
43
|
+
version=int(data.get("version", 1)),
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass(frozen=True)
|
|
48
|
+
class Settings:
|
|
49
|
+
"""Runtime settings from environment."""
|
|
50
|
+
|
|
51
|
+
root_override: Path | None
|
|
52
|
+
model_override: str | None
|
|
53
|
+
min_score: float
|
|
54
|
+
dedup_score: float
|
|
55
|
+
elaboration_score: float
|
|
56
|
+
|
|
57
|
+
@classmethod
|
|
58
|
+
def from_env(cls) -> Settings:
|
|
59
|
+
root = os.environ.get("MEMEX_ROOT")
|
|
60
|
+
model = os.environ.get("MEMEX_MODEL")
|
|
61
|
+
raw_min = os.environ.get("MEMEX_MIN_SCORE", "0.0")
|
|
62
|
+
try:
|
|
63
|
+
min_score = float(raw_min)
|
|
64
|
+
except ValueError:
|
|
65
|
+
min_score = 0.0
|
|
66
|
+
raw_dedup = os.environ.get("MEMEX_DEDUP_SCORE", str(DEFAULT_DEDUP_SCORE))
|
|
67
|
+
try:
|
|
68
|
+
dedup_score = float(raw_dedup)
|
|
69
|
+
except ValueError:
|
|
70
|
+
dedup_score = DEFAULT_DEDUP_SCORE
|
|
71
|
+
raw_elab = os.environ.get(
|
|
72
|
+
"MEMEX_ELABORATION_SCORE", str(DEFAULT_ELABORATION_SCORE)
|
|
73
|
+
)
|
|
74
|
+
try:
|
|
75
|
+
elaboration_score = float(raw_elab)
|
|
76
|
+
except ValueError:
|
|
77
|
+
elaboration_score = DEFAULT_ELABORATION_SCORE
|
|
78
|
+
return cls(
|
|
79
|
+
root_override=Path(root).expanduser().resolve() if root else None,
|
|
80
|
+
model_override=model.strip() if model else None,
|
|
81
|
+
min_score=min_score,
|
|
82
|
+
dedup_score=dedup_score,
|
|
83
|
+
elaboration_score=elaboration_score,
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def memory_dir(git_root: Path) -> Path:
|
|
88
|
+
return git_root / MEMORY_DIRNAME
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def notes_dir(git_root: Path) -> Path:
|
|
92
|
+
return memory_dir(git_root) / NOTES_DIRNAME
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def db_path(git_root: Path) -> Path:
|
|
96
|
+
return memory_dir(git_root) / DB_FILENAME
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def model_path(git_root: Path) -> Path:
|
|
100
|
+
return memory_dir(git_root) / MODEL_FILENAME
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def load_model_pin(git_root: Path) -> ModelPin | None:
|
|
104
|
+
path = model_path(git_root)
|
|
105
|
+
if not path.is_file():
|
|
106
|
+
return None
|
|
107
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
108
|
+
return ModelPin.from_dict(data)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def save_model_pin(git_root: Path, pin: ModelPin) -> None:
|
|
112
|
+
path = model_path(git_root)
|
|
113
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
114
|
+
path.write_text(json.dumps(pin.to_dict(), indent=2) + "\n", encoding="utf-8")
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def default_model_name() -> str:
|
|
118
|
+
settings = Settings.from_env()
|
|
119
|
+
return settings.model_override or DEFAULT_MODEL
|
memex/embedder.py
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""Lazy in-process sentence-transformers embedder."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import threading
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from memex.config import DEFAULT_DIMENSION, DEFAULT_MODEL, default_model_name
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class EmbedderError(RuntimeError):
|
|
12
|
+
"""Embedding model failed to load or produce vectors."""
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Embedder:
|
|
16
|
+
"""Process-wide lazy SentenceTransformer wrapper."""
|
|
17
|
+
|
|
18
|
+
_lock = threading.Lock()
|
|
19
|
+
_instance: Embedder | None = None
|
|
20
|
+
|
|
21
|
+
def __init__(self, model_name: str | None = None) -> None:
|
|
22
|
+
self._model_name = model_name or default_model_name()
|
|
23
|
+
self._model: Any | None = None
|
|
24
|
+
self._dimension: int | None = None
|
|
25
|
+
self._load_lock = threading.Lock()
|
|
26
|
+
|
|
27
|
+
@classmethod
|
|
28
|
+
def get(cls, model_name: str | None = None) -> Embedder:
|
|
29
|
+
name = model_name or default_model_name()
|
|
30
|
+
with cls._lock:
|
|
31
|
+
if cls._instance is None or cls._instance.model_name != name:
|
|
32
|
+
cls._instance = Embedder(name)
|
|
33
|
+
return cls._instance
|
|
34
|
+
|
|
35
|
+
@classmethod
|
|
36
|
+
def reset_for_tests(cls) -> None:
|
|
37
|
+
with cls._lock:
|
|
38
|
+
cls._instance = None
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
def model_name(self) -> str:
|
|
42
|
+
return self._model_name
|
|
43
|
+
|
|
44
|
+
@property
|
|
45
|
+
def dimension(self) -> int:
|
|
46
|
+
self._ensure_loaded()
|
|
47
|
+
assert self._dimension is not None
|
|
48
|
+
return self._dimension
|
|
49
|
+
|
|
50
|
+
def _ensure_loaded(self) -> None:
|
|
51
|
+
if self._model is not None:
|
|
52
|
+
return
|
|
53
|
+
with self._load_lock:
|
|
54
|
+
if self._model is not None:
|
|
55
|
+
return
|
|
56
|
+
try:
|
|
57
|
+
from sentence_transformers import SentenceTransformer
|
|
58
|
+
except ImportError as exc:
|
|
59
|
+
raise EmbedderError(
|
|
60
|
+
"sentence-transformers is not installed"
|
|
61
|
+
) from exc
|
|
62
|
+
try:
|
|
63
|
+
model = SentenceTransformer(self._model_name)
|
|
64
|
+
# Probe dimension without requiring a full encode of user text.
|
|
65
|
+
probe = model.encode(["dimension probe"], normalize_embeddings=True)
|
|
66
|
+
dim = int(probe.shape[1])
|
|
67
|
+
except Exception as exc:
|
|
68
|
+
raise EmbedderError(
|
|
69
|
+
f"failed to load embedding model {self._model_name!r}: {exc}"
|
|
70
|
+
) from exc
|
|
71
|
+
self._model = model
|
|
72
|
+
self._dimension = dim
|
|
73
|
+
|
|
74
|
+
def embed(self, texts: list[str]) -> list[list[float]]:
|
|
75
|
+
if not texts:
|
|
76
|
+
return []
|
|
77
|
+
cleaned = [t.strip() for t in texts]
|
|
78
|
+
if any(not t for t in cleaned):
|
|
79
|
+
raise ValueError("cannot embed empty text")
|
|
80
|
+
self._ensure_loaded()
|
|
81
|
+
assert self._model is not None
|
|
82
|
+
vectors = self._model.encode(cleaned, normalize_embeddings=True)
|
|
83
|
+
return [list(map(float, row)) for row in vectors]
|
|
84
|
+
|
|
85
|
+
def embed_one(self, text: str) -> list[float]:
|
|
86
|
+
return self.embed([text])[0]
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class StubEmbedder:
|
|
90
|
+
"""Deterministic embedder for unit tests (no torch)."""
|
|
91
|
+
|
|
92
|
+
def __init__(self, dimension: int = DEFAULT_DIMENSION, model_name: str = DEFAULT_MODEL) -> None:
|
|
93
|
+
self._dimension = dimension
|
|
94
|
+
self._model_name = model_name
|
|
95
|
+
|
|
96
|
+
@property
|
|
97
|
+
def model_name(self) -> str:
|
|
98
|
+
return self._model_name
|
|
99
|
+
|
|
100
|
+
@property
|
|
101
|
+
def dimension(self) -> int:
|
|
102
|
+
return self._dimension
|
|
103
|
+
|
|
104
|
+
def embed(self, texts: list[str]) -> list[list[float]]:
|
|
105
|
+
out: list[list[float]] = []
|
|
106
|
+
for text in texts:
|
|
107
|
+
if not text.strip():
|
|
108
|
+
raise ValueError("cannot embed empty text")
|
|
109
|
+
# Simple bag-of-chars hash into a unit-ish vector.
|
|
110
|
+
vec = [0.0] * self._dimension
|
|
111
|
+
for i, ch in enumerate(text.encode("utf-8")):
|
|
112
|
+
vec[i % self._dimension] += (ch % 31) / 31.0
|
|
113
|
+
norm = sum(v * v for v in vec) ** 0.5 or 1.0
|
|
114
|
+
out.append([v / norm for v in vec])
|
|
115
|
+
return out
|
|
116
|
+
|
|
117
|
+
def embed_one(self, text: str) -> list[float]:
|
|
118
|
+
return self.embed([text])[0]
|
memex/gitroot.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Locate the git repository root."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from memex.config import Settings
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class NotAGitRepositoryError(RuntimeError):
|
|
11
|
+
"""Raised when no .git directory is found walking parents."""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def find_git_root(start: Path | None = None) -> Path:
|
|
15
|
+
"""Walk parents from *start* (default: cwd) until a ``.git`` entry is found.
|
|
16
|
+
|
|
17
|
+
Honors ``MEMEX_ROOT`` when set: that path must itself be inside a
|
|
18
|
+
git work tree (or be the root).
|
|
19
|
+
"""
|
|
20
|
+
settings = Settings.from_env()
|
|
21
|
+
if settings.root_override is not None:
|
|
22
|
+
return _require_git_root(settings.root_override)
|
|
23
|
+
|
|
24
|
+
cur = (start or Path.cwd()).expanduser().resolve()
|
|
25
|
+
return _require_git_root(cur)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _require_git_root(start: Path) -> Path:
|
|
29
|
+
cur = start.expanduser().resolve()
|
|
30
|
+
if not cur.exists():
|
|
31
|
+
raise NotAGitRepositoryError(f"path does not exist: {cur}")
|
|
32
|
+
if cur.is_file():
|
|
33
|
+
cur = cur.parent
|
|
34
|
+
|
|
35
|
+
for candidate in [cur, *cur.parents]:
|
|
36
|
+
git_entry = candidate / ".git"
|
|
37
|
+
if git_entry.exists():
|
|
38
|
+
return candidate
|
|
39
|
+
|
|
40
|
+
raise NotAGitRepositoryError(
|
|
41
|
+
f"not a git repository (or any parent): {start}. "
|
|
42
|
+
"Set MEMEX_ROOT to the repo root, or run inside a git checkout."
|
|
43
|
+
)
|
memex/install.py
ADDED
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
"""Install Memex into a git repository for agents (layout, rules, MCP)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
import shutil
|
|
8
|
+
import sys
|
|
9
|
+
from importlib import resources
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any, Iterable, Sequence
|
|
12
|
+
|
|
13
|
+
from memex.gitroot import find_git_root
|
|
14
|
+
from memex.store import ensure_layout
|
|
15
|
+
|
|
16
|
+
BEGIN_MARK = "<!-- BEGIN MEMEX AGENTS -->"
|
|
17
|
+
END_MARK = "<!-- END MEMEX AGENTS -->"
|
|
18
|
+
_BLOCK_RE = re.compile(
|
|
19
|
+
re.escape(BEGIN_MARK) + r".*?" + re.escape(END_MARK),
|
|
20
|
+
re.DOTALL,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
BEGIN_CODEX_MCP = "# BEGIN MEMEX MCP"
|
|
24
|
+
END_CODEX_MCP = "# END MEMEX MCP"
|
|
25
|
+
_CODEX_MCP_RE = re.compile(
|
|
26
|
+
re.escape(BEGIN_CODEX_MCP) + r".*?" + re.escape(END_CODEX_MCP),
|
|
27
|
+
re.DOTALL,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
GITATTRIBUTES_LINE = ".memex/memory.db binary"
|
|
31
|
+
|
|
32
|
+
PLATFORMS = ("cursor", "claude", "codex")
|
|
33
|
+
TARGETS = (*PLATFORMS, "all")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def load_template(name: str) -> str:
|
|
37
|
+
"""Return a packaged template file's text."""
|
|
38
|
+
root = resources.files("memex") / "templates"
|
|
39
|
+
return (root / name).read_text(encoding="utf-8")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def resolve_memex_command() -> str:
|
|
43
|
+
"""Absolute path to the ``memex`` binary when possible (IDEs need this)."""
|
|
44
|
+
argv0 = Path(sys.argv[0]).expanduser()
|
|
45
|
+
try:
|
|
46
|
+
resolved = argv0.resolve()
|
|
47
|
+
except OSError:
|
|
48
|
+
resolved = argv0
|
|
49
|
+
if resolved.is_file() and resolved.name in {"memex", "memex.exe"}:
|
|
50
|
+
return str(resolved)
|
|
51
|
+
|
|
52
|
+
found = shutil.which("memex")
|
|
53
|
+
if found:
|
|
54
|
+
return str(Path(found).resolve())
|
|
55
|
+
return "memex"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def normalize_targets(targets: Sequence[str]) -> list[str]:
|
|
59
|
+
"""Expand ``all`` and de-duplicate while preserving order."""
|
|
60
|
+
out: list[str] = []
|
|
61
|
+
for raw in targets:
|
|
62
|
+
name = raw.strip().lower()
|
|
63
|
+
if name == "all":
|
|
64
|
+
for platform in PLATFORMS:
|
|
65
|
+
if platform not in out:
|
|
66
|
+
out.append(platform)
|
|
67
|
+
continue
|
|
68
|
+
if name not in PLATFORMS:
|
|
69
|
+
raise ValueError(
|
|
70
|
+
f"unknown install target {raw!r}; choose from: "
|
|
71
|
+
+ ", ".join(TARGETS)
|
|
72
|
+
)
|
|
73
|
+
if name not in out:
|
|
74
|
+
out.append(name)
|
|
75
|
+
if not out:
|
|
76
|
+
raise ValueError("specify at least one target: " + ", ".join(TARGETS))
|
|
77
|
+
return out
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def upsert_marked_block(path: Path, block: str) -> str:
|
|
81
|
+
"""Insert or replace a BEGIN/END Memex markdown block. Returns action."""
|
|
82
|
+
block = block.strip() + "\n"
|
|
83
|
+
if not path.exists():
|
|
84
|
+
path.write_text(block, encoding="utf-8")
|
|
85
|
+
return "created"
|
|
86
|
+
|
|
87
|
+
text = path.read_text(encoding="utf-8")
|
|
88
|
+
if BEGIN_MARK in text and END_MARK in text:
|
|
89
|
+
if not _BLOCK_RE.search(text):
|
|
90
|
+
raise ValueError(
|
|
91
|
+
f"{path}: found {BEGIN_MARK!r} / {END_MARK!r} but "
|
|
92
|
+
"could not parse the block (markers out of order?)"
|
|
93
|
+
)
|
|
94
|
+
new_text = _BLOCK_RE.sub(block.rstrip("\n"), text, count=1)
|
|
95
|
+
if not new_text.endswith("\n"):
|
|
96
|
+
new_text += "\n"
|
|
97
|
+
if new_text == text:
|
|
98
|
+
return "unchanged"
|
|
99
|
+
path.write_text(new_text, encoding="utf-8")
|
|
100
|
+
return "updated"
|
|
101
|
+
|
|
102
|
+
stripped = text.rstrip()
|
|
103
|
+
path.write_text(stripped + "\n\n" + block, encoding="utf-8")
|
|
104
|
+
return "appended"
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def upsert_agents_block(agents_path: Path, block: str) -> str:
|
|
108
|
+
"""Insert or replace the Memex block in ``AGENTS.md``."""
|
|
109
|
+
return upsert_marked_block(agents_path, block)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def write_text_file(path: Path, content: str) -> str:
|
|
113
|
+
"""Create or update a text file. Returns created|updated|unchanged."""
|
|
114
|
+
body = content if content.endswith("\n") else content + "\n"
|
|
115
|
+
existed = path.is_file()
|
|
116
|
+
if existed and path.read_text(encoding="utf-8") == body:
|
|
117
|
+
return "unchanged"
|
|
118
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
119
|
+
path.write_text(body, encoding="utf-8")
|
|
120
|
+
return "updated" if existed else "created"
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def upsert_mcp_json(
|
|
124
|
+
mcp_path: Path,
|
|
125
|
+
*,
|
|
126
|
+
command: str,
|
|
127
|
+
memex_root_env: str,
|
|
128
|
+
) -> str:
|
|
129
|
+
"""Merge the memex server into an MCP JSON config. Returns action."""
|
|
130
|
+
mcp_path.parent.mkdir(parents=True, exist_ok=True)
|
|
131
|
+
entry = {
|
|
132
|
+
"command": command,
|
|
133
|
+
"args": ["serve"],
|
|
134
|
+
"env": {"MEMEX_ROOT": memex_root_env},
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if mcp_path.is_file():
|
|
138
|
+
try:
|
|
139
|
+
data = json.loads(mcp_path.read_text(encoding="utf-8"))
|
|
140
|
+
except json.JSONDecodeError as exc:
|
|
141
|
+
raise ValueError(f"invalid JSON in {mcp_path}: {exc}") from exc
|
|
142
|
+
if not isinstance(data, dict):
|
|
143
|
+
raise ValueError(f"{mcp_path} must contain a JSON object")
|
|
144
|
+
servers = data.setdefault("mcpServers", {})
|
|
145
|
+
if not isinstance(servers, dict):
|
|
146
|
+
raise ValueError(f"{mcp_path}: mcpServers must be an object")
|
|
147
|
+
if servers.get("memex") == entry:
|
|
148
|
+
return "unchanged"
|
|
149
|
+
action = "updated" if "memex" in servers else "merged"
|
|
150
|
+
servers["memex"] = entry
|
|
151
|
+
else:
|
|
152
|
+
data = {"mcpServers": {"memex": entry}}
|
|
153
|
+
action = "created"
|
|
154
|
+
|
|
155
|
+
mcp_path.write_text(
|
|
156
|
+
json.dumps(data, indent=2, ensure_ascii=False) + "\n",
|
|
157
|
+
encoding="utf-8",
|
|
158
|
+
)
|
|
159
|
+
return action
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _toml_string(value: str) -> str:
|
|
163
|
+
return json.dumps(value, ensure_ascii=False)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def format_codex_mcp_block(command: str, memex_root: str) -> str:
|
|
167
|
+
"""TOML fragment for Codex project MCP config."""
|
|
168
|
+
return (
|
|
169
|
+
f"{BEGIN_CODEX_MCP}\n"
|
|
170
|
+
f"[mcp_servers.memex]\n"
|
|
171
|
+
f"command = {_toml_string(command)}\n"
|
|
172
|
+
f'args = ["serve"]\n'
|
|
173
|
+
f"\n"
|
|
174
|
+
f"[mcp_servers.memex.env]\n"
|
|
175
|
+
f"MEMEX_ROOT = {_toml_string(memex_root)}\n"
|
|
176
|
+
f"{END_CODEX_MCP}\n"
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def upsert_codex_mcp(config_path: Path, *, command: str, memex_root: str) -> str:
|
|
181
|
+
"""Insert or replace the Memex MCP block in ``.codex/config.toml``."""
|
|
182
|
+
block = format_codex_mcp_block(command, memex_root).rstrip("\n")
|
|
183
|
+
config_path.parent.mkdir(parents=True, exist_ok=True)
|
|
184
|
+
|
|
185
|
+
if not config_path.exists():
|
|
186
|
+
config_path.write_text(block + "\n", encoding="utf-8")
|
|
187
|
+
return "created"
|
|
188
|
+
|
|
189
|
+
text = config_path.read_text(encoding="utf-8")
|
|
190
|
+
if BEGIN_CODEX_MCP in text and END_CODEX_MCP in text:
|
|
191
|
+
if not _CODEX_MCP_RE.search(text):
|
|
192
|
+
raise ValueError(
|
|
193
|
+
f"{config_path}: found Memex MCP markers but could not parse block"
|
|
194
|
+
)
|
|
195
|
+
new_text = _CODEX_MCP_RE.sub(block, text, count=1)
|
|
196
|
+
if not new_text.endswith("\n"):
|
|
197
|
+
new_text += "\n"
|
|
198
|
+
if new_text == text:
|
|
199
|
+
return "unchanged"
|
|
200
|
+
config_path.write_text(new_text, encoding="utf-8")
|
|
201
|
+
return "updated"
|
|
202
|
+
|
|
203
|
+
stripped = text.rstrip()
|
|
204
|
+
config_path.write_text(stripped + "\n\n" + block + "\n", encoding="utf-8")
|
|
205
|
+
return "appended"
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def ensure_gitattributes(git_root: Path) -> str:
|
|
209
|
+
"""Ensure ``.gitattributes`` marks ``memory.db`` as binary."""
|
|
210
|
+
path = git_root / ".gitattributes"
|
|
211
|
+
if path.is_file():
|
|
212
|
+
lines = path.read_text(encoding="utf-8").splitlines()
|
|
213
|
+
if GITATTRIBUTES_LINE in lines:
|
|
214
|
+
return "unchanged"
|
|
215
|
+
text = path.read_text(encoding="utf-8")
|
|
216
|
+
suffix = "" if text.endswith("\n") or not text else "\n"
|
|
217
|
+
path.write_text(text + suffix + GITATTRIBUTES_LINE + "\n", encoding="utf-8")
|
|
218
|
+
return "appended"
|
|
219
|
+
path.write_text(GITATTRIBUTES_LINE + "\n", encoding="utf-8")
|
|
220
|
+
return "created"
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _install_shared(
|
|
224
|
+
root: Path,
|
|
225
|
+
*,
|
|
226
|
+
agents: bool,
|
|
227
|
+
gitattributes: bool,
|
|
228
|
+
) -> dict[str, Any]:
|
|
229
|
+
out: dict[str, Any] = {}
|
|
230
|
+
if agents:
|
|
231
|
+
out["agents"] = {
|
|
232
|
+
"path": "AGENTS.md",
|
|
233
|
+
"action": upsert_marked_block(
|
|
234
|
+
root / "AGENTS.md", load_template("AGENTS.md")
|
|
235
|
+
),
|
|
236
|
+
}
|
|
237
|
+
else:
|
|
238
|
+
out["agents"] = {"skipped": True}
|
|
239
|
+
|
|
240
|
+
if gitattributes:
|
|
241
|
+
out["gitattributes"] = {
|
|
242
|
+
"path": ".gitattributes",
|
|
243
|
+
"action": ensure_gitattributes(root),
|
|
244
|
+
}
|
|
245
|
+
else:
|
|
246
|
+
out["gitattributes"] = {"skipped": True}
|
|
247
|
+
return out
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _install_cursor(
|
|
251
|
+
root: Path,
|
|
252
|
+
*,
|
|
253
|
+
command: str,
|
|
254
|
+
rules: bool,
|
|
255
|
+
mcp: bool,
|
|
256
|
+
) -> dict[str, Any]:
|
|
257
|
+
out: dict[str, Any] = {}
|
|
258
|
+
if rules:
|
|
259
|
+
out["cursor_rule"] = {
|
|
260
|
+
"path": ".cursor/rules/memex.mdc",
|
|
261
|
+
"action": write_text_file(
|
|
262
|
+
root / ".cursor" / "rules" / "memex.mdc",
|
|
263
|
+
load_template("memex.mdc"),
|
|
264
|
+
),
|
|
265
|
+
}
|
|
266
|
+
else:
|
|
267
|
+
out["cursor_rule"] = {"skipped": True}
|
|
268
|
+
|
|
269
|
+
if mcp:
|
|
270
|
+
out["cursor_mcp"] = {
|
|
271
|
+
"path": ".cursor/mcp.json",
|
|
272
|
+
"action": upsert_mcp_json(
|
|
273
|
+
root / ".cursor" / "mcp.json",
|
|
274
|
+
command=command,
|
|
275
|
+
memex_root_env="${workspaceFolder}",
|
|
276
|
+
),
|
|
277
|
+
"command": command,
|
|
278
|
+
}
|
|
279
|
+
else:
|
|
280
|
+
out["cursor_mcp"] = {"skipped": True}
|
|
281
|
+
return out
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def _install_claude(
|
|
285
|
+
root: Path,
|
|
286
|
+
*,
|
|
287
|
+
command: str,
|
|
288
|
+
instructions: bool,
|
|
289
|
+
mcp: bool,
|
|
290
|
+
) -> dict[str, Any]:
|
|
291
|
+
out: dict[str, Any] = {}
|
|
292
|
+
if instructions:
|
|
293
|
+
out["claude_md"] = {
|
|
294
|
+
"path": "CLAUDE.md",
|
|
295
|
+
"action": upsert_marked_block(
|
|
296
|
+
root / "CLAUDE.md", load_template("AGENTS.md")
|
|
297
|
+
),
|
|
298
|
+
}
|
|
299
|
+
else:
|
|
300
|
+
out["claude_md"] = {"skipped": True}
|
|
301
|
+
|
|
302
|
+
if mcp:
|
|
303
|
+
out["claude_mcp"] = {
|
|
304
|
+
"path": ".mcp.json",
|
|
305
|
+
"action": upsert_mcp_json(
|
|
306
|
+
root / ".mcp.json",
|
|
307
|
+
command=command,
|
|
308
|
+
memex_root_env="${CLAUDE_PROJECT_DIR:-.}",
|
|
309
|
+
),
|
|
310
|
+
"command": command,
|
|
311
|
+
}
|
|
312
|
+
else:
|
|
313
|
+
out["claude_mcp"] = {"skipped": True}
|
|
314
|
+
return out
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def _install_codex(
|
|
318
|
+
root: Path,
|
|
319
|
+
*,
|
|
320
|
+
command: str,
|
|
321
|
+
mcp: bool,
|
|
322
|
+
) -> dict[str, Any]:
|
|
323
|
+
if not mcp:
|
|
324
|
+
return {"codex_mcp": {"skipped": True}}
|
|
325
|
+
return {
|
|
326
|
+
"codex_mcp": {
|
|
327
|
+
"path": ".codex/config.toml",
|
|
328
|
+
"action": upsert_codex_mcp(
|
|
329
|
+
root / ".codex" / "config.toml",
|
|
330
|
+
command=command,
|
|
331
|
+
memex_root=str(root),
|
|
332
|
+
),
|
|
333
|
+
"command": command,
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def install_memex(
|
|
339
|
+
git_root: Path | None = None,
|
|
340
|
+
*,
|
|
341
|
+
targets: Sequence[str] | Iterable[str] = ("cursor",),
|
|
342
|
+
agents: bool = True,
|
|
343
|
+
instructions: bool = True,
|
|
344
|
+
rules: bool = True,
|
|
345
|
+
mcp: bool = True,
|
|
346
|
+
gitattributes: bool = True,
|
|
347
|
+
memex_command: str | None = None,
|
|
348
|
+
) -> dict[str, Any]:
|
|
349
|
+
"""Set up Memex layout + agent wiring for one or more platforms."""
|
|
350
|
+
root = (git_root or find_git_root()).resolve()
|
|
351
|
+
platforms = normalize_targets(list(targets))
|
|
352
|
+
ensure_layout(root)
|
|
353
|
+
command = memex_command or resolve_memex_command()
|
|
354
|
+
|
|
355
|
+
actions: dict[str, Any] = {
|
|
356
|
+
"ok": True,
|
|
357
|
+
"git_root": str(root),
|
|
358
|
+
"targets": platforms,
|
|
359
|
+
"layout": "ensured",
|
|
360
|
+
"memex_command": command,
|
|
361
|
+
}
|
|
362
|
+
actions.update(_install_shared(root, agents=agents, gitattributes=gitattributes))
|
|
363
|
+
|
|
364
|
+
for platform in platforms:
|
|
365
|
+
if platform == "cursor":
|
|
366
|
+
actions.update(
|
|
367
|
+
_install_cursor(root, command=command, rules=rules, mcp=mcp)
|
|
368
|
+
)
|
|
369
|
+
elif platform == "claude":
|
|
370
|
+
actions.update(
|
|
371
|
+
_install_claude(
|
|
372
|
+
root,
|
|
373
|
+
command=command,
|
|
374
|
+
instructions=instructions,
|
|
375
|
+
mcp=mcp,
|
|
376
|
+
)
|
|
377
|
+
)
|
|
378
|
+
elif platform == "codex":
|
|
379
|
+
actions.update(_install_codex(root, command=command, mcp=mcp))
|
|
380
|
+
|
|
381
|
+
return actions
|
memex/models.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Dataclasses for memory records and recall hits."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(frozen=True)
|
|
9
|
+
class MemoryRecord:
|
|
10
|
+
id: str
|
|
11
|
+
content: str
|
|
12
|
+
tags: list[str] = field(default_factory=list)
|
|
13
|
+
created_at: str = ""
|
|
14
|
+
path: str = ""
|
|
15
|
+
content_hash: str = ""
|
|
16
|
+
source: str = "agent"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True)
|
|
20
|
+
class RecallHit:
|
|
21
|
+
id: str
|
|
22
|
+
content: str
|
|
23
|
+
score: float
|
|
24
|
+
path: str
|
|
25
|
+
tags: list[str] = field(default_factory=list)
|
|
26
|
+
|
|
27
|
+
def to_dict(self) -> dict:
|
|
28
|
+
return {
|
|
29
|
+
"id": self.id,
|
|
30
|
+
"content": self.content,
|
|
31
|
+
"score": self.score,
|
|
32
|
+
"path": self.path,
|
|
33
|
+
"tags": list(self.tags),
|
|
34
|
+
}
|