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.
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
+ }