From 1013829623c9cb18d74560217460954d3566c45a Mon Sep 17 00:00:00 2001 From: Jaixii Date: Wed, 5 Aug 2026 18:23:10 -0400 Subject: [PATCH 1/2] chore: normalize version to 1.5 (drop trailing .0) - pyproject.toml, __init__.py, CHANGELOG, README - claude-plugin manifests, commercial_manifest.json - test hardcoded references (savings, update_check, dashboard_v2) - savings.py regex accepts 2-part versions - regenerated skill-assets.sha256 checksums --- .claude-plugin/marketplace.json | 2 +- .claude-plugin/plugin.json | 2 +- .claude-plugin/skill-assets.sha256 | 6 +- CHANGELOG.md | 2406 +++++++++++++------------- README.md | 1454 ++++++++-------- engraphis/__init__.py | 34 +- engraphis/commercial_manifest.json | 188 +- engraphis/core/savings.py | 326 ++-- pyproject.toml | 568 +++--- tests/test_dashboard_v2.py | 2576 ++++++++++++++-------------- tests/test_savings.py | 436 ++--- tests/test_update_check.py | 604 +++---- 12 files changed, 4301 insertions(+), 4301 deletions(-) diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 45e50ae3..fb3ddd2f 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -9,7 +9,7 @@ "name": "engraphis-memory", "source": "./", "description": "Discipline for giving agents durable, scoped, explainable memory across sessions and repos with the Engraphis MCP tools.", - "version": "1.5.0" + "version": "1.5" } ] } diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 557c0612..c5147742 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "engraphis-memory", - "version": "1.5.0", + "version": "1.5", "description": "Give agents durable, scoped, explainable memory across sessions and repos via the Engraphis MCP tools. Use when you learn something worth keeping, need prior context before acting, or ask why/how a fact changed. Covers remember/recall, why/timeline, forget/pin/correct, sessions, and code search.", "author": { "name": "The Engraphis Authors", diff --git a/.claude-plugin/skill-assets.sha256 b/.claude-plugin/skill-assets.sha256 index 178a9b71..477bf87a 100644 --- a/.claude-plugin/skill-assets.sha256 +++ b/.claude-plugin/skill-assets.sha256 @@ -1,6 +1,6 @@ -5d315146fd0bdcd5bb803bab482504c51e6c8e94d9666cd4910ca8f4a645b564 .claude-plugin/marketplace.json -a51eb5baab17efb66193759be7f68df32451594b799476e7ec6e3b076b7fdff5 .claude-plugin/plugin.json -56be8d078a2a8fc6e6cd1c2be5716605d8621dab953caa8cfcd20e2dce474305 skills/engraphis-memory/SKILL.md +c9e7c8dd1cf66b8a55b259a2fd21128d71994b9608816b0c540252a36acf5e38 .claude-plugin/marketplace.json +077bc215aa8790e3d6dc2d08ea9fa03ac70cf58ed4afd5f53f0d969254f6fc2e .claude-plugin/plugin.json 45dd73ca6afdd9e12ecd38c48e4a612b7646c25a07a75a80ca0e68d0e0b85f0e skills/engraphis-memory/references/CONVENTIONS.md 529fff3bdbe73f83209087fd10055fad77c5e5224ad8a9e6b0254052aa50e109 skills/engraphis-memory/references/SCOPING.md b2489b60159655e7e564e234d5aff24ba4d8df7cb82626edeaaaf89264007f85 skills/engraphis-memory/references/TOOLS.md +56be8d078a2a8fc6e6cd1c2be5716605d8621dab953caa8cfcd20e2dce474305 skills/engraphis-memory/SKILL.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 17598d7e..239a83d9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,1203 +1,1203 @@ -# Changelog - -All notable changes to Engraphis are documented here. Format loosely follows -[Keep a Changelog](https://keepachangelog.com/); versions use SemVer. - -## [Unreleased] - -## [1.5.0] - 2026-08-04 - -Minor release advancing the v2 engine to schema 11 with governed recall recovery, -embedding-space safety, reproducible release evidence, and stronger offline memory-quality gates. - -### Security - -- Add opt-in immutable Hugging Face model provenance enforcement for remote embedding models, - rerankers, and chunk tokenizers, with revision plumbing across v2 services and local front ends; - model loaders now explicitly disable remote code execution while local paths remain supported. -- Refuse redirects in loopback startup-health and PyPI metadata probes, and treat shortcut icon - paths as data across PowerShell, macOS shells, and Linux desktop files. -- Add an offline release gate proving that quarantined, review-pending, and caller-self-approved - external content is downgraded and stays outside prompt recall, including direct poisoned - edges and pending-memory-supported edges, while trusted graph evidence remains available. -- Reject control characters in hosted access and refresh credentials, including credentials - returned during rotation, before any network or persistent-state use. -- Restrict the Inspector API to loopback clients when no API token is configured, and exclude - pending or quarantined memories from managed-cloud snapshots. -- Harden update checks with bounded, link-safe cache reads, atomic private cache writes, strict - version limits, finite timestamps, and validated HTTPS or loopback-HTTP URLs. -- Route private credential and state-file reads through one bounded, race-resistant boundary that - rejects links, reparse points, non-regular files, invalid UTF-8, and oversized input. -- Raise the optional `cryptography` floor to 50.0.0 to exclude known vulnerable releases. -- Require the patched pytest line in supported release environments and give every CI pytest - invocation a private runner-owned temporary root, including the Python 3.9 compatibility lane. - -### Fixed - -- In schema 11, migrate pre-review trusted memories to explicit approval without releasing quarantined or - ambiguous evidence; recover the exact historical local-agent service-gate downgrade and expose - content-free eligibility diagnostics when review gating causes zero-result recall. -- Replace per-backend vector version checks with one active embedding-space fingerprint, make - Sentence Transformer/API spaces durable, rebuild on every space transition (including - A -> B -> A), and disable vector recall throughout interrupted or mixed-space rebuilds. -- Describe the stable sqlite-vec backend accurately as native exact KNN, add a dedicated - `vector` install extra, require the upstream release containing the vec0 delete fix, - and let server entrypoints select it automatically with a safe NumPy fallback. -- Make contradiction supersession failure-atomic so a failed predecessor invalidation cannot - leave two live facts. -- Bound reinforcement stability and migrate existing out-of-range retention state to schema 10. -- Preserve v1 graph endpoints during migration and publish migrated databases only after a - validated staging database is complete. -- Reject partial API embedding batches instead of persisting zero-vector placeholders; give - semantic embedding spaces durable, secret-free identities; and batch SQLite vector hydration. -- Prevent CLI metadata from overriding trusted local provenance and honor the selected namespace - for grounded chat. -- Keep service replacement atomic when the prior SQLite handle cannot close, and make - authoritative cloud denials fail closed in-process before their durable state writes complete. -- Keep tag publication reachable by defining every workflow-verified release check in the public - evidence manifest, including CodeQL, reproducible distributions, and fresh artifact smokes, and - bind the evidence provenance to the completed code-security job. -- Repair GitHub releases only from the frozen, hash-verified distribution set, excluding any - publisher receipt or other unverified file left in the working distribution directory. -- Exercise both the exact tagged wheel and source distribution in clean Python 3.9 environments, - including dependency resolution, pip check, core CLI startup, and in-memory remember/recall; - declare the CI build and vulnerability-audit tool versions instead of relying on runner images. -- Eliminate duplicate NumPy vector writes and commits after ordinary remembers, embedding rebuilds, - sync application, and title re-embedding. Store-backed indexes opt out only when they share the - exact canonical Store; separately-backed and injected indexes retain explicit synchronization. -- Replace row-by-row NumPy scan hydration with one filtered, fixed-width matrix read while - preserving temporal/scope filters, malformed-dimension isolation, deterministic ties, and - immediate visibility of newly written vectors. -- Surface best-effort graph, entity-linking, evolution, conflict-repair, and index-audit failures as - per-engine rate-limited, payload-redacted warnings instead of silently suppressing operational - faults. -- Honor the configured embedding dimension, vector backend, model revisions, reranker, and encrypted - connection path consistently across every v2 front end and the sync/consolidation CLIs, preventing - an operational command from accidentally rebuilding a persisted semantic space with defaults. -- Commit standalone entity links without closing a caller-owned transaction, and make the Windows - shortcut installer retain its redacted Desktop launcher fallback when PowerShell is unavailable. -- Serialize and make Store shutdown idempotent, add context-manager and weakref-finalizer cleanup, - and keep the offline suite from loading production embedding/reranker models merely because a - developer has optional semantic dependencies installed. - -### Added - -- Extend `eval.vector_scale` with input-identical NumPy/sqlite-vec exact-KNN comparisons, - explicit backend identity, deterministic result hashes, and setup-excluded latency envelopes. -- Add `engraphis-cli review list|approve` for content-free, scoped bulk review. Approval is - dry-run by default, requires a reason and one batch confirmation, excludes quarantined records, - and supports explicit ids, source/repo filters, and the legacy-agent signature. -- Add embedding coverage and prompt-eligibility health to service stats, stamp service ingress and - writer-policy provenance, and document recall recovery without direct database surgery. -- Add deterministic reinforcement and adversarial-memory release gates plus a hash-bound LoCoMo - evidence-repair manifest and complete pinned-dataset retrieval diagnostics. -- Pin the Pyright contract for core, backends, and external evaluation; require it in CI and release - evidence; verify distribution contents; generate a reproducible CycloneDX SBOM; byte-compare - normalized repeat builds; smoke fresh wheel/sdist installs; and bind complete-tree CodeQL to the - tag gate. -- Smoke all 14 installed console entrypoints from their distribution metadata and generated wrapper - paths for both wheel and source-distribution installs, with bounded timeouts and diagnostics. -- Add opt-in semantic-confidence calibration for retrieval-arm experiments while preserving the - existing default ranking until paired external non-inferiority evidence is available. - -## [1.4.5] - 2026-08-04 - -Patch release aligning the package, runtime, commercial manifest, and plugin metadata at 1.4.5 -for the governed recall/write hardening, schema 8 migration, Smart MCP gateway fixes, and -credential-safe evaluation capture included in PR #111. -Schema 9 adds repository-scoped tombstone support and performs a one-time entity-canonicalization -repair; `confidence` and `pinned_at`/`unpinned_at` were introduced by the preceding v7-to-v8 -migration. Known-repository tombstones are terminal only within that repository, while legacy -repo-less tombstones remain global. - -## [1.4.0] - 2026-08-02 - -Engraphis 1.4 makes the compact Smart MCP gateway the default agent interface while preserving -the complete Classic surface for existing integrations. It also strengthens external-write -governance, -bounded context delivery, secure erasure, and release/runtime hardening, and moves the v2 SQLite -schema to version 9 (schema-level additions include repository-scoped `memory_tombstones`; the -upgrade also performs a one-time entity-canonicalization repair), which migrates automatically on -first open. Known-repository tombstones are terminal only within that repository; legacy repo-less -tombstones remain global. - -### Upgrade notes - -- `engraphis-mcp` now exposes nine Smart tools instead of 33 direct tools. Clients that depend on - the former names should switch their server command to `engraphis-mcp-classic`; HTTP clients can - use `engraphis-mcp-http --classic`. -- Existing v2 databases migrate automatically to schema 9 on first open; the change is additive - and requires no manual step. -- The NumPy-only core supports Python 3.9+. Dashboard, MCP, documents, Cloud Sync, and `all` - installations require Python 3.10+ because their supported dependency versions require it. - -### Added - -- Smart MCP is now the zero-configuration `engraphis-mcp` default. It exposes nine compact tools: - sessions, prompt-ready recall, durable memory, discovery, validated read/action execution, and - governed record read/update plus conflict review. `engraphis-mcp-classic` preserves the former 33 - direct tool names and legacy alias response shapes for pinned integrations. -- The first-party `@engraphis/pi` package under `integrations/pi` exposes that Smart MCP surface - as native Pi tools, verifies the Engraphis 1.4.x handshake, and ships with independent npm - packaging and release gates. -- Hosts that retain their own conversation history can call the non-MCP - `POST /api/adaptive-context` endpoint. Advanced proactive context also supports a bounded, - content-lean compact response while Classic keeps its full response by default. -- Opt-in planned recall adds a bounded deterministic planner, an injectable planner protocol and - optional LLM backend, priority-weighted multi-query RRF, post-rerank memory-type maxima, stable - context revisions, and diagnostics-only planner traces across Python, service, REST, and MCP - recall surfaces. The default remains the existing single-query path (now on schema 9). -- A 40-task context-routing stress fixture, four-way five-budget ablation harness, pinned - LongMemEval-V2 planner configurations, and evaluation-only imported-resource hierarchy prototype - encode local regression gates and matrix tooling. Official benchmark, safety, and hosted-cache - artifacts remain mandatory before any default or schema change. - -### Security - -- The Pi extension preserves the Smart gateway's destructive boundary: every discovered - state-changing action requires an explicit Pi confirmation, fails closed without a dialog, - and consumes its capability after one approval attempt so unknown outcomes are not retried. -- Public writes now enter an explicit review gate: MCP, REST/dashboard-intent, import, sync, and - extractor ingress are pending regardless of a caller-supplied trust label; detector matches are - quarantined before they can contribute to prompt context or derived state. Human approval creates - a fresh audited successor only through the CSRF-bound dashboard action or an interactive TTY - command, never through MCP or a general REST endpoint. Historical rescans demote non-approved - records and retire their derived bridges. Public history, graph/code retrieval and indexing, and - consolidation apply prompt eligibility before ranking or capacity decisions, so pending or - quarantined records cannot influence prompt-visible results through derived bridges. -- Smart MCP authorization now fails closed: discovery and read execution require viewer access, - state-changing execution requires admin access remotely, and pure reads do not emit write-side - telemetry receipts. Executor output is bounded without retrying or double-running handlers. -- Tokenless remote requests to the read-only recall and repository-graph API now fail closed; - health and OpenAPI discovery remain public. -- The deterministic detector now uses a pinned Unicode TR39 15.1.0 ASCII projection rather than - a short hand-picked table, covering additional Latin, Cyrillic, Greek, mathematical, and legacy - glyph substitutions without an online lookup or runtime dependency. -- Secret scanning is cycle-safe and depth-bounded, and PostgreSQL source identities are reduced to - credential-free digests for both URI and libpq keyword DSNs. - -### Fixed - -- Secure erase now rebuilds shared-edge provenance from surviving support rows. Historical-only - support remains available to time-travel reads while the edge is closed in the current graph. -- API embedding backends now validate dimensions, response cardinality, item indices, finite - values, and normalization before accepting provider output, with consistent bounded fallback. -- Planned-recall datasets reject dangling references, vector dimensions are bounded across local - and SQLite backends, and sync imports accept pinned state only when it is the literal boolean - `true`. -- The production image now removes build-only pip and its vendored dependency snapshot after - installation, eliminating unreachable vulnerable packages from the runtime attack surface. -- Automatic LLM retention supervision now discards proposed retention values when it - demotes an unapproved `critical` label; legacy poisoning rescans also honor - `--keep-unlabelled`, and code-memory exports apply eligibility before their result cap. -- Scope promotion now preserves an owner-approved detector match and its stable claim identity - without re-quarantining the approved derived copy. -- `engraphis connect` now treats its printed summary as a provider trust boundary: only bounded, - printable registration metadata is rendered, preventing malformed control-plane values from - being reflected into CLI or JSON output. -- Explicit local `engraphis-cli ingest` commands now record local-owner-approved provenance, - allowing their memories to appear in ordinary subsequent CLI recall. HTTP, MCP, import, and - file-ingestion boundaries remain pending review. -- The standalone v1→v2 migrator now refuses in-place and pre-existing output paths before - opening either database, preventing accidental mixing of legacy source history into a v2 target. -- Cloud Sync now closes failed HTTP response streams without reading their untrusted error bodies, - preventing descriptor leaks during repeated relay failures. -- Hosted customer clients now bind provider credential/session state before persistence and - preserve sanitized authorization/billing outcomes when an HTTP error body is truncated, so a - one-time connection cannot be stranded by an unreadable state file or retain stale paid badges. -- Authoritative hosted managed-compute authorization denials now immediately settle local - entitlement presentation state, so a revoked, lapsed, or de-authorized account is not shown - stale paid feature access while awaiting a background refresh. -- The production image health probe now follows the active IPv4 or IPv6 loopback listener, - preventing a Railway IPv6 deployment from being marked unhealthy while its readiness route - is serving traffic. -- Grounded recall's absolute support floor ignores titles and non-finite semantic scores, so - display text cannot independently make an answer eligible. -- Keyed-claim deduplication ignores harmless punctuation, and legacy zero, negative, or non-finite - stability values use the documented one-day default instead of producing invalid decay scores. -- Approval requires a non-empty audit reason, accepts only a live pending source, and preserves the - reviewed claim's pin, sensitivity, and keyed identity on its approved successor. -- The zero-config Compose quickstart remains loopback-only; a LAN deployment is an explicit, - token-protected operator choice and cannot inherit the local Docker bridge trust exception. -- Credential-shaped values are rejected before capture can create memory, FTS, vector, event, or - sync copies. `retire` is the canonical temporal lifecycle operation; targeted `secure_erase` - removes an already-leaked record and known local derivatives while reporting physical limits. -- The standalone MCP-over-HTTP launcher is explicitly loopback-only. Remote MCP clients must use - the dashboard's authenticated `/mcp` endpoint instead of an unauthenticated FastMCP bind. - -### Changed - -- MCP-over-HTTP has a packaged `engraphis-mcp-http` command and a generic local setup guide. The - project makes no client-specific integration claim without a maintained guide and integration - test. -- `.env.example` now mirrors runtime defaults for decay, context packing, loop cadence, and recall - depth so copied configurations do not silently override the documented behavior. - -## [1.3.0] - 2026-08-01 - -### Added - -- The optional `hosted-eval` extra adds guarded hosted-Luna productivity evaluation with a - redacted public evidence exporter. -- Protected public benchmark workflows now support redacted hosted and retrieval evidence runs. - -### Security - -- Untrusted ingress now fails closed: provenance and extractor metadata are allowlisted, suspicious - records are quarantined before embedding, linking, graph extraction, resolution, recall, or - grounding, and `scripts/rescan_poisoning.py` can retroactively label or quarantine old records. -- Trust is preserved across resolution, structured graph writes, consolidation, entity profiles, - and review paths. Untrusted records cannot mutate or promote trusted memory, and derived outputs - remain trusted only when every source is explicitly trusted. - -### Documentation - -- README and release guidance now match the current install extras, public entry points, product - boundaries, and focused MCP/provider documentation. - -### Fixed - -- Public server entry points now share the v2 service, keeping recall behavior consistent across - the dashboard, server, Compose, Classic, and MCP-over-HTTP. -- Keyed mutable-fact replacements now load their live predecessor directly, so reworded updates - preserve history without relying on vector top-K recall. -- Versioned deterministic embeddings now rebuild persisted vectors after a mapping change, keeping - existing databases searchable after an upgrade. -- Prompt-facing recall now widens candidate search when untrusted results crowd out trusted - evidence, while keeping expansion bounded. Title text now contributes to absolute support floors - for grounded and hosted recall. -- Hosted productivity evaluation now scores canonical, acceptable, or supporting-evidence answers - with strict natural-language framing instead of token containment or raw JSON text. -- Hosted-Luna workers on Windows now establish kill-on-close containment before sending input; a - failure refuses the request, and timeouts clean up the full worker tree. -- Poisoning rescans preserve existing temporal validity boundaries and invalidate affected edges - without overwriting governed history. - -### Changed - -- CI and release/install metadata now cover Python 3.13 and 3.14. - -## [1.2.5] - 2026-07-31 - -### Added - -- `engraphis_context_savings` aggregates validated, content-free recall receipts by workspace, - repo, operation, and token-counter identity. The view is available through the service, - dashboard, and read-only APIs. -- Recall supports an explicit adaptive candidate-depth experiment while retaining the historical - fixed depth by default. Performance reports record requested and actual candidate depths. -- `MemoryEngine` and `MemoryService` now provide adaptive context routing: bypass retrieval when - prompt history fits, use compact recall when support is strong, and fall back to bounded recent - history when support is weak. -- `eval.productivity` measures task completion, corrections, agent turns, memory calls, latency, - and model-facing tokens. -- Chunk ingestion can enforce budgets with a configured Hugging Face tokenizer and records the - counter identity, target, and overlap in chunk metadata. -- Offline adapters now cover MemoryAgentBench, LoCoMo-Plus, and Mem2ActBench, with a paired - full-history versus Engraphis code-agent analyzer. -- Public benchmark evidence can carry source hashes, repository state, environment and model - provenance, secret-redacted commands and URLs, content digests, and adjacent immutable SHA-256 - files. - -### Changed - -- Context-economy evaluation now compares full history, a same-budget recency window, and hybrid - recall while accounting for indexing cost. -- Official LongMemEval-V2 output has a dedicated redacted evidence exporter that retains the - official QA, token, and latency measures without publishing prompts, answers, model output, or - retrieved context. -- Folder-sync dry runs no longer create a remote directory or persist a local device identity. - -### Fixed - -- Sync rejects malformed scope/repo combinations and every peer-driven visibility change for an - existing memory, including malformed legacy rows. Scope promotion or repair remains a local, - explicit governance operation. -- Workspace consolidation excludes session-private memories and partitions digests and entity - profiles by their exact visibility owner, preventing cross-repo or cross-scope summaries. -- Tokenizer-aware chunk overlap can no longer exceed the configured prose budget or emit a - duplicate overlap-only record before an oversized paragraph. Invalid token counters fail - closed instead of silently producing mis-sized chunks. -- Ledger graph interactions preserve manually selected nodes during refreshes. -- The new evidence guide is included in wheel and source distributions. - -## [1.2.2] - 2026-07-30 - -### Fixed - -- Cloud Sync now continues past legacy plaintext, malformed, and tampered relay objects while - still failing closed for each object. Later authenticated peer bundles apply, and the affected - sync round is explicitly reported as incomplete rather than successful. -- Security and sync documentation now consistently distinguish end-to-end encrypted Cloud Sync - from the separately readable managed-compute snapshot service. -- README visual PNG exports now use their SVG canvas dimensions without hidden screenshot padding. - -## [1.2.1] - 2026-07-30 - -### Security - -- Cloud Sync now encrypts every eligible shared-workspace bundle on the client with - ChaCha20-Poly1305 before upload. The relay receives opaque deterministic bundle names and - ciphertext only; tampered, renamed, cross-workspace, wrong-key, and legacy plaintext bundles - are rejected before the merge engine. -- Cloud Sync requires a client-held 32-byte workspace key and the `cloud-sync` optional runtime. - Missing or malformed encryption configuration stops sync rather than falling back to plaintext. - -### Changed - -- Cloud Sync privacy copy now states that eligible shared-workspace changes are encrypted - end-to-end before leaving the device and cannot be read by Engraphis Cloud. Product and - security documentation separately identifies managed compute as the readable, bounded-snapshot - service it is. - -## [1.2.0] - 2026-07-30 - -### Added - -- `engraphis_recall_context` brings the MCP surface to 30 tools and is the compact, hard-budget - path for agent prompts. It returns packed context, compact source identities, strict token usage - fields, optional retrieval diagnostics, and preserves `engraphis_recall` as the full-response - compatibility surface. -- Recall and grounded recall now expose `valid_at` (world time) and `known_at` (system time); - `as_of` remains the compatible `valid_at` alias and conflicting anchors are rejected. Retrieval - defaults to the `balanced` profile; `auto` remains explicit opt-in. -- MCP and HTTP remember calls can set a fact's world-time `valid_from`; recall, grounded recall, - and the compatibility answer tool can run a point-in-time `as_of` query. -- `eval.performance` reports full recall-pipeline quality, packed context tokens, and - p50/p95/p99 latency with a reproducible JSON schema and deterministic corpus scaling. -- Schema v5 adds temporal history for symbols, code edges, code-memory links, and persisted - memory-entity incidence. Code retrieval is now a first-class profile, and graph walks use - bounded sparse PageRank instead of a dense quadratic transition matrix. -- Optional `subject_key` and `claim_kind` make mutable claims explicit. Uncertain similar facts - are conservatively related while keyed or strongly evidenced contradictions supersede. -- `engraphis-benchmark/v2`, canonical workspace exports, and release-evidence manifests provide - deterministic hashes, per-question records, fixed token-budget curves, and validation before - public evidence is written. - -### Fixed - -- Supersessions now close the old fact at the replacement's effective world time instead of its - ingestion time. Superseded, corrected, promoted, merged, forgotten, and consolidated source - vectors remain available to historical semantic recall while temporal filters keep them out of - the current view. -- Non-finite write and recall timestamps fail validation instead of entering scoring or SQLite. -- Ordinary recall is observational by default, so weak nearest-neighbor results do not gain - stability merely by being returned. Grounded recall still reinforces only cited evidence, and - Python callers with an explicit use signal can request reinforcement. -- Code and PPR retrieval now restrict incident-symbol and memory-entity lookups to the reachable - frontier before applying their safety caps, and repo writes link text mentions to visible - workspace-level entities. - -## [1.1.5] - 2026-07-28 - -### Changed - -- Simplified the Ledger and Classic graph controls by removing the complete-graph action. -- Replaced the README Knowledge Graph image with the corrected Ledger screenshot. - -### Fixed - -- Ledger now has one working `Show unlinked nodes` control that reloads the intended bounded - graph view. -- Time-travel graph views prioritize support visible at the selected anchor, and graph drag - handling remains safe when browser animation-frame globals are unavailable. - -## [1.1.2] - 2026-07-27 - -### Added - -- **The complete Ledger design is now the primary local WebUI**, ported from the final - five-area design package without its sample store or unsafe design runtime. Today, grounded - Ask, Library, the advanced Graph & Relations view, Provenance, and Manage all use live v2 data. - Manage includes workspaces, reviewed local consolidation, hosted Analytics/Automation/Team - status, the full plan comparison, settings, and persisted Slate, Midnight, Paper, and Matrix - themes. -- Ledger now exposes the production grounded-answer route (`POST /api/answer`), returning a - cited answer or an explicit abstention. Graph & Relations ships the supplied graph capabilities: - five layouts, four render styles, palettes, degree/betweenness sizing, bridge detection, - valid-time filtering, superseded ghosts, focus, and automatic cluster collapse. -- The complete former dashboard remains available at `/classic`. Both interfaces expose a - visible dashboard selector and share the same workspaces, memories, receipts, and engine. - -### Changed - -- Ledger defers both the CSP-sensitive renderer and graph payload until Graph & Relations is opened, - ignores stale workspace responses, renders memory text through DOM text nodes, and provides - responsive, reduced-motion-aware keyboard focus styling. Classic loads its lazy graph vendor - dependency from its own packaged backup tree. -- Graph nodes now use oversampled, cached screen-space material rendering with face-level - texture: full-face iridescent PVD for Cyber, directional blue-violet anodizing for Galaxy, - concentric brushed copper for Solar, and horizontal satin gunmetal grain for Classic, with - deterministic low-detail fallbacks for large graphs. -- Dashboard asset URLs now carry the node-material revision and local static responses - revalidate, preventing an already-open browser from pinning the pre-material renderer. -- Pro and Team purchase actions now preserve both the selected plan and billing interval, while - existing or lapsed subscribers are sent to the plan-neutral account portal for billing recovery. - Public documentation now distinguishes hosted-account grace and recovery behavior from the - always-local, Apache-licensed dashboard and MCP write paths. - -### Fixed - -- Token-protected dashboards can now establish a short-lived signed, HttpOnly browser session - without storing the API token in browser storage. Remote peers remain denied when no token is - configured, and non-loopback v1 server startup is refused unless authentication is enabled. -- Hosted entitlement refreshes use bounded exponential backoff, terminal denials settle every - local entitlement view, inactive sessions expose no paid feature flags, and ambiguous - single-use refresh responses permanently retire the possibly spent credential instead of - replaying it. -- Recommended Automation bootstrap is resumable across partial upload/policy-save failures and - authorizes paid work before generating or locking a local snapshot. -- Release checks now enforce commercial prices and trial terms, expose skipped tests instead of - hiding them behind duplicate quiet flags, and verify the full-stack dependency imports used by - the HTTP authorization boundary. - -### Security - -- Credential state directories are owner-only, product token forms are redacted consistently - from logs, checkout overrides fail closed to validated HTTPS or loopback HTTP destinations, and - unsafe control characters can no longer reform blocked browser URL schemes. - -## [1.1.0] - 2026-07-26 - -Public 1.1.0 hosted-connect and graph-experience release. - -### Added - -- **`engraphis connect --token engr_ct_…`**: the missing client half of device connect. - `cloud_session.save_bootstrap()` is the only writer of `~/.engraphis/cloud_session.json`, - and it had no production caller: the docs told paying customers to prefer a file nothing - created, so a purchased installation could not be connected without hand-writing state. - The new command redeems the one-time connect token from the account portal against - `POST /v1/devices/connect`, saves the returned session with owner-only permissions, and - verifies `cloud_session.configured()` before reporting success. The token is sent in the - request body and nowhere else; it is never printed, logged, or written to disk, and every - refusal maps to fixed, actionable copy (an expired or already-used token is not confused - with a lapsed subscription). Session storage is pre-flighted before the exchange, so an - unwritable state directory or a `cloud_session.json` replaced by a link fails the command - *without* spending the single-use token; the customer fixes the path and retries with the - same token instead of returning to the portal for a new one. Faults that can only happen - *after* the exchange: a reply truncated mid-body (`http.client.IncompleteRead`), or an - endpoint that stops resolving before the session is written (`CloudUrlUnresolved`) are - reported as errors that say the token was already used, rather than escaping as tracebacks - that leave the customer unable to tell whether to retry. Also installed as - `engraphis-connect`. -- An `engraphis` front-door command that dispatches to the existing `engraphis-` - entry points, so the command the account portal displays is runnable as shown. -- A stable per-installation identity at `~/.engraphis/client_identity.json` (random ULIDs, - not a hardware fingerprint) so reconnecting a machine updates its existing installation - instead of registering a new device every time. - -### Removed - -- Removed an unimplemented hosted export claim from public product surfaces. - -### Changed - -- Managed compute consent now travels with the cloud account: an installation connected to - Engraphis Cloud is enabled for managed analytics, dreaming, and consolidation **by - default**, because connecting already accepts the terms that cover it. A local-only - installation with no cloud session is still never allowed. - `ENGRAPHIS_MANAGED_COMPUTE_CONSENT` remains as an explicit operator override (`=0` opts a - connected installation back out, `=1` forces it on regardless of session state) and is no - longer surfaced anywhere in the UI. - -## [1.0.1] - 2026-07-24 - -Public 1.0.1 client reliability release. - -### Fixed - -- Cloud Sync now defaults to `https://relay.engraphis.com` and safely migrates the former - dashboard host and retired Railway relay URL without changing customer-provided relay URLs. -- Default Pro and Team upgrade links now target the live authenticated account portal rather - than the retired Team dashboard host. -- Hosted endpoint validation now fails closed unless DNS establishes a globally routable - destination, and credential-bearing HTTPS connections pin the vetted address while preserving - original-host TLS verification to prevent DNS-rebinding SSRF. -- Hosted Automation and maintenance requests now use the selected workspace end to end rather - than silently falling back to the first workspace. -- The Automation tab has one proposal action, clear managed-upload disclosure, and explicit - managed-compute consent in addition to entitlement checks, snapshot redaction, and limits. -- Commercial metadata now describes Pro as one owner account across that owner's local - installations, matching the hosted entitlement model; Team remains billed per named seat. -- API error responses and provider logs no longer expose arbitrary exception or configuration - text; local folder and repository reads resolve and re-check filesystem boundaries. -- Entity extraction and dashboard asset migration avoid adversarial regular-expression - backtracking. CodeQL now disables pull-request diff-informed analysis and CI fails on every - raw SARIF result, including pre-existing and source-suppressed results. -- The documented grounded-recall evaluation prints with the default Windows console encoding. -- Hosted Pro and Team links preserve the selected plan through account creation and Checkout. -- A total `401`/`402`/`403` Cloud Sync authorization loss restores the hosted recovery CTA, - while a successful empty or read-only workspace remains a partial result instead of being - misreported as a total denial. - -## [1.0.0] - 2026-07-23 - -Public 1.0.0 open-core GA release. - -### Added - -- The search-first Galaxy Knowledge Graph explorer with deterministic communities, canonical - evidence-weighted scenes, entity/relation search, temporal filtering, evidence and history - inspection, strongest-evidence paths, synchronized accessible tables, saved scene state, - local PNG/JSON/CSV export, Simple and Advanced views, and a locally bundled ForceGraph + D3 renderer - under the strict same-origin CSP. -- Additive schema-v4 canonical identity and bi-temporal edge-support records; deterministic - graph scene, suggestion, entity, and path APIs; and a persisted graph-index job with dry-run, - progress, cancellation, bounded errors, audit records, and tamper-evident receipts. -- A 29-tool MCP surface with explicit behavior annotations, operation receipts, exact session - retry semantics, portable plugin manifests, and checksummed skill assets. -- Customer-side hosted protocols for scoped Cloud Sync, rotating cloud sessions, Analytics, - and managed Automation requests, plus explicit manual folder exchange for local workflows. - -### Changed - -- The public distribution is a universal Python open-core package that runs only as a customer - node. Hosted authorization, billing, relay storage, managed compute, Team identity, workers, - and vendor operations remain private services. -- Commercial compatibility modules now expose presentation and customer-protocol metadata only; - no environment variable turns the public package into a hosted Engraphis service. -- Session identity is exact across workspace, repo, authenticated user, agent, and goal; callers - can request a distinct run with `force_new=true` and observe retry reuse explicitly. -- The legacy graph view defaults to deterministic community islands, keeps sparse influence - bridges subordinate, and renders bounded A-MEM links when entity extraction is disabled. The - repository screen demo proves session handoff, bi-temporal supersession, recall evidence, and - history without an external service. -- The hosted no-card trial is exactly 3 active days after email confirmation. A separate - `workspace_write_grace` may preserve ordinary local writes for at most 24 hours but never - extends trial or paid cloud access. -- Apache-2.0 rights in published releases remain irrevocable; proprietary hosted value is - enforced by the private implementation and service authorization boundary. - -### Fixed - -- Session start/end and session-scoped writes are atomic under concurrency; exact retries reuse - one session while intentionally separate runs remain distinct. -- Rotating refresh credentials serialize across threads and processes, persist replacements in - owner-only state, close failed HTTP responses, and never regress to a stale bootstrap value. -- Managed snapshots reserve a monotonic generation in the same local write transaction as the - capture, use one operation ID per run and retry, redact provider errors, reject unknown - sensitivity, exclude session and secret data, and enforce exact record/byte limits. -- Graph reads, suggestions, evidence, history, indexing, exports, audit views, fallback search, - and workspace statistics consistently enforce workspace and session boundaries, including - forgotten session-only graph evidence. -- Windows private-state validation uses safe file metadata checks without weakening symlink, - ownership, size, or atomic-publication protections. -- Recall graph seeding uses one boundary-aware compiled pattern instead of rescanning every - memory per entity, and the streamable HTTP launcher warms the singleton service before - accepting clients. -- Graph GET requests remain read-only and return a rebuilding conflict while an explicit - mutating index job is in progress. - -### Security - -- Bare memory IDs, shared-workspace controls, graph entities, statistics, snapshots, exports, - audit rows, and keyword fallbacks cannot cross authenticated session or workspace boundaries. -- Managed uploads require explicit customer consent, are capped at 16 MiB and 100,000 rows, - omit all session-scoped and secret-class memories, and surface only fixed client-safe - provider errors. -- Customer credentials remain owner-only, redirect-safe, serialized during rotation, and are - never substituted with an unproven local machine identifier. - -## [0.9.9] - 2026-07-18 - -Security and reliability release spanning graph isolation and performance, Team / Pro -authentication, licensing and relay behavior, and the redesigned Knowledge Graph. - -### Security - -- Code-graph search, path, impact, export, and unified-graph reads now apply the same - workspace/repo/session hierarchy filter as recall. Session-scoped memory content and - identifiers previously remained reachable through persisted code-memory links from a - repo-level caller. Reindexing still rebuilds those links for the owning session, but - every read now filters them by caller-visible scope. -- Auth-bound dashboard users can no longer omit `workspace` to reach global recall. - Inspector per-user and deployment bearer tokens now bind real or synthetic identities - before personal receipt reads, so the deployment service account remains available for - shared automation without bypassing personal-folder ownership. The standalone - read-only graph endpoint also disables lazy write-on-read backfill. -- Repository indexing now creates a first-time Team workspace through the same - privacy-aware path as remember/import/session writes, instead of silently creating a - shared, unowned folder for the authenticated user. - -### Fixed - -- Code-graph layer responses and filters now use the concrete persisted layer, including - inferred causal relations and explicitly semantic code edges. Code-memory link rebuilds - page through every live repo-associated memory instead of clearing the bridge and - stopping at 5,000, and Git impact parsing uses NUL-delimited paths without rewriting - valid filename characters. -- Graph layer predicates are applied before workspace and code-edge response caps, and an - explicit all-off layer selection remains empty instead of reverting to every layer. - Layout preset and custom link-distance changes also recompute component centers while - preserving the existing graph data and node objects. - Filter reloads also tolerate transient graph-data invalidation, so restoring layers - redraws the canvas instead of leaving the explorer list beside an empty graph. -- Oversized audio/video resources are rejected before transcription begins. A blank - `ENGRAPHIS_GRAPH_TOKEN` now correctly falls back to `ENGRAPHIS_API_TOKEN`. -- The sync relay now has its own per-IP token bucket - (`ENGRAPHIS_RELAY_RATE_PER_MINUTE`, default 600) instead of sharing the - 60-request/minute license-registration budget. A full 64-bundle sync round can complete - without throttling its final requests, while invalid-key floods remain bounded before - Ed25519 verification. -- Every `/start-trial/verify` response (success, each error, and the 429) sends - `Cache-Control: no-store` and `Referrer-Policy: no-referrer`. The request URL carries - the one-time token, so the error pages are as Referer-leaky as the success page that - holds the key; they previously used separate inline header literals and had drifted. - -### Changed - -- `GET /api/auth/users` checks `admin` at the route, matching `auth.min_role()`. The - middleware already enforced admin, so this is defense in depth with no behaviour change; - the route previously said `member`, which was dead code that misrepresented the policy. -- Successful version-tag publication now creates the matching GitHub Release and attaches - the same validated wheel and source distribution sent to PyPI. Manual workflow dispatch - remains build/check-only, and the release job is tag-gated behind successful PyPI - publication. -- The Knowledge Graph defaults to compact component-aware packing and adds community, - radial, constellation, original, and custom layouts; selectable Cyberpunk, Galaxy, - Solar system, and Classic visual styles with persisted palettes; per-type node colors; - a synchronized keyboard-accessible explorer; collision-aware labels; and responsive - controls. Large graphs reuse rendered data, cap explorer DOM rows, reduce animation - work, and suppress expensive dense-graph effects. -- The duplicate global Recall shortcut was removed from the dashboard header. Recall - remains available in the Memory Operations sidebar and from contextual page actions. -- The README documentation was expanded to clarify note-link graphs, agent memory, code - awareness, encryption, and sleep-time consolidation without making unmeasured product - comparisons. -- The README now documents Command Code CLI as an MCP-native client and includes its - verified stdio registration command. - -## [0.9.8] - 2026-07-18 - -Hardening release focused on dependable installation, upgrades, startup, dashboard use, -and safe hosted deployment. - -### Security - -- Every entrypoint sends baseline response headers: CSP, `X-Frame-Options: DENY`, - `X-Content-Type-Options`, `Referrer-Policy`, `Permissions-Policy`, and HSTS over HTTPS - only. Override with `ENGRAPHIS_CSP` / `ENGRAPHIS_HSTS`; set either to an empty string to - omit that header where a fronting proxy supplies its own. -- Loopback/bootstrap trust now rejects all common forwarding metadata, including - `X-Forwarded-Proto`; a same-host TLS proxy can no longer make an internet request - look like an unproxied local setup request. -- Inspector first-admin setup now uses the auth store's atomic empty-database gate, so - concurrent different-email requests cannot both create administrators. - -### Added - -- MCP clients now receive canonical recall, session, durable-memory, and handoff guidance - through the server's initialization instructions. -- The dashboard exposes a small `/api` service index, and the graph CLI documents its - public commands without showing the internal merge-driver command. -- Regression coverage now exercises the sqlite-vec backend, workspace-aware entity recall, - installed database migration, encryption packaging, CLI startup, update paths, and release - artifacts. - -### Changed - -- Installed builds now keep the default database in the platform user-data directory. - Existing package-directory databases are copied with SQLite's backup API, validated, and - preserved as recovery copies; source checkouts retain their repository-local default. -- `engraphis-update` discovers the highest stable SemVer tag, validates explicit versions, - fails closed on fetch errors, refuses dirty editable worktrees, and keeps pip, pipx, Git, - and documents the source-rebuild path for locally built Docker images. -- Dashboard styling and navigation were reworked with five selectable themes, responsive - mobile behavior, semantic landmarks, improved keyboard focus, clearer confirmations, and - fully self-hosted browser assets. -- Console launchers now validate arguments before optional imports, report actionable startup - failures, display reachable IPv4/IPv6 URLs and resolved database paths, and advertise the - current dashboard and API routes. -- Optional-dependency bounds and extras were refreshed. The cross-platform `all` extra no - longer pulls the platform-limited SQLCipher driver, while encryption continues to fail - closed when no compatible driver is available. -- The release workflow now pins actions by commit, runs the full test/evaluation and package - validation gates, matches release tags to package versions, and reserves publishing for - validated tag pushes. Bundled browser-library license notices are included in distributions. -- Installation, hosting, sync, graph-query, MCP tool-count, and database-location guidance was - synchronized with the current commands and runtime behavior. - -### Fixed - -- Installed `engraphis-init` configuration is now loaded from the current directory's - `.env` without parent traversal, while explicit environment variables retain precedence. - Upgrading no longer opens a fresh platform-default database instead of the database the - user selected through `engraphis-init`. -- A failed dashboard memory-detail request can no longer retain a prior memory identity or - leave write controls enabled, preventing a later Save from modifying the wrong memory. -- A fresh hosted deployment now renders an actionable, non-data bootstrap screen when remote - API access is denied by default; it offers the safe Team-trial path or deployment-variable - setup without exposing account-wide license activation to a signed-out browser. -- Dashboard, REST, Inspector, MCP, licensing, sync, billing, and provider failures now return - bounded user-facing messages rather than raw exceptions or upstream response bodies. -- Trusted-proxy handling now evaluates the rightmost forwarded hop, supports exact/CIDR - allow-lists, and prevents untrusted forwarding headers from changing URLs or secure-cookie - decisions. Interactive API documentation is disabled on user-facing servers by default. -- Dashboard handlers now read memory, workspace, member, and token identifiers from escaped - `data-*` attributes instead of interpolating untrusted values into inline JavaScript. -- Repository-graph JSON output now escapes non-ASCII labels so Windows console encodings do - not turn successful `impact`, `prs`, or query commands into exit-code 2 failures. -- A server-only installation now includes the multipart parser required by dashboard import - routes instead of depending on the unrelated MCP extra to provide it transitively. -- `engraphis-mcp --help` works without importing the optional MCP stack; server-only and - explicitly offline configurations no longer emit misleading missing-dependency warnings. -- Dashboard and legacy-server launch failures retain database recovery details instead of - collapsing them into generic errors, and invalid port values are rejected cleanly. -- SQLite vector selection is now tested in both accelerated and offline-fallback modes, while - memory writes remain durable and audited if an index update fails. -- The zero-configuration Compose dashboard now admits its Docker host bridge while both - published ports remain loopback-only; widening a port requires an API token. -- Git-installed updates retain their recorded PEP 610 remote, and failed editable updates - restore the original branch without exposing a Python traceback. -- Customer-operated sync relays are separated from the managed license/trial/invite service, - and the sample `.env` no longer overrides installed database defaults with a relative path. -- MCP end-of-session guidance again represents completed work with an empty unresolved list - instead of persisting a fake open thread. - -## [0.9.7] - 2026-07-17 - -### Security -- Team-mode login gained a per-source-IP failure throttle (25 failures / 15 min) - alongside the existing per-email lockout, closing the credential-stuffing sweep - that tried each address once; lockouts now surface as a typed - `AccountLockedError` mapped to HTTP 429 + `Retry-After` (previously 401, or a - 429 derived by substring-matching the error message). - -### Fixed -- `remember`/`remember_with_resolution` are now atomic across the neighbor-resolve → - insert sequence (engine-level write lock): concurrent near-duplicate writes can no - longer both resolve ADD and store duplicates instead of NOOP/INVALIDATE. -- The Inspector's `/api/auth/login`/`setup` no longer run PBKDF2 (600k iterations) - on the asyncio event loop; password hashing moved to a worker thread, so a burst - of logins can't stall every other request. -- A failed vector-index upsert on the write path is now logged and audited - (`index_upsert_failed`) instead of silently swallowed. Previously, the memory - stayed invisible to semantic recall with no trace. -- URLs built from a bind host are now IPv6-safe and connectable (`engraphis.netutil`): - `ENGRAPHIS_HOST=::` no longer yields the malformed `http://:::8700` in the printed - dashboard URL, the :8710 redirector target, or `Settings.base_url`; wildcard binds - map to loopback. -- The Docker image no longer bakes an IPv4-only bind: the entrypoint defaults - `ENGRAPHIS_HOST` to dual-stack `::` when the kernel has IPv6 (what Railway's - private-network healthchecks require) and `0.0.0.0` otherwise, so wiping the - service's env vars can't regress the 2026-07-16 healthcheck outage. - -### Changed -- Consolidated four per-app bearer-token checks into one constant-time - `inspector.auth.bearer_ok` helper (scheme now matched case-insensitively per - RFC 7235 everywhere); extracted the ~230-line code-graph HTML/Markdown export - templates from `core/engine.py` into `core/codegraph_export.py`; documented the - v1/v2 split in `engraphis/routes/__init__`; entity ancestor-widening in graph - recall now applies to `workspace_id` symmetrically with `repo_id`; filtered - sqlite-vec searches cap their geometric widening with a single full scan. - -### Added -- Schema v3 logical graph layers (`temporal`, `entity`, `causal`, `semantic`), privacy-safe - SHA-256 receipt chains, optional LLM/host retention supervision, and a persistent code↔memory - bridge. -- Incremental multi-language repository indexing (Python, JS/TS, Go, Rust, Java, C#, C/C++, - SQL, Terraform), docstrings/comments, variables, inheritance/implementation, weighted - communities, hotspots, path queries, git/PR impact analysis, portable JSON/HTML/Markdown - exports, and a graph union merge driver. -- Local multi-format resource ingestion for text/code/HTML/DOCX, optional PDF/image OCR and - faster-whisper transcription, plus live PostgreSQL schema introspection with DSN redaction. -- Seven MCP tools for code paths/impact/export, PostgreSQL schema ingestion, and receipt - list/verify/export, bringing the tool surface from 20 to 27. -- `engraphis-graph` workflow CLI and token-protected `engraphis-graph-server` read-only HTTP - surface. - -### Changed -- Railway hosting now supports Pro solo single-admin deployments: any active Pro or Team - entitlement can bootstrap the first admin and activates the login wall, while member - seats and direct hosted agent writes remain Team-only. The hosting guide now covers both - Pro solo sync-relay and Team member flows. - -### Fixed -- 1-hop graph recall (and the PPR large-graph fallback) now honors `graph_layers`, matching - the PPR arm: `Store.neighbors()` gained a `layers` filter. -- `FolderTransport.push()` no longer follows peer-planted symlinks in the shared sync folder - (unpredictable temp name + `O_CREAT|O_EXCL|O_NOFOLLOW`), closing an arbitrary-file-write - vector that mirrored the already-hardened read side. -- `engraphis-graph-server` treats an empty `--host`/`ENGRAPHIS_GRAPH_HOST` as non-loopback - (it binds all interfaces), so the bearer-token requirement can no longer be skipped. -- Caller-supplied `metadata.retention_supervision` is stripped at the service boundary; only - the validated `retention_class` presets can influence importance/stability. -- `merge_workspaces()` no longer duplicates symbols/code edges when both workspaces indexed - the same file in a same-named repo: the losing snapshot's rows are cleared, and its - memory↔code links are re-pointed at the surviving same-fqname symbols. -- `engraphis-graph impact/prs` reject leading-dash git revisions (git option injection), and - graph exports refuse a symlinked output directory and are written atomically without - following pre-planted symlinks. -- The unified graph endpoint bounds entity edges and code edges/links per request - (`limit`-derived cap) so a large workspace graph or indexed repo can't produce unbounded - viewer-role responses. -- Relay sync fails closed when a workspace's settings are unreadable rather than treating a - possibly-personal folder as shared: in the sync CLI and in the dashboard/background - `_sync_all` path; resource extraction enforces its own raw-size cap. - -## [0.9.6] - 2026-07-16 - -### Added -- **Agent Connect for hosted Team instances.** Members can mint SHA-256-hashed per-user - bearer tokens in Settings and use the hosted v2 store through `POST /api/remember`, - the existing read routes, token management under `/api/auth/token*`, and - `GET /api/auth/connect-info`. Tokens retain the user's role and personal-folder scope; - viewers are read-only and disabling a user invalidates their tokens immediately. -- **Authenticated MCP-over-HTTP at `/mcp`.** When the MCP extra is installed, the - dashboard mounts the same 20 tools as the standalone server and injects its existing - `MemoryService`, avoiding a second SQLite writer. The endpoint requires an active Team - entitlement and per-user bearer token, enforces viewer/member/admin roles per tool, and - reports actual mount availability through connect-info. -- **One-click Railway hosting.** Added `railway.json`, the README deploy button, and - `docs/HOSTING_RAILWAY.md` for persistent volumes, forwarded HTTPS headers, Team - entitlement bootstrap, member invites, and HTTP/MCP agent connection. -- **Two new MCP context tools.** The MCP inventory grows from 18 to 20 with - `engraphis_answer`, a compatibility alias for the existing grounded-recall contract, - and `engraphis_proactive_context`, also available at `POST /api/proactive-context`. - Proactive packets include bounded task/agent state, cited memories, suggested queries, - and the previous session handoff. Optional LLM prose is accepted only when every claim - carries a valid citation. -- **Structured LLM ingestion and consolidation.** `ENGRAPHIS_EXTRACTOR=llm_structured` - validates typed facts, entities, relations, keywords, and confidence; that metadata is - preserved through storage and automatically feeds the graph. Settings now includes a - **Connect your LLM** card backed by `/api/llm/status` and `/api/llm/test`. - Consolidation adds schema-validated facts and explicit source supersession across the - service, REST, MCP, and CLI surfaces, with deterministic fallback on provider/schema - failure. -- **Opt-in deterministic memory intelligence APIs.** Added conflict triage for duplicate, - refinement, contradiction, and obsolete candidates, plus a serializable `UserModel` - that learns interaction preferences and reranks recall results. These helpers do not - mutate the store or alter default recall unless a caller invokes them. - -### Changed -- **Team mode is opt-out by default.** `ENGRAPHIS_TEAM_MODE=0` (or false/no/off) disables - Team plumbing. A fresh solo install stays open, first-admin setup requires a live Team - entitlement, and an existing team's authentication wall remains active if its license - lapses so private data never becomes public. -- Pre-login license status and trial routes now allow a fresh instance to start a Team - trial before first-admin setup. Purchased keys bootstrap through - `ENGRAPHIS_LICENSE_KEY` or the license file; `/api/license/activate` remains admin-only. -- Package fallback metadata and all user-facing tool inventories now agree on version - `0.9.6` and 20 MCP tools. - -### Fixed -- **Agent Connect and dashboard lifecycle:** corrected generated endpoint URLs, retained - one-time token visibility, made `/mcp` bearer-only, bound MCP sessions to their initiating - user, rechecked tool roles on every call, retained DNS-rebinding protection, closed - previously injected stores, and made connect-info reflect the real optional MCP mount. -- **License and Team enforcement:** authoritative revocations override cached entitlement - and persist tombstones for previously unrecorded keys; transient failures may use only - an unexpired lease; public license/trial bootstrap routes close after the first Team user; - trial rate limits trust forwarded addresses only from configured proxies; managed - requests use explicit client headers; retired managed relay URLs are canonicalized - across key issuance, license/trial, invite, and sync clients; and configured keys - that fall back to free after transient outages retry automatically. -- **Python and packaging compatibility:** rate-limit buckets and audit exports use - timezone-aware UTC APIs, package metadata uses the SPDX license format, and the - deterministic fallback matches the default embedding model’s 384 dimensions. -- **Memory and retrieval integrity:** audit writes are committed durably, recall excludes - non-live rows, mixed embedding dimensions no longer crash recall and have a backed-up - repair path, sync enforces workspace/repository boundaries in both directions, graph - provenance is pruned per memory instead of deleting shared edges, SQLite-vector distances - are converted to cosine similarity, entity expansion matches complete names, and the - sentence-transformers adapters support both legacy and renamed dimension APIs. -- **Structured-data safety:** extraction metadata survives ingest unchanged, proactive and - consolidation inputs are bounded, structured consolidation rejects source IDs outside - the requested cluster, and synthesized context cannot replace deterministic output - without valid citations. -- **Dashboard graph navigation:** focusing an isolated node now retains the requested node - through the delayed renderer retry instead of reporting a false “Entity not in view.” -- **Dashboard typography:** replaced sub-12px text and the flat type ramp with a consistent - 12/16/24/32px hierarchy while preserving responsive layout. - -### Documentation -- Updated the README, Agent Connect, Railway, Kilo Code, bundled memory skill, benchmark - command, and package-version fallback to match the shipped routes, tool count, setup - order, and extractor/consolidation options; removed the unused shortcut icon helper. - -## [0.9.5] - 2026-07-14 - -### Changed -- **Team mode is now ON by default (opt-out).** `ENGRAPHIS_TEAM_MODE` defaults to on; - set `ENGRAPHIS_TEAM_MODE=0` (or false/no/off) to disable. The per-user login wall is - no longer raised just because the mode flag is on. It now requires a *live* `team` - feature entitlement (`licensing.has_feature("team")`), checked at request time in - `dashboard_app.py` and reflected in `/api/auth/state`. Solo / no-license installs stay - fully open, and the wall appears the moment a team license key is added, even via the - dashboard UI at runtime. A `team` license is still required to *add seats* beyond the - first admin (bootstrap admin is created unconditionally). Docs (`.env.example`, - `AGENTS.md`, `README.md`, `SECURITY.md`, `scripts/init.py`) and team-mode test fixtures - updated. -- **Team-invite email rewritten to separate "join" from "activate a key".** The old - invite conflated the two, so members pasted the shared team key into the hosted/Railway - dashboard, saw it "work" (it just re-activated a license already active there), and - thought they'd joined, when joining means signing in with email + password. The email - now frames two distinct options: **Option 1** (required to join) sign in to the team - dashboard with email + the admin-set password, with explicitly *no license key needed here, - don't paste one*; **Option 2** (optional) run Engraphis on your own machine and access - the team's memories locally; that is what the shared team key is for (LOCAL - `http://127.0.0.1:8700` → Settings → License, then Settings → Cloud Sync to pull the - converged team store down to a local offline copy). Invites now always carry a - clickable sign-in link: `dashboard_url` resolves explicit arg → `ENGRAPHIS_DASHBOARD_URL` - → `DEFAULT_TEAM_DASHBOARD_URL` (`https://team.engraphis.com/`). A footer with the - canonical site + repo links is added as env-overridable module constants - (`SITE_URL`/`REPO_URL`) so the URLs can't drift per-email. `tests/test_billing.py`. - -### Fixed -- **Intermittent `database is locked` from `set_service`.** `routes/v2_api.set_service` - swapped the global `MemoryService` without closing the previously-bound service's store - connection, so under heavy test churn a deferred-GC close of the old SQLite/WAL handle - collided with the next `MemoryService.create` on the same path. The prior store is now - closed on swap (best-effort, never blocks the swap on a close error). - -### Docs -- **README now documents three previously-undocumented shipped features** (the features - themselves shipped in 0.9.3): sub-file chunking (`ENGRAPHIS_EXTRACTOR=chunk` + the - `eval.chunking_eval` whole-file-vs-chunked harness), auto-dreaming (the background - cross-cluster-inference loop, accumulation + idle trigger, `dream_inference` - provenance/auditability), and every automation dream knob exposed via the dashboard - Automation tab and the `GET/POST /automation` + `POST /maintenance/run` API. Also: a - **Team early-access beta** callout (top + feature/pricing tables + Free-vs-Pro section) - and a **daily-update reminder for maintainers** near the top (code wins; fix the doc in - the same change). - -### Chore -- `.gitignore` now excludes `automation.json` / `autosync.json` (regenerable local - runtime state from `engraphis/automation.py`, not source content). - -## [0.9.4] - 2026-07-14 - -### Fixed -- **The dashboard (`engraphis-dashboard` / `http://127.0.0.1:8700`) would not start.** - `scripts/start_dashboard.py` runs uvicorn against `engraphis.dashboard_app:app`, but - `dashboard_app.py` only defined the `create_app()` factory and never built a module-level - `app` instance, so uvicorn aborted with `Attribute "app" not found` and nothing bound - port 8700. The missing `app = create_app()` (present in `engraphis/app.py` and - `engraphis/redirector.py`, but dropped from `dashboard_app.py`) is now restored. The - background autosync/dreaming/revalidation loops inside `create_app()` are pytest-guarded, - so importing the module under test is side-effect-free. -- **Flaky `database is locked` dashboard test.** - `test_consolidate_inference_pass_is_pro_gated` opened two FastAPI `TestClient` lifespans - back-to-back on the same temp DB file; the first app's still-open SQLite connection - blocked the second's schema init. Split into two one-client test functions, matching - the convention already documented above `test_analytics_and_export_*` (two TestClients - in one test reproducibly deadlock). Full suite now green (693 passed, 3 skipped). - -## [0.9.3] - 2026-07-14 - -### Added -- **Email-verified self-serve trial + abuse protections on the trial endpoint.** - Starting a trial now requires a verified email and sends a one-time confirmation link - before any license is issued; the request path is rate-limited so the endpoint can't be - used to spam or farm trials. This raises the bar significantly above the previous - device-only gate while keeping the same paste-a-key activation flow on the dashboard. - `tests/test_cloud_license.py`, `tests/test_dashboard_v2.py`, - `tests/test_online_only_enforcement.py`. -- **Deterministic, offline sub-file chunking on the write path (`ENGRAPHIS_EXTRACTOR=chunk`).** - A third `Extractor` alongside passthrough/LLM: `ChunkingExtractor` splits a document into - retrieval-sized `ExtractedFact` chunks that preserve meaning: markdown headings start new - chunks and become the title, fenced code blocks stay intact, prose is packed to a token - budget (`ENGRAPHIS_CHUNK_TOKENS`, default 256) with a sentence-level overlap - (`ENGRAPHIS_CHUNK_OVERLAP`, default 32); a hard per-document cap - (`ENGRAPHIS_CHUNK_MAX`, default 200) bounds amplification. numpy/stdlib only, so it runs - under the offline gate and is byte-identical across runs. This gives long, multi-topic - documents finer retrieval units instead of one diluted memory; the bundled evaluation below - preserves Recall@5 while reducing retrieved context. New: `ChunkingExtractor` in - `backends/extractor.py`; `tests/test_chunking_extractor.py`. -- **File/folder imports chunk too.** With `ENGRAPHIS_EXTRACTOR=chunk`, - `import_folder`/`import_files` split each file into several retrieval-sized memories - (each still `trusted:false`, stamped with `metadata.chunk={index,of,heading}`) instead of - one; the LLM extractor is deliberately never applied to the local import path (no external - calls on untrusted disk files). A file still counts as one imported unit. - `tests/test_import_chunking.py`. -- **Chunking eval + `longdoc` dataset.** `eval/chunking_eval.py` + - `eval/datasets/longdoc.jsonl` compare whole-file vs chunked ingestion through the real - recall pipeline. On the offline embedder: identical recall@5 (1.000) at **~73% fewer - context tokens** (809 → 219) and ~4× smaller tokens-to-evidence (162 → 42); the "quality per token" - number `BENCHMARKS.md` calls for. `tests/test_chunking_eval.py`. -- **"Dreaming" trigger for automated maintenance.** `automation.should_dream` / `dream_due` - run a consolidation sweep *before* the cadence when enough new episodic memories have - accumulated **and** the store has gone quiet (`dream_min_new` / `dream_idle_minutes` policy - knobs); wired into `scripts/auto_maintain.py`. Purely additive to the existing cadence, so - cron behaviour is unchanged; still Pro-gated. `tests/test_dreaming_trigger.py`. -- **Associative cross-cluster inference (dream pass 4).** `consolidate.infer_links` / - `consolidate(infer=True)` proposes evidence-only links between memories in *different, - dissimilar* subject clusters that share a bridging entity: the "connect distant dots" step - same-subject distillation never reaches. **Off by default** (`infer=False`); the pass - follows the sweep's own `dry_run` flag, so a dry-run proposes into the report and a real - run applies. Applied inferences are low-salience (`importance=0.25`), `trusted:false`, - `source='dream_inference'`, linked to their sources and audited, so a bad inference is - visible, downweighted, and never merge-eligible into a trusted fact. Fan-out capped, - idempotent. Entity matching is now word-boundary (so `Redis` won't fire on - `rediscovered`) and the per-sweep text scan is computed once, not per entity. - `tests/test_inference.py`. -- **Inference is reachable from the maintenance path.** A new `infer` policy knob (off - by default) runs the inference pass inside `run_maintenance`, whether manual or from the dream loop, - following the sweep's `dry_run`. `/api/consolidate` takes `infer` (`false` by default); - `/api/automation` round-trips `infer`; the dashboard Automation tab has an Inference - toggle. `tests/test_dashboard_v2.py` (policy round-trip + `/maintenance/run` proposes the - Redis bridge), `tests/test_dashboard_dream_ui.py`. -- **Dreaming runs without cron.** A dashboard background loop (`_maybe_start_dreaming`, - mirroring auto-sync) runs a maintenance sweep whenever `automation.dream_due` fires. It is opt-in, - Pro-gated, fault-isolated, with an `ENGRAPHIS_DREAM_LOOP=0` kill switch. The `/api/automation` - policy round-trips the `dream` / `dream_min_new` / `dream_idle_minutes` knobs, and the - dashboard's Automation tab surfaces them as form controls (toggle + thresholds). The - trigger now scopes its accumulation/idle count to the policy's `workspaces` (a burst in - an out-of-scope workspace no longer fires a sweep). `tests/test_dreaming_trigger.py`, - `tests/test_dashboard_dream_ui.py`, `tests/test_dashboard_v2.py`. - -### Fixed -- **First-run team-mode bootstrap hardened.** The admin-creation path no longer depends - on an external relay round-trip succeeding to provision the first seat, and concurrent - first-admin requests are serialized so only one unlicensed bootstrap admin can ever be - created. Subsequent seat additions still require an active Team license. -- **First-run team-mode bootstrap fixed (frontend).** The admin-account screen now triggers - the trial/activation step before provisioning the first admin, so a fresh self-hosted - instance no longer deadlocks on the team-feature gate with no way to proceed. - No backend change; frontend-only. -- `MemoryService.create` now defaults `extractor` from `settings.extractor` - (`ENGRAPHIS_EXTRACTOR`) when unset, mirroring the existing `graph_extractor` fallback so - the dashboard and automated-maintenance front ends honor the config knob, not just the MCP - server and CLI. An explicit `extractor="none"` still overrides the environment. - -### Security -- **Closed a Pro-feature bypass on the manual consolidate endpoint.** The inference pass - (a paid capability) was reachable through the free housekeeping endpoint without a - license; it is now gated at the route and reinforced inside the service layer, so no - caller can reach the Pro-only path without a server-approved license. The free manual - consolidate action is unchanged. `tests/test_dashboard_v2.py`, `tests/test_inference.py`. -- **Strengthened license enforcement and revocation handling.** Reaffirmed that every paid - surface requires a live, server-validated lease and fails closed when the server is - unreachable; tightened the verification so licenses can't be forged client-side, and - serverside-issued seats can't be minted without a valid license. Revoked or refunded keys - are now re-confirmed against the server on a background interval so they degrade promptly - rather than remaining usable until lease expiry, while legitimate offline customers are - never stalled. `tests/test_online_only_enforcement.py`, `tests/test_cloud_license.py`. - -## [0.9.2] - 2026-07-13 - -### Added -- **Personal vs. shared folders + a redesigned Team dashboard.** A folder can now be - created `visibility='personal'` (owned by, and visible/usable only to, the creating - dashboard user) or `shared` (the whole team, the previous, still-default behaviour). - Enforcement runs through a single workspace-authorization chokepoint, so every scoped - read/write inherits it and a non-owner cannot access another user's personal folder. - Personal folders are excluded from relay sync so they stay on-device. The **Team - dashboard** gains a team overview (seat usage + activity), a Folders panel that creates - and manages shared/personal folders (folder creation now lives here: the Workspaces - tab is selection-only in team mode), members with last-active, and a team audit log with - CSV export. New/updated: `service.py`, `routes/v2_api.py`, `dashboard_app.py`, - `static/index.html`; tests in `tests/test_personal_folders.py`, - `tests/test_dashboard_v2.py`, `tests/test_sync_dashboard.py`. - -### Changed -- README expanded with the missing features (cloud sync, encryption, import/ingest, - workspace ops, Docker, config, and more) and now links to the Engraphis Discord. - -## [0.9.0] - 2026-07-13 - -### Added -- **Automatic v1→v2 database migration on startup**: a pre-existing v1-shaped - `engraphis.db` (no `workspace_id` column) is backed up and migrated to the v2 - schema, so existing installs upgrade cleanly without manual SQL. - -### Fixed -- **Dockerfile default entrypoint** is now `engraphis-dashboard --no-open` (was the v1 - single-user `engraphis-server`), so a fresh container serves a working team dashboard - with auth/license/trial routes instead of a permanently signed-out UI. - `engraphis-server` remains available as an explicit override for single-user - deployments. -- **CI**: ruff lint errors and core-floor (numpy-only) test collection. - fastapi-dependent tests now skip cleanly on the minimal core floor. `loads_strict` - now rejects pathologically deep JSON on every Python version (3.12's JSON scanner - no longer raises RecursionError for ~1000-deep input, which had broken the - deep-nesting parsing guard and its test on 3.12). - -## [0.8.8] - 2026-07-13 - -### Security -- Hardened license validation and trial consumption tracking -- Improved offline trial tamper resistance - -## [0.8.7] - 2026-07-12 - -### Added -- **Dashboard "Import files & folders"** restored on v2 engine -- **Kilo Code integration docs** (`docs/KILO_CODE_INTEGRATION.md`) - -### Fixed -- Dashboard auth: session handling, role badges, member management -- License cloud enforcement: lease validation, online-only gating -- Service layer: workspace operations, memory reorder, merge - -## [0.8.6] - 2026-07-12 - -### Added -- Dashboard "Import files & folders" section restored on v2 engine - (`engraphis/service.py`, `routes/v2_api.py`, `static/index.html`, Workspaces tab) -- Server-side path import and drag-and-drop upload, both member-gated and bounded -- Imported memories marked untrusted by default; 21 new tests - -### Security -- Hardened folder import against path-traversal and containment bypasses - -## [0.8.5] - 2026-07-12 - -### Fixed -- Logout no longer re-triggers sign-in modal loop -- Team bootstrap: trial/license endpoints now accessible before first admin exists -- Expired/revoked Team license no longer locks out all logins -- Trial start now idempotent (no 400 on repeated calls mid-trial) -- Team trial grants 5 seats (was 1), enabling actual team evaluation -- Dashboard handles empty workspaces gracefully -- Static assets (dashboard HTML, vendor JS) now ship correctly in wheel - -## [0.8.4] - 2026-07-12 - -### Security -- Paid features now require a live, server-issued license lease -- Offline handling degrades gracefully with bounded grace when the server is unreachable -- Local/offline trial grants removed; trials are server-issued and tracked per device -- Issued keys are server-enforced by default - -## [0.8.3] - 2026-07-12 - -### Fixed -- Empty workspace `/api/memories` returns `[]` instead of 500 -- Online-only license enforcement: cloud-mode keys validated per request - -## [0.8.2] - 2026-07-12 - -### Fixed -- Static package discovery: `engraphis/static/__init__.py` added -- Vendor glob: recursive pattern so `static/vendor/` bundles ship in wheel -- Dashboard 500 on `GET /`: `static/index.html` was missing from wheel (packaging bug) -- Dashboard 500 on fresh install: `GET /api/memories` crashed on empty workspace - ---- - -## Earlier versions (condensed) - -### Versions 0.5.x to 0.7.x -- MCP server with 18 tools -- Memory Inspector product UI (`engraphis-inspector`, port 8710) -- Dashboard rebuilt on v2 engine with recall, governance, consolidate, analytics -- Team mode: login auth, viewer/member/admin roles, seat limits -- Grounded recall with cited answers and abstain gate -- Sleep-time consolidation with compaction accounting -- Personalized PageRank graph arm (HippoRAG-style) -- Offline signed license keys (no phone-home) -- Pro analytics dashboard -- Code-symbol graph via tree-sitter or regex fallback -- Docker + docker-compose deployment -- 300+ tests, eval harness, ablation suite - -### [0.1.0] - 2026-07-09 -- Initial public release: local-first AI memory engine for agents -- Ebbinghaus decay, interaction-aware recall, bi-temporal facts -- Background consolidation; you bring the LLM - ---- - -**Security reporting:** Email **security@engraphis.dev** for vulnerability disclosure. +# Changelog + +All notable changes to Engraphis are documented here. Format loosely follows +[Keep a Changelog](https://keepachangelog.com/); versions use SemVer. + +## [Unreleased] + +## [1.5] - 2026-08-04 + +Minor release advancing the v2 engine to schema 11 with governed recall recovery, +embedding-space safety, reproducible release evidence, and stronger offline memory-quality gates. + +### Security + +- Add opt-in immutable Hugging Face model provenance enforcement for remote embedding models, + rerankers, and chunk tokenizers, with revision plumbing across v2 services and local front ends; + model loaders now explicitly disable remote code execution while local paths remain supported. +- Refuse redirects in loopback startup-health and PyPI metadata probes, and treat shortcut icon + paths as data across PowerShell, macOS shells, and Linux desktop files. +- Add an offline release gate proving that quarantined, review-pending, and caller-self-approved + external content is downgraded and stays outside prompt recall, including direct poisoned + edges and pending-memory-supported edges, while trusted graph evidence remains available. +- Reject control characters in hosted access and refresh credentials, including credentials + returned during rotation, before any network or persistent-state use. +- Restrict the Inspector API to loopback clients when no API token is configured, and exclude + pending or quarantined memories from managed-cloud snapshots. +- Harden update checks with bounded, link-safe cache reads, atomic private cache writes, strict + version limits, finite timestamps, and validated HTTPS or loopback-HTTP URLs. +- Route private credential and state-file reads through one bounded, race-resistant boundary that + rejects links, reparse points, non-regular files, invalid UTF-8, and oversized input. +- Raise the optional `cryptography` floor to 50.0.0 to exclude known vulnerable releases. +- Require the patched pytest line in supported release environments and give every CI pytest + invocation a private runner-owned temporary root, including the Python 3.9 compatibility lane. + +### Fixed + +- In schema 11, migrate pre-review trusted memories to explicit approval without releasing quarantined or + ambiguous evidence; recover the exact historical local-agent service-gate downgrade and expose + content-free eligibility diagnostics when review gating causes zero-result recall. +- Replace per-backend vector version checks with one active embedding-space fingerprint, make + Sentence Transformer/API spaces durable, rebuild on every space transition (including + A -> B -> A), and disable vector recall throughout interrupted or mixed-space rebuilds. +- Describe the stable sqlite-vec backend accurately as native exact KNN, add a dedicated + `vector` install extra, require the upstream release containing the vec0 delete fix, + and let server entrypoints select it automatically with a safe NumPy fallback. +- Make contradiction supersession failure-atomic so a failed predecessor invalidation cannot + leave two live facts. +- Bound reinforcement stability and migrate existing out-of-range retention state to schema 10. +- Preserve v1 graph endpoints during migration and publish migrated databases only after a + validated staging database is complete. +- Reject partial API embedding batches instead of persisting zero-vector placeholders; give + semantic embedding spaces durable, secret-free identities; and batch SQLite vector hydration. +- Prevent CLI metadata from overriding trusted local provenance and honor the selected namespace + for grounded chat. +- Keep service replacement atomic when the prior SQLite handle cannot close, and make + authoritative cloud denials fail closed in-process before their durable state writes complete. +- Keep tag publication reachable by defining every workflow-verified release check in the public + evidence manifest, including CodeQL, reproducible distributions, and fresh artifact smokes, and + bind the evidence provenance to the completed code-security job. +- Repair GitHub releases only from the frozen, hash-verified distribution set, excluding any + publisher receipt or other unverified file left in the working distribution directory. +- Exercise both the exact tagged wheel and source distribution in clean Python 3.9 environments, + including dependency resolution, pip check, core CLI startup, and in-memory remember/recall; + declare the CI build and vulnerability-audit tool versions instead of relying on runner images. +- Eliminate duplicate NumPy vector writes and commits after ordinary remembers, embedding rebuilds, + sync application, and title re-embedding. Store-backed indexes opt out only when they share the + exact canonical Store; separately-backed and injected indexes retain explicit synchronization. +- Replace row-by-row NumPy scan hydration with one filtered, fixed-width matrix read while + preserving temporal/scope filters, malformed-dimension isolation, deterministic ties, and + immediate visibility of newly written vectors. +- Surface best-effort graph, entity-linking, evolution, conflict-repair, and index-audit failures as + per-engine rate-limited, payload-redacted warnings instead of silently suppressing operational + faults. +- Honor the configured embedding dimension, vector backend, model revisions, reranker, and encrypted + connection path consistently across every v2 front end and the sync/consolidation CLIs, preventing + an operational command from accidentally rebuilding a persisted semantic space with defaults. +- Commit standalone entity links without closing a caller-owned transaction, and make the Windows + shortcut installer retain its redacted Desktop launcher fallback when PowerShell is unavailable. +- Serialize and make Store shutdown idempotent, add context-manager and weakref-finalizer cleanup, + and keep the offline suite from loading production embedding/reranker models merely because a + developer has optional semantic dependencies installed. + +### Added + +- Extend `eval.vector_scale` with input-identical NumPy/sqlite-vec exact-KNN comparisons, + explicit backend identity, deterministic result hashes, and setup-excluded latency envelopes. +- Add `engraphis-cli review list|approve` for content-free, scoped bulk review. Approval is + dry-run by default, requires a reason and one batch confirmation, excludes quarantined records, + and supports explicit ids, source/repo filters, and the legacy-agent signature. +- Add embedding coverage and prompt-eligibility health to service stats, stamp service ingress and + writer-policy provenance, and document recall recovery without direct database surgery. +- Add deterministic reinforcement and adversarial-memory release gates plus a hash-bound LoCoMo + evidence-repair manifest and complete pinned-dataset retrieval diagnostics. +- Pin the Pyright contract for core, backends, and external evaluation; require it in CI and release + evidence; verify distribution contents; generate a reproducible CycloneDX SBOM; byte-compare + normalized repeat builds; smoke fresh wheel/sdist installs; and bind complete-tree CodeQL to the + tag gate. +- Smoke all 14 installed console entrypoints from their distribution metadata and generated wrapper + paths for both wheel and source-distribution installs, with bounded timeouts and diagnostics. +- Add opt-in semantic-confidence calibration for retrieval-arm experiments while preserving the + existing default ranking until paired external non-inferiority evidence is available. + +## [1.4.5] - 2026-08-04 + +Patch release aligning the package, runtime, commercial manifest, and plugin metadata at 1.4.5 +for the governed recall/write hardening, schema 8 migration, Smart MCP gateway fixes, and +credential-safe evaluation capture included in PR #111. +Schema 9 adds repository-scoped tombstone support and performs a one-time entity-canonicalization +repair; `confidence` and `pinned_at`/`unpinned_at` were introduced by the preceding v7-to-v8 +migration. Known-repository tombstones are terminal only within that repository, while legacy +repo-less tombstones remain global. + +## [1.4.0] - 2026-08-02 + +Engraphis 1.4 makes the compact Smart MCP gateway the default agent interface while preserving +the complete Classic surface for existing integrations. It also strengthens external-write +governance, +bounded context delivery, secure erasure, and release/runtime hardening, and moves the v2 SQLite +schema to version 9 (schema-level additions include repository-scoped `memory_tombstones`; the +upgrade also performs a one-time entity-canonicalization repair), which migrates automatically on +first open. Known-repository tombstones are terminal only within that repository; legacy repo-less +tombstones remain global. + +### Upgrade notes + +- `engraphis-mcp` now exposes nine Smart tools instead of 33 direct tools. Clients that depend on + the former names should switch their server command to `engraphis-mcp-classic`; HTTP clients can + use `engraphis-mcp-http --classic`. +- Existing v2 databases migrate automatically to schema 9 on first open; the change is additive + and requires no manual step. +- The NumPy-only core supports Python 3.9+. Dashboard, MCP, documents, Cloud Sync, and `all` + installations require Python 3.10+ because their supported dependency versions require it. + +### Added + +- Smart MCP is now the zero-configuration `engraphis-mcp` default. It exposes nine compact tools: + sessions, prompt-ready recall, durable memory, discovery, validated read/action execution, and + governed record read/update plus conflict review. `engraphis-mcp-classic` preserves the former 33 + direct tool names and legacy alias response shapes for pinned integrations. +- The first-party `@engraphis/pi` package under `integrations/pi` exposes that Smart MCP surface + as native Pi tools, verifies the Engraphis 1.4.x handshake, and ships with independent npm + packaging and release gates. +- Hosts that retain their own conversation history can call the non-MCP + `POST /api/adaptive-context` endpoint. Advanced proactive context also supports a bounded, + content-lean compact response while Classic keeps its full response by default. +- Opt-in planned recall adds a bounded deterministic planner, an injectable planner protocol and + optional LLM backend, priority-weighted multi-query RRF, post-rerank memory-type maxima, stable + context revisions, and diagnostics-only planner traces across Python, service, REST, and MCP + recall surfaces. The default remains the existing single-query path (now on schema 9). +- A 40-task context-routing stress fixture, four-way five-budget ablation harness, pinned + LongMemEval-V2 planner configurations, and evaluation-only imported-resource hierarchy prototype + encode local regression gates and matrix tooling. Official benchmark, safety, and hosted-cache + artifacts remain mandatory before any default or schema change. + +### Security + +- The Pi extension preserves the Smart gateway's destructive boundary: every discovered + state-changing action requires an explicit Pi confirmation, fails closed without a dialog, + and consumes its capability after one approval attempt so unknown outcomes are not retried. +- Public writes now enter an explicit review gate: MCP, REST/dashboard-intent, import, sync, and + extractor ingress are pending regardless of a caller-supplied trust label; detector matches are + quarantined before they can contribute to prompt context or derived state. Human approval creates + a fresh audited successor only through the CSRF-bound dashboard action or an interactive TTY + command, never through MCP or a general REST endpoint. Historical rescans demote non-approved + records and retire their derived bridges. Public history, graph/code retrieval and indexing, and + consolidation apply prompt eligibility before ranking or capacity decisions, so pending or + quarantined records cannot influence prompt-visible results through derived bridges. +- Smart MCP authorization now fails closed: discovery and read execution require viewer access, + state-changing execution requires admin access remotely, and pure reads do not emit write-side + telemetry receipts. Executor output is bounded without retrying or double-running handlers. +- Tokenless remote requests to the read-only recall and repository-graph API now fail closed; + health and OpenAPI discovery remain public. +- The deterministic detector now uses a pinned Unicode TR39 15.1.0 ASCII projection rather than + a short hand-picked table, covering additional Latin, Cyrillic, Greek, mathematical, and legacy + glyph substitutions without an online lookup or runtime dependency. +- Secret scanning is cycle-safe and depth-bounded, and PostgreSQL source identities are reduced to + credential-free digests for both URI and libpq keyword DSNs. + +### Fixed + +- Secure erase now rebuilds shared-edge provenance from surviving support rows. Historical-only + support remains available to time-travel reads while the edge is closed in the current graph. +- API embedding backends now validate dimensions, response cardinality, item indices, finite + values, and normalization before accepting provider output, with consistent bounded fallback. +- Planned-recall datasets reject dangling references, vector dimensions are bounded across local + and SQLite backends, and sync imports accept pinned state only when it is the literal boolean + `true`. +- The production image now removes build-only pip and its vendored dependency snapshot after + installation, eliminating unreachable vulnerable packages from the runtime attack surface. +- Automatic LLM retention supervision now discards proposed retention values when it + demotes an unapproved `critical` label; legacy poisoning rescans also honor + `--keep-unlabelled`, and code-memory exports apply eligibility before their result cap. +- Scope promotion now preserves an owner-approved detector match and its stable claim identity + without re-quarantining the approved derived copy. +- `engraphis connect` now treats its printed summary as a provider trust boundary: only bounded, + printable registration metadata is rendered, preventing malformed control-plane values from + being reflected into CLI or JSON output. +- Explicit local `engraphis-cli ingest` commands now record local-owner-approved provenance, + allowing their memories to appear in ordinary subsequent CLI recall. HTTP, MCP, import, and + file-ingestion boundaries remain pending review. +- The standalone v1→v2 migrator now refuses in-place and pre-existing output paths before + opening either database, preventing accidental mixing of legacy source history into a v2 target. +- Cloud Sync now closes failed HTTP response streams without reading their untrusted error bodies, + preventing descriptor leaks during repeated relay failures. +- Hosted customer clients now bind provider credential/session state before persistence and + preserve sanitized authorization/billing outcomes when an HTTP error body is truncated, so a + one-time connection cannot be stranded by an unreadable state file or retain stale paid badges. +- Authoritative hosted managed-compute authorization denials now immediately settle local + entitlement presentation state, so a revoked, lapsed, or de-authorized account is not shown + stale paid feature access while awaiting a background refresh. +- The production image health probe now follows the active IPv4 or IPv6 loopback listener, + preventing a Railway IPv6 deployment from being marked unhealthy while its readiness route + is serving traffic. +- Grounded recall's absolute support floor ignores titles and non-finite semantic scores, so + display text cannot independently make an answer eligible. +- Keyed-claim deduplication ignores harmless punctuation, and legacy zero, negative, or non-finite + stability values use the documented one-day default instead of producing invalid decay scores. +- Approval requires a non-empty audit reason, accepts only a live pending source, and preserves the + reviewed claim's pin, sensitivity, and keyed identity on its approved successor. +- The zero-config Compose quickstart remains loopback-only; a LAN deployment is an explicit, + token-protected operator choice and cannot inherit the local Docker bridge trust exception. +- Credential-shaped values are rejected before capture can create memory, FTS, vector, event, or + sync copies. `retire` is the canonical temporal lifecycle operation; targeted `secure_erase` + removes an already-leaked record and known local derivatives while reporting physical limits. +- The standalone MCP-over-HTTP launcher is explicitly loopback-only. Remote MCP clients must use + the dashboard's authenticated `/mcp` endpoint instead of an unauthenticated FastMCP bind. + +### Changed + +- MCP-over-HTTP has a packaged `engraphis-mcp-http` command and a generic local setup guide. The + project makes no client-specific integration claim without a maintained guide and integration + test. +- `.env.example` now mirrors runtime defaults for decay, context packing, loop cadence, and recall + depth so copied configurations do not silently override the documented behavior. + +## [1.3.0] - 2026-08-01 + +### Added + +- The optional `hosted-eval` extra adds guarded hosted-Luna productivity evaluation with a + redacted public evidence exporter. +- Protected public benchmark workflows now support redacted hosted and retrieval evidence runs. + +### Security + +- Untrusted ingress now fails closed: provenance and extractor metadata are allowlisted, suspicious + records are quarantined before embedding, linking, graph extraction, resolution, recall, or + grounding, and `scripts/rescan_poisoning.py` can retroactively label or quarantine old records. +- Trust is preserved across resolution, structured graph writes, consolidation, entity profiles, + and review paths. Untrusted records cannot mutate or promote trusted memory, and derived outputs + remain trusted only when every source is explicitly trusted. + +### Documentation + +- README and release guidance now match the current install extras, public entry points, product + boundaries, and focused MCP/provider documentation. + +### Fixed + +- Public server entry points now share the v2 service, keeping recall behavior consistent across + the dashboard, server, Compose, Classic, and MCP-over-HTTP. +- Keyed mutable-fact replacements now load their live predecessor directly, so reworded updates + preserve history without relying on vector top-K recall. +- Versioned deterministic embeddings now rebuild persisted vectors after a mapping change, keeping + existing databases searchable after an upgrade. +- Prompt-facing recall now widens candidate search when untrusted results crowd out trusted + evidence, while keeping expansion bounded. Title text now contributes to absolute support floors + for grounded and hosted recall. +- Hosted productivity evaluation now scores canonical, acceptable, or supporting-evidence answers + with strict natural-language framing instead of token containment or raw JSON text. +- Hosted-Luna workers on Windows now establish kill-on-close containment before sending input; a + failure refuses the request, and timeouts clean up the full worker tree. +- Poisoning rescans preserve existing temporal validity boundaries and invalidate affected edges + without overwriting governed history. + +### Changed + +- CI and release/install metadata now cover Python 3.13 and 3.14. + +## [1.2.5] - 2026-07-31 + +### Added + +- `engraphis_context_savings` aggregates validated, content-free recall receipts by workspace, + repo, operation, and token-counter identity. The view is available through the service, + dashboard, and read-only APIs. +- Recall supports an explicit adaptive candidate-depth experiment while retaining the historical + fixed depth by default. Performance reports record requested and actual candidate depths. +- `MemoryEngine` and `MemoryService` now provide adaptive context routing: bypass retrieval when + prompt history fits, use compact recall when support is strong, and fall back to bounded recent + history when support is weak. +- `eval.productivity` measures task completion, corrections, agent turns, memory calls, latency, + and model-facing tokens. +- Chunk ingestion can enforce budgets with a configured Hugging Face tokenizer and records the + counter identity, target, and overlap in chunk metadata. +- Offline adapters now cover MemoryAgentBench, LoCoMo-Plus, and Mem2ActBench, with a paired + full-history versus Engraphis code-agent analyzer. +- Public benchmark evidence can carry source hashes, repository state, environment and model + provenance, secret-redacted commands and URLs, content digests, and adjacent immutable SHA-256 + files. + +### Changed + +- Context-economy evaluation now compares full history, a same-budget recency window, and hybrid + recall while accounting for indexing cost. +- Official LongMemEval-V2 output has a dedicated redacted evidence exporter that retains the + official QA, token, and latency measures without publishing prompts, answers, model output, or + retrieved context. +- Folder-sync dry runs no longer create a remote directory or persist a local device identity. + +### Fixed + +- Sync rejects malformed scope/repo combinations and every peer-driven visibility change for an + existing memory, including malformed legacy rows. Scope promotion or repair remains a local, + explicit governance operation. +- Workspace consolidation excludes session-private memories and partitions digests and entity + profiles by their exact visibility owner, preventing cross-repo or cross-scope summaries. +- Tokenizer-aware chunk overlap can no longer exceed the configured prose budget or emit a + duplicate overlap-only record before an oversized paragraph. Invalid token counters fail + closed instead of silently producing mis-sized chunks. +- Ledger graph interactions preserve manually selected nodes during refreshes. +- The new evidence guide is included in wheel and source distributions. + +## [1.2.2] - 2026-07-30 + +### Fixed + +- Cloud Sync now continues past legacy plaintext, malformed, and tampered relay objects while + still failing closed for each object. Later authenticated peer bundles apply, and the affected + sync round is explicitly reported as incomplete rather than successful. +- Security and sync documentation now consistently distinguish end-to-end encrypted Cloud Sync + from the separately readable managed-compute snapshot service. +- README visual PNG exports now use their SVG canvas dimensions without hidden screenshot padding. + +## [1.2.1] - 2026-07-30 + +### Security + +- Cloud Sync now encrypts every eligible shared-workspace bundle on the client with + ChaCha20-Poly1305 before upload. The relay receives opaque deterministic bundle names and + ciphertext only; tampered, renamed, cross-workspace, wrong-key, and legacy plaintext bundles + are rejected before the merge engine. +- Cloud Sync requires a client-held 32-byte workspace key and the `cloud-sync` optional runtime. + Missing or malformed encryption configuration stops sync rather than falling back to plaintext. + +### Changed + +- Cloud Sync privacy copy now states that eligible shared-workspace changes are encrypted + end-to-end before leaving the device and cannot be read by Engraphis Cloud. Product and + security documentation separately identifies managed compute as the readable, bounded-snapshot + service it is. + +## [1.2.0] - 2026-07-30 + +### Added + +- `engraphis_recall_context` brings the MCP surface to 30 tools and is the compact, hard-budget + path for agent prompts. It returns packed context, compact source identities, strict token usage + fields, optional retrieval diagnostics, and preserves `engraphis_recall` as the full-response + compatibility surface. +- Recall and grounded recall now expose `valid_at` (world time) and `known_at` (system time); + `as_of` remains the compatible `valid_at` alias and conflicting anchors are rejected. Retrieval + defaults to the `balanced` profile; `auto` remains explicit opt-in. +- MCP and HTTP remember calls can set a fact's world-time `valid_from`; recall, grounded recall, + and the compatibility answer tool can run a point-in-time `as_of` query. +- `eval.performance` reports full recall-pipeline quality, packed context tokens, and + p50/p95/p99 latency with a reproducible JSON schema and deterministic corpus scaling. +- Schema v5 adds temporal history for symbols, code edges, code-memory links, and persisted + memory-entity incidence. Code retrieval is now a first-class profile, and graph walks use + bounded sparse PageRank instead of a dense quadratic transition matrix. +- Optional `subject_key` and `claim_kind` make mutable claims explicit. Uncertain similar facts + are conservatively related while keyed or strongly evidenced contradictions supersede. +- `engraphis-benchmark/v2`, canonical workspace exports, and release-evidence manifests provide + deterministic hashes, per-question records, fixed token-budget curves, and validation before + public evidence is written. + +### Fixed + +- Supersessions now close the old fact at the replacement's effective world time instead of its + ingestion time. Superseded, corrected, promoted, merged, forgotten, and consolidated source + vectors remain available to historical semantic recall while temporal filters keep them out of + the current view. +- Non-finite write and recall timestamps fail validation instead of entering scoring or SQLite. +- Ordinary recall is observational by default, so weak nearest-neighbor results do not gain + stability merely by being returned. Grounded recall still reinforces only cited evidence, and + Python callers with an explicit use signal can request reinforcement. +- Code and PPR retrieval now restrict incident-symbol and memory-entity lookups to the reachable + frontier before applying their safety caps, and repo writes link text mentions to visible + workspace-level entities. + +## [1.1.5] - 2026-07-28 + +### Changed + +- Simplified the Ledger and Classic graph controls by removing the complete-graph action. +- Replaced the README Knowledge Graph image with the corrected Ledger screenshot. + +### Fixed + +- Ledger now has one working `Show unlinked nodes` control that reloads the intended bounded + graph view. +- Time-travel graph views prioritize support visible at the selected anchor, and graph drag + handling remains safe when browser animation-frame globals are unavailable. + +## [1.1.2] - 2026-07-27 + +### Added + +- **The complete Ledger design is now the primary local WebUI**, ported from the final + five-area design package without its sample store or unsafe design runtime. Today, grounded + Ask, Library, the advanced Graph & Relations view, Provenance, and Manage all use live v2 data. + Manage includes workspaces, reviewed local consolidation, hosted Analytics/Automation/Team + status, the full plan comparison, settings, and persisted Slate, Midnight, Paper, and Matrix + themes. +- Ledger now exposes the production grounded-answer route (`POST /api/answer`), returning a + cited answer or an explicit abstention. Graph & Relations ships the supplied graph capabilities: + five layouts, four render styles, palettes, degree/betweenness sizing, bridge detection, + valid-time filtering, superseded ghosts, focus, and automatic cluster collapse. +- The complete former dashboard remains available at `/classic`. Both interfaces expose a + visible dashboard selector and share the same workspaces, memories, receipts, and engine. + +### Changed + +- Ledger defers both the CSP-sensitive renderer and graph payload until Graph & Relations is opened, + ignores stale workspace responses, renders memory text through DOM text nodes, and provides + responsive, reduced-motion-aware keyboard focus styling. Classic loads its lazy graph vendor + dependency from its own packaged backup tree. +- Graph nodes now use oversampled, cached screen-space material rendering with face-level + texture: full-face iridescent PVD for Cyber, directional blue-violet anodizing for Galaxy, + concentric brushed copper for Solar, and horizontal satin gunmetal grain for Classic, with + deterministic low-detail fallbacks for large graphs. +- Dashboard asset URLs now carry the node-material revision and local static responses + revalidate, preventing an already-open browser from pinning the pre-material renderer. +- Pro and Team purchase actions now preserve both the selected plan and billing interval, while + existing or lapsed subscribers are sent to the plan-neutral account portal for billing recovery. + Public documentation now distinguishes hosted-account grace and recovery behavior from the + always-local, Apache-licensed dashboard and MCP write paths. + +### Fixed + +- Token-protected dashboards can now establish a short-lived signed, HttpOnly browser session + without storing the API token in browser storage. Remote peers remain denied when no token is + configured, and non-loopback v1 server startup is refused unless authentication is enabled. +- Hosted entitlement refreshes use bounded exponential backoff, terminal denials settle every + local entitlement view, inactive sessions expose no paid feature flags, and ambiguous + single-use refresh responses permanently retire the possibly spent credential instead of + replaying it. +- Recommended Automation bootstrap is resumable across partial upload/policy-save failures and + authorizes paid work before generating or locking a local snapshot. +- Release checks now enforce commercial prices and trial terms, expose skipped tests instead of + hiding them behind duplicate quiet flags, and verify the full-stack dependency imports used by + the HTTP authorization boundary. + +### Security + +- Credential state directories are owner-only, product token forms are redacted consistently + from logs, checkout overrides fail closed to validated HTTPS or loopback HTTP destinations, and + unsafe control characters can no longer reform blocked browser URL schemes. + +## [1.1.0] - 2026-07-26 + +Public 1.1.0 hosted-connect and graph-experience release. + +### Added + +- **`engraphis connect --token engr_ct_…`**: the missing client half of device connect. + `cloud_session.save_bootstrap()` is the only writer of `~/.engraphis/cloud_session.json`, + and it had no production caller: the docs told paying customers to prefer a file nothing + created, so a purchased installation could not be connected without hand-writing state. + The new command redeems the one-time connect token from the account portal against + `POST /v1/devices/connect`, saves the returned session with owner-only permissions, and + verifies `cloud_session.configured()` before reporting success. The token is sent in the + request body and nowhere else; it is never printed, logged, or written to disk, and every + refusal maps to fixed, actionable copy (an expired or already-used token is not confused + with a lapsed subscription). Session storage is pre-flighted before the exchange, so an + unwritable state directory or a `cloud_session.json` replaced by a link fails the command + *without* spending the single-use token; the customer fixes the path and retries with the + same token instead of returning to the portal for a new one. Faults that can only happen + *after* the exchange: a reply truncated mid-body (`http.client.IncompleteRead`), or an + endpoint that stops resolving before the session is written (`CloudUrlUnresolved`) are + reported as errors that say the token was already used, rather than escaping as tracebacks + that leave the customer unable to tell whether to retry. Also installed as + `engraphis-connect`. +- An `engraphis` front-door command that dispatches to the existing `engraphis-` + entry points, so the command the account portal displays is runnable as shown. +- A stable per-installation identity at `~/.engraphis/client_identity.json` (random ULIDs, + not a hardware fingerprint) so reconnecting a machine updates its existing installation + instead of registering a new device every time. + +### Removed + +- Removed an unimplemented hosted export claim from public product surfaces. + +### Changed + +- Managed compute consent now travels with the cloud account: an installation connected to + Engraphis Cloud is enabled for managed analytics, dreaming, and consolidation **by + default**, because connecting already accepts the terms that cover it. A local-only + installation with no cloud session is still never allowed. + `ENGRAPHIS_MANAGED_COMPUTE_CONSENT` remains as an explicit operator override (`=0` opts a + connected installation back out, `=1` forces it on regardless of session state) and is no + longer surfaced anywhere in the UI. + +## [1.0.1] - 2026-07-24 + +Public 1.0.1 client reliability release. + +### Fixed + +- Cloud Sync now defaults to `https://relay.engraphis.com` and safely migrates the former + dashboard host and retired Railway relay URL without changing customer-provided relay URLs. +- Default Pro and Team upgrade links now target the live authenticated account portal rather + than the retired Team dashboard host. +- Hosted endpoint validation now fails closed unless DNS establishes a globally routable + destination, and credential-bearing HTTPS connections pin the vetted address while preserving + original-host TLS verification to prevent DNS-rebinding SSRF. +- Hosted Automation and maintenance requests now use the selected workspace end to end rather + than silently falling back to the first workspace. +- The Automation tab has one proposal action, clear managed-upload disclosure, and explicit + managed-compute consent in addition to entitlement checks, snapshot redaction, and limits. +- Commercial metadata now describes Pro as one owner account across that owner's local + installations, matching the hosted entitlement model; Team remains billed per named seat. +- API error responses and provider logs no longer expose arbitrary exception or configuration + text; local folder and repository reads resolve and re-check filesystem boundaries. +- Entity extraction and dashboard asset migration avoid adversarial regular-expression + backtracking. CodeQL now disables pull-request diff-informed analysis and CI fails on every + raw SARIF result, including pre-existing and source-suppressed results. +- The documented grounded-recall evaluation prints with the default Windows console encoding. +- Hosted Pro and Team links preserve the selected plan through account creation and Checkout. +- A total `401`/`402`/`403` Cloud Sync authorization loss restores the hosted recovery CTA, + while a successful empty or read-only workspace remains a partial result instead of being + misreported as a total denial. + +## [1.0.0] - 2026-07-23 + +Public 1.0.0 open-core GA release. + +### Added + +- The search-first Galaxy Knowledge Graph explorer with deterministic communities, canonical + evidence-weighted scenes, entity/relation search, temporal filtering, evidence and history + inspection, strongest-evidence paths, synchronized accessible tables, saved scene state, + local PNG/JSON/CSV export, Simple and Advanced views, and a locally bundled ForceGraph + D3 renderer + under the strict same-origin CSP. +- Additive schema-v4 canonical identity and bi-temporal edge-support records; deterministic + graph scene, suggestion, entity, and path APIs; and a persisted graph-index job with dry-run, + progress, cancellation, bounded errors, audit records, and tamper-evident receipts. +- A 29-tool MCP surface with explicit behavior annotations, operation receipts, exact session + retry semantics, portable plugin manifests, and checksummed skill assets. +- Customer-side hosted protocols for scoped Cloud Sync, rotating cloud sessions, Analytics, + and managed Automation requests, plus explicit manual folder exchange for local workflows. + +### Changed + +- The public distribution is a universal Python open-core package that runs only as a customer + node. Hosted authorization, billing, relay storage, managed compute, Team identity, workers, + and vendor operations remain private services. +- Commercial compatibility modules now expose presentation and customer-protocol metadata only; + no environment variable turns the public package into a hosted Engraphis service. +- Session identity is exact across workspace, repo, authenticated user, agent, and goal; callers + can request a distinct run with `force_new=true` and observe retry reuse explicitly. +- The legacy graph view defaults to deterministic community islands, keeps sparse influence + bridges subordinate, and renders bounded A-MEM links when entity extraction is disabled. The + repository screen demo proves session handoff, bi-temporal supersession, recall evidence, and + history without an external service. +- The hosted no-card trial is exactly 3 active days after email confirmation. A separate + `workspace_write_grace` may preserve ordinary local writes for at most 24 hours but never + extends trial or paid cloud access. +- Apache-2.0 rights in published releases remain irrevocable; proprietary hosted value is + enforced by the private implementation and service authorization boundary. + +### Fixed + +- Session start/end and session-scoped writes are atomic under concurrency; exact retries reuse + one session while intentionally separate runs remain distinct. +- Rotating refresh credentials serialize across threads and processes, persist replacements in + owner-only state, close failed HTTP responses, and never regress to a stale bootstrap value. +- Managed snapshots reserve a monotonic generation in the same local write transaction as the + capture, use one operation ID per run and retry, redact provider errors, reject unknown + sensitivity, exclude session and secret data, and enforce exact record/byte limits. +- Graph reads, suggestions, evidence, history, indexing, exports, audit views, fallback search, + and workspace statistics consistently enforce workspace and session boundaries, including + forgotten session-only graph evidence. +- Windows private-state validation uses safe file metadata checks without weakening symlink, + ownership, size, or atomic-publication protections. +- Recall graph seeding uses one boundary-aware compiled pattern instead of rescanning every + memory per entity, and the streamable HTTP launcher warms the singleton service before + accepting clients. +- Graph GET requests remain read-only and return a rebuilding conflict while an explicit + mutating index job is in progress. + +### Security + +- Bare memory IDs, shared-workspace controls, graph entities, statistics, snapshots, exports, + audit rows, and keyword fallbacks cannot cross authenticated session or workspace boundaries. +- Managed uploads require explicit customer consent, are capped at 16 MiB and 100,000 rows, + omit all session-scoped and secret-class memories, and surface only fixed client-safe + provider errors. +- Customer credentials remain owner-only, redirect-safe, serialized during rotation, and are + never substituted with an unproven local machine identifier. + +## [0.9.9] - 2026-07-18 + +Security and reliability release spanning graph isolation and performance, Team / Pro +authentication, licensing and relay behavior, and the redesigned Knowledge Graph. + +### Security + +- Code-graph search, path, impact, export, and unified-graph reads now apply the same + workspace/repo/session hierarchy filter as recall. Session-scoped memory content and + identifiers previously remained reachable through persisted code-memory links from a + repo-level caller. Reindexing still rebuilds those links for the owning session, but + every read now filters them by caller-visible scope. +- Auth-bound dashboard users can no longer omit `workspace` to reach global recall. + Inspector per-user and deployment bearer tokens now bind real or synthetic identities + before personal receipt reads, so the deployment service account remains available for + shared automation without bypassing personal-folder ownership. The standalone + read-only graph endpoint also disables lazy write-on-read backfill. +- Repository indexing now creates a first-time Team workspace through the same + privacy-aware path as remember/import/session writes, instead of silently creating a + shared, unowned folder for the authenticated user. + +### Fixed + +- Code-graph layer responses and filters now use the concrete persisted layer, including + inferred causal relations and explicitly semantic code edges. Code-memory link rebuilds + page through every live repo-associated memory instead of clearing the bridge and + stopping at 5,000, and Git impact parsing uses NUL-delimited paths without rewriting + valid filename characters. +- Graph layer predicates are applied before workspace and code-edge response caps, and an + explicit all-off layer selection remains empty instead of reverting to every layer. + Layout preset and custom link-distance changes also recompute component centers while + preserving the existing graph data and node objects. + Filter reloads also tolerate transient graph-data invalidation, so restoring layers + redraws the canvas instead of leaving the explorer list beside an empty graph. +- Oversized audio/video resources are rejected before transcription begins. A blank + `ENGRAPHIS_GRAPH_TOKEN` now correctly falls back to `ENGRAPHIS_API_TOKEN`. +- The sync relay now has its own per-IP token bucket + (`ENGRAPHIS_RELAY_RATE_PER_MINUTE`, default 600) instead of sharing the + 60-request/minute license-registration budget. A full 64-bundle sync round can complete + without throttling its final requests, while invalid-key floods remain bounded before + Ed25519 verification. +- Every `/start-trial/verify` response (success, each error, and the 429) sends + `Cache-Control: no-store` and `Referrer-Policy: no-referrer`. The request URL carries + the one-time token, so the error pages are as Referer-leaky as the success page that + holds the key; they previously used separate inline header literals and had drifted. + +### Changed + +- `GET /api/auth/users` checks `admin` at the route, matching `auth.min_role()`. The + middleware already enforced admin, so this is defense in depth with no behaviour change; + the route previously said `member`, which was dead code that misrepresented the policy. +- Successful version-tag publication now creates the matching GitHub Release and attaches + the same validated wheel and source distribution sent to PyPI. Manual workflow dispatch + remains build/check-only, and the release job is tag-gated behind successful PyPI + publication. +- The Knowledge Graph defaults to compact component-aware packing and adds community, + radial, constellation, original, and custom layouts; selectable Cyberpunk, Galaxy, + Solar system, and Classic visual styles with persisted palettes; per-type node colors; + a synchronized keyboard-accessible explorer; collision-aware labels; and responsive + controls. Large graphs reuse rendered data, cap explorer DOM rows, reduce animation + work, and suppress expensive dense-graph effects. +- The duplicate global Recall shortcut was removed from the dashboard header. Recall + remains available in the Memory Operations sidebar and from contextual page actions. +- The README documentation was expanded to clarify note-link graphs, agent memory, code + awareness, encryption, and sleep-time consolidation without making unmeasured product + comparisons. +- The README now documents Command Code CLI as an MCP-native client and includes its + verified stdio registration command. + +## [0.9.8] - 2026-07-18 + +Hardening release focused on dependable installation, upgrades, startup, dashboard use, +and safe hosted deployment. + +### Security + +- Every entrypoint sends baseline response headers: CSP, `X-Frame-Options: DENY`, + `X-Content-Type-Options`, `Referrer-Policy`, `Permissions-Policy`, and HSTS over HTTPS + only. Override with `ENGRAPHIS_CSP` / `ENGRAPHIS_HSTS`; set either to an empty string to + omit that header where a fronting proxy supplies its own. +- Loopback/bootstrap trust now rejects all common forwarding metadata, including + `X-Forwarded-Proto`; a same-host TLS proxy can no longer make an internet request + look like an unproxied local setup request. +- Inspector first-admin setup now uses the auth store's atomic empty-database gate, so + concurrent different-email requests cannot both create administrators. + +### Added + +- MCP clients now receive canonical recall, session, durable-memory, and handoff guidance + through the server's initialization instructions. +- The dashboard exposes a small `/api` service index, and the graph CLI documents its + public commands without showing the internal merge-driver command. +- Regression coverage now exercises the sqlite-vec backend, workspace-aware entity recall, + installed database migration, encryption packaging, CLI startup, update paths, and release + artifacts. + +### Changed + +- Installed builds now keep the default database in the platform user-data directory. + Existing package-directory databases are copied with SQLite's backup API, validated, and + preserved as recovery copies; source checkouts retain their repository-local default. +- `engraphis-update` discovers the highest stable SemVer tag, validates explicit versions, + fails closed on fetch errors, refuses dirty editable worktrees, and keeps pip, pipx, Git, + and documents the source-rebuild path for locally built Docker images. +- Dashboard styling and navigation were reworked with five selectable themes, responsive + mobile behavior, semantic landmarks, improved keyboard focus, clearer confirmations, and + fully self-hosted browser assets. +- Console launchers now validate arguments before optional imports, report actionable startup + failures, display reachable IPv4/IPv6 URLs and resolved database paths, and advertise the + current dashboard and API routes. +- Optional-dependency bounds and extras were refreshed. The cross-platform `all` extra no + longer pulls the platform-limited SQLCipher driver, while encryption continues to fail + closed when no compatible driver is available. +- The release workflow now pins actions by commit, runs the full test/evaluation and package + validation gates, matches release tags to package versions, and reserves publishing for + validated tag pushes. Bundled browser-library license notices are included in distributions. +- Installation, hosting, sync, graph-query, MCP tool-count, and database-location guidance was + synchronized with the current commands and runtime behavior. + +### Fixed + +- Installed `engraphis-init` configuration is now loaded from the current directory's + `.env` without parent traversal, while explicit environment variables retain precedence. + Upgrading no longer opens a fresh platform-default database instead of the database the + user selected through `engraphis-init`. +- A failed dashboard memory-detail request can no longer retain a prior memory identity or + leave write controls enabled, preventing a later Save from modifying the wrong memory. +- A fresh hosted deployment now renders an actionable, non-data bootstrap screen when remote + API access is denied by default; it offers the safe Team-trial path or deployment-variable + setup without exposing account-wide license activation to a signed-out browser. +- Dashboard, REST, Inspector, MCP, licensing, sync, billing, and provider failures now return + bounded user-facing messages rather than raw exceptions or upstream response bodies. +- Trusted-proxy handling now evaluates the rightmost forwarded hop, supports exact/CIDR + allow-lists, and prevents untrusted forwarding headers from changing URLs or secure-cookie + decisions. Interactive API documentation is disabled on user-facing servers by default. +- Dashboard handlers now read memory, workspace, member, and token identifiers from escaped + `data-*` attributes instead of interpolating untrusted values into inline JavaScript. +- Repository-graph JSON output now escapes non-ASCII labels so Windows console encodings do + not turn successful `impact`, `prs`, or query commands into exit-code 2 failures. +- A server-only installation now includes the multipart parser required by dashboard import + routes instead of depending on the unrelated MCP extra to provide it transitively. +- `engraphis-mcp --help` works without importing the optional MCP stack; server-only and + explicitly offline configurations no longer emit misleading missing-dependency warnings. +- Dashboard and legacy-server launch failures retain database recovery details instead of + collapsing them into generic errors, and invalid port values are rejected cleanly. +- SQLite vector selection is now tested in both accelerated and offline-fallback modes, while + memory writes remain durable and audited if an index update fails. +- The zero-configuration Compose dashboard now admits its Docker host bridge while both + published ports remain loopback-only; widening a port requires an API token. +- Git-installed updates retain their recorded PEP 610 remote, and failed editable updates + restore the original branch without exposing a Python traceback. +- Customer-operated sync relays are separated from the managed license/trial/invite service, + and the sample `.env` no longer overrides installed database defaults with a relative path. +- MCP end-of-session guidance again represents completed work with an empty unresolved list + instead of persisting a fake open thread. + +## [0.9.7] - 2026-07-17 + +### Security +- Team-mode login gained a per-source-IP failure throttle (25 failures / 15 min) + alongside the existing per-email lockout, closing the credential-stuffing sweep + that tried each address once; lockouts now surface as a typed + `AccountLockedError` mapped to HTTP 429 + `Retry-After` (previously 401, or a + 429 derived by substring-matching the error message). + +### Fixed +- `remember`/`remember_with_resolution` are now atomic across the neighbor-resolve → + insert sequence (engine-level write lock): concurrent near-duplicate writes can no + longer both resolve ADD and store duplicates instead of NOOP/INVALIDATE. +- The Inspector's `/api/auth/login`/`setup` no longer run PBKDF2 (600k iterations) + on the asyncio event loop; password hashing moved to a worker thread, so a burst + of logins can't stall every other request. +- A failed vector-index upsert on the write path is now logged and audited + (`index_upsert_failed`) instead of silently swallowed. Previously, the memory + stayed invisible to semantic recall with no trace. +- URLs built from a bind host are now IPv6-safe and connectable (`engraphis.netutil`): + `ENGRAPHIS_HOST=::` no longer yields the malformed `http://:::8700` in the printed + dashboard URL, the :8710 redirector target, or `Settings.base_url`; wildcard binds + map to loopback. +- The Docker image no longer bakes an IPv4-only bind: the entrypoint defaults + `ENGRAPHIS_HOST` to dual-stack `::` when the kernel has IPv6 (what Railway's + private-network healthchecks require) and `0.0.0.0` otherwise, so wiping the + service's env vars can't regress the 2026-07-16 healthcheck outage. + +### Changed +- Consolidated four per-app bearer-token checks into one constant-time + `inspector.auth.bearer_ok` helper (scheme now matched case-insensitively per + RFC 7235 everywhere); extracted the ~230-line code-graph HTML/Markdown export + templates from `core/engine.py` into `core/codegraph_export.py`; documented the + v1/v2 split in `engraphis/routes/__init__`; entity ancestor-widening in graph + recall now applies to `workspace_id` symmetrically with `repo_id`; filtered + sqlite-vec searches cap their geometric widening with a single full scan. + +### Added +- Schema v3 logical graph layers (`temporal`, `entity`, `causal`, `semantic`), privacy-safe + SHA-256 receipt chains, optional LLM/host retention supervision, and a persistent code↔memory + bridge. +- Incremental multi-language repository indexing (Python, JS/TS, Go, Rust, Java, C#, C/C++, + SQL, Terraform), docstrings/comments, variables, inheritance/implementation, weighted + communities, hotspots, path queries, git/PR impact analysis, portable JSON/HTML/Markdown + exports, and a graph union merge driver. +- Local multi-format resource ingestion for text/code/HTML/DOCX, optional PDF/image OCR and + faster-whisper transcription, plus live PostgreSQL schema introspection with DSN redaction. +- Seven MCP tools for code paths/impact/export, PostgreSQL schema ingestion, and receipt + list/verify/export, bringing the tool surface from 20 to 27. +- `engraphis-graph` workflow CLI and token-protected `engraphis-graph-server` read-only HTTP + surface. + +### Changed +- Railway hosting now supports Pro solo single-admin deployments: any active Pro or Team + entitlement can bootstrap the first admin and activates the login wall, while member + seats and direct hosted agent writes remain Team-only. The hosting guide now covers both + Pro solo sync-relay and Team member flows. + +### Fixed +- 1-hop graph recall (and the PPR large-graph fallback) now honors `graph_layers`, matching + the PPR arm: `Store.neighbors()` gained a `layers` filter. +- `FolderTransport.push()` no longer follows peer-planted symlinks in the shared sync folder + (unpredictable temp name + `O_CREAT|O_EXCL|O_NOFOLLOW`), closing an arbitrary-file-write + vector that mirrored the already-hardened read side. +- `engraphis-graph-server` treats an empty `--host`/`ENGRAPHIS_GRAPH_HOST` as non-loopback + (it binds all interfaces), so the bearer-token requirement can no longer be skipped. +- Caller-supplied `metadata.retention_supervision` is stripped at the service boundary; only + the validated `retention_class` presets can influence importance/stability. +- `merge_workspaces()` no longer duplicates symbols/code edges when both workspaces indexed + the same file in a same-named repo: the losing snapshot's rows are cleared, and its + memory↔code links are re-pointed at the surviving same-fqname symbols. +- `engraphis-graph impact/prs` reject leading-dash git revisions (git option injection), and + graph exports refuse a symlinked output directory and are written atomically without + following pre-planted symlinks. +- The unified graph endpoint bounds entity edges and code edges/links per request + (`limit`-derived cap) so a large workspace graph or indexed repo can't produce unbounded + viewer-role responses. +- Relay sync fails closed when a workspace's settings are unreadable rather than treating a + possibly-personal folder as shared: in the sync CLI and in the dashboard/background + `_sync_all` path; resource extraction enforces its own raw-size cap. + +## [0.9.6] - 2026-07-16 + +### Added +- **Agent Connect for hosted Team instances.** Members can mint SHA-256-hashed per-user + bearer tokens in Settings and use the hosted v2 store through `POST /api/remember`, + the existing read routes, token management under `/api/auth/token*`, and + `GET /api/auth/connect-info`. Tokens retain the user's role and personal-folder scope; + viewers are read-only and disabling a user invalidates their tokens immediately. +- **Authenticated MCP-over-HTTP at `/mcp`.** When the MCP extra is installed, the + dashboard mounts the same 20 tools as the standalone server and injects its existing + `MemoryService`, avoiding a second SQLite writer. The endpoint requires an active Team + entitlement and per-user bearer token, enforces viewer/member/admin roles per tool, and + reports actual mount availability through connect-info. +- **One-click Railway hosting.** Added `railway.json`, the README deploy button, and + `docs/HOSTING_RAILWAY.md` for persistent volumes, forwarded HTTPS headers, Team + entitlement bootstrap, member invites, and HTTP/MCP agent connection. +- **Two new MCP context tools.** The MCP inventory grows from 18 to 20 with + `engraphis_answer`, a compatibility alias for the existing grounded-recall contract, + and `engraphis_proactive_context`, also available at `POST /api/proactive-context`. + Proactive packets include bounded task/agent state, cited memories, suggested queries, + and the previous session handoff. Optional LLM prose is accepted only when every claim + carries a valid citation. +- **Structured LLM ingestion and consolidation.** `ENGRAPHIS_EXTRACTOR=llm_structured` + validates typed facts, entities, relations, keywords, and confidence; that metadata is + preserved through storage and automatically feeds the graph. Settings now includes a + **Connect your LLM** card backed by `/api/llm/status` and `/api/llm/test`. + Consolidation adds schema-validated facts and explicit source supersession across the + service, REST, MCP, and CLI surfaces, with deterministic fallback on provider/schema + failure. +- **Opt-in deterministic memory intelligence APIs.** Added conflict triage for duplicate, + refinement, contradiction, and obsolete candidates, plus a serializable `UserModel` + that learns interaction preferences and reranks recall results. These helpers do not + mutate the store or alter default recall unless a caller invokes them. + +### Changed +- **Team mode is opt-out by default.** `ENGRAPHIS_TEAM_MODE=0` (or false/no/off) disables + Team plumbing. A fresh solo install stays open, first-admin setup requires a live Team + entitlement, and an existing team's authentication wall remains active if its license + lapses so private data never becomes public. +- Pre-login license status and trial routes now allow a fresh instance to start a Team + trial before first-admin setup. Purchased keys bootstrap through + `ENGRAPHIS_LICENSE_KEY` or the license file; `/api/license/activate` remains admin-only. +- Package fallback metadata and all user-facing tool inventories now agree on version + `0.9.6` and 20 MCP tools. + +### Fixed +- **Agent Connect and dashboard lifecycle:** corrected generated endpoint URLs, retained + one-time token visibility, made `/mcp` bearer-only, bound MCP sessions to their initiating + user, rechecked tool roles on every call, retained DNS-rebinding protection, closed + previously injected stores, and made connect-info reflect the real optional MCP mount. +- **License and Team enforcement:** authoritative revocations override cached entitlement + and persist tombstones for previously unrecorded keys; transient failures may use only + an unexpired lease; public license/trial bootstrap routes close after the first Team user; + trial rate limits trust forwarded addresses only from configured proxies; managed + requests use explicit client headers; retired managed relay URLs are canonicalized + across key issuance, license/trial, invite, and sync clients; and configured keys + that fall back to free after transient outages retry automatically. +- **Python and packaging compatibility:** rate-limit buckets and audit exports use + timezone-aware UTC APIs, package metadata uses the SPDX license format, and the + deterministic fallback matches the default embedding model’s 384 dimensions. +- **Memory and retrieval integrity:** audit writes are committed durably, recall excludes + non-live rows, mixed embedding dimensions no longer crash recall and have a backed-up + repair path, sync enforces workspace/repository boundaries in both directions, graph + provenance is pruned per memory instead of deleting shared edges, SQLite-vector distances + are converted to cosine similarity, entity expansion matches complete names, and the + sentence-transformers adapters support both legacy and renamed dimension APIs. +- **Structured-data safety:** extraction metadata survives ingest unchanged, proactive and + consolidation inputs are bounded, structured consolidation rejects source IDs outside + the requested cluster, and synthesized context cannot replace deterministic output + without valid citations. +- **Dashboard graph navigation:** focusing an isolated node now retains the requested node + through the delayed renderer retry instead of reporting a false “Entity not in view.” +- **Dashboard typography:** replaced sub-12px text and the flat type ramp with a consistent + 12/16/24/32px hierarchy while preserving responsive layout. + +### Documentation +- Updated the README, Agent Connect, Railway, Kilo Code, bundled memory skill, benchmark + command, and package-version fallback to match the shipped routes, tool count, setup + order, and extractor/consolidation options; removed the unused shortcut icon helper. + +## [0.9.5] - 2026-07-14 + +### Changed +- **Team mode is now ON by default (opt-out).** `ENGRAPHIS_TEAM_MODE` defaults to on; + set `ENGRAPHIS_TEAM_MODE=0` (or false/no/off) to disable. The per-user login wall is + no longer raised just because the mode flag is on. It now requires a *live* `team` + feature entitlement (`licensing.has_feature("team")`), checked at request time in + `dashboard_app.py` and reflected in `/api/auth/state`. Solo / no-license installs stay + fully open, and the wall appears the moment a team license key is added, even via the + dashboard UI at runtime. A `team` license is still required to *add seats* beyond the + first admin (bootstrap admin is created unconditionally). Docs (`.env.example`, + `AGENTS.md`, `README.md`, `SECURITY.md`, `scripts/init.py`) and team-mode test fixtures + updated. +- **Team-invite email rewritten to separate "join" from "activate a key".** The old + invite conflated the two, so members pasted the shared team key into the hosted/Railway + dashboard, saw it "work" (it just re-activated a license already active there), and + thought they'd joined, when joining means signing in with email + password. The email + now frames two distinct options: **Option 1** (required to join) sign in to the team + dashboard with email + the admin-set password, with explicitly *no license key needed here, + don't paste one*; **Option 2** (optional) run Engraphis on your own machine and access + the team's memories locally; that is what the shared team key is for (LOCAL + `http://127.0.0.1:8700` → Settings → License, then Settings → Cloud Sync to pull the + converged team store down to a local offline copy). Invites now always carry a + clickable sign-in link: `dashboard_url` resolves explicit arg → `ENGRAPHIS_DASHBOARD_URL` + → `DEFAULT_TEAM_DASHBOARD_URL` (`https://team.engraphis.com/`). A footer with the + canonical site + repo links is added as env-overridable module constants + (`SITE_URL`/`REPO_URL`) so the URLs can't drift per-email. `tests/test_billing.py`. + +### Fixed +- **Intermittent `database is locked` from `set_service`.** `routes/v2_api.set_service` + swapped the global `MemoryService` without closing the previously-bound service's store + connection, so under heavy test churn a deferred-GC close of the old SQLite/WAL handle + collided with the next `MemoryService.create` on the same path. The prior store is now + closed on swap (best-effort, never blocks the swap on a close error). + +### Docs +- **README now documents three previously-undocumented shipped features** (the features + themselves shipped in 0.9.3): sub-file chunking (`ENGRAPHIS_EXTRACTOR=chunk` + the + `eval.chunking_eval` whole-file-vs-chunked harness), auto-dreaming (the background + cross-cluster-inference loop, accumulation + idle trigger, `dream_inference` + provenance/auditability), and every automation dream knob exposed via the dashboard + Automation tab and the `GET/POST /automation` + `POST /maintenance/run` API. Also: a + **Team early-access beta** callout (top + feature/pricing tables + Free-vs-Pro section) + and a **daily-update reminder for maintainers** near the top (code wins; fix the doc in + the same change). + +### Chore +- `.gitignore` now excludes `automation.json` / `autosync.json` (regenerable local + runtime state from `engraphis/automation.py`, not source content). + +## [0.9.4] - 2026-07-14 + +### Fixed +- **The dashboard (`engraphis-dashboard` / `http://127.0.0.1:8700`) would not start.** + `scripts/start_dashboard.py` runs uvicorn against `engraphis.dashboard_app:app`, but + `dashboard_app.py` only defined the `create_app()` factory and never built a module-level + `app` instance, so uvicorn aborted with `Attribute "app" not found` and nothing bound + port 8700. The missing `app = create_app()` (present in `engraphis/app.py` and + `engraphis/redirector.py`, but dropped from `dashboard_app.py`) is now restored. The + background autosync/dreaming/revalidation loops inside `create_app()` are pytest-guarded, + so importing the module under test is side-effect-free. +- **Flaky `database is locked` dashboard test.** + `test_consolidate_inference_pass_is_pro_gated` opened two FastAPI `TestClient` lifespans + back-to-back on the same temp DB file; the first app's still-open SQLite connection + blocked the second's schema init. Split into two one-client test functions, matching + the convention already documented above `test_analytics_and_export_*` (two TestClients + in one test reproducibly deadlock). Full suite now green (693 passed, 3 skipped). + +## [0.9.3] - 2026-07-14 + +### Added +- **Email-verified self-serve trial + abuse protections on the trial endpoint.** + Starting a trial now requires a verified email and sends a one-time confirmation link + before any license is issued; the request path is rate-limited so the endpoint can't be + used to spam or farm trials. This raises the bar significantly above the previous + device-only gate while keeping the same paste-a-key activation flow on the dashboard. + `tests/test_cloud_license.py`, `tests/test_dashboard_v2.py`, + `tests/test_online_only_enforcement.py`. +- **Deterministic, offline sub-file chunking on the write path (`ENGRAPHIS_EXTRACTOR=chunk`).** + A third `Extractor` alongside passthrough/LLM: `ChunkingExtractor` splits a document into + retrieval-sized `ExtractedFact` chunks that preserve meaning: markdown headings start new + chunks and become the title, fenced code blocks stay intact, prose is packed to a token + budget (`ENGRAPHIS_CHUNK_TOKENS`, default 256) with a sentence-level overlap + (`ENGRAPHIS_CHUNK_OVERLAP`, default 32); a hard per-document cap + (`ENGRAPHIS_CHUNK_MAX`, default 200) bounds amplification. numpy/stdlib only, so it runs + under the offline gate and is byte-identical across runs. This gives long, multi-topic + documents finer retrieval units instead of one diluted memory; the bundled evaluation below + preserves Recall@5 while reducing retrieved context. New: `ChunkingExtractor` in + `backends/extractor.py`; `tests/test_chunking_extractor.py`. +- **File/folder imports chunk too.** With `ENGRAPHIS_EXTRACTOR=chunk`, + `import_folder`/`import_files` split each file into several retrieval-sized memories + (each still `trusted:false`, stamped with `metadata.chunk={index,of,heading}`) instead of + one; the LLM extractor is deliberately never applied to the local import path (no external + calls on untrusted disk files). A file still counts as one imported unit. + `tests/test_import_chunking.py`. +- **Chunking eval + `longdoc` dataset.** `eval/chunking_eval.py` + + `eval/datasets/longdoc.jsonl` compare whole-file vs chunked ingestion through the real + recall pipeline. On the offline embedder: identical recall@5 (1.000) at **~73% fewer + context tokens** (809 → 219) and ~4× smaller tokens-to-evidence (162 → 42); the "quality per token" + number `BENCHMARKS.md` calls for. `tests/test_chunking_eval.py`. +- **"Dreaming" trigger for automated maintenance.** `automation.should_dream` / `dream_due` + run a consolidation sweep *before* the cadence when enough new episodic memories have + accumulated **and** the store has gone quiet (`dream_min_new` / `dream_idle_minutes` policy + knobs); wired into `scripts/auto_maintain.py`. Purely additive to the existing cadence, so + cron behaviour is unchanged; still Pro-gated. `tests/test_dreaming_trigger.py`. +- **Associative cross-cluster inference (dream pass 4).** `consolidate.infer_links` / + `consolidate(infer=True)` proposes evidence-only links between memories in *different, + dissimilar* subject clusters that share a bridging entity: the "connect distant dots" step + same-subject distillation never reaches. **Off by default** (`infer=False`); the pass + follows the sweep's own `dry_run` flag, so a dry-run proposes into the report and a real + run applies. Applied inferences are low-salience (`importance=0.25`), `trusted:false`, + `source='dream_inference'`, linked to their sources and audited, so a bad inference is + visible, downweighted, and never merge-eligible into a trusted fact. Fan-out capped, + idempotent. Entity matching is now word-boundary (so `Redis` won't fire on + `rediscovered`) and the per-sweep text scan is computed once, not per entity. + `tests/test_inference.py`. +- **Inference is reachable from the maintenance path.** A new `infer` policy knob (off + by default) runs the inference pass inside `run_maintenance`, whether manual or from the dream loop, + following the sweep's `dry_run`. `/api/consolidate` takes `infer` (`false` by default); + `/api/automation` round-trips `infer`; the dashboard Automation tab has an Inference + toggle. `tests/test_dashboard_v2.py` (policy round-trip + `/maintenance/run` proposes the + Redis bridge), `tests/test_dashboard_dream_ui.py`. +- **Dreaming runs without cron.** A dashboard background loop (`_maybe_start_dreaming`, + mirroring auto-sync) runs a maintenance sweep whenever `automation.dream_due` fires. It is opt-in, + Pro-gated, fault-isolated, with an `ENGRAPHIS_DREAM_LOOP=0` kill switch. The `/api/automation` + policy round-trips the `dream` / `dream_min_new` / `dream_idle_minutes` knobs, and the + dashboard's Automation tab surfaces them as form controls (toggle + thresholds). The + trigger now scopes its accumulation/idle count to the policy's `workspaces` (a burst in + an out-of-scope workspace no longer fires a sweep). `tests/test_dreaming_trigger.py`, + `tests/test_dashboard_dream_ui.py`, `tests/test_dashboard_v2.py`. + +### Fixed +- **First-run team-mode bootstrap hardened.** The admin-creation path no longer depends + on an external relay round-trip succeeding to provision the first seat, and concurrent + first-admin requests are serialized so only one unlicensed bootstrap admin can ever be + created. Subsequent seat additions still require an active Team license. +- **First-run team-mode bootstrap fixed (frontend).** The admin-account screen now triggers + the trial/activation step before provisioning the first admin, so a fresh self-hosted + instance no longer deadlocks on the team-feature gate with no way to proceed. + No backend change; frontend-only. +- `MemoryService.create` now defaults `extractor` from `settings.extractor` + (`ENGRAPHIS_EXTRACTOR`) when unset, mirroring the existing `graph_extractor` fallback so + the dashboard and automated-maintenance front ends honor the config knob, not just the MCP + server and CLI. An explicit `extractor="none"` still overrides the environment. + +### Security +- **Closed a Pro-feature bypass on the manual consolidate endpoint.** The inference pass + (a paid capability) was reachable through the free housekeeping endpoint without a + license; it is now gated at the route and reinforced inside the service layer, so no + caller can reach the Pro-only path without a server-approved license. The free manual + consolidate action is unchanged. `tests/test_dashboard_v2.py`, `tests/test_inference.py`. +- **Strengthened license enforcement and revocation handling.** Reaffirmed that every paid + surface requires a live, server-validated lease and fails closed when the server is + unreachable; tightened the verification so licenses can't be forged client-side, and + serverside-issued seats can't be minted without a valid license. Revoked or refunded keys + are now re-confirmed against the server on a background interval so they degrade promptly + rather than remaining usable until lease expiry, while legitimate offline customers are + never stalled. `tests/test_online_only_enforcement.py`, `tests/test_cloud_license.py`. + +## [0.9.2] - 2026-07-13 + +### Added +- **Personal vs. shared folders + a redesigned Team dashboard.** A folder can now be + created `visibility='personal'` (owned by, and visible/usable only to, the creating + dashboard user) or `shared` (the whole team, the previous, still-default behaviour). + Enforcement runs through a single workspace-authorization chokepoint, so every scoped + read/write inherits it and a non-owner cannot access another user's personal folder. + Personal folders are excluded from relay sync so they stay on-device. The **Team + dashboard** gains a team overview (seat usage + activity), a Folders panel that creates + and manages shared/personal folders (folder creation now lives here: the Workspaces + tab is selection-only in team mode), members with last-active, and a team audit log with + CSV export. New/updated: `service.py`, `routes/v2_api.py`, `dashboard_app.py`, + `static/index.html`; tests in `tests/test_personal_folders.py`, + `tests/test_dashboard_v2.py`, `tests/test_sync_dashboard.py`. + +### Changed +- README expanded with the missing features (cloud sync, encryption, import/ingest, + workspace ops, Docker, config, and more) and now links to the Engraphis Discord. + +## [0.9.0] - 2026-07-13 + +### Added +- **Automatic v1→v2 database migration on startup**: a pre-existing v1-shaped + `engraphis.db` (no `workspace_id` column) is backed up and migrated to the v2 + schema, so existing installs upgrade cleanly without manual SQL. + +### Fixed +- **Dockerfile default entrypoint** is now `engraphis-dashboard --no-open` (was the v1 + single-user `engraphis-server`), so a fresh container serves a working team dashboard + with auth/license/trial routes instead of a permanently signed-out UI. + `engraphis-server` remains available as an explicit override for single-user + deployments. +- **CI**: ruff lint errors and core-floor (numpy-only) test collection. + fastapi-dependent tests now skip cleanly on the minimal core floor. `loads_strict` + now rejects pathologically deep JSON on every Python version (3.12's JSON scanner + no longer raises RecursionError for ~1000-deep input, which had broken the + deep-nesting parsing guard and its test on 3.12). + +## [0.8.8] - 2026-07-13 + +### Security +- Hardened license validation and trial consumption tracking +- Improved offline trial tamper resistance + +## [0.8.7] - 2026-07-12 + +### Added +- **Dashboard "Import files & folders"** restored on v2 engine +- **Kilo Code integration docs** (`docs/KILO_CODE_INTEGRATION.md`) + +### Fixed +- Dashboard auth: session handling, role badges, member management +- License cloud enforcement: lease validation, online-only gating +- Service layer: workspace operations, memory reorder, merge + +## [0.8.6] - 2026-07-12 + +### Added +- Dashboard "Import files & folders" section restored on v2 engine + (`engraphis/service.py`, `routes/v2_api.py`, `static/index.html`, Workspaces tab) +- Server-side path import and drag-and-drop upload, both member-gated and bounded +- Imported memories marked untrusted by default; 21 new tests + +### Security +- Hardened folder import against path-traversal and containment bypasses + +## [0.8.5] - 2026-07-12 + +### Fixed +- Logout no longer re-triggers sign-in modal loop +- Team bootstrap: trial/license endpoints now accessible before first admin exists +- Expired/revoked Team license no longer locks out all logins +- Trial start now idempotent (no 400 on repeated calls mid-trial) +- Team trial grants 5 seats (was 1), enabling actual team evaluation +- Dashboard handles empty workspaces gracefully +- Static assets (dashboard HTML, vendor JS) now ship correctly in wheel + +## [0.8.4] - 2026-07-12 + +### Security +- Paid features now require a live, server-issued license lease +- Offline handling degrades gracefully with bounded grace when the server is unreachable +- Local/offline trial grants removed; trials are server-issued and tracked per device +- Issued keys are server-enforced by default + +## [0.8.3] - 2026-07-12 + +### Fixed +- Empty workspace `/api/memories` returns `[]` instead of 500 +- Online-only license enforcement: cloud-mode keys validated per request + +## [0.8.2] - 2026-07-12 + +### Fixed +- Static package discovery: `engraphis/static/__init__.py` added +- Vendor glob: recursive pattern so `static/vendor/` bundles ship in wheel +- Dashboard 500 on `GET /`: `static/index.html` was missing from wheel (packaging bug) +- Dashboard 500 on fresh install: `GET /api/memories` crashed on empty workspace + +--- + +## Earlier versions (condensed) + +### Versions 0.5.x to 0.7.x +- MCP server with 18 tools +- Memory Inspector product UI (`engraphis-inspector`, port 8710) +- Dashboard rebuilt on v2 engine with recall, governance, consolidate, analytics +- Team mode: login auth, viewer/member/admin roles, seat limits +- Grounded recall with cited answers and abstain gate +- Sleep-time consolidation with compaction accounting +- Personalized PageRank graph arm (HippoRAG-style) +- Offline signed license keys (no phone-home) +- Pro analytics dashboard +- Code-symbol graph via tree-sitter or regex fallback +- Docker + docker-compose deployment +- 300+ tests, eval harness, ablation suite + +### [0.1.0] - 2026-07-09 +- Initial public release: local-first AI memory engine for agents +- Ebbinghaus decay, interaction-aware recall, bi-temporal facts +- Background consolidation; you bring the LLM + +--- + +**Security reporting:** Email **security@engraphis.dev** for vulnerability disclosure. diff --git a/README.md b/README.md index 41289467..cc56c3ae 100644 --- a/README.md +++ b/README.md @@ -1,727 +1,727 @@ -# Engraphis - -[![PyPI version](https://img.shields.io/pypi/v/engraphis.svg)](https://pypi.org/project/engraphis/) -[![License](https://img.shields.io/badge/license-Apache--2.0-green.svg)](https://github.com/Coding-Dev-Tools/engraphis/blob/main/LICENSE) -[![Support](https://img.shields.io/badge/Buy%20Me%20a%20Coffee-support-yellow?logo=buy-me-a-coffee)](https://buymeacoffee.com/Jaixii) - -[https://engraphis.com/](https://engraphis.com/) - -[https://discord.com/invite/Wfr2ejBmY](https://discord.com/invite/Wfr2ejBmY) - -**Give your AI agents a memory. See it, search it, and maintain it, all in a beautiful WebUI on your own machine.** - -

- Engraphis Knowledge Graph tab: force-directed entity-relation network -
- Knowledge Graph · run engraphis-dashboard to see it live -

- ---- - -> **Open-core boundary:** this repository contains the free local engine, dashboard, MCP server, -> and customer-side clients. Hosted sync, analytics, automation, and team services run on the -> official hosted service; their server implementations are not distributed here. - -> **Support continued Engraphis development with Pro.** [Start a 3-day Pro trial](https://api.engraphis.com/account?plan=pro&interval=monthly&utm_source=engraphis&utm_medium=docs&utm_campaign=pro_conversion&utm_content=readme_intro&trial=pro#billing) -> or [subscribe to Pro](https://api.engraphis.com/account?plan=pro&interval=monthly&utm_source=engraphis&utm_medium=docs&utm_campaign=pro_conversion&utm_content=readme_intro#billing). - ---- - -## Measured token and context savings - -### Runtime estimator - -The dashboard Overview and Audit/Receipts views also show a receipt-backed estimate from -real context deliveries. It compares the host history or retrieved source baseline with the -context Engraphis actually emitted, keeps token counters and release versions separate, and -labels adaptive history reductions separately from packing savings. Receipts without estimator -metadata remain historical/unclassified. This measures estimated prompt-context reduction; it -does not measure provider billing. The `/context-savings` API and -`engraphis_context_savings` MCP tool accept optional `from_ts`, `to_ts`, and `release_version` -filters. - -

- Dark chart showing Engraphis using 98.21 percent less long-history context, 71.1 percent less retrieved content per question, 73.9 percent fewer tokens in the smallest useful memory, a 57.15 percent smaller recall payload proxy, and 47.8 percent less repeated-memory context after consolidation -
- Less repeated history means more room for the task, tools, and useful evidence. -

- -
-See benchmark details and reproduce the results - -### Controlled before-and-after example - -| Retrieval mode | Mean returned memory content | Recall@5 | -|---|---:|---:| -| Whole documents | 740.3 tokens | 1.000 | -| Engraphis structure-aware chunks | 214.1 tokens | 1.000 | - -The chunked mode returns the relevant passage instead of the whole document: **526.2 fewer tokens -per question**. Under the same model-context budget, that leaves roughly **526 tokens** for task -instructions or other relevant evidence. - -### Measurement details and reproducibility - -The table below records every current token/context efficiency measurement and its counting -boundary. - -| What is counted | Comparison | Measured reduction | Quality held constant | -|---|---|---|---| -| Cumulative reader context across a 1,986-question LoCoMo diagnostic | Full-history replay: **49,915,394** tokens → Engraphis: **891,857** tokens | **49,023,537 fewer context tokens** (**98.2133% lower**) | Focused retrieval used far less context; uncapped full history retained higher retrieval recall | -| Retrieved top-5 memory content, averaged per question | Whole documents: **740.3** tokens → structure-aware chunks: **214.1** tokens | **526.2 fewer tokens per question** (**71.1% lower**, about **3.5× smaller**) | Recall@5 **1.000** in both modes across 6 documents and 18 questions | -| Smallest returned memory that contains the reference evidence | Whole documents: **162.2** tokens → chunks: **42.4** tokens | **119.8 fewer tokens to evidence** (**73.9% lower**, about **3.8× smaller**) | The same 18 questions had a returned evidence-holding memory in both modes | -| Full versus compact recall payload proxy across one 26-question pass within a 260-timed-recall CodeMem run | Full proxy: **23,810** `engraphis.regex.v1` tokens → compact proxy: **10,202** tokens | **13,608 proxy tokens avoided** (**57.15% lower**) | 26 payload samples; 260 timed recalls; Recall@5, hit@5, and answer-token recall all **1.000** | -| Repeated-memory consolidation fixture | 12 related episodic memories: **230** tokens → one digest: **120** tokens | **110 tokens removed from the active digest** (**47.8% lower**) | Original memories remain available for provenance and audit | -| Small histories across 26 CodeMem agent tasks | Always retrieve: **1,883** total agent-facing tokens and **26** memory calls → adaptive: **1,942** tokens and **0** memory calls | Adaptive uses **59 more tokens** (**3.1% higher**) while eliminating all **26** memory calls | Both completed **24/26** tasks with the same deterministic offline task agent; this fixture demonstrates bypass behavior, not token savings | -| Packed prompt-context usage in the same 26-question CodeMem sample pass | Hard budget: **1,500** tokens; observed mean: **85.38**; observed maximum: **108** | A hard cap prevents a recall from exceeding its configured context budget | This is usage accounting, not a before/after savings comparison | - -The LoCoMo context-use row is an **unpinned, noncanonical retrieval diagnostic**, not official -LoCoMo QA, answer-quality, provider-cost, or leaderboard evidence. It is not reproduced by the -small offline fixtures below; [BENCHMARKS.md](BENCHMARKS.md) records its exact limitations and -the separate hash-bound canonical retrieval diagnostic. - -The compact payload shape avoids duplicating full memory bodies when the packed context and source -list are enough. The evaluator tokenizes JSON-shaped full and compact payload proxies built from -recall results; it does **not** serialize the MCP envelope or measure a transport response. The -fixture therefore does not measure model-provider charges, end-to-end task time, or customer cost -savings. - -The measures are deliberately separate and **must not be added together**: chunking counts the -content of retrieved memory records before `ContextPacker`, whereas compact recall counts a -serialized JSON-shape payload proxy. “Tokens to evidence” is the size of the smallest -retrieved memory record holding the reference evidence; it is not latency or end-to-end answer -accuracy. Chunking creates more focused stored records (24 chunks rather than 6 whole-document -memories in this fixture), so this is a context-efficiency result, not a storage-reduction claim. - -Reproduce the quality and token/context measurements without a network connection or API key: - -```bash -python -m eval.harness --dataset eval/datasets/codemem.jsonl --k 5 -python -m eval.grounded -python -m eval.chunking_eval -python -m eval.adversarial_memory_security -python -m eval.performance --dataset eval/datasets/codemem.jsonl --k 5 --iterations 10 --json -python -m eval.productivity --dataset eval/datasets/codemem.jsonl -``` - -These are small deterministic correctness and efficiency fixtures, not official LoCoMo / -LongMemEval QA scores or a third-party leaderboard result. Compact-response counts use the exact -`engraphis.regex.v1` counter; the chunking evaluation uses its documented deterministic -normalized-character estimator. Chunking measures retrieved memory content, while compact recall -measures a serialized JSON-shape payload proxy, not an MCP transport response. See -[`BENCHMARKS.md`](BENCHMARKS.md) for definitions, -limitations, canonical external-evaluation requirements, and the no-unsupported-claims policy. - -
- ---- - -## Full Engraphis install: pip install "engraphis[all]" - -The complete `engraphis[all]` install is the default way to use Engraphis: it includes the local -dashboard, Smart MCP server, documents, Cloud Sync client, and supported optional integrations. -Python 3.10+ is required. - -```bash -pip install "engraphis[all]" -engraphis-dashboard -``` - -The dashboard opens at [http://127.0.0.1:8700](http://127.0.0.1:8700). Local memory needs no -account or API key. - -### Smaller installation options - -Use a smaller package only when you intentionally need a limited surface. The NumPy-only core -continues to support Python 3.9+. - -| Goal | Install | Start | -|---|---|---| -| Local dashboard and REST API | `pip install "engraphis[server]"` | `engraphis-dashboard` | -| Coding-agent memory over Smart MCP | `pip install "engraphis[mcp]"` | `codex mcp add engraphis -- engraphis-mcp` | -| Native SQLite vector acceleration | `pip install "engraphis[vector]"` | Server entrypoints select it automatically | -| Offline Python library | `pip install engraphis` | `MemoryService.create("engraphis.db")` | - -For MCP clients other than Codex, configure a stdio server whose command is `engraphis-mcp`; see -the [agent connection guide](docs/AGENT_CONNECT.md). - -### Updating - -Use `engraphis-update` to upgrade the installation using its detected install method. Package -metadata does not record which extras were selected, so the updater defaults to the safe -superset `engraphis[all]` rather than silently dropping an optional surface. For a deliberate -selection, set `ENGRAPHIS_UPDATE_EXTRAS` to a comma-separated list (for example -`server,mcp`), or set it to `none` for the base package only. - -> **Upgrading to 1.4:** `engraphis-mcp` now exposes the nine-tool Smart gateway. Integrations that -> require the former 33 direct tool names should run `engraphis-mcp-classic`. The SQLite schema -> in the 1.4.0 release was version 9. Existing v7-to-v8 databases already contain `confidence` -> and `pinned_at`/`unpinned_at`; v9 adds the `memory_tombstones` repository-scope column/table -> and performs a one-time entity-canonicalization repair, then migrates automatically on first -> open. A tombstone with a known `repo_id` is terminal only in that repository; legacy repo-less -> tombstones remain global. See the [1.4.0 release notes](CHANGELOG.md#140---2026-08-02). - -> **Upgrading to 1.5:** schema 10 bounds legacy retention state and schema 11 backfills explicit -> approval only for eligible pre-review local memories. Pending and quarantined evidence remains -> gated. Existing 1.4.x databases migrate automatically when Engraphis 1.5 opens them; see the -> [1.5.0 release notes](CHANGELOG.md#150---2026-08-04). - ---- - -## What Engraphis gives an agent - -An agent should not have to reconstruct a project from scattered chat history on every task. -Engraphis turns local project knowledge into scoped, time-aware memory; retrieves the evidence -that supports the current question; and returns a bounded, attributable context packet. - -The core task is continuity: retrieve the current, supported project decision without dragging the -whole history into the next prompt. See [measured token and context savings](#measured-token-and-context-savings) -for the short version of how much less history an agent has to carry. - -| Agent need | What Engraphis changes | -|---|---| -| Remember a project across sessions | Stores typed memory in a `workspace → repo → session` hierarchy and provides a last-session handoff. | -| Find support for the current task | Fuses vector, lexical, graph, and code-aware retrieval instead of relying on one search signal; `fast` can skip graph traversal for small or latency-sensitive vaults. | -| Know what is true now and what changed | Preserves bi-temporal history and supersession chains instead of silently overwriting a fact. | -| Avoid confident guesses | Returns cited evidence or explicitly abstains when support is too weak. | -| Avoid dragging the whole project into every prompt | Packs context to a configured hard budget and can return a compact MCP response. | -| Keep knowledge in the operator's control | Runs local-first and offline-capable, with scopes, audit records, and optional privacy-safe receipts. | - -## Dashboard and local UI - -The Engraphis dashboard opens `http://127.0.0.1:8700`. Local memory needs no cloud account, -signup, or API key and stays in a SQLite file on your machine. - -**Ledger** is the primary local interface for recall, memories, graph exploration, provenance, -workspaces, and manual consolidation. **Classic** preserves the former full tool suite; both use -the same local data. Switch in **Manage → Settings → Interface** (Ledger) or **Settings → -Appearance & Engine** (Classic). - -### Start it on every platform - -| Platform | How | -|----------|-----| -| **Windows** | Double-click **Engraphis Dashboard** on your Desktop or Start Menu (install: `engraphis-dashboard --install-shortcuts`) | -| **macOS** | Double-click **Engraphis Dashboard.app** on your Desktop (install: same command) | -| **Linux** | Desktop entry in Applications → Development (GNOME/KDE/etc.) | -| **Docker** | `docker compose up`: see `docker-compose.yml` for the one-command deployment | -| **Any** | `engraphis-dashboard` in a terminal | - -### Accessibility-first inspection, built in - -Inspect memories, supersession diffs, recall scores, timelines, links, consolidation, and audit -records in the dashboard. The offline graph renderer is vendored, and the interface is keyboard- -navigable with light and dark themes. - ---- - -## How it works - -Engraphis gives agents durable, scoped, *explainable* project knowledge. The local engine combines -Ebbinghaus decay, bi-temporal facts, and hybrid vector/lexical/graph recall; it runs offline with -SQLite, local embeddings, and `numpy` only. - -- **Grounded and governed:** deterministic conflict resolution, cited answers or abstention, - explicit correction/promotion/forgetting, and a complete history. -- **Agent-ready:** MCP tools, hard-budget context packets, handoffs, and code-aware retrieval. -- **Auditable:** content-free receipt chains, provenance, and temporal/entity/code relationships. -- **Practical:** local file and code ingest, optional PDF/OCR/transcription, and SQLCipher at rest. - -### Optional LLM providers - -The memory engine, embeddings, conflict resolution, and recall stay local without an LLM. An -explicitly configured provider adds structured extraction, cited synthesis, consolidation, and -retention supervision. Configure it in **Settings → Connect an LLM**. The activity view records -outcomes, never keys, prompts, or raw provider responses. See the -[LLM provider guide](docs/LLM_PROVIDERS.md) for setup and privacy choices. - -> Privacy boundary: text sent to an explicitly selected provider leaves the local process under -> that provider's terms. Use `ENGRAPHIS_RETENTION_SUPERVISOR=none` (the default) and the offline -> `chunk` extractor when ingestion must remain entirely local. - -Choose and configure an external LLM with the [LLM provider guide](docs/LLM_PROVIDERS.md), -including OpenAI, Anthropic, Google, OpenRouter, Ollama, Cohere Command, Command Code Provider, -and other compatible endpoints. The guide also covers Codex subscription MCP connections. - ---- - -## Install - -```bash -pip install "engraphis[all]" # self-hosted dashboard, MCP, code graph, documents, transcription, PostgreSQL, and Cloud Sync -pip install "engraphis[server]" # dashboard + REST API -pip install "engraphis[mcp]" # MCP server only -pip install "engraphis[documents]" # PDF + image OCR bindings -pip install "engraphis[transcription]" # faster-whisper audio/video -pip install "engraphis[postgres]" # PostgreSQL schema introspection -pip install "engraphis[code]" # tree-sitter code graph indexing -pip install "engraphis[vector]" # native sqlite-vec exact-KNN acceleration -pip install "engraphis[cloud-sync]" # Cloud Sync client crypto/runtime -pip install "engraphis[encryption]" # SQLCipher encryption-at-rest extra -pip install engraphis # core library: numpy only, fully offline -``` - -The official Docker image includes the local Tesseract executable for image OCR. Outside -Docker, the `documents` extra installs its Python bindings; install Tesseract through your -operating system as well if you enable image OCR. - -The NumPy-only core library supports Python 3.9+. Current patched releases of the WebUI -stack, MCP SDK, image parser, and Cloud Sync client require Python 3.10+, so use Python 3.10 -or newer for the `server`, `mcp`, `documents`, `cloud-sync`, or `all` installation paths. - -The default `NumpyVectorIndex` performs an exact full scan. There is no universal memory-count -cutoff because latency depends on vector size, hardware, filters, and the rest of the recall -pipeline. Measure your machine with `python -m eval.vector_scale --backend numpy`, then run -`python -m eval.performance` on a representative corpus. If exact scans miss your latency target, -install `engraphis[vector]`, create the engine with `vector_backend="sqlite-vec"`, and remeasure. -The stable sqlite-vec `vec0` backend executes exact KNN in native code; it is acceleration, not a -claim of sublinear ANN scaling. See [BENCHMARKS.md](BENCHMARKS.md) for the reproducible commands -and reporting limits. - -Dashboard, REST, and MCP entrypoints default to `ENGRAPHIS_VECTOR_BACKEND=auto`: they use -sqlite-vec when the `vector` extra is installed and compatible, then safely fall back to NumPy. -Programmatic `MemoryEngine.create()` and `MemoryService.create()` retain the deterministic -`numpy` default unless a backend is requested explicitly. -Use `python -m eval.vector_scale --backend sqlite-vec` for an input-identical direct-search -comparison; setup/index-build time is explicitly excluded from the timed search envelope. - -`sqlcipher3-binary` publishes CPython manylinux x86-64 wheels. On that target, -`engraphis[encryption]` installs the driver. The cross-platform `all` extra deliberately -omits it so `all` remains resolvable on macOS, Windows, Linux ARM, and musl; on those -targets, provision a compatible SQLCipher driver separately before enabling a database -key. The programmatic core remains plaintext unless a database key is configured. For a -fresh database, `engraphis-init` enables SQLCipher automatically when a compatible driver is -available, creates a private key sidecar, and can be overridden with `--no-encryption`. - -> **Linux / macOS:** if `pip install` fails with `error: externally-managed-environment`, -> your system Python is marked read-only (PEP 668). Install into a virtual environment -> instead. Run `python3 -m venv venv && source venv/bin/activate && pip install "engraphis[server]"` -> Alternatively, use Docker (`docker compose up`). `pipx install "engraphis[server]"` also works. - -> First run downloads `all-MiniLM-L6-v2` (~80 MB). Without it, the engine falls back -> to deterministic feature hashing so it always runs offline. That fallback captures lexical -> overlap, not meaning: recall and grounded MCP responses set `degraded_mode=true` and -> `semantic_support=false`, and disable vector retrieval plus semantic-cosine evidence. Install -> a declared embedding model for semantic retrieval. - -> To require a model that is already local, set `ENGRAPHIS_EMBED_MODEL=local:/absolute/model/path` -> or `local:`. This path never downloads a model. If it is unavailable, Engraphis -> explicitly enters lexical degraded mode instead of presenting hash-vector scores as semantic. - ---- - -## Quickstart: dashboard - -```bash -pip install "engraphis[server]" -engraphis-dashboard # → http://127.0.0.1:8700 -engraphis-dashboard --install-shortcuts # → Desktop + Start Menu icons -``` - -### Docker - -```bash -docker compose up # → http://127.0.0.1:8700 -``` - -For Docker Compose persistence and loopback-port configuration, see the -[Docker deployment guide](docs/DOCKER.md). -`engraphis-server` and `engraphis server` are headless compatibility aliases -for this same v2 service, so every public surface has the same scoped recall and retention model. - -For optional LAN exposure, token configuration, and HTTP MCP setup, see the -[Docker deployment guide](docs/DOCKER.md). - -Set `ENGRAPHIS_API_TOKEN` to require API authentication and `ENGRAPHIS_DB_KEY` to encrypt -the local database at rest. Hosted-plan credentials configure customer clients; they do not -install premium server implementations into this image. See `docker-compose.yml` for options. - ---- - -## Quickstart: MCP server (for coding agents) - -```bash -pip install "engraphis[mcp]" -engraphis-init # writes .env + prints config snippets -claude mcp add engraphis -- engraphis-mcp -codex mcp add engraphis -- engraphis-mcp # Codex subscription - -``` -For Codex subscription setup and verification, see the [agent connection guide](docs/AGENT_CONNECT.md) -and the [LLM provider guide](docs/LLM_PROVIDERS.md). - -`engraphis-mcp` is zero-configuration Smart MCP: agents begin with nine compact tools for sessions, -prompt-ready recall, durable memory, governed record read/update, conflict review, action discovery, -and safe execution. For code graphs, -governance, audit, or other advanced work, the agent calls `engraphis_discover_actions` and then -the indicated read or action executor; no profile selection is required. The gateway validates -the discovered capability again before it runs it, and clients remain responsible for their -normal destructive-action approval boundary. - -Existing clients that pin the historical 33 named tools can use -`engraphis-mcp-classic` (or `engraphis-mcp-http --classic`). The complete classic inventory, -including `engraphis_check_update`, is in the [MCP tool reference](docs/MCP_TOOLS.md). - -### Pi extension - -For installation, configuration, lifecycle commands, and the local trust boundary, see the -[Pi extension guide](integrations/pi/README.md). - -### Hermes provider - -Engraphis also ships a native Hermes memory-provider plugin with local prefetch, bounded turn -capture, scoped recall, and explicit secure erase. Install Engraphis in the Hermes Python -environment, copy the provider, then select it with `hermes memory setup`. See the -[Hermes integration guide](integrations/hermes/README.md). The provider never installs itself or -downloads an embedding model. - -## Quickstart: repository graph - -```bash -pip install "engraphis[code]" -engraphis-graph index -w acme -r api --root . -engraphis-graph search -w acme -r api "UserService" -# `query`/`explain` blend code search with your stored memories: query matches symbol -# and file NAMES (a full question sentence won't match anything), and explain's answer -# is drawn from memories recorded against the repo; both are empty on a fresh index. -engraphis-graph query -w acme -r api "UserService" -engraphis-graph explain -w acme -r api "why does deploy depend on approval?" -engraphis-graph path -w acme -r api UserService DatabasePool -engraphis-graph impact -w acme -r api --root . --git-range origin/main...HEAD -engraphis-graph prs -w acme -r api --base main --head HEAD -engraphis-graph export -w acme -r api -o engraphis-graph-out -engraphis-graph install-merge-driver --root . -``` - -The export contains `graph.json`, a self-contained `graph.html`, and `GRAPH_REPORT.md`. -Indexing supports Python, JavaScript, TypeScript, Go, Rust, Java, C#, C, C++, SQL, and -Terraform. Tree-sitter is used when available; the dependency-free regex backend remains a -functional fallback. Definitions, methods, calls, imports, ownership, variables, -inheritance/implementation, and docstrings/comments are indexed. Indexing is incremental by -content hash, honors `.engraphisignore`, and does not follow file symlinks outside the repository -root. Call edges are name-based and best-effort rather than type-resolved. The optional Git merge -driver validates bounded graph JSON and deterministically unions nodes and edges instead of -choosing one export side. - -For a read-only recall and graph API that can be shared without exposing write operations: - -```bash -pip install "engraphis[server]" -engraphis-graph-server # API at http://127.0.0.1:8720; schema at /openapi.json -``` - -A non-loopback bind fails closed unless `ENGRAPHIS_GRAPH_TOKEN` (or -`ENGRAPHIS_API_TOKEN`) is set. See [the v3 architecture/design document](docs/ARCHITECTURE_V3.md). - ---- - -## Quickstart: Python library - -```python -from engraphis.service import MemoryService - -mem = MemoryService.create("engraphis.db") -mem.remember("Auth migrated from JWT to PASETO.", workspace="acme", repo="api") -hit = mem.recall("why did we change auth?", workspace="acme", repo="api") -print(hit["context"]) -``` - -The same `MemoryService` backs the dashboard and the MCP server. - -After an upgrade, `stats()` reports prompt-eligibility counts and active embedding-space -coverage. Zero-result recall identifies a review-gated scope instead of silently looking empty, -and `engraphis-cli review list|approve` provides a dry-run-first local bulk workflow. Embedding -model changes trigger a guarded rebuild; vector recall stays disabled until every stored vector -matches the new fingerprint. See [recall recovery](docs/RECALL_RECOVERY.md). - -Agent hosts can avoid retrieval when their existing history already fits: - -```python -decision = mem.adaptive_context( - "what should the agent do next?", - current_history, - workspace="acme", - repo="api", - max_context_tokens=8_192, - retrieval_token_budget=1_024, -) -prompt_context = decision["context"] -``` - -The decision is `history_bypass` when the history fits, `retrieval` when compact evidence is -strong, and `history_fallback` when weak retrieval should widen back to recent raw history. - -For an agent prompt, prefer `engraphis_recall_context`: it returns one hard-budget packed -`context` plus compact `sources`, deterministic `usage` accounting (`budget_tokens`, `context_tokens`, -`source_tokens`, `saved_tokens`, `savings_ratio`, `packed_count`, `omitted_count`, and -`token_counter`), and optional diagnostics. Accounting is exact for the named counter; inject the -reader's tokenizer when reader-model token parity is required. `engraphis_recall` remains the compatible full-recall -surface; use `response_mode="compact"` when the packed context is enough and full memory bodies -would duplicate it. For advanced query-planning configuration, see the -[architecture guide](docs/ARCHITECTURE_V3.md#query-planning). - -For bi-temporal reads, `valid_at` selects what was true at a Unix timestamp and `known_at` selects -what Engraphis had learned then. `as_of` remains a compatibility alias for `valid_at`; supplying -both is allowed only when they match. - -For a mutable claim, pass a stable `subject_key` and optional `claim_kind`, such as -`subject_key="api.rate_limit", claim_kind="configured_value"`. Offline conflict resolution -deterministically adds, reinforces, relates, or supersedes records while preserving temporal -history; it does not need an LLM. Matching claim identities let it supersede substantially -reworded mutable facts. Without them, the dependency-free lexical embedder cannot reliably infer -that a paraphrase is a contradiction, so keep both records or use an explicit `correct` operation. - ---- - -## Govern memories without losing history - -Engraphis separates automatic write resolution from explicit human governance: - -| Operation | Use it when | What happens to history | -|---|---|---| -| `remember` | Adding or restating one fact | Adds, reinforces, safely supersedes, or relates an uncertain neighbor | -| `correct` | Replacing one known-wrong memory | Closes the old validity window and links the replacement | -| `promote` | A narrow learning now applies more broadly | Writes a wider-scope successor and closes/links the source instead of editing scope in place | -| `merge` | Combining two or more overlapping memories | Retires every source and creates one memory that supersedes all of them | -| `retire` | Removing a memory from live recall | Bi-temporally closes it; the audit/history record remains | -| `consolidate` | Distilling recurring episodic memories automatically | Creates linked semantic digests; sources stay live unless explicit supersession is requested | - -Manual N→1 merge is available through `MemoryService.merge()` and `POST /api/merge`: - -```python -a = mem.remember("Deploys happen Friday at 3pm.", workspace="acme") -b = mem.remember("We deploy Fridays around 15:00.", workspace="acme") - -merged = mem.merge( - [a["id"], b["id"]], - "Deploys ship every Friday at approximately 15:00.", - workspace="acme", - reason="deduplicate the deployment schedule", -) -print(merged["compaction"]) -``` - -`retire` is intentionally not deletion: it preserves temporal history, FTS, and vector -evidence for historical reads. If a credential was captured, new writes are blocked before -storage; for a legacy leak use the explicitly destructive `MemoryService.secure_erase()` or -`POST /api/secure-erase`/`engraphis_secure_erase`. That flow removes the one memory and local -FTS/vector-index and derived graph/link rows, runs SQLite secure-delete, WAL checkpoint, and -VACUUM, and scans recognised local SQLite recovery backups. It cannot erase exports, filesystem -snapshots, remote peers, unknown backups, or information a running/compromised agent already -read; rotate the credential. See [secure-erasure limits](docs/SECURE_ERASURE.md). `forget` -remains a deprecated compatibility alias for `retire`. - -All sources must belong to the named workspace. The result inherits the strictest source -sensitivity, remains untrusted if any source was untrusted, and stays pinned if any source was -pinned. The full multi-predecessor chain remains visible through inspection, Why, and Timeline. - ---- - -## Free forever vs. hosted plans - -The core engine, local dashboard, MCP server, and manual consolidation are Apache-2.0 and free. -**Pro and Team are services** that provide optional access to the official hosted service; its -control-plane, billing, relay, compute, and Team identity modules live in a private repository. -They do not limit the local core. See -[hosted plans](docs/HOSTED_PLANS.md), [licensing](docs/LICENSING.md), and -[Cloud Sync](docs/SYNC.md) for service boundaries, lifecycle, and pricing. - -[Subscribe to Pro](https://api.engraphis.com/account?plan=pro&interval=monthly&utm_source=engraphis&utm_medium=docs&utm_campaign=pro_conversion&utm_content=readme_pricing#billing) -to support the project and add hosted services. - -[Compare hosted plans](https://api.engraphis.com/account?plan=pro&interval=monthly&utm_source=engraphis&utm_medium=docs&utm_campaign=pro_conversion&utm_content=readme_intro#billing) -when you are ready to evaluate the service boundary and billing options. - -| | Free (available now) | Pro: $10/mo or $100/yr | Team: $20/seat/mo or $200/seat/yr | -|---|---|---|---| -| Dashboard WebUI (with built-in inspector) | ✓ | ✓ | ✓ | -| Memory engine + Smart MCP (Classic 33-tool compatibility) | ✓ | ✓ | ✓ | -| Version-chain diffs, offline knowledge graph | ✓ | ✓ | ✓ | -| Manual local consolidation (dry-run by default) | ✓ | ✓ | ✓ | -| Local workspace export (JSON: memories, sessions, audit) | ✓ | ✓ | ✓ | -| Hosted Cloud Sync | | ✓ | ✓ | -| Hosted Analytics | | ✓ | ✓ | -| Hosted Auto Consolidation + retention policy | | ✓ | ✓ | -| Hosted Auto Dreaming + managed proposals | | ✓ | ✓ | -| Priority support | | ✓ | ✓ | -| Hosted multi-user dashboard: invitations, logins, roles, seat management | | | ✓ | -| Hosted Team audit log + CSV export | | | ✓ | -| 72-hour pending invitations (resend/revoke) | | | ✓ | -| Scoped, expiring per-user agent and sync tokens | | | ✓ | - ---- - -## MCP tools - -Engraphis exposes a zero-configuration Smart MCP gateway plus a 33-tool Classic compatibility -server across memory, recall, code graphs, governance, sessions, and privacy-safe audit receipts. -The focused [MCP tool reference](docs/MCP_TOOLS.md) is the source for -the full inventory and parameters. - ---- - -## Graphs and privacy-safe receipts - -Memory, entity, and code relationships live in one local graph. Engraphis also provides -content-free operation receipts for inspectable audit evidence. See the -[architecture](docs/ARCHITECTURE_V3.md), [MCP tool reference](docs/MCP_TOOLS.md), and -[security policy](SECURITY.md) for the data model, tools, and guarantees. - ---- - -## Cloud sync - -Cloud Sync is an optional hosted Pro/Team service. The public package includes the customer client -and deterministic merge implementation; hosted relay and account operations are separate. See -[Cloud Sync](docs/SYNC.md) for setup, encryption, merge behavior, and the local folder exchange. - ---- - -## Security and trust boundaries - -Engraphis is local-first and binds to loopback by default. Read the -[security policy](SECURITY.md) before remote deployment or integrating external resources; it -covers supported versions, data protections, threat model, and vulnerability reporting. - ---- - -## Encryption at rest - -Set `ENGRAPHIS_DB_KEY` (or `ENGRAPHIS_DB_KEY_FILE`) and install the extra: - -```bash -pip install "engraphis[encryption]" -``` - -The entire main memory database file is transparently encrypted with AES-256 via SQLCipher; -full-text search, the graph, and every query keep working unchanged. Customer authentication -and managed-service state use their respective deployment protections. When a key is set for the -main database, Engraphis **fails closed with an error** rather than silently falling back to -plaintext. Generate a strong key: - -```bash -python -c "import secrets; print(secrets.token_hex(32))" -``` - -When using `ENGRAPHIS_DB_KEY_FILE`, provision a regular secret file readable only by the -service identity. Engraphis rejects links, reparse points, hard links, malformed text, and -oversized key files rather than following an unexpected filesystem object. - -> An existing plaintext database cannot be opened with a key: migrate it (dump → import -> into a fresh keyed DB). See `.env.example` for all encryption options. - ---- - -## Import files and folders - -Import supported documents and code through the dashboard, a local folder, or MCP. Optional -extractors add offline chunking, structured LLM extraction, document OCR, transcription, and -PostgreSQL schema ingestion. See the [MCP tool reference](docs/MCP_TOOLS.md), -[architecture guide](docs/ARCHITECTURE_V3.md), and [security policy](SECURITY.md) for formats, -configuration, and local-resource safeguards. - ---- - -## Consolidation and automation - -Manual consolidation is free, local, and dry-run by default; use the dashboard, SDK, CLI, or -MCP. Hosted Pro and Team automation is optional managed compute that produces reviewable -proposals rather than silently changing local data. See [hosted plans](docs/HOSTED_PLANS.md), -[licensing](docs/LICENSING.md), and the [MCP tool reference](docs/MCP_TOOLS.md) for scope and use. - ---- - -## Configuration - -All via environment (or `.env`): - -| Env Var | Default | Description | -|---------|---------|-------------| -| `ENGRAPHIS_DB_PATH` | Source: `/engraphis.db`; installed: platform user-data directory | SQLite database file. Installed defaults are `%LOCALAPPDATA%\engraphis\engraphis.db` (Windows), `~/Library/Application Support/engraphis/engraphis.db` (macOS), and `$XDG_DATA_HOME/engraphis/engraphis.db` or `~/.local/share/engraphis/engraphis.db` (Linux). The environment variable overrides every default. | -| `ENGRAPHIS_HOST` | `127.0.0.1` | Server bind address | -| `ENGRAPHIS_PORT` | `8700` | Dashboard port | -| `ENGRAPHIS_SERVICE_MODE` | `customer` | The public package supports only `customer`; hosted vendor, relay, compute, and worker roles are not distributed here | -| `ENGRAPHIS_API_TOKEN` | Not set | Optional bearer credential for this single-user local customer node; never reuse a hosted credential | -| `ENGRAPHIS_CORS_ORIGINS` | loopback on `ENGRAPHIS_PORT` | Comma-separated REST CORS allow-list; defaults to `127.0.0.1` and `localhost` on the configured port | -| `ENGRAPHIS_WORKSPACES` | Not set | Optional comma-separated server-side workspace allow-list | -| `ENGRAPHIS_INDEX_ROOTS` | Working, home, and temporary directories | Optional path-separator-delimited absolute-path allow-list that replaces the default roots accepted by local code indexing | -| `ENGRAPHIS_HTTP_INDEX_ROOT` | First `ENGRAPHIS_INDEX_ROOTS` entry, or current directory | Single root for dashboard and REST `POST /api/code/index`; submitted paths resolve beneath it. An explicit root (or fallback entry) must be absolute; an explicit HTTP root is included in the engine-approved set. MCP and CLI indexing continue to use `ENGRAPHIS_INDEX_ROOTS`. | -| `ENGRAPHIS_DB_KEY` | Not set | Encrypt the database at rest (SQLCipher). Or use `ENGRAPHIS_DB_KEY_FILE` | -| `ENGRAPHIS_EMBED_MODEL` | `sentence-transformers/all-MiniLM-L6-v2` | sentence-transformers model | -| `ENGRAPHIS_EMBED_REVISION` | Not set | Optional immutable lowercase 40-hex Hugging Face commit for the embedding model | -| `ENGRAPHIS_RERANK_MODEL` | Not set | Optional sentence-transformers cross-encoder reranker | -| `ENGRAPHIS_RERANK_REVISION` | Not set | Optional immutable lowercase 40-hex Hugging Face commit for the reranker | -| `ENGRAPHIS_REQUIRE_IMMUTABLE_MODELS` | `false` | When enabled, require a 40-hex commit before loading remote embedding models, rerankers, or chunk tokenizers; `local:` selectors and filesystem paths remain permitted | -| `ENGRAPHIS_EXTRACTOR` | `none` | `none` = verbatim; `chunk` = offline structure-aware chunks; `llm` = free-form LLM facts; `llm_structured` = schema-validated facts + graph metadata | -| `ENGRAPHIS_CHUNK_TOKENIZER_MODEL` | Not set | Optional Hugging Face tokenizer used to enforce chunk budgets with the downstream reader's real tokenization; requires the optional `transformers` package | -| `ENGRAPHIS_CHUNK_TOKENIZER_REVISION` | Not set | Optional immutable tokenizer/model revision recorded in the chunk-counter identity; pin this for reproducible benchmark artifacts | -| `ENGRAPHIS_GRAPH_EXTRACTOR` | `regex` | `regex` = offline heuristic NER; `none` = disable heuristic text extraction (validated `llm_structured` metadata still feeds the graph) | -| `ENGRAPHIS_RETENTION_SUPERVISOR` | `none` | `none` = deterministic only; `llm` = sends a bounded excerpt to the configured provider for advisory ephemeral/normal/critical classification | -| `ENGRAPHIS_ALLOW_AUTOMATIC_CRITICAL_RETENTION` | `false` | Opt in only when an LLM supervisor may automatically assign the long-lived `critical` class; explicit user-selected critical retention is unaffected | -| `ENGRAPHIS_WHISPER_MODEL` | Not set | Enables local faster-whisper audio/video transcription | -| `ENGRAPHIS_POSTGRES_DSN` | Not set | CLI-only PostgreSQL source; used for the connection and never stored | -| `ENGRAPHIS_POSTGRES_CONNECT_TIMEOUT` | `10` | PostgreSQL introspection connection timeout in seconds (bounded to 1–120) | -| `ENGRAPHIS_POSTGRES_STATEMENT_TIMEOUT_MS` | `30000` | Per-introspection PostgreSQL statement timeout in milliseconds (bounded to 1–300000) | -| `ENGRAPHIS_GRAPH_TOKEN` | Not set | Bearer token for `engraphis-graph-server`; required off-loopback | -| `ENGRAPHIS_GRAPH_HOST` / `ENGRAPHIS_GRAPH_PORT` | `127.0.0.1` / `8720` | Read-only graph/recall server bind address | -| `ENGRAPHIS_LLM_PROVIDER` | `openai` | `openai \| anthropic \| google \| openrouter \| custom` | -| `ENGRAPHIS_LLM_MODEL` | `gpt-4o-mini` | Model name (provider-specific) | -| `ENGRAPHIS_LLM_API_KEY` | Not set | API key for chat/synthesis, `llm` / `llm_structured` extraction, and structured consolidation | -| `ENGRAPHIS_LLM_BASE_URL` | Not set | Base URL for openrouter / custom OpenAI-compatible endpoints | -| `ENGRAPHIS_LLM_AUTO_EXTRACT` | `0` | Opt in to switching the running engine to `llm_structured` after a successful live connection test; the dashboard's extraction Off button persists `0`, and its On button restores `1` | -| `ENGRAPHIS_FORWARDED_ALLOW_IPS` | *(none)* | Proxies trusted for forwarded client/TLS headers (`*` only when the service is reachable exclusively through that proxy) | -| `ENGRAPHIS_LOCAL_TRUSTED_PEERS` | *(none)* | Exact peers/CIDRs treated as local without forwarding headers; use only for trusted Docker/LAN peers, never public deployments | -| `ENGRAPHIS_CLOUD_CONTROL_URL` | hosted default | Official entitlement, organization, and credential control API | -| `ENGRAPHIS_CLOUD_COMPUTE_URL` | hosted default | Official Analytics and managed-automation API | -| `ENGRAPHIS_CLOUD_ORGANIZATION_ID` | Not set | Hosted organization bound to this customer session | -| `ENGRAPHIS_CLOUD_REFRESH_CREDENTIAL` | Not set | Bootstrap-only rotating hosted credential; after first use the owner-only cloud session replacement takes precedence | -| `ENGRAPHIS_CLOUD_TOKEN_SUBJECT` | `member` | Subject fixed during hosted bootstrap (`device` or `member`); set explicitly with an environment-only refresh credential | -| `ENGRAPHIS_CLOUD_ACCESS_TOKEN` | Not set | Optional short-lived access token for ephemeral jobs | -| `ENGRAPHIS_MANAGED_COMPUTE_CONSENT` | *(auto)* | Operator override only; default follows whether a cloud session is configured (connected = allowed, local-only = never). `0` opts a connected installation out; `1` permits local snapshot preparation but does not create a cloud credential or authorize an upload | - -See `.env.example` for the full customer-runtime and managed-service client options. - ---- - -## Project structure - -``` -engraphis/ -├── 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 # Smart MCP gateway + 33-tool Classic compatibility server -│ ├── dashboard_app.py # dashboard WebUI (FastAPI) -│ ├── dashboard_assets/ # primary Ledger interface + graph engine -│ ├── classic_assets/ # selectable full operator dashboard backup -│ ├── read_only_api.py # token-protected recall/repository-graph HTTP surface -│ ├── hosted_client.py # hosted URLs, plan labels, and endpoint validation only -│ ├── licensing.py # compatibility facade for hosted presentation metadata -│ ├── cloud_session.py # rotating hosted customer-session client -│ ├── cloud_features.py # consented managed-feature protocol client -│ ├── config.py / app.py # env settings / REST server -│ └── static/ # compatibility dashboard asset paths -├── eval/ # offline retrieval eval harness + datasets -├── tests/ # offline-first pytest suite and release/security contracts -├── scripts/ # dashboard, server, graph, CLI, connect, update, consolidation, sync -├── docs/ # product, API, hosting, sync, and provider guides -├── Dockerfile / docker-compose.yml -└── pyproject.toml -``` - -New capability belongs in the v2 path (`engraphis/core/`, `engraphis/backends/`, and -`MemoryService`) behind the interfaces in `core/interfaces.py`. The flat-namespace v1 server -under `engraphis/app.py`, `routes/`, `stores/`, and `engines/` remains a compatibility/reference -surface; `engraphis-dashboard`, the MCP server, and the Python quickstart above use v2. - ---- - -## License - -Apache-2.0. See [LICENSE](LICENSE) and [NOTICE](NOTICE). "Engraphis" is a trademark of the -Engraphis project; the license does not grant trademark rights. Code already distributed -under Apache-2.0 keeps that grant; later releases cannot retroactively withdraw it. The -official hosted control plane, its production credentials and records, managed operations, -support, and future separately delivered commercial modules are outside the public source -grant. See [`docs/LICENSING.md`](docs/LICENSING.md) for the complete boundary. +# Engraphis + +[![PyPI version](https://img.shields.io/pypi/v/engraphis.svg)](https://pypi.org/project/engraphis/) +[![License](https://img.shields.io/badge/license-Apache--2.0-green.svg)](https://github.com/Coding-Dev-Tools/engraphis/blob/main/LICENSE) +[![Support](https://img.shields.io/badge/Buy%20Me%20a%20Coffee-support-yellow?logo=buy-me-a-coffee)](https://buymeacoffee.com/Jaixii) + +[https://engraphis.com/](https://engraphis.com/) + +[https://discord.com/invite/Wfr2ejBmY](https://discord.com/invite/Wfr2ejBmY) + +**Give your AI agents a memory. See it, search it, and maintain it, all in a beautiful WebUI on your own machine.** + +

+ Engraphis Knowledge Graph tab: force-directed entity-relation network +
+ Knowledge Graph · run engraphis-dashboard to see it live +

+ +--- + +> **Open-core boundary:** this repository contains the free local engine, dashboard, MCP server, +> and customer-side clients. Hosted sync, analytics, automation, and team services run on the +> official hosted service; their server implementations are not distributed here. + +> **Support continued Engraphis development with Pro.** [Start a 3-day Pro trial](https://api.engraphis.com/account?plan=pro&interval=monthly&utm_source=engraphis&utm_medium=docs&utm_campaign=pro_conversion&utm_content=readme_intro&trial=pro#billing) +> or [subscribe to Pro](https://api.engraphis.com/account?plan=pro&interval=monthly&utm_source=engraphis&utm_medium=docs&utm_campaign=pro_conversion&utm_content=readme_intro#billing). + +--- + +## Measured token and context savings + +### Runtime estimator + +The dashboard Overview and Audit/Receipts views also show a receipt-backed estimate from +real context deliveries. It compares the host history or retrieved source baseline with the +context Engraphis actually emitted, keeps token counters and release versions separate, and +labels adaptive history reductions separately from packing savings. Receipts without estimator +metadata remain historical/unclassified. This measures estimated prompt-context reduction; it +does not measure provider billing. The `/context-savings` API and +`engraphis_context_savings` MCP tool accept optional `from_ts`, `to_ts`, and `release_version` +filters. + +

+ Dark chart showing Engraphis using 98.21 percent less long-history context, 71.1 percent less retrieved content per question, 73.9 percent fewer tokens in the smallest useful memory, a 57.15 percent smaller recall payload proxy, and 47.8 percent less repeated-memory context after consolidation +
+ Less repeated history means more room for the task, tools, and useful evidence. +

+ +
+See benchmark details and reproduce the results + +### Controlled before-and-after example + +| Retrieval mode | Mean returned memory content | Recall@5 | +|---|---:|---:| +| Whole documents | 740.3 tokens | 1.000 | +| Engraphis structure-aware chunks | 214.1 tokens | 1.000 | + +The chunked mode returns the relevant passage instead of the whole document: **526.2 fewer tokens +per question**. Under the same model-context budget, that leaves roughly **526 tokens** for task +instructions or other relevant evidence. + +### Measurement details and reproducibility + +The table below records every current token/context efficiency measurement and its counting +boundary. + +| What is counted | Comparison | Measured reduction | Quality held constant | +|---|---|---|---| +| Cumulative reader context across a 1,986-question LoCoMo diagnostic | Full-history replay: **49,915,394** tokens → Engraphis: **891,857** tokens | **49,023,537 fewer context tokens** (**98.2133% lower**) | Focused retrieval used far less context; uncapped full history retained higher retrieval recall | +| Retrieved top-5 memory content, averaged per question | Whole documents: **740.3** tokens → structure-aware chunks: **214.1** tokens | **526.2 fewer tokens per question** (**71.1% lower**, about **3.5× smaller**) | Recall@5 **1.000** in both modes across 6 documents and 18 questions | +| Smallest returned memory that contains the reference evidence | Whole documents: **162.2** tokens → chunks: **42.4** tokens | **119.8 fewer tokens to evidence** (**73.9% lower**, about **3.8× smaller**) | The same 18 questions had a returned evidence-holding memory in both modes | +| Full versus compact recall payload proxy across one 26-question pass within a 260-timed-recall CodeMem run | Full proxy: **23,810** `engraphis.regex.v1` tokens → compact proxy: **10,202** tokens | **13,608 proxy tokens avoided** (**57.15% lower**) | 26 payload samples; 260 timed recalls; Recall@5, hit@5, and answer-token recall all **1.000** | +| Repeated-memory consolidation fixture | 12 related episodic memories: **230** tokens → one digest: **120** tokens | **110 tokens removed from the active digest** (**47.8% lower**) | Original memories remain available for provenance and audit | +| Small histories across 26 CodeMem agent tasks | Always retrieve: **1,883** total agent-facing tokens and **26** memory calls → adaptive: **1,942** tokens and **0** memory calls | Adaptive uses **59 more tokens** (**3.1% higher**) while eliminating all **26** memory calls | Both completed **24/26** tasks with the same deterministic offline task agent; this fixture demonstrates bypass behavior, not token savings | +| Packed prompt-context usage in the same 26-question CodeMem sample pass | Hard budget: **1,500** tokens; observed mean: **85.38**; observed maximum: **108** | A hard cap prevents a recall from exceeding its configured context budget | This is usage accounting, not a before/after savings comparison | + +The LoCoMo context-use row is an **unpinned, noncanonical retrieval diagnostic**, not official +LoCoMo QA, answer-quality, provider-cost, or leaderboard evidence. It is not reproduced by the +small offline fixtures below; [BENCHMARKS.md](BENCHMARKS.md) records its exact limitations and +the separate hash-bound canonical retrieval diagnostic. + +The compact payload shape avoids duplicating full memory bodies when the packed context and source +list are enough. The evaluator tokenizes JSON-shaped full and compact payload proxies built from +recall results; it does **not** serialize the MCP envelope or measure a transport response. The +fixture therefore does not measure model-provider charges, end-to-end task time, or customer cost +savings. + +The measures are deliberately separate and **must not be added together**: chunking counts the +content of retrieved memory records before `ContextPacker`, whereas compact recall counts a +serialized JSON-shape payload proxy. “Tokens to evidence” is the size of the smallest +retrieved memory record holding the reference evidence; it is not latency or end-to-end answer +accuracy. Chunking creates more focused stored records (24 chunks rather than 6 whole-document +memories in this fixture), so this is a context-efficiency result, not a storage-reduction claim. + +Reproduce the quality and token/context measurements without a network connection or API key: + +```bash +python -m eval.harness --dataset eval/datasets/codemem.jsonl --k 5 +python -m eval.grounded +python -m eval.chunking_eval +python -m eval.adversarial_memory_security +python -m eval.performance --dataset eval/datasets/codemem.jsonl --k 5 --iterations 10 --json +python -m eval.productivity --dataset eval/datasets/codemem.jsonl +``` + +These are small deterministic correctness and efficiency fixtures, not official LoCoMo / +LongMemEval QA scores or a third-party leaderboard result. Compact-response counts use the exact +`engraphis.regex.v1` counter; the chunking evaluation uses its documented deterministic +normalized-character estimator. Chunking measures retrieved memory content, while compact recall +measures a serialized JSON-shape payload proxy, not an MCP transport response. See +[`BENCHMARKS.md`](BENCHMARKS.md) for definitions, +limitations, canonical external-evaluation requirements, and the no-unsupported-claims policy. + +
+ +--- + +## Full Engraphis install: pip install "engraphis[all]" + +The complete `engraphis[all]` install is the default way to use Engraphis: it includes the local +dashboard, Smart MCP server, documents, Cloud Sync client, and supported optional integrations. +Python 3.10+ is required. + +```bash +pip install "engraphis[all]" +engraphis-dashboard +``` + +The dashboard opens at [http://127.0.0.1:8700](http://127.0.0.1:8700). Local memory needs no +account or API key. + +### Smaller installation options + +Use a smaller package only when you intentionally need a limited surface. The NumPy-only core +continues to support Python 3.9+. + +| Goal | Install | Start | +|---|---|---| +| Local dashboard and REST API | `pip install "engraphis[server]"` | `engraphis-dashboard` | +| Coding-agent memory over Smart MCP | `pip install "engraphis[mcp]"` | `codex mcp add engraphis -- engraphis-mcp` | +| Native SQLite vector acceleration | `pip install "engraphis[vector]"` | Server entrypoints select it automatically | +| Offline Python library | `pip install engraphis` | `MemoryService.create("engraphis.db")` | + +For MCP clients other than Codex, configure a stdio server whose command is `engraphis-mcp`; see +the [agent connection guide](docs/AGENT_CONNECT.md). + +### Updating + +Use `engraphis-update` to upgrade the installation using its detected install method. Package +metadata does not record which extras were selected, so the updater defaults to the safe +superset `engraphis[all]` rather than silently dropping an optional surface. For a deliberate +selection, set `ENGRAPHIS_UPDATE_EXTRAS` to a comma-separated list (for example +`server,mcp`), or set it to `none` for the base package only. + +> **Upgrading to 1.4:** `engraphis-mcp` now exposes the nine-tool Smart gateway. Integrations that +> require the former 33 direct tool names should run `engraphis-mcp-classic`. The SQLite schema +> in the 1.4.0 release was version 9. Existing v7-to-v8 databases already contain `confidence` +> and `pinned_at`/`unpinned_at`; v9 adds the `memory_tombstones` repository-scope column/table +> and performs a one-time entity-canonicalization repair, then migrates automatically on first +> open. A tombstone with a known `repo_id` is terminal only in that repository; legacy repo-less +> tombstones remain global. See the [1.4.0 release notes](CHANGELOG.md#140---2026-08-02). + +> **Upgrading to 1.5:** schema 10 bounds legacy retention state and schema 11 backfills explicit +> approval only for eligible pre-review local memories. Pending and quarantined evidence remains +> gated. Existing 1.4.x databases migrate automatically when Engraphis 1.5 opens them; see the +> [1.5 release notes](CHANGELOG.md#150---2026-08-04). + +--- + +## What Engraphis gives an agent + +An agent should not have to reconstruct a project from scattered chat history on every task. +Engraphis turns local project knowledge into scoped, time-aware memory; retrieves the evidence +that supports the current question; and returns a bounded, attributable context packet. + +The core task is continuity: retrieve the current, supported project decision without dragging the +whole history into the next prompt. See [measured token and context savings](#measured-token-and-context-savings) +for the short version of how much less history an agent has to carry. + +| Agent need | What Engraphis changes | +|---|---| +| Remember a project across sessions | Stores typed memory in a `workspace → repo → session` hierarchy and provides a last-session handoff. | +| Find support for the current task | Fuses vector, lexical, graph, and code-aware retrieval instead of relying on one search signal; `fast` can skip graph traversal for small or latency-sensitive vaults. | +| Know what is true now and what changed | Preserves bi-temporal history and supersession chains instead of silently overwriting a fact. | +| Avoid confident guesses | Returns cited evidence or explicitly abstains when support is too weak. | +| Avoid dragging the whole project into every prompt | Packs context to a configured hard budget and can return a compact MCP response. | +| Keep knowledge in the operator's control | Runs local-first and offline-capable, with scopes, audit records, and optional privacy-safe receipts. | + +## Dashboard and local UI + +The Engraphis dashboard opens `http://127.0.0.1:8700`. Local memory needs no cloud account, +signup, or API key and stays in a SQLite file on your machine. + +**Ledger** is the primary local interface for recall, memories, graph exploration, provenance, +workspaces, and manual consolidation. **Classic** preserves the former full tool suite; both use +the same local data. Switch in **Manage → Settings → Interface** (Ledger) or **Settings → +Appearance & Engine** (Classic). + +### Start it on every platform + +| Platform | How | +|----------|-----| +| **Windows** | Double-click **Engraphis Dashboard** on your Desktop or Start Menu (install: `engraphis-dashboard --install-shortcuts`) | +| **macOS** | Double-click **Engraphis Dashboard.app** on your Desktop (install: same command) | +| **Linux** | Desktop entry in Applications → Development (GNOME/KDE/etc.) | +| **Docker** | `docker compose up`: see `docker-compose.yml` for the one-command deployment | +| **Any** | `engraphis-dashboard` in a terminal | + +### Accessibility-first inspection, built in + +Inspect memories, supersession diffs, recall scores, timelines, links, consolidation, and audit +records in the dashboard. The offline graph renderer is vendored, and the interface is keyboard- +navigable with light and dark themes. + +--- + +## How it works + +Engraphis gives agents durable, scoped, *explainable* project knowledge. The local engine combines +Ebbinghaus decay, bi-temporal facts, and hybrid vector/lexical/graph recall; it runs offline with +SQLite, local embeddings, and `numpy` only. + +- **Grounded and governed:** deterministic conflict resolution, cited answers or abstention, + explicit correction/promotion/forgetting, and a complete history. +- **Agent-ready:** MCP tools, hard-budget context packets, handoffs, and code-aware retrieval. +- **Auditable:** content-free receipt chains, provenance, and temporal/entity/code relationships. +- **Practical:** local file and code ingest, optional PDF/OCR/transcription, and SQLCipher at rest. + +### Optional LLM providers + +The memory engine, embeddings, conflict resolution, and recall stay local without an LLM. An +explicitly configured provider adds structured extraction, cited synthesis, consolidation, and +retention supervision. Configure it in **Settings → Connect an LLM**. The activity view records +outcomes, never keys, prompts, or raw provider responses. See the +[LLM provider guide](docs/LLM_PROVIDERS.md) for setup and privacy choices. + +> Privacy boundary: text sent to an explicitly selected provider leaves the local process under +> that provider's terms. Use `ENGRAPHIS_RETENTION_SUPERVISOR=none` (the default) and the offline +> `chunk` extractor when ingestion must remain entirely local. + +Choose and configure an external LLM with the [LLM provider guide](docs/LLM_PROVIDERS.md), +including OpenAI, Anthropic, Google, OpenRouter, Ollama, Cohere Command, Command Code Provider, +and other compatible endpoints. The guide also covers Codex subscription MCP connections. + +--- + +## Install + +```bash +pip install "engraphis[all]" # self-hosted dashboard, MCP, code graph, documents, transcription, PostgreSQL, and Cloud Sync +pip install "engraphis[server]" # dashboard + REST API +pip install "engraphis[mcp]" # MCP server only +pip install "engraphis[documents]" # PDF + image OCR bindings +pip install "engraphis[transcription]" # faster-whisper audio/video +pip install "engraphis[postgres]" # PostgreSQL schema introspection +pip install "engraphis[code]" # tree-sitter code graph indexing +pip install "engraphis[vector]" # native sqlite-vec exact-KNN acceleration +pip install "engraphis[cloud-sync]" # Cloud Sync client crypto/runtime +pip install "engraphis[encryption]" # SQLCipher encryption-at-rest extra +pip install engraphis # core library: numpy only, fully offline +``` + +The official Docker image includes the local Tesseract executable for image OCR. Outside +Docker, the `documents` extra installs its Python bindings; install Tesseract through your +operating system as well if you enable image OCR. + +The NumPy-only core library supports Python 3.9+. Current patched releases of the WebUI +stack, MCP SDK, image parser, and Cloud Sync client require Python 3.10+, so use Python 3.10 +or newer for the `server`, `mcp`, `documents`, `cloud-sync`, or `all` installation paths. + +The default `NumpyVectorIndex` performs an exact full scan. There is no universal memory-count +cutoff because latency depends on vector size, hardware, filters, and the rest of the recall +pipeline. Measure your machine with `python -m eval.vector_scale --backend numpy`, then run +`python -m eval.performance` on a representative corpus. If exact scans miss your latency target, +install `engraphis[vector]`, create the engine with `vector_backend="sqlite-vec"`, and remeasure. +The stable sqlite-vec `vec0` backend executes exact KNN in native code; it is acceleration, not a +claim of sublinear ANN scaling. See [BENCHMARKS.md](BENCHMARKS.md) for the reproducible commands +and reporting limits. + +Dashboard, REST, and MCP entrypoints default to `ENGRAPHIS_VECTOR_BACKEND=auto`: they use +sqlite-vec when the `vector` extra is installed and compatible, then safely fall back to NumPy. +Programmatic `MemoryEngine.create()` and `MemoryService.create()` retain the deterministic +`numpy` default unless a backend is requested explicitly. +Use `python -m eval.vector_scale --backend sqlite-vec` for an input-identical direct-search +comparison; setup/index-build time is explicitly excluded from the timed search envelope. + +`sqlcipher3-binary` publishes CPython manylinux x86-64 wheels. On that target, +`engraphis[encryption]` installs the driver. The cross-platform `all` extra deliberately +omits it so `all` remains resolvable on macOS, Windows, Linux ARM, and musl; on those +targets, provision a compatible SQLCipher driver separately before enabling a database +key. The programmatic core remains plaintext unless a database key is configured. For a +fresh database, `engraphis-init` enables SQLCipher automatically when a compatible driver is +available, creates a private key sidecar, and can be overridden with `--no-encryption`. + +> **Linux / macOS:** if `pip install` fails with `error: externally-managed-environment`, +> your system Python is marked read-only (PEP 668). Install into a virtual environment +> instead. Run `python3 -m venv venv && source venv/bin/activate && pip install "engraphis[server]"` +> Alternatively, use Docker (`docker compose up`). `pipx install "engraphis[server]"` also works. + +> First run downloads `all-MiniLM-L6-v2` (~80 MB). Without it, the engine falls back +> to deterministic feature hashing so it always runs offline. That fallback captures lexical +> overlap, not meaning: recall and grounded MCP responses set `degraded_mode=true` and +> `semantic_support=false`, and disable vector retrieval plus semantic-cosine evidence. Install +> a declared embedding model for semantic retrieval. + +> To require a model that is already local, set `ENGRAPHIS_EMBED_MODEL=local:/absolute/model/path` +> or `local:`. This path never downloads a model. If it is unavailable, Engraphis +> explicitly enters lexical degraded mode instead of presenting hash-vector scores as semantic. + +--- + +## Quickstart: dashboard + +```bash +pip install "engraphis[server]" +engraphis-dashboard # → http://127.0.0.1:8700 +engraphis-dashboard --install-shortcuts # → Desktop + Start Menu icons +``` + +### Docker + +```bash +docker compose up # → http://127.0.0.1:8700 +``` + +For Docker Compose persistence and loopback-port configuration, see the +[Docker deployment guide](docs/DOCKER.md). +`engraphis-server` and `engraphis server` are headless compatibility aliases +for this same v2 service, so every public surface has the same scoped recall and retention model. + +For optional LAN exposure, token configuration, and HTTP MCP setup, see the +[Docker deployment guide](docs/DOCKER.md). + +Set `ENGRAPHIS_API_TOKEN` to require API authentication and `ENGRAPHIS_DB_KEY` to encrypt +the local database at rest. Hosted-plan credentials configure customer clients; they do not +install premium server implementations into this image. See `docker-compose.yml` for options. + +--- + +## Quickstart: MCP server (for coding agents) + +```bash +pip install "engraphis[mcp]" +engraphis-init # writes .env + prints config snippets +claude mcp add engraphis -- engraphis-mcp +codex mcp add engraphis -- engraphis-mcp # Codex subscription + +``` +For Codex subscription setup and verification, see the [agent connection guide](docs/AGENT_CONNECT.md) +and the [LLM provider guide](docs/LLM_PROVIDERS.md). + +`engraphis-mcp` is zero-configuration Smart MCP: agents begin with nine compact tools for sessions, +prompt-ready recall, durable memory, governed record read/update, conflict review, action discovery, +and safe execution. For code graphs, +governance, audit, or other advanced work, the agent calls `engraphis_discover_actions` and then +the indicated read or action executor; no profile selection is required. The gateway validates +the discovered capability again before it runs it, and clients remain responsible for their +normal destructive-action approval boundary. + +Existing clients that pin the historical 33 named tools can use +`engraphis-mcp-classic` (or `engraphis-mcp-http --classic`). The complete classic inventory, +including `engraphis_check_update`, is in the [MCP tool reference](docs/MCP_TOOLS.md). + +### Pi extension + +For installation, configuration, lifecycle commands, and the local trust boundary, see the +[Pi extension guide](integrations/pi/README.md). + +### Hermes provider + +Engraphis also ships a native Hermes memory-provider plugin with local prefetch, bounded turn +capture, scoped recall, and explicit secure erase. Install Engraphis in the Hermes Python +environment, copy the provider, then select it with `hermes memory setup`. See the +[Hermes integration guide](integrations/hermes/README.md). The provider never installs itself or +downloads an embedding model. + +## Quickstart: repository graph + +```bash +pip install "engraphis[code]" +engraphis-graph index -w acme -r api --root . +engraphis-graph search -w acme -r api "UserService" +# `query`/`explain` blend code search with your stored memories: query matches symbol +# and file NAMES (a full question sentence won't match anything), and explain's answer +# is drawn from memories recorded against the repo; both are empty on a fresh index. +engraphis-graph query -w acme -r api "UserService" +engraphis-graph explain -w acme -r api "why does deploy depend on approval?" +engraphis-graph path -w acme -r api UserService DatabasePool +engraphis-graph impact -w acme -r api --root . --git-range origin/main...HEAD +engraphis-graph prs -w acme -r api --base main --head HEAD +engraphis-graph export -w acme -r api -o engraphis-graph-out +engraphis-graph install-merge-driver --root . +``` + +The export contains `graph.json`, a self-contained `graph.html`, and `GRAPH_REPORT.md`. +Indexing supports Python, JavaScript, TypeScript, Go, Rust, Java, C#, C, C++, SQL, and +Terraform. Tree-sitter is used when available; the dependency-free regex backend remains a +functional fallback. Definitions, methods, calls, imports, ownership, variables, +inheritance/implementation, and docstrings/comments are indexed. Indexing is incremental by +content hash, honors `.engraphisignore`, and does not follow file symlinks outside the repository +root. Call edges are name-based and best-effort rather than type-resolved. The optional Git merge +driver validates bounded graph JSON and deterministically unions nodes and edges instead of +choosing one export side. + +For a read-only recall and graph API that can be shared without exposing write operations: + +```bash +pip install "engraphis[server]" +engraphis-graph-server # API at http://127.0.0.1:8720; schema at /openapi.json +``` + +A non-loopback bind fails closed unless `ENGRAPHIS_GRAPH_TOKEN` (or +`ENGRAPHIS_API_TOKEN`) is set. See [the v3 architecture/design document](docs/ARCHITECTURE_V3.md). + +--- + +## Quickstart: Python library + +```python +from engraphis.service import MemoryService + +mem = MemoryService.create("engraphis.db") +mem.remember("Auth migrated from JWT to PASETO.", workspace="acme", repo="api") +hit = mem.recall("why did we change auth?", workspace="acme", repo="api") +print(hit["context"]) +``` + +The same `MemoryService` backs the dashboard and the MCP server. + +After an upgrade, `stats()` reports prompt-eligibility counts and active embedding-space +coverage. Zero-result recall identifies a review-gated scope instead of silently looking empty, +and `engraphis-cli review list|approve` provides a dry-run-first local bulk workflow. Embedding +model changes trigger a guarded rebuild; vector recall stays disabled until every stored vector +matches the new fingerprint. See [recall recovery](docs/RECALL_RECOVERY.md). + +Agent hosts can avoid retrieval when their existing history already fits: + +```python +decision = mem.adaptive_context( + "what should the agent do next?", + current_history, + workspace="acme", + repo="api", + max_context_tokens=8_192, + retrieval_token_budget=1_024, +) +prompt_context = decision["context"] +``` + +The decision is `history_bypass` when the history fits, `retrieval` when compact evidence is +strong, and `history_fallback` when weak retrieval should widen back to recent raw history. + +For an agent prompt, prefer `engraphis_recall_context`: it returns one hard-budget packed +`context` plus compact `sources`, deterministic `usage` accounting (`budget_tokens`, `context_tokens`, +`source_tokens`, `saved_tokens`, `savings_ratio`, `packed_count`, `omitted_count`, and +`token_counter`), and optional diagnostics. Accounting is exact for the named counter; inject the +reader's tokenizer when reader-model token parity is required. `engraphis_recall` remains the compatible full-recall +surface; use `response_mode="compact"` when the packed context is enough and full memory bodies +would duplicate it. For advanced query-planning configuration, see the +[architecture guide](docs/ARCHITECTURE_V3.md#query-planning). + +For bi-temporal reads, `valid_at` selects what was true at a Unix timestamp and `known_at` selects +what Engraphis had learned then. `as_of` remains a compatibility alias for `valid_at`; supplying +both is allowed only when they match. + +For a mutable claim, pass a stable `subject_key` and optional `claim_kind`, such as +`subject_key="api.rate_limit", claim_kind="configured_value"`. Offline conflict resolution +deterministically adds, reinforces, relates, or supersedes records while preserving temporal +history; it does not need an LLM. Matching claim identities let it supersede substantially +reworded mutable facts. Without them, the dependency-free lexical embedder cannot reliably infer +that a paraphrase is a contradiction, so keep both records or use an explicit `correct` operation. + +--- + +## Govern memories without losing history + +Engraphis separates automatic write resolution from explicit human governance: + +| Operation | Use it when | What happens to history | +|---|---|---| +| `remember` | Adding or restating one fact | Adds, reinforces, safely supersedes, or relates an uncertain neighbor | +| `correct` | Replacing one known-wrong memory | Closes the old validity window and links the replacement | +| `promote` | A narrow learning now applies more broadly | Writes a wider-scope successor and closes/links the source instead of editing scope in place | +| `merge` | Combining two or more overlapping memories | Retires every source and creates one memory that supersedes all of them | +| `retire` | Removing a memory from live recall | Bi-temporally closes it; the audit/history record remains | +| `consolidate` | Distilling recurring episodic memories automatically | Creates linked semantic digests; sources stay live unless explicit supersession is requested | + +Manual N→1 merge is available through `MemoryService.merge()` and `POST /api/merge`: + +```python +a = mem.remember("Deploys happen Friday at 3pm.", workspace="acme") +b = mem.remember("We deploy Fridays around 15:00.", workspace="acme") + +merged = mem.merge( + [a["id"], b["id"]], + "Deploys ship every Friday at approximately 15:00.", + workspace="acme", + reason="deduplicate the deployment schedule", +) +print(merged["compaction"]) +``` + +`retire` is intentionally not deletion: it preserves temporal history, FTS, and vector +evidence for historical reads. If a credential was captured, new writes are blocked before +storage; for a legacy leak use the explicitly destructive `MemoryService.secure_erase()` or +`POST /api/secure-erase`/`engraphis_secure_erase`. That flow removes the one memory and local +FTS/vector-index and derived graph/link rows, runs SQLite secure-delete, WAL checkpoint, and +VACUUM, and scans recognised local SQLite recovery backups. It cannot erase exports, filesystem +snapshots, remote peers, unknown backups, or information a running/compromised agent already +read; rotate the credential. See [secure-erasure limits](docs/SECURE_ERASURE.md). `forget` +remains a deprecated compatibility alias for `retire`. + +All sources must belong to the named workspace. The result inherits the strictest source +sensitivity, remains untrusted if any source was untrusted, and stays pinned if any source was +pinned. The full multi-predecessor chain remains visible through inspection, Why, and Timeline. + +--- + +## Free forever vs. hosted plans + +The core engine, local dashboard, MCP server, and manual consolidation are Apache-2.0 and free. +**Pro and Team are services** that provide optional access to the official hosted service; its +control-plane, billing, relay, compute, and Team identity modules live in a private repository. +They do not limit the local core. See +[hosted plans](docs/HOSTED_PLANS.md), [licensing](docs/LICENSING.md), and +[Cloud Sync](docs/SYNC.md) for service boundaries, lifecycle, and pricing. + +[Subscribe to Pro](https://api.engraphis.com/account?plan=pro&interval=monthly&utm_source=engraphis&utm_medium=docs&utm_campaign=pro_conversion&utm_content=readme_pricing#billing) +to support the project and add hosted services. + +[Compare hosted plans](https://api.engraphis.com/account?plan=pro&interval=monthly&utm_source=engraphis&utm_medium=docs&utm_campaign=pro_conversion&utm_content=readme_intro#billing) +when you are ready to evaluate the service boundary and billing options. + +| | Free (available now) | Pro: $10/mo or $100/yr | Team: $20/seat/mo or $200/seat/yr | +|---|---|---|---| +| Dashboard WebUI (with built-in inspector) | ✓ | ✓ | ✓ | +| Memory engine + Smart MCP (Classic 33-tool compatibility) | ✓ | ✓ | ✓ | +| Version-chain diffs, offline knowledge graph | ✓ | ✓ | ✓ | +| Manual local consolidation (dry-run by default) | ✓ | ✓ | ✓ | +| Local workspace export (JSON: memories, sessions, audit) | ✓ | ✓ | ✓ | +| Hosted Cloud Sync | | ✓ | ✓ | +| Hosted Analytics | | ✓ | ✓ | +| Hosted Auto Consolidation + retention policy | | ✓ | ✓ | +| Hosted Auto Dreaming + managed proposals | | ✓ | ✓ | +| Priority support | | ✓ | ✓ | +| Hosted multi-user dashboard: invitations, logins, roles, seat management | | | ✓ | +| Hosted Team audit log + CSV export | | | ✓ | +| 72-hour pending invitations (resend/revoke) | | | ✓ | +| Scoped, expiring per-user agent and sync tokens | | | ✓ | + +--- + +## MCP tools + +Engraphis exposes a zero-configuration Smart MCP gateway plus a 33-tool Classic compatibility +server across memory, recall, code graphs, governance, sessions, and privacy-safe audit receipts. +The focused [MCP tool reference](docs/MCP_TOOLS.md) is the source for +the full inventory and parameters. + +--- + +## Graphs and privacy-safe receipts + +Memory, entity, and code relationships live in one local graph. Engraphis also provides +content-free operation receipts for inspectable audit evidence. See the +[architecture](docs/ARCHITECTURE_V3.md), [MCP tool reference](docs/MCP_TOOLS.md), and +[security policy](SECURITY.md) for the data model, tools, and guarantees. + +--- + +## Cloud sync + +Cloud Sync is an optional hosted Pro/Team service. The public package includes the customer client +and deterministic merge implementation; hosted relay and account operations are separate. See +[Cloud Sync](docs/SYNC.md) for setup, encryption, merge behavior, and the local folder exchange. + +--- + +## Security and trust boundaries + +Engraphis is local-first and binds to loopback by default. Read the +[security policy](SECURITY.md) before remote deployment or integrating external resources; it +covers supported versions, data protections, threat model, and vulnerability reporting. + +--- + +## Encryption at rest + +Set `ENGRAPHIS_DB_KEY` (or `ENGRAPHIS_DB_KEY_FILE`) and install the extra: + +```bash +pip install "engraphis[encryption]" +``` + +The entire main memory database file is transparently encrypted with AES-256 via SQLCipher; +full-text search, the graph, and every query keep working unchanged. Customer authentication +and managed-service state use their respective deployment protections. When a key is set for the +main database, Engraphis **fails closed with an error** rather than silently falling back to +plaintext. Generate a strong key: + +```bash +python -c "import secrets; print(secrets.token_hex(32))" +``` + +When using `ENGRAPHIS_DB_KEY_FILE`, provision a regular secret file readable only by the +service identity. Engraphis rejects links, reparse points, hard links, malformed text, and +oversized key files rather than following an unexpected filesystem object. + +> An existing plaintext database cannot be opened with a key: migrate it (dump → import +> into a fresh keyed DB). See `.env.example` for all encryption options. + +--- + +## Import files and folders + +Import supported documents and code through the dashboard, a local folder, or MCP. Optional +extractors add offline chunking, structured LLM extraction, document OCR, transcription, and +PostgreSQL schema ingestion. See the [MCP tool reference](docs/MCP_TOOLS.md), +[architecture guide](docs/ARCHITECTURE_V3.md), and [security policy](SECURITY.md) for formats, +configuration, and local-resource safeguards. + +--- + +## Consolidation and automation + +Manual consolidation is free, local, and dry-run by default; use the dashboard, SDK, CLI, or +MCP. Hosted Pro and Team automation is optional managed compute that produces reviewable +proposals rather than silently changing local data. See [hosted plans](docs/HOSTED_PLANS.md), +[licensing](docs/LICENSING.md), and the [MCP tool reference](docs/MCP_TOOLS.md) for scope and use. + +--- + +## Configuration + +All via environment (or `.env`): + +| Env Var | Default | Description | +|---------|---------|-------------| +| `ENGRAPHIS_DB_PATH` | Source: `/engraphis.db`; installed: platform user-data directory | SQLite database file. Installed defaults are `%LOCALAPPDATA%\engraphis\engraphis.db` (Windows), `~/Library/Application Support/engraphis/engraphis.db` (macOS), and `$XDG_DATA_HOME/engraphis/engraphis.db` or `~/.local/share/engraphis/engraphis.db` (Linux). The environment variable overrides every default. | +| `ENGRAPHIS_HOST` | `127.0.0.1` | Server bind address | +| `ENGRAPHIS_PORT` | `8700` | Dashboard port | +| `ENGRAPHIS_SERVICE_MODE` | `customer` | The public package supports only `customer`; hosted vendor, relay, compute, and worker roles are not distributed here | +| `ENGRAPHIS_API_TOKEN` | Not set | Optional bearer credential for this single-user local customer node; never reuse a hosted credential | +| `ENGRAPHIS_CORS_ORIGINS` | loopback on `ENGRAPHIS_PORT` | Comma-separated REST CORS allow-list; defaults to `127.0.0.1` and `localhost` on the configured port | +| `ENGRAPHIS_WORKSPACES` | Not set | Optional comma-separated server-side workspace allow-list | +| `ENGRAPHIS_INDEX_ROOTS` | Working, home, and temporary directories | Optional path-separator-delimited absolute-path allow-list that replaces the default roots accepted by local code indexing | +| `ENGRAPHIS_HTTP_INDEX_ROOT` | First `ENGRAPHIS_INDEX_ROOTS` entry, or current directory | Single root for dashboard and REST `POST /api/code/index`; submitted paths resolve beneath it. An explicit root (or fallback entry) must be absolute; an explicit HTTP root is included in the engine-approved set. MCP and CLI indexing continue to use `ENGRAPHIS_INDEX_ROOTS`. | +| `ENGRAPHIS_DB_KEY` | Not set | Encrypt the database at rest (SQLCipher). Or use `ENGRAPHIS_DB_KEY_FILE` | +| `ENGRAPHIS_EMBED_MODEL` | `sentence-transformers/all-MiniLM-L6-v2` | sentence-transformers model | +| `ENGRAPHIS_EMBED_REVISION` | Not set | Optional immutable lowercase 40-hex Hugging Face commit for the embedding model | +| `ENGRAPHIS_RERANK_MODEL` | Not set | Optional sentence-transformers cross-encoder reranker | +| `ENGRAPHIS_RERANK_REVISION` | Not set | Optional immutable lowercase 40-hex Hugging Face commit for the reranker | +| `ENGRAPHIS_REQUIRE_IMMUTABLE_MODELS` | `false` | When enabled, require a 40-hex commit before loading remote embedding models, rerankers, or chunk tokenizers; `local:` selectors and filesystem paths remain permitted | +| `ENGRAPHIS_EXTRACTOR` | `none` | `none` = verbatim; `chunk` = offline structure-aware chunks; `llm` = free-form LLM facts; `llm_structured` = schema-validated facts + graph metadata | +| `ENGRAPHIS_CHUNK_TOKENIZER_MODEL` | Not set | Optional Hugging Face tokenizer used to enforce chunk budgets with the downstream reader's real tokenization; requires the optional `transformers` package | +| `ENGRAPHIS_CHUNK_TOKENIZER_REVISION` | Not set | Optional immutable tokenizer/model revision recorded in the chunk-counter identity; pin this for reproducible benchmark artifacts | +| `ENGRAPHIS_GRAPH_EXTRACTOR` | `regex` | `regex` = offline heuristic NER; `none` = disable heuristic text extraction (validated `llm_structured` metadata still feeds the graph) | +| `ENGRAPHIS_RETENTION_SUPERVISOR` | `none` | `none` = deterministic only; `llm` = sends a bounded excerpt to the configured provider for advisory ephemeral/normal/critical classification | +| `ENGRAPHIS_ALLOW_AUTOMATIC_CRITICAL_RETENTION` | `false` | Opt in only when an LLM supervisor may automatically assign the long-lived `critical` class; explicit user-selected critical retention is unaffected | +| `ENGRAPHIS_WHISPER_MODEL` | Not set | Enables local faster-whisper audio/video transcription | +| `ENGRAPHIS_POSTGRES_DSN` | Not set | CLI-only PostgreSQL source; used for the connection and never stored | +| `ENGRAPHIS_POSTGRES_CONNECT_TIMEOUT` | `10` | PostgreSQL introspection connection timeout in seconds (bounded to 1–120) | +| `ENGRAPHIS_POSTGRES_STATEMENT_TIMEOUT_MS` | `30000` | Per-introspection PostgreSQL statement timeout in milliseconds (bounded to 1–300000) | +| `ENGRAPHIS_GRAPH_TOKEN` | Not set | Bearer token for `engraphis-graph-server`; required off-loopback | +| `ENGRAPHIS_GRAPH_HOST` / `ENGRAPHIS_GRAPH_PORT` | `127.0.0.1` / `8720` | Read-only graph/recall server bind address | +| `ENGRAPHIS_LLM_PROVIDER` | `openai` | `openai \| anthropic \| google \| openrouter \| custom` | +| `ENGRAPHIS_LLM_MODEL` | `gpt-4o-mini` | Model name (provider-specific) | +| `ENGRAPHIS_LLM_API_KEY` | Not set | API key for chat/synthesis, `llm` / `llm_structured` extraction, and structured consolidation | +| `ENGRAPHIS_LLM_BASE_URL` | Not set | Base URL for openrouter / custom OpenAI-compatible endpoints | +| `ENGRAPHIS_LLM_AUTO_EXTRACT` | `0` | Opt in to switching the running engine to `llm_structured` after a successful live connection test; the dashboard's extraction Off button persists `0`, and its On button restores `1` | +| `ENGRAPHIS_FORWARDED_ALLOW_IPS` | *(none)* | Proxies trusted for forwarded client/TLS headers (`*` only when the service is reachable exclusively through that proxy) | +| `ENGRAPHIS_LOCAL_TRUSTED_PEERS` | *(none)* | Exact peers/CIDRs treated as local without forwarding headers; use only for trusted Docker/LAN peers, never public deployments | +| `ENGRAPHIS_CLOUD_CONTROL_URL` | hosted default | Official entitlement, organization, and credential control API | +| `ENGRAPHIS_CLOUD_COMPUTE_URL` | hosted default | Official Analytics and managed-automation API | +| `ENGRAPHIS_CLOUD_ORGANIZATION_ID` | Not set | Hosted organization bound to this customer session | +| `ENGRAPHIS_CLOUD_REFRESH_CREDENTIAL` | Not set | Bootstrap-only rotating hosted credential; after first use the owner-only cloud session replacement takes precedence | +| `ENGRAPHIS_CLOUD_TOKEN_SUBJECT` | `member` | Subject fixed during hosted bootstrap (`device` or `member`); set explicitly with an environment-only refresh credential | +| `ENGRAPHIS_CLOUD_ACCESS_TOKEN` | Not set | Optional short-lived access token for ephemeral jobs | +| `ENGRAPHIS_MANAGED_COMPUTE_CONSENT` | *(auto)* | Operator override only; default follows whether a cloud session is configured (connected = allowed, local-only = never). `0` opts a connected installation out; `1` permits local snapshot preparation but does not create a cloud credential or authorize an upload | + +See `.env.example` for the full customer-runtime and managed-service client options. + +--- + +## Project structure + +``` +engraphis/ +├── 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 # Smart MCP gateway + 33-tool Classic compatibility server +│ ├── dashboard_app.py # dashboard WebUI (FastAPI) +│ ├── dashboard_assets/ # primary Ledger interface + graph engine +│ ├── classic_assets/ # selectable full operator dashboard backup +│ ├── read_only_api.py # token-protected recall/repository-graph HTTP surface +│ ├── hosted_client.py # hosted URLs, plan labels, and endpoint validation only +│ ├── licensing.py # compatibility facade for hosted presentation metadata +│ ├── cloud_session.py # rotating hosted customer-session client +│ ├── cloud_features.py # consented managed-feature protocol client +│ ├── config.py / app.py # env settings / REST server +│ └── static/ # compatibility dashboard asset paths +├── eval/ # offline retrieval eval harness + datasets +├── tests/ # offline-first pytest suite and release/security contracts +├── scripts/ # dashboard, server, graph, CLI, connect, update, consolidation, sync +├── docs/ # product, API, hosting, sync, and provider guides +├── Dockerfile / docker-compose.yml +└── pyproject.toml +``` + +New capability belongs in the v2 path (`engraphis/core/`, `engraphis/backends/`, and +`MemoryService`) behind the interfaces in `core/interfaces.py`. The flat-namespace v1 server +under `engraphis/app.py`, `routes/`, `stores/`, and `engines/` remains a compatibility/reference +surface; `engraphis-dashboard`, the MCP server, and the Python quickstart above use v2. + +--- + +## License + +Apache-2.0. See [LICENSE](LICENSE) and [NOTICE](NOTICE). "Engraphis" is a trademark of the +Engraphis project; the license does not grant trademark rights. Code already distributed +under Apache-2.0 keeps that grant; later releases cannot retroactively withdraw it. The +official hosted control plane, its production credentials and records, managed operations, +support, and future separately delivered commercial modules are outside the public source +grant. See [`docs/LICENSING.md`](docs/LICENSING.md) for the complete boundary. diff --git a/engraphis/__init__.py b/engraphis/__init__.py index 6bef7f1c..e71355cb 100644 --- a/engraphis/__init__.py +++ b/engraphis/__init__.py @@ -1,17 +1,17 @@ -"""Engraphis — self-hosted AI memory system.""" - -from importlib.metadata import PackageNotFoundError, version as _dist_version - -_SOURCE_VERSION = "1.5.0" - -try: - __version__ = _dist_version("engraphis") - # Editable checkouts can retain stale dist-info until their next reinstall. The - # checked-in source version is authoritative for this runtime and must not advertise - # the prior MCP contract merely because metadata has not been refreshed yet. - if __version__ != _SOURCE_VERSION: - __version__ = _SOURCE_VERSION -except PackageNotFoundError: # source tree without an installed distribution - # Keep in step with [project] version in pyproject.toml — tests/test_packaging.py - # pins the two together so a release cannot ship them out of sync. - __version__ = "1.5.0" +"""Engraphis — self-hosted AI memory system.""" + +from importlib.metadata import PackageNotFoundError, version as _dist_version + +_SOURCE_VERSION = "1.5" + +try: + __version__ = _dist_version("engraphis") + # Editable checkouts can retain stale dist-info until their next reinstall. The + # checked-in source version is authoritative for this runtime and must not advertise + # the prior MCP contract merely because metadata has not been refreshed yet. + if __version__ != _SOURCE_VERSION: + __version__ = _SOURCE_VERSION +except PackageNotFoundError: # source tree without an installed distribution + # Keep in step with [project] version in pyproject.toml — tests/test_packaging.py + # pins the two together so a release cannot ship them out of sync. + __version__ = "1.5" diff --git a/engraphis/commercial_manifest.json b/engraphis/commercial_manifest.json index 20d5602f..898326fc 100644 --- a/engraphis/commercial_manifest.json +++ b/engraphis/commercial_manifest.json @@ -1,94 +1,94 @@ -{ - "schema": "engraphis-commercial/v2", - "version": "1.5.0", - "control_plane": "https://api.engraphis.com", - "account_portal": "https://api.engraphis.com/account", - "billing": { - "authority": "stripe", - "new_subscriptions": "stripe", - "legacy_providers": [], - "checkout_mode": "authenticated_server_session", - "portal_url": "https://api.engraphis.com/account#billing", - "provider_price_ids_public": false - }, - "trial": { - "days": 3, - "card_required": false, - "plans": ["pro", "team"] - }, - "entitlement_lifecycle": { - "max_grace_hours": 24, - "grace_mode": "workspace_write_grace", - "enforced_by": "private_control_plane", - "grace_for": "already_authorized_hosted_accounts", - "grace_allows": ["authenticated_existing_user_hosted_account_continuity"], - "live_authorization_still_required_for": [ - "paid_or_cost_bearing_features", - "hosted_mcp_or_agent_writes" - ], - "grace_blocks_account_growth": true, - "trial_expiry_extended_by_grace": false, - "recovery": { - "mode": "recovery_read_only", - "enforced_by": "private_control_plane", - "allows": [ - "login", - "password_recovery", - "authenticated_reads", - "data_export", - "relicensing" - ], - "blocks_normal_mutations": true - } - }, - "plans": { - "free": { - "monthly_usd": 0, - "annual_usd": 0, - "billing_unit": "installation" - }, - "pro": { - "monthly_usd": 10, - "annual_usd": 100, - "billing_unit": "owner", - "products": { - "monthly": { - "provider": "stripe", - "checkout_url": "https://api.engraphis.com/account?plan=pro&interval=monthly#billing" - }, - "annual": { - "provider": "stripe", - "checkout_url": "https://api.engraphis.com/account?plan=pro&interval=annual#billing" - } - } - }, - "team": { - "monthly_usd": 20, - "annual_usd": 200, - "billing_unit": "seat", - "products": { - "monthly": { - "provider": "stripe", - "checkout_url": "https://api.engraphis.com/account?plan=team&interval=monthly#billing" - }, - "annual": { - "provider": "stripe", - "checkout_url": "https://api.engraphis.com/account?plan=team&interval=annual#billing" - } - } - } - }, - "features": { - "offline_free_core": true, - "public_paid_license_gate": false, - "public_vendor_authority": false, - "hosted_authorization": true, - "hosted_opt_in_cloud_sync": true, - "hosted_multi_user_roles": true, - "hosted_team_audit_export": true, - "hosted_scoped_agent_tokens": true, - "end_to_end_encrypted_sync": true, - "sso": false, - "contractual_sla": false - } -} +{ + "schema": "engraphis-commercial/v2", + "version": "1.5", + "control_plane": "https://api.engraphis.com", + "account_portal": "https://api.engraphis.com/account", + "billing": { + "authority": "stripe", + "new_subscriptions": "stripe", + "legacy_providers": [], + "checkout_mode": "authenticated_server_session", + "portal_url": "https://api.engraphis.com/account#billing", + "provider_price_ids_public": false + }, + "trial": { + "days": 3, + "card_required": false, + "plans": ["pro", "team"] + }, + "entitlement_lifecycle": { + "max_grace_hours": 24, + "grace_mode": "workspace_write_grace", + "enforced_by": "private_control_plane", + "grace_for": "already_authorized_hosted_accounts", + "grace_allows": ["authenticated_existing_user_hosted_account_continuity"], + "live_authorization_still_required_for": [ + "paid_or_cost_bearing_features", + "hosted_mcp_or_agent_writes" + ], + "grace_blocks_account_growth": true, + "trial_expiry_extended_by_grace": false, + "recovery": { + "mode": "recovery_read_only", + "enforced_by": "private_control_plane", + "allows": [ + "login", + "password_recovery", + "authenticated_reads", + "data_export", + "relicensing" + ], + "blocks_normal_mutations": true + } + }, + "plans": { + "free": { + "monthly_usd": 0, + "annual_usd": 0, + "billing_unit": "installation" + }, + "pro": { + "monthly_usd": 10, + "annual_usd": 100, + "billing_unit": "owner", + "products": { + "monthly": { + "provider": "stripe", + "checkout_url": "https://api.engraphis.com/account?plan=pro&interval=monthly#billing" + }, + "annual": { + "provider": "stripe", + "checkout_url": "https://api.engraphis.com/account?plan=pro&interval=annual#billing" + } + } + }, + "team": { + "monthly_usd": 20, + "annual_usd": 200, + "billing_unit": "seat", + "products": { + "monthly": { + "provider": "stripe", + "checkout_url": "https://api.engraphis.com/account?plan=team&interval=monthly#billing" + }, + "annual": { + "provider": "stripe", + "checkout_url": "https://api.engraphis.com/account?plan=team&interval=annual#billing" + } + } + } + }, + "features": { + "offline_free_core": true, + "public_paid_license_gate": false, + "public_vendor_authority": false, + "hosted_authorization": true, + "hosted_opt_in_cloud_sync": true, + "hosted_multi_user_roles": true, + "hosted_team_audit_export": true, + "hosted_scoped_agent_tokens": true, + "end_to_end_encrypted_sync": true, + "sso": false, + "contractual_sla": false + } +} diff --git a/engraphis/core/savings.py b/engraphis/core/savings.py index 4dd02f4e..41b3e10a 100644 --- a/engraphis/core/savings.py +++ b/engraphis/core/savings.py @@ -1,163 +1,163 @@ -"""Pure token-savings estimation for prompt-context deliveries. - -The estimator deliberately distinguishes an actual host-history baseline from the -smaller source-packing baseline used by ordinary recall. It is an estimate of -avoided prompt context, not provider billing or end-to-end task cost. -""" -from __future__ import annotations - -import math -import re -from dataclasses import dataclass -from typing import Any, Optional - - -_RELEASE_VERSION = re.compile(r"^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$") - - -@dataclass(frozen=True) -class SavingsEstimate: - """One explainable, content-free token-savings estimate.""" - - baseline_tokens: int - emitted_tokens: int - saved_tokens: int - savings_ratio: float - basis: str - confidence: str - eligible: bool - token_counter: str = "unknown" - release_version: Optional[str] = None - - @property - def estimated_saved_tokens(self) -> int: - """Name used by receipt metadata for the same saved-token value.""" - return self.saved_tokens - - def to_dict(self) -> dict[str, Any]: - return { - "baseline_tokens": self.baseline_tokens, - "emitted_tokens": self.emitted_tokens, - "saved_tokens": self.saved_tokens, - "savings_ratio": self.savings_ratio, - "basis": self.basis, - "confidence": self.confidence, - "eligible": self.eligible, - "token_counter": self.token_counter, - **({"release_version": self.release_version} - if self.release_version else {}), - } - - -def normalize_release_version(value: Any) -> Optional[str]: - """Return a safe release label, or ``None`` for historical/unversioned data.""" - if not isinstance(value, str): - return None - value = value.strip() - return value if _RELEASE_VERSION.fullmatch(value) else None - - -def _count(value: Any) -> int: - if type(value) not in (int, float): - return 0 - if not math.isfinite(float(value)) or value < 0: - return 0 - return int(value) - - -def estimate_savings( - *, - operation: str, - baseline_tokens: Any, - emitted_tokens: Any, - token_counter: str = "unknown", - intent: Optional[str] = None, - adaptive_mode: Optional[str] = None, - release_version: Optional[str] = None, -) -> SavingsEstimate: - """Classify one delivery and compute its conservative savings estimate. - - ``adaptive_context`` has a real before/after history baseline. The packed - context operations use their retrieved-source total as a narrower packing - baseline. Ordinary full recall is not counted because callers may not inject - its returned memories into a model prompt. - """ - operation = str(operation or "").strip().casefold() - intent = str(intent or "").strip().casefold() - mode = str(adaptive_mode or "").strip().casefold() - - basis = "unclassified" - confidence = "unknown" - eligible = False - - if operation == "adaptive_context": - if mode == "retrieval": - basis, confidence, eligible = "history_retrieval", "high", True - elif mode == "history_fallback": - basis, confidence, eligible = "history_fallback", "medium", True - elif mode == "history_bypass": - basis, confidence, eligible = "history_bypass", "none", False - elif mode == "low_confidence_abstain": - basis, confidence, eligible = "low_confidence_abstain", "none", False - elif operation == "recall" and intent == "recall_context": - basis, confidence, eligible = "packed_context", "medium", True - elif operation in {"grounded_recall", "proactive_context"}: - basis, confidence, eligible = "packed_context", "medium", True - - baseline = _count(baseline_tokens) - emitted = _count(emitted_tokens) - saved = max(0, baseline - emitted) if eligible else 0 - ratio = saved / baseline if baseline else 0.0 - counter = str(token_counter or "unknown") - return SavingsEstimate( - baseline_tokens=baseline, - emitted_tokens=emitted, - saved_tokens=saved, - savings_ratio=ratio, - basis=basis, - confidence=confidence, - eligible=eligible, - token_counter=counter, - release_version=normalize_release_version(release_version), - ) - - -def annotate_usage( - usage: dict[str, Any], - *, - operation: str, - intent: Optional[str] = None, - adaptive_mode: Optional[str] = None, - baseline_tokens: Any = None, - emitted_tokens: Any = None, - release_version: Optional[str] = None, -) -> dict[str, Any]: - """Add estimator fields to an existing public usage dictionary.""" - estimate = estimate_savings( - operation=operation, - intent=intent, - adaptive_mode=adaptive_mode, - baseline_tokens=( - usage.get("source_tokens", 0) - if baseline_tokens is None else baseline_tokens - ), - emitted_tokens=( - usage.get("context_tokens", 0) - if emitted_tokens is None else emitted_tokens - ), - token_counter=str(usage.get("token_counter") or "unknown"), - release_version=release_version, - ) - out = dict(usage) - out.update({ - "baseline_tokens": estimate.baseline_tokens, - "emitted_tokens": estimate.emitted_tokens, - "estimated_saved_tokens": estimate.saved_tokens, - "estimated_savings_ratio": estimate.savings_ratio, - "savings_basis": estimate.basis, - "savings_confidence": estimate.confidence, - "savings_eligible": estimate.eligible, - }) - if estimate.release_version: - out["release_version"] = estimate.release_version - return out +"""Pure token-savings estimation for prompt-context deliveries. + +The estimator deliberately distinguishes an actual host-history baseline from the +smaller source-packing baseline used by ordinary recall. It is an estimate of +avoided prompt context, not provider billing or end-to-end task cost. +""" +from __future__ import annotations + +import math +import re +from dataclasses import dataclass +from typing import Any, Optional + + +_RELEASE_VERSION = re.compile(r"^\d+\.\d+(?:\.\d+)?(?:[-+][0-9A-Za-z.-]+)?$") + + +@dataclass(frozen=True) +class SavingsEstimate: + """One explainable, content-free token-savings estimate.""" + + baseline_tokens: int + emitted_tokens: int + saved_tokens: int + savings_ratio: float + basis: str + confidence: str + eligible: bool + token_counter: str = "unknown" + release_version: Optional[str] = None + + @property + def estimated_saved_tokens(self) -> int: + """Name used by receipt metadata for the same saved-token value.""" + return self.saved_tokens + + def to_dict(self) -> dict[str, Any]: + return { + "baseline_tokens": self.baseline_tokens, + "emitted_tokens": self.emitted_tokens, + "saved_tokens": self.saved_tokens, + "savings_ratio": self.savings_ratio, + "basis": self.basis, + "confidence": self.confidence, + "eligible": self.eligible, + "token_counter": self.token_counter, + **({"release_version": self.release_version} + if self.release_version else {}), + } + + +def normalize_release_version(value: Any) -> Optional[str]: + """Return a safe release label, or ``None`` for historical/unversioned data.""" + if not isinstance(value, str): + return None + value = value.strip() + return value if _RELEASE_VERSION.fullmatch(value) else None + + +def _count(value: Any) -> int: + if type(value) not in (int, float): + return 0 + if not math.isfinite(float(value)) or value < 0: + return 0 + return int(value) + + +def estimate_savings( + *, + operation: str, + baseline_tokens: Any, + emitted_tokens: Any, + token_counter: str = "unknown", + intent: Optional[str] = None, + adaptive_mode: Optional[str] = None, + release_version: Optional[str] = None, +) -> SavingsEstimate: + """Classify one delivery and compute its conservative savings estimate. + + ``adaptive_context`` has a real before/after history baseline. The packed + context operations use their retrieved-source total as a narrower packing + baseline. Ordinary full recall is not counted because callers may not inject + its returned memories into a model prompt. + """ + operation = str(operation or "").strip().casefold() + intent = str(intent or "").strip().casefold() + mode = str(adaptive_mode or "").strip().casefold() + + basis = "unclassified" + confidence = "unknown" + eligible = False + + if operation == "adaptive_context": + if mode == "retrieval": + basis, confidence, eligible = "history_retrieval", "high", True + elif mode == "history_fallback": + basis, confidence, eligible = "history_fallback", "medium", True + elif mode == "history_bypass": + basis, confidence, eligible = "history_bypass", "none", False + elif mode == "low_confidence_abstain": + basis, confidence, eligible = "low_confidence_abstain", "none", False + elif operation == "recall" and intent == "recall_context": + basis, confidence, eligible = "packed_context", "medium", True + elif operation in {"grounded_recall", "proactive_context"}: + basis, confidence, eligible = "packed_context", "medium", True + + baseline = _count(baseline_tokens) + emitted = _count(emitted_tokens) + saved = max(0, baseline - emitted) if eligible else 0 + ratio = saved / baseline if baseline else 0.0 + counter = str(token_counter or "unknown") + return SavingsEstimate( + baseline_tokens=baseline, + emitted_tokens=emitted, + saved_tokens=saved, + savings_ratio=ratio, + basis=basis, + confidence=confidence, + eligible=eligible, + token_counter=counter, + release_version=normalize_release_version(release_version), + ) + + +def annotate_usage( + usage: dict[str, Any], + *, + operation: str, + intent: Optional[str] = None, + adaptive_mode: Optional[str] = None, + baseline_tokens: Any = None, + emitted_tokens: Any = None, + release_version: Optional[str] = None, +) -> dict[str, Any]: + """Add estimator fields to an existing public usage dictionary.""" + estimate = estimate_savings( + operation=operation, + intent=intent, + adaptive_mode=adaptive_mode, + baseline_tokens=( + usage.get("source_tokens", 0) + if baseline_tokens is None else baseline_tokens + ), + emitted_tokens=( + usage.get("context_tokens", 0) + if emitted_tokens is None else emitted_tokens + ), + token_counter=str(usage.get("token_counter") or "unknown"), + release_version=release_version, + ) + out = dict(usage) + out.update({ + "baseline_tokens": estimate.baseline_tokens, + "emitted_tokens": estimate.emitted_tokens, + "estimated_saved_tokens": estimate.saved_tokens, + "estimated_savings_ratio": estimate.savings_ratio, + "savings_basis": estimate.basis, + "savings_confidence": estimate.confidence, + "savings_eligible": estimate.eligible, + }) + if estimate.release_version: + out["release_version"] = estimate.release_version + return out diff --git a/pyproject.toml b/pyproject.toml index 11e832b6..b8342c42 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,284 +1,284 @@ -[build-system] -requires = [ - # 83.0 fixes CVE-2026-59890 in sdist exclusion handling. Keep the Python 3.9 - # core installable with the newest compatible backend; release builds run 3.11. - "setuptools>=83.0; python_version >= '3.10'", - "setuptools>=77.0; python_version < '3.10'", - "wheel", -] -build-backend = "setuptools.build_meta" - -[project] -name = "engraphis" -version = "1.5.0" -description = "Local-first AI memory engine for agents — Ebbinghaus decay, interaction-aware recall, bi-temporal facts, hybrid retrieval, and an MCP server. You bring the LLM." -readme = "README.md" -license = "Apache-2.0" -license-files = ["LICENSE", "NOTICE"] -requires-python = ">=3.9" -authors = [{ name = "The Engraphis Authors" }] -keywords = ["ai", "agents", "memory", "mcp", "rag", "vector-search", "llm", "retrieval"] -classifiers = [ - "Development Status :: 5 - Production/Stable", - "Intended Audience :: Developers", - "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.9", - "Programming Language :: Python :: 3.10", - "Programming Language :: Python :: 3.11", - "Programming Language :: Python :: 3.12", - "Programming Language :: Python :: 3.13", - "Programming Language :: Python :: 3.14", - "Topic :: Scientific/Engineering :: Artificial Intelligence", - "Topic :: Software Development :: Libraries", -] -dependencies = [ - "numpy>=1.24", -] - -[project.optional-dependencies] -# Hosted productivity experiments only. The Codex SDK requires Python 3.10+; -# this extra never becomes a dependency of the Python 3.9-compatible core. -hosted-eval = [ - "openai-codex==0.144.4; python_version >= '3.10'", -] -# Managed Cloud Sync encrypts every bundle client-side with ChaCha20-Poly1305. Keep this -# outside the NumPy-only core so local/offline users do not acquire a crypto runtime. -cloud-sync = [ - "cryptography>=50.0.0; python_version >= '3.10'", -] -# The REST server + real embeddings (the full self-hosted stack). -server = [ - # These floors exclude known request-parsing, StaticFiles/UNC, and URL-boundary - # vulnerabilities. Their patched releases require Python 3.10, so the marker is - # mandatory: without it `pip install engraphis[server]` cannot RESOLVE AT ALL on - # 3.9 (no matching distribution), which is the very command the launcher tells a - # 3.9 user to run. Marked, the install succeeds and scripts/start_server.py - # reports the 3.10 requirement itself. The NumPy-only core stays 3.9-clean. - "fastapi>=0.133.1,<1; python_version >= '3.10'", - "starlette>=1.3.1,<2; python_version >= '3.10'", - "uvicorn[standard]>=0.29", - "httpx>=0.25", - "pydantic>=2.0", - "python-dotenv>=1.0", - # fastapi Form/File routes (dashboard import/upload) need this at app-import time; - # without it a bare [server] install crashes on startup (mcp only brings it - # transitively, which is how CI stayed green while [server] was broken). - # Marked for the same reason as fastapi/starlette: every release satisfying this - # floor requires 3.10, so an unmarked entry makes the whole extra unresolvable on 3.9. - "python-multipart>=0.0.31; python_version >= '3.10'", - "sentence-transformers>=2.7; python_version >= '3.10'", -] -# Native exact-KNN acceleration for the local SQLite vector index. Kept separate -# from ``all`` because upstream does not publish a musl wheel or source distribution, -# and ``all`` is intentionally resolvable on musl. v0.1.9 fixes incorrect DELETE -# behavior for vec0 rows with longer text primary keys. -vector = [ - "sqlite-vec>=0.1.9,<0.2", -] -# The MCP server — plug Engraphis into Claude Code, Cursor, Cline, Zed, etc. -# The upstream MCP SDK requires Python 3.10+; the numpy-only core remains Python 3.9. -mcp = [ - # 1.28.1 contains the current 1.x transport-security fixes; upstream explicitly - # recommends the <2 cap because stable v2 is a breaking line. - "mcp>=1.28.1,<2; python_version >= '3.10'", - "python-multipart>=0.0.31; python_version >= '3.10'", - "starlette>=1.3.1,<2; python_version >= '3.10'", - "pydantic-settings>=2.14.2; python_version >= '3.10'", - "cryptography>=50.0.0; python_version >= '3.10'", - "pydantic>=2.0", - "sentence-transformers>=2.7; python_version >= '3.10'", -] -# Code-symbol graph indexing. Optional: index_repo()/search_code() -# fall back to a dependency-free regex indexer without this — see backends/codegraph.py. -code = [ - "tree-sitter>=0.23; python_version >= '3.10'", - "tree-sitter-language-pack==0.9.0; python_version < '3.10'", - "tree-sitter-language-pack==1.13.5; python_version >= '3.10'", -] -# Local resource extraction. Text/code/HTML/DOCX remain stdlib-only; this adds PDF -# extraction and image OCR bindings (the Tesseract executable is installed separately). -# pytesseract requires Pillow at runtime, so it carries the same 3.10 marker as the -# Pillow floor: an unmarked floor would make a 3.9 [documents]/[all] install resolve -# pytesseract without its required Pillow, which breaks at runtime. -documents = [ - "pypdf>=4.0", - "Pillow>=12.3.0; python_version >= '3.10'", - "pytesseract>=0.3.10; python_version >= '3.10'", -] -transcription = [ - "faster-whisper>=1.0", - # onnxruntime 1.24+ dropped CPython 3.10 wheels; keep the advertised 3.10 - # install path resolvable while faster-whisper still supports it. - "onnxruntime<1.24; python_version < '3.11'", -] -postgres = [ - # psycopg is LGPL-3.0 — acceptable as an optional, user-installed, non-vendored - # extra of this Apache-2.0 project; noted here for license-audit clarity. - "psycopg[binary]>=3.1", -] -# Encryption at rest for the memory database (SQLCipher / AES-256). Opt-in: set -# ENGRAPHIS_DB_KEY (or ENGRAPHIS_DB_KEY_FILE). Without this the DB is plaintext. -encryption = [ - # Upstream publishes CPython manylinux x86-64 wheels only. musl/Alpine is not - # machine-detectable in PEP 508; those users must provision a compatible driver. - "sqlcipher3-binary>=0.5.0; platform_python_implementation == 'CPython' and platform_system == 'Linux' and platform_machine == 'x86_64'", -] -# Everything available on the current platform, for a full self-hosted install. -# Encryption is intentionally separate: ``all`` must resolve on macOS, Windows, Linux -# ARM, and musl even though sqlcipher3-binary does not publish wheels for those targets. -all = [ - "fastapi>=0.133.1,<1; python_version >= '3.10'", - "starlette>=1.3.1,<2; python_version >= '3.10'", - "uvicorn[standard]>=0.29", - "httpx>=0.25", - "pydantic>=2.0", - "python-dotenv>=1.0", - "python-multipart>=0.0.31; python_version >= '3.10'", - "sentence-transformers>=2.7; python_version >= '3.10'", - "mcp>=1.28.1,<2; python_version >= '3.10'", - "pydantic-settings>=2.14.2; python_version >= '3.10'", - "cryptography>=50.0.0; python_version >= '3.10'", - "tree-sitter>=0.23; python_version >= '3.10'", - "tree-sitter-language-pack==0.9.0; python_version < '3.10'", - "tree-sitter-language-pack==1.13.5; python_version >= '3.10'", - "pypdf>=4.0", - "Pillow>=12.3.0; python_version >= '3.10'", - "pytesseract>=0.3.10; python_version >= '3.10'", - "faster-whisper>=1.0", - "onnxruntime<1.24; python_version < '3.11'", - "psycopg[binary]>=3.1", -] -# The tmpdir-handling fix shipped only in pytest 9.0.3, whose supported line starts -# at Python 3.10. The isolated Python 3.9 CI lane installs its last compatible pytest -# separately and always supplies a private runner-owned --basetemp. -dev = [ - "pytest>=9.0.3; python_version >= '3.10'", - "pytest-asyncio>=0.23", - "ruff>=0.15.22,<0.17", - "pyright==1.1.411", -] -# Everything needed to run the general offline gate in CI (lint + all safe extras-gated -# tests) WITHOUT pulling torch/sentence-transformers — no test needs the real embedder. -# SQLCipher is deliberately excluded: loading its SQLite extension beside the stdlib sqlite -# extension makes current bundled Linux wheels unsafe in a long-running mixed test process. -# The ``encryption`` extra is exercised in its own short-lived CI job instead. -test = [ - "pytest>=9.0.3; python_version >= '3.10'", - "pytest-asyncio>=0.23", - "ruff>=0.15.22,<0.17", - "pyright==1.1.411", - "python-dotenv>=1.0", - "uvicorn[standard]>=0.29", - "fastapi>=0.133.1,<1; python_version >= '3.10'", - "starlette>=1.3.1,<2; python_version >= '3.10'", - "httpx>=0.25", - # explicit rather than transitive via mcp, so the dependency is stated where it is - # used (fastapi Form routes) instead of riding on another extra's resolution. - "python-multipart>=0.0.31; python_version >= '3.10'", - # exercises the real sqlite-vec native KNN backend (k=? + filtered widening) in CI. - # 0.1.9 fixes DELETE behavior for vec0 rows with longer text primary keys. - "sqlite-vec>=0.1.9,<0.2", - "mcp>=1.28.1,<2; python_version >= '3.10'", - "pydantic-settings>=2.14.2; python_version >= '3.10'", - "cryptography>=50.0.0; python_version >= '3.10'", - "tree-sitter>=0.23; python_version >= '3.10'", - "tree-sitter-language-pack==0.9.0; python_version < '3.10'", - "tree-sitter-language-pack==1.13.5; python_version >= '3.10'", - "pypdf>=4.0", - "Pillow>=12.3.0; python_version >= '3.10'", - "pytesseract>=0.3.10; python_version >= '3.10'", - "faster-whisper>=1.0", - "onnxruntime<1.24; python_version < '3.11'", - "psycopg[binary]>=3.1", -] - -[project.urls] -Homepage = "https://github.com/Coding-Dev-Tools/engraphis" -Repository = "https://github.com/Coding-Dev-Tools/engraphis" -Issues = "https://github.com/Coding-Dev-Tools/engraphis/issues" - -[project.scripts] -# The front door. The account portal tells customers to run `engraphis connect --token ...`, -# so the bare verb form has to exist; scripts.entry dispatches to the same main() each -# engraphis- script below calls. -engraphis = "scripts.entry:main" -engraphis-connect = "scripts.connect:main" -engraphis-server = "scripts.start_server:main" -engraphis-cli = "scripts.cli:main" -engraphis-mcp = "engraphis.mcp_cli:main" -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-consolidate = "scripts.consolidate:main" -engraphis-graph = "scripts.graph_cli:main" -engraphis-graph-server = "scripts.graph_server:main" -engraphis-init = "scripts.init:main" -engraphis-update = "scripts.update:main" - -[tool.setuptools] -include-package-data = false - -[tool.setuptools.packages.find] -include = ["engraphis*", "scripts*", "eval*"] - -[tool.setuptools.package-data] -# Keep the shipped dashboard assets explicit. A recursive catch-all also packages ignored -# runtime artifacts such as static/__pycache__/*.pyc after a local compile check. -# vendor/**/* covers nested vendor assets too (vendor/* alone doesn't cross "/"). -"engraphis.static" = ["*.html", "*.css", "*.js", "*.png", "*.ico", "vendor/*", "vendor/**/*"] -"engraphis.classic_assets" = ["*.html", "*.css", "*.js", "*.png", "*.ico", "vendor/*", "vendor/**/*"] -"engraphis.dashboard_assets" = ["*.html", "*.css", "*.js", "*.png", "*.ico", "vendor/*", "vendor/**/*"] -"engraphis" = ["commercial_manifest.json"] -"eval" = [ - "BASELINES.md", - "EVIDENCE.md", - "configs/*.json", - "datasets/*.jsonl", - "datasets/locomo10_repair_manifest.json", -] - -[tool.setuptools.exclude-package-data] -"*" = ["*.pyc", "*.pyo", "__pycache__/*"] - -[tool.ruff] -line-length = 100 -target-version = "py39" - -# Pin the rule set explicitly instead of inheriting ruff's built-in default, which is not -# stable across minor releases -- 0.16 widened it from 59 rules to 413 and turned every -# routine linter bump into a repo-wide breakage. These are exactly the 59 rules ruff -# 0.15.x enabled by default, so behaviour is unchanged; widening is now a deliberate edit. -[tool.ruff.lint] -select = ["E4", "E7", "E9", "F"] - -[tool.pyright] -include = [ - "engraphis/core", - "engraphis/backends", - "eval/harness.py", - "eval/external.py", -] -pythonVersion = "3.9" -typeCheckingMode = "basic" - -[tool.pytest.ini_options] -testpaths = ["tests"] -addopts = "-q" -markers = [ - "native_sqlitevec: tests that load the sqlite-vec native SQLite extension", - "native_sqlcipher: tests that import the SQLCipher native SQLite driver", -] -filterwarnings = [ - # Third-party (Starlette's TestClient), not our code and not fixable without a - # dependency bump — silence just this one message so the suite output stays clean. - "ignore:Using `httpx` with `starlette.testclient` is deprecated", -] - -[tool.coverage.run] -source = ["engraphis"] -omit = ["tests/*", "engraphis/classic_assets/*", "engraphis/dashboard_assets/*"] - -[tool.coverage.report] -show_missing = true -skip_covered = true +[build-system] +requires = [ + # 83.0 fixes CVE-2026-59890 in sdist exclusion handling. Keep the Python 3.9 + # core installable with the newest compatible backend; release builds run 3.11. + "setuptools>=83.0; python_version >= '3.10'", + "setuptools>=77.0; python_version < '3.10'", + "wheel", +] +build-backend = "setuptools.build_meta" + +[project] +name = "engraphis" +version = "1.5" +description = "Local-first AI memory engine for agents — Ebbinghaus decay, interaction-aware recall, bi-temporal facts, hybrid retrieval, and an MCP server. You bring the LLM." +readme = "README.md" +license = "Apache-2.0" +license-files = ["LICENSE", "NOTICE"] +requires-python = ">=3.9" +authors = [{ name = "The Engraphis Authors" }] +keywords = ["ai", "agents", "memory", "mcp", "rag", "vector-search", "llm", "retrieval"] +classifiers = [ + "Development Status :: 5 - Production/Stable", + "Intended Audience :: Developers", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", + "Topic :: Scientific/Engineering :: Artificial Intelligence", + "Topic :: Software Development :: Libraries", +] +dependencies = [ + "numpy>=1.24", +] + +[project.optional-dependencies] +# Hosted productivity experiments only. The Codex SDK requires Python 3.10+; +# this extra never becomes a dependency of the Python 3.9-compatible core. +hosted-eval = [ + "openai-codex==0.144.4; python_version >= '3.10'", +] +# Managed Cloud Sync encrypts every bundle client-side with ChaCha20-Poly1305. Keep this +# outside the NumPy-only core so local/offline users do not acquire a crypto runtime. +cloud-sync = [ + "cryptography>=50.0.0; python_version >= '3.10'", +] +# The REST server + real embeddings (the full self-hosted stack). +server = [ + # These floors exclude known request-parsing, StaticFiles/UNC, and URL-boundary + # vulnerabilities. Their patched releases require Python 3.10, so the marker is + # mandatory: without it `pip install engraphis[server]` cannot RESOLVE AT ALL on + # 3.9 (no matching distribution), which is the very command the launcher tells a + # 3.9 user to run. Marked, the install succeeds and scripts/start_server.py + # reports the 3.10 requirement itself. The NumPy-only core stays 3.9-clean. + "fastapi>=0.133.1,<1; python_version >= '3.10'", + "starlette>=1.3.1,<2; python_version >= '3.10'", + "uvicorn[standard]>=0.29", + "httpx>=0.25", + "pydantic>=2.0", + "python-dotenv>=1.0", + # fastapi Form/File routes (dashboard import/upload) need this at app-import time; + # without it a bare [server] install crashes on startup (mcp only brings it + # transitively, which is how CI stayed green while [server] was broken). + # Marked for the same reason as fastapi/starlette: every release satisfying this + # floor requires 3.10, so an unmarked entry makes the whole extra unresolvable on 3.9. + "python-multipart>=0.0.31; python_version >= '3.10'", + "sentence-transformers>=2.7; python_version >= '3.10'", +] +# Native exact-KNN acceleration for the local SQLite vector index. Kept separate +# from ``all`` because upstream does not publish a musl wheel or source distribution, +# and ``all`` is intentionally resolvable on musl. v0.1.9 fixes incorrect DELETE +# behavior for vec0 rows with longer text primary keys. +vector = [ + "sqlite-vec>=0.1.9,<0.2", +] +# The MCP server — plug Engraphis into Claude Code, Cursor, Cline, Zed, etc. +# The upstream MCP SDK requires Python 3.10+; the numpy-only core remains Python 3.9. +mcp = [ + # 1.28.1 contains the current 1.x transport-security fixes; upstream explicitly + # recommends the <2 cap because stable v2 is a breaking line. + "mcp>=1.28.1,<2; python_version >= '3.10'", + "python-multipart>=0.0.31; python_version >= '3.10'", + "starlette>=1.3.1,<2; python_version >= '3.10'", + "pydantic-settings>=2.14.2; python_version >= '3.10'", + "cryptography>=50.0.0; python_version >= '3.10'", + "pydantic>=2.0", + "sentence-transformers>=2.7; python_version >= '3.10'", +] +# Code-symbol graph indexing. Optional: index_repo()/search_code() +# fall back to a dependency-free regex indexer without this — see backends/codegraph.py. +code = [ + "tree-sitter>=0.23; python_version >= '3.10'", + "tree-sitter-language-pack==0.9.0; python_version < '3.10'", + "tree-sitter-language-pack==1.13.5; python_version >= '3.10'", +] +# Local resource extraction. Text/code/HTML/DOCX remain stdlib-only; this adds PDF +# extraction and image OCR bindings (the Tesseract executable is installed separately). +# pytesseract requires Pillow at runtime, so it carries the same 3.10 marker as the +# Pillow floor: an unmarked floor would make a 3.9 [documents]/[all] install resolve +# pytesseract without its required Pillow, which breaks at runtime. +documents = [ + "pypdf>=4.0", + "Pillow>=12.3.0; python_version >= '3.10'", + "pytesseract>=0.3.10; python_version >= '3.10'", +] +transcription = [ + "faster-whisper>=1.0", + # onnxruntime 1.24+ dropped CPython 3.10 wheels; keep the advertised 3.10 + # install path resolvable while faster-whisper still supports it. + "onnxruntime<1.24; python_version < '3.11'", +] +postgres = [ + # psycopg is LGPL-3.0 — acceptable as an optional, user-installed, non-vendored + # extra of this Apache-2.0 project; noted here for license-audit clarity. + "psycopg[binary]>=3.1", +] +# Encryption at rest for the memory database (SQLCipher / AES-256). Opt-in: set +# ENGRAPHIS_DB_KEY (or ENGRAPHIS_DB_KEY_FILE). Without this the DB is plaintext. +encryption = [ + # Upstream publishes CPython manylinux x86-64 wheels only. musl/Alpine is not + # machine-detectable in PEP 508; those users must provision a compatible driver. + "sqlcipher3-binary>=0.5.0; platform_python_implementation == 'CPython' and platform_system == 'Linux' and platform_machine == 'x86_64'", +] +# Everything available on the current platform, for a full self-hosted install. +# Encryption is intentionally separate: ``all`` must resolve on macOS, Windows, Linux +# ARM, and musl even though sqlcipher3-binary does not publish wheels for those targets. +all = [ + "fastapi>=0.133.1,<1; python_version >= '3.10'", + "starlette>=1.3.1,<2; python_version >= '3.10'", + "uvicorn[standard]>=0.29", + "httpx>=0.25", + "pydantic>=2.0", + "python-dotenv>=1.0", + "python-multipart>=0.0.31; python_version >= '3.10'", + "sentence-transformers>=2.7; python_version >= '3.10'", + "mcp>=1.28.1,<2; python_version >= '3.10'", + "pydantic-settings>=2.14.2; python_version >= '3.10'", + "cryptography>=50.0.0; python_version >= '3.10'", + "tree-sitter>=0.23; python_version >= '3.10'", + "tree-sitter-language-pack==0.9.0; python_version < '3.10'", + "tree-sitter-language-pack==1.13.5; python_version >= '3.10'", + "pypdf>=4.0", + "Pillow>=12.3.0; python_version >= '3.10'", + "pytesseract>=0.3.10; python_version >= '3.10'", + "faster-whisper>=1.0", + "onnxruntime<1.24; python_version < '3.11'", + "psycopg[binary]>=3.1", +] +# The tmpdir-handling fix shipped only in pytest 9.0.3, whose supported line starts +# at Python 3.10. The isolated Python 3.9 CI lane installs its last compatible pytest +# separately and always supplies a private runner-owned --basetemp. +dev = [ + "pytest>=9.0.3; python_version >= '3.10'", + "pytest-asyncio>=0.23", + "ruff>=0.15.22,<0.17", + "pyright==1.1.411", +] +# Everything needed to run the general offline gate in CI (lint + all safe extras-gated +# tests) WITHOUT pulling torch/sentence-transformers — no test needs the real embedder. +# SQLCipher is deliberately excluded: loading its SQLite extension beside the stdlib sqlite +# extension makes current bundled Linux wheels unsafe in a long-running mixed test process. +# The ``encryption`` extra is exercised in its own short-lived CI job instead. +test = [ + "pytest>=9.0.3; python_version >= '3.10'", + "pytest-asyncio>=0.23", + "ruff>=0.15.22,<0.17", + "pyright==1.1.411", + "python-dotenv>=1.0", + "uvicorn[standard]>=0.29", + "fastapi>=0.133.1,<1; python_version >= '3.10'", + "starlette>=1.3.1,<2; python_version >= '3.10'", + "httpx>=0.25", + # explicit rather than transitive via mcp, so the dependency is stated where it is + # used (fastapi Form routes) instead of riding on another extra's resolution. + "python-multipart>=0.0.31; python_version >= '3.10'", + # exercises the real sqlite-vec native KNN backend (k=? + filtered widening) in CI. + # 0.1.9 fixes DELETE behavior for vec0 rows with longer text primary keys. + "sqlite-vec>=0.1.9,<0.2", + "mcp>=1.28.1,<2; python_version >= '3.10'", + "pydantic-settings>=2.14.2; python_version >= '3.10'", + "cryptography>=50.0.0; python_version >= '3.10'", + "tree-sitter>=0.23; python_version >= '3.10'", + "tree-sitter-language-pack==0.9.0; python_version < '3.10'", + "tree-sitter-language-pack==1.13.5; python_version >= '3.10'", + "pypdf>=4.0", + "Pillow>=12.3.0; python_version >= '3.10'", + "pytesseract>=0.3.10; python_version >= '3.10'", + "faster-whisper>=1.0", + "onnxruntime<1.24; python_version < '3.11'", + "psycopg[binary]>=3.1", +] + +[project.urls] +Homepage = "https://github.com/Coding-Dev-Tools/engraphis" +Repository = "https://github.com/Coding-Dev-Tools/engraphis" +Issues = "https://github.com/Coding-Dev-Tools/engraphis/issues" + +[project.scripts] +# The front door. The account portal tells customers to run `engraphis connect --token ...`, +# so the bare verb form has to exist; scripts.entry dispatches to the same main() each +# engraphis- script below calls. +engraphis = "scripts.entry:main" +engraphis-connect = "scripts.connect:main" +engraphis-server = "scripts.start_server:main" +engraphis-cli = "scripts.cli:main" +engraphis-mcp = "engraphis.mcp_cli:main" +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-consolidate = "scripts.consolidate:main" +engraphis-graph = "scripts.graph_cli:main" +engraphis-graph-server = "scripts.graph_server:main" +engraphis-init = "scripts.init:main" +engraphis-update = "scripts.update:main" + +[tool.setuptools] +include-package-data = false + +[tool.setuptools.packages.find] +include = ["engraphis*", "scripts*", "eval*"] + +[tool.setuptools.package-data] +# Keep the shipped dashboard assets explicit. A recursive catch-all also packages ignored +# runtime artifacts such as static/__pycache__/*.pyc after a local compile check. +# vendor/**/* covers nested vendor assets too (vendor/* alone doesn't cross "/"). +"engraphis.static" = ["*.html", "*.css", "*.js", "*.png", "*.ico", "vendor/*", "vendor/**/*"] +"engraphis.classic_assets" = ["*.html", "*.css", "*.js", "*.png", "*.ico", "vendor/*", "vendor/**/*"] +"engraphis.dashboard_assets" = ["*.html", "*.css", "*.js", "*.png", "*.ico", "vendor/*", "vendor/**/*"] +"engraphis" = ["commercial_manifest.json"] +"eval" = [ + "BASELINES.md", + "EVIDENCE.md", + "configs/*.json", + "datasets/*.jsonl", + "datasets/locomo10_repair_manifest.json", +] + +[tool.setuptools.exclude-package-data] +"*" = ["*.pyc", "*.pyo", "__pycache__/*"] + +[tool.ruff] +line-length = 100 +target-version = "py39" + +# Pin the rule set explicitly instead of inheriting ruff's built-in default, which is not +# stable across minor releases -- 0.16 widened it from 59 rules to 413 and turned every +# routine linter bump into a repo-wide breakage. These are exactly the 59 rules ruff +# 0.15.x enabled by default, so behaviour is unchanged; widening is now a deliberate edit. +[tool.ruff.lint] +select = ["E4", "E7", "E9", "F"] + +[tool.pyright] +include = [ + "engraphis/core", + "engraphis/backends", + "eval/harness.py", + "eval/external.py", +] +pythonVersion = "3.9" +typeCheckingMode = "basic" + +[tool.pytest.ini_options] +testpaths = ["tests"] +addopts = "-q" +markers = [ + "native_sqlitevec: tests that load the sqlite-vec native SQLite extension", + "native_sqlcipher: tests that import the SQLCipher native SQLite driver", +] +filterwarnings = [ + # Third-party (Starlette's TestClient), not our code and not fixable without a + # dependency bump — silence just this one message so the suite output stays clean. + "ignore:Using `httpx` with `starlette.testclient` is deprecated", +] + +[tool.coverage.run] +source = ["engraphis"] +omit = ["tests/*", "engraphis/classic_assets/*", "engraphis/dashboard_assets/*"] + +[tool.coverage.report] +show_missing = true +skip_covered = true diff --git a/tests/test_dashboard_v2.py b/tests/test_dashboard_v2.py index 30714cd6..a7da41c8 100644 --- a/tests/test_dashboard_v2.py +++ b/tests/test_dashboard_v2.py @@ -1,1288 +1,1288 @@ -"""Unified local dashboard tests for the public open-core boundary.""" -import ast -import io -import threading -import urllib.error -from concurrent.futures import ThreadPoolExecutor -from pathlib import Path - -import pytest - -pytest.importorskip("fastapi", reason="full-stack extra not installed") -pytest.importorskip("httpx", reason="httpx not installed") - -from fastapi.testclient import TestClient # noqa: E402 -from fastapi import HTTPException # noqa: E402 - -from engraphis import cloud_features # noqa: E402 -from engraphis.config import settings # noqa: E402 -from engraphis.cloud_features import CloudFeatureError # noqa: E402 -from engraphis.core.interfaces import MemoryType, Scope # noqa: E402 -from engraphis.routes import v2_api # noqa: E402 -from engraphis.service import MemoryService, ValidationError # noqa: E402 - - -def _client(monkeypatch, tmp_path): - db_path = str(tmp_path / "dashboard.db") - monkeypatch.setattr(settings, "db_path", db_path) - monkeypatch.setattr(settings, "embed_model", "") - monkeypatch.setattr(settings, "embed_dim", 384) - monkeypatch.setattr(settings, "allowed_workspaces", []) - monkeypatch.setattr(settings, "api_token", "") - seeded = MemoryService.create(db_path) - demo_id = seeded.store.get_or_create_workspace("demo") - beta_id = seeded.store.get_or_create_workspace("beta") - seeded.engine.remember( - "Postgres 16 is the main database.", - workspace_id=demo_id, - scope=Scope.WORKSPACE, - title="Database", - ) - seeded.engine.remember( - "A second workspace must stay isolated.", - workspace_id=beta_id, - scope=Scope.WORKSPACE, - title="Isolation", - ) - seeded.store.close() - from engraphis.dashboard_app import create_app - return TestClient(create_app(), client=("127.0.0.1", 50000)) - - -def test_dashboard_serves_and_bootstraps_local_core(monkeypatch, tmp_path): - with _client(monkeypatch, tmp_path) as client: - page = client.get("/") - assert page.status_code == 200 - assert "Engraphis Ledger" in page.text - assert 'class="sidebar"' in page.text - for area in ("Today", "Ask", "Library", "Graph & Relationships", "Provenance", "Manage"): - assert f">{area}<" in page.text - assert 'value="matrix">Matrix' in page.text - assert 'class="dashboard-switcher" aria-label="Dashboard interface"' in page.text - assert 'id="sidebar-theme-select" aria-label="Dashboard theme"' in page.text - assert 'value="classic">Classic<' in page.text - assert 'href="/classic">Classic<' in page.text - assert 'Ledger (primary)' not in page.text - assert 'Classic (alternate)' not in page.text - assert '/v2-assets/vendor/d3.min.js' in page.text - assert '/v2-assets/vendor/force-graph.min.js' not in page.text - assert '/v2-assets/engraphis-graph.js' not in page.text - classic = client.get("/classic") - assert classic.status_code == 200 - assert '/classic-assets/dashboard.css' in classic.text - assert 'class="dashboard-switcher" aria-label="Dashboard interface"' in classic.text - assert 'href="/"' in classic.text - assert 'href="/classic" aria-current="page">Classic (alternate)<' in classic.text - assert 'value="classic" selected>Classic dashboard (alternate)<' in classic.text - assert 'id="graph-show-all"' not in classic.text - assert client.get("/v2-assets/ledger.css").status_code == 200 - ledger_js = client.get("/v2-assets/ledger.js") - assert ledger_js.status_code == 200 - assert "'/v2-assets/vendor/force-graph.min.js?v=20260727-final'" in ledger_js.text - assert "'/v2-assets/engraphis-graph.js?v=20260730-drag-stability'" in ledger_js.text - assert "/v2-assets/ledger.css?v=20260728-connected-memories" in page.text - assert "/v2-assets/ledger.js?v=20260728-connected-memories" in page.text - classic_js = client.get("/classic-assets/dashboard.js") - assert classic_js.status_code == 200 - assert "/static/vendor/force-graph.min.js" in classic_js.text - assert "/v2-assets/engraphis-graph.js?v=20260730-drag-stability" in classic_js.text - assert "graphLimit=GRAPH_FULL?20000:320" in classic_js.text - assert "graphScope=GRAPH_FULL?'&full=true':(showUnlinked?'':'&connected_only=true')" in classic_js.text - bootstrap = client.get("/api/bootstrap") - assert bootstrap.status_code == 200 - assert bootstrap.json()["stats"]["memories"] >= 1 - savings = client.get("/api/context-savings", params={"workspace": "demo"}) - assert savings.status_code == 200 - assert savings.json()["format"] == "engraphis-context-savings/1" - filtered = client.get( - "/api/context-savings", - params={"workspace": "demo", "from_ts": 0, "to_ts": 9_999_999_999, - "release_version": "1.5.0"}, - ) - assert filtered.status_code == 200 - assert filtered.json()["period"] == {"from_ts": 0, "to_ts": 9_999_999_999} - assert "Estimated context saved" in page.text - - -def test_dashboard_assets_revalidate_instead_of_pinning_old_visuals(monkeypatch, tmp_path): - with _client(monkeypatch, tmp_path) as client: - for path in ( - "/v2-assets/engraphis-graph.js?v=20260730-drag-stability", - "/v2-assets/ledger.js?v=20260728-connected-memories", - "/v2-assets/ledger.css?v=20260728-connected-memories", - "/classic-assets/dashboard.js?v=20260728-reference-materials", - ): - response = client.get(path) - assert response.status_code == 200 - assert response.headers["cache-control"] == "no-cache, must-revalidate" - - -def test_classic_dashboard_script_mirrors_the_static_compatibility_asset(): - root = Path(__file__).parents[1] / "engraphis" - assert (root / "classic_assets" / "dashboard.js").read_bytes() == ( - root / "static" / "dashboard.js" - ).read_bytes() - - -def test_dashboard_and_mcp_recall_share_the_v2_service(monkeypatch, tmp_path): - pytest.importorskip("mcp", reason="MCP extra not installed") - import json - - from engraphis import mcp_server - - with _client(monkeypatch, tmp_path) as client: - assert mcp_server.service() is client.app.state.service - response = client.get( - "/api/recall", - params={"q": "which database do we use", "workspace": "demo", "k": 3}, - ) - assert response.status_code == 200 - dashboard = response.json() - mcp = json.loads(mcp_server.engraphis_recall( - query="which database do we use", workspace="demo", k=3, - )) - assert [memory["id"] for memory in dashboard["memories"]] == [ - memory["id"] for memory in mcp["memories"] - ] - assert [memory["retention"] for memory in dashboard["memories"]] == [ - memory["retention"] for memory in mcp["memories"] - ] - assert [memory["relative_score"] for memory in dashboard["memories"]] == [ - memory["relative_score"] for memory in mcp["memories"] - ] - assert [memory["absolute_support"] for memory in dashboard["memories"]] == [ - memory["absolute_support"] for memory in mcp["memories"] - ] - assert dashboard["score_semantics"] == mcp["score_semantics"] - - -def test_dashboard_keyword_fallback_reports_truthful_lexical_scores(monkeypatch, tmp_path): - with _client(monkeypatch, tmp_path) as client: - def mismatched_embedder(*_args, **_kwargs): - raise ValueError("shapes (1,256) and (384,1) not aligned") - - monkeypatch.setattr(client.app.state.service, "recall", mismatched_embedder) - response = client.get( - "/api/recall", - params={ - "q": "which database do we use", - "workspace": "demo", - "k": 3, - "response_mode": "compact", - }, - ) - - assert response.status_code == 200 - payload = response.json() - assert payload["mode"] == "keyword" - assert "lexical Jaccard" in payload["score_semantics"]["relative_score"] - assert "Semantic support is unavailable" in ( - payload["score_semantics"]["absolute_support"] - ) - memory = payload["memories"][0] - assert memory["score"] == memory["relative_score"] == 1.0 - assert 0.0 < memory["absolute_support"] < 1.0 - assert memory["arm"] == "lexical" - assert "content" not in memory - - -def test_dashboard_keyword_fallback_applies_requested_memory_type_limits( - monkeypatch, tmp_path -): - with _client(monkeypatch, tmp_path) as client: - workspace_id = client.app.state.service.store.get_or_create_workspace("demo") - client.app.state.service.engine.remember( - "Database upgrade procedure requires a verified backup.", - workspace_id=workspace_id, - scope=Scope.WORKSPACE, - mtype=MemoryType.PROCEDURAL, - title="Database procedure", - ) - - def mismatched_embedder(*_args, **_kwargs): - raise ValueError("shapes (1,256) and (384,1) not aligned") - - monkeypatch.setattr(client.app.state.service, "recall", mismatched_embedder) - response = client.get( - "/api/recall", - params={ - "q": "database", - "workspace": "demo", - "k": 3, - "mtype_limits": '{"semantic":0,"procedural":1}', - }, - ) - - assert response.status_code == 200 - payload = response.json() - assert payload["mtype_limits"] == {"semantic": 0, "procedural": 1} - assert [memory["memory_type"] for memory in payload["memories"]] == [ - "procedural" - ] - - -@pytest.mark.parametrize("invalid_limit", [True, "2"]) -def test_dashboard_post_recall_surfaces_reject_coerced_memory_type_limits( - monkeypatch, tmp_path, invalid_limit -): - with _client(monkeypatch, tmp_path) as client: - response = client.post( - "/api/intent/recall", - json={"query": "database", "mtype_limits": {"semantic": invalid_limit}}, - ) - - assert response.status_code == 422 - - -def test_dashboard_serves_the_graph_engine_from_its_v2_asset_surface(monkeypatch, tmp_path): - with _client(monkeypatch, tmp_path) as client: - asset = client.get("/v2-assets/engraphis-graph.js") - assert asset.status_code == 200 - assert "window.EngraphisGraph =" in asset.text - compat = client.get("/v2-assets/engraphis-graph-compat.js") - assert compat.status_code == 200 - assert "window.EngraphisGraphCompat =" in compat.text - assert client.get("/v2-assets/vendor/d3.min.js").status_code == 200 - assert client.get("/v2-assets/vendor/force-graph.min.js").status_code == 200 - - -def test_graph_load_is_bounded_single_flight_and_retryable(monkeypatch, tmp_path): - with _client(monkeypatch, tmp_path) as client: - page = client.get("/") - script = client.get("/v2-assets/ledger.js") - assert 'id="graph-retry"' in page.text - assert 'id="graph-full"' not in page.text - assert '>Show all nodes<' not in page.text - assert 'id="graph-show-unlinked"' in page.text - assert 'id="graph-unlinked"' not in page.text - assert 'id="graph-tune-unlinked"' not in page.text - assert 'id="graph-style" type="hidden" value="cyber"' in page.text - assert "const GRAPH_INITIAL_NODE_LIMIT = 320;" in script.text - assert "const GRAPH_FULL_NODE_LIMIT = 20_000;" in script.text - assert "const GRAPH_LOAD_TIMEOUT_MS = 12_000;" in script.text - assert "AbortController" in script.text - assert "state.graphLoadPromise" in script.text - assert "&full=true" in script.text - assert "&connected_only=true" in script.text - assert "style: 'cyber'" in script.text - assert "renderMode: targetMode" in script.text - assert "loadGraph({ force: true })" in script.text - - -def test_graph_motion_saved_views_and_tuning_controls_are_wired(monkeypatch, tmp_path): - with _client(monkeypatch, tmp_path) as client: - page = client.get("/") - script = client.get("/v2-assets/ledger.js") - for control in ( - 'id="graph-flow-speed"', 'data-graph-saved-view="operations"', - 'data-graph-saved-view="schema"', 'data-graph-saved-view="people"', - 'data-graph-saved-view="code"', 'id="graph-save-view"', - 'id="graph-repel"', 'id="graph-depth"', 'id="graph-reset-tuning"', - 'data-graph-layer="code"', - ): - assert control in page.text - for behavior in ( - "function applyGraphView(id)", "function resetGraphTuning()", - "function saveCurrentGraphView()", "function graphTuningSettings()", - "&include_code=true", "graph.setLayers(graphLayerState())", - "setSettings({ flowSpeed: speed })", - ): - assert behavior in script.text - - -def test_graph_palette_recolors_every_colour_mode(monkeypatch, tmp_path): - with _client(monkeypatch, tmp_path) as client: - engine = client.get("/v2-assets/engraphis-graph.js") - ledger = client.get("/v2-assets/ledger.js") - assert engine.status_code == 200 - assert "function selectedPalette()" in engine.text - assert "function commPal() {" in engine.text - assert "return selectedPalette() ||" in engine.text - assert "const colors = selectedPalette() || GRAPH_HEAT;" in engine.text - # Palettes still recolor every identity mode, but material families stay stable: - # semantic color belongs to the slim identity ring rather than rotating the whole - # Cyber film into arbitrary green/yellow alloys. - assert "function iridescentTint(c)" not in engine.text - assert "fixedPalette" in engine.text - assert "function identityRing(" in engine.text - assert "identity: rgbString(identity)" in engine.text - assert "function graphThemeColors()" in ledger.text - assert "graph.setThemeColors(graphThemeColors());" in ledger.text - assert "state.graphEngine.setThemeColors(graphThemeColors());" in ledger.text - assert "renderMode: opts.renderMode === 'full' ? 'full' : 'overview'" in engine.text - assert "function pinFullGraphLayout(data)" in engine.text - - -def test_graph_facts_and_search_use_the_atomic_node_reveal(monkeypatch, tmp_path): - with _client(monkeypatch, tmp_path) as client: - page = client.get("/") - ledger = client.get("/v2-assets/ledger.js") - engine = client.get("/v2-assets/engraphis-graph.js") - assert 'id="graph-connections-dialog"' in page.text - assert "function revealGraphNode(id, label = 'Selected entity')" in ledger.text - assert "revealGraphNode(item.id, item.name)" in ledger.text - assert "function openGraphConnections(item)" in ledger.text - assert "function showGraphConnectionMemories(item)" in ledger.text - assert "onNodeClick: item => openGraphConnections(item)" in ledger.text - assert "api.reveal = id =>" in engine.text - assert "function centerRenderedNode(id)" in engine.text - assert "suppressNodeClickAfterDrag" in engine.text - assert "render(true, true);" not in engine.text[engine.text.index("api.focus = id =>"):engine.text.index("api.clearFocus")] - - -def test_library_editor_stacks_directly_below_the_selected_memory_panel(monkeypatch, tmp_path): - with _client(monkeypatch, tmp_path) as client: - page = client.get("/") - assert page.status_code == 200 - assert '
' in page.text - assert page.text.index('id="memory-detail"') < page.text.index('id="memory-editor"') - stylesheet = client.get("/v2-assets/ledger.css") - assert ".library-detail-stack { display: grid; gap: 12px; align-content: start; }" in stylesheet.text - - -def test_workspace_switcher_uses_the_active_ledger_theme_for_native_dropdowns(monkeypatch, tmp_path): - with _client(monkeypatch, tmp_path) as client: - stylesheet = client.get("/v2-assets/ledger.css") - assert stylesheet.status_code == 200 - css = stylesheet.text - assert ".workspace-switcher select {" in css - assert "background: var(--c-inset);" in css - assert "color-scheme: dark;" in css - assert 'body[data-theme="paper"] .workspace-switcher select { color-scheme: light; }' in css - assert ".workspace-switcher select option { background: var(--c-inset); color: var(--c-fg); }" in css - assert ".workspace-switcher select option:checked { background: var(--c-acc); color: var(--c-bg); }" in css - - -def test_sidebar_keeps_manage_and_compare_plans_in_separate_flex_rows(monkeypatch, tmp_path): - with _client(monkeypatch, tmp_path) as client: - stylesheet = client.get("/v2-assets/ledger.css") - assert stylesheet.status_code == 200 - css = stylesheet.text - sidebar = css[css.index(".sidebar {"):css.index(".brand-row {")] - assert "display: flex;" in sidebar - assert "flex-direction: column;" in sidebar - assert "grid-template-rows" not in sidebar - assert ".primary-nav { flex: 1 0 auto; }" in css - assert ".manage-nav { flex: 0 0 auto; }" in css - assert ".sidebar-promo {\n flex: 0 0 auto;" in css - - -def test_dashboard_grounded_answer_route_cites_or_abstains(monkeypatch, tmp_path): - with _client(monkeypatch, tmp_path) as client: - grounded = client.post( - "/api/answer", - json={ - "query": "Which database is the main database?", - "workspace": "demo", - "k": 8, - "max_citations": 5, - "candidate_depth": "adaptive", - }, - ) - assert grounded.status_code == 200 - body = grounded.json() - assert body["query"] == "Which database is the main database?" - assert body["grounded"] is True - assert body["abstained"] is False - assert body["citations"] - assert body["sources"] == body["citations"] - assert "[1]" in body["answer"] - assert body["candidate_depth"] == "adaptive" - # ``candidate_k_used`` is the final page depth after prompt-safe - # overfetch/widening, rather than the adaptive policy's starting depth. - assert body["candidate_k_used"] >= body["candidate_k_requested"] - - abstained = client.post( - "/api/answer", - json={ - "query": "How should I bake a sourdough loaf?", - "workspace": "demo", - }, - ) - assert abstained.status_code == 200 - assert abstained.json()["grounded"] is False - assert abstained.json()["abstained"] is True - - -def test_dashboard_grounded_answer_route_bounds_and_redacts(monkeypatch, tmp_path): - with _client(monkeypatch, tmp_path) as client: - assert client.post("/api/answer", json={"query": "", "workspace": "demo"}).status_code == 422 - assert client.post( - "/api/answer", - json={"query": "database", "workspace": "demo", "k": 51}, - ).status_code == 422 - - -def test_team_account_routes_are_not_in_public_runtime(monkeypatch, tmp_path): - with _client(monkeypatch, tmp_path) as client: - assert client.post("/api/auth/setup", json={}).status_code == 404 - assert client.get("/api/auth/users").status_code == 404 - state = client.get("/api/auth/state").json() - assert state["enabled"] is False - assert state["hosted_team"] is True - - -def test_local_agent_write_has_no_client_side_team_paywall(monkeypatch, tmp_path): - with _client(monkeypatch, tmp_path) as client: - response = client.post( - "/api/remember", - json={"workspace": "demo", "content": "Queues use at-least-once delivery."}, - ) - assert response.status_code == 200 - - -def test_http_memory_api_exposes_world_timed_agent_writes_immediately(monkeypatch, tmp_path): - with _client(monkeypatch, tmp_path) as client: - old = client.post( - "/api/remember", - json={ - "workspace": "demo", - "content": "The API rate limit is 100 requests per minute.", - "valid_from": 1_000.0, - "subject_key": "api.rate_limit", - "claim_kind": "configured_value", - }, - ).json() - new = client.post( - "/api/intent/remember", - json={ - "workspace": "demo", - "text": "The API rate limit is 500 requests per minute.", - "valid_from": 2_000.0, - "subject_key": "api.rate_limit", - "claim_kind": "configured_value", - }, - ).json() - - before = client.get( - "/api/recall", - params={ - "workspace": "demo", - "q": "What is the API rate limit?", - "as_of": 1_500.0, - }, - ) - after = client.post( - "/api/answer", - json={ - "workspace": "demo", - "query": "What is the API rate limit?", - "as_of": 2_500.0, - "min_support": 0.0, - }, - ) - - assert before.status_code == 200 - assert [memory["id"] for memory in before.json()["memories"]] == [old["id"]] - assert after.status_code == 200 - assert after.json()["sources"] - service = client.app.state.service - assert service.store.get_memory(old["id"]).valid_from == 1_000.0 - assert service.store.get_memory(new["id"]).valid_from == 2_000.0 - assert service.store.get_memory(old["id"]).provenance["review_state"] == "approved" - assert service.store.get_memory(new["id"]).provenance["review_state"] == "approved" - - -def test_keyword_recall_fallback_keeps_bitemporal_visibility(monkeypatch, tmp_path): - """A semantic-backend failure must not leak current facts into historical views.""" - with _client(monkeypatch, tmp_path) as client: - svc = v2_api.service() - workspace_id = svc.store.get_or_create_workspace("demo") - old = {"id": svc.engine.remember( - "The fallback retention setting was ten days.", workspace_id=workspace_id, - scope=Scope.WORKSPACE, valid_from=1_000.0, resolve_conflicts=False, - )} - new = {"id": svc.engine.remember( - "The fallback retention setting was thirty days.", workspace_id=workspace_id, - scope=Scope.WORKSPACE, valid_from=2_000.0, resolve_conflicts=False, - )} - # The writes happened during this test, but the fixture models facts learned - # before the requested historical system-time anchors. - svc.store.conn.execute( - "UPDATE memories SET ingested_at=100 WHERE id=?", (old["id"],) - ) - svc.store.conn.execute( - "UPDATE memories SET ingested_at=200 WHERE id=?", (new["id"],) - ) - svc.store.conn.execute( - "UPDATE memories SET valid_to=2000, valid_to_recorded_at=200, " - "subject_key='retention.days', claim_kind='configured_value' " - "WHERE id=?", - (old["id"],), - ) - svc.store.conn.commit() - old_before = v2_api._keyword_search( - "demo", "fallback retention", valid_at=1_500.0, known_at=3_000.0 - ) - old_known = v2_api._keyword_search( - "demo", "fallback retention", valid_at=1_500.0, known_at=50.0 - ) - current = v2_api._keyword_search( - "demo", "fallback retention", valid_at=2_500.0, known_at=3_000.0 - ) - closure_unknown = v2_api._keyword_search( - "demo", "fallback retention", valid_at=2_500.0, known_at=150.0 - ) - - assert [memory["id"] for memory in old_before] == [old["id"]] - assert old_known == [] - assert [memory["id"] for memory in current] == [new["id"]] - assert [memory["id"] for memory in closure_unknown] == [old["id"]] - assert closure_unknown[0]["valid_to_recorded_at"] == 200.0 - assert closure_unknown[0]["subject_key"] == "retention.days" - assert closure_unknown[0]["claim_kind"] == "configured_value" - - def incompatible_embedder(*_args, **_kwargs): - raise ValueError("shapes (256,) and (384,) not aligned") - - monkeypatch.setattr(svc, "recall", incompatible_embedder) - fallback = client.get( - "/api/recall", - params={ - "workspace": "demo", "q": "fallback retention", - "valid_at": 2_500.0, "known_at": 150.0, - }, - ) - assert fallback.status_code == 200 - assert fallback.json()["mode"] == "keyword" - assert [item["id"] for item in fallback.json()["memories"]] == [old["id"]] - - compact_fallback = client.get( - "/api/recall", - params={ - "workspace": "demo", "q": "fallback retention", "response_mode": "compact", - "token_budget": 0, - }, - ) - payload = compact_fallback.json() - assert compact_fallback.status_code == 200 - assert payload["mode"] == "keyword" - assert payload["response_mode"] == "compact" - assert payload["usage"]["budget_tokens"] == 0 - assert payload["usage"]["context_tokens"] == 0 - assert payload["memories"] and "content" not in payload["memories"][0] - - -def test_keyword_recall_fallback_excludes_untrusted_memories(monkeypatch, tmp_path): - """A degraded HTTP recall must enforce the same prompt eligibility boundary.""" - with _client(monkeypatch, tmp_path) as client: - svc = v2_api.service() - workspace_id = svc.store.get_or_create_workspace("demo") - trusted = {"id": svc.engine.remember( - "Fallback visibility trusted candidate.", - workspace_id=workspace_id, scope=Scope.WORKSPACE, - )} - untrusted = svc.remember( - "Fallback visibility untrusted candidate.", - workspace="demo", - source="sync", - trusted=False, - ) - - def incompatible_embedder(*_args, **_kwargs): - raise ValueError("shapes (256,) and (384,) not aligned") - - monkeypatch.setattr(svc, "recall", incompatible_embedder) - response = client.get( - "/api/recall", - params={"workspace": "demo", "q": "fallback visibility candidate", "k": 1}, - ) - - payload = response.json() - assert response.status_code == 200 - assert payload["mode"] == "keyword" - assert [memory["id"] for memory in payload["memories"]] == [trusted["id"]] - assert untrusted["id"] not in {memory["id"] for memory in payload["memories"]} - assert "untrusted candidate" not in repr(payload) - - -def test_http_memory_api_rejects_backdated_agent_claim_supersession( - monkeypatch, tmp_path -): - with _client(monkeypatch, tmp_path) as client: - original = client.post( - "/api/remember", - json={ - "workspace": "demo", - "content": "The deployment window is Friday afternoon.", - "valid_from": 2_000.0, - }, - ).json() - service = v2_api.service() - count_before = len(service.store.list_memories(include_invalid=True)) - rejected = client.post( - "/api/remember", - json={ - "workspace": "demo", - "content": "The deployment window is Thursday afternoon.", - "valid_from": 1_000.0, - }, - ) - - assert rejected.status_code == 400 - assert service.store.get_memory(original["id"]).valid_to is None - assert len(service.store.list_memories(include_invalid=True)) == count_before - - -def test_manual_consolidation_stays_local_but_dreaming_is_cloud_only( - monkeypatch, tmp_path -): - with _client(monkeypatch, tmp_path) as client: - manual = client.post( - "/api/consolidate", - json={"workspace": "demo", "dry_run": True, "infer": False}, - ) - assert manual.status_code == 200 - dream = client.post( - "/api/consolidate", - json={"workspace": "demo", "dry_run": True, "infer": True}, - ) - assert dream.status_code == 501 - assert dream.json()["detail"]["cloud_only"] is True - - -def test_analytics_route_delegates_to_managed_compute(monkeypatch, tmp_path): - monkeypatch.setattr( - "engraphis.cloud_features.run_managed_job", - lambda service, workspace, kind: { - "result": { - "kind": kind, - "generation": 4, - "totals": {"live": 1}, - } - }, - ) - with _client(monkeypatch, tmp_path) as client: - response = client.get("/api/analytics?workspace=demo") - assert response.status_code == 200 - assert response.json()["kind"] == "analytics" - assert response.json()["generation"] == 4 - - -def test_unconnected_automation_returns_a_structured_auth_error(monkeypatch, tmp_path): - for name in ( - "ENGRAPHIS_CLOUD_ACCESS_TOKEN", - "ENGRAPHIS_CLOUD_ORGANIZATION_ID", - "ENGRAPHIS_CLOUD_COMPUTE_URL", - "ENGRAPHIS_CLOUD_REFRESH_CREDENTIAL", - "ENGRAPHIS_CLOUD_CONTROL_URL", - ): - monkeypatch.delenv(name, raising=False) - monkeypatch.setenv("ENGRAPHIS_STATE_DIR", str(tmp_path / "unconnected-state")) - - with _client(monkeypatch, tmp_path) as client: - response = client.get("/api/automation?workspace=demo") - - assert response.status_code == 401 - # The copy is ``_public_session_error(401)``: fixed, status-keyed, and actionable. The - # generic placeholder told an unconnected customer nothing they could act on. - assert response.json()["detail"] == { - "error": "Connect this installation to Engraphis Cloud to use hosted features.", - "managed_cloud": True, - "transient": False, - "code": "cloud_unconfigured", - } - - -def test_hosted_automation_accepts_the_cloud_policy_field(monkeypatch, tmp_path): - saved = {} - - class _Cloud: - def upload_snapshot(self, workspace_id, snapshot): - return {"generation": snapshot["generation"]} - - def get_policy(self, workspace_id): - return {"enabled": False, "cadence_minutes": 1440, "dream_enabled": False} - - def save_policy(self, workspace_id, policy): - saved.update(policy) - return {"version": 2} - - monkeypatch.setattr( - "engraphis.cloud_features.build_managed_snapshot", - lambda service, workspace: ("ws_cloud", {"generation": 1}), - ) - monkeypatch.setattr( - "engraphis.cloud_features.CloudFeatureClient.from_environment", - lambda workspace_id=None: _Cloud(), - ) - with _client(monkeypatch, tmp_path) as client: - response = client.post( - "/api/automation", - json={"enabled": True, "dream_enabled": True, "cadence_hours": 12}, - ) - assert response.status_code == 200 - assert response.json()["dream_enabled"] is True - assert saved["dream_enabled"] is True - - -def test_first_hosted_automation_view_bootstraps_the_recommended_policy( - monkeypatch, tmp_path -): - """A connected Pro/Team workspace starts maintaining itself without a toggle.""" - - uploaded = [] - saved = [] - - class _Cloud: - organization_id = "org_test" - - def get_policy(self, workspace_id): - # Version zero is the private Cloud's documented no-policy sentinel. - return {"enabled": False, "cadence_minutes": 1440, "version": 0} - - def upload_snapshot(self, workspace_id, snapshot): - uploaded.append((workspace_id, snapshot)) - return {"generation": snapshot["generation"]} - - def save_policy(self, workspace_id, policy): - saved.append((workspace_id, policy)) - return {"version": 1} - - def list_jobs(self, workspace_id, *, limit=10): - return {"jobs": []} - - monkeypatch.setattr( - "engraphis.cloud_features.build_managed_snapshot", - lambda service, workspace: ("ws_cloud", {"generation": 7}), - ) - monkeypatch.setattr( - "engraphis.cloud_features.CloudFeatureClient.from_environment", - lambda workspace_id=None: _Cloud(), - ) - with _client(monkeypatch, tmp_path) as client: - response = client.get("/api/automation") - - assert response.status_code == 200 - assert response.json()["enabled"] is True - assert response.json()["dream"] is True - assert uploaded == [("ws_cloud", {"generation": 7})] - assert saved == [("ws_cloud", { - "enabled": True, - "cadence_minutes": 1440, - "dream_enabled": True, - "dream_min_new": 25, - "dream_idle_minutes": 15, - "infer": False, - })] - - -def test_first_automation_policy_retry_does_not_upload_the_snapshot_twice( - monkeypatch, tmp_path -): - """A failed policy write resumes after the already successful private upload.""" - - from engraphis.cloud_features import CloudFeatureError - - uploaded = [] - saved = [] - builds = [] - - class _Cloud: - organization_id = "org_test" - - def get_policy(self, workspace_id): - return {"enabled": False, "cadence_minutes": 1440, "version": 0} - - def upload_snapshot(self, workspace_id, snapshot): - uploaded.append((workspace_id, snapshot)) - return {"generation": snapshot["generation"]} - - def save_policy(self, workspace_id, policy): - saved.append((workspace_id, policy)) - if len(saved) == 1: - raise CloudFeatureError( - "Engraphis Cloud is temporarily unavailable.", - status=503, - transient=True, - ) - return {"version": 1} - - def list_jobs(self, workspace_id, *, limit=10): - return {"jobs": []} - - def _snapshot(service, workspace): - builds.append(workspace) - return "ws_cloud", {"generation": 7} - - monkeypatch.setattr("engraphis.cloud_features.build_managed_snapshot", _snapshot) - monkeypatch.setattr( - "engraphis.cloud_features.CloudFeatureClient.from_environment", - lambda workspace_id=None: _Cloud(), - ) - with _client(monkeypatch, tmp_path) as client: - first = client.get("/api/automation") - second = client.get("/api/automation") - - assert first.status_code == 503 - assert second.status_code == 200 - assert len(builds) == 1 - assert uploaded == [("ws_cloud", {"generation": 7})] - assert len(saved) == 2 - - -def test_concurrent_first_automation_views_upload_one_snapshot(monkeypatch, tmp_path): - """Parallel dashboard reads serialize the sensitive first-bootstrap upload.""" - - uploaded = [] - saved = [] - started = threading.Event() - release_upload = threading.Event() - - class _Cloud: - organization_id = "org_concurrent" - - def get_policy(self, workspace_id): - return {"enabled": False, "cadence_minutes": 1440, "version": 0} - - def upload_snapshot(self, workspace_id, snapshot): - uploaded.append((workspace_id, snapshot)) - started.set() - assert release_upload.wait(timeout=5) - return {"generation": snapshot["generation"]} - - def save_policy(self, workspace_id, policy): - saved.append((workspace_id, policy)) - return {"version": 1} - - def list_jobs(self, workspace_id, *, limit=10): - return {"jobs": []} - - monkeypatch.setattr( - "engraphis.cloud_features.build_managed_snapshot", - lambda service, workspace: ("ws_cloud", {"generation": 7}), - ) - monkeypatch.setattr( - "engraphis.cloud_features.CloudFeatureClient.from_environment", - lambda workspace_id=None: _Cloud(), - ) - with _client(monkeypatch, tmp_path): - with ThreadPoolExecutor(max_workers=2) as pool: - first = pool.submit(v2_api.automation_get) - assert started.wait(timeout=5) - second = pool.submit(v2_api.automation_get) - release_upload.set() - assert first.result(timeout=5)["enabled"] is True - follower = second.result(timeout=5) - assert follower["enabled"] is True - assert follower["version"] == 1 - - assert uploaded == [("ws_cloud", {"generation": 7})] - assert len(saved) == 1 - - -def test_reading_or_disabling_automation_never_uploads_memory_content( - monkeypatch, tmp_path -): - saved = {} - - class _Cloud: - def get_policy(self, workspace_id): - return {"enabled": True, "cadence_minutes": 60, "dream_enabled": True} - - def list_jobs(self, workspace_id, *, limit=10): - return {"jobs": []} - - def save_policy(self, workspace_id, policy): - saved.update(policy) - return {"version": 3} - - def _unexpected_upload(*args, **kwargs): - raise AssertionError("policy inspection must not build or upload a snapshot") - - monkeypatch.setattr( - "engraphis.cloud_features.build_managed_snapshot", - _unexpected_upload, - ) - monkeypatch.setattr( - "engraphis.cloud_features.CloudFeatureClient.from_environment", - lambda workspace_id=None: _Cloud(), - ) - with _client(monkeypatch, tmp_path) as client: - assert client.get("/api/automation").status_code == 200 - response = client.post("/api/automation", json={"enabled": False}) - assert response.status_code == 200 - assert saved["enabled"] is False - - -def test_automation_and_maintenance_use_the_selected_workspace(monkeypatch, tmp_path): - policy_workspaces = [] - snapshot_workspaces = [] - maintenance_workspaces = [] - - class _Cloud: - def get_policy(self, workspace_id): - policy_workspaces.append(workspace_id) - return {"enabled": False, "cadence_minutes": 60, "dream_enabled": True} - - def list_jobs(self, workspace_id, *, limit=10): - policy_workspaces.append(workspace_id) - return {"jobs": []} - - def upload_snapshot(self, workspace_id, snapshot): - snapshot_workspaces.append(workspace_id) - return {"generation": snapshot["generation"]} - - def save_policy(self, workspace_id, policy): - policy_workspaces.append(workspace_id) - return {"version": 1} - - def snapshot(service, workspace): - snapshot_workspaces.append(workspace) - return service._lookup_workspace(workspace), {"generation": 1} - - def managed_job(service, workspace, kind): - maintenance_workspaces.append((workspace, kind)) - return {"result": {"kind": kind}} - - monkeypatch.setattr("engraphis.cloud_features.build_managed_snapshot", snapshot) - monkeypatch.setattr("engraphis.cloud_features.run_managed_job", managed_job) - monkeypatch.setattr( - "engraphis.cloud_features.CloudFeatureClient.from_environment", - lambda workspace_id=None: _Cloud(), - ) - with _client(monkeypatch, tmp_path) as client: - beta_id = client.app.state.service._lookup_workspace("beta") - demo_id = client.app.state.service._lookup_workspace("demo") - assert client.get("/api/automation?workspace=beta").status_code == 200 - assert client.post( - "/api/automation?workspace=beta", json={"enabled": True} - ).status_code == 200 - assert client.post( - "/api/maintenance/run?workspace=beta", json={"dry_run": True} - ).status_code == 200 - - assert beta_id in policy_workspaces - assert demo_id not in policy_workspaces - assert "beta" in snapshot_workspaces - assert maintenance_workspaces == [("beta", "consolidate")] - - -def test_automation_workspace_query_unknown_is_not_replaced_by_legacy_default( - monkeypatch, tmp_path -): - with _client(monkeypatch, tmp_path) as client: - for method, path, payload in ( - (client.get, "/api/automation?workspace=missing", None), - (client.post, "/api/automation?workspace=missing", {"enabled": False}), - (client.post, "/api/maintenance/run?workspace=missing", {"dry_run": True}), - ): - response = method(path, json=payload) if payload is not None else method(path) - assert response.status_code == 404 - - -def test_dashboard_automation_uses_active_workspace_and_discloses_upload_boundary(): - source = Path(__file__).parents[1] / "engraphis" / "static" / "dashboard.js" - source = source.read_text(encoding="utf-8") - assert "/automation?workspace=" in source - assert "/maintenance/run?workspace=" in source - assert "Preview snapshot" not in source - assert "uploads the selected workspace’s normal and sensitive memory content" in source - # The upload boundary is still disclosed, but consent now travels with the cloud - # account: the dashboard must not name the operator override anywhere. - assert "ENGRAPHIS_MANAGED_COMPUTE_CONSENT" not in source - assert "Hosted work is automatic with Pro." in source - - -def test_portfolio_and_report_analytics_are_hosted_only(monkeypatch, tmp_path): - with _client(monkeypatch, tmp_path) as client: - assert client.get("/api/analytics/portfolio").status_code == 501 - assert client.get("/api/analytics/export?workspace=demo").status_code == 501 - - -def test_raw_owner_export_is_free_and_signed_export_is_honestly_unimplemented( - monkeypatch, tmp_path -): - """The signed variant must not claim to exist somewhere else. - - It previously answered ``cloud_only: True`` — but Engraphis Cloud has no export route, - no supported hosted export capability, so that pointed a customer at a - product that does not exist. The 501 now says the capability is unimplemented and names - the working unsigned export instead. - """ - - with _client(monkeypatch, tmp_path) as client: - raw = client.get("/api/export?workspace=demo") - assert raw.status_code == 200 - assert raw.json()["counts"]["memories"] >= 1 - signed = client.get("/api/export?workspace=demo&signed=true") - assert signed.status_code == 501 - detail = signed.json()["detail"] - assert detail["implemented"] is False - assert detail["alternative"] == "/export" - assert "cloud_only" not in detail - assert "Engraphis Cloud" not in detail["error"] - - -def test_health_and_readiness_remain_public(monkeypatch, tmp_path): - with _client(monkeypatch, tmp_path) as client: - assert client.get("/api/health").status_code == 200 - assert client.get("/api/ready").status_code == 200 - - -def test_dashboard_exception_responses_do_not_echo_untrusted_exception_text(): - secret = "https://provider.example/?api_key=do-not-return-this" - - def fail_with(exc): - raise exc - - with pytest.raises(HTTPException) as internal: - v2_api._run(fail_with, RuntimeError(secret)) - assert internal.value.status_code == 500 - assert internal.value.detail == {"error": "internal server error"} - assert secret not in repr(internal.value.detail) - - with pytest.raises(HTTPException) as validation: - v2_api._run(fail_with, ValidationError(secret)) - assert validation.value.status_code == 400 - assert validation.value.detail == {"error": "invalid request"} - assert secret not in repr(validation.value.detail) - - with pytest.raises(HTTPException) as downstream: - v2_api._run(fail_with, HTTPException(status_code=418, detail={"error": secret})) - assert downstream.value.status_code == 418 - assert downstream.value.detail == {"error": "request rejected"} - assert secret not in repr(downstream.value.detail) - - with pytest.raises(HTTPException) as invalid_status: - v2_api._run(fail_with, HTTPException(status_code=999, detail={"error": secret})) - assert invalid_status.value.status_code == 500 - assert invalid_status.value.detail == {"error": "internal server error"} - assert secret not in repr(invalid_status.value.detail) - - with pytest.raises(HTTPException) as mismatch: - v2_api._run(fail_with, ValueError(f"{secret}: shapes 256 and 384 are not aligned")) - assert mismatch.value.status_code == 409 - assert mismatch.value.detail["embedder"] is True - assert secret not in repr(mismatch.value.detail) - - with pytest.raises(HTTPException) as ordinary_value_error: - v2_api._run(fail_with, ValueError(secret)) - assert ordinary_value_error.value.status_code == 400 - assert ordinary_value_error.value.detail == {"error": "invalid request"} - assert secret not in repr(ordinary_value_error.value.detail) - - -def test_dashboard_engine_value_error_is_a_sanitized_client_error(monkeypatch, tmp_path): - secret = "malformed document details must stay private" - with _client(monkeypatch, tmp_path) as client: - def reject_document(*_args, **_kwargs): - raise ValueError(secret) - - monkeypatch.setattr(client.app.state.service, "remember", reject_document) - response = client.post( - "/api/remember", - json={"content": "client document", "workspace": "demo"}, - ) - - assert response.status_code == 400 - assert response.json() == {"detail": {"error": "invalid request"}} - assert secret not in response.text - - -def test_managed_cloud_errors_forward_only_bounded_public_copy(): - """``_managed_call`` forwards the message; the bound is the boundary's own check. - - ``CloudFeatureError`` is the already-redacted form -- every raise site builds it from - fixed, status-keyed copy -- so its text is what the customer should read. The bound - here is not the redaction, it is the guard for a message that is *not* that fixed copy: - anything oversized, empty, or carrying control characters is dropped for the generic - placeholder rather than rendered into a JSON error body. - """ - - def fail_with(exc): - raise exc - - for message in ("x" * 301, "", "connection\x00reset", "trace\x1b[31m"): - with pytest.raises(HTTPException) as caught: - v2_api._managed_call(fail_with, CloudFeatureError(message, status=502)) - assert caught.value.status_code == 502 - assert caught.value.detail == { - "error": v2_api._MANAGED_ERROR_FALLBACK, "managed_cloud": True, - "transient": False, - } - - with pytest.raises(HTTPException) as consent: - v2_api._managed_call( - fail_with, - CloudFeatureError( - "Managed compute is turned off for this installation.", - status=409, code="consent_required", - ), - ) - assert consent.value.status_code == 409 - assert consent.value.detail == { - "error": "Managed compute is turned off for this installation.", - "managed_cloud": True, - "transient": False, - "code": "consent_required", - } - - with pytest.raises(HTTPException) as unconfigured: - v2_api._managed_call( - fail_with, - CloudFeatureError( - "Connect this installation to Engraphis Cloud to use hosted features.", - status=401, code="cloud_unconfigured", - ), - ) - assert unconfigured.value.status_code == 401 - assert unconfigured.value.detail == { - "error": "Connect this installation to Engraphis Cloud to use hosted features.", - "managed_cloud": True, - "transient": False, - "code": "cloud_unconfigured", - } - - -@pytest.mark.parametrize("status", (401, 402, 403)) -def test_managed_authorization_denial_settles_local_entitlement(monkeypatch, status): - """A live hosted denial must immediately retire stale paid presentation state.""" - - calls = [] - monkeypatch.setattr(v2_api, "_record_authoritative_denial", lambda: calls.append(status)) - - def fail_with(exc): - raise exc - - with pytest.raises(HTTPException) as caught: - v2_api._managed_call( - fail_with, CloudFeatureError("Engraphis Cloud authorization was rejected.", - status=status), - ) - - assert caught.value.status_code == status - assert calls == [status] - - -@pytest.mark.parametrize("status", (409, 429, 503)) -def test_managed_non_authorization_failures_do_not_settle_entitlement(monkeypatch, status): - """Conflicts and outages do not prove that a subscription or membership changed.""" - - calls = [] - monkeypatch.setattr(v2_api, "_record_authoritative_denial", lambda: calls.append(status)) - - def fail_with(exc): - raise exc - - with pytest.raises(HTTPException): - v2_api._managed_call( - fail_with, CloudFeatureError("Engraphis Cloud temporarily failed.", status=status), - ) - - assert calls == [] - - -def _managed_http_failure(monkeypatch, status: int) -> HTTPException: - """Drive one real hosted request against a control plane that answers ``status``.""" - - class _Opener: - def open(self, request, timeout=None): - raise urllib.error.HTTPError( - "https://compute.example.test/private", status, "failure", {}, - io.BytesIO(b'{"detail": "provider-internals https://backend.invalid"}'), - ) - - monkeypatch.setattr( - cloud_features, "build_pinned_https_opener", lambda *handlers: _Opener() - ) - client = cloud_features.CloudFeatureClient( - "https://compute.example.test", "org_1", "token" - ) - with pytest.raises(HTTPException) as caught: - v2_api._managed_call(client._request, "GET", "/private") - return caught.value - - -def test_a_managed_outage_is_distinguishable_from_a_workspace_conflict(monkeypatch): - """The defect: every hosted failure rendered as one fixed, unactionable string. - - ``cloud_features._public_http_error`` already produces redacted, status-keyed copy that - tells a retryable outage apart from a conflict the customer has to fix -- and - ``_managed_call`` threw all of it away, so the dashboard's error branch could only ever - show "managed cloud operation failed" for a 429, a 5xx and a 409 alike. - """ - - busy = _managed_http_failure(monkeypatch, 429) - down = _managed_http_failure(monkeypatch, 503) - conflict = _managed_http_failure(monkeypatch, 409) - - assert busy.status_code == 429 - assert busy.detail["transient"] is True - assert "temporarily busy" in busy.detail["error"], busy.detail["error"] - - assert down.status_code == 503 - assert down.detail["transient"] is True - assert "temporarily unavailable" in down.detail["error"], down.detail["error"] - - assert conflict.status_code == 409 - assert conflict.detail["transient"] is False - assert "workspace state" in conflict.detail["error"], conflict.detail["error"] - - messages = {busy.detail["error"], down.detail["error"], conflict.detail["error"]} - assert len(messages) == 3, "the dashboard still cannot tell these three apart" - assert v2_api._MANAGED_ERROR_FALLBACK not in messages - # Forwarding the public copy must not forward the provider's body with it. - assert all("provider-internals" not in text for text in messages) - assert all("backend.invalid" not in text for text in messages) - - -def test_every_managed_cloud_error_message_is_fixed_local_copy(): - """The invariant that makes forwarding safe, pinned against future raise sites. - - ``_managed_call`` may forward a ``CloudFeatureError`` message only because every one of - them is built from a literal in this repository -- never from a provider body, a - ``CloudSessionError``, or a local path. A raise site that interpolated a runtime value - would silently turn this boundary into a reflection point, so the shape is asserted - rather than trusted. - - Three forms are accepted: a string literal; a name bound from ``_public_http_error`` / - ``_public_session_error`` (both of which switch on a bare integer status and return - fixed copy); and the one audited ``%`` template, below. - """ - - source = Path(cloud_features.__file__).read_text(encoding="utf-8") - tree = ast.parse(source) - - public_copy = {"_public_http_error", "_public_session_error"} - from_public_copy = set() - for node in ast.walk(tree): - if not isinstance(node, ast.Assign) or not isinstance(node.value, ast.Call): - continue - called = node.value.func - if not isinstance(called, ast.Name) or called.id not in public_copy: - continue - for target in node.targets: - elements = target.elts if isinstance(target, ast.Tuple) else [target] - from_public_copy.update( - item.id for item in elements if isinstance(item, ast.Name) - ) - assert from_public_copy, "the fixed-copy helpers are no longer bound to a name" - - interpolated = [] - for node in ast.walk(tree): - if not isinstance(node, ast.Call): - continue - name = node.func.id if isinstance(node.func, ast.Name) else None - if name != "CloudFeatureError" or not node.args: - continue - message = node.args[0] - if isinstance(message, ast.Constant) and isinstance(message.value, str): - continue - if isinstance(message, ast.Name) and message.id in from_public_copy: - continue - # ``"literal %s" % (...)`` is allowed only where the substituted values are - # themselves constrained to local literals; ``run_job`` is the single such site - # and its ``status`` is guarded by an ``in {"failed", "canceled"}`` membership - # test one line above. Anything else -- an f-string, a bare name, a concatenated - # response field -- is a reflection risk and fails here. - if (isinstance(message, ast.BinOp) and isinstance(message.op, ast.Mod) - and isinstance(message.left, ast.Constant) - and message.left.value == "Managed %s did not complete (%s)."): - continue - interpolated.append((node.lineno, ast.dump(message)[:120])) - - assert interpolated == [], ( - "a CloudFeatureError message is no longer fixed local copy; _managed_call " - "forwards it to the customer: %r" % (interpolated,) - ) +"""Unified local dashboard tests for the public open-core boundary.""" +import ast +import io +import threading +import urllib.error +from concurrent.futures import ThreadPoolExecutor +from pathlib import Path + +import pytest + +pytest.importorskip("fastapi", reason="full-stack extra not installed") +pytest.importorskip("httpx", reason="httpx not installed") + +from fastapi.testclient import TestClient # noqa: E402 +from fastapi import HTTPException # noqa: E402 + +from engraphis import cloud_features # noqa: E402 +from engraphis.config import settings # noqa: E402 +from engraphis.cloud_features import CloudFeatureError # noqa: E402 +from engraphis.core.interfaces import MemoryType, Scope # noqa: E402 +from engraphis.routes import v2_api # noqa: E402 +from engraphis.service import MemoryService, ValidationError # noqa: E402 + + +def _client(monkeypatch, tmp_path): + db_path = str(tmp_path / "dashboard.db") + monkeypatch.setattr(settings, "db_path", db_path) + monkeypatch.setattr(settings, "embed_model", "") + monkeypatch.setattr(settings, "embed_dim", 384) + monkeypatch.setattr(settings, "allowed_workspaces", []) + monkeypatch.setattr(settings, "api_token", "") + seeded = MemoryService.create(db_path) + demo_id = seeded.store.get_or_create_workspace("demo") + beta_id = seeded.store.get_or_create_workspace("beta") + seeded.engine.remember( + "Postgres 16 is the main database.", + workspace_id=demo_id, + scope=Scope.WORKSPACE, + title="Database", + ) + seeded.engine.remember( + "A second workspace must stay isolated.", + workspace_id=beta_id, + scope=Scope.WORKSPACE, + title="Isolation", + ) + seeded.store.close() + from engraphis.dashboard_app import create_app + return TestClient(create_app(), client=("127.0.0.1", 50000)) + + +def test_dashboard_serves_and_bootstraps_local_core(monkeypatch, tmp_path): + with _client(monkeypatch, tmp_path) as client: + page = client.get("/") + assert page.status_code == 200 + assert "Engraphis Ledger" in page.text + assert 'class="sidebar"' in page.text + for area in ("Today", "Ask", "Library", "Graph & Relationships", "Provenance", "Manage"): + assert f">{area}<" in page.text + assert 'value="matrix">Matrix' in page.text + assert 'class="dashboard-switcher" aria-label="Dashboard interface"' in page.text + assert 'id="sidebar-theme-select" aria-label="Dashboard theme"' in page.text + assert 'value="classic">Classic<' in page.text + assert 'href="/classic">Classic<' in page.text + assert 'Ledger (primary)' not in page.text + assert 'Classic (alternate)' not in page.text + assert '/v2-assets/vendor/d3.min.js' in page.text + assert '/v2-assets/vendor/force-graph.min.js' not in page.text + assert '/v2-assets/engraphis-graph.js' not in page.text + classic = client.get("/classic") + assert classic.status_code == 200 + assert '/classic-assets/dashboard.css' in classic.text + assert 'class="dashboard-switcher" aria-label="Dashboard interface"' in classic.text + assert 'href="/"' in classic.text + assert 'href="/classic" aria-current="page">Classic (alternate)<' in classic.text + assert 'value="classic" selected>Classic dashboard (alternate)<' in classic.text + assert 'id="graph-show-all"' not in classic.text + assert client.get("/v2-assets/ledger.css").status_code == 200 + ledger_js = client.get("/v2-assets/ledger.js") + assert ledger_js.status_code == 200 + assert "'/v2-assets/vendor/force-graph.min.js?v=20260727-final'" in ledger_js.text + assert "'/v2-assets/engraphis-graph.js?v=20260730-drag-stability'" in ledger_js.text + assert "/v2-assets/ledger.css?v=20260728-connected-memories" in page.text + assert "/v2-assets/ledger.js?v=20260728-connected-memories" in page.text + classic_js = client.get("/classic-assets/dashboard.js") + assert classic_js.status_code == 200 + assert "/static/vendor/force-graph.min.js" in classic_js.text + assert "/v2-assets/engraphis-graph.js?v=20260730-drag-stability" in classic_js.text + assert "graphLimit=GRAPH_FULL?20000:320" in classic_js.text + assert "graphScope=GRAPH_FULL?'&full=true':(showUnlinked?'':'&connected_only=true')" in classic_js.text + bootstrap = client.get("/api/bootstrap") + assert bootstrap.status_code == 200 + assert bootstrap.json()["stats"]["memories"] >= 1 + savings = client.get("/api/context-savings", params={"workspace": "demo"}) + assert savings.status_code == 200 + assert savings.json()["format"] == "engraphis-context-savings/1" + filtered = client.get( + "/api/context-savings", + params={"workspace": "demo", "from_ts": 0, "to_ts": 9_999_999_999, + "release_version": "1.5"}, + ) + assert filtered.status_code == 200 + assert filtered.json()["period"] == {"from_ts": 0, "to_ts": 9_999_999_999} + assert "Estimated context saved" in page.text + + +def test_dashboard_assets_revalidate_instead_of_pinning_old_visuals(monkeypatch, tmp_path): + with _client(monkeypatch, tmp_path) as client: + for path in ( + "/v2-assets/engraphis-graph.js?v=20260730-drag-stability", + "/v2-assets/ledger.js?v=20260728-connected-memories", + "/v2-assets/ledger.css?v=20260728-connected-memories", + "/classic-assets/dashboard.js?v=20260728-reference-materials", + ): + response = client.get(path) + assert response.status_code == 200 + assert response.headers["cache-control"] == "no-cache, must-revalidate" + + +def test_classic_dashboard_script_mirrors_the_static_compatibility_asset(): + root = Path(__file__).parents[1] / "engraphis" + assert (root / "classic_assets" / "dashboard.js").read_bytes() == ( + root / "static" / "dashboard.js" + ).read_bytes() + + +def test_dashboard_and_mcp_recall_share_the_v2_service(monkeypatch, tmp_path): + pytest.importorskip("mcp", reason="MCP extra not installed") + import json + + from engraphis import mcp_server + + with _client(monkeypatch, tmp_path) as client: + assert mcp_server.service() is client.app.state.service + response = client.get( + "/api/recall", + params={"q": "which database do we use", "workspace": "demo", "k": 3}, + ) + assert response.status_code == 200 + dashboard = response.json() + mcp = json.loads(mcp_server.engraphis_recall( + query="which database do we use", workspace="demo", k=3, + )) + assert [memory["id"] for memory in dashboard["memories"]] == [ + memory["id"] for memory in mcp["memories"] + ] + assert [memory["retention"] for memory in dashboard["memories"]] == [ + memory["retention"] for memory in mcp["memories"] + ] + assert [memory["relative_score"] for memory in dashboard["memories"]] == [ + memory["relative_score"] for memory in mcp["memories"] + ] + assert [memory["absolute_support"] for memory in dashboard["memories"]] == [ + memory["absolute_support"] for memory in mcp["memories"] + ] + assert dashboard["score_semantics"] == mcp["score_semantics"] + + +def test_dashboard_keyword_fallback_reports_truthful_lexical_scores(monkeypatch, tmp_path): + with _client(monkeypatch, tmp_path) as client: + def mismatched_embedder(*_args, **_kwargs): + raise ValueError("shapes (1,256) and (384,1) not aligned") + + monkeypatch.setattr(client.app.state.service, "recall", mismatched_embedder) + response = client.get( + "/api/recall", + params={ + "q": "which database do we use", + "workspace": "demo", + "k": 3, + "response_mode": "compact", + }, + ) + + assert response.status_code == 200 + payload = response.json() + assert payload["mode"] == "keyword" + assert "lexical Jaccard" in payload["score_semantics"]["relative_score"] + assert "Semantic support is unavailable" in ( + payload["score_semantics"]["absolute_support"] + ) + memory = payload["memories"][0] + assert memory["score"] == memory["relative_score"] == 1.0 + assert 0.0 < memory["absolute_support"] < 1.0 + assert memory["arm"] == "lexical" + assert "content" not in memory + + +def test_dashboard_keyword_fallback_applies_requested_memory_type_limits( + monkeypatch, tmp_path +): + with _client(monkeypatch, tmp_path) as client: + workspace_id = client.app.state.service.store.get_or_create_workspace("demo") + client.app.state.service.engine.remember( + "Database upgrade procedure requires a verified backup.", + workspace_id=workspace_id, + scope=Scope.WORKSPACE, + mtype=MemoryType.PROCEDURAL, + title="Database procedure", + ) + + def mismatched_embedder(*_args, **_kwargs): + raise ValueError("shapes (1,256) and (384,1) not aligned") + + monkeypatch.setattr(client.app.state.service, "recall", mismatched_embedder) + response = client.get( + "/api/recall", + params={ + "q": "database", + "workspace": "demo", + "k": 3, + "mtype_limits": '{"semantic":0,"procedural":1}', + }, + ) + + assert response.status_code == 200 + payload = response.json() + assert payload["mtype_limits"] == {"semantic": 0, "procedural": 1} + assert [memory["memory_type"] for memory in payload["memories"]] == [ + "procedural" + ] + + +@pytest.mark.parametrize("invalid_limit", [True, "2"]) +def test_dashboard_post_recall_surfaces_reject_coerced_memory_type_limits( + monkeypatch, tmp_path, invalid_limit +): + with _client(monkeypatch, tmp_path) as client: + response = client.post( + "/api/intent/recall", + json={"query": "database", "mtype_limits": {"semantic": invalid_limit}}, + ) + + assert response.status_code == 422 + + +def test_dashboard_serves_the_graph_engine_from_its_v2_asset_surface(monkeypatch, tmp_path): + with _client(monkeypatch, tmp_path) as client: + asset = client.get("/v2-assets/engraphis-graph.js") + assert asset.status_code == 200 + assert "window.EngraphisGraph =" in asset.text + compat = client.get("/v2-assets/engraphis-graph-compat.js") + assert compat.status_code == 200 + assert "window.EngraphisGraphCompat =" in compat.text + assert client.get("/v2-assets/vendor/d3.min.js").status_code == 200 + assert client.get("/v2-assets/vendor/force-graph.min.js").status_code == 200 + + +def test_graph_load_is_bounded_single_flight_and_retryable(monkeypatch, tmp_path): + with _client(monkeypatch, tmp_path) as client: + page = client.get("/") + script = client.get("/v2-assets/ledger.js") + assert 'id="graph-retry"' in page.text + assert 'id="graph-full"' not in page.text + assert '>Show all nodes<' not in page.text + assert 'id="graph-show-unlinked"' in page.text + assert 'id="graph-unlinked"' not in page.text + assert 'id="graph-tune-unlinked"' not in page.text + assert 'id="graph-style" type="hidden" value="cyber"' in page.text + assert "const GRAPH_INITIAL_NODE_LIMIT = 320;" in script.text + assert "const GRAPH_FULL_NODE_LIMIT = 20_000;" in script.text + assert "const GRAPH_LOAD_TIMEOUT_MS = 12_000;" in script.text + assert "AbortController" in script.text + assert "state.graphLoadPromise" in script.text + assert "&full=true" in script.text + assert "&connected_only=true" in script.text + assert "style: 'cyber'" in script.text + assert "renderMode: targetMode" in script.text + assert "loadGraph({ force: true })" in script.text + + +def test_graph_motion_saved_views_and_tuning_controls_are_wired(monkeypatch, tmp_path): + with _client(monkeypatch, tmp_path) as client: + page = client.get("/") + script = client.get("/v2-assets/ledger.js") + for control in ( + 'id="graph-flow-speed"', 'data-graph-saved-view="operations"', + 'data-graph-saved-view="schema"', 'data-graph-saved-view="people"', + 'data-graph-saved-view="code"', 'id="graph-save-view"', + 'id="graph-repel"', 'id="graph-depth"', 'id="graph-reset-tuning"', + 'data-graph-layer="code"', + ): + assert control in page.text + for behavior in ( + "function applyGraphView(id)", "function resetGraphTuning()", + "function saveCurrentGraphView()", "function graphTuningSettings()", + "&include_code=true", "graph.setLayers(graphLayerState())", + "setSettings({ flowSpeed: speed })", + ): + assert behavior in script.text + + +def test_graph_palette_recolors_every_colour_mode(monkeypatch, tmp_path): + with _client(monkeypatch, tmp_path) as client: + engine = client.get("/v2-assets/engraphis-graph.js") + ledger = client.get("/v2-assets/ledger.js") + assert engine.status_code == 200 + assert "function selectedPalette()" in engine.text + assert "function commPal() {" in engine.text + assert "return selectedPalette() ||" in engine.text + assert "const colors = selectedPalette() || GRAPH_HEAT;" in engine.text + # Palettes still recolor every identity mode, but material families stay stable: + # semantic color belongs to the slim identity ring rather than rotating the whole + # Cyber film into arbitrary green/yellow alloys. + assert "function iridescentTint(c)" not in engine.text + assert "fixedPalette" in engine.text + assert "function identityRing(" in engine.text + assert "identity: rgbString(identity)" in engine.text + assert "function graphThemeColors()" in ledger.text + assert "graph.setThemeColors(graphThemeColors());" in ledger.text + assert "state.graphEngine.setThemeColors(graphThemeColors());" in ledger.text + assert "renderMode: opts.renderMode === 'full' ? 'full' : 'overview'" in engine.text + assert "function pinFullGraphLayout(data)" in engine.text + + +def test_graph_facts_and_search_use_the_atomic_node_reveal(monkeypatch, tmp_path): + with _client(monkeypatch, tmp_path) as client: + page = client.get("/") + ledger = client.get("/v2-assets/ledger.js") + engine = client.get("/v2-assets/engraphis-graph.js") + assert 'id="graph-connections-dialog"' in page.text + assert "function revealGraphNode(id, label = 'Selected entity')" in ledger.text + assert "revealGraphNode(item.id, item.name)" in ledger.text + assert "function openGraphConnections(item)" in ledger.text + assert "function showGraphConnectionMemories(item)" in ledger.text + assert "onNodeClick: item => openGraphConnections(item)" in ledger.text + assert "api.reveal = id =>" in engine.text + assert "function centerRenderedNode(id)" in engine.text + assert "suppressNodeClickAfterDrag" in engine.text + assert "render(true, true);" not in engine.text[engine.text.index("api.focus = id =>"):engine.text.index("api.clearFocus")] + + +def test_library_editor_stacks_directly_below_the_selected_memory_panel(monkeypatch, tmp_path): + with _client(monkeypatch, tmp_path) as client: + page = client.get("/") + assert page.status_code == 200 + assert '
' in page.text + assert page.text.index('id="memory-detail"') < page.text.index('id="memory-editor"') + stylesheet = client.get("/v2-assets/ledger.css") + assert ".library-detail-stack { display: grid; gap: 12px; align-content: start; }" in stylesheet.text + + +def test_workspace_switcher_uses_the_active_ledger_theme_for_native_dropdowns(monkeypatch, tmp_path): + with _client(monkeypatch, tmp_path) as client: + stylesheet = client.get("/v2-assets/ledger.css") + assert stylesheet.status_code == 200 + css = stylesheet.text + assert ".workspace-switcher select {" in css + assert "background: var(--c-inset);" in css + assert "color-scheme: dark;" in css + assert 'body[data-theme="paper"] .workspace-switcher select { color-scheme: light; }' in css + assert ".workspace-switcher select option { background: var(--c-inset); color: var(--c-fg); }" in css + assert ".workspace-switcher select option:checked { background: var(--c-acc); color: var(--c-bg); }" in css + + +def test_sidebar_keeps_manage_and_compare_plans_in_separate_flex_rows(monkeypatch, tmp_path): + with _client(monkeypatch, tmp_path) as client: + stylesheet = client.get("/v2-assets/ledger.css") + assert stylesheet.status_code == 200 + css = stylesheet.text + sidebar = css[css.index(".sidebar {"):css.index(".brand-row {")] + assert "display: flex;" in sidebar + assert "flex-direction: column;" in sidebar + assert "grid-template-rows" not in sidebar + assert ".primary-nav { flex: 1 0 auto; }" in css + assert ".manage-nav { flex: 0 0 auto; }" in css + assert ".sidebar-promo {\n flex: 0 0 auto;" in css + + +def test_dashboard_grounded_answer_route_cites_or_abstains(monkeypatch, tmp_path): + with _client(monkeypatch, tmp_path) as client: + grounded = client.post( + "/api/answer", + json={ + "query": "Which database is the main database?", + "workspace": "demo", + "k": 8, + "max_citations": 5, + "candidate_depth": "adaptive", + }, + ) + assert grounded.status_code == 200 + body = grounded.json() + assert body["query"] == "Which database is the main database?" + assert body["grounded"] is True + assert body["abstained"] is False + assert body["citations"] + assert body["sources"] == body["citations"] + assert "[1]" in body["answer"] + assert body["candidate_depth"] == "adaptive" + # ``candidate_k_used`` is the final page depth after prompt-safe + # overfetch/widening, rather than the adaptive policy's starting depth. + assert body["candidate_k_used"] >= body["candidate_k_requested"] + + abstained = client.post( + "/api/answer", + json={ + "query": "How should I bake a sourdough loaf?", + "workspace": "demo", + }, + ) + assert abstained.status_code == 200 + assert abstained.json()["grounded"] is False + assert abstained.json()["abstained"] is True + + +def test_dashboard_grounded_answer_route_bounds_and_redacts(monkeypatch, tmp_path): + with _client(monkeypatch, tmp_path) as client: + assert client.post("/api/answer", json={"query": "", "workspace": "demo"}).status_code == 422 + assert client.post( + "/api/answer", + json={"query": "database", "workspace": "demo", "k": 51}, + ).status_code == 422 + + +def test_team_account_routes_are_not_in_public_runtime(monkeypatch, tmp_path): + with _client(monkeypatch, tmp_path) as client: + assert client.post("/api/auth/setup", json={}).status_code == 404 + assert client.get("/api/auth/users").status_code == 404 + state = client.get("/api/auth/state").json() + assert state["enabled"] is False + assert state["hosted_team"] is True + + +def test_local_agent_write_has_no_client_side_team_paywall(monkeypatch, tmp_path): + with _client(monkeypatch, tmp_path) as client: + response = client.post( + "/api/remember", + json={"workspace": "demo", "content": "Queues use at-least-once delivery."}, + ) + assert response.status_code == 200 + + +def test_http_memory_api_exposes_world_timed_agent_writes_immediately(monkeypatch, tmp_path): + with _client(monkeypatch, tmp_path) as client: + old = client.post( + "/api/remember", + json={ + "workspace": "demo", + "content": "The API rate limit is 100 requests per minute.", + "valid_from": 1_000.0, + "subject_key": "api.rate_limit", + "claim_kind": "configured_value", + }, + ).json() + new = client.post( + "/api/intent/remember", + json={ + "workspace": "demo", + "text": "The API rate limit is 500 requests per minute.", + "valid_from": 2_000.0, + "subject_key": "api.rate_limit", + "claim_kind": "configured_value", + }, + ).json() + + before = client.get( + "/api/recall", + params={ + "workspace": "demo", + "q": "What is the API rate limit?", + "as_of": 1_500.0, + }, + ) + after = client.post( + "/api/answer", + json={ + "workspace": "demo", + "query": "What is the API rate limit?", + "as_of": 2_500.0, + "min_support": 0.0, + }, + ) + + assert before.status_code == 200 + assert [memory["id"] for memory in before.json()["memories"]] == [old["id"]] + assert after.status_code == 200 + assert after.json()["sources"] + service = client.app.state.service + assert service.store.get_memory(old["id"]).valid_from == 1_000.0 + assert service.store.get_memory(new["id"]).valid_from == 2_000.0 + assert service.store.get_memory(old["id"]).provenance["review_state"] == "approved" + assert service.store.get_memory(new["id"]).provenance["review_state"] == "approved" + + +def test_keyword_recall_fallback_keeps_bitemporal_visibility(monkeypatch, tmp_path): + """A semantic-backend failure must not leak current facts into historical views.""" + with _client(monkeypatch, tmp_path) as client: + svc = v2_api.service() + workspace_id = svc.store.get_or_create_workspace("demo") + old = {"id": svc.engine.remember( + "The fallback retention setting was ten days.", workspace_id=workspace_id, + scope=Scope.WORKSPACE, valid_from=1_000.0, resolve_conflicts=False, + )} + new = {"id": svc.engine.remember( + "The fallback retention setting was thirty days.", workspace_id=workspace_id, + scope=Scope.WORKSPACE, valid_from=2_000.0, resolve_conflicts=False, + )} + # The writes happened during this test, but the fixture models facts learned + # before the requested historical system-time anchors. + svc.store.conn.execute( + "UPDATE memories SET ingested_at=100 WHERE id=?", (old["id"],) + ) + svc.store.conn.execute( + "UPDATE memories SET ingested_at=200 WHERE id=?", (new["id"],) + ) + svc.store.conn.execute( + "UPDATE memories SET valid_to=2000, valid_to_recorded_at=200, " + "subject_key='retention.days', claim_kind='configured_value' " + "WHERE id=?", + (old["id"],), + ) + svc.store.conn.commit() + old_before = v2_api._keyword_search( + "demo", "fallback retention", valid_at=1_500.0, known_at=3_000.0 + ) + old_known = v2_api._keyword_search( + "demo", "fallback retention", valid_at=1_500.0, known_at=50.0 + ) + current = v2_api._keyword_search( + "demo", "fallback retention", valid_at=2_500.0, known_at=3_000.0 + ) + closure_unknown = v2_api._keyword_search( + "demo", "fallback retention", valid_at=2_500.0, known_at=150.0 + ) + + assert [memory["id"] for memory in old_before] == [old["id"]] + assert old_known == [] + assert [memory["id"] for memory in current] == [new["id"]] + assert [memory["id"] for memory in closure_unknown] == [old["id"]] + assert closure_unknown[0]["valid_to_recorded_at"] == 200.0 + assert closure_unknown[0]["subject_key"] == "retention.days" + assert closure_unknown[0]["claim_kind"] == "configured_value" + + def incompatible_embedder(*_args, **_kwargs): + raise ValueError("shapes (256,) and (384,) not aligned") + + monkeypatch.setattr(svc, "recall", incompatible_embedder) + fallback = client.get( + "/api/recall", + params={ + "workspace": "demo", "q": "fallback retention", + "valid_at": 2_500.0, "known_at": 150.0, + }, + ) + assert fallback.status_code == 200 + assert fallback.json()["mode"] == "keyword" + assert [item["id"] for item in fallback.json()["memories"]] == [old["id"]] + + compact_fallback = client.get( + "/api/recall", + params={ + "workspace": "demo", "q": "fallback retention", "response_mode": "compact", + "token_budget": 0, + }, + ) + payload = compact_fallback.json() + assert compact_fallback.status_code == 200 + assert payload["mode"] == "keyword" + assert payload["response_mode"] == "compact" + assert payload["usage"]["budget_tokens"] == 0 + assert payload["usage"]["context_tokens"] == 0 + assert payload["memories"] and "content" not in payload["memories"][0] + + +def test_keyword_recall_fallback_excludes_untrusted_memories(monkeypatch, tmp_path): + """A degraded HTTP recall must enforce the same prompt eligibility boundary.""" + with _client(monkeypatch, tmp_path) as client: + svc = v2_api.service() + workspace_id = svc.store.get_or_create_workspace("demo") + trusted = {"id": svc.engine.remember( + "Fallback visibility trusted candidate.", + workspace_id=workspace_id, scope=Scope.WORKSPACE, + )} + untrusted = svc.remember( + "Fallback visibility untrusted candidate.", + workspace="demo", + source="sync", + trusted=False, + ) + + def incompatible_embedder(*_args, **_kwargs): + raise ValueError("shapes (256,) and (384,) not aligned") + + monkeypatch.setattr(svc, "recall", incompatible_embedder) + response = client.get( + "/api/recall", + params={"workspace": "demo", "q": "fallback visibility candidate", "k": 1}, + ) + + payload = response.json() + assert response.status_code == 200 + assert payload["mode"] == "keyword" + assert [memory["id"] for memory in payload["memories"]] == [trusted["id"]] + assert untrusted["id"] not in {memory["id"] for memory in payload["memories"]} + assert "untrusted candidate" not in repr(payload) + + +def test_http_memory_api_rejects_backdated_agent_claim_supersession( + monkeypatch, tmp_path +): + with _client(monkeypatch, tmp_path) as client: + original = client.post( + "/api/remember", + json={ + "workspace": "demo", + "content": "The deployment window is Friday afternoon.", + "valid_from": 2_000.0, + }, + ).json() + service = v2_api.service() + count_before = len(service.store.list_memories(include_invalid=True)) + rejected = client.post( + "/api/remember", + json={ + "workspace": "demo", + "content": "The deployment window is Thursday afternoon.", + "valid_from": 1_000.0, + }, + ) + + assert rejected.status_code == 400 + assert service.store.get_memory(original["id"]).valid_to is None + assert len(service.store.list_memories(include_invalid=True)) == count_before + + +def test_manual_consolidation_stays_local_but_dreaming_is_cloud_only( + monkeypatch, tmp_path +): + with _client(monkeypatch, tmp_path) as client: + manual = client.post( + "/api/consolidate", + json={"workspace": "demo", "dry_run": True, "infer": False}, + ) + assert manual.status_code == 200 + dream = client.post( + "/api/consolidate", + json={"workspace": "demo", "dry_run": True, "infer": True}, + ) + assert dream.status_code == 501 + assert dream.json()["detail"]["cloud_only"] is True + + +def test_analytics_route_delegates_to_managed_compute(monkeypatch, tmp_path): + monkeypatch.setattr( + "engraphis.cloud_features.run_managed_job", + lambda service, workspace, kind: { + "result": { + "kind": kind, + "generation": 4, + "totals": {"live": 1}, + } + }, + ) + with _client(monkeypatch, tmp_path) as client: + response = client.get("/api/analytics?workspace=demo") + assert response.status_code == 200 + assert response.json()["kind"] == "analytics" + assert response.json()["generation"] == 4 + + +def test_unconnected_automation_returns_a_structured_auth_error(monkeypatch, tmp_path): + for name in ( + "ENGRAPHIS_CLOUD_ACCESS_TOKEN", + "ENGRAPHIS_CLOUD_ORGANIZATION_ID", + "ENGRAPHIS_CLOUD_COMPUTE_URL", + "ENGRAPHIS_CLOUD_REFRESH_CREDENTIAL", + "ENGRAPHIS_CLOUD_CONTROL_URL", + ): + monkeypatch.delenv(name, raising=False) + monkeypatch.setenv("ENGRAPHIS_STATE_DIR", str(tmp_path / "unconnected-state")) + + with _client(monkeypatch, tmp_path) as client: + response = client.get("/api/automation?workspace=demo") + + assert response.status_code == 401 + # The copy is ``_public_session_error(401)``: fixed, status-keyed, and actionable. The + # generic placeholder told an unconnected customer nothing they could act on. + assert response.json()["detail"] == { + "error": "Connect this installation to Engraphis Cloud to use hosted features.", + "managed_cloud": True, + "transient": False, + "code": "cloud_unconfigured", + } + + +def test_hosted_automation_accepts_the_cloud_policy_field(monkeypatch, tmp_path): + saved = {} + + class _Cloud: + def upload_snapshot(self, workspace_id, snapshot): + return {"generation": snapshot["generation"]} + + def get_policy(self, workspace_id): + return {"enabled": False, "cadence_minutes": 1440, "dream_enabled": False} + + def save_policy(self, workspace_id, policy): + saved.update(policy) + return {"version": 2} + + monkeypatch.setattr( + "engraphis.cloud_features.build_managed_snapshot", + lambda service, workspace: ("ws_cloud", {"generation": 1}), + ) + monkeypatch.setattr( + "engraphis.cloud_features.CloudFeatureClient.from_environment", + lambda workspace_id=None: _Cloud(), + ) + with _client(monkeypatch, tmp_path) as client: + response = client.post( + "/api/automation", + json={"enabled": True, "dream_enabled": True, "cadence_hours": 12}, + ) + assert response.status_code == 200 + assert response.json()["dream_enabled"] is True + assert saved["dream_enabled"] is True + + +def test_first_hosted_automation_view_bootstraps_the_recommended_policy( + monkeypatch, tmp_path +): + """A connected Pro/Team workspace starts maintaining itself without a toggle.""" + + uploaded = [] + saved = [] + + class _Cloud: + organization_id = "org_test" + + def get_policy(self, workspace_id): + # Version zero is the private Cloud's documented no-policy sentinel. + return {"enabled": False, "cadence_minutes": 1440, "version": 0} + + def upload_snapshot(self, workspace_id, snapshot): + uploaded.append((workspace_id, snapshot)) + return {"generation": snapshot["generation"]} + + def save_policy(self, workspace_id, policy): + saved.append((workspace_id, policy)) + return {"version": 1} + + def list_jobs(self, workspace_id, *, limit=10): + return {"jobs": []} + + monkeypatch.setattr( + "engraphis.cloud_features.build_managed_snapshot", + lambda service, workspace: ("ws_cloud", {"generation": 7}), + ) + monkeypatch.setattr( + "engraphis.cloud_features.CloudFeatureClient.from_environment", + lambda workspace_id=None: _Cloud(), + ) + with _client(monkeypatch, tmp_path) as client: + response = client.get("/api/automation") + + assert response.status_code == 200 + assert response.json()["enabled"] is True + assert response.json()["dream"] is True + assert uploaded == [("ws_cloud", {"generation": 7})] + assert saved == [("ws_cloud", { + "enabled": True, + "cadence_minutes": 1440, + "dream_enabled": True, + "dream_min_new": 25, + "dream_idle_minutes": 15, + "infer": False, + })] + + +def test_first_automation_policy_retry_does_not_upload_the_snapshot_twice( + monkeypatch, tmp_path +): + """A failed policy write resumes after the already successful private upload.""" + + from engraphis.cloud_features import CloudFeatureError + + uploaded = [] + saved = [] + builds = [] + + class _Cloud: + organization_id = "org_test" + + def get_policy(self, workspace_id): + return {"enabled": False, "cadence_minutes": 1440, "version": 0} + + def upload_snapshot(self, workspace_id, snapshot): + uploaded.append((workspace_id, snapshot)) + return {"generation": snapshot["generation"]} + + def save_policy(self, workspace_id, policy): + saved.append((workspace_id, policy)) + if len(saved) == 1: + raise CloudFeatureError( + "Engraphis Cloud is temporarily unavailable.", + status=503, + transient=True, + ) + return {"version": 1} + + def list_jobs(self, workspace_id, *, limit=10): + return {"jobs": []} + + def _snapshot(service, workspace): + builds.append(workspace) + return "ws_cloud", {"generation": 7} + + monkeypatch.setattr("engraphis.cloud_features.build_managed_snapshot", _snapshot) + monkeypatch.setattr( + "engraphis.cloud_features.CloudFeatureClient.from_environment", + lambda workspace_id=None: _Cloud(), + ) + with _client(monkeypatch, tmp_path) as client: + first = client.get("/api/automation") + second = client.get("/api/automation") + + assert first.status_code == 503 + assert second.status_code == 200 + assert len(builds) == 1 + assert uploaded == [("ws_cloud", {"generation": 7})] + assert len(saved) == 2 + + +def test_concurrent_first_automation_views_upload_one_snapshot(monkeypatch, tmp_path): + """Parallel dashboard reads serialize the sensitive first-bootstrap upload.""" + + uploaded = [] + saved = [] + started = threading.Event() + release_upload = threading.Event() + + class _Cloud: + organization_id = "org_concurrent" + + def get_policy(self, workspace_id): + return {"enabled": False, "cadence_minutes": 1440, "version": 0} + + def upload_snapshot(self, workspace_id, snapshot): + uploaded.append((workspace_id, snapshot)) + started.set() + assert release_upload.wait(timeout=5) + return {"generation": snapshot["generation"]} + + def save_policy(self, workspace_id, policy): + saved.append((workspace_id, policy)) + return {"version": 1} + + def list_jobs(self, workspace_id, *, limit=10): + return {"jobs": []} + + monkeypatch.setattr( + "engraphis.cloud_features.build_managed_snapshot", + lambda service, workspace: ("ws_cloud", {"generation": 7}), + ) + monkeypatch.setattr( + "engraphis.cloud_features.CloudFeatureClient.from_environment", + lambda workspace_id=None: _Cloud(), + ) + with _client(monkeypatch, tmp_path): + with ThreadPoolExecutor(max_workers=2) as pool: + first = pool.submit(v2_api.automation_get) + assert started.wait(timeout=5) + second = pool.submit(v2_api.automation_get) + release_upload.set() + assert first.result(timeout=5)["enabled"] is True + follower = second.result(timeout=5) + assert follower["enabled"] is True + assert follower["version"] == 1 + + assert uploaded == [("ws_cloud", {"generation": 7})] + assert len(saved) == 1 + + +def test_reading_or_disabling_automation_never_uploads_memory_content( + monkeypatch, tmp_path +): + saved = {} + + class _Cloud: + def get_policy(self, workspace_id): + return {"enabled": True, "cadence_minutes": 60, "dream_enabled": True} + + def list_jobs(self, workspace_id, *, limit=10): + return {"jobs": []} + + def save_policy(self, workspace_id, policy): + saved.update(policy) + return {"version": 3} + + def _unexpected_upload(*args, **kwargs): + raise AssertionError("policy inspection must not build or upload a snapshot") + + monkeypatch.setattr( + "engraphis.cloud_features.build_managed_snapshot", + _unexpected_upload, + ) + monkeypatch.setattr( + "engraphis.cloud_features.CloudFeatureClient.from_environment", + lambda workspace_id=None: _Cloud(), + ) + with _client(monkeypatch, tmp_path) as client: + assert client.get("/api/automation").status_code == 200 + response = client.post("/api/automation", json={"enabled": False}) + assert response.status_code == 200 + assert saved["enabled"] is False + + +def test_automation_and_maintenance_use_the_selected_workspace(monkeypatch, tmp_path): + policy_workspaces = [] + snapshot_workspaces = [] + maintenance_workspaces = [] + + class _Cloud: + def get_policy(self, workspace_id): + policy_workspaces.append(workspace_id) + return {"enabled": False, "cadence_minutes": 60, "dream_enabled": True} + + def list_jobs(self, workspace_id, *, limit=10): + policy_workspaces.append(workspace_id) + return {"jobs": []} + + def upload_snapshot(self, workspace_id, snapshot): + snapshot_workspaces.append(workspace_id) + return {"generation": snapshot["generation"]} + + def save_policy(self, workspace_id, policy): + policy_workspaces.append(workspace_id) + return {"version": 1} + + def snapshot(service, workspace): + snapshot_workspaces.append(workspace) + return service._lookup_workspace(workspace), {"generation": 1} + + def managed_job(service, workspace, kind): + maintenance_workspaces.append((workspace, kind)) + return {"result": {"kind": kind}} + + monkeypatch.setattr("engraphis.cloud_features.build_managed_snapshot", snapshot) + monkeypatch.setattr("engraphis.cloud_features.run_managed_job", managed_job) + monkeypatch.setattr( + "engraphis.cloud_features.CloudFeatureClient.from_environment", + lambda workspace_id=None: _Cloud(), + ) + with _client(monkeypatch, tmp_path) as client: + beta_id = client.app.state.service._lookup_workspace("beta") + demo_id = client.app.state.service._lookup_workspace("demo") + assert client.get("/api/automation?workspace=beta").status_code == 200 + assert client.post( + "/api/automation?workspace=beta", json={"enabled": True} + ).status_code == 200 + assert client.post( + "/api/maintenance/run?workspace=beta", json={"dry_run": True} + ).status_code == 200 + + assert beta_id in policy_workspaces + assert demo_id not in policy_workspaces + assert "beta" in snapshot_workspaces + assert maintenance_workspaces == [("beta", "consolidate")] + + +def test_automation_workspace_query_unknown_is_not_replaced_by_legacy_default( + monkeypatch, tmp_path +): + with _client(monkeypatch, tmp_path) as client: + for method, path, payload in ( + (client.get, "/api/automation?workspace=missing", None), + (client.post, "/api/automation?workspace=missing", {"enabled": False}), + (client.post, "/api/maintenance/run?workspace=missing", {"dry_run": True}), + ): + response = method(path, json=payload) if payload is not None else method(path) + assert response.status_code == 404 + + +def test_dashboard_automation_uses_active_workspace_and_discloses_upload_boundary(): + source = Path(__file__).parents[1] / "engraphis" / "static" / "dashboard.js" + source = source.read_text(encoding="utf-8") + assert "/automation?workspace=" in source + assert "/maintenance/run?workspace=" in source + assert "Preview snapshot" not in source + assert "uploads the selected workspace’s normal and sensitive memory content" in source + # The upload boundary is still disclosed, but consent now travels with the cloud + # account: the dashboard must not name the operator override anywhere. + assert "ENGRAPHIS_MANAGED_COMPUTE_CONSENT" not in source + assert "Hosted work is automatic with Pro." in source + + +def test_portfolio_and_report_analytics_are_hosted_only(monkeypatch, tmp_path): + with _client(monkeypatch, tmp_path) as client: + assert client.get("/api/analytics/portfolio").status_code == 501 + assert client.get("/api/analytics/export?workspace=demo").status_code == 501 + + +def test_raw_owner_export_is_free_and_signed_export_is_honestly_unimplemented( + monkeypatch, tmp_path +): + """The signed variant must not claim to exist somewhere else. + + It previously answered ``cloud_only: True`` — but Engraphis Cloud has no export route, + no supported hosted export capability, so that pointed a customer at a + product that does not exist. The 501 now says the capability is unimplemented and names + the working unsigned export instead. + """ + + with _client(monkeypatch, tmp_path) as client: + raw = client.get("/api/export?workspace=demo") + assert raw.status_code == 200 + assert raw.json()["counts"]["memories"] >= 1 + signed = client.get("/api/export?workspace=demo&signed=true") + assert signed.status_code == 501 + detail = signed.json()["detail"] + assert detail["implemented"] is False + assert detail["alternative"] == "/export" + assert "cloud_only" not in detail + assert "Engraphis Cloud" not in detail["error"] + + +def test_health_and_readiness_remain_public(monkeypatch, tmp_path): + with _client(monkeypatch, tmp_path) as client: + assert client.get("/api/health").status_code == 200 + assert client.get("/api/ready").status_code == 200 + + +def test_dashboard_exception_responses_do_not_echo_untrusted_exception_text(): + secret = "https://provider.example/?api_key=do-not-return-this" + + def fail_with(exc): + raise exc + + with pytest.raises(HTTPException) as internal: + v2_api._run(fail_with, RuntimeError(secret)) + assert internal.value.status_code == 500 + assert internal.value.detail == {"error": "internal server error"} + assert secret not in repr(internal.value.detail) + + with pytest.raises(HTTPException) as validation: + v2_api._run(fail_with, ValidationError(secret)) + assert validation.value.status_code == 400 + assert validation.value.detail == {"error": "invalid request"} + assert secret not in repr(validation.value.detail) + + with pytest.raises(HTTPException) as downstream: + v2_api._run(fail_with, HTTPException(status_code=418, detail={"error": secret})) + assert downstream.value.status_code == 418 + assert downstream.value.detail == {"error": "request rejected"} + assert secret not in repr(downstream.value.detail) + + with pytest.raises(HTTPException) as invalid_status: + v2_api._run(fail_with, HTTPException(status_code=999, detail={"error": secret})) + assert invalid_status.value.status_code == 500 + assert invalid_status.value.detail == {"error": "internal server error"} + assert secret not in repr(invalid_status.value.detail) + + with pytest.raises(HTTPException) as mismatch: + v2_api._run(fail_with, ValueError(f"{secret}: shapes 256 and 384 are not aligned")) + assert mismatch.value.status_code == 409 + assert mismatch.value.detail["embedder"] is True + assert secret not in repr(mismatch.value.detail) + + with pytest.raises(HTTPException) as ordinary_value_error: + v2_api._run(fail_with, ValueError(secret)) + assert ordinary_value_error.value.status_code == 400 + assert ordinary_value_error.value.detail == {"error": "invalid request"} + assert secret not in repr(ordinary_value_error.value.detail) + + +def test_dashboard_engine_value_error_is_a_sanitized_client_error(monkeypatch, tmp_path): + secret = "malformed document details must stay private" + with _client(monkeypatch, tmp_path) as client: + def reject_document(*_args, **_kwargs): + raise ValueError(secret) + + monkeypatch.setattr(client.app.state.service, "remember", reject_document) + response = client.post( + "/api/remember", + json={"content": "client document", "workspace": "demo"}, + ) + + assert response.status_code == 400 + assert response.json() == {"detail": {"error": "invalid request"}} + assert secret not in response.text + + +def test_managed_cloud_errors_forward_only_bounded_public_copy(): + """``_managed_call`` forwards the message; the bound is the boundary's own check. + + ``CloudFeatureError`` is the already-redacted form -- every raise site builds it from + fixed, status-keyed copy -- so its text is what the customer should read. The bound + here is not the redaction, it is the guard for a message that is *not* that fixed copy: + anything oversized, empty, or carrying control characters is dropped for the generic + placeholder rather than rendered into a JSON error body. + """ + + def fail_with(exc): + raise exc + + for message in ("x" * 301, "", "connection\x00reset", "trace\x1b[31m"): + with pytest.raises(HTTPException) as caught: + v2_api._managed_call(fail_with, CloudFeatureError(message, status=502)) + assert caught.value.status_code == 502 + assert caught.value.detail == { + "error": v2_api._MANAGED_ERROR_FALLBACK, "managed_cloud": True, + "transient": False, + } + + with pytest.raises(HTTPException) as consent: + v2_api._managed_call( + fail_with, + CloudFeatureError( + "Managed compute is turned off for this installation.", + status=409, code="consent_required", + ), + ) + assert consent.value.status_code == 409 + assert consent.value.detail == { + "error": "Managed compute is turned off for this installation.", + "managed_cloud": True, + "transient": False, + "code": "consent_required", + } + + with pytest.raises(HTTPException) as unconfigured: + v2_api._managed_call( + fail_with, + CloudFeatureError( + "Connect this installation to Engraphis Cloud to use hosted features.", + status=401, code="cloud_unconfigured", + ), + ) + assert unconfigured.value.status_code == 401 + assert unconfigured.value.detail == { + "error": "Connect this installation to Engraphis Cloud to use hosted features.", + "managed_cloud": True, + "transient": False, + "code": "cloud_unconfigured", + } + + +@pytest.mark.parametrize("status", (401, 402, 403)) +def test_managed_authorization_denial_settles_local_entitlement(monkeypatch, status): + """A live hosted denial must immediately retire stale paid presentation state.""" + + calls = [] + monkeypatch.setattr(v2_api, "_record_authoritative_denial", lambda: calls.append(status)) + + def fail_with(exc): + raise exc + + with pytest.raises(HTTPException) as caught: + v2_api._managed_call( + fail_with, CloudFeatureError("Engraphis Cloud authorization was rejected.", + status=status), + ) + + assert caught.value.status_code == status + assert calls == [status] + + +@pytest.mark.parametrize("status", (409, 429, 503)) +def test_managed_non_authorization_failures_do_not_settle_entitlement(monkeypatch, status): + """Conflicts and outages do not prove that a subscription or membership changed.""" + + calls = [] + monkeypatch.setattr(v2_api, "_record_authoritative_denial", lambda: calls.append(status)) + + def fail_with(exc): + raise exc + + with pytest.raises(HTTPException): + v2_api._managed_call( + fail_with, CloudFeatureError("Engraphis Cloud temporarily failed.", status=status), + ) + + assert calls == [] + + +def _managed_http_failure(monkeypatch, status: int) -> HTTPException: + """Drive one real hosted request against a control plane that answers ``status``.""" + + class _Opener: + def open(self, request, timeout=None): + raise urllib.error.HTTPError( + "https://compute.example.test/private", status, "failure", {}, + io.BytesIO(b'{"detail": "provider-internals https://backend.invalid"}'), + ) + + monkeypatch.setattr( + cloud_features, "build_pinned_https_opener", lambda *handlers: _Opener() + ) + client = cloud_features.CloudFeatureClient( + "https://compute.example.test", "org_1", "token" + ) + with pytest.raises(HTTPException) as caught: + v2_api._managed_call(client._request, "GET", "/private") + return caught.value + + +def test_a_managed_outage_is_distinguishable_from_a_workspace_conflict(monkeypatch): + """The defect: every hosted failure rendered as one fixed, unactionable string. + + ``cloud_features._public_http_error`` already produces redacted, status-keyed copy that + tells a retryable outage apart from a conflict the customer has to fix -- and + ``_managed_call`` threw all of it away, so the dashboard's error branch could only ever + show "managed cloud operation failed" for a 429, a 5xx and a 409 alike. + """ + + busy = _managed_http_failure(monkeypatch, 429) + down = _managed_http_failure(monkeypatch, 503) + conflict = _managed_http_failure(monkeypatch, 409) + + assert busy.status_code == 429 + assert busy.detail["transient"] is True + assert "temporarily busy" in busy.detail["error"], busy.detail["error"] + + assert down.status_code == 503 + assert down.detail["transient"] is True + assert "temporarily unavailable" in down.detail["error"], down.detail["error"] + + assert conflict.status_code == 409 + assert conflict.detail["transient"] is False + assert "workspace state" in conflict.detail["error"], conflict.detail["error"] + + messages = {busy.detail["error"], down.detail["error"], conflict.detail["error"]} + assert len(messages) == 3, "the dashboard still cannot tell these three apart" + assert v2_api._MANAGED_ERROR_FALLBACK not in messages + # Forwarding the public copy must not forward the provider's body with it. + assert all("provider-internals" not in text for text in messages) + assert all("backend.invalid" not in text for text in messages) + + +def test_every_managed_cloud_error_message_is_fixed_local_copy(): + """The invariant that makes forwarding safe, pinned against future raise sites. + + ``_managed_call`` may forward a ``CloudFeatureError`` message only because every one of + them is built from a literal in this repository -- never from a provider body, a + ``CloudSessionError``, or a local path. A raise site that interpolated a runtime value + would silently turn this boundary into a reflection point, so the shape is asserted + rather than trusted. + + Three forms are accepted: a string literal; a name bound from ``_public_http_error`` / + ``_public_session_error`` (both of which switch on a bare integer status and return + fixed copy); and the one audited ``%`` template, below. + """ + + source = Path(cloud_features.__file__).read_text(encoding="utf-8") + tree = ast.parse(source) + + public_copy = {"_public_http_error", "_public_session_error"} + from_public_copy = set() + for node in ast.walk(tree): + if not isinstance(node, ast.Assign) or not isinstance(node.value, ast.Call): + continue + called = node.value.func + if not isinstance(called, ast.Name) or called.id not in public_copy: + continue + for target in node.targets: + elements = target.elts if isinstance(target, ast.Tuple) else [target] + from_public_copy.update( + item.id for item in elements if isinstance(item, ast.Name) + ) + assert from_public_copy, "the fixed-copy helpers are no longer bound to a name" + + interpolated = [] + for node in ast.walk(tree): + if not isinstance(node, ast.Call): + continue + name = node.func.id if isinstance(node.func, ast.Name) else None + if name != "CloudFeatureError" or not node.args: + continue + message = node.args[0] + if isinstance(message, ast.Constant) and isinstance(message.value, str): + continue + if isinstance(message, ast.Name) and message.id in from_public_copy: + continue + # ``"literal %s" % (...)`` is allowed only where the substituted values are + # themselves constrained to local literals; ``run_job`` is the single such site + # and its ``status`` is guarded by an ``in {"failed", "canceled"}`` membership + # test one line above. Anything else -- an f-string, a bare name, a concatenated + # response field -- is a reflection risk and fails here. + if (isinstance(message, ast.BinOp) and isinstance(message.op, ast.Mod) + and isinstance(message.left, ast.Constant) + and message.left.value == "Managed %s did not complete (%s)."): + continue + interpolated.append((node.lineno, ast.dump(message)[:120])) + + assert interpolated == [], ( + "a CloudFeatureError message is no longer fixed local copy; _managed_call " + "forwards it to the customer: %r" % (interpolated,) + ) diff --git a/tests/test_savings.py b/tests/test_savings.py index 713394b6..04cfaebf 100644 --- a/tests/test_savings.py +++ b/tests/test_savings.py @@ -1,218 +1,218 @@ -import pytest - -from engraphis import __version__ -from engraphis.core.savings import SavingsEstimate, annotate_usage, estimate_savings -from engraphis.core.store import Store -from engraphis.service import MemoryService, ValidationError - - -@pytest.mark.parametrize( - ("operation", "intent", "adaptive_mode", "basis", "confidence", "eligible"), - [ - ("adaptive_context", None, "retrieval", "history_retrieval", "high", True), - ("adaptive_context", None, "history_fallback", "history_fallback", "medium", True), - ("adaptive_context", None, "history_bypass", "history_bypass", "none", False), - ( - "adaptive_context", - None, - "low_confidence_abstain", - "low_confidence_abstain", - "none", - False, - ), - ("recall", "recall_context", None, "packed_context", "medium", True), - ("grounded_recall", None, None, "packed_context", "medium", True), - ("proactive_context", None, None, "packed_context", "medium", True), - ("recall", "recall", None, "unclassified", "unknown", False), - ], -) -def test_estimator_classifies_each_delivery_basis( - operation, intent, adaptive_mode, basis, confidence, eligible -): - estimate = estimate_savings( - operation=operation, - intent=intent, - adaptive_mode=adaptive_mode, - baseline_tokens=100, - emitted_tokens=40, - token_counter="engraphis.regex.v1", - release_version="1.5.0", - ) - - assert isinstance(estimate, SavingsEstimate) - assert estimate.basis == basis - assert estimate.confidence == confidence - assert estimate.eligible is eligible - assert estimate.saved_tokens == (60 if eligible else 0) - assert 0 <= estimate.saved_tokens <= estimate.baseline_tokens - assert 0 <= estimate.savings_ratio <= 1 - assert estimate.release_version == "1.5.0" - - -def test_estimator_is_conservative_for_bad_counts_and_annotates_existing_usage(): - usage = annotate_usage( - {"source_tokens": 90, "context_tokens": 30, "saved_tokens": 60, - "token_counter": "engraphis.regex.v1"}, - operation="adaptive_context", - adaptive_mode="history_fallback", - baseline_tokens=90, - emitted_tokens=30, - release_version=__version__, - ) - - assert usage["estimated_saved_tokens"] == 60 - assert usage["savings_eligible"] is True - assert usage["release_version"] == "1.5.0" - abstained = estimate_savings( - operation="adaptive_context", - adaptive_mode="low_confidence_abstain", - baseline_tokens=float("nan"), - emitted_tokens=0, - ) - assert abstained.saved_tokens == 0 - assert abstained.baseline_tokens == 0 - - -def _usage(baseline, emitted, *, counter, release="1.5.0", eligible=True, - basis="history_retrieval", confidence="high"): - saved = max(0, baseline - emitted) if eligible else 0 - return { - "source_tokens": baseline, - "context_tokens": emitted, - "saved_tokens": saved, - "budget_tokens": baseline, - "packed_count": 1, - "omitted_count": 0, - "token_counter": counter, - "baseline_tokens": baseline, - "emitted_tokens": emitted, - "estimated_saved_tokens": saved, - "estimated_savings_ratio": saved / baseline if baseline else 0.0, - "savings_basis": basis, - "savings_confidence": confidence, - "savings_eligible": eligible, - "release_version": release, - } - - -def test_context_savings_aggregates_estimates_filters_releases_and_counters(): - store = Store(":memory:") - wid = store.get_or_create_workspace("savings") - rid = store.get_or_create_repo(wid, "repo") - first = store.record_receipt( - "adaptive_context", - workspace_id=wid, - repo_id=rid, - metadata={"adaptive_mode": "retrieval", "token_usage": _usage( - 100, 40, counter="engraphis.regex.v1" - )}, - ) - second = store.record_receipt( - "adaptive_context", - workspace_id=wid, - repo_id=rid, - metadata={"adaptive_mode": "history_bypass", "token_usage": _usage( - 80, 80, counter="engraphis.regex.v1", eligible=False, - basis="history_bypass", confidence="none" - )}, - ) - third = store.record_receipt( - "recall", - workspace_id=wid, - repo_id=rid, - metadata={"intent": "recall_context", "token_usage": _usage( - 50, 20, counter="estimate_tokens", release="1.4.0", - basis="packed_context", confidence="medium" - )}, - ) - old = store.record_receipt( - "recall", - workspace_id=wid, - repo_id=rid, - metadata={"intent": "recall_context", "token_usage": { - "source_tokens": 20, "context_tokens": 10, "saved_tokens": 10, - "token_counter": "engraphis.regex.v1", - }}, - ) - for timestamp, receipt in ((100.0, first), (110.0, second), (120.0, third), (130.0, old)): - store.conn.execute( - "UPDATE operation_receipts SET ts=? WHERE id=?", (timestamp, receipt["id"]) - ) - store.conn.commit() - - summary = store.context_savings( - workspace_id=wid, repo_id=rid, from_ts=99, to_ts=121 - ) - assert summary["estimated"]["eligible_receipt_count"] == 2 - assert summary["estimated"]["excluded_receipt_count"] == 1 - assert summary["estimated"]["unclassified_receipt_count"] == 0 - assert summary["estimated"]["baseline_tokens"] == 150 - assert summary["estimated"]["emitted_tokens"] == 60 - assert summary["estimated"]["saved_tokens"] == 90 - assert {row["token_counter"] for row in summary["estimated"]["by_token_counter"]} == { - "engraphis.regex.v1", "estimate_tokens" - } - assert summary["period"] == {"from_ts": 99, "to_ts": 121} - all_time = store.context_savings(workspace_id=wid, repo_id=rid) - assert all_time["estimated"]["unclassified_receipt_count"] == 1 - - current = store.context_savings( - workspace_id=wid, repo_id=rid, release_version="1.5.0" - ) - assert current["receipt_count"] == 2 - assert current["usage_receipt_count"] == 2 - assert current["estimated"]["eligible_receipt_count"] == 1 - assert current["estimated"]["saved_tokens"] == 60 - assert current["estimated"]["by_basis"][0]["basis"] == "history_retrieval" - - with pytest.raises(ValueError, match="semantic version"): - store.context_savings(workspace_id=wid, release_version="not-a-release") - - -def test_service_context_savings_filters_and_new_receipts_are_versioned(): - service = MemoryService.create(":memory:", graph_extractor="none") - service.remember("Versioned context delivery.", workspace="versioned", scope="workspace") - service.recall( - "context delivery", - workspace="versioned", - token_budget=32, - response_mode="compact", - intent="recall_context", - ) - receipt = service.receipt_log(workspace="versioned")["entries"][0] - usage = receipt["metadata"]["token_usage"] - assert usage["release_version"] == __version__ - assert usage["savings_basis"] == "packed_context" - assert usage["savings_eligible"] is True - filtered = service.context_savings( - workspace="versioned", release_version=__version__, - from_ts=0, to_ts=9_999_999_999, - ) - assert filtered["estimated"]["eligible_receipt_count"] == 1 - with pytest.raises(ValidationError, match="semantic version"): - service.context_savings(workspace="versioned", release_version="legacy") - - -def test_context_savings_ignores_gateway_copies_and_rejects_noncanonical_estimates(): - store = Store(":memory:") - wid = store.get_or_create_workspace("gateway-savings") - authoritative = _usage(100, 40, counter="engraphis.regex.v1") - store.record_receipt( - "adaptive_context", workspace_id=wid, - metadata={"token_usage": authoritative}, - ) - store.record_receipt( - "smart_gateway", workspace_id=wid, - metadata={"token_usage": authoritative}, - ) - noncanonical = _usage(80, 20, counter="engraphis.regex.v1") - noncanonical["estimated_savings_ratio"] = 0.1 - store.record_receipt( - "adaptive_context", workspace_id=wid, - metadata={"token_usage": noncanonical}, - ) - - summary = store.context_savings(workspace_id=wid) - assert summary["estimated"]["eligible_receipt_count"] == 1 - assert summary["estimated"]["saved_tokens"] == 60 - assert summary["estimated"]["invalid_estimate_count"] == 1 +import pytest + +from engraphis import __version__ +from engraphis.core.savings import SavingsEstimate, annotate_usage, estimate_savings +from engraphis.core.store import Store +from engraphis.service import MemoryService, ValidationError + + +@pytest.mark.parametrize( + ("operation", "intent", "adaptive_mode", "basis", "confidence", "eligible"), + [ + ("adaptive_context", None, "retrieval", "history_retrieval", "high", True), + ("adaptive_context", None, "history_fallback", "history_fallback", "medium", True), + ("adaptive_context", None, "history_bypass", "history_bypass", "none", False), + ( + "adaptive_context", + None, + "low_confidence_abstain", + "low_confidence_abstain", + "none", + False, + ), + ("recall", "recall_context", None, "packed_context", "medium", True), + ("grounded_recall", None, None, "packed_context", "medium", True), + ("proactive_context", None, None, "packed_context", "medium", True), + ("recall", "recall", None, "unclassified", "unknown", False), + ], +) +def test_estimator_classifies_each_delivery_basis( + operation, intent, adaptive_mode, basis, confidence, eligible +): + estimate = estimate_savings( + operation=operation, + intent=intent, + adaptive_mode=adaptive_mode, + baseline_tokens=100, + emitted_tokens=40, + token_counter="engraphis.regex.v1", + release_version="1.5", + ) + + assert isinstance(estimate, SavingsEstimate) + assert estimate.basis == basis + assert estimate.confidence == confidence + assert estimate.eligible is eligible + assert estimate.saved_tokens == (60 if eligible else 0) + assert 0 <= estimate.saved_tokens <= estimate.baseline_tokens + assert 0 <= estimate.savings_ratio <= 1 + assert estimate.release_version == "1.5" + + +def test_estimator_is_conservative_for_bad_counts_and_annotates_existing_usage(): + usage = annotate_usage( + {"source_tokens": 90, "context_tokens": 30, "saved_tokens": 60, + "token_counter": "engraphis.regex.v1"}, + operation="adaptive_context", + adaptive_mode="history_fallback", + baseline_tokens=90, + emitted_tokens=30, + release_version=__version__, + ) + + assert usage["estimated_saved_tokens"] == 60 + assert usage["savings_eligible"] is True + assert usage["release_version"] == "1.5" + abstained = estimate_savings( + operation="adaptive_context", + adaptive_mode="low_confidence_abstain", + baseline_tokens=float("nan"), + emitted_tokens=0, + ) + assert abstained.saved_tokens == 0 + assert abstained.baseline_tokens == 0 + + +def _usage(baseline, emitted, *, counter, release="1.5", eligible=True, + basis="history_retrieval", confidence="high"): + saved = max(0, baseline - emitted) if eligible else 0 + return { + "source_tokens": baseline, + "context_tokens": emitted, + "saved_tokens": saved, + "budget_tokens": baseline, + "packed_count": 1, + "omitted_count": 0, + "token_counter": counter, + "baseline_tokens": baseline, + "emitted_tokens": emitted, + "estimated_saved_tokens": saved, + "estimated_savings_ratio": saved / baseline if baseline else 0.0, + "savings_basis": basis, + "savings_confidence": confidence, + "savings_eligible": eligible, + "release_version": release, + } + + +def test_context_savings_aggregates_estimates_filters_releases_and_counters(): + store = Store(":memory:") + wid = store.get_or_create_workspace("savings") + rid = store.get_or_create_repo(wid, "repo") + first = store.record_receipt( + "adaptive_context", + workspace_id=wid, + repo_id=rid, + metadata={"adaptive_mode": "retrieval", "token_usage": _usage( + 100, 40, counter="engraphis.regex.v1" + )}, + ) + second = store.record_receipt( + "adaptive_context", + workspace_id=wid, + repo_id=rid, + metadata={"adaptive_mode": "history_bypass", "token_usage": _usage( + 80, 80, counter="engraphis.regex.v1", eligible=False, + basis="history_bypass", confidence="none" + )}, + ) + third = store.record_receipt( + "recall", + workspace_id=wid, + repo_id=rid, + metadata={"intent": "recall_context", "token_usage": _usage( + 50, 20, counter="estimate_tokens", release="1.4.0", + basis="packed_context", confidence="medium" + )}, + ) + old = store.record_receipt( + "recall", + workspace_id=wid, + repo_id=rid, + metadata={"intent": "recall_context", "token_usage": { + "source_tokens": 20, "context_tokens": 10, "saved_tokens": 10, + "token_counter": "engraphis.regex.v1", + }}, + ) + for timestamp, receipt in ((100.0, first), (110.0, second), (120.0, third), (130.0, old)): + store.conn.execute( + "UPDATE operation_receipts SET ts=? WHERE id=?", (timestamp, receipt["id"]) + ) + store.conn.commit() + + summary = store.context_savings( + workspace_id=wid, repo_id=rid, from_ts=99, to_ts=121 + ) + assert summary["estimated"]["eligible_receipt_count"] == 2 + assert summary["estimated"]["excluded_receipt_count"] == 1 + assert summary["estimated"]["unclassified_receipt_count"] == 0 + assert summary["estimated"]["baseline_tokens"] == 150 + assert summary["estimated"]["emitted_tokens"] == 60 + assert summary["estimated"]["saved_tokens"] == 90 + assert {row["token_counter"] for row in summary["estimated"]["by_token_counter"]} == { + "engraphis.regex.v1", "estimate_tokens" + } + assert summary["period"] == {"from_ts": 99, "to_ts": 121} + all_time = store.context_savings(workspace_id=wid, repo_id=rid) + assert all_time["estimated"]["unclassified_receipt_count"] == 1 + + current = store.context_savings( + workspace_id=wid, repo_id=rid, release_version="1.5" + ) + assert current["receipt_count"] == 2 + assert current["usage_receipt_count"] == 2 + assert current["estimated"]["eligible_receipt_count"] == 1 + assert current["estimated"]["saved_tokens"] == 60 + assert current["estimated"]["by_basis"][0]["basis"] == "history_retrieval" + + with pytest.raises(ValueError, match="semantic version"): + store.context_savings(workspace_id=wid, release_version="not-a-release") + + +def test_service_context_savings_filters_and_new_receipts_are_versioned(): + service = MemoryService.create(":memory:", graph_extractor="none") + service.remember("Versioned context delivery.", workspace="versioned", scope="workspace") + service.recall( + "context delivery", + workspace="versioned", + token_budget=32, + response_mode="compact", + intent="recall_context", + ) + receipt = service.receipt_log(workspace="versioned")["entries"][0] + usage = receipt["metadata"]["token_usage"] + assert usage["release_version"] == __version__ + assert usage["savings_basis"] == "packed_context" + assert usage["savings_eligible"] is True + filtered = service.context_savings( + workspace="versioned", release_version=__version__, + from_ts=0, to_ts=9_999_999_999, + ) + assert filtered["estimated"]["eligible_receipt_count"] == 1 + with pytest.raises(ValidationError, match="semantic version"): + service.context_savings(workspace="versioned", release_version="legacy") + + +def test_context_savings_ignores_gateway_copies_and_rejects_noncanonical_estimates(): + store = Store(":memory:") + wid = store.get_or_create_workspace("gateway-savings") + authoritative = _usage(100, 40, counter="engraphis.regex.v1") + store.record_receipt( + "adaptive_context", workspace_id=wid, + metadata={"token_usage": authoritative}, + ) + store.record_receipt( + "smart_gateway", workspace_id=wid, + metadata={"token_usage": authoritative}, + ) + noncanonical = _usage(80, 20, counter="engraphis.regex.v1") + noncanonical["estimated_savings_ratio"] = 0.1 + store.record_receipt( + "adaptive_context", workspace_id=wid, + metadata={"token_usage": noncanonical}, + ) + + summary = store.context_savings(workspace_id=wid) + assert summary["estimated"]["eligible_receipt_count"] == 1 + assert summary["estimated"]["saved_tokens"] == 60 + assert summary["estimated"]["invalid_estimate_count"] == 1 diff --git a/tests/test_update_check.py b/tests/test_update_check.py index e48e3490..933bc8bd 100644 --- a/tests/test_update_check.py +++ b/tests/test_update_check.py @@ -1,302 +1,302 @@ -"""Offline tests for the update-reminder module (engraphis.update_check). - -Everything here is deterministic and network-free: version math is pure, and the one -code path that would hit the network (``_fetch``) is either monkeypatched or exercised -only on inputs it rejects *before* opening a socket. -""" -from __future__ import annotations - -import json -import os - -import pytest - -from engraphis import update_check as u - - -# ── pure version math ───────────────────────────────────────────────────────── -@pytest.mark.parametrize("text,expected", [ - ("1.2.3", (1, 2, 3)), - ("v1.2.3", (1, 2, 3)), - (" V2.0 ", (2, 0)), - ("1.2.3-rc1", (1, 2, 3)), - ("1.0.0+build.5", (1, 0, 0)), - ("10.4", (10, 4)), - ("nightly", None), - ("", None), - (None, None), - (123, None), -]) -def test_parse_version(text, expected): - assert u.parse_version(text) == expected - - -@pytest.mark.parametrize("text", [ - "1." + "9" * 1000, - ".".join(["1"] * (u._MAX_VERSION_PARTS + 1)), -]) -def test_parse_version_rejects_pathological_numeric_versions(text): - assert u.parse_version(text) is None - - -@pytest.mark.parametrize("latest,current,newer", [ - ("1.1.0", "1.0.0", True), - ("1.0.1", "1.0.0", True), - ("2.0", "1.9.9", True), - ("1.0.0", "1.0.0", False), # equal is not newer - ("1.0", "1.0.0", False), # zero-padded equal - ("0.9.9", "1.0.0", False), - ("v1.2.0", "1.1.5", True), # tolerates the v prefix on both sides - ("garbage", "1.0.0", False), # unparseable → never newer - ("1.0.0", "garbage", False), -]) -def test_is_newer(latest, current, newer): - assert u.is_newer(latest, current) is newer - - -# ── payload normalization ───────────────────────────────────────────────────── -def test_parse_github_release(): - got = u._parse_release_payload({ - "tag_name": "v1.4.0", "html_url": "https://example/releases/tag/v1.4.0", - "draft": False, "prerelease": False, - }) - assert got == {"version": "v1.4.0", "url": "https://example/releases/tag/v1.4.0"} - - -def test_parse_github_rejects_draft_and_prerelease(): - assert u._parse_release_payload({"tag_name": "v2", "draft": True}) is None - assert u._parse_release_payload({"tag_name": "v2", "prerelease": True}) is None - - -def test_parse_pypi_payload(): - got = u._parse_release_payload({"info": {"version": "1.5.0"}}) - assert got["version"] == "1.5.0" - assert "1.5.0" in got["url"] - - -def test_parse_generic_and_garbage(): - assert u._parse_release_payload({"version": "3.0", "url": "https://x/y"}) == { - "version": "3.0", "url": "https://x/y"} - assert u._parse_release_payload({"nope": 1}) is None - assert u._parse_release_payload("not a dict") is None - - -# ── network guard (no socket opened for a bad scheme/host) ──────────────────── -@pytest.mark.parametrize("url", [ - "http://example.com/releases", # plain http, non-loopback - "ftp://example.com/x", - "file:///etc/passwd", - "https://user@example.com/releases", - "https://[::1/releases", - "https://example.com\\@127.0.0.1/releases", -]) -def test_fetch_rejects_unsafe_schemes(url): - assert u._fetch(url, timeout=0.01) is None - - -def test_fetch_rejects_dns_loopback_alias_before_opening(monkeypatch): - monkeypatch.setattr( - u, "build_pinned_https_opener", - lambda *args, **kwargs: pytest.fail("a DNS alias must not reach an HTTP opener"), - ) - assert u._fetch("http://localhost/latest", timeout=0.01) is None - - -# ── endpoint / explicit opt-in configuration ────────────────────────────────── -def test_endpoint_default_and_overrides(monkeypatch): - monkeypatch.delenv("ENGRAPHIS_UPDATE_URL", raising=False) - monkeypatch.delenv("ENGRAPHIS_UPDATE_REPO", raising=False) - assert u._endpoint() == "https://api.github.com/repos/%s/releases/latest" % u.DEFAULT_REPO - monkeypatch.setenv("ENGRAPHIS_UPDATE_REPO", "acme/thing") - assert u._endpoint().endswith("/repos/acme/thing/releases/latest") - monkeypatch.setenv("ENGRAPHIS_UPDATE_URL", "https://mirror/latest.json") - assert u._endpoint() == "https://mirror/latest.json" # explicit URL wins over repo - - -@pytest.mark.parametrize("value", [ - None, "0", "false", "no", "off", "disable", "disabled", - "treu", "enabled-ish", "2", "random", -]) -def test_unset_false_like_and_misspelled_values_stay_offline(monkeypatch, value): - if value is None: - monkeypatch.delenv("ENGRAPHIS_UPDATE_CHECK", raising=False) - else: - monkeypatch.setenv("ENGRAPHIS_UPDATE_CHECK", value) - assert u.enabled() is False - - # Every non-affirmative value must keep check() from opening a socket. - monkeypatch.setattr(u, "_fetch", lambda *a, **k: pytest.fail("must not hit network")) - snap = u.check() - assert snap == u._disabled_snapshot() - assert u.notice_line(snap) is None - - -@pytest.mark.parametrize("value", ["1", "true", "yes", "on", "enable", "enabled"]) -def test_recognized_explicit_opt_in_values(monkeypatch, value): - monkeypatch.setenv("ENGRAPHIS_UPDATE_CHECK", value) - assert u.enabled() is True - - -# ── cache + snapshot behavior ───────────────────────────────────────────────── -@pytest.fixture -def cache(tmp_path, monkeypatch): - """Isolate the on-disk cache and force checks enabled with a known endpoint.""" - path = tmp_path / "update.json" - monkeypatch.setenv("ENGRAPHIS_UPDATE_CACHE", str(path)) - monkeypatch.setenv("ENGRAPHIS_UPDATE_CHECK", "1") - monkeypatch.setenv("ENGRAPHIS_UPDATE_URL", "https://example.test/latest") - return path - - -def test_check_fetches_writes_cache_and_reports_update(cache, monkeypatch): - monkeypatch.setattr(u, "CURRENT_VERSION", "1.0.0") - monkeypatch.setattr(u, "_fetch", - lambda url, timeout: {"version": "1.4.0", "url": "https://rel/1.4.0"}) - snap = u.check(force=True) - assert snap["update_available"] is True - assert snap["latest"] == "1.4.0" and snap["current"] == "1.0.0" - assert snap["url"] == "https://rel/1.4.0" - # cache persisted - saved = json.loads(cache.read_text()) - assert saved["latest"] == "1.4.0" and saved["checked_at"] > 0 - - -def test_fresh_cache_short_circuits_network(cache, monkeypatch): - monkeypatch.setattr(u, "CURRENT_VERSION", "1.0.0") - u._write_cache("1.3.0", "https://rel/1.3.0") - monkeypatch.setattr(u, "_fetch", lambda *a, **k: pytest.fail("fresh cache must not refetch")) - snap = u.check() # not forced → should use the fresh cache - assert snap["latest"] == "1.3.0" and snap["update_available"] is True - - -def test_upgrade_clears_banner_without_ttl_wait(cache, monkeypatch): - """After the user upgrades, a still-fresh cache whose ``latest`` == installed version - must report no update — update_available is recomputed against the live version.""" - u._write_cache("1.4.0", "https://rel/1.4.0") - monkeypatch.setattr(u, "CURRENT_VERSION", "1.4.0") # simulate the just-installed upgrade - monkeypatch.setattr(u, "_fetch", lambda *a, **k: pytest.fail("no network needed")) - snap = u.check() - assert snap["update_available"] is False - - -def test_fetch_failure_preserves_last_good(cache, monkeypatch): - monkeypatch.setattr(u, "CURRENT_VERSION", "1.0.0") - u._write_cache("1.4.0", "https://rel/1.4.0") - # Expire the cache so check() attempts a refresh, then have the network fail. - stale = json.loads(cache.read_text()) - stale["checked_at"] = 0.0 - cache.write_text(json.dumps(stale)) - monkeypatch.setattr(u, "_fetch", lambda *a, **k: None) - snap = u.check() - assert snap["latest"] == "1.4.0" and snap["update_available"] is True # last good kept - - -def test_unexpected_fetch_failure_is_fail_silent(cache, monkeypatch): - stale = {"latest": "1.4.0", "url": "https://rel/1.4.0", "checked_at": 0.0} - cache.write_text(json.dumps(stale)) - - def fail(*_args, **_kwargs): - raise RuntimeError("provider detail must not escape") - - monkeypatch.setattr(u, "_fetch", fail) - snap = u.check() - - assert snap["latest"] == "1.4.0" - assert snap["error"] == "update check unavailable" - -def test_snapshot_is_non_blocking(cache, monkeypatch): - monkeypatch.setattr(u, "CURRENT_VERSION", "1.0.0") - called = {"bg": False} - monkeypatch.setattr(u, "refresh_in_background", lambda *a, **k: called.__setitem__("bg", True)) - monkeypatch.setattr(u, "_fetch", lambda *a, **k: pytest.fail("snapshot must not fetch inline")) - snap = u.snapshot() # empty cache → returns immediately, schedules a background refresh - assert snap["update_available"] is False - assert called["bg"] is True - - -@pytest.mark.parametrize("checked_at", [ - [1], {"value": 1}, "nan", "inf", "-inf", -]) -def test_malformed_cache_timestamp_is_fail_silent(cache, monkeypatch, checked_at): - cache.write_text(json.dumps({"latest": "2.0.0", "checked_at": checked_at})) - monkeypatch.setattr(u, "refresh_in_background", lambda *args, **kwargs: None) - - snap = u.snapshot() - - assert snap["checked_at"] == 0.0 - - -def test_oversized_cache_is_ignored(cache): - cache.write_text("x" * (u._MAX_CACHE_BYTES + 1)) - assert u._read_cache() == {} - - -def test_linked_cache_is_ignored_and_never_overwrites_target(cache): - victim = cache.with_name("victim.json") - victim.write_text("do not replace") - try: - cache.symlink_to(victim) - except (NotImplementedError, OSError): - try: - os.link(victim, cache) - except OSError: - pytest.skip("this platform cannot create a link for the cache test") - - assert u._read_cache() == {} - u._write_cache("9.9.9", "https://example.test/release") - assert victim.read_text() == "do not replace" - - -def test_notice_line(monkeypatch): - line = u.notice_line({"enabled": True, "update_available": True, - "latest": "1.4.0", "current": "1.0.0", "url": "https://rel/1.4.0"}) - assert "1.4.0" in line and "1.0.0" in line and "pip install -U engraphis" in line - assert u.notice_line({"enabled": True, "update_available": False}) is None - - -def test_cli_notice_uses_the_non_blocking_snapshot_and_is_fail_silent(monkeypatch): - seen = [] - monkeypatch.setenv("ENGRAPHIS_UPDATE_CHECK", "1") - monkeypatch.setattr(u, "snapshot", lambda: { - "enabled": True, "update_available": True, "latest": "1.4.0", "current": "1.0.0", - "url": "https://rel/1.4.0", - }) - monkeypatch.setattr(u, "check", lambda **_kwargs: pytest.fail("CLI must not check inline")) - u.emit_cli_notice(seen.append) - assert seen and "1.4.0" in seen[0] - - monkeypatch.setattr(u, "snapshot", lambda: (_ for _ in ()).throw(RuntimeError("offline"))) - u.emit_cli_notice(seen.append) - assert len(seen) == 1 - - -def test_primary_ledger_renders_the_update_snapshot(): - root = __import__("pathlib").Path(__file__).resolve().parents[1] / "engraphis" / "dashboard_assets" - html = (root / "index.html").read_text(encoding="utf-8") - script = (root / "ledger.js").read_text(encoding="utf-8") - css = (root / "ledger.css").read_text(encoding="utf-8") - assert 'id="update-banner"' in html - assert "renderUpdateBanner(bootstrap.update)" in script - assert "pip install -U engraphis" in script - assert ".update-banner" in css - - -def test_api_update_endpoint(monkeypatch): - pytest.importorskip("fastapi", reason="v2_api requires fastapi (extras)") - from engraphis.routes import v2_api - monkeypatch.setattr(u, "snapshot", - lambda: {"enabled": True, "update_available": True, "latest": "1.4.0"}) - out = v2_api.api_update(force=False) - assert out["update_available"] is True and out["latest"] == "1.4.0" - - -def test_api_update_never_raises(monkeypatch): - pytest.importorskip("fastapi", reason="v2_api requires fastapi (extras)") - from engraphis.routes import v2_api - - def boom(): - raise RuntimeError("nope") - - monkeypatch.setattr(u, "snapshot", boom) - out = v2_api.api_update(force=False) - assert out == {"enabled": False, "update_available": False} +"""Offline tests for the update-reminder module (engraphis.update_check). + +Everything here is deterministic and network-free: version math is pure, and the one +code path that would hit the network (``_fetch``) is either monkeypatched or exercised +only on inputs it rejects *before* opening a socket. +""" +from __future__ import annotations + +import json +import os + +import pytest + +from engraphis import update_check as u + + +# ── pure version math ───────────────────────────────────────────────────────── +@pytest.mark.parametrize("text,expected", [ + ("1.2.3", (1, 2, 3)), + ("v1.2.3", (1, 2, 3)), + (" V2.0 ", (2, 0)), + ("1.2.3-rc1", (1, 2, 3)), + ("1.0.0+build.5", (1, 0, 0)), + ("10.4", (10, 4)), + ("nightly", None), + ("", None), + (None, None), + (123, None), +]) +def test_parse_version(text, expected): + assert u.parse_version(text) == expected + + +@pytest.mark.parametrize("text", [ + "1." + "9" * 1000, + ".".join(["1"] * (u._MAX_VERSION_PARTS + 1)), +]) +def test_parse_version_rejects_pathological_numeric_versions(text): + assert u.parse_version(text) is None + + +@pytest.mark.parametrize("latest,current,newer", [ + ("1.1.0", "1.0.0", True), + ("1.0.1", "1.0.0", True), + ("2.0", "1.9.9", True), + ("1.0.0", "1.0.0", False), # equal is not newer + ("1.0", "1.0.0", False), # zero-padded equal + ("0.9.9", "1.0.0", False), + ("v1.2.0", "1.1.5", True), # tolerates the v prefix on both sides + ("garbage", "1.0.0", False), # unparseable → never newer + ("1.0.0", "garbage", False), +]) +def test_is_newer(latest, current, newer): + assert u.is_newer(latest, current) is newer + + +# ── payload normalization ───────────────────────────────────────────────────── +def test_parse_github_release(): + got = u._parse_release_payload({ + "tag_name": "v1.4.0", "html_url": "https://example/releases/tag/v1.4.0", + "draft": False, "prerelease": False, + }) + assert got == {"version": "v1.4.0", "url": "https://example/releases/tag/v1.4.0"} + + +def test_parse_github_rejects_draft_and_prerelease(): + assert u._parse_release_payload({"tag_name": "v2", "draft": True}) is None + assert u._parse_release_payload({"tag_name": "v2", "prerelease": True}) is None + + +def test_parse_pypi_payload(): + got = u._parse_release_payload({"info": {"version": "1.5"}}) + assert got["version"] == "1.5" + assert "1.5" in got["url"] + + +def test_parse_generic_and_garbage(): + assert u._parse_release_payload({"version": "3.0", "url": "https://x/y"}) == { + "version": "3.0", "url": "https://x/y"} + assert u._parse_release_payload({"nope": 1}) is None + assert u._parse_release_payload("not a dict") is None + + +# ── network guard (no socket opened for a bad scheme/host) ──────────────────── +@pytest.mark.parametrize("url", [ + "http://example.com/releases", # plain http, non-loopback + "ftp://example.com/x", + "file:///etc/passwd", + "https://user@example.com/releases", + "https://[::1/releases", + "https://example.com\\@127.0.0.1/releases", +]) +def test_fetch_rejects_unsafe_schemes(url): + assert u._fetch(url, timeout=0.01) is None + + +def test_fetch_rejects_dns_loopback_alias_before_opening(monkeypatch): + monkeypatch.setattr( + u, "build_pinned_https_opener", + lambda *args, **kwargs: pytest.fail("a DNS alias must not reach an HTTP opener"), + ) + assert u._fetch("http://localhost/latest", timeout=0.01) is None + + +# ── endpoint / explicit opt-in configuration ────────────────────────────────── +def test_endpoint_default_and_overrides(monkeypatch): + monkeypatch.delenv("ENGRAPHIS_UPDATE_URL", raising=False) + monkeypatch.delenv("ENGRAPHIS_UPDATE_REPO", raising=False) + assert u._endpoint() == "https://api.github.com/repos/%s/releases/latest" % u.DEFAULT_REPO + monkeypatch.setenv("ENGRAPHIS_UPDATE_REPO", "acme/thing") + assert u._endpoint().endswith("/repos/acme/thing/releases/latest") + monkeypatch.setenv("ENGRAPHIS_UPDATE_URL", "https://mirror/latest.json") + assert u._endpoint() == "https://mirror/latest.json" # explicit URL wins over repo + + +@pytest.mark.parametrize("value", [ + None, "0", "false", "no", "off", "disable", "disabled", + "treu", "enabled-ish", "2", "random", +]) +def test_unset_false_like_and_misspelled_values_stay_offline(monkeypatch, value): + if value is None: + monkeypatch.delenv("ENGRAPHIS_UPDATE_CHECK", raising=False) + else: + monkeypatch.setenv("ENGRAPHIS_UPDATE_CHECK", value) + assert u.enabled() is False + + # Every non-affirmative value must keep check() from opening a socket. + monkeypatch.setattr(u, "_fetch", lambda *a, **k: pytest.fail("must not hit network")) + snap = u.check() + assert snap == u._disabled_snapshot() + assert u.notice_line(snap) is None + + +@pytest.mark.parametrize("value", ["1", "true", "yes", "on", "enable", "enabled"]) +def test_recognized_explicit_opt_in_values(monkeypatch, value): + monkeypatch.setenv("ENGRAPHIS_UPDATE_CHECK", value) + assert u.enabled() is True + + +# ── cache + snapshot behavior ───────────────────────────────────────────────── +@pytest.fixture +def cache(tmp_path, monkeypatch): + """Isolate the on-disk cache and force checks enabled with a known endpoint.""" + path = tmp_path / "update.json" + monkeypatch.setenv("ENGRAPHIS_UPDATE_CACHE", str(path)) + monkeypatch.setenv("ENGRAPHIS_UPDATE_CHECK", "1") + monkeypatch.setenv("ENGRAPHIS_UPDATE_URL", "https://example.test/latest") + return path + + +def test_check_fetches_writes_cache_and_reports_update(cache, monkeypatch): + monkeypatch.setattr(u, "CURRENT_VERSION", "1.0.0") + monkeypatch.setattr(u, "_fetch", + lambda url, timeout: {"version": "1.4.0", "url": "https://rel/1.4.0"}) + snap = u.check(force=True) + assert snap["update_available"] is True + assert snap["latest"] == "1.4.0" and snap["current"] == "1.0.0" + assert snap["url"] == "https://rel/1.4.0" + # cache persisted + saved = json.loads(cache.read_text()) + assert saved["latest"] == "1.4.0" and saved["checked_at"] > 0 + + +def test_fresh_cache_short_circuits_network(cache, monkeypatch): + monkeypatch.setattr(u, "CURRENT_VERSION", "1.0.0") + u._write_cache("1.3.0", "https://rel/1.3.0") + monkeypatch.setattr(u, "_fetch", lambda *a, **k: pytest.fail("fresh cache must not refetch")) + snap = u.check() # not forced → should use the fresh cache + assert snap["latest"] == "1.3.0" and snap["update_available"] is True + + +def test_upgrade_clears_banner_without_ttl_wait(cache, monkeypatch): + """After the user upgrades, a still-fresh cache whose ``latest`` == installed version + must report no update — update_available is recomputed against the live version.""" + u._write_cache("1.4.0", "https://rel/1.4.0") + monkeypatch.setattr(u, "CURRENT_VERSION", "1.4.0") # simulate the just-installed upgrade + monkeypatch.setattr(u, "_fetch", lambda *a, **k: pytest.fail("no network needed")) + snap = u.check() + assert snap["update_available"] is False + + +def test_fetch_failure_preserves_last_good(cache, monkeypatch): + monkeypatch.setattr(u, "CURRENT_VERSION", "1.0.0") + u._write_cache("1.4.0", "https://rel/1.4.0") + # Expire the cache so check() attempts a refresh, then have the network fail. + stale = json.loads(cache.read_text()) + stale["checked_at"] = 0.0 + cache.write_text(json.dumps(stale)) + monkeypatch.setattr(u, "_fetch", lambda *a, **k: None) + snap = u.check() + assert snap["latest"] == "1.4.0" and snap["update_available"] is True # last good kept + + +def test_unexpected_fetch_failure_is_fail_silent(cache, monkeypatch): + stale = {"latest": "1.4.0", "url": "https://rel/1.4.0", "checked_at": 0.0} + cache.write_text(json.dumps(stale)) + + def fail(*_args, **_kwargs): + raise RuntimeError("provider detail must not escape") + + monkeypatch.setattr(u, "_fetch", fail) + snap = u.check() + + assert snap["latest"] == "1.4.0" + assert snap["error"] == "update check unavailable" + +def test_snapshot_is_non_blocking(cache, monkeypatch): + monkeypatch.setattr(u, "CURRENT_VERSION", "1.0.0") + called = {"bg": False} + monkeypatch.setattr(u, "refresh_in_background", lambda *a, **k: called.__setitem__("bg", True)) + monkeypatch.setattr(u, "_fetch", lambda *a, **k: pytest.fail("snapshot must not fetch inline")) + snap = u.snapshot() # empty cache → returns immediately, schedules a background refresh + assert snap["update_available"] is False + assert called["bg"] is True + + +@pytest.mark.parametrize("checked_at", [ + [1], {"value": 1}, "nan", "inf", "-inf", +]) +def test_malformed_cache_timestamp_is_fail_silent(cache, monkeypatch, checked_at): + cache.write_text(json.dumps({"latest": "2.0.0", "checked_at": checked_at})) + monkeypatch.setattr(u, "refresh_in_background", lambda *args, **kwargs: None) + + snap = u.snapshot() + + assert snap["checked_at"] == 0.0 + + +def test_oversized_cache_is_ignored(cache): + cache.write_text("x" * (u._MAX_CACHE_BYTES + 1)) + assert u._read_cache() == {} + + +def test_linked_cache_is_ignored_and_never_overwrites_target(cache): + victim = cache.with_name("victim.json") + victim.write_text("do not replace") + try: + cache.symlink_to(victim) + except (NotImplementedError, OSError): + try: + os.link(victim, cache) + except OSError: + pytest.skip("this platform cannot create a link for the cache test") + + assert u._read_cache() == {} + u._write_cache("9.9.9", "https://example.test/release") + assert victim.read_text() == "do not replace" + + +def test_notice_line(monkeypatch): + line = u.notice_line({"enabled": True, "update_available": True, + "latest": "1.4.0", "current": "1.0.0", "url": "https://rel/1.4.0"}) + assert "1.4.0" in line and "1.0.0" in line and "pip install -U engraphis" in line + assert u.notice_line({"enabled": True, "update_available": False}) is None + + +def test_cli_notice_uses_the_non_blocking_snapshot_and_is_fail_silent(monkeypatch): + seen = [] + monkeypatch.setenv("ENGRAPHIS_UPDATE_CHECK", "1") + monkeypatch.setattr(u, "snapshot", lambda: { + "enabled": True, "update_available": True, "latest": "1.4.0", "current": "1.0.0", + "url": "https://rel/1.4.0", + }) + monkeypatch.setattr(u, "check", lambda **_kwargs: pytest.fail("CLI must not check inline")) + u.emit_cli_notice(seen.append) + assert seen and "1.4.0" in seen[0] + + monkeypatch.setattr(u, "snapshot", lambda: (_ for _ in ()).throw(RuntimeError("offline"))) + u.emit_cli_notice(seen.append) + assert len(seen) == 1 + + +def test_primary_ledger_renders_the_update_snapshot(): + root = __import__("pathlib").Path(__file__).resolve().parents[1] / "engraphis" / "dashboard_assets" + html = (root / "index.html").read_text(encoding="utf-8") + script = (root / "ledger.js").read_text(encoding="utf-8") + css = (root / "ledger.css").read_text(encoding="utf-8") + assert 'id="update-banner"' in html + assert "renderUpdateBanner(bootstrap.update)" in script + assert "pip install -U engraphis" in script + assert ".update-banner" in css + + +def test_api_update_endpoint(monkeypatch): + pytest.importorskip("fastapi", reason="v2_api requires fastapi (extras)") + from engraphis.routes import v2_api + monkeypatch.setattr(u, "snapshot", + lambda: {"enabled": True, "update_available": True, "latest": "1.4.0"}) + out = v2_api.api_update(force=False) + assert out["update_available"] is True and out["latest"] == "1.4.0" + + +def test_api_update_never_raises(monkeypatch): + pytest.importorskip("fastapi", reason="v2_api requires fastapi (extras)") + from engraphis.routes import v2_api + + def boom(): + raise RuntimeError("nope") + + monkeypatch.setattr(u, "snapshot", boom) + out = v2_api.api_update(force=False) + assert out == {"enabled": False, "update_available": False} From 0f0d3c5c260e3b9cf852962c4be48164cfe37ea0 Mon Sep 17 00:00:00 2001 From: Jaixii Date: Wed, 5 Aug 2026 18:31:47 -0400 Subject: [PATCH 2/2] fix: correct skill-assets.sha256 to match committed file content --- .claude-plugin/skill-assets.sha256 | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.claude-plugin/skill-assets.sha256 b/.claude-plugin/skill-assets.sha256 index 477bf87a..033158ca 100644 --- a/.claude-plugin/skill-assets.sha256 +++ b/.claude-plugin/skill-assets.sha256 @@ -1,5 +1,5 @@ -c9e7c8dd1cf66b8a55b259a2fd21128d71994b9608816b0c540252a36acf5e38 .claude-plugin/marketplace.json -077bc215aa8790e3d6dc2d08ea9fa03ac70cf58ed4afd5f53f0d969254f6fc2e .claude-plugin/plugin.json +d30ad152dcc4c82ce10e7167fdfe67e709358e5f435293939125f2d6cffc5b7e .claude-plugin/marketplace.json +28dcd15a7a186f8cb8a15705f1bd7734086167991c4acc28ec2cfea59a2374ab .claude-plugin/plugin.json 45dd73ca6afdd9e12ecd38c48e4a612b7646c25a07a75a80ca0e68d0e0b85f0e skills/engraphis-memory/references/CONVENTIONS.md 529fff3bdbe73f83209087fd10055fad77c5e5224ad8a9e6b0254052aa50e109 skills/engraphis-memory/references/SCOPING.md b2489b60159655e7e564e234d5aff24ba4d8df7cb82626edeaaaf89264007f85 skills/engraphis-memory/references/TOOLS.md