From a6c9cae87f0c0477df92edec97c4025d08d9280a Mon Sep 17 00:00:00 2001 From: Coding-Dev-Tools <274649000+Coding-Dev-Tools@users.noreply.github.com> Date: Sun, 8 Nov 2026 23:10:45 -0500 Subject: [PATCH 1/5] feat: add engraphis-demo seeded first-run New users currently land on an empty dashboard. Add an engraphis-demo command that seeds a disposable SQLite workspace with scoped decisions, a superseded auth fact, and a cited recall, then launches the existing dashboard against it. The demo stays fully local: no extractor, no retention LLM call, and no access to the operator's real database. Cover the seed with a pytest that asserts the supersession and recall. --- engraphis/service.py | 2 + pyproject.toml | 1 + scripts/demo.py | 265 ++++++++++++++++++++++++++++++++++++++++ tests/test_demo_seed.py | 50 ++++++++ 4 files changed, 318 insertions(+) create mode 100644 scripts/demo.py create mode 100644 tests/test_demo_seed.py diff --git a/engraphis/service.py b/engraphis/service.py index 967a5cbbf..d9559c266 100644 --- a/engraphis/service.py +++ b/engraphis/service.py @@ -1176,6 +1176,8 @@ def _title_from_content(content: str, fallback: str) -> str: def _warn_if_db_empty_with_populated_sibling(db_path: str) -> None: """Warn, without blocking startup, when a sibling database holds the data.""" + if os.environ.get("ENGRAPHIS_DEMO") == "1": + return import sqlite3 configured = Path(db_path) diff --git a/pyproject.toml b/pyproject.toml index e23dd269a..272c5456b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -219,6 +219,7 @@ engraphis-mcp-classic = "engraphis.mcp_classic_cli:main" engraphis-mcp-http = "engraphis.mcp_http_cli:main" engraphis-inspector = "scripts.inspector:main" engraphis-dashboard = "scripts.start_dashboard:main" +engraphis-demo = "scripts.demo:main" engraphis-consolidate = "scripts.consolidate:main" engraphis-graph = "scripts.graph_cli:main" engraphis-graph-server = "scripts.graph_server:main" diff --git a/scripts/demo.py b/scripts/demo.py new file mode 100644 index 000000000..a5d511eb9 --- /dev/null +++ b/scripts/demo.py @@ -0,0 +1,265 @@ +#!/usr/bin/env python3 +"""Seed a disposable demo workspace and launch the local dashboard. + + engraphis-demo + engraphis-demo --no-open --port 9000 + engraphis-demo --db ./engraphis-demo.db --force + +The demo uses a separate SQLite database and never reads or writes the +database selected by ENGRAPHIS_DB_PATH until it points that variable at the +demo file for the dashboard process. +""" + +from __future__ import annotations + +import argparse +import os +import tempfile +from pathlib import Path + +DEMO_FACTS = ( + { + "content": "Checkout API uses JWT access tokens.", + "workspace": "acme-api", + "repo": "checkout", + "title": "Legacy checkout token format", + "subject_key": "auth.token_format", + "claim_kind": "decision", + "importance": 0.7, + }, + { + "content": "Checkout API uses PASETO access tokens.", + "workspace": "acme-api", + "repo": "checkout", + "title": "Checkout token format", + "subject_key": "auth.token_format", + "claim_kind": "decision", + "importance": 0.9, + }, + { + "content": "The November auth migration replaced JWT with PASETO to retire the legacy signer.", + "workspace": "acme-api", + "repo": "checkout", + "title": "Auth migration reason", + "subject_key": "auth.migration_reason", + "claim_kind": "decision", + "importance": 0.8, + }, + { + "content": "Checkout frontend packages use pnpm. The lockfile is pnpm-lock.yaml; do not add a package-lock.json.", + "workspace": "acme-api", + "repo": "checkout", + "title": "Checkout package manager", + "subject_key": "repo.package_manager", + "claim_kind": "convention", + "importance": 0.8, + }, + { + "content": "Run `pnpm test --filter checkout` before merging changes that touch the checkout package.", + "workspace": "acme-api", + "repo": "checkout", + "title": "Checkout test command", + "subject_key": "repo.test_command", + "claim_kind": "convention", + "importance": 0.7, + }, + { + "content": "The checkout retry loop must send the same idempotency key on every attempt. A missing key caused duplicate charges in the October incident.", + "workspace": "acme-api", + "repo": "checkout", + "title": "Checkout retry idempotency", + "subject_key": "checkout.idempotency", + "claim_kind": "incident", + "importance": 0.9, + }, + { + "content": "Order events are written to the outbox table in the same transaction as the order. The publisher worker drains the outbox.", + "workspace": "acme-api", + "repo": "checkout", + "title": "Checkout event delivery", + "subject_key": "checkout.event_delivery", + "claim_kind": "architecture", + "importance": 0.8, + }, + { + "content": "Schema migrations require a verified backup and a rollback note before the migration runs.", + "workspace": "acme-api", + "repo": "checkout", + "title": "Migration policy", + "subject_key": "db.migration_policy", + "claim_kind": "convention", + "importance": 0.8, + }, + { + "content": "Stripe webhook handlers must be idempotent by event id. Store the processed event id before applying the change.", + "workspace": "acme-api", + "repo": "billing", + "title": "Billing webhook idempotency", + "subject_key": "billing.webhook_idempotency", + "claim_kind": "convention", + "importance": 0.9, + }, + { + "content": "The invoice reconciliation job double-counted prorations when a subscription changed mid-cycle. The fix reads the proration line items once and keys the result by invoice id.", + "workspace": "acme-api", + "repo": "billing", + "title": "Proration reconciliation bug", + "subject_key": "billing.proration_reconciliation", + "claim_kind": "incident", + "importance": 0.8, + }, + { + "content": "Dashboard components are server components by default. Add `use client` only at interactive leaves.", + "workspace": "acme-web", + "repo": "dashboard", + "title": "Dashboard component default", + "subject_key": "web.component_default", + "claim_kind": "convention", + "importance": 0.8, + }, + { + "content": "Dashboard feature flags are read at request time, not baked in at build time.", + "workspace": "acme-web", + "repo": "dashboard", + "title": "Dashboard feature flags", + "subject_key": "web.feature_flags", + "claim_kind": "decision", + "importance": 0.7, + }, + { + "content": "Use the design tokens in `styles/tokens.css`. Do not hardcode hex colors in dashboard components.", + "workspace": "acme-web", + "repo": "dashboard", + "title": "Dashboard styling tokens", + "subject_key": "web.styling", + "claim_kind": "convention", + "importance": 0.7, + }, +) + + +def _port(value: str) -> int: + try: + port = int(value) + except (TypeError, ValueError): + raise argparse.ArgumentTypeError("port must be an integer from 1 to 65535") from None + if not 1 <= port <= 65535: + raise argparse.ArgumentTypeError("port must be from 1 to 65535") + return port + + +def seed_demo(service) -> list[dict]: + """Write the demo facts through the public MemoryService API.""" + return [service.remember(**fact) for fact in DEMO_FACTS] + + +def _demo_path(path: str | None, *, force: bool) -> Path: + if path: + db_path = Path(path).expanduser().resolve() + if db_path.exists() and db_path.stat().st_size > 0 and not force: + raise SystemExit( + f"Refusing to overwrite the existing database at {db_path}. " + "Pass --force to replace it." + ) + if force: + for suffix in ("", "-wal", "-shm"): + candidate = Path(str(db_path) + suffix) + if candidate.exists(): + candidate.unlink() + db_path.parent.mkdir(parents=True, exist_ok=True) + return db_path + return Path(tempfile.mkdtemp(prefix="engraphis-demo-")) / "demo.db" + + +def create_demo_database( + path: str | None = None, *, force: bool = False +) -> tuple[Path, list[dict], dict]: + """Create a seeded demo database and return its path and sample recall.""" + db_path = _demo_path(path, force=force) + # Set the path before importing Engraphis so the dashboard's cached Settings + # snapshot resolves to the demo file, not the operator's real database. + os.environ["ENGRAPHIS_DB_PATH"] = str(db_path) + # The demo database is intentionally empty until it is seeded. Suppress the + # unrelated "your real database has memories" diagnostic during creation. + os.environ["ENGRAPHIS_DEMO"] = "1" + + from engraphis.service import MemoryService + + service = MemoryService.create( + str(db_path), + extractor="none", + graph_extractor="regex", + retention_supervisor="none", + ) + try: + results = seed_demo(service) + sample = service.recall( + "What token format does the checkout API use?", + workspace="acme-api", + repo="checkout", + k=3, + ) + finally: + service.close() + return db_path, results, sample + + +def main(argv=None) -> None: + ap = argparse.ArgumentParser( + description="Seed a disposable Engraphis demo workspace and start the dashboard.", + ) + ap.add_argument( + "--host", + default=os.environ.get("ENGRAPHIS_HOST", "127.0.0.1"), + help="Bind host (default: $ENGRAPHIS_HOST, else 127.0.0.1).", + ) + ap.add_argument( + "--port", + type=_port, + default=(os.environ.get("PORT") or os.environ.get("ENGRAPHIS_PORT", "8700")), + help="Bind port (default: $PORT, else $ENGRAPHIS_PORT, else 8700).", + ) + ap.add_argument("--no-open", action="store_true", help="Do not open the browser.") + ap.add_argument( + "--db", + default="", + help="Use a specific demo database path instead of a temporary directory.", + ) + ap.add_argument( + "--force", + action="store_true", + help="Replace an existing database selected with --db.", + ) + args = ap.parse_args(argv) + + db_path, results, sample = create_demo_database(args.db or None, force=args.force) + + # Keep the dashboard's Settings snapshot pinned to the demo file even if a + # caller imported Engraphis before this function ran. + try: + from engraphis import config as _config + + _config.settings.db_path = str(db_path) + except Exception: + pass + + superseded = sum(1 for result in results if result.get("op") == "invalidate") + print("Engraphis demo workspace") + print(f" Database : {db_path}") + print(f" Memories : {len(results)}") + print(f" Superseded : {superseded}") + print(" Sample recall:") + for line in str(sample.get("context") or "").splitlines(): + print(f" {line}") + print() + + from scripts.start_dashboard import main as dashboard_main + + forwarded = ["--host", args.host, "--port", str(args.port)] + if args.no_open: + forwarded.append("--no-open") + dashboard_main(forwarded) + + +if __name__ == "__main__": + main() diff --git a/tests/test_demo_seed.py b/tests/test_demo_seed.py new file mode 100644 index 000000000..25c168b70 --- /dev/null +++ b/tests/test_demo_seed.py @@ -0,0 +1,50 @@ +"""The demo seed must show a live supersession and a cited recall.""" + +from __future__ import annotations + +from engraphis.service import MemoryService +from scripts.demo import DEMO_FACTS, seed_demo + + +def test_seed_demo_supersedes_the_old_auth_fact_and_recalls_the_new_one(): + service = MemoryService.create( + ":memory:", + extractor="none", + graph_extractor="regex", + retention_supervisor="none", + ) + try: + results = seed_demo(service) + assert len(results) == len(DEMO_FACTS) + + invalidations = [result for result in results if result.get("op") == "invalidate"] + assert len(invalidations) == 1 + assert results[0]["id"] in invalidations[0]["superseded"] + + recall = service.recall( + "What token format does the checkout API use?", + workspace="acme-api", + repo="checkout", + k=5, + ) + context = recall["context"] + assert "PASETO" in context + assert "Checkout API uses JWT access tokens." not in context + + package_manager = service.recall( + "Which package manager should the checkout frontend use?", + workspace="acme-api", + repo="checkout", + k=5, + ) + assert "pnpm" in package_manager["context"] + finally: + service.close() + + +def test_demo_facts_stay_in_the_two_demo_workspaces(): + workspaces = {fact["workspace"] for fact in DEMO_FACTS} + repos = {fact["repo"] for fact in DEMO_FACTS} + assert workspaces == {"acme-api", "acme-web"} + assert repos == {"checkout", "billing", "dashboard"} + assert len({fact["subject_key"] for fact in DEMO_FACTS}) == len(DEMO_FACTS) - 1 From 0bd315d6162b9cac9dd4bd4510ca7ed8bb557046 Mon Sep 17 00:00:00 2001 From: Coding-Dev-Tools <274649000+Coding-Dev-Tools@users.noreply.github.com> Date: Sun, 8 Nov 2026 23:17:08 -0500 Subject: [PATCH 2/5] test: register engraphis-demo in the entry-point smoke map The entry-point manifest must match pyproject.toml. Add the new demo command so the release smoke test covers it. --- scripts/smoke_entry_points.py | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/smoke_entry_points.py b/scripts/smoke_entry_points.py index c12c6b8cf..8b5f930ac 100644 --- a/scripts/smoke_entry_points.py +++ b/scripts/smoke_entry_points.py @@ -42,6 +42,7 @@ "engraphis-mcp-http": "engraphis.mcp_http_cli:main", "engraphis-inspector": "scripts.inspector:main", "engraphis-dashboard": "scripts.start_dashboard:main", + "engraphis-demo": "scripts.demo:main", "engraphis-consolidate": "scripts.consolidate:main", "engraphis-graph": "scripts.graph_cli:main", "engraphis-graph-server": "scripts.graph_server:main", From ee31b2cadf0679ef420ac99a7f3045f501f1bc24 Mon Sep 17 00:00:00 2001 From: Coding-Dev-Tools <274649000+Coding-Dev-Tools@users.noreply.github.com> Date: Sun, 8 Nov 2026 23:55:04 -0500 Subject: [PATCH 3/5] fix: harden the demo database path and keep it offline The initial demo allowed --force to delete any path, could reuse an existing dashboard, and mutated the process environment permanently. Use a disposable temp database, refuse existing files, require a free port, bind the dashboard settings to the demo database, and force the offline embedder. Add file-backed, refusal, environment, and help tests. --- engraphis/service.py | 3 +- scripts/demo.py | 157 +++++++++++++++++++--------------------- tests/test_demo_seed.py | 71 ++++++++++++++++-- 3 files changed, 144 insertions(+), 87 deletions(-) diff --git a/engraphis/service.py b/engraphis/service.py index d9559c266..3c32f81de 100644 --- a/engraphis/service.py +++ b/engraphis/service.py @@ -1176,7 +1176,8 @@ def _title_from_content(content: str, fallback: str) -> str: def _warn_if_db_empty_with_populated_sibling(db_path: str) -> None: """Warn, without blocking startup, when a sibling database holds the data.""" - if os.environ.get("ENGRAPHIS_DEMO") == "1": + # The demo command creates an intentionally empty database before seeding it. + if os.environ.get("ENGRAPHIS_DEMO", "").strip().lower() in {"1", "true", "yes"}: return import sqlite3 diff --git a/scripts/demo.py b/scripts/demo.py index a5d511eb9..a19b44260 100644 --- a/scripts/demo.py +++ b/scripts/demo.py @@ -3,11 +3,11 @@ engraphis-demo engraphis-demo --no-open --port 9000 - engraphis-demo --db ./engraphis-demo.db --force -The demo uses a separate SQLite database and never reads or writes the -database selected by ENGRAPHIS_DB_PATH until it points that variable at the -demo file for the dashboard process. +The demo uses a fresh SQLite database inside a temporary directory. It never +reads or writes the database selected by ENGRAPHIS_DB_PATH. ENGRAPHIS_DEMO=1 +is set only while the seed service is created, to suppress the unrelated +"your real database has memories" diagnostic. """ from __future__ import annotations @@ -17,6 +17,8 @@ import tempfile from pathlib import Path +from scripts.start_dashboard import _port, _port_is_available + DEMO_FACTS = ( { "content": "Checkout API uses JWT access tokens.", @@ -138,60 +140,40 @@ ) -def _port(value: str) -> int: - try: - port = int(value) - except (TypeError, ValueError): - raise argparse.ArgumentTypeError("port must be an integer from 1 to 65535") from None - if not 1 <= port <= 65535: - raise argparse.ArgumentTypeError("port must be from 1 to 65535") - return port - - def seed_demo(service) -> list[dict]: """Write the demo facts through the public MemoryService API.""" return [service.remember(**fact) for fact in DEMO_FACTS] -def _demo_path(path: str | None, *, force: bool) -> Path: - if path: - db_path = Path(path).expanduser().resolve() - if db_path.exists() and db_path.stat().st_size > 0 and not force: - raise SystemExit( - f"Refusing to overwrite the existing database at {db_path}. " - "Pass --force to replace it." - ) - if force: - for suffix in ("", "-wal", "-shm"): - candidate = Path(str(db_path) + suffix) - if candidate.exists(): - candidate.unlink() - db_path.parent.mkdir(parents=True, exist_ok=True) - return db_path - return Path(tempfile.mkdtemp(prefix="engraphis-demo-")) / "demo.db" - +def create_demo_database(db_path: str | Path) -> tuple[list[dict], dict]: + """Seed a new demo database and return its write results and sample recall.""" + path = Path(db_path).expanduser().resolve() + if path.exists() and path.stat().st_size > 0: + raise SystemExit( + f"Refusing to overwrite the existing database at {path}. Choose a new demo path." + ) + for suffix in ("-wal", "-shm"): + companion = Path(str(path) + suffix) + if companion.exists(): + raise SystemExit(f"Refusing to use {path}: the companion file {companion} exists.") + path.parent.mkdir(parents=True, exist_ok=True) -def create_demo_database( - path: str | None = None, *, force: bool = False -) -> tuple[Path, list[dict], dict]: - """Create a seeded demo database and return its path and sample recall.""" - db_path = _demo_path(path, force=force) - # Set the path before importing Engraphis so the dashboard's cached Settings - # snapshot resolves to the demo file, not the operator's real database. - os.environ["ENGRAPHIS_DB_PATH"] = str(db_path) - # The demo database is intentionally empty until it is seeded. Suppress the - # unrelated "your real database has memories" diagnostic during creation. + previous_demo = os.environ.get("ENGRAPHIS_DEMO") + previous_embed = os.environ.get("ENGRAPHIS_EMBED_MODEL") os.environ["ENGRAPHIS_DEMO"] = "1" + os.environ["ENGRAPHIS_EMBED_MODEL"] = "" - from engraphis.service import MemoryService - - service = MemoryService.create( - str(db_path), - extractor="none", - graph_extractor="regex", - retention_supervisor="none", - ) + service = None try: + from engraphis.service import MemoryService + + service = MemoryService.create( + str(path), + embed_model="", + extractor="none", + graph_extractor="regex", + retention_supervisor="none", + ) results = seed_demo(service) sample = service.recall( "What token format does the checkout API use?", @@ -200,8 +182,17 @@ def create_demo_database( k=3, ) finally: - service.close() - return db_path, results, sample + if service is not None: + service.close() + if previous_demo is None: + os.environ.pop("ENGRAPHIS_DEMO", None) + else: + os.environ["ENGRAPHIS_DEMO"] = previous_demo + if previous_embed is None: + os.environ.pop("ENGRAPHIS_EMBED_MODEL", None) + else: + os.environ["ENGRAPHIS_EMBED_MODEL"] = previous_embed + return results, sample def main(argv=None) -> None: @@ -220,45 +211,49 @@ def main(argv=None) -> None: help="Bind port (default: $PORT, else $ENGRAPHIS_PORT, else 8700).", ) ap.add_argument("--no-open", action="store_true", help="Do not open the browser.") - ap.add_argument( - "--db", - default="", - help="Use a specific demo database path instead of a temporary directory.", - ) - ap.add_argument( - "--force", - action="store_true", - help="Replace an existing database selected with --db.", - ) args = ap.parse_args(argv) - db_path, results, sample = create_demo_database(args.db or None, force=args.force) + if not _port_is_available(args.host, args.port): + ap.exit( + 1, + f"Port {args.port} is already in use. Choose another with --port; " + "the demo never reuses an existing dashboard.\n", + ) - # Keep the dashboard's Settings snapshot pinned to the demo file even if a - # caller imported Engraphis before this function ran. - try: + with tempfile.TemporaryDirectory(prefix="engraphis-demo-") as demo_dir: + db_path = Path(demo_dir) / "demo.db" + print( + "Creating a disposable demo database. Your existing Engraphis database is not modified." + ) + results, sample = create_demo_database(db_path) + + os.environ["ENGRAPHIS_DB_PATH"] = str(db_path) + os.environ["ENGRAPHIS_EMBED_MODEL"] = "" from engraphis import config as _config _config.settings.db_path = str(db_path) - except Exception: - pass + _config.settings.embed_model = "" + if Path(_config.settings.db_path).resolve() != db_path: + raise SystemExit( + "Refusing to start: the dashboard settings did not bind to the demo database." + ) - superseded = sum(1 for result in results if result.get("op") == "invalidate") - print("Engraphis demo workspace") - print(f" Database : {db_path}") - print(f" Memories : {len(results)}") - print(f" Superseded : {superseded}") - print(" Sample recall:") - for line in str(sample.get("context") or "").splitlines(): - print(f" {line}") - print() + superseded = sum(len(result.get("superseded") or []) for result in results) + print("Engraphis demo workspace") + print(f" Database : {db_path}") + print(f" Memories : {len(results)}") + print(f" Superseded : {superseded}") + print(" Sample recall:") + for line in str(sample.get("context") or "").splitlines(): + print(f" {line}") + print() - from scripts.start_dashboard import main as dashboard_main + from scripts.start_dashboard import main as dashboard_main - forwarded = ["--host", args.host, "--port", str(args.port)] - if args.no_open: - forwarded.append("--no-open") - dashboard_main(forwarded) + forwarded = ["--host", args.host, "--port", str(args.port)] + if args.no_open: + forwarded.append("--no-open") + dashboard_main(forwarded) if __name__ == "__main__": diff --git a/tests/test_demo_seed.py b/tests/test_demo_seed.py index 25c168b70..eb1e29be0 100644 --- a/tests/test_demo_seed.py +++ b/tests/test_demo_seed.py @@ -1,14 +1,20 @@ -"""The demo seed must show a live supersession and a cited recall.""" +"""The demo seed must show a live supersession, a cited recall, and safe paths.""" from __future__ import annotations +import os +from pathlib import Path + +import pytest + from engraphis.service import MemoryService -from scripts.demo import DEMO_FACTS, seed_demo +from scripts.demo import DEMO_FACTS, create_demo_database, main, seed_demo def test_seed_demo_supersedes_the_old_auth_fact_and_recalls_the_new_one(): service = MemoryService.create( ":memory:", + embed_model="", extractor="none", graph_extractor="regex", retention_supervisor="none", @@ -17,9 +23,17 @@ def test_seed_demo_supersedes_the_old_auth_fact_and_recalls_the_new_one(): results = seed_demo(service) assert len(results) == len(DEMO_FACTS) - invalidations = [result for result in results if result.get("op") == "invalidate"] - assert len(invalidations) == 1 - assert results[0]["id"] in invalidations[0]["superseded"] + old = next( + result + for result, fact in zip(results, DEMO_FACTS) + if fact["content"] == "Checkout API uses JWT access tokens." + ) + new = next( + result + for result, fact in zip(results, DEMO_FACTS) + if fact["content"] == "Checkout API uses PASETO access tokens." + ) + assert old["id"] in new["superseded"] recall = service.recall( "What token format does the checkout API use?", @@ -42,6 +56,53 @@ def test_seed_demo_supersedes_the_old_auth_fact_and_recalls_the_new_one(): service.close() +def test_create_demo_database_seeds_a_file_backed_store(tmp_path): + db_path = tmp_path / "demo.db" + results, sample = create_demo_database(db_path) + + assert db_path.is_file() + assert len(results) == len(DEMO_FACTS) + assert sum(len(result.get("superseded") or []) for result in results) == 1 + assert "PASETO" in sample["context"] + + +def test_create_demo_database_refuses_an_existing_database(tmp_path): + db_path = tmp_path / "demo.db" + db_path.write_bytes(b"existing database") + + with pytest.raises(SystemExit, match="Refusing to overwrite"): + create_demo_database(db_path) + + +def test_create_demo_database_refuses_a_wal_companion(tmp_path): + db_path = tmp_path / "demo.db" + Path(str(db_path) + "-wal").write_bytes(b"wal") + + with pytest.raises(SystemExit, match="companion"): + create_demo_database(db_path) + + +def test_create_demo_database_restores_environment(tmp_path, monkeypatch): + monkeypatch.setenv("ENGRAPHIS_DEMO", "0") + monkeypatch.setenv("ENGRAPHIS_EMBED_MODEL", "keep-me") + + create_demo_database(tmp_path / "demo.db") + + assert os.environ["ENGRAPHIS_DEMO"] == "0" + assert os.environ["ENGRAPHIS_EMBED_MODEL"] == "keep-me" + + +def test_main_help_exits_zero_without_creating_a_database(monkeypatch): + for name in ("ENGRAPHIS_DEMO", "ENGRAPHIS_EMBED_MODEL", "ENGRAPHIS_DB_PATH"): + monkeypatch.delenv(name, raising=False) + + with pytest.raises(SystemExit) as exc: + main(["--help"]) + + assert exc.value.code == 0 + assert "ENGRAPHIS_DB_PATH" not in os.environ + + def test_demo_facts_stay_in_the_two_demo_workspaces(): workspaces = {fact["workspace"] for fact in DEMO_FACTS} repos = {fact["repo"] for fact in DEMO_FACTS} From b44cb6cc672dd2ef57cd62eddd2d7017af75c6db Mon Sep 17 00:00:00 2001 From: Coding-Dev-Tools <274649000+Coding-Dev-Tools@users.noreply.github.com> Date: Mon, 9 Nov 2026 00:08:47 -0500 Subject: [PATCH 4/5] fix: keep the demo out of benchmark-pinned service code The demo no longer changes engraphis/service.py, so the benchmark evidence hash stays valid. It explains the empty-database warning instead of suppressing it, and still restores the offline embedder environment after seeding. --- engraphis/service.py | 3 --- scripts/demo.py | 14 +++++--------- tests/test_demo_seed.py | 4 +--- 3 files changed, 6 insertions(+), 15 deletions(-) diff --git a/engraphis/service.py b/engraphis/service.py index 3c32f81de..967a5cbbf 100644 --- a/engraphis/service.py +++ b/engraphis/service.py @@ -1176,9 +1176,6 @@ def _title_from_content(content: str, fallback: str) -> str: def _warn_if_db_empty_with_populated_sibling(db_path: str) -> None: """Warn, without blocking startup, when a sibling database holds the data.""" - # The demo command creates an intentionally empty database before seeding it. - if os.environ.get("ENGRAPHIS_DEMO", "").strip().lower() in {"1", "true", "yes"}: - return import sqlite3 configured = Path(db_path) diff --git a/scripts/demo.py b/scripts/demo.py index a19b44260..2b4cb2101 100644 --- a/scripts/demo.py +++ b/scripts/demo.py @@ -5,9 +5,8 @@ engraphis-demo --no-open --port 9000 The demo uses a fresh SQLite database inside a temporary directory. It never -reads or writes the database selected by ENGRAPHIS_DB_PATH. ENGRAPHIS_DEMO=1 -is set only while the seed service is created, to suppress the unrelated -"your real database has memories" diagnostic. +reads or writes the database selected by ENGRAPHIS_DB_PATH. The offline +embedder is used for both the seed and the dashboard. """ from __future__ import annotations @@ -158,9 +157,7 @@ def create_demo_database(db_path: str | Path) -> tuple[list[dict], dict]: raise SystemExit(f"Refusing to use {path}: the companion file {companion} exists.") path.parent.mkdir(parents=True, exist_ok=True) - previous_demo = os.environ.get("ENGRAPHIS_DEMO") previous_embed = os.environ.get("ENGRAPHIS_EMBED_MODEL") - os.environ["ENGRAPHIS_DEMO"] = "1" os.environ["ENGRAPHIS_EMBED_MODEL"] = "" service = None @@ -184,10 +181,6 @@ def create_demo_database(db_path: str | Path) -> tuple[list[dict], dict]: finally: if service is not None: service.close() - if previous_demo is None: - os.environ.pop("ENGRAPHIS_DEMO", None) - else: - os.environ["ENGRAPHIS_DEMO"] = previous_demo if previous_embed is None: os.environ.pop("ENGRAPHIS_EMBED_MODEL", None) else: @@ -225,6 +218,9 @@ def main(argv=None) -> None: print( "Creating a disposable demo database. Your existing Engraphis database is not modified." ) + print( + "If an empty-database warning appears, it refers to the new demo file, not your real database." + ) results, sample = create_demo_database(db_path) os.environ["ENGRAPHIS_DB_PATH"] = str(db_path) diff --git a/tests/test_demo_seed.py b/tests/test_demo_seed.py index eb1e29be0..d76b299e0 100644 --- a/tests/test_demo_seed.py +++ b/tests/test_demo_seed.py @@ -83,17 +83,15 @@ def test_create_demo_database_refuses_a_wal_companion(tmp_path): def test_create_demo_database_restores_environment(tmp_path, monkeypatch): - monkeypatch.setenv("ENGRAPHIS_DEMO", "0") monkeypatch.setenv("ENGRAPHIS_EMBED_MODEL", "keep-me") create_demo_database(tmp_path / "demo.db") - assert os.environ["ENGRAPHIS_DEMO"] == "0" assert os.environ["ENGRAPHIS_EMBED_MODEL"] == "keep-me" def test_main_help_exits_zero_without_creating_a_database(monkeypatch): - for name in ("ENGRAPHIS_DEMO", "ENGRAPHIS_EMBED_MODEL", "ENGRAPHIS_DB_PATH"): + for name in ("ENGRAPHIS_EMBED_MODEL", "ENGRAPHIS_DB_PATH"): monkeypatch.delenv(name, raising=False) with pytest.raises(SystemExit) as exc: From ff96321b660781e1792a5b793410bb9dfde3208b Mon Sep 17 00:00:00 2001 From: Coding-Dev-Tools <274649000+Coding-Dev-Tools@users.noreply.github.com> Date: Mon, 9 Nov 2026 03:26:02 -0500 Subject: [PATCH 5/5] refactor: ship the demo as a module, defer the entry point pyproject.toml is a protected packaging path in the merge gate. Keep the seeded demo runnable as python -m scripts.demo and move the console entry point to a separate packaging change. --- pyproject.toml | 1 - scripts/demo.py | 5 +++-- scripts/smoke_entry_points.py | 1 - 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 272c5456b..e23dd269a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -219,7 +219,6 @@ engraphis-mcp-classic = "engraphis.mcp_classic_cli:main" engraphis-mcp-http = "engraphis.mcp_http_cli:main" engraphis-inspector = "scripts.inspector:main" engraphis-dashboard = "scripts.start_dashboard:main" -engraphis-demo = "scripts.demo:main" engraphis-consolidate = "scripts.consolidate:main" engraphis-graph = "scripts.graph_cli:main" engraphis-graph-server = "scripts.graph_server:main" diff --git a/scripts/demo.py b/scripts/demo.py index 2b4cb2101..3b4e87815 100644 --- a/scripts/demo.py +++ b/scripts/demo.py @@ -1,9 +1,10 @@ #!/usr/bin/env python3 """Seed a disposable demo workspace and launch the local dashboard. - engraphis-demo - engraphis-demo --no-open --port 9000 + python -m scripts.demo + python -m scripts.demo --no-open --port 9000 +The engraphis-demo console entry point is a separate packaging change. The demo uses a fresh SQLite database inside a temporary directory. It never reads or writes the database selected by ENGRAPHIS_DB_PATH. The offline embedder is used for both the seed and the dashboard. diff --git a/scripts/smoke_entry_points.py b/scripts/smoke_entry_points.py index 8b5f930ac..c12c6b8cf 100644 --- a/scripts/smoke_entry_points.py +++ b/scripts/smoke_entry_points.py @@ -42,7 +42,6 @@ "engraphis-mcp-http": "engraphis.mcp_http_cli:main", "engraphis-inspector": "scripts.inspector:main", "engraphis-dashboard": "scripts.start_dashboard:main", - "engraphis-demo": "scripts.demo:main", "engraphis-consolidate": "scripts.consolidate:main", "engraphis-graph": "scripts.graph_cli:main", "engraphis-graph-server": "scripts.graph_server:main",