Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 16 additions & 3 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,18 @@ ENGRAPHIS_SERVICE_MODE=combined
# The dashboard base URL is derived from ENGRAPHIS_HOST:ENGRAPHIS_PORT.
# Set ENGRAPHIS_DASHBOARD_URL for a canonical public HTTPS URL behind a reverse proxy.

# Update reminder. When on (default), the server checks for a newer Engraphis release
# once a day and surfaces it in the dashboard banner, the startup log, and over MCP.
# The check is fail-silent and cached; set to 0 to disable all update network activity.
# ENGRAPHIS_UPDATE_CHECK=1
# Override the release source. Default: the GitHub releases/latest API for the project
# repo. Accepts any HTTPS endpoint returning a GitHub-release, PyPI, or
# {"version": "...", "url": "..."} JSON payload.
# ENGRAPHIS_UPDATE_URL=
# Point the default GitHub source at a different owner/repo (ignored when
# ENGRAPHIS_UPDATE_URL is set). Default: Coding-Dev-Tools/engraphis.
# ENGRAPHIS_UPDATE_REPO=Coding-Dev-Tools/engraphis

# Optional shared service/operations bearer. If set, supported protected routes accept
# Authorization: Bearer <token>; managed backup/readiness automation should use a strong,
# independently revocable value. It does not prove hosted deployment ownership and is not
Expand Down Expand Up @@ -64,11 +76,12 @@ ENGRAPHIS_EMBED_MODEL=sentence-transformers/all-MiniLM-L6-v2
# "llm": free-form fact extraction. "llm_structured": schema-validated typed facts,
# entities, relations, and keywords using the LLM settings below.
ENGRAPHIS_EXTRACTOR=none
# A successful dashboard connection test turns llm_structured on automatically. The
# Settings On/Off button updates the live engine immediately and persists this flag plus
# OFF by default (opt-in). While ON, a successful dashboard connection test enables
# llm_structured automatically. Use the Settings "LLM extraction" On/Off button to turn
# it on or off; it updates the live engine immediately and persists this flag plus
# ENGRAPHIS_EXTRACTOR only when the project .env is writable. Explicit deployment
# environment variables remain authoritative after restart.
ENGRAPHIS_LLM_AUTO_EXTRACT=1
ENGRAPHIS_LLM_AUTO_EXTRACT=0

# ── Knowledge-graph extraction (powers the dashboard Graph tab) ──────────────
# "regex" (default): dependency-free heuristic NER runs on every ingest so the Graph tab
Expand Down
6 changes: 6 additions & 0 deletions conftest.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,17 @@
"""Ensure the repo root is importable when running ``python -m pytest`` from anywhere."""
import os
import sys
from pathlib import Path

ROOT = Path(__file__).resolve().parent
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))

# The update reminder reaches out to the network to look up the newest release. Keep the
# offline test suite network-inert by default; tests that exercise the feature opt back in
# explicitly via monkeypatch (see tests/test_update_check.py).
os.environ.setdefault("ENGRAPHIS_UPDATE_CHECK", "0")

# The legacy scripts/test_*.py files are HTTP smoke tests (need a running server +
# httpx), not unit tests. Keep pytest focused on the tests/ suite.
collect_ignore_glob = ["scripts/*"]
5 changes: 5 additions & 0 deletions engraphis/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,11 @@ async def _lifespan(app: FastAPI):
logger.info("Background consciousness loop started (interval=%ds)", settings.loop_interval)
else:
logger.info("Background loop disabled (ENGRAPHIS_LOOP_INTERVAL=0)")
try: # one-line "update available" notice (background, fail-silent, opt-out)
from engraphis import update_check
update_check.emit_startup_notice(logger.info)
except Exception: # noqa: BLE001 - never block server startup
pass
try:
yield
finally:
Expand Down
2 changes: 1 addition & 1 deletion engraphis/billing.py
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,7 @@ def _dedup_path() -> Optional[str]:
raise WebhookStateError("vendor webhook state cannot use an in-memory store")
return override
db = os.environ.get("ENGRAPHIS_DB_PATH", "").strip()
if db and db != ":memory:":
if not vendor_mode and db and db != ":memory:":
try:
return str(Path(db).expanduser().resolve().parent / ".engraphis_webhooks.db")
except (OSError, RuntimeError) as exc:
Expand Down
28 changes: 24 additions & 4 deletions engraphis/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -275,6 +275,16 @@ def _env_float(key: str, default: float) -> float:
return default


_FALSY_ENV = {"0", "false", "no", "off", "disable", "disabled"}


def _env_bool(key: str, default: bool) -> bool:
raw = os.environ.get(key)
if raw is None or not raw.strip():
return default
return raw.strip().lower() not in _FALSY_ENV


def persist_project_env(values: dict[str, str], path: Optional[Path] = None) -> Path:
"""Upsert non-secret runtime settings in the project-local ``.env`` atomically.

Expand Down Expand Up @@ -412,11 +422,12 @@ class Settings:
llm_extra_headers: dict = field(
default_factory=lambda: _parse_headers(_env("ENGRAPHIS_LLM_EXTRA_HEADERS", ""))
)
# A successful dashboard connection test enables schema-validated extraction unless
# the user explicitly turned it off. The separate flag is what makes an automatic
# default and a durable on/off control compatible.
# OFF by default (opt-in): a successful dashboard connection test enables
# schema-validated extraction ONLY while the user has turned extraction on (the
# Settings On/Off control, or ENGRAPHIS_LLM_AUTO_EXTRACT=1) — so a mere connection
# test never silently starts provider egress of ingested content.
llm_auto_extract: bool = field(
default_factory=lambda: _env("ENGRAPHIS_LLM_AUTO_EXTRACT", "1").lower()
default_factory=lambda: _env("ENGRAPHIS_LLM_AUTO_EXTRACT", "0").lower()
not in ("0", "false", "no", "off")
)

Expand Down Expand Up @@ -452,6 +463,15 @@ class Settings:
rate_limit: int = field(default_factory=lambda: _env_int("ENGRAPHIS_RATE_LIMIT", 0))
rate_window: int = field(default_factory=lambda: _env_int("ENGRAPHIS_RATE_WINDOW", 60))

# Update reminder: check the newest published release and surface it in the dashboard,
# server startup log, and MCP. On by default; ``ENGRAPHIS_UPDATE_CHECK=0`` opts out and
# stops all network activity. ``ENGRAPHIS_UPDATE_URL`` overrides the default GitHub
# releases source (see engraphis.update_check, the runtime authority for both knobs).
update_check: bool = field(
default_factory=lambda: _env_bool("ENGRAPHIS_UPDATE_CHECK", True))
update_check_url: str = field(
default_factory=lambda: _env("ENGRAPHIS_UPDATE_URL", ""))

@property
def base_url(self) -> str:
"""Connectable local base URL (wildcard binds map to loopback, IPv6 literals are
Expand Down
7 changes: 7 additions & 0 deletions engraphis/dashboard_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,13 @@ def create_app() -> FastAPI:

@_contextlib.asynccontextmanager
async def _lifespan(app: FastAPI):
try: # one-line "update available" notice (background, fail-silent, opt-out)
import logging as _logging

from engraphis import update_check
update_check.emit_startup_notice(_logging.getLogger("engraphis").info)
except Exception: # noqa: BLE001 - never block dashboard startup
pass
if _mcp_asgi is not None:
async with _mcp_mgr.run():
yield
Expand Down
2 changes: 1 addition & 1 deletion engraphis/email_outbox.py
Original file line number Diff line number Diff line change
Expand Up @@ -244,7 +244,7 @@ def deliver_now(message_id: str,
now = time.time()
updated = conn.execute(
"UPDATE email_outbox SET status='sent',provider=?,provider_message_id=?,"
"last_error='',sent_at=?,updated_at=? "
"last_error='',sent_at=?,updated_at=?,text_body='',reply_to=NULL "
"WHERE id=? AND status='sending' AND attempts=?",
(provider_name, provider_message_id, now, now,
message_id, int(message["attempts"])))
Expand Down
21 changes: 12 additions & 9 deletions engraphis/inspector/license_cloud.py
Original file line number Diff line number Diff line change
Expand Up @@ -914,7 +914,7 @@ async def team_invite(request: Request):
behalf of a self-hosted Team dashboard that has none configured. 402 if *key*
doesn't verify or lacks the ``team`` feature (:func:`license_registry.
verify_for_feature` — the same server-side gate every other licensed feature
uses); 400 for a malformed recipient; 409 if a paid key tries to re-aim its invites
uses); 400 for a malformed recipient or a missing/invalid ``invite_url``; 409 if a
at a different ``dashboard_url`` than the one it pinned on first use; 429 for a
per-IP burst or past the per-key daily cap. Accepted messages are durably queued and
retried by the vendor outbox. Trial keys never choose the link (see below), and a ``viewer``
Expand Down Expand Up @@ -947,13 +947,16 @@ async def team_invite(request: Request):
dashboard_url = cloud_license.validate_cloud_base_url(dashboard_url)
except ValueError:
return JSONResponse({"error": "invalid dashboard URL"}, status_code=400)
if invite_url:
invite_origin = _auth_link_origin(invite_url, "invite_token")
if invite_origin is None:
return JSONResponse({"error": "invalid invitation URL"}, status_code=400)
if dashboard_url and dashboard_url.rstrip("/") != invite_origin.rstrip("/"):
return JSONResponse({"error": "invitation URL origin mismatch"}, status_code=400)
dashboard_url = invite_origin
if not invite_url:
return JSONResponse(
{"error": "an invitation URL with a one-time invite_token is required"},
status_code=400)
invite_origin = _auth_link_origin(invite_url, "invite_token")
if invite_origin is None:
return JSONResponse({"error": "invalid invitation URL"}, status_code=400)
if dashboard_url and dashboard_url.rstrip("/") != invite_origin.rstrip("/"):
return JSONResponse({"error": "invitation URL origin mismatch"}, status_code=400)
dashboard_url = invite_origin

# Burst-cap before the verify below, for the same reason /register does: this is an
# unauthenticated Ed25519 verify on a caller-supplied key. The bucket is deliberately
Expand Down Expand Up @@ -981,7 +984,7 @@ async def team_invite(request: Request):
# A FREE trial key may never aim a vendor-branded invitation at a caller-chosen
# origin (branded phishing relay). Trials must target the relay's own trusted
# dashboard origin; the one-time accept token in invite_url is preserved.
if not invite_url or dashboard_url.rstrip("/") != _relay_trusted_origin():
if dashboard_url.rstrip("/") != _relay_trusted_origin():
return JSONResponse(
{"error": "trial invitations must target the deployment's own dashboard "
"origin (set ENGRAPHIS_DASHBOARD_URL on the relay)"},
Expand Down
7 changes: 2 additions & 5 deletions engraphis/llm/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -260,13 +260,10 @@ def _chat_google(self, messages, system, temperature, max_tokens) -> str:
if gen_config:
body["generationConfig"] = gen_config

headers = {"Content-Type": "application/json"}
headers = {"Content-Type": "application/json", "x-goog-api-key": self.api_key}
headers.update(self.extra_headers)

url = (
f"{self.base_url}/models/{self.model}:generateContent"
f"?key={self.api_key}"
)
url = f"{self.base_url}/models/{self.model}:generateContent"
logger.debug("LLM provider request started")
data = self._post_json(url, body, headers)
try:
Expand Down
28 changes: 28 additions & 0 deletions engraphis/mcp_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,7 @@ def _err(exc: Exception) -> str:
"engraphis_verify_receipts",
"engraphis_export_receipts",
"engraphis_stats",
"engraphis_check_update",
})
_ADMIN_TOOLS = frozenset({
"engraphis_consolidate",
Expand Down Expand Up @@ -990,6 +991,33 @@ def engraphis_stats(
return _err(exc)


@mcp.tool(
name="engraphis_check_update",
annotations={"title": "Check for an Engraphis update", "readOnlyHint": True,
"destructiveHint": False, "idempotentHint": True, "openWorldHint": True},
)
def engraphis_check_update(
force: Annotated[bool, Field(description="Bypass the ~24h cache and re-check the "
"release source now.")] = False,
) -> str:
"""Report whether a newer Engraphis release is available, so an agent can proactively
remind the user to upgrade.

Cached ~24h and fail-silent; honors ``ENGRAPHIS_UPDATE_CHECK=0`` (then ``enabled`` is
false). The default GitHub source is overridable via ``ENGRAPHIS_UPDATE_URL``.

Returns:
str: JSON ``{"enabled","current","latest","update_available","url","notice"}``.
"""
try:
from engraphis import update_check
snap = dict(update_check.check(force=True) if force else update_check.snapshot())
snap["notice"] = update_check.notice_line(snap) or ""
return _ok(snap)
except Exception as exc: # noqa: BLE001
return _err(exc)


@mcp.tool(
name="engraphis_ingest",
annotations={"title": "Ingest raw text (extract facts first)", "readOnlyHint": False,
Expand Down
26 changes: 26 additions & 0 deletions engraphis/routes/v2_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -239,9 +239,35 @@ def bootstrap():
"stats": _run(current_service.stats, workspace=scoped_stats_workspace),
"embedder": emb,
"features": {"graph_ui_v2": bool(settings.graph_ui_v2)},
# Non-blocking best-known update snapshot; the dashboard renders an "update
# available" banner from this and a background refresh warms the cache.
"update": _update_snapshot(),
}


def _update_snapshot() -> dict:
"""Best-known update snapshot for the dashboard; never raises into bootstrap."""
try:
from engraphis import update_check
return update_check.snapshot()
except Exception: # noqa: BLE001 - a convenience feature must not break bootstrap
return {"enabled": False, "update_available": False}


@router.get("/update")
def api_update(force: bool = False):
"""Update-availability snapshot for the dashboard banner.

Cached ~24h and fail-silent: reports the newest published release vs the installed
version. ``?force=1`` bypasses the cache and re-checks now.
"""
try:
from engraphis import update_check
return update_check.check(force=True) if force else update_check.snapshot()
except Exception: # noqa: BLE001
return {"enabled": False, "update_available": False}


# ── workspaces / stats ────────────────────────────────────────────────────────
@router.get("/workspaces")
def workspaces():
Expand Down
38 changes: 34 additions & 4 deletions engraphis/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -2307,14 +2307,44 @@ def _new_repo(old_repo_id):
)

# 3) Edges: relabel workspace/repo, remapping any entity ids folded in step 2.
# When a remapped source edge would collide with an existing live edge
# on the unique index (workspace_id, [repo_id,] src, dst, relation, layer),
# merge its evidence rows into the survivor and drop the duplicate.
src_edges = [dict(x) for x in c.execute(
"SELECT id, repo_id, src, dst FROM edges WHERE workspace_id=?", (wid_src,))]
"SELECT id, repo_id, src, dst, relation, layer, valid_to, expired_at "
"FROM edges WHERE workspace_id=?", (wid_src,))]
for ed in src_edges:
new_src = entity_remap.get(ed["src"], ed["src"])
new_dst = entity_remap.get(ed["dst"], ed["dst"])
new_repo = _new_repo(ed["repo_id"])
Comment on lines +2317 to +2319

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Normalize symmetric endpoints after remapping

For symmetric relations such as related, source and destination edges are canonicalized by their original entity IDs in Store.upsert_edge(). If the source IDs sort in the opposite order from the target IDs, remapping produces the reverse (src, dst) pair here, so the collision query misses the target edge and the merge leaves two live copies of the same undirected relation. This occurs for overlapping entities with independently generated IDs and persists until a later process startup happens to run _deduplicate_live_edges().

Useful? React with 👍 / 👎.

is_live = ed["valid_to"] is None and ed["expired_at"] is None
if is_live:
if new_repo is None:
collision = c.execute(
"SELECT id FROM edges WHERE workspace_id=? AND repo_id IS NULL "
"AND src=? AND dst=? AND relation=? AND layer=? "
"AND valid_to IS NULL AND expired_at IS NULL LIMIT 1",
(wid_dst, new_src, new_dst, ed["relation"], ed["layer"]),
).fetchone()
else:
collision = c.execute(
"SELECT id FROM edges WHERE workspace_id=? AND repo_id=? "
"AND src=? AND dst=? AND relation=? AND layer=? "
"AND valid_to IS NULL AND expired_at IS NULL LIMIT 1",
(wid_dst, new_repo, new_src, new_dst, ed["relation"], ed["layer"]),
).fetchone()
if collision:
# Merge evidence: re-point supports, skip duplicates.
c.execute(
"UPDATE OR IGNORE edge_supports SET edge_id=? WHERE edge_id=?",
(collision["id"], ed["id"]),
)
Comment on lines +2337 to +2341

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Merge duplicate support confidence before deleting it

When both colliding edges have a live support with the same (memory_id, source_kind), this UPDATE OR IGNORE leaves the source support in place because of the live-support unique index, and the following delete removes it. Consequently, a higher source confidence (and its support provenance) is discarded in favor of the target value; graph-scene confidence filtering and evidence ordering can then understate or hide the merged relation. Merge duplicate support rows as the store's deduplication routine does instead of ignoring them.

Useful? React with 👍 / 👎.

c.execute("DELETE FROM edge_supports WHERE edge_id=?", (ed["id"],))
c.execute("DELETE FROM edges WHERE id=?", (ed["id"],))
Comment on lines +2338 to +2343

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve edge provenance when merging a collision

When a source edge collides with a target edge during a normal workspace merge, this moves the normalized edge_supports rows but then deletes the source edge without merging its edges.provenance. The surviving edge consequently still reports only the destination edge's memory_ids/source metadata, even though it now has evidence from both workspaces. This leaves the stored provenance inconsistent for legacy consumers and exports; merge the edge-level provenance as Store._deduplicate_live_edges() does before deleting the duplicate.

Useful? React with 👍 / 👎.

Comment on lines +2342 to +2343

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep the strongest weight when collapsing an edge

For a collision between otherwise identical live edges, the source row is deleted without incorporating its weight, so a source relation with a higher weight is silently downgraded to the destination edge's lower weight. Weighted graph recall consumes Edge.weight, and the existing edge deduplication path explicitly retains the maximum weight; apply the same merge rule here before deleting the source edge.

Useful? React with 👍 / 👎.

continue
c.execute(
"UPDATE edges SET workspace_id=?, repo_id=?, src=?, dst=? WHERE id=?",
(wid_dst, _new_repo(ed["repo_id"]),
entity_remap.get(ed["src"], ed["src"]), entity_remap.get(ed["dst"], ed["dst"]),
ed["id"]))
(wid_dst, new_repo, new_src, new_dst, ed["id"]))

# 4) Memories / sessions / events: relabel workspace/repo per distinct repo_id
# bucket (ids, content and history are untouched).
Expand Down
Loading