memgit 0.1.1__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.
memgit/mcp_server.py ADDED
@@ -0,0 +1,418 @@
1
+ """memgit MCP server — exposes memory search/get/list/save over stdio MCP protocol."""
2
+
3
+ from __future__ import annotations
4
+ import sys
5
+ from datetime import datetime, timezone
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ import mcp.server.stdio
10
+ from mcp.server import Server
11
+ from mcp.types import (
12
+ TextContent,
13
+ Tool,
14
+ )
15
+
16
+ from .models import Mnemonic
17
+ from .repo import Repository
18
+ from .scorer import score as bm25_score
19
+ from .toon import serialize_mnemonic
20
+
21
+
22
+ _SERVER_DESCRIPTION = (
23
+ "memgit is a version-controlled memory store — git for AI memory. "
24
+ "It stores typed, prioritized facts, rules, preferences, and lessons learned, "
25
+ "then serves only the most relevant ones per query via BM25 scoring. "
26
+ "CRITICAL INSTRUCTIONS: "
27
+ "(1) Call search_memories at the START of every session before answering the first question — "
28
+ "this loads your persistent context and prevents repeating questions the user has already answered. "
29
+ "(2) Call save_memory whenever you learn something durable — a rule the user corrected you on, "
30
+ "a preference they stated, a project decision, a lesson from a mistake. "
31
+ "Do NOT wait for the user to ask you to remember — save proactively. "
32
+ "(3) memgit is cross-tool: memories saved here are also available in Cursor, Windsurf, GPT, and Gemini."
33
+ )
34
+
35
+ _TYPE_DESCRIPTIONS = (
36
+ "fb=feedback (corrections, preferences, how the user likes to work), "
37
+ "us=user (who the user is, their role, expertise, goals), "
38
+ "pj=project (active projects, goals, decisions, deadlines), "
39
+ "rf=reference (pointers to external systems, URLs, tools), "
40
+ "cn=convention (code style, naming, architecture rules), "
41
+ "lx=lesson (lessons learned, post-mortems, 'we got burned by X')"
42
+ )
43
+
44
+
45
+ def _default_store() -> Path:
46
+ return Path.home() / ".claude" / "memgit-store"
47
+
48
+
49
+ def _load_repo(store_path: Path | None) -> Repository | None:
50
+ path = store_path or _default_store()
51
+ memgit_dir = path / ".memgit"
52
+ if not memgit_dir.is_dir():
53
+ return None
54
+ return Repository(memgit_dir)
55
+
56
+
57
+ def _mnem_to_dict(m: Mnemonic, score: float | None = None) -> dict[str, Any]:
58
+ d: dict[str, Any] = {
59
+ "slug": m.slug,
60
+ "type": m.type_code,
61
+ "priority": m.priority,
62
+ "rule": m.rule,
63
+ }
64
+ if m.why:
65
+ d["why"] = m.why
66
+ if m.when:
67
+ d["when"] = m.when
68
+ if m.tags:
69
+ d["tags"] = m.tags
70
+ if m.desc:
71
+ d["desc"] = m.desc
72
+ if score is not None:
73
+ d["score"] = round(score, 4)
74
+ return d
75
+
76
+
77
+ def run_server(store_path: Path | None = None) -> None:
78
+ """Run the MCP server on stdio."""
79
+ server = Server(
80
+ "memgit",
81
+ )
82
+
83
+ @server.list_tools()
84
+ async def list_tools() -> list[Tool]:
85
+ return [
86
+ Tool(
87
+ name="search_memories",
88
+ description=(
89
+ "Search your persistent memory store for facts, rules, and lessons relevant to the current task. "
90
+ "CALL THIS: at the start of every session, before answering questions about past work, "
91
+ "before applying preferences the user may have expressed before, or whenever you are unsure "
92
+ "whether you have prior context on a topic. "
93
+ "Returns memories ranked by relevance — only what matters, not everything. "
94
+ "This is faster and more relevant than reading individual memory files."
95
+ ),
96
+ inputSchema={
97
+ "type": "object",
98
+ "properties": {
99
+ "query": {
100
+ "type": "string",
101
+ "description": (
102
+ "Natural-language description of what you want to recall. "
103
+ "Examples: 'user preferences for code style', "
104
+ "'how does the Instagram pipeline work', "
105
+ "'what trading rules should I follow'"
106
+ ),
107
+ },
108
+ "top_k": {
109
+ "type": "integer",
110
+ "description": "Max results to return (default 8, max 30). Use 15-20 for broad topic discovery.",
111
+ "default": 8,
112
+ },
113
+ "type_filter": {
114
+ "type": "string",
115
+ "enum": ["fb", "us", "pj", "rf", "cn", "lx"],
116
+ "description": _TYPE_DESCRIPTIONS,
117
+ },
118
+ "format": {
119
+ "type": "string",
120
+ "enum": ["json", "toon"],
121
+ "description": (
122
+ "Output format. 'json' (default) is universal and works with all LLMs. "
123
+ "'toon' is a token-efficient sigil format — use only if you know the TOON spec."
124
+ ),
125
+ "default": "json",
126
+ },
127
+ },
128
+ "required": ["query"],
129
+ },
130
+ ),
131
+ Tool(
132
+ name="get_memory",
133
+ description=(
134
+ "Fetch a single memory by its exact slug identifier. "
135
+ "CALL THIS: when search_memories returned a relevant slug and you need "
136
+ "the full details (why, when to apply, tags). "
137
+ "Slugs are kebab-case identifiers like 'ig-pipeline-no-fallback' or 'trading-capital-track'."
138
+ ),
139
+ inputSchema={
140
+ "type": "object",
141
+ "properties": {
142
+ "slug": {
143
+ "type": "string",
144
+ "description": "The kebab-case memory slug (e.g. 'ig-pipeline-no-fallback')",
145
+ },
146
+ "format": {
147
+ "type": "string",
148
+ "enum": ["json", "toon"],
149
+ "default": "json",
150
+ },
151
+ },
152
+ "required": ["slug"],
153
+ },
154
+ ),
155
+ Tool(
156
+ name="list_memories",
157
+ description=(
158
+ "List all memories, returning slug + rule for each. "
159
+ "CALL THIS: to browse what's stored, discover memory slugs for get_memory, "
160
+ "or audit the full memory set. For finding relevant memories on a topic, "
161
+ "use search_memories instead — it is faster and ranked."
162
+ ),
163
+ inputSchema={
164
+ "type": "object",
165
+ "properties": {
166
+ "type_filter": {
167
+ "type": "string",
168
+ "enum": ["fb", "us", "pj", "rf", "cn", "lx"],
169
+ "description": _TYPE_DESCRIPTIONS,
170
+ },
171
+ "min_priority": {
172
+ "type": "integer",
173
+ "description": (
174
+ "Only return memories at or above this priority. "
175
+ "1=low (all memories), 2=medium+, 3=critical only. Default 1."
176
+ ),
177
+ "default": 1,
178
+ },
179
+ },
180
+ },
181
+ ),
182
+ Tool(
183
+ name="save_memory",
184
+ description=(
185
+ "Persist a new fact, rule, preference, or lesson to the memory store for future sessions. "
186
+ "CALL THIS: whenever you learn something the user would want you to remember next time — "
187
+ "a preference they stated, a rule they corrected you on, a project decision, "
188
+ "a lesson from a mistake, or a reference to an external system. "
189
+ "Do NOT save ephemeral or task-specific details; save durable facts only. "
190
+ "If a memory with the same slug already exists, this updates it."
191
+ ),
192
+ inputSchema={
193
+ "type": "object",
194
+ "properties": {
195
+ "slug": {
196
+ "type": "string",
197
+ "description": (
198
+ "Short kebab-case identifier, unique per memory. "
199
+ "Examples: 'user-prefers-terse-responses', 'ig-pipeline-no-fallback', "
200
+ "'trading-confirm-only-orders'. Use existing slug to update."
201
+ ),
202
+ },
203
+ "rule": {
204
+ "type": "string",
205
+ "description": (
206
+ "The primary fact or rule to remember (max ~200 chars). "
207
+ "Write as a declarative statement: 'always X', 'never Y', 'Z is located at ...'"
208
+ ),
209
+ },
210
+ "type_code": {
211
+ "type": "string",
212
+ "enum": ["fb", "us", "pj", "rf", "cn", "lx"],
213
+ "description": _TYPE_DESCRIPTIONS,
214
+ "default": "fb",
215
+ },
216
+ "why": {
217
+ "type": "string",
218
+ "description": "Why this rule exists — the incident, reason, or motivation. Optional but strongly recommended.",
219
+ },
220
+ "when": {
221
+ "type": "string",
222
+ "description": "When / where to apply this rule. Optional.",
223
+ },
224
+ "tags": {
225
+ "type": "array",
226
+ "items": {"type": "string"},
227
+ "description": "Topic tags for filtering. Examples: ['instagram', 'trading', 'admin']",
228
+ },
229
+ "priority": {
230
+ "type": "integer",
231
+ "enum": [1, 2, 3],
232
+ "description": (
233
+ "1=low (background context), "
234
+ "2=medium (default — apply when relevant), "
235
+ "3=critical (always loaded, applied in every session)"
236
+ ),
237
+ "default": 2,
238
+ },
239
+ },
240
+ "required": ["slug", "rule"],
241
+ },
242
+ ),
243
+ Tool(
244
+ name="get_checkpoint_log",
245
+ description=(
246
+ "Show recent checkpoint history for the memory store — when memories were last synced "
247
+ "and what changed. CALL THIS: to understand how fresh the memory store is, or to "
248
+ "debug sync issues."
249
+ ),
250
+ inputSchema={
251
+ "type": "object",
252
+ "properties": {
253
+ "limit": {
254
+ "type": "integer",
255
+ "description": "Max checkpoints to return (default 5)",
256
+ "default": 5,
257
+ },
258
+ },
259
+ },
260
+ ),
261
+ ]
262
+
263
+ @server.call_tool()
264
+ async def call_tool(name: str, arguments: dict[str, Any]) -> list[TextContent]:
265
+ import json
266
+
267
+ repo = _load_repo(store_path)
268
+ if repo is None:
269
+ return [TextContent(
270
+ type="text",
271
+ text="Error: memgit store not found. Run `memgit init` in ~/.claude/memgit-store/",
272
+ )]
273
+
274
+ if name == "search_memories":
275
+ query = arguments.get("query", "")
276
+ top_k = min(int(arguments.get("top_k", 8)), 30)
277
+ type_filter = arguments.get("type_filter")
278
+ fmt = arguments.get("format", "json")
279
+
280
+ mnemonics = repo.list()
281
+ if type_filter:
282
+ mnemonics = [m for m in mnemonics if m.type_code == type_filter]
283
+
284
+ results = bm25_score(query, mnemonics, top_k=top_k)
285
+
286
+ if not results:
287
+ return [TextContent(type="text", text="No results found.")]
288
+
289
+ if fmt == "toon":
290
+ lines = [f"# search: {query!r} ({len(results)} results)\n"]
291
+ for r in results:
292
+ lines.append(f"# score={r.score:.2f} matched={r.matched_fields}")
293
+ lines.append(serialize_mnemonic(r.mnemonic))
294
+ lines.append("")
295
+ text = "\n".join(lines)
296
+ else:
297
+ out = [_mnem_to_dict(r.mnemonic, r.score) for r in results]
298
+ text = json.dumps(out, indent=2)
299
+
300
+ return [TextContent(type="text", text=text)]
301
+
302
+ elif name == "get_memory":
303
+ slug = arguments.get("slug", "")
304
+ fmt = arguments.get("format", "json")
305
+ m = repo.get(slug)
306
+ if m is None:
307
+ return [TextContent(type="text", text=f"No memory found: {slug}")]
308
+
309
+ if fmt == "toon":
310
+ text = serialize_mnemonic(m)
311
+ else:
312
+ text = json.dumps(_mnem_to_dict(m), indent=2)
313
+
314
+ return [TextContent(type="text", text=text)]
315
+
316
+ elif name == "list_memories":
317
+ type_filter = arguments.get("type_filter")
318
+ min_priority = int(arguments.get("min_priority", 1))
319
+
320
+ mnemonics = repo.list()
321
+ if type_filter:
322
+ mnemonics = [m for m in mnemonics if m.type_code == type_filter]
323
+ if min_priority > 1:
324
+ mnemonics = [m for m in mnemonics if m.priority >= min_priority]
325
+ mnemonics.sort(key=lambda m: (m.type_code, m.slug))
326
+
327
+ if not mnemonics:
328
+ return [TextContent(type="text", text="No memories found.")]
329
+
330
+ lines = [f"# {len(mnemonics)} memories"]
331
+ for m in mnemonics:
332
+ rule_preview = m.rule[:80] + ".." if len(m.rule) > 80 else m.rule
333
+ lines.append(f"{m.slug}\t[{m.type_code}p{m.priority}]\t{rule_preview}")
334
+
335
+ return [TextContent(type="text", text="\n".join(lines))]
336
+
337
+ elif name == "save_memory":
338
+ slug = arguments.get("slug", "").strip()
339
+ rule = arguments.get("rule", "").strip()
340
+
341
+ if not slug or not rule:
342
+ return [TextContent(type="text", text="Error: slug and rule are required.")]
343
+
344
+ type_code = arguments.get("type_code", "fb")
345
+ why = arguments.get("why")
346
+ when = arguments.get("when")
347
+ tags = arguments.get("tags", [])
348
+ priority = int(arguments.get("priority", 2))
349
+
350
+ existing = repo.get(slug)
351
+ now = datetime.now(timezone.utc)
352
+
353
+ m = Mnemonic(
354
+ type_code=type_code,
355
+ slug=slug,
356
+ timestamp=now,
357
+ rule=rule,
358
+ priority=priority,
359
+ tags=tags if isinstance(tags, list) else [],
360
+ why=why,
361
+ when=when,
362
+ )
363
+
364
+ repo.add(m)
365
+
366
+ action = "updated" if existing else "saved"
367
+ return [TextContent(
368
+ type="text",
369
+ text=json.dumps({
370
+ "status": "ok",
371
+ "action": action,
372
+ "slug": slug,
373
+ "type": type_code,
374
+ "priority": priority,
375
+ }, indent=2),
376
+ )]
377
+
378
+ elif name == "get_checkpoint_log":
379
+ limit = int(arguments.get("limit", 5))
380
+ checkpoints = repo.log(limit=limit)
381
+ if not checkpoints:
382
+ return [TextContent(type="text", text="No checkpoints yet.")]
383
+
384
+ lines = []
385
+ for ck in checkpoints:
386
+ sha_s = ck.sha[:8] if ck.sha else "?"
387
+ ts = ck.timestamp.strftime("%Y-%m-%d %H:%M")
388
+ d = ck.diff_summary
389
+ delta = ""
390
+ if d:
391
+ parts = []
392
+ if d.added:
393
+ parts.append(f"+{len(d.added)}")
394
+ if d.modified:
395
+ parts.append(f"~{len(d.modified)}")
396
+ if d.removed:
397
+ parts.append(f"-{len(d.removed)}")
398
+ if parts:
399
+ delta = " " + " ".join(parts)
400
+ lines.append(f"{sha_s} {ts} {ck.message}{delta}")
401
+
402
+ return [TextContent(type="text", text="\n".join(lines))]
403
+
404
+ else:
405
+ return [TextContent(type="text", text=f"Unknown tool: {name}")]
406
+
407
+ # Run
408
+ import asyncio
409
+
410
+ async def _main():
411
+ async with mcp.server.stdio.stdio_server() as (read_stream, write_stream):
412
+ await server.run(
413
+ read_stream,
414
+ write_stream,
415
+ server.create_initialization_options(),
416
+ )
417
+
418
+ asyncio.run(_main())
memgit/models.py ADDED
@@ -0,0 +1,80 @@
1
+ """Core data models for memgit."""
2
+
3
+ from __future__ import annotations
4
+ from dataclasses import dataclass, field
5
+ from datetime import datetime
6
+ from typing import Optional
7
+
8
+
9
+ @dataclass
10
+ class Mnemonic:
11
+ """A single atomic memory unit — one fact, rule, lesson, or reference."""
12
+ type_code: str # fb|us|pj|rf|cn|lx
13
+ slug: str # kebab-case unique identifier
14
+ timestamp: datetime
15
+ rule: str # primary rule/fact (required)
16
+ priority: int = 2 # 1=low, 2=medium, 3=critical
17
+ tags: list[str] = field(default_factory=list)
18
+ why: Optional[str] = None
19
+ when: Optional[str] = None
20
+ desc: Optional[str] = None
21
+ who: Optional[str] = None
22
+ where: Optional[str] = None
23
+ dl: Optional[str] = None
24
+ inc: Optional[str] = None
25
+ cost: Optional[str] = None
26
+ supersedes: list[str] = field(default_factory=list)
27
+ related: list[str] = field(default_factory=list)
28
+ source: Optional[str] = None
29
+ sha: Optional[str] = None # computed by store
30
+
31
+
32
+ @dataclass
33
+ class MindStateEntry:
34
+ """One entry in a MindState — maps slug to mnemonic SHA."""
35
+ slug: str
36
+ mnem_sha: str
37
+
38
+
39
+ @dataclass
40
+ class MindState:
41
+ """Snapshot of all active memories at a point in time (git tree equivalent)."""
42
+ timestamp: datetime
43
+ entries: list[MindStateEntry] = field(default_factory=list)
44
+ sha: Optional[str] = None # computed by store
45
+
46
+ @property
47
+ def count(self) -> int:
48
+ return len(self.entries)
49
+
50
+
51
+ @dataclass
52
+ class DiffSummary:
53
+ """Summary of what changed between two MindStates."""
54
+ added: list[str] = field(default_factory=list)
55
+ removed: list[str] = field(default_factory=list)
56
+ modified: list[str] = field(default_factory=list)
57
+ unchanged: list[str] = field(default_factory=list)
58
+
59
+
60
+ @dataclass
61
+ class Checkpoint:
62
+ """Immutable snapshot of a MindState (git commit equivalent)."""
63
+ mindstate_sha: str
64
+ timestamp: datetime
65
+ trigger: str # session_end|session_start|explicit|auto|merge|import
66
+ message: str
67
+ author: str
68
+ session_id: str
69
+ parent_sha: Optional[str] = None
70
+ diff_summary: Optional[DiffSummary] = None
71
+ sha: Optional[str] = None # computed by store
72
+
73
+
74
+ @dataclass
75
+ class Thread:
76
+ """Named pointer to a Checkpoint — an independent line of memory (git branch equivalent)."""
77
+ name: str
78
+ head_sha: str
79
+ created_at: datetime
80
+ description: str = ""