engraphis 0.1.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.
- engraphis/__init__.py +8 -0
- engraphis/analytics.py +243 -0
- engraphis/app.py +228 -0
- engraphis/backends/__init__.py +16 -0
- engraphis/backends/codegraph.py +345 -0
- engraphis/backends/embedder_api.py +188 -0
- engraphis/backends/embedder_deterministic.py +49 -0
- engraphis/backends/embedder_st.py +52 -0
- engraphis/backends/extractor.py +148 -0
- engraphis/backends/graph_extractor.py +201 -0
- engraphis/backends/reranker.py +46 -0
- engraphis/backends/vector_numpy.py +52 -0
- engraphis/backends/vector_sqlitevec.py +88 -0
- engraphis/config.py +137 -0
- engraphis/core/__init__.py +43 -0
- engraphis/core/consolidate.py +349 -0
- engraphis/core/engine.py +517 -0
- engraphis/core/graphrank.py +73 -0
- engraphis/core/grounded.py +207 -0
- engraphis/core/ids.py +54 -0
- engraphis/core/interfaces.py +186 -0
- engraphis/core/recall.py +232 -0
- engraphis/core/resolve.py +99 -0
- engraphis/core/schema.py +191 -0
- engraphis/core/scoring.py +108 -0
- engraphis/core/store.py +598 -0
- engraphis/core/textutil.py +51 -0
- engraphis/dashboard_app.py +115 -0
- engraphis/engines/__init__.py +1 -0
- engraphis/engines/embedder.py +104 -0
- engraphis/engines/ingest.py +216 -0
- engraphis/engines/intelligence.py +176 -0
- engraphis/engines/recall.py +155 -0
- engraphis/engines/reweight.py +90 -0
- engraphis/engines/thoughts.py +67 -0
- engraphis/graphdata.py +65 -0
- engraphis/inspector/__init__.py +9 -0
- engraphis/inspector/app.py +436 -0
- engraphis/inspector/auth.py +252 -0
- engraphis/inspector/index.html +1255 -0
- engraphis/licensing.py +432 -0
- engraphis/llm/__init__.py +1 -0
- engraphis/llm/client.py +237 -0
- engraphis/logging_setup.py +65 -0
- engraphis/mcp_server.py +703 -0
- engraphis/models.py +180 -0
- engraphis/routes/__init__.py +1 -0
- engraphis/routes/memory.py +828 -0
- engraphis/routes/v2_api.py +444 -0
- engraphis/routes/v2_team.py +152 -0
- engraphis/routes/vault.py +615 -0
- engraphis/service.py +881 -0
- engraphis/stores/__init__.py +194 -0
- engraphis/stores/graph.py +138 -0
- engraphis/stores/ledger.py +139 -0
- engraphis/stores/vaults.py +117 -0
- engraphis/stores/vectors.py +270 -0
- engraphis-0.1.0.dist-info/METADATA +333 -0
- engraphis-0.1.0.dist-info/RECORD +78 -0
- engraphis-0.1.0.dist-info/WHEEL +5 -0
- engraphis-0.1.0.dist-info/entry_points.txt +9 -0
- engraphis-0.1.0.dist-info/licenses/LICENSE +201 -0
- engraphis-0.1.0.dist-info/licenses/NOTICE +14 -0
- engraphis-0.1.0.dist-info/top_level.txt +2 -0
- scripts/__init__.py +1 -0
- scripts/cli.py +181 -0
- scripts/consolidate.py +208 -0
- scripts/init.py +164 -0
- scripts/inspector.py +78 -0
- scripts/install_shortcuts.py +220 -0
- scripts/license_admin.py +121 -0
- scripts/migrate_to_v2.py +201 -0
- scripts/sdk_compat.py +45 -0
- scripts/seed_from_obsidian.py +98 -0
- scripts/start_dashboard.py +79 -0
- scripts/start_server.py +29 -0
- scripts/test_routes.py +198 -0
- scripts/update.py +192 -0
engraphis/__init__.py
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""Engraphis — self-hosted AI memory system."""
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import PackageNotFoundError, version as _dist_version
|
|
4
|
+
|
|
5
|
+
try:
|
|
6
|
+
__version__ = _dist_version("engraphis")
|
|
7
|
+
except PackageNotFoundError: # source tree without an installed distribution
|
|
8
|
+
__version__ = "0.1.0"
|
engraphis/analytics.py
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
"""Workspace analytics — the Pro dashboard's data layer.
|
|
2
|
+
|
|
3
|
+
House style (AGENTS.md §3): the math is a pure, tested function over plain rows
|
|
4
|
+
(:func:`analytics_from_rows`); SQL lives only in the thin :func:`compute_analytics`
|
|
5
|
+
wrapper. stdlib + core only — no new dependency, no LLM, no network.
|
|
6
|
+
|
|
7
|
+
Gating note: the *license check does not live here*. This module computes for whoever
|
|
8
|
+
calls it; the Inspector's HTTP layer enforces ``require_feature("analytics")``. Keeping
|
|
9
|
+
the gate at the edge means the core stays honestly open (Apache-2.0) and the paid
|
|
10
|
+
surface is exactly one, auditable place.
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import html as _html
|
|
15
|
+
import math
|
|
16
|
+
import time
|
|
17
|
+
from typing import Any, Iterable, Optional
|
|
18
|
+
|
|
19
|
+
from engraphis.core.scoring import retention
|
|
20
|
+
|
|
21
|
+
#: Retention level treated as "effectively forgotten" — matches the consolidation
|
|
22
|
+
#: sweep's ``archive_below`` default so the forecast predicts what the next sweep does.
|
|
23
|
+
FORGET_THRESHOLD = 0.05
|
|
24
|
+
|
|
25
|
+
_WEEK = 7 * 86400.0
|
|
26
|
+
_GROWTH_WEEKS = 12
|
|
27
|
+
_HIST_BUCKETS = 5
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _days_until_forgotten(stability: float, last_access: Optional[float],
|
|
31
|
+
now: float) -> float:
|
|
32
|
+
"""Days until Ebbinghaus retention exp(-Δt/S) crosses :data:`FORGET_THRESHOLD`.
|
|
33
|
+
|
|
34
|
+
Solving exp(-dt/S) = T gives dt = S·ln(1/T); subtract days already elapsed.
|
|
35
|
+
Returns +inf for pinned-style stability outliers only via natural math (S huge).
|
|
36
|
+
"""
|
|
37
|
+
s = max(stability or 1.0, 1e-3)
|
|
38
|
+
horizon_days = s * math.log(1.0 / FORGET_THRESHOLD)
|
|
39
|
+
elapsed_days = max((now - (last_access if last_access is not None else now)) / 86400.0, 0.0)
|
|
40
|
+
return horizon_days - elapsed_days
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def analytics_from_rows(mem_rows: Iterable[dict], audit_counts: dict,
|
|
44
|
+
entity_rows: Iterable[dict], *, now: Optional[float] = None) -> dict:
|
|
45
|
+
"""Pure aggregation. ``mem_rows`` need: mtype, stability, last_access, ingested_at,
|
|
46
|
+
importance, pinned, valid_to, expired_at. ``audit_counts`` maps action→count.
|
|
47
|
+
``entity_rows`` need: name, etype, n (mention/edge count)."""
|
|
48
|
+
now = time.time() if now is None else now
|
|
49
|
+
rows = list(mem_rows)
|
|
50
|
+
live = [r for r in rows
|
|
51
|
+
if r.get("expired_at") is None
|
|
52
|
+
and (r.get("valid_to") is None or now < r["valid_to"])]
|
|
53
|
+
|
|
54
|
+
# ── growth: weekly ingest counts, oldest→newest, exactly _GROWTH_WEEKS buckets ──
|
|
55
|
+
growth = [0] * _GROWTH_WEEKS
|
|
56
|
+
for r in rows:
|
|
57
|
+
ts = r.get("ingested_at")
|
|
58
|
+
if ts is None:
|
|
59
|
+
continue
|
|
60
|
+
idx = _GROWTH_WEEKS - 1 - int((now - ts) // _WEEK)
|
|
61
|
+
if 0 <= idx < _GROWTH_WEEKS:
|
|
62
|
+
growth[idx] += 1
|
|
63
|
+
|
|
64
|
+
# ── retention histogram over live memories ──
|
|
65
|
+
hist = [0] * _HIST_BUCKETS
|
|
66
|
+
ret_sum = 0.0
|
|
67
|
+
for r in live:
|
|
68
|
+
ret = retention(r.get("stability") or 1.0, r.get("last_access"), now)
|
|
69
|
+
ret_sum += ret
|
|
70
|
+
hist[min(int(ret * _HIST_BUCKETS), _HIST_BUCKETS - 1)] += 1
|
|
71
|
+
|
|
72
|
+
# ── decay forecast (pinned memories are exempt from decay archival) ──
|
|
73
|
+
at_risk_7 = at_risk_30 = 0
|
|
74
|
+
for r in live:
|
|
75
|
+
if r.get("pinned"):
|
|
76
|
+
continue
|
|
77
|
+
days = _days_until_forgotten(r.get("stability") or 1.0, r.get("last_access"), now)
|
|
78
|
+
if days <= 7:
|
|
79
|
+
at_risk_7 += 1
|
|
80
|
+
if days <= 30:
|
|
81
|
+
at_risk_30 += 1
|
|
82
|
+
|
|
83
|
+
by_type: dict = {}
|
|
84
|
+
for r in live:
|
|
85
|
+
by_type[r.get("mtype") or "?"] = by_type.get(r.get("mtype") or "?", 0) + 1
|
|
86
|
+
|
|
87
|
+
total = len(rows)
|
|
88
|
+
n_live = len(live)
|
|
89
|
+
return {
|
|
90
|
+
"generated_at": now,
|
|
91
|
+
"totals": {
|
|
92
|
+
"live": n_live,
|
|
93
|
+
"all_rows": total,
|
|
94
|
+
"superseded": total - n_live,
|
|
95
|
+
"pinned": sum(1 for r in live if r.get("pinned")),
|
|
96
|
+
"avg_retention": round(ret_sum / n_live, 4) if n_live else 0.0,
|
|
97
|
+
},
|
|
98
|
+
"growth_weekly": growth,
|
|
99
|
+
"retention_histogram": {
|
|
100
|
+
"buckets": ["0–20%", "20–40%", "40–60%", "60–80%", "80–100%"],
|
|
101
|
+
"counts": hist,
|
|
102
|
+
},
|
|
103
|
+
"decay_forecast": {
|
|
104
|
+
"threshold": FORGET_THRESHOLD,
|
|
105
|
+
"at_risk_7d": at_risk_7,
|
|
106
|
+
"at_risk_30d": at_risk_30,
|
|
107
|
+
},
|
|
108
|
+
"by_type": by_type,
|
|
109
|
+
"resolver_mix": {k: int(v) for k, v in sorted(audit_counts.items())},
|
|
110
|
+
"top_entities": [
|
|
111
|
+
{"name": e["name"], "etype": e.get("etype") or "", "n": int(e.get("n") or 0)}
|
|
112
|
+
for e in entity_rows
|
|
113
|
+
],
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
_REPORT_CSS = """
|
|
118
|
+
body{font:14px/1.5 system-ui,-apple-system,'Segoe UI',Roboto,sans-serif;color:#1f2328;
|
|
119
|
+
background:#fff;margin:0;padding:32px;max-width:880px}
|
|
120
|
+
h1{font-size:22px;margin:0 0 2px}
|
|
121
|
+
h2{font-size:15px;margin:26px 0 8px;border-bottom:1px solid #d0d7de;padding-bottom:4px}
|
|
122
|
+
.sub{color:#59636e;font-size:13px;margin:0 0 18px}
|
|
123
|
+
table{border-collapse:collapse;width:100%;font-size:13px;margin:6px 0}
|
|
124
|
+
th,td{text-align:left;padding:6px 10px;border-bottom:1px solid #e4e8ec;vertical-align:top}
|
|
125
|
+
th{color:#59636e;font-weight:600;background:#f6f8fa}
|
|
126
|
+
td.num{text-align:right;font-variant-numeric:tabular-nums}
|
|
127
|
+
.bar{background:#dbe6f4;height:12px;border-radius:3px;display:inline-block;
|
|
128
|
+
vertical-align:middle;min-width:2px}
|
|
129
|
+
.footer{color:#59636e;font-size:12px;margin-top:28px;border-top:1px solid #d0d7de;
|
|
130
|
+
padding-top:8px}
|
|
131
|
+
""".strip()
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _esc(value: Any) -> str:
|
|
135
|
+
return _html.escape(str(value), quote=True)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _table(headers: list, rows: list) -> str:
|
|
139
|
+
"""rows are lists of (text, is_numeric) cells, already escaped by the caller."""
|
|
140
|
+
head = "".join("<th>%s</th>" % h for h in headers)
|
|
141
|
+
body = "".join(
|
|
142
|
+
"<tr>%s</tr>" % "".join(
|
|
143
|
+
'<td class="num">%s</td>' % c[0] if c[1] else "<td>%s</td>" % c[0]
|
|
144
|
+
for c in row)
|
|
145
|
+
for row in rows)
|
|
146
|
+
return "<table><thead><tr>%s</tr></thead><tbody>%s</tbody></table>" % (head, body)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _bar_cell(n: int, peak: int) -> str:
|
|
150
|
+
width = int(round(n / peak * 160)) if peak else 0
|
|
151
|
+
return '<span class="bar" style="width:%dpx"></span> %d' % (width, n)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def render_analytics_html(data: dict, *, workspace: str, version: str = "") -> str:
|
|
155
|
+
"""A self-contained HTML report over the :func:`analytics_from_rows` payload.
|
|
156
|
+
|
|
157
|
+
Everything inline (CSS included), zero external requests — the file can be
|
|
158
|
+
archived, emailed, or opened offline years later and still render. All dynamic
|
|
159
|
+
text is escaped; nothing from the store reaches the page unescaped."""
|
|
160
|
+
t = data.get("totals", {})
|
|
161
|
+
f = data.get("decay_forecast", {})
|
|
162
|
+
generated = time.strftime("%Y-%m-%d %H:%M:%S UTC",
|
|
163
|
+
time.gmtime(data.get("generated_at", time.time())))
|
|
164
|
+
|
|
165
|
+
totals_rows = [
|
|
166
|
+
[("Live memories", False), (_esc(t.get("live", 0)), True)],
|
|
167
|
+
[("All rows (incl. superseded history)", False), (_esc(t.get("all_rows", 0)), True)],
|
|
168
|
+
[("Superseded (kept in history)", False), (_esc(t.get("superseded", 0)), True)],
|
|
169
|
+
[("Pinned (decay-exempt)", False), (_esc(t.get("pinned", 0)), True)],
|
|
170
|
+
[("Average retention", False),
|
|
171
|
+
("%.0f%%" % (float(t.get("avg_retention", 0.0)) * 100), True)],
|
|
172
|
+
[("Fading within 7 days", False), (_esc(f.get("at_risk_7d", 0)), True)],
|
|
173
|
+
[("Fading within 30 days", False), (_esc(f.get("at_risk_30d", 0)), True)],
|
|
174
|
+
]
|
|
175
|
+
|
|
176
|
+
weeks = list(data.get("growth_weekly", []))
|
|
177
|
+
peak = max(weeks) if weeks else 0
|
|
178
|
+
growth_rows = [
|
|
179
|
+
[("now" if back == 0 else "%d week%s ago" % (back, "" if back == 1 else "s"), False),
|
|
180
|
+
(_bar_cell(n, peak), True)]
|
|
181
|
+
for back, n in ((len(weeks) - 1 - i, n) for i, n in enumerate(weeks))]
|
|
182
|
+
|
|
183
|
+
hist = data.get("retention_histogram", {})
|
|
184
|
+
counts = list(hist.get("counts", []))
|
|
185
|
+
hpeak = max(counts) if counts else 0
|
|
186
|
+
hist_rows = [[(_esc(b), False), (_bar_cell(n, hpeak), True)]
|
|
187
|
+
for b, n in zip(hist.get("buckets", []), counts)]
|
|
188
|
+
|
|
189
|
+
type_rows = [[(_esc(k), False), (_esc(v), True)]
|
|
190
|
+
for k, v in sorted(data.get("by_type", {}).items())]
|
|
191
|
+
mix_rows = [[(_esc(k), False), (_esc(v), True)]
|
|
192
|
+
for k, v in sorted(data.get("resolver_mix", {}).items())]
|
|
193
|
+
entity_rows = [
|
|
194
|
+
[(_esc(e.get("name", "")), False), (_esc(e.get("etype", "")), False),
|
|
195
|
+
(_esc(e.get("n", 0)), True)]
|
|
196
|
+
for e in data.get("top_entities", [])]
|
|
197
|
+
|
|
198
|
+
def section(title: str, body: str, empty_note: str = "nothing recorded yet") -> str:
|
|
199
|
+
return "<h2>%s</h2>%s" % (
|
|
200
|
+
_esc(title), body or "<p class=\"sub\">%s</p>" % _esc(empty_note))
|
|
201
|
+
|
|
202
|
+
parts = [
|
|
203
|
+
"<!doctype html><html lang=\"en\"><head><meta charset=\"utf-8\">",
|
|
204
|
+
"<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">",
|
|
205
|
+
"<title>Engraphis analytics — %s</title>" % _esc(workspace),
|
|
206
|
+
"<style>%s</style></head><body>" % _REPORT_CSS,
|
|
207
|
+
"<h1>Engraphis analytics report</h1>",
|
|
208
|
+
"<p class=\"sub\">workspace <strong>%s</strong> · generated %s%s</p>" % (
|
|
209
|
+
_esc(workspace), _esc(generated),
|
|
210
|
+
" · engraphis v%s" % _esc(version) if version else ""),
|
|
211
|
+
section("Totals & decay forecast", _table(["Metric", "Value"], totals_rows)),
|
|
212
|
+
section("Memories written per week", _table(["Week", "Written"], growth_rows)),
|
|
213
|
+
section("Retention distribution (live memories)",
|
|
214
|
+
_table(["Retention", "Memories"], hist_rows)),
|
|
215
|
+
section("Live memories by type", _table(["Type", "Count"], type_rows)
|
|
216
|
+
if type_rows else ""),
|
|
217
|
+
section("Write-path resolver activity", _table(["Action", "Count"], mix_rows)
|
|
218
|
+
if mix_rows else "", "no resolver events yet"),
|
|
219
|
+
section("Most connected entities",
|
|
220
|
+
_table(["Entity", "Type", "Connections"], entity_rows)
|
|
221
|
+
if entity_rows else "", "no entities yet — they appear as the graph grows"),
|
|
222
|
+
"<p class=\"footer\">Self-contained report — no external assets, no tracking. "
|
|
223
|
+
"Engraphis Pro analytics%s.</p>" % (" · v%s" % _esc(version) if version else ""),
|
|
224
|
+
"</body></html>",
|
|
225
|
+
]
|
|
226
|
+
return "".join(parts)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def compute_analytics(store: Any, workspace_id: str, *, now: Optional[float] = None) -> dict:
|
|
230
|
+
"""SQL wrapper: fetch rows for one workspace, delegate to the pure function."""
|
|
231
|
+
conn = store.conn
|
|
232
|
+
mem_rows = [dict(r) for r in conn.execute(
|
|
233
|
+
"SELECT mtype, stability, last_access, ingested_at, importance, pinned, "
|
|
234
|
+
"valid_to, expired_at FROM memories WHERE workspace_id=?", (workspace_id,))]
|
|
235
|
+
audit_counts = {r["action"]: r["n"] for r in conn.execute(
|
|
236
|
+
"SELECT a.action, COUNT(*) AS n FROM audit a JOIN memories m ON m.id = a.target "
|
|
237
|
+
"WHERE m.workspace_id=? GROUP BY a.action", (workspace_id,))}
|
|
238
|
+
entity_rows = [dict(r) for r in conn.execute(
|
|
239
|
+
"SELECT e.name, e.etype, COUNT(ed.id) AS n FROM entities e "
|
|
240
|
+
"LEFT JOIN edges ed ON (ed.src = e.id OR ed.dst = e.id) "
|
|
241
|
+
"WHERE e.workspace_id=? GROUP BY e.id, e.name, e.etype "
|
|
242
|
+
"ORDER BY n DESC, e.created_at DESC LIMIT 8", (workspace_id,))]
|
|
243
|
+
return analytics_from_rows(mem_rows, audit_counts, entity_rows, now=now)
|
engraphis/app.py
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
"""FastAPI app assembly — mounts all routes, serves dashboard, initializes DB, starts background loop."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import asyncio
|
|
5
|
+
import hmac
|
|
6
|
+
import logging
|
|
7
|
+
import time
|
|
8
|
+
import uuid
|
|
9
|
+
from collections import defaultdict, deque
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from fastapi import FastAPI, Request
|
|
13
|
+
from fastapi.middleware.cors import CORSMiddleware
|
|
14
|
+
from fastapi.responses import HTMLResponse, JSONResponse
|
|
15
|
+
from fastapi.staticfiles import StaticFiles
|
|
16
|
+
|
|
17
|
+
from engraphis import __version__
|
|
18
|
+
from engraphis.config import settings
|
|
19
|
+
from engraphis.engines import reweight, thoughts as thoughts_engine
|
|
20
|
+
from engraphis.logging_setup import configure_logging
|
|
21
|
+
from engraphis.routes.memory import router as memory_router
|
|
22
|
+
from engraphis.routes.vault import router as vault_router
|
|
23
|
+
from engraphis.stores import get_conn, init_db
|
|
24
|
+
|
|
25
|
+
logger = logging.getLogger("engraphis")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _const_time_eq(a: str, b: str) -> bool:
|
|
29
|
+
"""Constant-time string comparison (avoids token-timing side channels)."""
|
|
30
|
+
return hmac.compare_digest(a.encode("utf-8"), b.encode("utf-8"))
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
_background_task: asyncio.Task | None = None
|
|
34
|
+
_STATIC_DIR = Path(__file__).resolve().parent / "static"
|
|
35
|
+
# Readiness cache: only a *successful* embedder init is cached, so a transient
|
|
36
|
+
# failure is re-checked on the next probe instead of wedging the pod NotReady.
|
|
37
|
+
_embedder_ok: bool = False
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _embedder_ready() -> bool:
|
|
41
|
+
global _embedder_ok
|
|
42
|
+
try:
|
|
43
|
+
from engraphis.backends.embedder_st import get_embedder
|
|
44
|
+
emb = get_embedder(settings.embed_model or None, settings.embed_dim or 256)
|
|
45
|
+
_embedder_ok = emb is not None and int(emb.dim) > 0
|
|
46
|
+
except Exception as e: # pragma: no cover - defensive; get_embedder falls back itself
|
|
47
|
+
logger.warning("Readiness: embedder init failed: %s", e)
|
|
48
|
+
_embedder_ok = False
|
|
49
|
+
return _embedder_ok
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def create_app() -> FastAPI:
|
|
53
|
+
"""Build and configure the FastAPI application."""
|
|
54
|
+
configure_logging()
|
|
55
|
+
|
|
56
|
+
app = FastAPI(
|
|
57
|
+
title="Engraphis",
|
|
58
|
+
description="Self-hosted AI memory engine for agents — Ebbinghaus decay, "
|
|
59
|
+
"interaction-aware recall, bi-temporal facts, and background "
|
|
60
|
+
"consolidation. Local-first; you bring the LLM.",
|
|
61
|
+
version=__version__,
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
# Local-first CORS: loopback by default, override with ENGRAPHIS_CORS_ORIGINS.
|
|
65
|
+
# Credentials are only allowed when the allow-list is explicit (never with "*").
|
|
66
|
+
_wildcard = "*" in settings.cors_origins
|
|
67
|
+
app.add_middleware(
|
|
68
|
+
CORSMiddleware,
|
|
69
|
+
allow_origins=settings.cors_origins,
|
|
70
|
+
allow_credentials=not _wildcard,
|
|
71
|
+
allow_methods=["*"],
|
|
72
|
+
allow_headers=["*"],
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
# Optional bearer-token auth. Active only when ENGRAPHIS_API_TOKEN is set.
|
|
76
|
+
# Health-type probes (liveness + readiness) stay unauthenticated by convention.
|
|
77
|
+
_PUBLIC_PREFIXES = ("/memory/health", "/api/health", "/api/ready",
|
|
78
|
+
"/docs", "/openapi.json", "/redoc", "/static")
|
|
79
|
+
|
|
80
|
+
@app.middleware("http")
|
|
81
|
+
async def _require_token(request: Request, call_next):
|
|
82
|
+
token = settings.api_token
|
|
83
|
+
if token and request.method != "OPTIONS" and request.url.path != "/" \
|
|
84
|
+
and not request.url.path.startswith(_PUBLIC_PREFIXES):
|
|
85
|
+
header = request.headers.get("authorization", "")
|
|
86
|
+
presented = header[7:].strip() if header.lower().startswith("bearer ") else ""
|
|
87
|
+
if not _const_time_eq(presented, token):
|
|
88
|
+
return JSONResponse({"error": "unauthorized"}, status_code=401)
|
|
89
|
+
return await call_next(request)
|
|
90
|
+
|
|
91
|
+
# Optional in-process rate limiting (per-client-IP sliding window). Disabled unless
|
|
92
|
+
# ENGRAPHIS_RATE_LIMIT > 0. In-memory/per-process — fine for one self-hosted instance;
|
|
93
|
+
# front it with a reverse proxy for multi-process or distributed limits.
|
|
94
|
+
if settings.rate_limit > 0:
|
|
95
|
+
_hits: dict[str, deque] = defaultdict(deque)
|
|
96
|
+
_PRUNE_EVERY = 60 # seconds between cleanup sweeps
|
|
97
|
+
_last_prune = time.monotonic()
|
|
98
|
+
|
|
99
|
+
@app.middleware("http")
|
|
100
|
+
async def _rate_limit(request: Request, call_next):
|
|
101
|
+
nonlocal _last_prune
|
|
102
|
+
if request.method == "OPTIONS" or request.url.path.startswith(_PUBLIC_PREFIXES):
|
|
103
|
+
return await call_next(request)
|
|
104
|
+
client = request.client.host if request.client else "unknown"
|
|
105
|
+
now = time.monotonic()
|
|
106
|
+
# Periodically prune stale IP entries to prevent unbounded growth.
|
|
107
|
+
if now - _last_prune > _PRUNE_EVERY:
|
|
108
|
+
cutoff_all = now - settings.rate_window
|
|
109
|
+
stale = [k for k, dq in _hits.items() if not dq or dq[-1] < cutoff_all]
|
|
110
|
+
for k in stale:
|
|
111
|
+
del _hits[k]
|
|
112
|
+
_last_prune = now
|
|
113
|
+
dq = _hits[client]
|
|
114
|
+
cutoff = now - settings.rate_window
|
|
115
|
+
while dq and dq[0] <= cutoff:
|
|
116
|
+
dq.popleft()
|
|
117
|
+
if len(dq) >= settings.rate_limit:
|
|
118
|
+
retry = int(dq[0] + settings.rate_window - now) + 1
|
|
119
|
+
return JSONResponse({"error": "rate limit exceeded"}, status_code=429,
|
|
120
|
+
headers={"Retry-After": str(retry)})
|
|
121
|
+
dq.append(now)
|
|
122
|
+
return await call_next(request)
|
|
123
|
+
|
|
124
|
+
# Request-ID + access log. Defined last so it is the *outermost* middleware and
|
|
125
|
+
# also covers requests short-circuited by auth/rate-limit above. An incoming
|
|
126
|
+
# X-Request-ID is propagated (so a fronting proxy's id survives); otherwise one
|
|
127
|
+
# is assigned. Echoed on the response for client-side correlation.
|
|
128
|
+
@app.middleware("http")
|
|
129
|
+
async def _request_log(request: Request, call_next):
|
|
130
|
+
request_id = request.headers.get("x-request-id", "").strip() or uuid.uuid4().hex
|
|
131
|
+
start = time.perf_counter()
|
|
132
|
+
response = await call_next(request)
|
|
133
|
+
duration_ms = round((time.perf_counter() - start) * 1000, 1)
|
|
134
|
+
response.headers["X-Request-ID"] = request_id
|
|
135
|
+
logger.info(
|
|
136
|
+
"%s %s -> %d (%.1fms)",
|
|
137
|
+
request.method, request.url.path, response.status_code, duration_ms,
|
|
138
|
+
extra={"request_id": request_id, "method": request.method,
|
|
139
|
+
"path": request.url.path, "status": response.status_code,
|
|
140
|
+
"duration_ms": duration_ms},
|
|
141
|
+
)
|
|
142
|
+
return response
|
|
143
|
+
|
|
144
|
+
# Database initialization deferred to startup event so CLI can set ENGRAPHIS_DB_PATH first
|
|
145
|
+
@app.on_event("startup")
|
|
146
|
+
async def _startup_db():
|
|
147
|
+
init_db()
|
|
148
|
+
|
|
149
|
+
app.include_router(memory_router)
|
|
150
|
+
app.include_router(vault_router)
|
|
151
|
+
|
|
152
|
+
# ── probes (unauthenticated; see _PUBLIC_PREFIXES) ──────────────────────────
|
|
153
|
+
@app.get("/api/health")
|
|
154
|
+
async def api_health():
|
|
155
|
+
"""Liveness: the process is up and serving. No dependency checks."""
|
|
156
|
+
return {"status": "ok", "timestamp": time.time(), "service": "engraphis"}
|
|
157
|
+
|
|
158
|
+
@app.get("/api/ready")
|
|
159
|
+
async def api_ready():
|
|
160
|
+
"""Readiness: DB answers a trivial SELECT and the embedder backend
|
|
161
|
+
initializes. 503 until both hold, so orchestrators hold traffic."""
|
|
162
|
+
checks = {"db": False, "embedder": False}
|
|
163
|
+
try:
|
|
164
|
+
get_conn().execute("SELECT 1").fetchone()
|
|
165
|
+
checks["db"] = True
|
|
166
|
+
except Exception as e:
|
|
167
|
+
logger.warning("Readiness: db check failed: %s", e)
|
|
168
|
+
checks["embedder"] = _embedder_ready()
|
|
169
|
+
ready = all(checks.values())
|
|
170
|
+
return JSONResponse({"ready": ready, "checks": checks, "version": __version__},
|
|
171
|
+
status_code=200 if ready else 503)
|
|
172
|
+
|
|
173
|
+
@app.on_event("startup")
|
|
174
|
+
async def _startup() -> None:
|
|
175
|
+
global _background_task
|
|
176
|
+
if settings.loop_interval > 0:
|
|
177
|
+
_background_task = asyncio.create_task(_consciousness_loop())
|
|
178
|
+
logger.info("Background consciousness loop started (interval=%ds)", settings.loop_interval)
|
|
179
|
+
else:
|
|
180
|
+
logger.info("Background loop disabled (ENGRAPHIS_LOOP_INTERVAL=0)")
|
|
181
|
+
|
|
182
|
+
@app.on_event("shutdown")
|
|
183
|
+
async def _shutdown() -> None:
|
|
184
|
+
global _background_task
|
|
185
|
+
if _background_task:
|
|
186
|
+
_background_task.cancel()
|
|
187
|
+
try:
|
|
188
|
+
await _background_task
|
|
189
|
+
except asyncio.CancelledError:
|
|
190
|
+
pass
|
|
191
|
+
|
|
192
|
+
@app.get("/", response_class=HTMLResponse)
|
|
193
|
+
async def dashboard():
|
|
194
|
+
"""Serve the visual dashboard."""
|
|
195
|
+
index_path = _STATIC_DIR / "index.html"
|
|
196
|
+
if index_path.exists():
|
|
197
|
+
return HTMLResponse(index_path.read_text(encoding="utf-8"))
|
|
198
|
+
return HTMLResponse("<h1>Dashboard not found</h1><p>Static files missing at: "
|
|
199
|
+
f"{_STATIC_DIR}</p>", status_code=404)
|
|
200
|
+
|
|
201
|
+
if _STATIC_DIR.exists():
|
|
202
|
+
app.mount("/static", StaticFiles(directory=str(_STATIC_DIR)), name="static")
|
|
203
|
+
|
|
204
|
+
return app
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
async def _consciousness_loop() -> None:
|
|
208
|
+
"""Phase 2 + Phase 4 background cycle: decay → thought synthesis → reweight."""
|
|
209
|
+
while True:
|
|
210
|
+
try:
|
|
211
|
+
await asyncio.sleep(settings.loop_interval)
|
|
212
|
+
touched = reweight.decay_pass(namespace=None)
|
|
213
|
+
if touched:
|
|
214
|
+
logger.info("Decay pass: %d memories reweighted", touched)
|
|
215
|
+
result = thoughts_engine.synthesize_thoughts(
|
|
216
|
+
namespace=None,
|
|
217
|
+
max_chunks=settings.loop_top_k,
|
|
218
|
+
persist=True,
|
|
219
|
+
)
|
|
220
|
+
if result.get("persisted"):
|
|
221
|
+
logger.info("Thought synthesized: %s", result.get("thought"))
|
|
222
|
+
except asyncio.CancelledError:
|
|
223
|
+
raise
|
|
224
|
+
except Exception as e:
|
|
225
|
+
logger.error("Consciousness loop error: %s", e)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
app = create_app()
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""Pluggable backends implementing the engraphis.core interfaces.
|
|
2
|
+
|
|
3
|
+
Phase 0 ships *reference* implementations chosen for portability and zero extra
|
|
4
|
+
dependencies so the system runs and is testable anywhere:
|
|
5
|
+
|
|
6
|
+
* ``NumpyVectorIndex`` — brute-force cosine over the store. Correct, not fast.
|
|
7
|
+
Phase 1 replaces it with a ``sqlite-vec`` / LanceDB /
|
|
8
|
+
Qdrant backend behind the same ``VectorIndex`` interface.
|
|
9
|
+
* ``DeterministicEmbedder`` — a hashing embedder with no model download, for
|
|
10
|
+
offline tests and CI. Production uses a real model
|
|
11
|
+
(BGE-M3 / Qwen3 class) behind the same ``Embedder`` interface.
|
|
12
|
+
"""
|
|
13
|
+
from engraphis.backends.embedder_deterministic import DeterministicEmbedder
|
|
14
|
+
from engraphis.backends.vector_numpy import NumpyVectorIndex
|
|
15
|
+
|
|
16
|
+
__all__ = ["DeterministicEmbedder", "NumpyVectorIndex"]
|