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/__init__.py +3 -0
- memgit/cli.py +1267 -0
- memgit/graph.py +486 -0
- memgit/http_server.py +231 -0
- memgit/importer.py +121 -0
- memgit/mcp_server.py +418 -0
- memgit/models.py +80 -0
- memgit/repo.py +714 -0
- memgit/scorer.py +123 -0
- memgit/store.py +176 -0
- memgit/tokens.py +48 -0
- memgit/toon.py +356 -0
- memgit-0.1.1.dist-info/METADATA +457 -0
- memgit-0.1.1.dist-info/RECORD +18 -0
- memgit-0.1.1.dist-info/WHEEL +5 -0
- memgit-0.1.1.dist-info/entry_points.txt +2 -0
- memgit-0.1.1.dist-info/licenses/LICENSE +21 -0
- memgit-0.1.1.dist-info/top_level.txt +1 -0
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
|
+
)
|