Skip to content
Merged
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
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ embeddings. You bring an LLM only for optional chat, synthesis, structured extra
or structured consolidation.

- **Local-first & private** — runs offline; the core depends only on `numpy`.
- **MCP-native** — 28 tools for Claude Code, Command Code, Cursor, Cline, Zed, Windsurf.
- **MCP-native** — 29 tools for Claude Code, Command Code, Cursor, Cline, Zed, Windsurf.
- **Self-maintaining facts** — writes are deterministically conflict-resolved (no LLM required).
- **Advisory retention supervision** — an optional LLM can label writes as ephemeral, normal,
or critical; outputs are bounded, clamped, audited, and can never silently drop a write.
Expand Down Expand Up @@ -294,10 +294,10 @@ claude mcp add engraphis -- engraphis-mcp
cmd mcp add engraphis -- engraphis-mcp # Command Code CLI
```

Your agent now has 28 tools — remember, recall (grounded + proactive), proactive context,
Your agent now has 29 tools — remember, recall (grounded + proactive), proactive context,
grounded answer alias, why, timeline, forget, pin, correct, promote, ingest, consolidate, index_repo,
search/code path/impact/export, privacy receipts, PostgreSQL schema ingestion, link,
record_event, start/end_session, and stats. See the [MCP tools table](#mcp-tools) below.
record_event, start/end_session, stats, and check_update. See the [MCP tools table](#mcp-tools) below.

For unattended jobs, `engraphis_start_session`, `engraphis_remember`, and
`engraphis_record_event` use workspace `default` when `workspace` is omitted.
Expand Down Expand Up @@ -684,7 +684,7 @@ engraphis/
│ ├── core/ # v2 engine — interfaces, store, recall, scoring, schema, sync
│ ├── backends/ # pluggable embedder / vector index / reranker / codegraph / sync transports / encryption
│ ├── service.py # validated MemoryService facade
│ ├── mcp_server.py # MCP server — 28 tools
│ ├── mcp_server.py # MCP server — 29 tools
│ ├── dashboard_app.py # dashboard WebUI (FastAPI)
│ ├── read_only_api.py # token-protected recall/repository-graph HTTP surface
│ ├── autosync.py # background auto-sync loop (Pro/Team)
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/*"]
8 changes: 4 additions & 4 deletions docs/KILO_CODE_INTEGRATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,9 @@ This manual is written for someone who wants the full technical picture: what En

There are two separate questions hiding inside "connect Kilo Code to Engraphis," and they are usually where people talk past each other:

1. **Transport layer — "get the pipes connected."** This is: install the Engraphis MCP server, tell Kilo Code how to launch it, confirm the tools show up. It's a plumbing task. When it's done, Kilo Code can *see* 28 `engraphis_*` tools. Success here is binary — either the tools appear or they don't.
1. **Transport layer — "get the pipes connected."** This is: install the Engraphis MCP server, tell Kilo Code how to launch it, confirm the tools show up. It's a plumbing task. When it's done, Kilo Code can *see* 29 `engraphis_*` tools. Success here is binary — either the tools appear or they don't.

2. **Orchestration layer — "use the memory well."** This is: *when* should the agent remember vs. recall, how should memories be scoped (`workspace → repo → session`), which of the 28 tools answers which question, and how to keep the store clean over time. This is where the actual value is, and it's a discipline, not a config.
2. **Orchestration layer — "use the memory well."** This is: *when* should the agent remember vs. recall, how should memories be scoped (`workspace → repo → session`), which of the 29 tools answers which question, and how to keep the store clean over time. This is where the actual value is, and it's a discipline, not a config.

You need both. A perfect config with no discipline gives you an agent that has memory tools and never uses them correctly. Good discipline with a broken config gives you an agent that wants to remember and can't. **Section 3 is the transport layer. Sections 4–6 are the orchestration layer.** Do them in order.

Expand Down Expand Up @@ -40,7 +40,7 @@ Everything runs on your machine. The whole store is a single SQLite file. Local
You interact with Engraphis through three surfaces, all backed by the *same* engine (`MemoryService`), so they can never drift apart:

- **The dashboard WebUI** (`engraphis-dashboard`, `http://127.0.0.1:8700`) — a visual product to see, search, and curate memory.
- **The MCP server** (`engraphis-mcp`) — the 28 tools your coding agent calls. **This is the surface Kilo Code uses.**
- **The MCP server** (`engraphis-mcp`) — the 29 tools your coding agent calls. **This is the surface Kilo Code uses.**
- **The Python library** (`from engraphis.service import MemoryService`) — for direct programmatic use.

### 2.1 The five ideas that make it more than a vector store
Expand Down Expand Up @@ -179,7 +179,7 @@ You can also click **Approve Always** on any tool at runtime to write the same r

---

## 4. The 28 tools — the orchestration surface
## 4. The 29 tools — the orchestration surface

Once connected, Kilo Code sees these. Do **not** assume only `remember`/`recall` exist — the value is in the rest. This is the full surface, grouped by what question each one answers.

Expand Down
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
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
24 changes: 23 additions & 1 deletion engraphis/email_outbox.py
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,21 @@ def _provider_state(conn: sqlite3.Connection, provider_message_id: str) -> str:
return "sent"


def _body_has_license_key(text_body: Optional[str]) -> bool:
"""True when the body carries a signed license key (ENGR1.<sig>.<payload>).

Mirrors inspector/webhooks.py::_existing_license_delivery() extraction so a
purchase/renewal outbox row stays the recoverable source of truth until the
Polar fulfillment claim is finalized; only non-recoverable bodies are redacted
after a successful send.
"""
for entry in str(text_body or "").splitlines():
candidate = entry.strip()
if candidate.startswith("ENGR1.") and candidate.count(".") == 2:
return True
return False


def deliver_now(message_id: str,
deliverer: Callable[
[str, str, str, Optional[str], str], tuple[str, str]]) -> bool:
Expand Down Expand Up @@ -242,9 +257,16 @@ def deliver_now(message_id: str,
provider_message_id = (provider_id or "")[:160]
conn.execute("BEGIN IMMEDIATE")
now = time.time()
# A purchased license key in the body is the crash-recovery source of
# truth for inspector/webhooks.py::_existing_license_delivery() until the
# Polar fulfillment claim is finalized; redacting a license row here would
# strand the webhook. Only clear bodies with no recoverable entitlement.
redact_sql = (
"" if _body_has_license_key(message["text_body"])
else ",text_body='',reply_to=NULL")
Comment on lines +264 to +266

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Clear license material after fulfillment

For every normal purchase_license email, inspector/webhooks.py::_license_email_text() places the signed key on its own indented line, so this predicate is true and the successful-send update deliberately leaves text_body intact. There is no subsequent outbox cleanup or fulfillment-finalization path in this commit—_existing_license_delivery() only reads the body—so all successfully delivered customer license keys now remain as plaintext in the vendor ledger indefinitely, whereas they were previously redacted after delivery. Retain recovery material only through the webhook finalization window (or in a protected recovery record), then clear it.

Useful? React with 👍 / 👎.

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=?" + redact_sql + " "
"WHERE id=? AND status='sending' AND attempts=?",
Comment on lines 268 to 270

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Retain a recovery record for fulfilled license emails

Clearing text_body after a successful provider send removes the only durable copy of a purchased license key. If the process crashes after deliver_now() succeeds but before the Polar fulfillment claim is finalized, the redelivered event enters inspector/webhooks.py::_existing_license_delivery(), finds this same idempotency row with an empty body, and raises durable purchase email is missing its signed key instead of converging on the original fulfillment. That leaves the webhook retryable but permanently unable to finalize unless an operator intervenes; retain the key in an appropriately protected recovery record until the fulfillment is finalized.

Useful? React with 👍 / 👎.

(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:
Comment on lines +994 to +1002

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Update the shipped MCP tool documentation

This adds engraphis_check_update, but the shipped skill still says there are 28 tools and omits it from both skills/engraphis-memory/SKILL.md and skills/engraphis-memory/references/TOOLS.md; README and integration docs likewise advertise 28. This violates the AGENTS.md §7 requirement to keep the portable skill's tool names and parameters synchronized with mcp_server.py, leaving MCP clients and agents unable to discover the new supported capability.

Useful? React with 👍 / 👎.

"""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
Loading