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/http_server.py ADDED
@@ -0,0 +1,231 @@
1
+ """memgit HTTP server — REST API for GPT Custom Actions and Gemini function calling.
2
+
3
+ Run with: memgit serve --http [--port 7474]
4
+
5
+ Serves the same 5 tools as the MCP server but over HTTP+JSON so any LLM that
6
+ supports OpenAPI-based tool use (GPT Custom Actions, Gemini Extensions, etc.)
7
+ can call it without MCP support.
8
+
9
+ The /openapi.json endpoint serves the spec so GPT Actions can import it directly.
10
+ """
11
+
12
+ from __future__ import annotations
13
+ import json
14
+ import re
15
+ from datetime import datetime, timezone
16
+ from http.server import BaseHTTPRequestHandler, HTTPServer
17
+ from pathlib import Path
18
+ from typing import Any
19
+ from urllib.parse import urlparse, parse_qs
20
+
21
+ from .models import Mnemonic
22
+ from .repo import Repository
23
+ from .scorer import score as bm25_score
24
+
25
+
26
+ def _default_store() -> Path:
27
+ return Path.home() / ".claude" / "memgit-store"
28
+
29
+
30
+ def _load_repo(store_path: Path | None) -> Repository | None:
31
+ path = store_path or _default_store()
32
+ memgit_dir = path / ".memgit"
33
+ if not memgit_dir.is_dir():
34
+ return None
35
+ return Repository(memgit_dir)
36
+
37
+
38
+ def _mnem_to_dict(m: Mnemonic, score: float | None = None) -> dict[str, Any]:
39
+ d: dict[str, Any] = {"slug": m.slug, "type": m.type_code, "priority": m.priority, "rule": m.rule}
40
+ if m.why:
41
+ d["why"] = m.why
42
+ if m.when:
43
+ d["when"] = m.when
44
+ if m.tags:
45
+ d["tags"] = m.tags
46
+ if m.desc:
47
+ d["desc"] = m.desc
48
+ if score is not None:
49
+ d["score"] = round(score, 4)
50
+ return d
51
+
52
+
53
+ def _load_openapi_spec() -> dict:
54
+ spec_path = Path(__file__).parent.parent / "openapi.json"
55
+ if spec_path.exists():
56
+ return json.loads(spec_path.read_text())
57
+ return {"error": "openapi.json not found"}
58
+
59
+
60
+ class MemgitHandler(BaseHTTPRequestHandler):
61
+ store_path: Path | None = None
62
+ openapi_spec: dict = {}
63
+
64
+ def log_message(self, fmt, *args):
65
+ pass # suppress default Apache-style logs
66
+
67
+ def _json_response(self, data: Any, status: int = 200) -> None:
68
+ body = json.dumps(data, indent=2, default=str).encode()
69
+ self.send_response(status)
70
+ self.send_header("Content-Type", "application/json")
71
+ self.send_header("Content-Length", str(len(body)))
72
+ self.send_header("Access-Control-Allow-Origin", "*")
73
+ self.end_headers()
74
+ self.wfile.write(body)
75
+
76
+ def _error(self, msg: str, status: int = 400) -> None:
77
+ self._json_response({"error": msg}, status)
78
+
79
+ def _read_body(self) -> dict:
80
+ length = int(self.headers.get("Content-Length", 0))
81
+ if length == 0:
82
+ return {}
83
+ return json.loads(self.rfile.read(length))
84
+
85
+ def do_OPTIONS(self):
86
+ self.send_response(204)
87
+ self.send_header("Access-Control-Allow-Origin", "*")
88
+ self.send_header("Access-Control-Allow-Methods", "GET, POST, PUT, OPTIONS")
89
+ self.send_header("Access-Control-Allow-Headers", "Content-Type, Authorization")
90
+ self.end_headers()
91
+
92
+ def do_GET(self):
93
+ parsed = urlparse(self.path)
94
+ path = parsed.path.rstrip("/") or "/"
95
+ qs = parse_qs(parsed.query)
96
+
97
+ if path in ("/", "/openapi.json"):
98
+ spec = dict(self.openapi_spec)
99
+ spec["servers"] = [{"url": f"http://localhost:{self.server.server_address[1]}"}]
100
+ self._json_response(spec)
101
+ return
102
+
103
+ if path == "/memories":
104
+ repo = _load_repo(self.store_path)
105
+ if repo is None:
106
+ self._error("memgit store not found. Run `memgit init` first.", 503)
107
+ return
108
+ type_filter = qs.get("type_filter", [None])[0]
109
+ min_priority = int(qs.get("min_priority", [1])[0])
110
+ mnemonics = repo.list()
111
+ if type_filter:
112
+ mnemonics = [m for m in mnemonics if m.type_code == type_filter]
113
+ if min_priority > 1:
114
+ mnemonics = [m for m in mnemonics if m.priority >= min_priority]
115
+ mnemonics.sort(key=lambda m: (m.type_code, m.slug))
116
+ self._json_response([_mnem_to_dict(m) for m in mnemonics])
117
+ return
118
+
119
+ m = re.match(r"^/memories/([a-z0-9_-]+)$", path)
120
+ if m:
121
+ slug = m.group(1)
122
+ repo = _load_repo(self.store_path)
123
+ if repo is None:
124
+ self._error("memgit store not found.", 503)
125
+ return
126
+ mem = repo.get(slug)
127
+ if mem is None:
128
+ self._error(f"Memory not found: {slug}", 404)
129
+ return
130
+ self._json_response(_mnem_to_dict(mem))
131
+ return
132
+
133
+ if path == "/checkpoints":
134
+ repo = _load_repo(self.store_path)
135
+ if repo is None:
136
+ self._error("memgit store not found.", 503)
137
+ return
138
+ limit = int(qs.get("limit", [5])[0])
139
+ checkpoints = repo.log(limit=limit)
140
+ results = []
141
+ for ck in checkpoints:
142
+ d = {"sha": ck.sha[:8] if ck.sha else "?", "timestamp": ck.timestamp.isoformat(), "message": ck.message}
143
+ if ck.diff_summary:
144
+ d["added"] = len(ck.diff_summary.added)
145
+ d["modified"] = len(ck.diff_summary.modified)
146
+ d["removed"] = len(ck.diff_summary.removed)
147
+ results.append(d)
148
+ self._json_response(results)
149
+ return
150
+
151
+ self._error(f"Not found: {path}", 404)
152
+
153
+ def do_POST(self):
154
+ parsed = urlparse(self.path)
155
+ path = parsed.path.rstrip("/")
156
+
157
+ if path == "/memories/search":
158
+ body = self._read_body()
159
+ query = body.get("query", "")
160
+ if not query:
161
+ self._error("query is required")
162
+ return
163
+ top_k = min(int(body.get("top_k", 8)), 30)
164
+ type_filter = body.get("type_filter")
165
+
166
+ repo = _load_repo(self.store_path)
167
+ if repo is None:
168
+ self._error("memgit store not found.", 503)
169
+ return
170
+
171
+ mnemonics = repo.list()
172
+ if type_filter:
173
+ mnemonics = [m for m in mnemonics if m.type_code == type_filter]
174
+
175
+ results = bm25_score(query, mnemonics, top_k=top_k)
176
+ self._json_response([_mnem_to_dict(r.mnemonic, r.score) for r in results])
177
+ return
178
+
179
+ self._error(f"Not found: {path}", 404)
180
+
181
+ def do_PUT(self):
182
+ parsed = urlparse(self.path)
183
+ path = parsed.path.rstrip("/")
184
+
185
+ m = re.match(r"^/memories/([a-z0-9_-]+)$", path)
186
+ if m:
187
+ slug = m.group(1)
188
+ body = self._read_body()
189
+ rule = body.get("rule", "").strip()
190
+ if not rule:
191
+ self._error("rule is required")
192
+ return
193
+
194
+ repo = _load_repo(self.store_path)
195
+ if repo is None:
196
+ self._error("memgit store not found.", 503)
197
+ return
198
+
199
+ existing = repo.get(slug)
200
+ mem = Mnemonic(
201
+ type_code=body.get("type_code", "fb"),
202
+ slug=slug,
203
+ timestamp=datetime.now(timezone.utc),
204
+ rule=rule,
205
+ priority=int(body.get("priority", 2)),
206
+ tags=body.get("tags", []),
207
+ why=body.get("why"),
208
+ when=body.get("when"),
209
+ )
210
+ repo.add(mem)
211
+ action = "updated" if existing else "saved"
212
+ self._json_response({"status": "ok", "action": action, "slug": slug})
213
+ return
214
+
215
+ self._error(f"Not found: {path}", 404)
216
+
217
+
218
+ def run_http_server(port: int = 7474, store_path: Path | None = None) -> None:
219
+ """Run the memgit HTTP REST server."""
220
+ MemgitHandler.store_path = store_path
221
+ MemgitHandler.openapi_spec = _load_openapi_spec()
222
+
223
+ server = HTTPServer(("127.0.0.1", port), MemgitHandler)
224
+ print(f"memgit HTTP server running at http://127.0.0.1:{port}")
225
+ print(f" OpenAPI spec: http://127.0.0.1:{port}/openapi.json")
226
+ print(f" For GPT Custom Actions: import the spec from http://127.0.0.1:{port}/openapi.json")
227
+ print(f" Press Ctrl+C to stop.")
228
+ try:
229
+ server.serve_forever()
230
+ except KeyboardInterrupt:
231
+ print("\nStopped.")
memgit/importer.py ADDED
@@ -0,0 +1,121 @@
1
+ """Import memories from external sources into memgit."""
2
+
3
+ from __future__ import annotations
4
+ import re
5
+ from datetime import datetime, timezone
6
+ from pathlib import Path
7
+ from typing import Optional
8
+
9
+ from .models import Mnemonic
10
+
11
+ TYPE_MAP = {
12
+ 'feedback': 'fb',
13
+ 'user': 'us',
14
+ 'project': 'pj',
15
+ 'reference': 'rf',
16
+ 'convention': 'cn',
17
+ 'lesson': 'lx',
18
+ }
19
+
20
+
21
+ def from_claude_code(memory_dir: Path = None) -> list[Mnemonic]:
22
+ """Import all Claude Code markdown memory files.
23
+
24
+ Searches `~/.claude/projects/*/memory/*.md` by default,
25
+ or a specific directory if provided.
26
+ """
27
+ if memory_dir is not None:
28
+ dirs = [memory_dir]
29
+ else:
30
+ base = Path.home() / '.claude' / 'projects'
31
+ if not base.exists():
32
+ return []
33
+ dirs = [d / 'memory' for d in base.iterdir() if (d / 'memory').is_dir()]
34
+
35
+ mnemonics = []
36
+ for d in dirs:
37
+ for md_file in sorted(d.glob('*.md')):
38
+ if md_file.name.upper() == 'MEMORY.MD':
39
+ continue # skip index files
40
+ m = _parse_md(md_file)
41
+ if m:
42
+ mnemonics.append(m)
43
+ return mnemonics
44
+
45
+
46
+ def from_markdown_file(path: Path) -> Optional[Mnemonic]:
47
+ """Import a single Claude Code markdown memory file."""
48
+ return _parse_md(path)
49
+
50
+
51
+ def from_toon_file(path: Path) -> list[Mnemonic]:
52
+ """Import mnemonics from a .toon file."""
53
+ from .toon import parse_toon
54
+ text = path.read_text(encoding='utf-8')
55
+ objs = parse_toon(text)
56
+ return [o for o in objs if isinstance(o, Mnemonic)]
57
+
58
+
59
+ def _parse_md(path: Path) -> Optional[Mnemonic]:
60
+ try:
61
+ text = path.read_text(encoding='utf-8')
62
+ except Exception:
63
+ return None
64
+
65
+ if not text.startswith('---'):
66
+ return None
67
+ end = text.find('---', 3)
68
+ if end == -1:
69
+ return None
70
+
71
+ frontmatter = text[3:end].strip()
72
+ body = text[end + 3:].strip()
73
+
74
+ # Parse frontmatter (simple line-by-line, handles nested metadata block)
75
+ fm: dict[str, str] = {}
76
+ for line in frontmatter.splitlines():
77
+ stripped = line.strip()
78
+ if not stripped or stripped.startswith('#'):
79
+ continue
80
+ if ':' in stripped:
81
+ k, v = stripped.split(':', 1)
82
+ fm[k.strip()] = v.strip()
83
+
84
+ slug = fm.get('name', path.stem)
85
+ desc = fm.get('description', '')
86
+ type_str = fm.get('type', 'feedback')
87
+ type_code = TYPE_MAP.get(type_str, 'fb')
88
+
89
+ # Extract WHY and WHEN from body (bold labels)
90
+ why_m = re.search(r'\*\*Why:\*\*\s*(.+?)(?=\n\n|\*\*|$)', body, re.DOTALL)
91
+ when_m = re.search(r'\*\*How to apply:\*\*\s*(.+?)(?=\n\n|\*\*|$)', body, re.DOTALL)
92
+ why = why_m.group(1).strip() if why_m else None
93
+ when = when_m.group(1).strip() if when_m else None
94
+
95
+ # Rule = first paragraph before any ** sections
96
+ rule_text = re.split(r'\n\*\*|\n\n', body)[0].strip()
97
+ rule = rule_text or desc or slug
98
+
99
+ # Try to clean up multi-line rule
100
+ rule = ' '.join(rule.split('\n')).strip()
101
+
102
+ # Timestamp from file mtime
103
+ try:
104
+ mtime = path.stat().st_mtime
105
+ timestamp = datetime.fromtimestamp(mtime, tz=timezone.utc)
106
+ except Exception:
107
+ timestamp = datetime.now(timezone.utc)
108
+
109
+ if not slug:
110
+ return None
111
+
112
+ return Mnemonic(
113
+ type_code=type_code,
114
+ slug=slug,
115
+ timestamp=timestamp,
116
+ rule=rule[:400] if rule else desc,
117
+ why=why[:300] if why else None,
118
+ when=when[:300] if when else None,
119
+ priority=2,
120
+ tags=[type_code],
121
+ )