mneme-cli 0.4.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.
mneme/server.py ADDED
@@ -0,0 +1,312 @@
1
+ """
2
+ server.py - Mnemosyne local web UI server.
3
+
4
+ Usage:
5
+ cd mneme && python3 server.py
6
+
7
+ Runs on http://localhost:3141
8
+ """
9
+
10
+ import json
11
+ import os
12
+ import sys
13
+ import time
14
+ import traceback
15
+ from http.server import HTTPServer, BaseHTTPRequestHandler
16
+ from urllib.parse import urlparse, parse_qs, unquote
17
+
18
+ from .config import (
19
+ WIKI_DIR,
20
+ SCHEMA_DIR,
21
+ LOG_FILE,
22
+ INDEX_FILE,
23
+ UI_FILE,
24
+ )
25
+
26
+ # Lazy import core so memvid errors don't kill startup
27
+ try:
28
+ from .core import (
29
+ get_stats,
30
+ dual_search,
31
+ check_drift,
32
+ parse_frontmatter,
33
+ sync_all_pages,
34
+ )
35
+ CORE_OK = True
36
+ except Exception as e:
37
+ CORE_OK = False
38
+ CORE_ERROR = str(e)
39
+
40
+
41
+ PORT = 3141
42
+
43
+
44
+ def _json_response(data):
45
+ return json.dumps(data, default=str).encode('utf-8')
46
+
47
+
48
+ def _error(msg, code=500):
49
+ return code, _json_response({'error': msg})
50
+
51
+
52
+ def handle_stats():
53
+ if not CORE_OK:
54
+ return 500, _json_response({'error': CORE_ERROR})
55
+ try:
56
+ data = get_stats()
57
+ return 200, _json_response(data)
58
+ except Exception as e:
59
+ return 500, _json_response({'error': str(e), 'trace': traceback.format_exc()})
60
+
61
+
62
+ def handle_search(query: str):
63
+ if not CORE_OK:
64
+ return 500, _json_response({'error': CORE_ERROR})
65
+ if not query.strip():
66
+ return 400, _json_response({'error': 'Empty query'})
67
+ try:
68
+ t0 = time.time()
69
+ results = dual_search(query)
70
+ elapsed_ms = round((time.time() - t0) * 1000, 1)
71
+ return 200, _json_response({'results': results, 'count': len(results), 'query': query, 'elapsed_ms': elapsed_ms})
72
+ except Exception as e:
73
+ return 500, _json_response({'error': str(e), 'trace': traceback.format_exc()})
74
+
75
+
76
+ def handle_drift():
77
+ if not CORE_OK:
78
+ return 500, _json_response({'error': CORE_ERROR})
79
+ try:
80
+ data = check_drift()
81
+ return 200, _json_response(data)
82
+ except Exception as e:
83
+ return 500, _json_response({'error': str(e), 'trace': traceback.format_exc()})
84
+
85
+
86
+ def handle_wiki_list():
87
+ """Return a nested structure: {client: [page_slug, ...]}"""
88
+ import glob
89
+ from pathlib import Path
90
+ try:
91
+ pattern = os.path.join(WIKI_DIR, '**', '*.md')
92
+ all_pages = glob.glob(pattern, recursive=True)
93
+ tree = {}
94
+ for page in sorted(all_pages):
95
+ rel = os.path.relpath(page, WIKI_DIR)
96
+ parts = Path(rel).parts
97
+ # Skip _templates
98
+ if any(p.startswith('_templates') for p in parts):
99
+ continue
100
+ client = parts[0] if len(parts) > 1 else '_root'
101
+ slug = os.path.splitext(rel)[0] # path without .md
102
+ if client not in tree:
103
+ tree[client] = []
104
+ # Get title from frontmatter if possible
105
+ title = slug.split('/')[-1]
106
+ try:
107
+ with open(page, 'r', encoding='utf-8') as f:
108
+ content = f.read()
109
+ if CORE_OK:
110
+ fm, _ = parse_frontmatter(content)
111
+ title = fm.get('title', title)
112
+ except Exception:
113
+ pass
114
+ tree[client].append({'slug': slug, 'title': title})
115
+ return 200, _json_response({'tree': tree})
116
+ except Exception as e:
117
+ return 500, _json_response({'error': str(e)})
118
+
119
+
120
+ def handle_wiki_page(path_suffix: str):
121
+ """
122
+ Read a specific wiki page. path_suffix is like 'demo-retail/sample-proposal'
123
+ (no .md extension - we add it).
124
+ """
125
+ from pathlib import Path
126
+
127
+ clean = path_suffix.lstrip('/')
128
+ if not clean:
129
+ return 400, _json_response({'error': 'No path given'})
130
+
131
+ # Try with and without .md extension, restricted to WIKI_DIR only
132
+ wiki_base = Path(WIKI_DIR).resolve()
133
+ candidates = [
134
+ wiki_base / clean,
135
+ wiki_base / (clean + '.md'),
136
+ ]
137
+ page_path = None
138
+ for c in candidates:
139
+ resolved = c.resolve()
140
+ # Bounds check: must be within WIKI_DIR
141
+ if not str(resolved).startswith(str(wiki_base)):
142
+ continue
143
+ if resolved.is_file() and str(resolved).endswith('.md'):
144
+ page_path = str(resolved)
145
+ break
146
+
147
+ if not page_path:
148
+ return 404, _json_response({'error': f'Page not found: {clean}'})
149
+
150
+ try:
151
+ with open(page_path, 'r', encoding='utf-8') as f:
152
+ content = f.read()
153
+ frontmatter = {}
154
+ body = content
155
+ if CORE_OK:
156
+ frontmatter, body = parse_frontmatter(content)
157
+ return 200, _json_response({
158
+ 'path': clean,
159
+ 'frontmatter': frontmatter,
160
+ 'body': body,
161
+ 'raw': content,
162
+ })
163
+ except Exception as e:
164
+ return 500, _json_response({'error': str(e)})
165
+
166
+
167
+ def handle_entities():
168
+ path = os.path.join(SCHEMA_DIR, 'entities.json')
169
+ if not os.path.exists(path):
170
+ return 404, _json_response({'error': 'entities.json not found'})
171
+ try:
172
+ with open(path, 'r', encoding='utf-8') as f:
173
+ data = json.load(f)
174
+ return 200, _json_response(data)
175
+ except Exception as e:
176
+ return 500, _json_response({'error': str(e)})
177
+
178
+
179
+ def handle_tags():
180
+ path = os.path.join(SCHEMA_DIR, 'tags.json')
181
+ if not os.path.exists(path):
182
+ return 404, _json_response({'error': 'tags.json not found'})
183
+ try:
184
+ with open(path, 'r', encoding='utf-8') as f:
185
+ data = json.load(f)
186
+ return 200, _json_response(data)
187
+ except Exception as e:
188
+ return 500, _json_response({'error': str(e)})
189
+
190
+
191
+ def handle_log():
192
+ if not os.path.exists(LOG_FILE):
193
+ return 404, _json_response({'error': 'log.md not found'})
194
+ try:
195
+ with open(LOG_FILE, 'r', encoding='utf-8') as f:
196
+ content = f.read()
197
+ return 200, _json_response({'content': content})
198
+ except Exception as e:
199
+ return 500, _json_response({'error': str(e)})
200
+
201
+
202
+ def handle_sync():
203
+ if not CORE_OK:
204
+ return 500, _json_response({'error': CORE_ERROR})
205
+ try:
206
+ result = sync_all_pages()
207
+ return 200, _json_response(result)
208
+ except Exception as e:
209
+ return 500, _json_response({'error': str(e), 'trace': traceback.format_exc()})
210
+
211
+
212
+ class MnemeHandler(BaseHTTPRequestHandler):
213
+
214
+ def log_message(self, format, *args):
215
+ # Custom minimal logging
216
+ print(f' {self.command} {self.path} -> {args[1] if len(args) > 1 else "?"}')
217
+
218
+ def _send(self, code: int, body: bytes, content_type: str = 'application/json'):
219
+ self.send_response(code)
220
+ self.send_header('Content-Type', content_type)
221
+ self.send_header('Content-Length', str(len(body)))
222
+ self.send_header('Access-Control-Allow-Origin', 'http://localhost:3141')
223
+ self.end_headers()
224
+ self.wfile.write(body)
225
+
226
+ def do_GET(self):
227
+ parsed = urlparse(self.path)
228
+ path = parsed.path
229
+ qs = parse_qs(parsed.query)
230
+
231
+ # Serve UI
232
+ if path == '/' or path == '/index.html':
233
+ if os.path.exists(UI_FILE):
234
+ with open(UI_FILE, 'rb') as f:
235
+ body = f.read()
236
+ self._send(200, body, 'text/html; charset=utf-8')
237
+ else:
238
+ self._send(404, b'<h1>ui.html not found</h1>', 'text/html')
239
+ return
240
+
241
+ # API routing
242
+ if path == '/api/stats':
243
+ code, body = handle_stats()
244
+ self._send(code, body)
245
+
246
+ elif path == '/api/search':
247
+ query = qs.get('q', [''])[0]
248
+ code, body = handle_search(query)
249
+ self._send(code, body)
250
+
251
+ elif path == '/api/drift':
252
+ code, body = handle_drift()
253
+ self._send(code, body)
254
+
255
+ elif path == '/api/wiki':
256
+ code, body = handle_wiki_list()
257
+ self._send(code, body)
258
+
259
+ elif path.startswith('/api/wiki/'):
260
+ suffix = unquote(path[len('/api/wiki/'):])
261
+ code, body = handle_wiki_page(suffix)
262
+ self._send(code, body)
263
+
264
+ elif path == '/api/entities':
265
+ code, body = handle_entities()
266
+ self._send(code, body)
267
+
268
+ elif path == '/api/tags':
269
+ code, body = handle_tags()
270
+ self._send(code, body)
271
+
272
+ elif path == '/api/log':
273
+ code, body = handle_log()
274
+ self._send(code, body)
275
+
276
+ else:
277
+ self._send(404, _json_response({'error': f'Unknown endpoint: {path}'}))
278
+
279
+ def do_POST(self):
280
+ parsed = urlparse(self.path)
281
+ path = parsed.path
282
+
283
+ if path == '/api/sync':
284
+ code, body = handle_sync()
285
+ self._send(code, body)
286
+ else:
287
+ self._send(404, _json_response({'error': f'Unknown endpoint: {path}'}))
288
+
289
+ def do_OPTIONS(self):
290
+ self.send_response(200)
291
+ self.send_header('Access-Control-Allow-Origin', 'http://localhost:3141')
292
+ self.send_header('Access-Control-Allow-Methods', 'GET, POST, OPTIONS')
293
+ self.send_header('Access-Control-Allow-Headers', 'Content-Type')
294
+ self.end_headers()
295
+
296
+
297
+ def main():
298
+ server = HTTPServer(('localhost', PORT), MnemeHandler)
299
+ print(f'Mnemosyne UI running at http://localhost:{PORT}')
300
+ print('Press Ctrl+C to stop')
301
+ if not CORE_OK:
302
+ print(f'[WARNING] core import failed: {CORE_ERROR}')
303
+ print('[WARNING] Some API endpoints will return errors. Memvid features disabled.')
304
+ try:
305
+ server.serve_forever()
306
+ except KeyboardInterrupt:
307
+ print('\nStopped.')
308
+ server.server_close()
309
+
310
+
311
+ if __name__ == '__main__':
312
+ main()
@@ -0,0 +1,9 @@
1
+ memvid/*.mv2
2
+ memvid/per-client/
3
+ memvid/.sync-manifest.json
4
+ exports/
5
+ snapshots/
6
+ .mneme/
7
+ __pycache__/
8
+ *.pyc
9
+ .DS_Store