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.
Files changed (78) hide show
  1. engraphis/__init__.py +8 -0
  2. engraphis/analytics.py +243 -0
  3. engraphis/app.py +228 -0
  4. engraphis/backends/__init__.py +16 -0
  5. engraphis/backends/codegraph.py +345 -0
  6. engraphis/backends/embedder_api.py +188 -0
  7. engraphis/backends/embedder_deterministic.py +49 -0
  8. engraphis/backends/embedder_st.py +52 -0
  9. engraphis/backends/extractor.py +148 -0
  10. engraphis/backends/graph_extractor.py +201 -0
  11. engraphis/backends/reranker.py +46 -0
  12. engraphis/backends/vector_numpy.py +52 -0
  13. engraphis/backends/vector_sqlitevec.py +88 -0
  14. engraphis/config.py +137 -0
  15. engraphis/core/__init__.py +43 -0
  16. engraphis/core/consolidate.py +349 -0
  17. engraphis/core/engine.py +517 -0
  18. engraphis/core/graphrank.py +73 -0
  19. engraphis/core/grounded.py +207 -0
  20. engraphis/core/ids.py +54 -0
  21. engraphis/core/interfaces.py +186 -0
  22. engraphis/core/recall.py +232 -0
  23. engraphis/core/resolve.py +99 -0
  24. engraphis/core/schema.py +191 -0
  25. engraphis/core/scoring.py +108 -0
  26. engraphis/core/store.py +598 -0
  27. engraphis/core/textutil.py +51 -0
  28. engraphis/dashboard_app.py +115 -0
  29. engraphis/engines/__init__.py +1 -0
  30. engraphis/engines/embedder.py +104 -0
  31. engraphis/engines/ingest.py +216 -0
  32. engraphis/engines/intelligence.py +176 -0
  33. engraphis/engines/recall.py +155 -0
  34. engraphis/engines/reweight.py +90 -0
  35. engraphis/engines/thoughts.py +67 -0
  36. engraphis/graphdata.py +65 -0
  37. engraphis/inspector/__init__.py +9 -0
  38. engraphis/inspector/app.py +436 -0
  39. engraphis/inspector/auth.py +252 -0
  40. engraphis/inspector/index.html +1255 -0
  41. engraphis/licensing.py +432 -0
  42. engraphis/llm/__init__.py +1 -0
  43. engraphis/llm/client.py +237 -0
  44. engraphis/logging_setup.py +65 -0
  45. engraphis/mcp_server.py +703 -0
  46. engraphis/models.py +180 -0
  47. engraphis/routes/__init__.py +1 -0
  48. engraphis/routes/memory.py +828 -0
  49. engraphis/routes/v2_api.py +444 -0
  50. engraphis/routes/v2_team.py +152 -0
  51. engraphis/routes/vault.py +615 -0
  52. engraphis/service.py +881 -0
  53. engraphis/stores/__init__.py +194 -0
  54. engraphis/stores/graph.py +138 -0
  55. engraphis/stores/ledger.py +139 -0
  56. engraphis/stores/vaults.py +117 -0
  57. engraphis/stores/vectors.py +270 -0
  58. engraphis-0.1.0.dist-info/METADATA +333 -0
  59. engraphis-0.1.0.dist-info/RECORD +78 -0
  60. engraphis-0.1.0.dist-info/WHEEL +5 -0
  61. engraphis-0.1.0.dist-info/entry_points.txt +9 -0
  62. engraphis-0.1.0.dist-info/licenses/LICENSE +201 -0
  63. engraphis-0.1.0.dist-info/licenses/NOTICE +14 -0
  64. engraphis-0.1.0.dist-info/top_level.txt +2 -0
  65. scripts/__init__.py +1 -0
  66. scripts/cli.py +181 -0
  67. scripts/consolidate.py +208 -0
  68. scripts/init.py +164 -0
  69. scripts/inspector.py +78 -0
  70. scripts/install_shortcuts.py +220 -0
  71. scripts/license_admin.py +121 -0
  72. scripts/migrate_to_v2.py +201 -0
  73. scripts/sdk_compat.py +45 -0
  74. scripts/seed_from_obsidian.py +98 -0
  75. scripts/start_dashboard.py +79 -0
  76. scripts/start_server.py +29 -0
  77. scripts/test_routes.py +198 -0
  78. 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"]