-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathmemnos_server.py
More file actions
1564 lines (1477 loc) · 87.9 KB
/
Copy pathmemnos_server.py
File metadata and controls
1564 lines (1477 loc) · 87.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
"""memnos production memory server — pooled, authenticated, namespace-ACL'd, audited.
Hardening vs the early prototype:
- Bearer-token auth (server-side identity; never client-trusted) → principal
- Namespace ACL: every read/write clamped to the principal's grants
- Connection POOL (psycopg_pool) — no connection-per-request exhaustion
- Input validation + size limits + structured errors (no stack-trace leaks)
- Audit log (who/what/when) + usage ledger (cost) — the governance moat
- /healthz (liveness) + /readyz (DB reachable)
Config via env: MEMNOS_DSN, MEMNOS_PORT, MEMNOS_POOL_MAX, OPENAI_API_KEY (enables
1536-d embeddings + extraction; else free local 384-d).
Bootstrap identity with: python memnos_admin.py ...
"""
import base64
import io
import json
import os
import queue
import re
import sys
import threading
import time
import traceback
import urllib.request
from datetime import datetime, timezone
from concurrent.futures import ThreadPoolExecutor
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import urlparse, parse_qs
sys.path.insert(0, ".")
def _load_env(path=".env"):
"""Zero-dependency .env loader so secrets (OPENAI_API_KEY) stay in .env, not the
launchd plist. Does not override already-set environment variables."""
try:
with open(path) as fh:
for line in fh:
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
k, v = line.split("=", 1)
k, v = k.strip(), v.strip().strip('"').strip("'")
os.environ.setdefault(k, v)
except FileNotFoundError:
pass
def _load_config():
"""Load ~/.memnos/config.json (written by `memnos setup`) into env. setdefault, so an
existing .env / real env still wins — but a fresh pip install with only config.json
works with no .env. Shares the master key + DSN between CLI and server."""
import json as _json
p = os.path.join(os.path.expanduser("~"), ".memnos", "config.json")
try:
with open(p) as fh:
cfg = _json.load(fh)
if cfg.get("dsn"):
os.environ.setdefault("MEMNOS_DSN", cfg["dsn"])
if cfg.get("secret_key"):
os.environ.setdefault("MEMNOS_SECRET_KEY", cfg["secret_key"])
if cfg.get("port"):
os.environ.setdefault("MEMNOS_PORT", str(cfg["port"]))
if cfg.get("openai"): # 'secret://openai' — resolved at startup
os.environ.setdefault("OPENAI_API_KEY", cfg["openai"])
except (FileNotFoundError, ValueError):
pass
_load_env()
_load_config()
from psycopg import OperationalError
from psycopg.rows import dict_row
from psycopg_pool import ConnectionPool, PoolTimeout
from core.store import BrainStore, query_clamp
from core.service import MemnosMemory
from core.control import Control
from core import rerank as brain_rerank
from core import memrelief
DSN = os.environ.get("MEMNOS_DSN", "postgresql://memnos:memnos@localhost:5432/memnos")
PORT = int(os.environ.get("MEMNOS_PORT", "8900"))
POOL_MAX = int(os.environ.get("MEMNOS_POOL_MAX", "16"))
# Extraction model: gpt-4o-mini on the OpenAI path; override for OpenAI-compatible
# local servers (e.g. MEMNOS_EXTRACT_MODEL=llama3.2:3b with MEMNOS_EXTRACT_BASE_URL).
EXTRACT_MODEL = os.environ.get("MEMNOS_EXTRACT_MODEL", "gpt-4o-mini")
MAX_BODY = 256 * 1024 # 256 KB request cap
# issue #15: recall queries are CLAMPED to this many chars (not 400-rejected) so a long
# query returns 200 — the embedder truncates and the FTS arm is token-clamped, so it is
# safe. Generous default; only a genuinely abusive payload exceeds it.
_QUERY_MAX_CHARS = int(os.environ.get("MEMNOS_QUERY_MAX_CHARS", "20000"))
# issue #14: optional budget thresholds — float USD or None (unconfigured)
def _parse_budget_env(name):
v = os.environ.get(name, "").strip()
try:
return float(v) if v else None
except ValueError:
return None
BUDGET_DAILY_USD = _parse_budget_env("MEMNOS_BUDGET_DAILY_USD")
BUDGET_MONTHLY_USD = _parse_budget_env("MEMNOS_BUDGET_MONTHLY_USD")
WRITE_OPS = {"/remember", "/consolidate", "/memory/write", "/memory/delete", "/corpus/ingest",
"/ingest/file", "/episode/segment", "/episode/decay", "/namespace/copy",
"/lease/acquire", "/lease/heartbeat", "/lease/release"}
def _suggest_enabled():
"""Suggest-on-mismatch is ON by default; MEMNOS_SUGGEST_NAMESPACE=0 turns it off so a
deployment that finds it noisy (or wants zero extra per-write SQL) can opt out."""
return os.environ.get("MEMNOS_SUGGEST_NAMESPACE", "1").strip().lower() not in ("0", "false", "no", "off")
def _write_suggestion(conn, principal_id, write_ns, facts, raw_text):
"""Build the non-blocking suggest-on-mismatch hint for a /remember response, or None.
Gathers the write's entity names (each fact's subject + cheap proper-noun NER over the
raw turn — the SAME signal the encoder writes as entities/mentions) and asks the bounded
Control.suggest_namespace helper whether a DIFFERENT writable namespace dominates them.
SUGGEST ONLY — never moves the write. Best-effort: any error -> None (a write must NEVER
fail because the advisory broke)."""
if not _suggest_enabled():
return None
try:
from core.encode import extract_entities
names = set(extract_entities(raw_text or ""))
for f in (facts or []):
s = (f.get("subject") or "").strip()
if s:
names.add(s)
return Control.suggest_namespace(conn, principal_id, write_ns, list(names))
except Exception:
return None
# Endpoints whose handler mixes DB work with SLOW NON-DB work (network embeddings, LLM
# calls, cross-encoder rerank, file parsing). These are served by the PHASED dispatcher
# (_phased): pool connections are only held for the short DB phases — never across model
# I/O. Everything left in the generic do_POST block below is pure SQL.
PHASED_OPS = {"/recall", "/memory/search", "/recall_v2", "/memory/context", "/consolidate",
"/ingest/file", "/episode/segment", "/episode/recall", "/reconcile"}
# TYPED MEMORIES (0.1.6): the closed set of memory types. Validated server-side on every
# write (400 on anything else) so the column can never accumulate junk labels.
# type='constraint' memories are PINNED into /recall (see _phased_op).
ALLOWED_MEMORY_TYPES = ("decision", "incident", "constraint", "skill", "fact")
def _memory_type(req):
"""Parse + validate the optional `type` field. Returns (ok, value_or_error_obj):
(True, None) when absent, (True, t) when valid, (False, {error}) on an unknown type."""
t = req.get("type")
if t is None or str(t).strip() == "":
return True, None
t = str(t).strip().lower()
if t not in ALLOWED_MEMORY_TYPES:
return False, {"error": "unknown type %r (allowed: %s)" % (t, " | ".join(ALLOWED_MEMORY_TYPES))}
return True, t
def _chunk_text(text, size=1200, overlap=150):
"""Paragraph-aware chunking: pack paragraphs up to ~size chars; hard-split any
paragraph longer than size (with overlap). Keeps semantically-coherent chunks."""
text = (text or "").strip()
if not text:
return []
chunks, cur = [], ""
for p in re.split(r"\n\s*\n", text):
p = p.strip()
if not p:
continue
if len(p) > size:
if cur:
chunks.append(cur); cur = ""
for i in range(0, len(p), max(1, size - overlap)):
chunks.append(p[i:i + size])
elif len(cur) + len(p) + 1 <= size:
cur = (cur + "\n" + p).strip()
else:
if cur:
chunks.append(cur)
cur = p
if cur:
chunks.append(cur)
return chunks
def _extract_file_text(filename, raw: bytes):
"""Best-effort text extraction. PDF/DOCX need pypdf/python-docx (optional); everything
else is decoded as UTF-8 text (md/txt/code). Returns None if it can't be parsed."""
low = (filename or "").lower()
try:
if low.endswith(".pdf"):
import pypdf
r = pypdf.PdfReader(io.BytesIO(raw))
return "\n\n".join((pg.extract_text() or "") for pg in r.pages)
if low.endswith(".docx"):
import docx
d = docx.Document(io.BytesIO(raw))
return "\n\n".join(p.text for p in d.paragraphs)
return raw.decode("utf-8", "replace")
except Exception:
return None
_DELIVER_EVENT = threading.Event() # set after a write → wakes the webhook pusher
WEBHOOK_TIMEOUT = float(os.environ.get("MEMNOS_WEBHOOK_TIMEOUT", "5"))
PUSHER_INTERVAL = float(os.environ.get("MEMNOS_PUSHER_INTERVAL", "3"))
def _webhook_post(url, payload):
"""POST a JSON payload to a subscriber webhook; raises on non-2xx (stdlib, no dep)."""
req = urllib.request.Request(url, method="POST",
data=json.dumps(payload, default=str).encode(),
headers={"Content-Type": "application/json",
"User-Agent": "memnos-webhook/1"})
with urllib.request.urlopen(req, timeout=WEBHOOK_TIMEOUT) as r:
if not (200 <= r.status < 300):
raise RuntimeError(f"webhook HTTP {r.status}")
def _pusher_loop():
"""Background webhook delivery: wakes on a write (event) or every PUSHER_INTERVAL,
delivers pending events to subscriber webhooks at-least-once. Daemon thread.
conn_factory=POOL.connection: DB steps each use a short-lived pool conn so NO pool
slot is held across the webhook POSTs (network, up to WEBHOOK_TIMEOUT each)."""
while True:
_DELIVER_EVENT.wait(timeout=PUSHER_INTERVAL)
_DELIVER_EVENT.clear()
try:
Control.deliver_pending(None, _webhook_post, conn_factory=POOL.connection)
except (PoolTimeout, OperationalError) as e:
print(f"[memnos] webhook pusher: DB unreachable ({type(e).__name__}) — will retry", flush=True)
except Exception:
traceback.print_exc()
def _find_ui_dir():
"""Locate the /admin console assets in both source and installed (pip/pipx) layouts —
next to the module (source/editable) or under <prefix>/share/memnos/ui (data-files)."""
here = os.path.dirname(os.path.abspath(__file__))
for c in (os.path.join(here, "ui"),
os.path.join(sys.prefix, "share", "memnos", "ui"),
os.path.join(os.path.dirname(os.path.dirname(sys.executable)), "share", "memnos", "ui")):
if os.path.isdir(c):
return c
return os.path.join(here, "ui")
UI_DIR = _find_ui_dir()
_CTYPE = {".html": "text/html", ".js": "text/javascript", ".css": "text/css",
".json": "application/json"}
POOL = None
_NS_CACHE = {"t": 0.0, "data": None} # namespace-census cache (10s TTL, write-invalidated)
_INGEST_Q = queue.Queue(maxsize=1024) # async /remember extraction queue
EMBED = None
LLM = None
DIM = 384
# Issue #12 — recall latency. Query-embed cache: hooks/Desktop repeat near-identical
# queries within seconds; a short-TTL LRU keyed (query, model) lets repeats skip the
# OpenAI round-trip entirely. Embed executor: the round-trip for cache MISSES is
# overlapped with the non-vector DB arms (recall_prefetch) — never with a pool conn held.
from core.embed import QueryEmbedCache
def _env_f(name, default):
try:
return float(os.environ.get(name, default))
except (TypeError, ValueError):
return default
_QUERY_CACHE = QueryEmbedCache(ttl_s=_env_f("MEMNOS_QUERY_CACHE_TTL_S", 60.0))
_EMBED_EXEC = ThreadPoolExecutor(max_workers=4, thread_name_prefix="memnos-query-embed")
class _UsageAcc:
"""Per-request accumulator for engine LLM token usage (extraction + consolidation),
converted to USD via the shared pricing table — so usage_ledger reflects real spend."""
__slots__ = ("tin", "tout", "cost")
def __init__(self):
self.tin = self.tout = 0
self.cost = 0.0
def __call__(self, model, prompt_tokens, completion_tokens):
from core.usage import PRICING
pin, pout = PRICING.get(model, (0.0, 0.0))
self.tin += int(prompt_tokens or 0)
self.tout += int(completion_tokens or 0)
self.cost += (prompt_tokens or 0) / 1e6 * pin + (completion_tokens or 0) / 1e6 * pout
def _fake_extract(text, date):
"""TEST-ONLY deterministic fact extractor (MEMNOS_FAKE_EXTRACT=1). Emits one SPO fact
per proper-noun token found by the regex NER — so `write_facts` persists those entities
into the write namespace, EXACTLY as the real LLM path does. This lets CI (no OpenAI
key) exercise the genuine P2(extract)->P3(write_facts)->suggestion ORDERING — the only
path where the self-pollution bug lived. NEVER enable in production: it captures noise,
not real facts."""
from core.encode import extract_entities
seen, facts = set(), []
for e in extract_entities(text or ""):
k = (e or "").strip().lower()
if not k or k in seen:
continue
seen.add(k)
facts.append({"subject": e, "predicate": "", "object": "",
"statement": f"{e} was mentioned."})
return facts
# When set, the server runs the FULL extraction branch with the $0 deterministic extractor
# above instead of an LLM — used only by tests/CI to reproduce the LLM-path write ordering.
EXTRACT_FN = _fake_extract if os.environ.get("MEMNOS_FAKE_EXTRACT", "").strip().lower() in (
"1", "true", "yes", "on") else None
def _have_extraction():
"""True when the /remember path should run extraction (real LLM OR the test extractor)."""
return LLM is not None or EXTRACT_FN is not None
def _build_embedder():
global LLM, DIM
if os.environ.get("OPENAI_API_KEY"):
from openai import OpenAI
from core.embed import CachedEmbedder
from core.usage import TSCostMeter
# timeout is REQUIRED: without it a single stalled request hangs the request
# thread forever (observed: extraction/consolidation wedged with no timeout).
LLM = OpenAI(max_retries=3, timeout=60)
DIM = 1536
emb = CachedEmbedder(LLM, TSCostMeter())
print("[memnos] OpenAI 1536-d embeddings + extraction ENABLED", flush=True)
return emb
from core import local_models
DIM = 384
# Local LLM extraction via any OpenAI-compatible endpoint (e.g. Ollama at
# http://localhost:11434/v1) WITHOUT switching embeddings off the free local-384
# path. Set MEMNOS_EXTRACT_BASE_URL (+ optional MEMNOS_EXTRACT_MODEL) and no
# OPENAI_API_KEY: embeddings stay local/free, only fact extraction calls the LLM.
if os.environ.get("MEMNOS_EXTRACT_BASE_URL"):
from openai import OpenAI
# OpenAI-compatible servers (Ollama, vLLM, LM Studio) ignore the key but the
# SDK requires one — a placeholder is fine and never leaves the box.
LLM = OpenAI(base_url=os.environ["MEMNOS_EXTRACT_BASE_URL"],
api_key=os.environ.get("MEMNOS_EXTRACT_API_KEY", "local"),
max_retries=2, timeout=120)
print(f"[memnos] local 384-d embeddings (free/private) + LOCAL LLM extraction "
f"via {os.environ['MEMNOS_EXTRACT_BASE_URL']} model={EXTRACT_MODEL}", flush=True)
return local_models.embed
print("[memnos] local 384-d embeddings (free/private), no extraction", flush=True)
return local_models.embed
class Handler(BaseHTTPRequestHandler):
server_version = "memnos/1.0"
def _send(self, code, obj):
body = json.dumps(obj, default=str).encode() # default=str → datetime/Decimal safe
try:
self.send_response(code)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
except (BrokenPipeError, ConnectionResetError):
pass # client timed out / gave up — nothing to send to
def _token(self):
h = self.headers.get("Authorization", "")
return h[7:].strip() if h.lower().startswith("bearer ") else None
def _send_static(self, fname):
"""Serve the zero-build console from ui/ (localhost-only shell; JS does auth)."""
safe = os.path.basename(fname) # no path traversal
fp = os.path.join(UI_DIR, safe)
if not os.path.isfile(fp):
return self._send(404, {"error": "not found"})
body = open(fp, "rb").read()
self.send_response(200)
self.send_header("Content-Type", _CTYPE.get(os.path.splitext(safe)[1], "application/octet-stream"))
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def _read_body(self):
try:
n = int(self.headers.get("Content-Length", 0))
except ValueError:
return None
if n > MAX_BODY:
return None
try:
req = json.loads(self.rfile.read(n) or b"{}")
return req if isinstance(req, dict) else None
except (ValueError, json.JSONDecodeError):
return None
def _admin(self, method, sub, qs, body):
"""Management-console API under /admin/api/. Requires an ADMIN principal
(holds the '*' grant). Returns (code, obj). Audited by the caller."""
with POOL.connection() as conn:
pid = Control.authenticate(conn, self._token())
if pid is None:
return 401, {"error": "unauthorized"}
if not Control.is_admin(conn, pid):
return 403, {"error": "admin token required ('*' grant)"}
body = body or {}
try:
if sub == "namespaces" and method == "GET":
# short TTL cache: the console fires several parallel calls on page
# load; the namespace census aggregates large tables — compute once
now = time.monotonic()
if _NS_CACHE["data"] is None or now - _NS_CACHE["t"] > 10:
_NS_CACHE["data"] = Control.list_namespaces(conn)
_NS_CACHE["t"] = now
return 200, {"namespaces": _NS_CACHE["data"]}
if sub == "namespaces" and method == "POST":
name = str(body.get("name", "")).strip()
if not name or len(name) > 200:
return 400, {"error": "name required (<=200 chars)"}
Control.create_namespace(conn, name, created_by=pid, description=body.get("description"))
if body.get("kind"):
Control.set_namespace_kind(conn, name, str(body["kind"]))
_NS_CACHE["data"] = None
return 200, {"ok": True, "name": name}
# --- grounded-recall namespace links (admin-only CRUD) ---
if sub == "namespaces/links" and method == "GET":
src = (qs.get("ns", [""])[0]).strip() or None
return 200, {"links": Control.list_links(conn, src)}
if sub == "namespaces/links" and method == "POST":
src = str(body.get("src", "")).strip()
dst = str(body.get("dst", "")).strip()
if not src or not dst:
return 400, {"error": "src and dst required"}
if src == dst:
return 400, {"error": "src and dst must differ"}
Control.link_namespaces(conn, src, dst, created_by=pid)
return 200, {"ok": True, "src": src, "dst": dst}
if sub == "namespaces/links" and method == "DELETE":
src = (qs.get("src", [""])[0]).strip()
dst = (qs.get("dst", [""])[0]).strip()
if not src or not dst:
return 400, {"error": "src and dst required"}
return 200, {"ok": True, "removed": Control.unlink_namespaces(conn, src, dst)}
if sub == "namespaces/kind" and method == "POST":
name = str(body.get("name", "")).strip()
kind = str(body.get("kind", "")).strip()
if not name or kind not in ("memory", "knowledge"):
return 400, {"error": "name and kind ('memory'|'knowledge') required"}
Control.set_namespace_kind(conn, name, kind)
_NS_CACHE["data"] = None
return 200, {"ok": True, "name": name, "kind": kind}
if sub == "namespaces" and method == "DELETE":
name = (qs.get("name", [""])[0]).strip()
if not name:
return 400, {"error": "name required"}
Control.delete_namespace(conn, name, purge_data=qs.get("purge", ["0"])[0] == "1")
_NS_CACHE["data"] = None
return 200, {"ok": True}
if sub == "principals" and method == "GET":
return 200, {"principals": Control.list_principals(conn)}
if sub == "principals" and method == "POST":
name = str(body.get("name", "")).strip()
if not name:
return 400, {"error": "name required"}
npid = Control.create_principal(conn, name, body.get("kind", "user"))
return 200, {"ok": True, "id": npid}
if sub == "tokens" and method == "GET":
p = int(qs.get("principal", [0])[0])
return 200, {"tokens": Control.list_tokens(conn, p)}
if sub == "tokens" and method == "POST":
p = int(body.get("principal_id", 0))
tok = Control.mint_token(conn, p, body.get("label"), body.get("ttl_days"))
return 200, {"token": tok} # plaintext ONCE
if sub == "tokens/revoke" and method == "POST":
Control.revoke_token_by_id(conn, int(body.get("id", 0)))
return 200, {"ok": True}
if sub == "grants" and method == "POST":
Control.grant(conn, int(body.get("principal_id", 0)), str(body.get("namespace", "")),
bool(body.get("can_read", True)), bool(body.get("can_write", True)))
return 200, {"ok": True}
if sub == "grants" and method == "DELETE":
Control.revoke_grant(conn, int(qs.get("principal", [0])[0]), qs.get("namespace", [""])[0])
return 200, {"ok": True}
if sub == "grants" and method == "GET":
return 200, {"grants": Control.authorized_namespaces(conn, int(qs.get("principal", [0])[0]))}
if sub == "stats" and method == "GET":
hours = max(1, min(int(qs.get("hours", [24])[0]), 720))
return 200, {"ops": Control.stats(conn, hours), "window_hours": hours}
if sub == "usage" and method == "GET":
days = qs.get("days", [None])[0]
days = max(1, min(int(days), 365)) if days else 30
ns_filter = (qs.get("namespace", [""])[0]).strip() or None
return 200, {**Control.usage_summary(conn, days, ns_filter),
"period_days": days, "namespace": ns_filter}
if sub == "budget" and method == "GET":
return 200, Control.budget_status(conn, BUDGET_DAILY_USD, BUDGET_MONTHLY_USD)
if sub == "memory/feed" and method == "GET":
# recent memories across ALL namespaces (admin-only, paginated)
limit = max(1, min(int(qs.get("limit", [50])[0]), 200))
offset = max(0, int(qs.get("offset", [0])[0]))
fns = (qs.get("namespace", [""])[0]).strip() or None
ftype = (qs.get("type", [""])[0]).strip().lower() or None
if ftype and ftype not in ALLOWED_MEMORY_TYPES:
return 400, {"error": "unknown type %r (allowed: %s)"
% (ftype, " | ".join(ALLOWED_MEMORY_TYPES))}
return 200, {"memories": Control.memory_feed(conn, limit, offset, fns, ftype),
"limit": limit, "offset": offset}
if sub == "audit" and method == "GET":
# server-side pagination: limit clamped 1..1000, total is APPROXIMATE
# (planner stats) so the endpoint stays O(page) as the log grows
limit = max(1, min(int(qs.get("limit", [100])[0]), 1000))
offset = max(0, int(qs.get("offset", [0])[0]))
return 200, {"audit": Control.recent_audit(conn, limit, offset),
"total": Control.audit_total(conn), "total_estimated": True,
"limit": limit, "offset": offset}
if sub == "health" and method == "GET":
return 200, {"findings": [{"level": l, "msg": m} for l, m in Control.health(conn)]}
if sub == "quality" and method == "GET":
return 200, {"trend": Control.eval_trend(conn, "stale_suppression", "rate", 10)}
if sub == "subscriptions" and method == "GET":
return 200, {"subscriptions": Control.list_subscriptions(conn, int(qs.get("principal", [pid])[0]))}
if sub == "deliver" and method == "POST":
# run a webhook delivery pass now (ops + deterministic tests)
return 200, {"delivered": Control.deliver_pending(conn, _webhook_post)}
if sub == "provider" and method == "GET":
from core.vault import Vault
return 200, {"mode": "openai" if os.environ.get("OPENAI_API_KEY") else "local",
"dim": DIM, "key_present": bool(os.environ.get("OPENAI_API_KEY")),
"extraction": LLM is not None,
"extract_base_url": os.environ.get("MEMNOS_EXTRACT_BASE_URL"),
"extract_model": EXTRACT_MODEL if LLM is not None else None,
"vault_unlocked": Vault.available()}
if sub == "secrets":
from core.vault import Vault, VaultLocked
try:
if method == "GET":
return 200, {"secrets": Vault.list(conn), "unlocked": Vault.available()}
if method == "POST":
name = str(body.get("name", "")).strip()
val = str(body.get("value", ""))
if not name or not val:
return 400, {"error": "name and value required"}
Vault.set(conn, name, val, body.get("description")) # plaintext never stored
return 200, {"ok": True, "name": name}
if method == "DELETE":
Vault.delete(conn, (qs.get("name", [""])[0]).strip())
return 200, {"ok": True}
except VaultLocked as v:
return 409, {"error": "vault locked", "msg": str(v)}
except Exception as e:
traceback.print_exc()
return 500, {"error": type(e).__name__, "msg": str(e)[:200]}
return 404, {"error": "unknown admin route"}
def _user(self, method, path, qs, body):
"""USER-scoped API (issue #20, Part A): any valid token, scoped to ITS OWN
principal — NOT admin-gated. A principal can only see/modify its own bindings +
hosts. Returns (code, obj) or None if `path` isn't a user route (so the caller
falls through to the normal dispatch). Mirrors `_admin`'s shape."""
if not (path == "/bindings" or path.startswith("/bindings/")
or path == "/hosts" or path == "/bindings/recap" or path == "/nudges"):
return None
with POOL.connection() as conn:
pid = Control.authenticate(conn, self._token())
if pid is None:
return 401, {"error": "unauthorized"}
body = body or {}
try:
if path == "/bindings/recap" and method == "GET":
try:
days = int((qs or {}).get("days", ["7"])[0])
except Exception:
days = 7
return 200, {"days": days, "recap": Control.write_recap(conn, pid, days=days)}
if path == "/bindings" and method == "GET":
return 200, {"bindings": Control.list_bindings(conn, pid),
"hosts": Control.list_hosts(conn, pid)}
if path == "/bindings" and method == "POST":
kt = str(body.get("key_type", "")).strip()
key = str(body.get("key", "")).strip()
ns = str(body.get("namespace", "")).strip()
host_id = (str(body.get("host_id", "")).strip() or None)
if kt not in ("repo", "host_repo", "host_path"):
return 400, {"error": "key_type must be 'repo'|'host_repo'|'host_path'"}
if not key or not ns or len(ns) > 200:
return 400, {"error": "key and namespace required (namespace <=200 chars)"}
if kt in ("host_repo", "host_path") and not host_id:
return 400, {"error": "host_id required for host-scoped binding"}
row = Control.upsert_binding(conn, pid, kt, key, ns, host_id)
return 200, {"binding": row}
if path.startswith("/bindings/") and method == "DELETE":
try:
bid = int(path[len("/bindings/"):])
except ValueError:
return 400, {"error": "binding id (int) required"}
if not Control.delete_binding(conn, pid, bid):
return 404, {"error": "binding not found"} # also not-theirs -> 404
return 200, {"ok": True}
if path == "/nudges" and method == "GET":
# deferred suggest-on-mismatch (issue #20, Part B3): return + mark
# delivered (at-most-once display). The SessionStart hook surfaces these.
return 200, {"nudges": Control.take_pending_nudges(conn, pid)}
if path == "/hosts" and method == "GET":
return 200, {"hosts": Control.list_hosts(conn, pid)}
if path == "/hosts" and method == "POST":
mid = str(body.get("machine_id", "")).strip()
fname = (str(body.get("friendly_name", "")).strip() or None)
if not mid:
return 400, {"error": "machine_id required"}
return 200, {"host": Control.upsert_host(conn, pid, mid, fname)}
except Exception as e:
traceback.print_exc()
return 500, {"error": type(e).__name__, "msg": str(e)[:200]}
return 404, {"error": "unknown user route"}
def do_GET(self):
u = urlparse(self.path)
# --- management console (zero-build static UI) ---
if u.path in ("/admin", "/admin/"):
return self._send_static("index.html")
if u.path.startswith("/admin/api/"):
code, obj = self._admin("GET", u.path[len("/admin/api/"):], parse_qs(u.query), None)
return self._send(code, obj)
if u.path.startswith("/admin/"):
return self._send_static(u.path[len("/admin/"):])
r = self._user("GET", u.path, parse_qs(u.query), None)
if r is not None:
return self._send(*r)
if self.path == "/healthz":
budget = {}
if BUDGET_DAILY_USD is not None or BUDGET_MONTHLY_USD is not None:
try:
with POOL.connection() as conn:
bs = Control.budget_status(conn, BUDGET_DAILY_USD, BUDGET_MONTHLY_USD)
budget = {"daily_ok": bs["daily_ok"], "monthly_ok": bs["monthly_ok"]}
except Exception:
budget = {}
resp = {"ok": True}
if budget:
resp["budget"] = budget
return self._send(200, resp)
if self.path == "/readyz":
try:
with POOL.connection() as conn, conn.cursor() as c:
c.execute("SELECT 1")
return self._send(200, {"ready": True})
except Exception:
return self._send(503, {"ready": False})
if self.path.startswith("/metrics"):
try:
hours = 24
with POOL.connection() as conn:
if Control.authenticate(conn, self._token()) is None:
return self._send(401, {"error": "unauthorized"})
rows = Control.stats(conn, hours)
return self._send(200, {"window_hours": hours, "ops": rows})
except Exception:
traceback.print_exc()
return self._send(500, {"error": "internal error"})
self._send(404, {"error": "not found"})
def do_DELETE(self):
u = urlparse(self.path)
if u.path.startswith("/admin/api/"):
code, obj = self._admin("DELETE", u.path[len("/admin/api/"):], parse_qs(u.query), None)
return self._send(code, obj)
r = self._user("DELETE", u.path, parse_qs(u.query), None)
if r is not None:
return self._send(*r)
return self._send(404, {"error": "not found"})
def do_POST(self):
u = urlparse(self.path)
# --- management console admin API ---
if u.path.startswith("/admin/api/"):
body = self._read_body()
if body is None:
return self._send(400, {"error": "invalid json"})
code, obj = self._admin("POST", u.path[len("/admin/api/"):], parse_qs(u.query), body)
return self._send(code, obj)
# --- user-scoped binding/host registry (no namespace in body) ---
if u.path == "/bindings" or u.path == "/hosts":
body = self._read_body()
if body is None:
return self._send(400, {"error": "invalid json"})
return self._send(*self._user("POST", u.path, parse_qs(u.query), body))
t0 = time.perf_counter()
# --- body limits + json ---
try:
n = int(self.headers.get("Content-Length", 0))
except ValueError:
return self._send(400, {"error": "bad content-length"})
if n > MAX_BODY:
return self._send(413, {"error": "payload too large"})
try:
req = json.loads(self.rfile.read(n) or b"{}")
if not isinstance(req, dict):
raise ValueError
except (ValueError, json.JSONDecodeError):
return self._send(400, {"error": "invalid json"})
ns = str(req.get("namespace", "")).strip()
token = self._token()
if not ns or len(ns) > 200:
return self._send(400, {"error": "namespace required (<=200 chars)"})
if self.path in ("/remember", "/memory/write"):
# dedicated phased path: a pool connection must NEVER be held across the
# embedding or LLM extraction calls — that starves the pool under concurrent
# sessions (field: 30s admin queueing behind in-flight hook writes)
if self.path == "/memory/write": # alias accepts text OR content
req["text"] = str(req.get("text", "") or req.get("content", "")).strip()
return self._remember_phased(req, ns, token, t0,
action=self.path.lstrip("/"))
if self.path in PHASED_OPS:
# endpoints with slow non-DB work (query embedding, cross-encoder rerank,
# per-entity dossier LLM calls, chunk extraction, file parsing): same phased
# discipline — short conn for auth/reads/writes, NO conn during model I/O.
return self._phased(req, ns, token, t0)
try:
with POOL.connection() as conn:
# --- auth ---
principal = Control.authenticate(conn, token)
if principal is None:
return self._send(401, {"error": "unauthorized"})
# --- ACL ---
write = self.path in WRITE_OPS
if not Control.authorize(conn, principal, ns, write=write):
Control.audit(conn, principal, self.path.lstrip("/"), ns, False, {"reason": "forbidden"})
body = {"error": "forbidden for namespace"}
if write:
try:
wns = Control.writable_namespaces(conn, principal)
if wns:
body["writable_namespaces"] = wns
body["hint"] = ("switch namespace with /memnos ns=<namespace> "
"or set MEMNOS_NS env var")
except Exception:
pass
return self._send(403, body)
if self.path == "/feedback": # the true quality signal: was recall helpful?
Control.record_feedback(conn, principal, ns, str(req.get("query", ""))[:1000],
bool(req.get("helpful", False)), (str(req.get("note", "")) or None))
Control.audit(conn, principal, "feedback", ns, True,
latency_ms=int((time.perf_counter() - t0) * 1000), status=200)
return self._send(200, {"ok": True})
store = BrainStore(conn=conn)
usage = _UsageAcc() # captures extraction/consolidation LLM tokens+cost
mem = MemnosMemory(store, EMBED, dim=DIM, llm=LLM, extract_model=EXTRACT_MODEL, on_usage=usage)
cost0 = getattr(getattr(EMBED, "meter", None), "cost", 0.0)
action = self.path.lstrip("/")
try:
# --- episodic decay (pure SQL; segment/recall are PHASED above) ---
if self.path == "/episode/decay":
out = {"updated": store.decay_episodes(mem.schema, ns,
half_life_days=float(req.get("half_life_days", 30)))}
elif self.path == "/episode":
try:
eid = int(req.get("id"))
except (TypeError, ValueError):
return self._send(400, {"error": "id (int) required"})
res = store.get_episode(mem.schema, ns, eid)
if res is None:
return self._send(404, {"error": "episode not found"})
store.touch_episodes(mem.schema, [eid])
out = res
# --- memory CRUD parity (write/search/context are PHASED above) ---
elif self.path == "/memory/delete": # expire a semantic fact by id (system-time)
try:
sid = int(req.get("id"))
except (TypeError, ValueError):
return self._send(400, {"error": "id (int) required"})
existing = store.get_semantic(mem.schema, ns, sid)
if not existing:
return self._send(404, {"error": "fact not found in namespace"})
store.expire(mem.schema, ns, sid)
out = {"deleted": sid, "statement": existing.get("statement")}
# --- graph read (entities/edges already populated; no API existed) ---
elif self.path == "/entity": # get_entity
name = str(req.get("name", "")).strip()
if not name:
return self._send(400, {"error": "name required"})
depth = max(1, min(int(req.get("depth", 1)), 3))
res = store.get_entity(mem.schema, ns, name, depth=depth)
if res is None:
return self._send(404, {"error": "entity not found"})
out = res
elif self.path == "/provenance": # evidence chain for a fact
try:
sid = int(req.get("id"))
except (TypeError, ValueError):
return self._send(400, {"error": "id (int) required"})
res = store.provenance_of(mem.schema, ns, sid)
if res is None:
return self._send(404, {"error": "fact not found in namespace"})
out = res
elif self.path == "/related": # get_related (adjacency)
name = str(req.get("name", "")).strip()
if not name:
return self._send(400, {"error": "name required"})
out = {"name": name, "related": store.get_related(mem.schema, ns, name)}
elif self.path == "/graph": # graph_query (N-hop expand → facts)
ents = req.get("entities") or ([req["name"]] if req.get("name") else [])
ents = [str(e) for e in ents if str(e).strip()][:10]
if not ents:
return self._send(400, {"error": "entities[] or name required"})
hops = max(1, min(int(req.get("hops", 2)), 3))
out = {"facts": store.graph_expand(mem.schema, ns, ents, hops=hops,
limit=int(req.get("limit", 20)))}
# --- communities / contradictions / knowledge health (Batch 2) ---
elif self.path == "/community": # community_search
name = str(req.get("name", "")).strip()
if not name:
return self._send(400, {"error": "name required"})
res = store.community(mem.schema, ns, name)
if res is None:
return self._send(404, {"error": "entity not found"})
out = res
elif self.path == "/contradictions": # check_contradictions
out = {"contradictions": store.contradictions(mem.schema, ns)}
elif self.path == "/knowledge/health": # knowledge_health (namespace)
out = store.health(mem.schema, ns)
# --- copy / move memories between namespaces ---
elif self.path == "/namespace/copy": # namespace = DESTINATION (write-authed above)
src = str(req.get("src", "")).strip()
if not src:
return self._send(400, {"error": "src (source namespace) required"})
if not Control.authorize(conn, principal, src, write=False): # need READ on source
return self._send(403, {"error": f"forbidden: read on source namespace {src}"})
mode = "move" if str(req.get("mode", "copy")).lower() == "move" else "copy"
if src == ns:
return self._send(400, {"error": "src and destination must differ"})
out = store.migrate_namespace(mem.schema, src, ns, mode=mode,
like=(str(req["like"]) if req.get("like") else None))
# --- namespace pub/sub (Batch 3) ---
elif self.path == "/subscribe": # namespace_subscribe
wh = (str(req.get("webhook")).strip() or None) if req.get("webhook") else None
out = Control.subscribe(conn, principal, ns, wh)
elif self.path == "/feed": # namespace_feed (poll since cursor)
try:
sid = int(req.get("subscription_id"))
except (TypeError, ValueError):
return self._send(400, {"error": "subscription_id (int) required"})
res = Control.feed(conn, principal, sid, ns, limit=int(req.get("limit", 50)))
if res is None:
return self._send(404, {"error": "subscription not found for this principal/namespace"})
out = res
elif self.path == "/unsubscribe":
try:
sid = int(req.get("subscription_id"))
except (TypeError, ValueError):
return self._send(400, {"error": "subscription_id (int) required"})
out = {"unsubscribed": Control.unsubscribe(conn, principal, sid)}
# --- agent coordination leases (issue #26) ---
elif self.path == "/lease/acquire":
key = str(req.get("key", "")).strip()
holder = str(req.get("holder_id", "")).strip()
ttl = max(1, int(req.get("ttl_seconds", 1200)))
if not key or not holder:
return self._send(400, {"error": "key and holder_id required"})
out = Control.lease_acquire(conn, ns, key, holder, principal, ttl)
if out.get("granted"):
store.insert_raw_turn(
mem.schema, ns, "", "memnos:lease",
json.dumps({"event": "acquired", "key": key, "holder_id": holder,
"expires_at": out["expires_at"]}),
datetime.now(timezone.utc), None)
elif self.path == "/lease/heartbeat":
key = str(req.get("key", "")).strip()
holder = str(req.get("holder_id", "")).strip()
ttl = max(1, int(req.get("ttl_seconds", 1200)))
if not key or not holder:
return self._send(400, {"error": "key and holder_id required"})
out = Control.lease_heartbeat(conn, ns, key, holder, ttl)
elif self.path == "/lease/release":
key = str(req.get("key", "")).strip()
holder = str(req.get("holder_id", "")).strip()
if not key or not holder:
return self._send(400, {"error": "key and holder_id required"})
out = Control.lease_release(conn, ns, key, holder)
if out.get("released"):
store.insert_raw_turn(
mem.schema, ns, "", "memnos:lease",
json.dumps({"event": "released", "key": key, "holder_id": holder}),
datetime.now(timezone.utc), None)
elif self.path == "/lease/who_holds":
key = str(req.get("key", "")).strip()
if not key:
return self._send(400, {"error": "key required"})
out = Control.lease_who_holds(conn, ns, key)
elif self.path == "/lease/list":
out = {"leases": Control.lease_list(conn, ns)}
# --- corpus ingestion + checks (Batch 4) ---
elif self.path == "/corpus/ingest": # parse doc constraints -> facts
name = str(req.get("name", "")).strip()
text = str(req.get("text", ""))
if not name or not text.strip():
return self._send(400, {"error": "name and text required"})
pname = (Control.principal_info(conn, principal) or {}).get("name")
ids = store.ingest_constraints(mem.schema, ns, name, text, author=pname)
Control.corpus_record(conn, ns, name, str(req.get("kind", "doc")),
(str(req.get("git_sha")) or None) if req.get("git_sha") else None,
len(ids))
out = {"source": name, "constraints": len(ids), "ids": ids}
elif self.path == "/corpus/check": # constraints relevant to a snippet
snippet = str(req.get("snippet", "") or req.get("code", ""))
if not snippet.strip():
return self._send(400, {"error": "snippet/code required"})
out = {"constraints": store.corpus_check(mem.schema, ns, snippet)}
elif self.path == "/entity/dossier": # get stored dossier for an entity
entity_name = str(req.get("entity", "")).strip()
if not entity_name:
return self._send(400, {"error": "entity name required"})
res = store.get_entity_dossier(mem.schema, ns, entity_name)
if res is None:
return self._send(404, {"error": "no dossier found for this entity"})
out = {
"entity": res["name"],
"dossier": res["dossier_text"],
"generated_at": res["generated_at"].isoformat() if res.get("generated_at") else None,
"model_used": res.get("model_used"),
}
elif self.path == "/corpus/list":
out = {"sources": Control.corpus_list(conn, ns)}
else:
return self._send(404, {"error": "not found"})
except Exception as op_err:
# capture WHAT broke (type+message) so it's actionable via `memnos_admin.py errors`
Control.audit(conn, principal, action, ns, False,
{"error": type(op_err).__name__, "msg": str(op_err)[:300]},
latency_ms=int((time.perf_counter() - t0) * 1000), status=500)
traceback.print_exc()
return self._send(500, {"error": "internal error"})
cost1 = getattr(getattr(EMBED, "meter", None), "cost", 0.0)
# full cost = embedding delta + extraction/consolidation LLM cost; tokens
# = extraction tokens (the previously-untracked spend)
Control.record_usage(conn, principal, ns, action, mem.extract_model,
usage.tin, usage.tout, round((cost1 - cost0) + usage.cost, 6))
rcount = len(out.get("memories", [])) if isinstance(out, dict) and "memories" in out else None
Control.audit(conn, principal, action, ns, True,
latency_ms=int((time.perf_counter() - t0) * 1000), result_count=rcount, status=200)
if self.path in WRITE_OPS:
_DELIVER_EVENT.set() # wake the webhook pusher (near-immediate push)
return self._send(200, out)
except (PoolTimeout, OperationalError) as e:
# fail FAST and clearly when the database is down — never leave clients hanging
print(f"[memnos] DB unreachable ({type(e).__name__}): {e}", flush=True)
return self._send(503, {"error": "database unreachable — is Postgres running?"})
except Exception:
traceback.print_exc() # pool/connection-level failure
return self._send(500, {"error": "internal error"})
def log_message(self, *a):
pass
def _auth_short(self, ns, token, *, write, action):
"""Short-lived auth+ACL phase on its OWN pool connection (released before any
slow work). Returns (principal, pname, None) or (None, None, (code, obj)) to
send. `pname` = the authenticated principal's NAME — the only source for
author attribution (never the request body)."""
with POOL.connection() as conn:
principal = Control.authenticate(conn, token)
if principal is None:
return None, None, (401, {"error": "unauthorized"})
if not Control.authorize(conn, principal, ns, write=write):
Control.audit(conn, principal, action, ns, False, {"reason": "forbidden"})
body = {"error": "forbidden for namespace"}
if write:
try:
wns = Control.writable_namespaces(conn, principal)
if wns:
body["writable_namespaces"] = wns
body["hint"] = ("switch namespace with /memnos ns=<namespace> "
"or set MEMNOS_NS env var")
except Exception:
pass
return None, None, (403, body)
info = Control.principal_info(conn, principal)
return principal, (info or {}).get("name"), None
def _remember_phased(self, req, ns, token, t0, action="remember"):
"""/remember (and its /memory/write alias) in phases — the 'LLM at ingest only,
NEVER on the request path while holding resources' rule from the architecture:
P0 (conn, ~ms): auth + ACL
P1a (NO conn): redact + EMBED the turn — network I/O in 1536 mode
P1b (conn, ~ms): store the verbatim raw turn
P2 (NO conn): LLM fact extraction — pure model I/O
P3 (conn, ~ms): supersession + fact writes + usage + audit
Default stays SYNCHRONOUS (same response contract). {"async": true} defers
P2+P3 to the background ingest workers and returns immediately after P1 —
used by the Claude Code Stop hook, which never reads the fact count."""
text = str(req.get("text", "")).strip()
if not text or len(text) > 20000:
return self._send(400, {"error": "text required (<=20000 chars)"})
ok, mtype = _memory_type(req) # typed memory (decision|incident|...)