From 600e23a00cbabc1a8e5d783f66182d78764ad7f5 Mon Sep 17 00:00:00 2001 From: VamikaSinghal Date: Thu, 30 Jul 2026 02:57:35 -0700 Subject: [PATCH 1/6] docs: align repository guidance with shipping product --- .github/workflows/ci.yml | 2 +- README.md | 240 +++++++++++++----- SETUP.md | 5 +- backend/README.md | 20 +- backend/tests/test_ops_readiness_check.py | 49 ++++ docs/APPLE_RELEASE.md | 8 +- docs/ARCHITECTURE.md | 3 +- docs/CONNECTOR_COVERAGE_READINESS.md | 46 +++- docs/DISTRIBUTION.md | 128 +++++----- docs/INSTALLER_AND_UPDATES.md | 71 +++--- docs/OPERATIONAL_READINESS.md | 8 +- docs/PRODUCTION_READINESS.md | 23 +- docs/README.md | 75 ++++++ docs/RELIABILITY_HARDENING.md | 2 +- docs/SOURCE_IMPORTS.md | 26 +- packages/openclaw-cortex-context/package.json | 2 +- scripts/check_docs_current.py | 55 ++++ scripts/ops_readiness_check.py | 11 +- scripts/validate_update_manifest.py | 21 +- sdk/python/pyproject.toml | 4 +- sdk/typescript/package.json | 4 +- site/index.html | 3 +- 22 files changed, 603 insertions(+), 203 deletions(-) create mode 100644 docs/README.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7fab48f0..6e549bcc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -156,7 +156,7 @@ jobs: run: python scripts/check_distribution_site.py - name: Validate site update manifest - run: python scripts/validate_update_manifest.py site/downloads/latest.json + run: python scripts/validate_update_manifest.py --allow-remote-artifacts site/downloads/latest.json obsidian-plugin: name: Obsidian plugin build diff --git a/README.md b/README.md index e3081e23..17b443b5 100644 --- a/README.md +++ b/README.md @@ -6,11 +6,10 @@ ### Your personal operating model for AI: a local, cited model of how you work. -Every AI tool runs on a foundation model of the world. Cortex compiles a **model of you** and serves -it to them. It turns your notes and AI-chat history into a typed, layered, **cited** model of your -voice, preferences, decisions, and the graph of your world, then feeds it to Claude, ChatGPT, Cursor, -and any MCP client. Your tools stop starting from zero and start reasoning with your judgment loaded -in, on hardware you own. +Every AI tool starts from a foundation model of the world and knows nothing about you. Cortex builds a +**model of you** on your own Mac — your voice, preferences, decisions, and the people and projects +around them — and serves it to Claude, ChatGPT, Cursor, and other MCP clients. Your memory is plain +Markdown plus a rebuildable index that you own, and every answer it gives is **cited or withheld**.
@@ -27,42 +26,60 @@ in, on hardware you own.
-![macOS 13+](https://img.shields.io/badge/macOS-13%2B-000000?logo=apple&logoColor=white) +![macOS 13+](https://img.shields.io/badge/macOS-13%2B%20(Apple%20Silicon)-000000?logo=apple&logoColor=white) ![Notarized](https://img.shields.io/badge/Apple-Notarized-555555?logo=apple&logoColor=white) ![SwiftUI](https://img.shields.io/badge/App-SwiftUI-F05138?logo=swift&logoColor=white) -![FastAPI](https://img.shields.io/badge/Engine-FastAPI-009688?logo=fastapi&logoColor=white) +![Python engine](https://img.shields.io/badge/Engine-Python%203.12-3776AB?logo=python&logoColor=white) ![SQLite + sqlite-vec](https://img.shields.io/badge/Storage-SQLite%20%2B%20sqlite--vec-003B57?logo=sqlite&logoColor=white) ![MCP](https://img.shields.io/badge/Protocol-MCP-6E56CF?logo=modelcontextprotocol&logoColor=white) ![Works with Claude](https://img.shields.io/badge/Works%20with-Claude-D97757?logo=claude&logoColor=white) -![Local-first](https://img.shields.io/badge/Local--first-100%25%20on%20your%20Mac-2E7D32?logo=ghostery&logoColor=white) +![On-device retrieval](https://img.shields.io/badge/Retrieval-on--device-2E7D32?logo=ghostery&logoColor=white) --- -## How the operating model works - -Approved memory goes in; a calibrated model of how you operate comes out. Five systems make that real: - -- **It dreams.** A bounded **sleep-time consolidation pass** runs while you are away: it resolves only - contradictions that clear deterministic safety rules (your source memory stays authoritative and - every decision is logged), and pre-warms **verified hot-context packs** so the next agent request is - served from a checked cache, not a cold build. -- **It recalls associatively.** Retrieval walks a trust-aware **knowledge graph** with bounded - multi-hop recall, including a **personalized-PageRank** mode that surfaces what is *connected* to the - query, not just lexically near it. Hops are budgeted and carry provenance. -- **It packs context as a protocol.** The [**Contextual Memory Protocol**](docs/CMP_PROTOCOL.md) fits a - model-calibrated **SMP envelope** with a token-aware knapsack, then runs a **per-session delta - channel** that never re-sends what an agent already holds (enforced invariants, measured savings). - Retrieval fuses BM25, `sqlite-vec` KNN, temporal, and intent, reranks on-device, and **cites or - abstains**. -- **It models judgment.** Deterministic extractors and an LLM condenser build seven typed layers - (voice, preferences, decisions, facts, episodic, entities, topics) into a **whole-person map**. - Agents call `GET /v1/agent-adaptation` to load your calibration brief before they work. That is the - Doppl thesis: a working model of how you operate, so delegation stops meaning re-explanation. -- **It compounds as an asset you own.** Packs are **sha256-addressed and replayable**. The model lives - as plain Markdown plus a rebuildable index on your Mac, vendor-portable, inspectable, and erasable in - one act. Switch assistants and your model comes with you. +## What Cortex does today + +Cortex is a **macOS beta**. It runs a local engine on your Mac that ingests your notes and AI-chat +exports, turns them into typed and cited memory you review, and serves that memory to AI tools over +MCP. + +Concretely, today you can: + +- **Bring in your history.** Drop in a ChatGPT, Claude, Gemini, Notion, Slack, Discord, or Telegram + export — or mbox/eml, `.ics`, `.vcf`, DOCX, Zoom transcripts, browser bookmarks, an X or LinkedIn + archive, and more. See [`docs/SOURCE_IMPORTS.md`](docs/SOURCE_IMPORTS.md). +- **Sync live sources with a key you paste.** GitHub (secretless device-flow sign-in), Slack, Linear, + Jira, Readwise, Raindrop, Zotero, Calendar, Notion, and Obsidian. +- **Review before you remember.** Captures from connected sources land in a Review inbox, grouped into at + most fifteen decisions, where you approve, edit, or archive. Approving records the memory and its audit + event in SQLite, then mirrors it to your Markdown vault. Files you import yourself are treated as trusted + and are usable right away; pass `auto_approve=false` to route them through Review too. +- **Ask and get citations or nothing.** Retrieval fuses BM25, `sqlite-vec` vector KNN, and temporal + signals, reranks on-device, and passes through a cite-or-abstain gate. With no cited evidence it tells + you so rather than guessing. +- **Connect your tools in one click.** Claude Desktop, Cursor, Windsurf, Zed, Cline, Roo Code, VS Code + Copilot, and Claude Code get a Cortex MCP server written into their config. +- **Keep control.** Per-tool tokens are scoped and revocable, secret/email redaction is on by default, + and export, maintenance, and destructive capabilities are **off** until you enable them. + +Ask composes its answers deterministically from cited excerpts — no generative language model is +bundled or called to write prose. Retrieval embeddings run entirely on-device. + +### Current limitations, plainly + +- **Apple Silicon, macOS 13+.** The Swift target is `arm64-apple-macosx13.0`; there is no Intel or + universal build. +- **This beta requires a one-time account sign-in.** The shipped build sets `CortexRequireAccount`, so + first launch shows a sign-in wall. Your memory still lives and is queried locally — the account is + identity plus an optional sync target. There is an "Explore with sample notes" path, but it is not + remembered between launches. +- **Cloud sync is not end-to-end encrypted by default.** Client-side E2EE exists but is opt-in and off, + so anything you sync is readable server-side. See [`docs/E2EE_SYNC_DESIGN.md`](docs/E2EE_SYNC_DESIGN.md). +- **Managed Google / Microsoft / Notion OAuth is implemented but not configured** in this build; those + client IDs ship empty. Use file import or a pasted token instead. +- **PDF text extraction is inactive** in the shipped app — `pypdf` is not bundled. ## The loop @@ -70,7 +87,7 @@ Approved memory goes in; a calibrated model of how you operate comes out. Five s | ① Connect | ② Review | ③ Ask | ④ Control | |:--:|:--:|:--:|:--:| -| Bring in notes, an AI-chat export, or sign in and import your history | Approve what's useful, archive the noise — memory stays trustworthy | Ask with cited answers, or let a connected AI tool retrieve what you approved | Keep reads, saves, exports, and every connection visible and revocable | +| Bring in notes, an AI-chat export, or connect a live source | Approve what's useful, archive the noise — memory stays trustworthy | Ask with cited answers, or let a connected AI tool retrieve what you approved | Keep reads, saves, exports, and every connection visible and revocable | @@ -80,7 +97,7 @@ Approved memory goes in; a calibrated model of how you operate comes out. Five s flowchart LR subgraph SOURCES["Your sources"] A1["Local notes / Obsidian"] - A2["ChatGPT · Claude · Perplexity · Notion"] + A2["ChatGPT · Claude · Notion · Slack"] A3["Files & exports"] end subgraph CORTEX["Cortex: your operating model, on your Mac"] @@ -90,70 +107,170 @@ flowchart LR B3["Context Assembly Engine
(CMP)"] end subgraph TOOLS["Your AI tools"] - C1["Claude Desktop · Cursor
Windsurf · Zed · any MCP client"] + C1["Claude Desktop · Cursor
Windsurf · Zed · Cline · more"] end SOURCES --> B1 --> B2 --> B4 --> B3 --> C1 C1 -. "cited retrieval" .-> B3 ``` -A native **SwiftUI** app bundles a local **FastAPI** engine on `127.0.0.1:8766`. Ingested sources become -typed, layered memory in a **SQLite** store (full-text + `sqlite-vec` vectors) that mirrors to a -human-readable, Obsidian-style vault you own. When a tool asks, the **Contextual Memory Protocol** packs -the smallest cited, model-calibrated context that answers the task. +A native **SwiftUI** app bundles a Python 3.12 interpreter and launches a local engine +(`backend/app/standalone_server.py`, stdlib `http.server`) on `127.0.0.1:8766`, loopback only. Ingested +sources become typed memory in a **SQLite** store — FTS5 full-text plus `sqlite-vec` vectors — that +mirrors to a human-readable, Obsidian-style vault at +`~/Library/Application Support/Cortex/Cortex.vault/`. When a tool asks, the **Contextual Memory +Protocol** packs the smallest cited, model-calibrated context that answers the task. + +The same storage core also runs behind a **FastAPI** app (`backend/app/main.py`) for the hosted +accounts and sync plane. That server is not part of the desktop app's local engine. + +## Inside the operating model + +Approved memory goes in; a calibrated model of how you operate comes out. + +- **It packs context as a protocol.** The [Contextual Memory Protocol](docs/CMP_PROTOCOL.md) fits an + **SMP envelope** calibrated to the consuming model: name your model and the pack is token-budgeted and + filled by a marginal-utility knapsack with MMR diversity rather than a flat greedy fill. A + **per-session delta channel** then tells each agent what changed since its last turn — new, + superseded, evicted — and never re-announces a memory it already holds, an invariant pinned in CI by + [`scripts/context_pack_eval.py`](scripts/context_pack_eval.py). +- **It retrieves and cites, or abstains.** BM25 and `sqlite-vec` vector KNN are fused with temporal + signals by weighted reciprocal rank, diversified by provenance, and reranked on-device with MMR; an + intent retriever backs them up when the lexical and temporal arms come back empty. Embeddings come + from a bundled 256-dimension `model2vec` model — no API key, no network. When nothing carries a + relevant citation, Ask returns that fact instead of an answer. +- **It models judgment.** Deterministic extractors build seven typed layers — `semantic`, `episodic`, + `style`, `decision`, `preference`, `negative`, `procedural` — plus entity and topic indexes, into a + whole-person map. Agents call `GET /v1/agent-adaptation` to load your calibration brief before they + work. +- **It recalls relationally.** Ask expands along direct stored relations (shared entity, same capture). + Agents can opt into **bounded multi-hop recall** over a trust-aware memory graph — depth ≤ 3, + trust-floored, path-strength gated — including a true **personalized-PageRank** mode, via + `search_memory` with `associative=true`. Every hop returns its full path with per-edge kind and weight. + Neither is the default Ask path. +- **It consolidates on request.** A bounded consolidation pass resolves only contradictions that clear + deterministic safety rules, keeps your source memory authoritative, logs every decision, and pre-warms + verified hot-context packs. It runs when invoked (`POST /v1/memory/consolidate` or the + `consolidate_memory` tool) and requires the maintenance permission, which is off by default. +- **It compounds as an asset you own.** Pin a pack and it becomes **sha256-addressed and replayable**, + byte-verified on every read, so an agent can prove exactly what it acted on. Your model lives as plain + Markdown plus an index rebuildable from those files alone — vendor-portable, inspectable, exportable as + a signed bundle, and erasable in one act. Switch assistants and it comes with you. ## Also inside -- **One-click connections.** Write-and-relaunch MCP config for desktop tools, session-import for the - web chat apps, drag-and-drop for exports. See [`docs/MCP_INTEGRATIONS.md`](docs/MCP_INTEGRATIONS.md). -- **Trust controls.** Scoped, revocable per-tool permissions with redaction on by default. See [`docs/TRUST_CONTROLS.md`](docs/TRUST_CONTROLS.md). -- **Optional cloud sync.** An end-to-end-encryption design for multi-device sync, opt-in and account-based; data stays local unless you turn it on. See [`docs/ACCOUNTS_ENCRYPTION_DESIGN.md`](docs/ACCOUNTS_ENCRYPTION_DESIGN.md). +- **One-click connections.** Config-write-and-relaunch for desktop MCP clients, deeplink install for + Cursor and VS Code, and copy-paste HTTP endpoints for LM Studio, Open WebUI, LibreChat, and + AnythingLLM. Web tools (ChatGPT, Claude web, Perplexity, Gemini) connect through a hosted connector + key and need an account. See [`docs/EXTERNAL_INTEGRATIONS.md`](docs/EXTERNAL_INTEGRATIONS.md) and + [`docs/MCP_INTEGRATIONS.md`](docs/MCP_INTEGRATIONS.md). +- **A curated MCP surface.** Tokens minted for your tools carry `read` and `write` scopes and see ten + core tools, including `use_cortex`, `get_context`, `ask_memory`, `search_memory`, and `remember_this`. + Permission checks run on every call, not just in the UI. +- **Trust controls.** Scoped revocable per-tool permissions, redaction on by default, an append-only + audit log, and a tamper-evident hash chain over your history. See + [`docs/TRUST_CONTROLS.md`](docs/TRUST_CONTROLS.md). +- **Portable memory.** A published, test-vectored bundle spec under + [`spec/portable-memory/v2/`](spec/portable-memory/v2). See + [`docs/PORTABLE_MEMORY_PROTOCOL_V2.md`](docs/PORTABLE_MEMORY_PROTOCOL_V2.md). + +## Beyond the Mac app + +| Surface | What it is | Path | +|---|---|---| +| Obsidian plugin | Sync a vault and write cited memory back into it | [`packages/obsidian-cortex-plugin`](packages/obsidian-cortex-plugin) | +| Browser extension | MV3 extension that injects cited context into ChatGPT, Claude, and Notion | [`extension/`](extension) | +| OpenClaw context plugin | Context-engine adapter for OpenClaw | [`packages/openclaw-cortex-context`](packages/openclaw-cortex-context) | +| Python SDK | Dependency-free client for the local API | [`sdk/python`](sdk/python) | +| TypeScript SDK | Dependency-free client for the local API | [`sdk/typescript`](sdk/typescript) | ## Install -1. **[Download the latest DMG →](https://github.com/doppl-tech/releases/releases/latest)** (macOS 13 or later). +1. **[Download the latest DMG →](https://github.com/doppl-tech/releases/releases/latest)** (macOS 13 or + later, Apple Silicon). 2. Open the DMG and drag **Cortex** into Applications. -3. Launch it. It's Developer ID signed and **notarized by Apple**, so it opens with no warning. -4. Point Cortex at a notes folder or import your AI chats, review your first memories, then connect a tool. +3. Launch it. Release builds are Developer ID signed and **notarized by Apple**, so it opens with no + warning. +4. Sign in, then point Cortex at a notes folder or import your AI chats, review your first memories, + and connect a tool. -Cortex checks for updates on its own, so once you're on a recent build, new releases arrive automatically. +Cortex checks the HTTPS release feed for new builds and links you to the current DMG. Installing an +update still uses the normal macOS app-replacement flow. ## Build from source +Requires **Python 3.12** and Xcode command line tools. CI and the packaged runtime both use Python 3.12. + ```bash -# macOS app (SwiftUI) +# macOS app (SwiftUI) — writes macos/build/Cortex.app ./macos/build.sh -# Backend engine + test suite (Python 3.11+) -python3 -m venv .venv && source .venv/bin/activate -pip install -r backend/runtime-requirements.txt +# Backend engine + test suite +python3.12 -m venv .venv && source .venv/bin/activate +python3 -m pip install -r backend/requirements.txt pytest python3 -m pytest backend/tests -# Retrieval-quality gate (deterministic, offline) +# Run the hosted-plane API (FastAPI + uvicorn) at 127.0.0.1:8766 +./scripts/dev_backend.sh + +# Or run the stdlib engine the packaged app actually ships +cd backend && python3 -m app.standalone_server --host 127.0.0.1 --port 8766 + +# Deterministic, offline quality gates (no dependencies needed) python3 scripts/retrieval_eval.py +python3 scripts/adaptation_eval.py ``` -See [`SETUP.md`](SETUP.md) for the full development setup and [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) -for how the pieces fit together. +By default `./macos/build.sh` produces an ad-hoc-signed app with **no** bundled interpreter or +embedding model, and prints an embeddings-fallback warning — good enough to launch and inspect. A full +build needs `CORTEX_BUNDLE_PYTHON=1` and a python.org framework install at +`/Library/Frameworks/Python.framework/Versions/3.12`; release packaging lives in +`macos/package_release.sh`. + +CI runs the full gate set on every push — see [`.github/workflows/ci.yml`](.github/workflows/ci.yml) +for the authoritative command list. [`SETUP.md`](SETUP.md) covers the app-side development loop. + +## Repository layout + +| Path | What lives there | +|---|---| +| `macos/` | The SwiftUI/AppKit app and its build and packaging scripts | +| `backend/app/` | Storage core, retrieval, connectors, MCP tools, and both HTTP servers | +| `backend/tests/` | The test suite (2,400+ tests) | +| `scripts/` | Dev helpers plus the deterministic CI quality gates | +| `packages/`, `sdk/`, `extension/` | Plugins, client SDKs, and the browser extension | +| `docs/` | Architecture, protocol, and release documentation | +| `deploy/`, `site/` | Hosted-plane ops and the static distribution site | +| `capture.py`, `ingest.py`, `mcp_server.py`, `redis_store.py`, `github_store.py`, `ui.py` | **Legacy prototype** at the repo root, kept for reference. Not used by the app; the root `requirements.txt` (Redis, Voyage AI, rumps) belongs to it, not to `backend/`. | ## Documentation | Area | Doc | |---|---| +| Documentation index and status | [`docs/README.md`](docs/README.md) | | System architecture | [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) | | Contextual Memory Protocol | [`docs/CMP_PROTOCOL.md`](docs/CMP_PROTOCOL.md) · [`docs/PORTABLE_MEMORY_PROTOCOL_V2.md`](docs/PORTABLE_MEMORY_PROTOCOL_V2.md) | | The vault format you own | [`docs/LOCAL_VAULT_FORMAT.md`](docs/LOCAL_VAULT_FORMAT.md) | -| Connecting AI tools (MCP) | [`docs/MCP_INTEGRATIONS.md`](docs/MCP_INTEGRATIONS.md) | +| Connecting AI tools | [`docs/EXTERNAL_INTEGRATIONS.md`](docs/EXTERNAL_INTEGRATIONS.md) · [`docs/MCP_INTEGRATIONS.md`](docs/MCP_INTEGRATIONS.md) | | Importing your sources | [`docs/SOURCE_IMPORTS.md`](docs/SOURCE_IMPORTS.md) | | Trust & privacy controls | [`docs/TRUST_CONTROLS.md`](docs/TRUST_CONTROLS.md) | -| Encryption & sync design | [`docs/ACCOUNTS_ENCRYPTION_DESIGN.md`](docs/ACCOUNTS_ENCRYPTION_DESIGN.md) · [`docs/CXE1_WIRE_FORMAT.md`](docs/CXE1_WIRE_FORMAT.md) | -| Install & auto-updates | [`docs/INSTALLER_AND_UPDATES.md`](docs/INSTALLER_AND_UPDATES.md) | +| Sync & encryption | [`docs/E2EE_SYNC_DESIGN.md`](docs/E2EE_SYNC_DESIGN.md) · [`docs/CXE1_WIRE_FORMAT.md`](docs/CXE1_WIRE_FORMAT.md) | +| Install & update checks | [`docs/INSTALLER_AND_UPDATES.md`](docs/INSTALLER_AND_UPDATES.md) | +| Release & distribution | [`docs/DISTRIBUTION.md`](docs/DISTRIBUTION.md) · [`docs/APPLE_RELEASE.md`](docs/APPLE_RELEASE.md) | +| Experimental pairwise twin evaluation | [Overview](https://github.com/trace-cortex/cortex-app/blob/feat/pairwise-twin-eval/docs/PAIRWISE_TWIN_EVALUATION.md) · [Integration guide](https://github.com/trace-cortex/cortex-app/blob/feat/pairwise-twin-eval/docs/PAIRWISE_TWIN_INTEGRATION_GUIDE.md) | ## Privacy -Cortex is local-first: the default experience needs no account and no cloud. Cortex reads a source only -after you connect it, records nothing ambient (no screen, no microphone), and shares context with an AI -tool only within the scoped permission you grant. The optional Cortex Cloud tier (for multi-device sync) -is described honestly in the app and on the site. Questions: **sdoven@uwaterloo.ca** or **vamika_singhal@berkeley.edu**. +Your memory is stored as plain files on your Mac at +`~/Library/Application Support/Cortex/Cortex.vault/`, and ingestion, retrieval, Ask, and the profile all +run locally on the bundled engine — embeddings included. Cortex reads a source only after you connect +it and records nothing ambient: there is no microphone access, and screen capture happens only when you +trigger it. Context goes to an AI tool only within the scoped permission you grant, with redaction on by +default. + +Two things worth being precise about. The current beta build requires a one-time account sign-in for +identity and sync, so it is not account-free — though your memory stays local either way. And synced +content is readable server-side unless you turn on client-side encryption, which is off by default. +Questions: **sdoven@uwaterloo.ca** or **vamika_singhal@berkeley.edu**. ## Affiliations & sponsor @@ -182,8 +299,7 @@ Cortex is released under the **[MIT License](LICENSE)**, free to use, modify, an

-**Cortex is your personal operating model for AI:** the cited, local, portable model of how you work, -that dreams while you rest and calibrates every tool you use. +**Cortex is your personal operating model for AI:** the cited, local, portable model of how you work. The more you bring in, the more your tools act the way you would.
diff --git a/SETUP.md b/SETUP.md index 751cc8ca..e14412ad 100644 --- a/SETUP.md +++ b/SETUP.md @@ -53,13 +53,16 @@ Open Connections & Privacy to manage: For backend development without opening the app: ```bash +python3 -m venv .venv +source .venv/bin/activate +python3 -m pip install -r backend/requirements.txt pytest ./scripts/dev_backend.sh ``` Run the main verification set: ```bash -python3 -W error::ResourceWarning -m unittest discover backend/tests +python3 -W error::ResourceWarning -m pytest backend/tests -q python3 scripts/retrieval_eval.py python3 scripts/adaptation_eval.py ./macos/build.sh diff --git a/backend/README.md b/backend/README.md index 91e2617f..99bb6e0e 100644 --- a/backend/README.md +++ b/backend/README.md @@ -14,7 +14,8 @@ It provides: - user-owned local vault persistence - SQLite/FTS rebuildable local index - full-text search -- optional `sqlite-vec` vector search with offline hash embeddings by default and opt-in OpenAI embeddings +- optional `sqlite-vec` vector search with bundled on-device Model2Vec + embeddings, a deterministic hash fallback, and opt-in OpenAI embeddings - review inbox - capture approve/archive/delete lifecycle - daily review with recommended actions @@ -57,7 +58,15 @@ OPENAI_API_KEY=optional If `ANTHROPIC_API_KEY` is missing, the backend uses a deterministic local extractor so capture/search still work. -If `CORTEX_EMBEDDING_PROVIDER=openai`, Cortex calls OpenAI's embeddings endpoint with the configured model and stores the resulting vectors in the rebuildable SQLite index. Leave the provider as `hash` for fully offline local search. Non-strict OpenAI mode falls back to hash embeddings when the provider is unavailable; set `CORTEX_EMBEDDING_STRICT=1` when indexing should fail instead. Keep `CORTEX_EMBEDDING_DIMENSIONS=384` for the current local sqlite-vec schema. +With no explicit provider override, Cortex uses the bundled on-device Model2Vec +model when it is present and falls back to `cortex-hash-v1` when it is not. +`minishlab/potion-base-8M` produces native 256-dimensional vectors; the +rebuildable sqlite-vec index reconciles its dimensions to the active provider. +Set `CORTEX_EMBEDDING_PROVIDER=hash` for the deterministic development/CI +fallback. If `CORTEX_EMBEDDING_PROVIDER=openai`, Cortex calls OpenAI's embeddings +endpoint with the configured model. Non-strict provider failures fall back to +hash retrieval; set `CORTEX_EMBEDDING_STRICT=1` when failures should stop +indexing instead. ## Core Endpoints @@ -147,7 +156,7 @@ See `docs/LOCAL_VAULT_FORMAT.md` for the full disk layout and recovery contract. ## Battle Tests ```bash -python3 -m unittest discover backend/tests +python3 -m pytest backend/tests -q python3 scripts/backend_beta_smoke.py ``` @@ -162,7 +171,10 @@ python3 scripts/ops_readiness_check.py --require-live ## Hosted Development Notes -These notes are experimental. The first-100 beta does not include hosted accounts, cloud sync, remote recovery, or a 10k-user hosted platform. +These notes cover the hosted plane as well as local development. The current direct build requires +account sign-in and includes optional capture sync; zero-access client encryption is opt-in. That does +not make the service a production-ready 10k-user platform: remote recovery, managed multi-tenant +operations, formal support, and broad observability remain incomplete. The same FastAPI service can be deployed to Render, Fly.io, Railway, or a small VPS for development. For hosted experiments, keep SQLite WAL plus `sqlite-vec` as the primary memory store, add login/API-token auth, encrypted backups, and eventually per-user or per-shard database files. diff --git a/backend/tests/test_ops_readiness_check.py b/backend/tests/test_ops_readiness_check.py index 66652b92..39d26917 100644 --- a/backend/tests/test_ops_readiness_check.py +++ b/backend/tests/test_ops_readiness_check.py @@ -154,6 +154,55 @@ def test_update_manifest_accepts_optional_obsidian_plugin_artifact(self) -> None self.assertEqual(payload["version"], "0.1.0") self.assertEqual({item["kind"] for item in payload["artifacts"]}, {"dmg", "zip", "obsidian-plugin"}) + def test_update_manifest_allows_explicit_https_release_artifacts(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + artifacts = [ + { + "kind": kind, + "filename": filename, + "url": f"https://github.com/trace-cortex/releases/download/v1/{filename}", + "size_bytes": 123, + "sha256": "a" * 64, + } + for kind, filename in ( + ("dmg", "Cortex-1.0.0-1.dmg"), + ("zip", "Cortex-1.0.0-1.app.zip"), + ) + ] + manifest = { + "app": "Cortex", + "bundle_id": "com.cortex.doppl", + "channel": "stable", + "version": "1.0.0", + "build": "1", + "minimum_macos": "13.0", + "released_at": "2026-07-28T00:00:00Z", + "mandatory": False, + "release_notes": [], + "artifacts": artifacts, + } + manifest_path = root / "latest.json" + manifest_path.write_text(json.dumps(manifest), encoding="utf-8") + + with self.assertRaises(FileNotFoundError): + validate_update_manifest(manifest_path) + + payload = validate_update_manifest(manifest_path, allow_remote_artifacts=True) + + self.assertEqual(payload["build"], "1") + + manifest["artifacts"][0]["url"] = "http://downloads.example.test/Cortex-1.0.0-1.dmg" + manifest_path.write_text(json.dumps(manifest), encoding="utf-8") + with self.assertRaises(FileNotFoundError): + validate_update_manifest(manifest_path, allow_remote_artifacts=True) + + manifest["artifacts"][0]["url"] = "https://downloads.example.test/Cortex-1.0.0-1.dmg" + manifest["artifacts"][0]["sha256"] = "not-a-digest" + manifest_path.write_text(json.dumps(manifest), encoding="utf-8") + with self.assertRaises(ValueError): + validate_update_manifest(manifest_path, allow_remote_artifacts=True) + def test_site_match_payload_can_skip_site_for_local_dmg_only_beta(self) -> None: with tempfile.TemporaryDirectory() as tmp: root = Path(tmp) diff --git a/docs/APPLE_RELEASE.md b/docs/APPLE_RELEASE.md index e6fe53e3..556f726c 100644 --- a/docs/APPLE_RELEASE.md +++ b/docs/APPLE_RELEASE.md @@ -8,7 +8,7 @@ a packaging script. |---|---|---| | Packager | `macos/package_app_store.sh` | `macos/package_release.sh` | | Sandbox | Yes (App Sandbox entitlement) | No | -| Cloud account | No | Optional | +| Account / sync | No account | Sign-in required; sync optional | | Outbound HTTPS connectors | No — TLS stripped from the bundle | Yes | | MCP setup | Guided **manual** (no config writes) | **Automatic** config install | | Distribution | App Store Connect review | Signed + notarized + stapled DMG/ZIP | @@ -31,9 +31,9 @@ review-safe build: - All memory stays inside the app's sandbox container. The notarized **Developer-ID DMG** is the full-featured power-user path: no -sandbox, optional cloud, outbound HTTPS connectors available, and automatic MCP -config install. It keeps `_ssl` and is built by `package_release.sh` — that path -is byte-identical to before and is documented in the second half of this file. +sandbox, required account sign-in, optional cloud sync, outbound HTTPS +connectors, and automatic MCP config install. It keeps `_ssl` and is built by +`package_release.sh` — that path is documented in the second half of this file. Every App Store behavior difference is gated behind `DistributionMode.isAppStore` (Swift, reads `CortexDistributionMode == "app-store"` from Info.plist) or the diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 0bb04884..24d0ad81 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -98,7 +98,8 @@ Local vault Rebuildable SQLite index FTS5 sqlite-vec when available - hash or opt-in OpenAI embeddings + bundled Model2Vec embeddings when available + deterministic hash fallback or opt-in OpenAI embeddings normalized joins Release pipeline diff --git a/docs/CONNECTOR_COVERAGE_READINESS.md b/docs/CONNECTOR_COVERAGE_READINESS.md index ed49d624..57ed2e9c 100644 --- a/docs/CONNECTOR_COVERAGE_READINESS.md +++ b/docs/CONNECTOR_COVERAGE_READINESS.md @@ -1,6 +1,15 @@ # Connector Coverage Readiness -This map is the product-facing source coverage plan for the first-100-user beta and the later live OAuth phase. +This map is the product-facing source coverage plan for the first-100-user beta +and later managed-OAuth rollout. + +> **Implementation status (audited 2026-07-30):** Thirteen read-only connector +> modules are wired into the local backend. GitHub device-flow sign-in is +> configured. Managed Google, Microsoft, and Notion OAuth flows are implemented, +> but the direct build ships those client IDs empty, so token/key setup remains +> the available path until release configuration supplies them. The executable +> setup contract in `backend/app/storage.py` overrides older roadmap wording in +> this document. ## Phase Definitions @@ -8,7 +17,12 @@ First-100 beta means local-first connected-source setup first. A supported servi Advanced/Fallback import remains only for unsupported services, migrations, legal exports, and support recovery. It is not the primary product loop and should not be presented as the normal setup path. -Live OAuth means direct cloud sync for services that expose appropriate APIs. Cortex now has the local source-account sync ingestion contract, but source-specific OAuth/sign-in flows still need explicit user consent, least-privilege scopes, durable source account health, reconnect states, sync cursors, rate-limit handling, deletion semantics, and product copy that distinguishes account sync from fallback import. +Live OAuth means direct cloud sync for services that expose appropriate APIs. +The source-account sync contract, GitHub device flow, and managed +Google/Microsoft/Notion OAuth flows are implemented. A release still needs to +provide the latter three providers' client IDs and validate consent, reconnect, +rate-limit, deletion, and source-health behavior before presenting them as +configured sign-in options. ## Readiness Legend @@ -42,10 +56,10 @@ Representative catalog expectations: | ChatGPT | `export-only` | MCP/direct AI-tool bridge where available; fallback export stays Advanced/Fallback. | None. | | Claude | `export-only` | MCP/direct AI-tool bridge where available; fallback export stays Advanced/Fallback. | None. | | Apple Mail | `import-ready` | Planned permissioned local mail integration; fallback `.eml`, `.emlx`, or `.mbox` stays Advanced/Fallback. | None. | -| Gmail | `token-ready` | Backend read-only Gmail sync works when a trusted OAuth access token is already available; managed Google sign-in remains planned. | `gmail.readonly`. | +| Gmail | `token-ready` | Read-only sync and managed Google OAuth are implemented; the current direct build has no Google client ID, so token setup is the available path. | `gmail.readonly`. | | Notion | `token-ready` | Read-only page sync works with an internal integration token shared into selected pages; fallback Markdown/CSV/HTML export stays Advanced/Fallback. | `read_content`. | | Slack | `token-ready` | Read-only selected channel sync works with a bot or user token; fallback workspace export stays Advanced/Fallback. | `channels:history`, `groups:history`, `channels:read`, `groups:read`. | -| GitHub | `token-ready` | Read-only GitHub issue and pull request sync works through source-account sync with a user-supplied token; fallback issue, PR, project, CSV, JSON, Markdown, or text export stays Advanced/Fallback. | `repo:read`. | +| GitHub | `token-ready` | Read-only issue and pull-request sync supports configured GitHub device flow, with a pasted token as fallback. | `repo:read`. | | Linear | `token-ready` | Read-only issue sync works with a personal API key. | `read`. | | Jira | `token-ready` | Read-only issue sync works with a Jira Cloud site URL, Atlassian account email, and API token. | `read:jira-work`. | | Readwise | `token-ready` | Read-only highlight sync works with a user access token. | `read`. | @@ -73,10 +87,10 @@ First-100 launch copy should still treat MCP AI tools and Obsidian/local notes a | Connector | Functional now | Local-only / local-first boundary | Explicitly not promised | | --- | --- | --- | --- | | Obsidian | Scans a user-selected Markdown/text vault, registers a source account, syncs records through Review, preserves file citations, advances cursors, skips unchanged notes, and supersedes edited notes. | Local folder access only after the user selects the vault. This is the first native first-100 connector. | No Obsidian cloud account sync, no remote vault crawl, and no write-back to notes. | -| GitHub | Syncs read-only issues and pull requests from selected repositories with stable GitHub citations and cursor-backed source-account state. | User-supplied read token at sync time; local backend stores source-account/cursor metadata, not a managed OAuth app flow. | No GitHub OAuth install, no writes/comments, no project/discussion coverage promise, and no org-wide discovery promise. | -| Gmail | Syncs read-only messages from Gmail using an explicit access token, query/label filters, pagination, parsed message bodies, and stable message citations. | The backend can consume an access token supplied by a local connector or operator path; managed Google sign-in is not shipped. | No managed Gmail OAuth, no mailbox writes, no broad label policy UI, and no authorship claims without identity aliases. | -| Google Drive | Syncs read-only Google Drive files and exported Google Docs/text/HTML content with stable file citations, pagination, and cursor state. | The backend can consume an access token supplied by a local connector or operator path; unsupported binaries are skipped. | No managed Google OAuth, no Drive writes, no full binary/PDF OCR promise, and no Docs revision UI. | -| Outlook | Syncs read-only Outlook/Microsoft Graph mail messages with parsed bodies, pagination, and stable message citations. | The backend can consume an access token supplied by a local connector or operator path; mail is the current wired slice. | No managed Microsoft OAuth, no Outlook writes, no Teams/OneDrive/contacts coverage in this connector, and no broad tenant administration. | +| GitHub | Syncs read-only issues and pull requests from selected repositories with stable GitHub citations and cursor-backed source-account state. | GitHub device-flow sign-in is configured; a pasted token remains the advanced fallback. | No GitHub App install, no writes/comments, no project/discussion coverage promise, and no org-wide discovery promise. | +| Gmail | Syncs read-only messages from Gmail using an explicit access token, query/label filters, pagination, parsed message bodies, and stable message citations. | Managed Google OAuth is implemented but the direct build ships no Google client ID; token setup remains available. | No mailbox writes, no broad label policy UI, and no authorship claims without identity aliases. | +| Google Drive | Syncs read-only Google Drive files and exported Google Docs/text/HTML content with stable file citations, pagination, and cursor state. | Managed Google OAuth is implemented but the direct build ships no Google client ID; unsupported binaries are skipped. | No Drive writes, no full binary/PDF OCR promise, and no Docs revision UI. | +| Outlook | Syncs read-only Outlook/Microsoft Graph mail messages with parsed bodies, pagination, and stable message citations. | Managed Microsoft OAuth is implemented but the direct build ships no Microsoft client ID; mail is the current wired slice. | No Outlook writes, no Teams/OneDrive/contacts coverage in this connector, and no broad tenant administration. | | Slack | Syncs read-only messages from selected channels with Slack permalinks or stable fallback citations and per-channel cursor state. | User-supplied bot/user token and explicit channel list. | No managed Slack OAuth, no broad workspace crawl, no DM/private-channel promise beyond granted scopes, and no automatic user-authorship attribution without aliases. | | Readwise | Syncs read-only highlights with pagination, source URLs or stable fallback citations, and source-account cursors. | User-supplied Readwise access token. | No OAuth, no write/highlight management, and no guarantee that unsupported Readwise object types sync. | | Calendar | Syncs read-only local `.ics` files or user-provided `.ics` feeds into event records with generated safe citations. | Local file/feed only; private feed URLs are not a product surface. | No Google Calendar/Microsoft OAuth, no system calendar database access, no calendar writes, and no background calendar daemon. | @@ -84,9 +98,13 @@ First-100 launch copy should still treat MCP AI tools and Obsidian/local notes a | Zotero | Syncs read-only items, notes, and annotations through the local desktop API by default, with optional Web API token support and Zotero item citations. | Local API is the default; attachment import is off by default. | No Zotero OAuth, no library writes, and no attachment/PDF content import promise by default. | | Linear | Syncs read-only issues with pagination, source URLs or `linear://` fallback citations, and source-account cursors. | User-supplied Linear API key. | No OAuth, no issue writes/comments, no project/team administration, and no workflow mutation. | | Jira | Syncs read-only Jira Cloud issues with a site URL, account email, API token, optional JQL, stable browse URLs, and cursor state. | User-supplied Atlassian API token; Jira Cloud path only. | No OAuth, no issue writes/transitions, no full project/changelog coverage promise, and no on-prem Jira guarantee. | -| Notion | Syncs read-only pages shared with a Notion internal integration token, preserving page IDs, page URLs, pagination, and optional page content. | User-supplied integration token; only pages shared with the integration are in scope. | No consumer OAuth, no whole-workspace discovery, no writes, and no full block/database fidelity promise. | +| Notion | Syncs read-only pages shared with a Notion integration, preserving page IDs, page URLs, pagination, and optional page content. | Managed Notion OAuth is implemented but the direct build ships no Notion client ID; an integration token remains available. | No whole-workspace discovery beyond granted pages, no writes, and no full block/database fidelity promise. | -Across all thirteen, the current promise is read-only local source-account sync into Review and cited Ask. The checklist explicitly does not promise managed OAuth, secret custody, hosted background workers, hosted deletion/export receipts, team administration, two-way service writes, billing/quotas, or primary first-run UI placement for every connector. +Across all thirteen, the current promise is read-only local source-account sync +into Review and cited Ask. The checklist does not promise that unconfigured +managed OAuth providers work in a distributed build, nor hosted background +workers, team administration, two-way service writes, billing/quotas, or +primary first-run UI placement for every connector. ## Coverage Map @@ -94,11 +112,11 @@ Across all thirteen, the current promise is read-only local source-account sync | --- | --- | --- | --- | --- | | ChatGPT / Claude / MCP tools | Connect local AI tools through MCP so assistants can read approved memory and write source records into Review. | Beta ready through MCP bridge. | Direct account import can stay planned; MCP output is the first useful integration surface. | `chatgpt`, `claude`, `mcp`, and canonical AI-tool sources. | | Obsidian/Markdown | In-app Obsidian vault connector for Markdown/text notes, with review-first source-account sync. | Beta ready for explicit local vault connection. | Local folder connector first; a background watcher can build on the same cursor contract later. | `obsidian`, `knowledge-base`, and `docs`. | -| Gmail | Advanced read-only Gmail connector backed by source-account sync with an explicit access token. | Backend wired; managed OAuth planned; export remains Advanced/Fallback for unsupported setup. | OAuth planned with read-only mail scopes, incremental cursors, reconnect, and label/thread preservation. | `gmail` maps to canonical `email`. | +| Gmail | Advanced read-only Gmail connector backed by source-account sync with an explicit access token. | Backend and managed OAuth flow are wired; the direct build's Google client ID is empty. | Configure and validate the shipped Google OAuth client before making managed sign-in a release promise. | `gmail` maps to canonical `email`. | | Apple Mail | Planned permissioned local mail connector; fallback export stays Advanced/Fallback. | Beta conditional. | Local/import only unless a safe permissioned local integration is added. | `apple-mail` maps to canonical `email`. | -| Outlook | Advanced read-only Outlook mail connector backed by source-account sync with an explicit Microsoft Graph access token. | Backend wired for mail; managed OAuth planned; export remains Advanced/Fallback for unsupported setup. | OAuth planned through Microsoft Graph for broader mail, calendar, contacts, and files. | `outlook` maps to `email`, `calendar`, `contacts`, and `cloud-docs`. | -| Notion | Advanced Notion integration-token connector backed by source-account sync for pages shared with the integration. | Token-ready; primary OAuth is still planned. | OAuth later for consumer-grade sign-in. Preserve page/database IDs and cite page URLs. | `notion`. | -| Google Drive/Docs | Advanced read-only Drive connector backed by source-account sync with an explicit access token; Google Docs export is supported through Drive. | Backend wired for Drive files and exported Docs/text/HTML; managed OAuth planned; standalone Docs OAuth remains planned. | OAuth planned through Drive/Docs read-only scopes with file cursors and revision safety. | `google-drive` and `google-docs` map to `cloud-docs` and `docs`. | +| Outlook | Advanced read-only Outlook mail connector backed by source-account sync with an explicit Microsoft Graph access token. | Backend and managed OAuth flow are wired for mail; the direct build's Microsoft client ID is empty. | Configure the shipped OAuth client before broadening coverage to calendar, contacts, or files. | `outlook` maps to `email`, `calendar`, `contacts`, and `cloud-docs`. | +| Notion | Advanced Notion integration-token connector backed by source-account sync for pages shared with the integration. | Backend and managed OAuth flow are wired; the direct build's Notion client ID is empty. | Configure and validate the shipped Notion OAuth client before making one-click sign-in a release promise. | `notion`. | +| Google Drive/Docs | Advanced read-only Drive connector backed by source-account sync with an explicit access token; Google Docs export is supported through Drive. | Backend and managed OAuth flow are wired; the direct build's Google client ID is empty. | Configure and validate the shipped Google OAuth client; preserve file cursors and revision safety. | `google-drive` and `google-docs` map to `cloud-docs` and `docs`. | | Slack | Advanced read-only channel connector backed by source-account sync with a bot/user token and selected channels. | Token-ready; primary OAuth is still planned. | OAuth later for channels, private channels, and DMs where granted. Requires workspace policy clarity. | `slack`. | | Discord | Planned only if a user-consented history API or connector path becomes product-safe. | Local/import only. | Local/import only until an official user-consented history export or API path is product-safe. | `discord`. | | Calendar | Read-only local `.ics` file/feed connector backed by source-account sync; Google/Microsoft account sign-in remains later. | Local/feed-ready; OAuth planned. | Preserve event UIDs and use generated source-account citations so local paths and private feed URLs do not leak. | `calendar`. | diff --git a/docs/DISTRIBUTION.md b/docs/DISTRIBUTION.md index ca12db8e..b77102ba 100644 --- a/docs/DISTRIBUTION.md +++ b/docs/DISTRIBUTION.md @@ -1,9 +1,12 @@ # Cortex Landing Page and Distribution -This document defines the controlled distribution path for the local-first macOS -beta. For the first 100 testers, the current path is an unnotarized direct -local-beta DMG unless the exact build has completed Developer ID signing, -notarization, and stapling. +This document defines the controlled distribution paths for the local-first +macOS beta. The current download referenced by +`site/downloads/latest.json` is a Developer ID signed, Apple-notarized direct +DMG hosted in `doppl-tech/releases`. An ad-hoc-signed `local-beta` path remains +available for internal QA, but it is not the build described by the public +download page. The direct build requires account sign-in; local retrieval +continues to run on-device and cloud capture sync is optional. ## Goal @@ -16,27 +19,33 @@ Ship a clean beta experience: 5. User completes the local setup loop. 6. User can verify privacy, checksums, and update metadata. -## First-100 Distribution Status +## Current Distribution Status -Current first-100 local-beta distribution is ready for controlled named-tester -invites when the package, static site, clean-profile install, update, rollback, -and support-bundle checks pass for the exact build being shared. +The current direct beta is ready for controlled tester distribution when the +package, static site, clean-profile install, update, rollback, and +support-bundle checks pass for the exact build being shared. -It is not a Gatekeeper-ready public release by default: +For the build named by `site/downloads/latest.json`: -- The local-beta app is ad-hoc signed, not Developer ID signed. -- The local-beta app and DMG are not notarized or stapled. -- The user may need Control-click > Open on first launch. -- Updates are manual DMG downloads and app replacement. +- The app is Developer ID signed and notarized by Apple. +- The normal install path is a double-click launch after copying the app to + Applications. +- Cortex checks the HTTPS update feed and directs users to the current DMG. +- Installation and rollback still use app replacement; there is no background + self-updater. - Rollback is manual app replacement with the local memory folder left in place. -- The site hosts downloads and metadata; it does not provide automatic update - installation. +- GitHub Releases hosts the binaries; the site hosts release metadata, + checksums, and download links. -Do not invite first-100 users from a generic public download page unless the page -clearly says this is a local-first beta, lists the manual install/update path, -links to privacy and checksum information, and avoids promises about -notarization, automatic updates, cloud backup, hosted accounts, or production -support. +This signing state does not make Cortex a broad production service. Public copy +must still identify it as a local-first beta, describe the app-replacement +update path, link privacy and checksum information, and avoid promises about +background updates, cloud backup, production-grade hosted operations, or +production support. + +Internal ad-hoc builds must use a separate handoff that explicitly documents +Control-click > Open. Never use that internal signing language for the current +notarized public DMG. ## Static Site @@ -59,7 +68,7 @@ The hero uses a canvas memory map instead of stock photography. This keeps the p ## Prepare Downloads -After packaging the app: +For an internal ad-hoc QA build: ```bash ./macos/package_release.sh \ @@ -88,8 +97,8 @@ python3 scripts/prepare_distribution_site.py \ This copies the DMG, ZIP, checksums, and update feed into `site/downloads/`. -This can host the first-100 local-beta artifacts, but hosting over HTTPS does -not make the build notarized or automatic-update ready. +This path can host internal QA artifacts, but HTTPS hosting does not change +their signing state or make them automatic-update ready. For a Gatekeeper-ready public direct download release, use the fail-closed packaging path instead: @@ -209,14 +218,16 @@ scripts/check_distribution_site.py --self-test` to exercise both branches. ## GitHub Releases Distribution (free binary hosting in the org) -Committing the DMG/ZIP into `site/downloads/` bloats git (~37 MB of stale -binaries today). The public path is to attach the notarized binaries to a GitHub -Release in the canonical org repo (free hosting) and point `latest.json` at the -Release asset URLs, so the site links to the Release and git stays small. +Committing the DMG/ZIP into `site/downloads/` bloats git. The current public +path attaches notarized binaries to +[`doppl-tech/releases`](https://github.com/doppl-tech/releases/releases) and +points `latest.json` at the Release asset URLs, so the site links to the Release +and git stays small. -The canonical org repo is decided by DECISION 0 in -`docs/REMAINING_LAUNCH_WORK.txt` (recommended: an org repo such as -`doppl-tech/cortex`). Pass it as `--repo OWNER/REPO`. +The canonical source repository is +[`trace-cortex/cortex-app`](https://github.com/trace-cortex/cortex-app); binary +artifacts currently use the separate `doppl-tech/releases` repository. Pass the +binary repository as `--repo OWNER/REPO`. Procedure: @@ -228,7 +239,7 @@ Procedure: CORTEX_NOTARY_PROFILE=cortex-notary \ CORTEX_BUNDLE_PYTHON=1 \ ./macos/package_release.sh --production --channel stable \ - --base-url https://github.com/doppl-tech/cortex/releases/download/v0.1.0-1 + --base-url https://github.com/doppl-tech/releases/releases/download/v0.2.0-52 ``` The `--base-url` can point at the Release download prefix so the release @@ -246,9 +257,9 @@ Procedure: ```bash scripts/publish_release.sh \ - --tag v0.1.0-1 \ - --release-dir outputs/Cortex-0.1.0-1 \ - --repo doppl-tech/cortex + --tag v0.2.0-52 \ + --release-dir outputs/Cortex-0.2.0-52 \ + --repo doppl-tech/releases ``` `scripts/publish_release.sh`: @@ -286,30 +297,23 @@ assets. This is founder task F4d. In a GitHub Actions workflow, grant `permissions: contents: write` to the job and export the token as `GH_TOKEN` so `gh` picks it up. -### Removing the binaries from git (after the first Release) +### Keeping binaries out of git -The stale binaries in `site/downloads/` are intentionally **left in place for -now**: the current `latest.json` still references local files, and removing them -before a real Release exists would break the download feed. The first notarized -Release has not been published yet (it needs F2/F10 — the Developer ID cert and a -notarized DMG). +The first notarized releases have been published and the current +`site/downloads/latest.json` uses GitHub Release URLs. Large DMG and ZIP +artifacts therefore stay out of git. -Once the first notarized Release is published and `latest.json` points at the -Release URLs (step 3 above), remove the binaries from git — the checker now -tolerates absent, Release-hosted artifacts: +For future releases, keep the binaries ignored and verify that the manifest +points at the newly published Release: ```bash -git rm --cached site/downloads/*.dmg site/downloads/*.app.zip -# add site/downloads/*.dmg and site/downloads/*.app.zip to .gitignore python3 scripts/check_distribution_site.py # still green: URLs are Release-hosted ``` Keep `site/downloads/latest.json`, `distribution.json`, and the -`*.checksums.txt` in git. Also update `site/index.html`'s download links to point -at the Release URLs (or at `latest.json`), since the checker validates local HTML -references and those download buttons currently target the local -`downloads/Cortex--.dmg` files. (This repo's site HTML is owned separately; -flag it as part of the same change.) +`*.checksums.txt` in git. Update `site/index.html` and the manifest together so +the visible buttons, checksums, and machine-readable feed identify the same +build. ## Manual Site QA @@ -473,20 +477,23 @@ Positioning: Cortex is not just AI memory. It is your personal operating model for the AI tools you already use. ``` -## Public Distribution Blockers +## Broad-Launch Status -Before broad public distribution: +Completed for the current direct macOS beta: - Apple Developer ID Application certificate - hardened runtime signing - notarized app and DMG - stapled notarization tickets - HTTPS-hosted downloads +- rollback artifacts for the current release + +Remaining before a broad production launch: + - signed update feed or Sparkle appcast - formal privacy policy review - crash/error reporting decision -- support email or feedback form -- rollback plan for broken releases +- formal support and incident-response ownership The local static site is enough for a small free beta. The public site should not promise automatic updates, hosted sync, team accounts, or background capture until those systems exist. @@ -495,12 +502,12 @@ The local static site is enough for a small free beta. The public site should no Do not describe these as available beta capabilities: - automatic app updates or in-app rollback -- hosted accounts, cloud sync, or cloud backup -- live OAuth/API sync for third-party services +- cloud backup or production-grade hosted operations +- preconfigured managed Google, Microsoft, or Notion OAuth in the distributed + build (their client IDs currently ship empty) - remote MCP/OAuth - billing, teams, enterprise policy, or hosted analytics - production incident response or telemetry -- notarized external distribution unless the current build completed Developer ID signing and notarization ## Free Beta Checklist @@ -508,9 +515,10 @@ The beta is ready to share with a small group when: - generated artifacts pass checksum, manifest, and package-readiness verification - the exact hosted DMG matches the verified release directory -- current copy states the build is an unnotarized local beta unless the exact - build completed Developer ID signing and notarization -- the Control-click > Open path has been tested for the unnotarized build +- public copy matches the manifest's version, build, signing state, and artifact + URLs +- normal double-click launch is tested for the notarized public DMG; the + Control-click > Open path is tested only for explicitly internal ad-hoc builds - a user can download and install in under two minutes - the app opens from Applications - first-run setup completes without docs diff --git a/docs/INSTALLER_AND_UPDATES.md b/docs/INSTALLER_AND_UPDATES.md index 150b02a6..6f828b6e 100644 --- a/docs/INSTALLER_AND_UPDATES.md +++ b/docs/INSTALLER_AND_UPDATES.md @@ -1,38 +1,42 @@ # Cortex Installer and Updates -Cortex is currently a local-first macOS beta. The release system should make it easy to create a repeatable app package today while leaving a clean path to signed, notarized, automatic updates later. +Cortex is currently a local-first macOS beta. The current direct-download build +referenced by `site/downloads/latest.json` is Developer ID signed and notarized +by Apple. It requires account sign-in, while memory retrieval remains local and +cloud capture sync is optional. The release system also retains an ad-hoc +`local-beta` track for internal QA. -The first-100 release track is explicitly the unnotarized `local-beta` -track unless a specific build completes the Apple Developer ID flow in -`docs/APPLE_RELEASE.md`. Treat it as a controlled direct beta for named testers, -not as a public macOS distribution. +Signing and notarization are complete for the current public beta artifact. +Cortex checks the release feed for newer builds, but installing or rolling back +still uses normal app replacement rather than a background self-updater. -## Current First-100 Track +## Current Direct Beta Track -Use the current local-beta path for the first 100 testers only when the invite -copy, operator handoff, and support runbook all say the same thing: +Use the direct beta path only when the landing page, release manifest, operator +handoff, and support runbook all describe the exact artifact consistently: - The app is packaged as a DMG and ZIP by `macos/package_release.sh`. -- The app is ad-hoc signed for local verification, not Developer ID signed. -- The app and DMG are not notarized or stapled. -- macOS Gatekeeper may block first launch until the user uses Control-click > - Open. -- Updates and rollback are manual app replacement flows. +- Production direct builds are Developer ID signed and notarized by Apple. +- GitHub Releases hosts the binaries referenced by the HTTPS update feed. +- Cortex can detect a newer build; installation and rollback remain app + replacement flows. - The local memory folder is outside `Cortex.app` and must not be deleted during install, update, or rollback. -- The static site and `latest.json` are release metadata and download plumbing, - not automatic update infrastructure. +- The static site and `latest.json` provide release metadata and download + discovery, not automatic background installation. -If any operator, invite, landing page, or handoff copy describes the current -local-beta build as notarized, auto-updating, production-ready, or broadly -public, the build is a no-go for first-100 invites. +If any operator, invite, landing page, or handoff copy disagrees with the +manifest about signing, notarization, version, build, or artifact URLs, the +release is a no-go. An internal ad-hoc build must explicitly document the +Control-click > Open path and must never reuse the public notarized-build copy. ## Ready And Not Ready -Ready for the first-100 local-beta track: +Ready for the current direct beta track: - repeatable DMG, ZIP, checksum file, `latest.json`, and `BETA_HANDOFF.md` generation +- Developer ID signing, Apple notarization, and GitHub Release hosting - checksum and manifest validation for generated artifacts - manual install from DMG on macOS 13 or newer - manual update by replacing `Cortex.app` @@ -42,15 +46,12 @@ Ready for the first-100 local-beta track: - live packaged-app smoke testing after launch - content-free support bundle generation -Not ready for the current first-100 local-beta track: +Not included in the current direct beta: -- Gatekeeper-ready public distribution -- Developer ID notarization or stapling, unless the exact build completed - `docs/APPLE_RELEASE.md` - automatic background updates or in-app rollback - Sparkle appcast or signed update-feed rollout -- hosted accounts, cloud sync, cloud backup, billing, teams, or production - telemetry +- cloud backup, billing, teams, production telemetry, or production-grade + hosted operations - production incident response for broad external launch ## Current Release Artifacts @@ -147,8 +148,8 @@ will receive. - Invite copy says the build is an unnotarized local beta when Developer ID notarization has not completed. - `BETA_HANDOFF.md`, landing-page copy, and support copy do not promise - automatic updates, hosted accounts, cloud backup, broad OAuth sync, or - production support. + automatic background updates, cloud backup, preconfigured broad OAuth + sync, or production support. 2. Generate or verify the package: ```bash @@ -313,19 +314,23 @@ manual QA. Manual updates are acceptable for early local beta because: - there is no hosted account service yet -- the app is ad-hoc signed in local builds -- automatic updates require a signing/notarization/key-management decision +- internal QA builds may still be ad-hoc signed +- background installation requires a dedicated update framework and separate + update-signing policy - users must retain confidence that their local memory folder is not touched by app replacement ## Production Upgrade Path -Before public distribution: +Completed for the current direct beta: - create an Apple Developer ID Application certificate - sign the app with hardened runtime - notarize the app and DMG - staple notarization tickets - host `latest.json` and artifacts over HTTPS + +Remaining before automatic background updates: + - rotate update feed keys separately from backend API keys - add release rollback policy - decide whether to adopt Sparkle for automatic updates @@ -335,7 +340,7 @@ Sparkle is the likely production path for background update download/install. Th ## Release Checklist - Increment `CFBundleShortVersionString` or `CFBundleVersion`. -- Run `python3 -m unittest discover backend/tests`. +- Run `python3 -m pytest backend/tests -q`. - Run `python3 scripts/retrieval_eval.py`. - Run `python3 scripts/adaptation_eval.py`. - Run `./macos/build.sh`. @@ -376,12 +381,12 @@ or newer user profile: This system does not yet: -- notarize the app - install privileged helpers - perform automatic replacement of the running app - run delta updates - verify update signatures beyond SHA-256 in the feed - provide rollback from inside the app -- provide hosted accounts, cloud backup, live OAuth/API sync, remote MCP/OAuth, billing, teams, or production telemetry +- provide cloud backup, preconfigured managed Google/Microsoft/Notion OAuth, + remote MCP/OAuth, billing, teams, or production telemetry Those are appropriate for the public-beta release track, not the local-first beta package. diff --git a/docs/OPERATIONAL_READINESS.md b/docs/OPERATIONAL_READINESS.md index 4fdaaf04..b044e778 100644 --- a/docs/OPERATIONAL_READINESS.md +++ b/docs/OPERATIONAL_READINESS.md @@ -1,6 +1,7 @@ # Cortex Operational Readiness -This document defines how Cortex should be operated for the local-first macOS beta before a hosted backend exists. +This document defines how Cortex should be operated for the local-first macOS +beta and its supporting hosted account/sync plane. ## Operating Model @@ -11,7 +12,8 @@ Cortex beta operations are local-first: - releases are packaged as DMG and ZIP artifacts - the update feed is a static `latest.json` - support triage starts from a sanitized support bundle -- public incident response is manual until hosted accounts and telemetry exist +- public incident response is manual until production telemetry, alerting, and + formal ownership exist The goal is not to pretend we have production cloud operations. The goal is to make the local beta repeatable, supportable, and recoverable. @@ -201,7 +203,7 @@ Examples: Immediate response: 1. Run `python3 scripts/check_distribution_site.py`. -2. Run `python3 scripts/validate_update_manifest.py site/downloads/latest.json`. +2. Run `python3 scripts/validate_update_manifest.py --allow-remote-artifacts site/downloads/latest.json`. 3. Re-run `python3 scripts/prepare_distribution_site.py`. 4. Re-test the static site locally. 5. Replace hosted files atomically: manifest after artifacts. diff --git a/docs/PRODUCTION_READINESS.md b/docs/PRODUCTION_READINESS.md index 6019c1b5..f7e80ce7 100644 --- a/docs/PRODUCTION_READINESS.md +++ b/docs/PRODUCTION_READINESS.md @@ -2,9 +2,15 @@ ## Current Goal -This repo is now a local-first beta candidate: useful enough for daily dogfooding on macOS, hardened enough to protect local user memory during a controlled beta, and shaped so a hosted backend can replace local SQLite later without changing the client contract. +This repo is now a local-first beta candidate: useful enough for daily +dogfooding on macOS, hardened enough to protect local user memory during a +controlled beta, and supported by a hosted account/sync plane without moving +local retrieval out of the app. -It is not yet a broad public launch or millions-of-users hosted system. That requires hosted auth, a managed multi-tenant database, remote MCP/OAuth, billing/quotas, observability, incident response, and notarized distribution. +It is not yet a broad public launch or millions-of-users hosted system. The +direct macOS beta is signed and notarized, but broad launch still requires +production hardening of hosted auth and storage, remote MCP/OAuth, +billing/quotas, observability, formal support, and incident response. ## Productization Status @@ -34,12 +40,14 @@ The remaining roadmap should stay non-overengineered: - prove daily value with real connected sources, approvals, and cited Ask results; - keep improving connector quality, chunking, reranking, citation paths, and review ergonomics before adding automation; - package and support a controlled macOS beta with repeatable checks, backups, and sanitized support bundles; -- add notarization, hosted downloads, update-feed policy, and formal support before external distribution; -- defer hosted accounts, remote MCP/OAuth, teams, billing, and enterprise controls until the local MCP/Obsidian loop is consistently useful. +- maintain signed/notarized hosted downloads and a verified update feed, then + add formal support before broader external distribution; +- defer broad-scale hosted operations, remote MCP/OAuth, teams, billing, and + enterprise controls until the local memory loop is consistently useful. ## Local Production Guarantees -- User-approved MCP and Obsidian sync only; no background app crawling +- Explicitly connected sources only; no ambient or background app crawling - API-token protected local endpoints - User-owned local vault folder with JSON records and append-only events - Content-free local sync change feed for event ordering, counts, safe metadata, and future hosted materialization planning @@ -77,7 +85,7 @@ The remaining roadmap should stay non-overengineered: ## Pre-Hosted Backend Checklist - Dogfood with at least 1,000 captures across clipboard, notes, ChatGPT, Claude, docs, and meetings -- Run `python3 -m unittest discover backend/tests` before every app package +- Run `python3 -m pytest backend/tests -q` before every app package - Run `python3 scripts/retrieval_eval.py` before every app package - Run `python3 scripts/check_connector_baseline.py` before claiming the 10k baseline connector set is intact - Run `python3 scripts/backend_beta_smoke.py` before inviting beta users; it must pass the Obsidian/MCP -> Review -> Ask loop with sockets blocked @@ -103,7 +111,8 @@ The remaining roadmap should stay non-overengineered: - Verify `python3 scripts/ops_readiness_check.py --refresh-site` passes before inviting testers - Verify the inbox is still understandable with 100+ pending captures - Verify the graph remains useful and responsive with thousands of nodes -- Add app notarization and signed installer before external distribution +- Keep Developer ID signing, notarization, release manifests, and hosted + artifact checks in the release gate - Add a user-facing "Open Vault Folder", "Change Vault Location", and "Restore Backup" flow ## Hosted Backend Requirements diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 00000000..7044d281 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,75 @@ +# Cortex Documentation + +This index separates the current product contract from operator runbooks, +forward-looking designs, and experimental feature branches. It was last audited +against `main` on 2026-07-30. + +## Status Rules + +- **Current** documents describe behavior implemented in `main`. +- **Operations** documents describe how the current macOS beta is built, + verified, distributed, and supported. +- **Design** documents preserve intended architecture or migration paths. They + are not launch promises unless the code and current-product docs agree. +- **Experimental** documents describe work outside `main`. + +When documents disagree, use this order of authority: + +1. executable tests and release manifests; +2. current API and application code; +3. current and operations documents; +4. design documents. + +## Current Product + +| Area | Documentation | +|---|---| +| Architecture | [ARCHITECTURE.md](ARCHITECTURE.md) | +| Product loop and onboarding | [SIMPLE_PRODUCT_LOOP.md](SIMPLE_PRODUCT_LOOP.md) · [FIRST_RUN_ONBOARDING.md](FIRST_RUN_ONBOARDING.md) | +| Local vault | [LOCAL_VAULT_FORMAT.md](LOCAL_VAULT_FORMAT.md) | +| Context protocol | [CMP_PROTOCOL.md](CMP_PROTOCOL.md) · [PORTABLE_MEMORY_PROTOCOL_V2.md](PORTABLE_MEMORY_PROTOCOL_V2.md) | +| Source ingestion | [SOURCE_IMPORTS.md](SOURCE_IMPORTS.md) · [CAPTURE_SURFACES.md](CAPTURE_SURFACES.md) | +| MCP and external tools | [MCP_INTEGRATIONS.md](MCP_INTEGRATIONS.md) · [EXTERNAL_INTEGRATIONS.md](EXTERNAL_INTEGRATIONS.md) | +| Connector readiness | [CONNECTOR_COVERAGE_READINESS.md](CONNECTOR_COVERAGE_READINESS.md) | +| Trust and privacy | [TRUST_CONTROLS.md](TRUST_CONTROLS.md) · [SECURITY_REVIEW.md](SECURITY_REVIEW.md) | +| Optional encrypted sync | [E2EE_SYNC_DESIGN.md](E2EE_SYNC_DESIGN.md) · [CXE1_WIRE_FORMAT.md](CXE1_WIRE_FORMAT.md) | + +## Release and Operations + +| Area | Documentation | +|---|---| +| Install and updates | [INSTALLER_AND_UPDATES.md](INSTALLER_AND_UPDATES.md) | +| Distribution | [DISTRIBUTION.md](DISTRIBUTION.md) | +| Apple release paths | [APPLE_RELEASE.md](APPLE_RELEASE.md) | +| Operational readiness | [OPERATIONAL_READINESS.md](OPERATIONAL_READINESS.md) | +| Production readiness | [PRODUCTION_READINESS.md](PRODUCTION_READINESS.md) | + +The canonical source repository is +[`trace-cortex/cortex-app`](https://github.com/trace-cortex/cortex-app). Current +signed binaries are published through +[`doppl-tech/releases`](https://github.com/doppl-tech/releases/releases); the +committed [`site/downloads/latest.json`](../site/downloads/latest.json) is the +source of truth for the downloadable build and its signing state. + +## Design and Migration Documents + +These preserve future paths and should not be read as proof that a feature is +already enabled: + +- [ACCOUNTS_ENCRYPTION_DESIGN.md](ACCOUNTS_ENCRYPTION_DESIGN.md) +- [PHASE2_SYNC_DESIGN.md](PHASE2_SYNC_DESIGN.md) +- [NATIVE_VAULT_PLAN.md](NATIVE_VAULT_PLAN.md) +- [MEMORY_BACKEND_BLUEPRINT.md](MEMORY_BACKEND_BLUEPRINT.md) +- [SQLITE_VEC_BACKEND_PLAN.md](SQLITE_VEC_BACKEND_PLAN.md) +- [DATA_INGESTION_SURVEY.md](DATA_INGESTION_SURVEY.md) + +## Experimental Work + +Pairwise digital-twin evaluation is implemented and documented on +`feat/pairwise-twin-eval`; it is not part of the current `main` release: + +- [Pairwise evaluation overview](https://github.com/trace-cortex/cortex-app/blob/feat/pairwise-twin-eval/docs/PAIRWISE_TWIN_EVALUATION.md) +- [Pairwise integration guide](https://github.com/trace-cortex/cortex-app/blob/feat/pairwise-twin-eval/docs/PAIRWISE_TWIN_INTEGRATION_GUIDE.md) + +The feature remains experimental until its owner study and production-admission +criteria are satisfied. diff --git a/docs/RELIABILITY_HARDENING.md b/docs/RELIABILITY_HARDENING.md index 0bc4f648..ba9722c3 100644 --- a/docs/RELIABILITY_HARDENING.md +++ b/docs/RELIABILITY_HARDENING.md @@ -97,7 +97,7 @@ Maintenance tools still pass through Trust controls. ## QA Commands ```bash -python3 -m unittest discover backend/tests +python3 -m pytest backend/tests -q python3 scripts/reliability_check.py --base-url http://127.0.0.1:8766 --token "$CORTEX_API_KEY" python3 scripts/first100_live_smoke.py --base-url http://127.0.0.1:8766 python3 scripts/battle_test_http.py --base-url http://127.0.0.1:8766 --token "$CORTEX_API_KEY" diff --git a/docs/SOURCE_IMPORTS.md b/docs/SOURCE_IMPORTS.md index d09461ce..9bd7f383 100644 --- a/docs/SOURCE_IMPORTS.md +++ b/docs/SOURCE_IMPORTS.md @@ -1,5 +1,16 @@ # Source Ingestion +> **Current connector note (audited 2026-07-30):** In addition to the export +> parsers below, the backend now has read-only live connectors for GitHub, +> Gmail, Google Drive, Outlook, Slack, Readwise, Calendar, Raindrop, Zotero, +> Linear, Jira, Notion, and Obsidian. GitHub supports device-flow sign-in. +> Google, Microsoft, and Notion managed OAuth flows exist in code, but the +> direct build currently ships their client IDs empty; those connectors +> therefore use a user-supplied token/key unless a release configures OAuth. +> Where older rollout notes below say those connectors still need to be built, +> this note and the executable `CONNECTOR_SETUP_BLUEPRINTS` contract in +> `backend/app/storage.py` are authoritative. + Cortex source ingestion turns connected services, local app connectors, MCP bridges, and support fallback imports into normalized memory candidates. The first-100-user product should lead with connected paths that can register a source account, stream records through `/v1/source-accounts/{account_id}/sync`, preserve citations, and route new records through Review. Local export/file import still exists as backend infrastructure for tests, migrations, unsupported services, and support recovery. It should not be the normal first-run product path. @@ -122,7 +133,14 @@ Repeated fallback imports are idempotent by content hash and source. If a fallba ## Source Account Registry -Cortex keeps durable local connector state for connected-source sync. The macOS app now includes a first native local connector for Obsidian vaults: the user grants a vault folder once, Cortex scans Markdown/text notes, registers a source account, streams cited records through `/v1/source-accounts/{account_id}/sync`, advances a cursor, and lets Review decide what becomes trusted memory. Other source-specific OAuth/sign-in UI is still implemented connector by connector, but the shared backend contract is in place for local app integrations, MCP bridges, and connector processes. +Cortex keeps durable local connector state for connected-source sync. Obsidian +is the native local-folder path: the user grants a vault folder once, Cortex +scans Markdown/text notes, registers a source account, streams cited records +through `/v1/source-accounts/{account_id}/sync`, advances a cursor, and lets +Review decide what becomes trusted memory. The same contract now backs the +read-only token/API connectors listed in the note above. GitHub device flow is +configured; managed Google, Microsoft, and Notion OAuth needs release-provided +client IDs. Connector capability catalog: @@ -222,7 +240,11 @@ Future source rows should follow the same pattern: connect or authorize the sour ## Current Limits -- The generic source-account sync ingestion endpoint is implemented; branded OAuth/API sign-in flows still need to be built source by source for Gmail, Notion, Slack, Google Chat, Google Drive, Microsoft 365, Teams, Linear, Jira, GitHub, LinkedIn, Twitter/X, Zoom, and browser history. +- The generic source-account sync ingestion endpoint and thirteen read-only + connector modules are implemented. Remaining gaps include configured + Google/Microsoft/Notion OAuth clients, managed OAuth for the other token + connectors, and direct connectors for Google Chat, Teams, LinkedIn, + Twitter/X, Zoom, and browser history. - Advanced/Fallback import remains available for services that cannot connect directly yet. - PDF extraction depends on optional `pypdf`; otherwise the macOS fallback can extract PDFs selected through the app. - Very large exports are capped by record count and per-record character limits, then chunking/reranking should be improved in the next ingestion pass. diff --git a/packages/openclaw-cortex-context/package.json b/packages/openclaw-cortex-context/package.json index cfc6cdff..5d830999 100644 --- a/packages/openclaw-cortex-context/package.json +++ b/packages/openclaw-cortex-context/package.json @@ -41,7 +41,7 @@ }, "repository": { "type": "git", - "url": "https://github.com/doppl-tech/cortex-app.git", + "url": "https://github.com/trace-cortex/cortex-app.git", "directory": "packages/openclaw-cortex-context" }, "peerDependencies": { diff --git a/scripts/check_docs_current.py b/scripts/check_docs_current.py index bec4b866..11549236 100644 --- a/scripts/check_docs_current.py +++ b/scripts/check_docs_current.py @@ -27,6 +27,9 @@ "README.md": ( "Five-tab product flow", "Model, Sources, Review, Ask, Trust", + "pip install -r backend/runtime-requirements.txt", + "new releases arrive automatically", + "checks the signed release feed", ), "docs/CAPTURE_SURFACES.md": ( "Save tab", @@ -53,6 +56,9 @@ "Local-first Cortex beta with installer, update manifest, capture, MCP, and trust controls.", "Connect MCP or an Obsidian/local notes vault", "Those are appropriate for the public-beta release track, not the local-first MVP package.", + "The app and DMG are not notarized or stapled.", + "first-100 release track is explicitly the unnotarized", + "binaries referenced by the signed update feed", ), "docs/OPERATIONAL_READINESS.md": ( "context pack contains stale content", @@ -61,6 +67,12 @@ "docs/DISTRIBUTION.md": ( "Cortex gives ChatGPT, Claude, Cursor, and MCP agents your memory", "personal operating model for agents", + "current path is an unnotarized direct", + "first notarized Release has not been published yet", + "checks the signed update feed", + ), + "docs/PRODUCTION_READINESS.md": ( + "Add app notarization and signed installer", ), "docs/APPLE_RELEASE.md": ( "local-first MCP/vault beta", @@ -74,6 +86,10 @@ "Complete first-run setup with a local vault, MCP or Obsidian connection", "Local-first Cortex beta with bundled backend, capture, MCP, and trust controls.", ), + "site/index.html": ( + "ad-hoc signed for local beta testing", + "not yet notarized for broad public distribution", + ), "macos/Sources/CortexApp.swift": ( "Review Source Import", "Import to Model", @@ -227,6 +243,29 @@ "Capture, Review, Reuse, Return", ) +REQUIRED_SNIPPETS: dict[str, tuple[str, ...]] = { + "README.md": ( + "docs/README.md", + "backend/requirements.txt pytest", + "feat/pairwise-twin-eval/docs/PAIRWISE_TWIN_EVALUATION.md", + ), + "backend/README.md": ( + "bundled on-device Model2Vec", + "cortex-hash-v1", + ), + "docs/ARCHITECTURE.md": ( + "bundled Model2Vec embeddings when available", + ), + "docs/README.md": ( + "trace-cortex/cortex-app", + "site/downloads/latest.json", + "feat/pairwise-twin-eval", + ), + "site/index.html": ( + "Developer ID signed, notarized by Apple", + ), +} + def run_command(command: list[str]) -> dict[str, object]: completed = subprocess.run(command, cwd=ROOT, text=True, capture_output=True) @@ -252,6 +291,21 @@ def phrase_errors() -> list[str]: errors.append(f"{relative_path}: stale phrase still present: {phrase!r}") errors.extend(primary_ui_errors()) errors.extend(scoped_stale_copy_errors()) + errors.extend(required_snippet_errors()) + return errors + + +def required_snippet_errors() -> list[str]: + errors: list[str] = [] + for relative_path, snippets in REQUIRED_SNIPPETS.items(): + path = ROOT / relative_path + if not path.exists(): + errors.append(f"{relative_path}: missing") + continue + text = path.read_text(encoding="utf-8") + for snippet in snippets: + if snippet not in text: + errors.append(f"{relative_path}: required current-doc reference missing: {snippet!r}") return errors @@ -440,6 +494,7 @@ def main() -> None: "site-visible-trust-step", "generated-beta-metadata-language", "user-operator-loop-language", + "required-doc-index", "direct-release-manifest", "mcp-config", ], diff --git a/scripts/ops_readiness_check.py b/scripts/ops_readiness_check.py index 00499faf..c837bcff 100644 --- a/scripts/ops_readiness_check.py +++ b/scripts/ops_readiness_check.py @@ -619,7 +619,16 @@ def main() -> None: {"skipped": True, "reason": "local-dmg-only"}, ) else: - site_manifest_result = run_command(root, [sys.executable, "scripts/validate_update_manifest.py", "site/downloads/latest.json"], timeout=60) + site_manifest_result = run_command( + root, + [ + sys.executable, + "scripts/validate_update_manifest.py", + "--allow-remote-artifacts", + "site/downloads/latest.json", + ], + timeout=60, + ) add_check(checks, "site_update_manifest", site_manifest_result["ok"], "Site update feed validates.", site_manifest_result) strict_package_artifacts = args.include_package or args.require_package_artifacts or release_dir_arg is not None diff --git a/scripts/validate_update_manifest.py b/scripts/validate_update_manifest.py index 68f06775..eb8a0f6c 100755 --- a/scripts/validate_update_manifest.py +++ b/scripts/validate_update_manifest.py @@ -37,7 +37,7 @@ def artifact_path(root: Path, filename: str, url: str) -> Path: return root / filename -def validate(manifest_path: Path) -> dict: +def validate(manifest_path: Path, *, allow_remote_artifacts: bool = False) -> dict: payload = json.loads(manifest_path.read_text(encoding="utf-8")) missing = sorted(REQUIRED_TOP_LEVEL - set(payload)) if missing: @@ -62,8 +62,18 @@ def validate(manifest_path: Path) -> dict: if kind not in {"dmg", "zip", "obsidian-plugin"}: raise ValueError(f"unsupported artifact kind: {kind}") seen_kinds.add(kind) - path = artifact_path(root, str(artifact["filename"]), str(artifact["url"])) + url = str(artifact["url"]) + path = artifact_path(root, str(artifact["filename"]), url) if not path.exists(): + parsed = urlparse(url) + if allow_remote_artifacts and parsed.scheme == "https": + size = int(artifact["size_bytes"]) + digest = str(artifact["sha256"]).lower() + if size <= 0: + raise ValueError(f"size_bytes must be positive for remote artifact: {artifact['filename']}") + if len(digest) != 64 or any(char not in "0123456789abcdef" for char in digest): + raise ValueError(f"sha256 must be a 64-character hexadecimal digest: {artifact['filename']}") + continue raise FileNotFoundError(f"artifact not found: {path}") size = path.stat().st_size if size != int(artifact["size_bytes"]): @@ -80,8 +90,13 @@ def validate(manifest_path: Path) -> dict: def main() -> None: parser = argparse.ArgumentParser(description="Validate a Cortex update manifest against local release artifacts.") parser.add_argument("manifest", type=Path) + parser.add_argument( + "--allow-remote-artifacts", + action="store_true", + help="Allow absent artifacts only when their manifest URL uses HTTPS; schema, size, and digest metadata remain required.", + ) args = parser.parse_args() - payload = validate(args.manifest) + payload = validate(args.manifest, allow_remote_artifacts=args.allow_remote_artifacts) print(json.dumps({"status": "ok", "version": payload["version"], "build": payload["build"], "artifacts": len(payload["artifacts"])}, indent=2)) diff --git a/sdk/python/pyproject.toml b/sdk/python/pyproject.toml index 5f261432..306e28c5 100644 --- a/sdk/python/pyproject.toml +++ b/sdk/python/pyproject.toml @@ -26,8 +26,8 @@ test = ["pytest>=7.0"] [project.urls] Homepage = "https://trydoppl.com" -Repository = "https://github.com/doppl-tech/cortex-app" -Documentation = "https://github.com/doppl-tech/cortex-app/blob/main/docs/EXTERNAL_INTEGRATIONS.md" +Repository = "https://github.com/trace-cortex/cortex-app" +Documentation = "https://github.com/trace-cortex/cortex-app/blob/main/docs/EXTERNAL_INTEGRATIONS.md" [tool.setuptools.packages.find] where = ["."] diff --git a/sdk/typescript/package.json b/sdk/typescript/package.json index 06de3fc0..92ba28c7 100644 --- a/sdk/typescript/package.json +++ b/sdk/typescript/package.json @@ -36,11 +36,11 @@ "homepage": "https://trydoppl.com", "repository": { "type": "git", - "url": "https://github.com/doppl-tech/cortex-app.git", + "url": "https://github.com/trace-cortex/cortex-app.git", "directory": "sdk/typescript" }, "bugs": { - "url": "https://github.com/doppl-tech/cortex-app/issues" + "url": "https://github.com/trace-cortex/cortex-app/issues" }, "publishConfig": { "access": "public" diff --git a/site/index.html b/site/index.html index d45a63d7..13f2f860 100644 --- a/site/index.html +++ b/site/index.html @@ -159,7 +159,8 @@

Reliability report

Distribution

Download the local macOS beta.

- This build is for early testers who want local AI memory today. It is ad-hoc signed for local beta testing and not yet notarized for broad public distribution. + This build is for early testers who want local AI memory today. The current DMG is Developer ID signed, notarized by Apple, and distributed through GitHub Releases. + Cortex remains a local-first beta; cloud backup and production support are not yet part of this release. For support or deletion questions, email support@trydoppl.com.

From 8eedd5ceab5c1410c714a0c1ee19f7b1a25864e5 Mon Sep 17 00:00:00 2001 From: VamikaSinghal Date: Thu, 30 Jul 2026 03:44:14 -0700 Subject: [PATCH 2/6] chore: harden open-source readiness --- .github/ISSUE_TEMPLATE/bug_report.yml | 62 +++++++++ .github/ISSUE_TEMPLATE/config.yml | 8 ++ .github/ISSUE_TEMPLATE/feature_request.yml | 38 ++++++ .github/PULL_REQUEST_TEMPLATE.md | 34 +++++ .github/workflows/ci.yml | 62 ++++++++- .gitignore | 1 + .python-version | 1 + CHANGELOG.md | 54 ++++++++ CODE_OF_CONDUCT.md | 35 ++++++ CONTRIBUTING.md | 112 +++++++++++++++++ Makefile | 45 +++++++ README.md | 56 +++++++-- ROADMAP.md | 47 +++++++ SECURITY.md | 58 +++++++++ SETUP.md | 39 ++++-- SUPPORT.md | 41 ++++++ backend/app/connector_policy.py | 52 ++++++++ backend/app/main.py | 87 +++++++++++++ backend/app/sharding.py | 1 + backend/app/storage.py | 103 +++++++++++++++ backend/tests/test_hosted_trust_boundaries.py | 118 ++++++++++++++++++ .../tests/test_macos_ui_quality_contract.py | 9 +- backend/tests/test_rerank_eval.py | 6 +- deploy/README.md | 32 +++-- deploy/backup.sh | 40 +++--- deploy/bootstrap.sh | 29 ++++- deploy/cortex.env.example | 16 ++- deploy/macmini/README.md | 23 ++-- deploy/macmini/setup.sh | 12 +- docs/APPLE_RELEASE.md | 12 +- docs/ARCHITECTURE.md | 104 ++++++++++++--- docs/BENCHMARKS.md | 66 ++++++++++ docs/FAQ.md | 77 ++++++++++++ docs/INSTALLER_AND_UPDATES.md | 4 +- docs/OPEN_SOURCE_READINESS.md | 90 +++++++++++++ docs/PHASE2_SYNC_DESIGN.md | 6 +- docs/README.md | 3 + docs/SECURITY_REVIEW.md | 66 +++++++++- docs/TROUBLESHOOTING.md | 89 +++++++++++++ examples/README.md | 76 +++++++++++ examples/memory_workflow.py | 34 +++++ examples/minimal_search.py | 23 ++++ examples/multi_agent_context.py | 39 ++++++ examples/tool_catalog.py | 29 +++++ requirements-dev.lock | 37 ++++++ scripts/bootstrap_dev.sh | 58 +++++++++ scripts/check_docs_current.py | 3 +- scripts/dev_backend.sh | 23 +++- site/faq.html | 11 +- site/privacy.html | 10 +- site/terms.html | 13 +- 51 files changed, 1969 insertions(+), 125 deletions(-) create mode 100644 .github/ISSUE_TEMPLATE/bug_report.yml create mode 100644 .github/ISSUE_TEMPLATE/config.yml create mode 100644 .github/ISSUE_TEMPLATE/feature_request.yml create mode 100644 .github/PULL_REQUEST_TEMPLATE.md create mode 100644 .python-version create mode 100644 CHANGELOG.md create mode 100644 CODE_OF_CONDUCT.md create mode 100644 CONTRIBUTING.md create mode 100644 Makefile create mode 100644 ROADMAP.md create mode 100644 SECURITY.md create mode 100644 SUPPORT.md create mode 100644 backend/app/connector_policy.py create mode 100644 backend/tests/test_hosted_trust_boundaries.py create mode 100644 docs/BENCHMARKS.md create mode 100644 docs/FAQ.md create mode 100644 docs/OPEN_SOURCE_READINESS.md create mode 100644 docs/TROUBLESHOOTING.md create mode 100644 examples/README.md create mode 100644 examples/memory_workflow.py create mode 100644 examples/minimal_search.py create mode 100644 examples/multi_agent_context.py create mode 100644 examples/tool_catalog.py create mode 100644 requirements-dev.lock create mode 100755 scripts/bootstrap_dev.sh diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 00000000..85878388 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,62 @@ +name: Bug report +description: Report a reproducible problem in Cortex. +title: "[Bug]: " +body: + - type: markdown + attributes: + value: | + Thanks for helping improve Cortex. Do not include private memory, vaults, exports, credentials, or tokens. + Security issues belong in the private process described in SECURITY.md. + - type: input + id: version + attributes: + label: Cortex version or commit + placeholder: "0.2.0 build 51, or commit SHA" + validations: + required: true + - type: input + id: environment + attributes: + label: Environment + description: macOS version, architecture, and installation method. + placeholder: "macOS 15.5, Apple Silicon, downloaded DMG" + validations: + required: true + - type: textarea + id: steps + attributes: + label: Reproduction steps + description: Use synthetic data and the smallest deterministic reproduction. + placeholder: | + 1. Start Cortex… + 2. Connect… + 3. Observe… + validations: + required: true + - type: textarea + id: expected + attributes: + label: Expected behavior + validations: + required: true + - type: textarea + id: actual + attributes: + label: Actual behavior + validations: + required: true + - type: textarea + id: diagnostics + attributes: + label: Sanitized diagnostics + description: Paste only content-free logs or support-bundle excerpts you reviewed. + render: text + - type: checkboxes + id: checks + attributes: + label: Safety checklist + options: + - label: I removed private memory, credentials, tokens, and personal identifiers. + required: true + - label: This is not a security vulnerability. + required: true diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 00000000..fa019b4e --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,8 @@ +blank_issues_enabled: false +contact_links: + - name: Security report + url: https://github.com/trace-cortex/cortex-app/security/policy + about: Report vulnerabilities privately; do not open a public issue. + - name: Support guide + url: https://github.com/trace-cortex/cortex-app/blob/main/SUPPORT.md + about: Choose the right support path and learn what diagnostics are safe to share. diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 00000000..c29c207a --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,38 @@ +name: Feature request +description: Propose a focused improvement backed by a real use case. +title: "[Feature]: " +body: + - type: textarea + id: problem + attributes: + label: Problem + description: What job is difficult today, and for whom? + validations: + required: true + - type: textarea + id: outcome + attributes: + label: Desired outcome + description: Describe success without prescribing an implementation. + validations: + required: true + - type: textarea + id: evidence + attributes: + label: Evidence or example + description: Use synthetic data; never paste private memory or credentials. + validations: + required: true + - type: textarea + id: alternatives + attributes: + label: Alternatives considered + - type: checkboxes + id: principles + attributes: + label: Product principles + options: + - label: This preserves explicit consent and local-first defaults. + required: true + - label: This does not require Cortex to produce uncited personal claims. + required: true diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 00000000..8375d5bc --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,34 @@ +## What changed + + + +## Why + + + +## Validation + +- [ ] Added or updated behavioral tests where appropriate +- [ ] Ran `make check` +- [ ] Ran the relevant package/app-specific checks +- [ ] Updated current-product documentation for public behavior changes + +Commands and results: + +```text +paste concise evidence here +``` + +## Risk and rollback + + + +## Privacy and security + +- [ ] No real memory, exports, credentials, tokens, or private identifiers are included +- [ ] Capability and authorization checks remain enforced at execution boundaries +- [ ] Not applicable + +## Screenshots + + diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6e549bcc..a1c760f8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,13 +31,13 @@ jobs: - name: Install backend test dependencies run: | python -m pip install --upgrade pip - python -m pip install -r backend/requirements.txt - python -m pip install pytest + python -m pip install -r requirements-dev.lock - name: Compile Python sources run: | python -m compileall -q \ backend \ + examples \ scripts \ capture.py \ github_store.py \ @@ -53,6 +53,9 @@ jobs: # behave correctly under pytest. run: python -m pytest backend/tests -q + - name: Run Python SDK tests + run: python -m pytest sdk/python/tests -q + - name: Run retrieval quality regression gate run: python scripts/retrieval_eval.py @@ -133,7 +136,7 @@ jobs: run: bandit -c .bandit.yaml -r backend scripts -ll - name: Audit backend dependencies for known vulnerabilities - run: pip-audit --strict -r backend/requirements.txt + run: pip-audit --strict -r requirements-dev.lock - name: Audit top-level dependencies for known vulnerabilities run: pip-audit --strict -r requirements.txt @@ -177,6 +180,59 @@ jobs: - name: Typecheck and build Obsidian plugin run: scripts/check_obsidian_plugin.sh + javascript-clients: + name: ${{ matrix.name }} + runs-on: ubuntu-latest + timeout-minutes: 10 + strategy: + fail-fast: false + matrix: + include: + - name: TypeScript SDK + directory: sdk/typescript + lockfile: sdk/typescript/package-lock.json + release_gate: npm pack --dry-run + - name: OpenClaw context plugin + directory: packages/openclaw-cortex-context + lockfile: packages/openclaw-cortex-context/package-lock.json + release_gate: npm run release:gate + + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Set up Node + uses: actions/setup-node@v4 + with: + node-version: "22" + cache: npm + cache-dependency-path: ${{ matrix.lockfile }} + + - name: Install dependencies + working-directory: ${{ matrix.directory }} + run: npm ci + + - name: Typecheck + working-directory: ${{ matrix.directory }} + run: npm run typecheck + + - name: Test + working-directory: ${{ matrix.directory }} + run: npm test + + - name: Build + working-directory: ${{ matrix.directory }} + run: npm run build + + - name: Validate release package + working-directory: ${{ matrix.directory }} + run: ${{ matrix.release_gate }} + macos-app: name: macOS app build runs-on: macos-latest diff --git a/.gitignore b/.gitignore index 76fe7f3a..793dfbef 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ .env .context/ venv/ +.venv/ __pycache__/ *.pyc .DS_Store diff --git a/.python-version b/.python-version new file mode 100644 index 00000000..e4fba218 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.12 diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..dbbb847d --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,54 @@ +# Changelog + +Notable user-facing and contributor-facing changes are recorded here. Cortex is +in beta; compatibility guarantees will tighten as the public API stabilizes. + +The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). + +## [Unreleased] + +### Added + +- Reproducible Python 3.12 contributor bootstrap and root `make` commands. +- Open-source contribution, security, support, conduct, roadmap, and issue/PR + guidance. +- Runnable Python SDK examples for search, memory workflows, multi-agent + context, and tool discovery. +- Reproducible benchmark documentation with metrics, commands, and explicit + limitations. +- CI gates for the Python and TypeScript SDKs and the OpenClaw context plugin. + +### Changed + +- Repository documentation now distinguishes implemented capabilities from + release-configured capabilities. +- SDK and plugin metadata points to `trace-cortex/cortex-app`. +- Hosted deployment, Apple release, account, offline, update, and legal + documentation now matches the behavior in code. + +### Security + +- Hosted runtimes now reject local path imports, file-backed connectors, and + untrusted connector origins at both HTTP and storage execution boundaries. +- Hosted capture rejects secrets and content in query strings. +- Deployment backups are encrypted with `age`, exclude environment secrets, + use restrictive filesystem permissions, and fail closed without a recipient. +- Public deployment examples default to verified email and disabled automatic + account verification. + +## [0.2.0] - 2026-07-24 + +### Added + +- Developer ID signed and Apple-notarized direct-download beta. +- Local cited memory retrieval, Review, Ask, MCP integrations, connector + contracts, local vault recovery, and support diagnostics. +- Python and TypeScript SDK source packages. + +### Security + +- Scoped MCP capabilities, default redaction, audit trails, and tamper-evident + memory history. + +[Unreleased]: https://github.com/trace-cortex/cortex-app/compare/v0.2.0-51...HEAD +[0.2.0]: https://github.com/trace-cortex/cortex-app/releases/tag/v0.2.0-51 diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 00000000..adca11ad --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,35 @@ +# Code of Conduct + +Cortex contributors are expected to make the project welcoming, rigorous, and +safe for people with different backgrounds and levels of experience. + +## Expected Behavior + +- Be respectful, specific, and constructive. +- Critique ideas and code, not people. +- Assume good intent while still naming concrete risks. +- Make room for newcomers and explain project-specific context. +- Protect user privacy. Never post another person's memory, credentials, or + identifying data. +- Accept maintainer decisions about scope without harassment or repeated + pressure. + +## Unacceptable Behavior + +Harassment, discrimination, threats, sexualized conduct, deliberate +misgendering, doxxing, sustained disruption, or publishing private information +without permission are not accepted in project spaces. + +## Reporting + +For conduct concerns, email **support@trydoppl.com** with the subject +`Code of Conduct`. Include links or screenshots when safe to do so. Do not +include unrelated private user data. + +Maintainers may edit or remove content, close discussions, restrict +participation, or ban contributors when needed to protect the community. Reports +will be handled as privately as the circumstances allow. Retaliation against a +reporter or participant in a review is prohibited. + +This policy applies to repository discussions, pull requests, issues, community +spaces, and public interactions where someone is representing Cortex. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..277c90e5 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,112 @@ +# Contributing to Cortex + +Thank you for helping make personal AI memory more trustworthy, portable, and +useful. This guide is the shortest path from a fresh clone to a reviewable pull +request. + +## Before You Start + +- Use an issue for changes that alter public behavior, storage formats, security + boundaries, or APIs. +- Keep pull requests focused. Separate refactors from behavior changes. +- Never commit real memory, exports, tokens, vaults, support bundles, or user + identifiers. +- Report vulnerabilities privately through [SECURITY.md](SECURITY.md). + +## Development Setup + +Requirements: + +- Python 3.12 +- Xcode command line tools for the macOS app +- Node.js 22 for the Obsidian and OpenClaw packages + +```bash +git clone https://github.com/trace-cortex/cortex-app.git +cd cortex-app +make setup +make check +``` + +`make setup` creates `.venv` and fails with an actionable message if the chosen +interpreter is not Python 3.12. It installs the exact versions in +`requirements-dev.lock`; dependency updates should refresh that file +intentionally. Use an explicit interpreter when necessary: + +```bash +make setup PYTHON=/absolute/path/to/python3.12 +``` + +Start the hosted-plane development API: + +```bash +make run +``` + +For the macOS app: + +```bash +./macos/build.sh +open macos/build/Cortex.app +``` + +The default app build is a development bundle without the release interpreter +or embedding model. See [SETUP.md](SETUP.md) and +[docs/APPLE_RELEASE.md](docs/APPLE_RELEASE.md) for release-like builds. + +## What to Test + +Run the smallest relevant set while developing, then the pre-PR gate: + +```bash +make test +make connector-check +make check +``` + +Additional surfaces: + +```bash +# Python SDK +.venv/bin/python -m pytest sdk/python/tests -q + +# TypeScript SDK +cd sdk/typescript && npm ci && npm run typecheck && npm test + +# Obsidian plugin +scripts/check_obsidian_plugin.sh + +# macOS app +./macos/build.sh +codesign --verify --deep --strict --verbose=2 macos/build/Cortex.app +``` + +CI is authoritative. If a check is too expensive or platform-specific to run +locally, say exactly what you did run in the pull request. + +## Engineering Expectations + +- Preserve local-first defaults and cite-or-abstain behavior. +- Treat the vault as authoritative and SQLite/vector indexes as rebuildable. +- Keep local and hosted HTTP surfaces behaviorally aligned. +- Add behavioral tests for bug fixes; avoid tests that only snapshot + implementation details. +- Keep capability checks at the execution boundary, not only in the UI. +- Use temporary vaults and databases in tests. +- Update the README or current-product docs when public behavior changes. +- Label forward-looking designs as designs rather than shipped behavior. + +Architecture and trust boundaries are documented in +[docs/ARCHITECTURE.md](docs/ARCHITECTURE.md), +[docs/LOCAL_VAULT_FORMAT.md](docs/LOCAL_VAULT_FORMAT.md), and +[docs/TRUST_CONTROLS.md](docs/TRUST_CONTROLS.md). + +## Pull Requests + +1. Branch from current `main`. +2. Make one coherent change with tests and docs. +3. Run the relevant checks. +4. Complete the pull-request template with evidence and risk notes. +5. Respond to review without force-pushing away useful review history. + +By participating, you agree to follow [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md). diff --git a/Makefile b/Makefile new file mode 100644 index 00000000..2166403a --- /dev/null +++ b/Makefile @@ -0,0 +1,45 @@ +PYTHON ?= python3.12 +VENV ?= .venv +VENV_PYTHON := $(VENV)/bin/python + +.PHONY: help setup run test check docs-check connector-check clean + +help: + @printf '%s\n' \ + 'Cortex contributor commands:' \ + ' make setup Create .venv with Python 3.12 and install test dependencies' \ + ' make run Start the FastAPI development server on 127.0.0.1:8766' \ + ' make test Run the backend test suite' \ + ' make check Run the fast local pre-PR checks' \ + ' make connector-check Validate all 13 connector contracts' \ + ' make clean Remove local Python caches (keeps .venv)' + +setup: + PYTHON_BIN="$(PYTHON)" ./scripts/bootstrap_dev.sh + +run: + @test -x "$(VENV_PYTHON)" || { echo "Missing $(VENV_PYTHON). Run 'make setup' first." >&2; exit 1; } + CORTEX_PYTHON="$(abspath $(VENV_PYTHON))" ./scripts/dev_backend.sh + +test: + @test -x "$(VENV_PYTHON)" || { echo "Missing $(VENV_PYTHON). Run 'make setup' first." >&2; exit 1; } + "$(VENV_PYTHON)" -m pytest backend/tests -q + +docs-check: + @test -x "$(VENV_PYTHON)" || { echo "Missing $(VENV_PYTHON). Run 'make setup' first." >&2; exit 1; } + "$(VENV_PYTHON)" scripts/check_docs_current.py + "$(VENV_PYTHON)" scripts/check_distribution_site.py + "$(VENV_PYTHON)" scripts/validate_update_manifest.py --allow-remote-artifacts site/downloads/latest.json + +connector-check: + @test -x "$(VENV_PYTHON)" || { echo "Missing $(VENV_PYTHON). Run 'make setup' first." >&2; exit 1; } + "$(VENV_PYTHON)" scripts/check_connector_baseline.py + +check: docs-check + "$(VENV_PYTHON)" scripts/retrieval_eval.py + "$(VENV_PYTHON)" scripts/adaptation_eval.py + "$(VENV_PYTHON)" -m pytest sdk/python/tests -q + +clean: + find backend scripts sdk -type d -name __pycache__ -prune -exec rm -r {} + + find backend scripts sdk -type f -name '*.pyc' -delete diff --git a/README.md b/README.md index 17b443b5..5b37f4fe 100644 --- a/README.md +++ b/README.md @@ -196,21 +196,50 @@ Approved memory goes in; a calibrated model of how you operate comes out. Cortex checks the HTTPS release feed for new builds and links you to the current DMG. Installing an update still uses the normal macOS app-replacement flow. +## Five-minute developer quickstart + +Requires Python 3.12. This starts a loopback-only development server and uses +synthetic data, so it does not touch your packaged Cortex vault. + +```bash +make setup +CORTEX_AUTO_APPROVE_CAPTURES=1 make run +``` + +In a second terminal: + +```bash +curl -sS http://127.0.0.1:8766/v1/captures \ + -H 'Authorization: Bearer dev-local-key' \ + -H 'Content-Type: application/json' \ + -d '{"content":"Project Atlas ships Thursday after the rollback drill.","source":"quickstart"}' + +curl -sS -G http://127.0.0.1:8766/v1/ask \ + -H 'Authorization: Bearer dev-local-key' \ + --data-urlencode 'query=When does Project Atlas ship?' +``` + +The second response either includes the cited quickstart memory or explicitly +abstains. Next, try the runnable [Python examples](examples/README.md), the +[Python SDK](sdk/python/README.md), or the +[TypeScript SDK](sdk/typescript/README.md). `dev-local-key` is accepted only by +the explicit local development configuration. + ## Build from source Requires **Python 3.12** and Xcode command line tools. CI and the packaged runtime both use Python 3.12. ```bash +# Reproducible contributor environment +make setup +make check +make test + # macOS app (SwiftUI) — writes macos/build/Cortex.app ./macos/build.sh -# Backend engine + test suite -python3.12 -m venv .venv && source .venv/bin/activate -python3 -m pip install -r backend/requirements.txt pytest -python3 -m pytest backend/tests - # Run the hosted-plane API (FastAPI + uvicorn) at 127.0.0.1:8766 -./scripts/dev_backend.sh +make run # Or run the stdlib engine the packaged app actually ships cd backend && python3 -m app.standalone_server --host 127.0.0.1 --port 8766 @@ -235,7 +264,7 @@ for the authoritative command list. [`SETUP.md`](SETUP.md) covers the app-side d |---|---| | `macos/` | The SwiftUI/AppKit app and its build and packaging scripts | | `backend/app/` | Storage core, retrieval, connectors, MCP tools, and both HTTP servers | -| `backend/tests/` | The test suite (2,400+ tests) | +| `backend/tests/` | The test suite (2,000+ pytest cases, plus parameterized subtests) | | `scripts/` | Dev helpers plus the deterministic CI quality gates | | `packages/`, `sdk/`, `extension/` | Plugins, client SDKs, and the browser extension | | `docs/` | Architecture, protocol, and release documentation | @@ -253,11 +282,24 @@ for the authoritative command list. [`SETUP.md`](SETUP.md) covers the app-side d | Connecting AI tools | [`docs/EXTERNAL_INTEGRATIONS.md`](docs/EXTERNAL_INTEGRATIONS.md) · [`docs/MCP_INTEGRATIONS.md`](docs/MCP_INTEGRATIONS.md) | | Importing your sources | [`docs/SOURCE_IMPORTS.md`](docs/SOURCE_IMPORTS.md) | | Trust & privacy controls | [`docs/TRUST_CONTROLS.md`](docs/TRUST_CONTROLS.md) | +| Reproducible benchmark results | [`docs/BENCHMARKS.md`](docs/BENCHMARKS.md) | +| Open-source readiness review | [`docs/OPEN_SOURCE_READINESS.md`](docs/OPEN_SOURCE_READINESS.md) | +| Examples | [`examples/README.md`](examples/README.md) | +| FAQ and troubleshooting | [`docs/FAQ.md`](docs/FAQ.md) · [`docs/TROUBLESHOOTING.md`](docs/TROUBLESHOOTING.md) | | Sync & encryption | [`docs/E2EE_SYNC_DESIGN.md`](docs/E2EE_SYNC_DESIGN.md) · [`docs/CXE1_WIRE_FORMAT.md`](docs/CXE1_WIRE_FORMAT.md) | | Install & update checks | [`docs/INSTALLER_AND_UPDATES.md`](docs/INSTALLER_AND_UPDATES.md) | | Release & distribution | [`docs/DISTRIBUTION.md`](docs/DISTRIBUTION.md) · [`docs/APPLE_RELEASE.md`](docs/APPLE_RELEASE.md) | | Experimental pairwise twin evaluation | [Overview](https://github.com/trace-cortex/cortex-app/blob/feat/pairwise-twin-eval/docs/PAIRWISE_TWIN_EVALUATION.md) · [Integration guide](https://github.com/trace-cortex/cortex-app/blob/feat/pairwise-twin-eval/docs/PAIRWISE_TWIN_INTEGRATION_GUIDE.md) | +## Contributing and support + +Start with [`CONTRIBUTING.md`](CONTRIBUTING.md), then use the issue forms for a +reproducible bug or a scoped feature proposal. Security reports belong in the +private channel described by [`SECURITY.md`](SECURITY.md), not a public issue. +Project direction and release history live in [`ROADMAP.md`](ROADMAP.md) and +[`CHANGELOG.md`](CHANGELOG.md); support boundaries are in +[`SUPPORT.md`](SUPPORT.md). + ## Privacy Your memory is stored as plain files on your Mac at diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 00000000..091c08b1 --- /dev/null +++ b/ROADMAP.md @@ -0,0 +1,47 @@ +# Cortex Roadmap + +This roadmap communicates direction, not guaranteed dates. Current behavior is +defined by executable tests, the application code, and the current-product +documents indexed in [docs/README.md](docs/README.md). + +## Now — Make the Beta Trustworthy + +- Improve installation success and clean-profile first run. +- Keep Review understandable under large, noisy imports. +- Increase cited-answer quality and honest abstention. +- Validate the thirteen read-only connector contracts against real accounts. +- Harden backup, restore, deletion, support bundles, and update handoff. +- Keep local and hosted API behavior aligned. +- Maintain versioned, reproducible benchmark reports and runnable integration + examples as the implementation evolves. + +## Next — Prove the Operating Model + +- Run the pairwise digital-twin owner study and satisfy its admission gates. +- Improve whole-person adaptation without weakening source provenance. +- Configure and validate managed OAuth providers release by release. +- Validate retrieval, context packing, latency, and answerability against + consented real-user tasks in addition to deterministic synthetic fixtures. + +Pairwise evaluation remains experimental on `feat/pairwise-twin-eval`; see its +[overview](https://github.com/trace-cortex/cortex-app/blob/feat/pairwise-twin-eval/docs/PAIRWISE_TWIN_EVALUATION.md). + +## Later — Scale Without Losing Ownership + +- Production hardening for hosted auth, storage, monitoring, and incident + response. +- Multi-device encrypted sync with a recovery experience users can understand. +- A stable extension/plugin ecosystem around the portable-memory protocol. +- Published, versioned Python and TypeScript SDKs. +- Team and enterprise features only after the personal product is reliably + useful. + +## Non-Goals + +- Ambient surveillance or background capture without explicit consent. +- Uncited answers presented as personal memory. +- Moving the authoritative personal vault into a proprietary format. +- Making every experimental feature part of the default product. + +Propose roadmap changes through the feature-request template with user evidence, +scope, alternatives, and success criteria. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 00000000..1775f609 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,58 @@ +# Security Policy + +Cortex stores highly personal data and exposes it to explicitly authorized +tools. Security and privacy regressions are treated as product failures. + +## Reporting a Vulnerability + +Please do **not** open a public issue for a suspected vulnerability. + +Email **support@trydoppl.com** with the subject `Cortex security report` and +include: + +- the affected commit, release, endpoint, or component; +- reproduction steps or a minimal proof of concept; +- expected and observed behavior; +- realistic impact and any prerequisites; +- whether you believe active exploitation is occurring. + +Do not send real tokens, private vaults, raw memory, or another person's data. +Use synthetic fixtures and redact identifiers. We aim to acknowledge reports +within five business days, validate severity, and coordinate a fix and +disclosure timeline with the reporter. + +## Supported Versions + +Security fixes target: + +- the current `main` branch; +- the latest direct-download beta release; +- the current published SDK/plugin versions, when any are available. + +Older beta artifacts may be asked to update before a report can be reproduced. + +## High-Sensitivity Areas + +Especially valuable reports include: + +- authentication or scope bypasses; +- cross-user or cross-vault access; +- unsafe MCP tool authorization; +- connector-token or Keychain leakage; +- path traversal or unsafe archive extraction; +- support-bundle or log disclosure; +- update/release integrity failures; +- encryption, deletion, backup, or restore failures; +- remote requests that can reach unintended hosts or local resources. + +The current threat model and controls are described in +[docs/SECURITY_REVIEW.md](docs/SECURITY_REVIEW.md) and +[docs/TRUST_CONTROLS.md](docs/TRUST_CONTROLS.md). + +## Safe Research + +Use accounts and data you control, avoid service disruption and automated +high-volume scanning, stop if you encounter another user's information, and +give maintainers reasonable time to remediate before public disclosure. + +There is currently no paid bug-bounty program. diff --git a/SETUP.md b/SETUP.md index e14412ad..10500938 100644 --- a/SETUP.md +++ b/SETUP.md @@ -1,6 +1,14 @@ # Cortex Setup Guide -Cortex is a local-first macOS beta. You do not need Redis, a GitHub token, Docker, or a hosted account to run the current app. +Cortex is a local-first macOS beta. You do not need Redis, Docker, or an +external vector database. The current direct-download build requires account +sign-in; retrieval and the user-owned vault remain local. + +## Requirements + +- Apple Silicon Mac running macOS 13 or later +- Xcode command line tools +- Python 3.12 (the repository includes `.python-version` for version managers) ## Build And Open @@ -53,19 +61,30 @@ Open Connections & Privacy to manage: For backend development without opening the app: ```bash -python3 -m venv .venv -source .venv/bin/activate -python3 -m pip install -r backend/requirements.txt pytest -./scripts/dev_backend.sh +make setup +make run +``` + +`make setup` refuses to continue with the system Python when it is not 3.12, +creates `.venv`, and installs the exact backend/test versions in +`requirements-dev.lock`. To use an explicit interpreter: + +```bash +make setup PYTHON=/opt/homebrew/bin/python3.12 +``` + +Run the fast local pre-PR set: + +```bash +make check ``` -Run the main verification set: +Run the broader verification set: ```bash -python3 -W error::ResourceWarning -m pytest backend/tests -q -python3 scripts/retrieval_eval.py -python3 scripts/adaptation_eval.py +make test +make connector-check ./macos/build.sh codesign --verify --deep --strict --verbose=2 macos/build/Cortex.app -python3 scripts/ops_readiness_check.py +.venv/bin/python scripts/ops_readiness_check.py ``` diff --git a/SUPPORT.md b/SUPPORT.md new file mode 100644 index 00000000..e810f99b --- /dev/null +++ b/SUPPORT.md @@ -0,0 +1,41 @@ +# Cortex Support + +## Choose the Right Channel + +- **Reproducible bug or installation problem:** open a GitHub issue using the + bug-report template. +- **Feature or design proposal:** open a feature request after checking + [ROADMAP.md](ROADMAP.md). +- **Security or private-data concern:** follow [SECURITY.md](SECURITY.md); do + not post it publicly. +- **Conduct concern:** follow [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md). + +## Before Filing a Bug + +1. Confirm you are testing the latest release or current `main`. +2. Check [docs/TROUBLESHOOTING.md](docs/TROUBLESHOOTING.md). +3. Run the relevant health check: + + ```bash + make check + ``` + +4. For a running app, create a sanitized support bundle from Connections & + Privacy or: + + ```bash + .venv/bin/python scripts/export_support_bundle.py --mode live + ``` + +Support bundles are designed to omit raw memory, but review the generated file +before sharing it. Never attach a vault, connector credential, API token, or +private export to a public issue. + +## Useful Bug Details + +Include the Cortex version/build, macOS version and architecture, installation +method, exact steps, expected behavior, actual behavior, and the smallest +sanitized log or diagnostic excerpt that demonstrates the problem. + +Cortex is currently a beta. Community support is best-effort and has no +guaranteed response time. diff --git a/backend/app/connector_policy.py b/backend/app/connector_policy.py new file mode 100644 index 00000000..93bb56d0 --- /dev/null +++ b/backend/app/connector_policy.py @@ -0,0 +1,52 @@ +"""Shared outbound-origin policy for credential-bearing hosted connectors.""" + +from __future__ import annotations + +from urllib.parse import urlsplit + + +HOSTED_CONNECTOR_ORIGINS: dict[str, tuple[str, ...]] = { + "github": ("https://api.github.com",), + "gmail": ("https://gmail.googleapis.com/gmail/v1",), + "google-drive": ("https://www.googleapis.com/drive/v3",), + "outlook": ("https://graph.microsoft.com/v1.0",), + "slack": ("https://slack.com/api",), + "readwise": ("https://readwise.io/api/v2",), + "raindrop": ("https://api.raindrop.io/rest/v1",), + "linear": ("https://api.linear.app/graphql",), + "notion": ("https://api.notion.com/v1",), +} + +HOSTED_OAUTH_TOKEN_ENDPOINTS: dict[str, tuple[str, ...]] = { + "gmail": ("https://oauth2.googleapis.com/token",), + "google-drive": ("https://oauth2.googleapis.com/token",), + "notion": ("https://api.notion.com/v1/oauth/token",), + "outlook": ("https://login.microsoftonline.com/common/oauth2/v2.0/token",), +} + + +def is_official_connector_origin(source: str, value: str) -> bool: + candidate = str(value or "").strip().rstrip("/") + return candidate in HOSTED_CONNECTOR_ORIGINS.get(source, ()) + + +def is_official_oauth_token_endpoint(source: str, value: str) -> bool: + candidate = str(value or "").strip().rstrip("/") + return candidate in HOSTED_OAUTH_TOKEN_ENDPOINTS.get(source, ()) + + +def is_atlassian_cloud_origin(value: str) -> bool: + parsed = urlsplit(str(value or "").strip()) + hostname = (parsed.hostname or "").lower() + try: + port = parsed.port + except ValueError: + return False + return ( + parsed.scheme == "https" + and parsed.username is None + and parsed.password is None + and port in (None, 443) + and hostname.endswith(".atlassian.net") + and hostname != ".atlassian.net" + ) diff --git a/backend/app/main.py b/backend/app/main.py index b6211a6c..958b6205 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -30,6 +30,11 @@ token_verify_hash, ) from .config import APP_BRAND, load_settings +from .connector_policy import ( + is_atlassian_cloud_origin, + is_official_connector_origin, + is_official_oauth_token_endpoint, +) from .extractor import extract_context from .hosted_readiness import hosted_readiness_contract from .mcp_tools import ( @@ -385,6 +390,52 @@ def _hosted_readiness_contract() -> dict[str, Any]: return hosted_readiness_contract(settings, runtime=runtime) +def _require_local_filesystem_access(operation: str) -> None: + """Keep client-supplied paths on the machine that actually owns them. + + Local mode is the desktop/self-hosted trust boundary. In a sharded hosted + deployment, accepting a path would read from the API server rather than the + authenticated user's computer and could cross tenant boundaries. + """ + if settings.shard_mode != "local": + raise HTTPException( + status_code=403, + detail=f"{operation} is available only in local mode; upload content to hosted {APP_BRAND} instead", + ) + + +def _require_hosted_connector_origin(source: str, value: str | None) -> None: + """Reject credential-bearing requests to user-controlled origins in hosted mode.""" + if settings.shard_mode == "local" or not str(value or "").strip(): + return + if not is_official_connector_origin(source, value): + raise HTTPException( + status_code=422, + detail=f"Custom {source} API origins are disabled in hosted mode", + ) + + +def _require_hosted_oauth_token_endpoint(source: str, value: str | None) -> None: + if settings.shard_mode == "local" or not str(value or "").strip(): + return + if not is_official_oauth_token_endpoint(source, str(value)): + raise HTTPException( + status_code=422, + detail=f"Custom {source} OAuth token endpoints are disabled in hosted mode", + ) + + +def _require_hosted_jira_cloud_origin(value: str) -> None: + """Hosted Jira currently supports Atlassian Cloud, not arbitrary intranet URLs.""" + if settings.shard_mode == "local": + return + if not is_atlassian_cloud_origin(value): + raise HTTPException( + status_code=422, + detail="Hosted Jira connections require an HTTPS *.atlassian.net site URL", + ) + + def _enforce_rate_limit(user_id: str) -> None: allowed, retry_after = rate_limiter.check(user_id) if not allowed: @@ -810,6 +861,11 @@ def capture_page( url: str = "", source: str = "browser-capture", ) -> HTMLResponse: + if settings.shard_mode != "local" and (token or text or content): + raise HTTPException( + status_code=405, + detail="Query-string capture is disabled in hosted mode; use POST /capture", + ) payload = content or text if payload.strip(): try: @@ -1009,6 +1065,7 @@ def sync_source_account(account_id: str, request: SourceAccountSyncRequest, user @app.post("/v1/connectors/obsidian/sync", response_model=ObsidianVaultSyncResponse) def sync_obsidian_vault(request: ObsidianVaultSyncRequest, user_id: str = Depends(auth)) -> dict[str, Any]: + _require_local_filesystem_access("Obsidian vault sync") try: result = store.sync_obsidian_vault( user_id, @@ -1032,6 +1089,7 @@ def sync_obsidian_vault(request: ObsidianVaultSyncRequest, user_id: str = Depend def sync_agent_sessions(request: AgentSessionsSyncRequest, user_id: str = Depends(auth)) -> dict[str, Any]: """Harvest the user's own messages from local coding-agent session logs (Claude Code, Codex, Cursor) into the review queue. Write-scoped like the other connector syncs; user words only.""" + _require_local_filesystem_access("Coding-agent session sync") try: result = store.sync_agent_sessions( user_id, @@ -1055,6 +1113,7 @@ def sync_agent_sessions(request: AgentSessionsSyncRequest, user_id: str = Depend def write_obsidian_pages(body: dict[str, Any] | None = None, user_id: str = Depends(auth)) -> Any: """Obsidian write-back: refresh the distilled, cited Cortex/ pages inside the user's vault. Export-scoped (memory egress into user-owned files), parity with the MCP tool.""" + _require_local_filesystem_access("Obsidian write-back") payload = body or {} try: people_limit = int(payload.get("people_limit") or 10) @@ -1077,6 +1136,7 @@ def write_obsidian_pages(body: dict[str, Any] | None = None, user_id: str = Depe @app.post("/v1/connectors/github/sync", response_model=GitHubSyncResponse) def sync_github_account(request: GitHubSyncRequest, user_id: str = Depends(auth)) -> dict[str, Any]: + _require_hosted_connector_origin("github", request.api_base_url) try: result = store.sync_github_account( user_id, @@ -1102,6 +1162,7 @@ def sync_github_account(request: GitHubSyncRequest, user_id: str = Depends(auth) @app.post("/v1/connectors/github/discover", response_model=GitHubRepositoryDiscoveryResponse) def discover_github_account_repositories(request: GitHubRepositoryDiscoveryRequest, user_id: str = Depends(auth)) -> dict[str, Any]: del user_id + _require_hosted_connector_origin("github", request.api_base_url) try: from .connectors.github import discover_github_repositories @@ -1117,6 +1178,7 @@ def discover_github_account_repositories(request: GitHubRepositoryDiscoveryReque @app.post("/v1/connectors/google/oauth/start", response_model=GoogleOAuthStartResponse) def start_google_oauth(request: GoogleOAuthStartRequest, user_id: str = Depends(auth)) -> dict[str, Any]: + _require_hosted_oauth_token_endpoint(request.source, request.token_endpoint) try: target_store = store.store_for_user(user_id) if hasattr(store, "store_for_user") else store started = target_store.start_google_oauth( @@ -1204,6 +1266,7 @@ def google_oauth_callback( @app.post("/v1/connectors/google/oauth/complete", response_model=GoogleOAuthCompleteResponse) def complete_google_oauth(request: GoogleOAuthCompleteRequest, user_id: str = Depends(auth)) -> dict[str, Any]: + _require_hosted_oauth_token_endpoint(request.source, request.token_endpoint) try: return store.public_payload( user_id, @@ -1234,6 +1297,8 @@ def complete_google_oauth(request: GoogleOAuthCompleteRequest, user_id: str = De @app.post("/v1/connectors/oauth/start", response_model=ManagedOAuthStartResponse) def start_managed_oauth(request: ManagedOAuthStartRequest, user_id: str = Depends(auth)) -> dict[str, Any]: + _require_hosted_connector_origin(request.source, request.api_base_url) + _require_hosted_oauth_token_endpoint(request.source, request.token_endpoint) try: target_store = store.store_for_user(user_id) if hasattr(store, "store_for_user") else store started = target_store.start_managed_oauth( @@ -1312,6 +1377,8 @@ def managed_oauth_callback( @app.post("/v1/connectors/oauth/complete", response_model=ManagedOAuthCompleteResponse) def complete_managed_oauth(request: ManagedOAuthCompleteRequest, user_id: str = Depends(auth)) -> dict[str, Any]: + _require_hosted_connector_origin(request.source, request.api_base_url) + _require_hosted_oauth_token_endpoint(request.source, request.token_endpoint) try: return store.public_payload( user_id, @@ -1339,6 +1406,7 @@ def complete_managed_oauth(request: ManagedOAuthCompleteRequest, user_id: str = @app.post("/v1/connectors/gmail/sync", response_model=GmailSyncResponse) def sync_gmail_account(request: GmailSyncRequest, user_id: str = Depends(auth)) -> dict[str, Any]: + _require_hosted_connector_origin("gmail", request.api_base_url) try: result = store.sync_gmail_account( user_id, @@ -1364,6 +1432,7 @@ def sync_gmail_account(request: GmailSyncRequest, user_id: str = Depends(auth)) @app.post("/v1/connectors/google-drive/sync", response_model=GoogleDriveSyncResponse) def sync_google_drive_account(request: GoogleDriveSyncRequest, user_id: str = Depends(auth)) -> dict[str, Any]: + _require_hosted_connector_origin("google-drive", request.api_base_url) try: result = store.sync_google_drive_account( user_id, @@ -1389,6 +1458,7 @@ def sync_google_drive_account(request: GoogleDriveSyncRequest, user_id: str = De @app.post("/v1/connectors/outlook/sync", response_model=OutlookSyncResponse) def sync_outlook_account(request: OutlookSyncRequest, user_id: str = Depends(auth)) -> dict[str, Any]: + _require_hosted_connector_origin("outlook", request.api_base_url) try: result = store.sync_outlook_account( user_id, @@ -1413,6 +1483,7 @@ def sync_outlook_account(request: OutlookSyncRequest, user_id: str = Depends(aut @app.post("/v1/connectors/slack/sync", response_model=SlackSyncResponse) def sync_slack_account(request: SlackSyncRequest, user_id: str = Depends(auth)) -> dict[str, Any]: + _require_hosted_connector_origin("slack", request.api_base_url) try: result = store.sync_slack_account( user_id, @@ -1437,6 +1508,7 @@ def sync_slack_account(request: SlackSyncRequest, user_id: str = Depends(auth)) @app.post("/v1/connectors/slack/discover", response_model=SlackChannelDiscoveryResponse) def discover_slack_account_channels(request: SlackChannelDiscoveryRequest, user_id: str = Depends(auth)) -> dict[str, Any]: del user_id + _require_hosted_connector_origin("slack", request.api_base_url) try: from .connectors.slack import discover_slack_channels @@ -1453,6 +1525,7 @@ def discover_slack_account_channels(request: SlackChannelDiscoveryRequest, user_ @app.post("/v1/connectors/readwise/sync", response_model=ReadwiseSyncResponse) def sync_readwise_account(request: ReadwiseSyncRequest, user_id: str = Depends(auth)) -> dict[str, Any]: + _require_hosted_connector_origin("readwise", request.api_base_url) try: result = store.sync_readwise_account( user_id, @@ -1475,6 +1548,13 @@ def sync_readwise_account(request: ReadwiseSyncRequest, user_id: str = Depends(a @app.post("/v1/connectors/calendar/sync", response_model=CalendarSyncResponse) def sync_calendar_account(request: CalendarSyncRequest, user_id: str = Depends(auth)) -> dict[str, Any]: + if request.ics_path: + _require_local_filesystem_access("Calendar file sync") + if settings.shard_mode != "local" and request.feed_url: + raise HTTPException( + status_code=422, + detail="Calendar feed URLs are disabled in hosted mode until outbound URL policy is configured", + ) try: result = store.sync_calendar_account( user_id, @@ -1496,6 +1576,7 @@ def sync_calendar_account(request: CalendarSyncRequest, user_id: str = Depends(a @app.post("/v1/connectors/raindrop/sync", response_model=RaindropSyncResponse) def sync_raindrop_account(request: RaindropSyncRequest, user_id: str = Depends(auth)) -> dict[str, Any]: + _require_hosted_connector_origin("raindrop", request.api_base_url) try: result = store.sync_raindrop_account( user_id, @@ -1520,6 +1601,7 @@ def sync_raindrop_account(request: RaindropSyncRequest, user_id: str = Depends(a @app.post("/v1/connectors/zotero/sync", response_model=ZoteroSyncResponse) def sync_zotero_account(request: ZoteroSyncRequest, user_id: str = Depends(auth)) -> dict[str, Any]: + _require_local_filesystem_access("Zotero local API sync") try: result = store.sync_zotero_account( user_id, @@ -1545,6 +1627,7 @@ def sync_zotero_account(request: ZoteroSyncRequest, user_id: str = Depends(auth) @app.post("/v1/connectors/linear/sync", response_model=LinearSyncResponse) def sync_linear_account(request: LinearSyncRequest, user_id: str = Depends(auth)) -> dict[str, Any]: + _require_hosted_connector_origin("linear", request.api_url) try: result = store.sync_linear_account( user_id, @@ -1567,6 +1650,7 @@ def sync_linear_account(request: LinearSyncRequest, user_id: str = Depends(auth) @app.post("/v1/connectors/jira/sync", response_model=JiraSyncResponse) def sync_jira_account(request: JiraSyncRequest, user_id: str = Depends(auth)) -> dict[str, Any]: + _require_hosted_jira_cloud_origin(request.site_url) try: result = store.sync_jira_account( user_id, @@ -1591,6 +1675,7 @@ def sync_jira_account(request: JiraSyncRequest, user_id: str = Depends(auth)) -> @app.post("/v1/connectors/notion/sync", response_model=NotionSyncResponse) def sync_notion_account(request: NotionSyncRequest, user_id: str = Depends(auth)) -> dict[str, Any]: + _require_hosted_connector_origin("notion", request.api_base_url) try: result = store.sync_notion_account( user_id, @@ -1693,6 +1778,7 @@ def list_imports(limit: int = Query(default=50, ge=1, le=100), include_deleted: @app.post("/v1/imports/analyze", response_model=SourceAnalyzeResponse) def analyze_import_sources(request: SourceAnalyzeRequest, user_id: str = Depends(auth)) -> dict[str, Any]: + _require_local_filesystem_access("Path-based import analysis") return store.analyze_import_sources( request.paths, source_hint=request.source_hint, @@ -1702,6 +1788,7 @@ def analyze_import_sources(request: SourceAnalyzeRequest, user_id: str = Depends @app.post("/v1/imports", response_model=SourceImportResponse) def import_sources(request: SourceImportRequest, user_id: str = Depends(auth)) -> dict[str, Any]: + _require_local_filesystem_access("Path-based import") return store.import_sources( user_id=user_id or request.user_id, paths=request.paths, diff --git a/backend/app/sharding.py b/backend/app/sharding.py index eed43615..8e53c334 100644 --- a/backend/app/sharding.py +++ b/backend/app/sharding.py @@ -742,6 +742,7 @@ def _inject_credential_cipher(self, store: CortexStore) -> None: hosted mode, CORTEX_REQUIRE_ENCRYPTED_CREDENTIALS makes plaintext writes refuse — even if the cipher is (mis)configured absent, the vault then raises rather than silently writing plaintext (design doc §4).""" + store.hosted_mode = self.router.mode != "local" if self.router.mode == "local": return vault = getattr(store, "vault", None) diff --git a/backend/app/storage.py b/backend/app/storage.py index 93d1bb1e..3dd31bf8 100644 --- a/backend/app/storage.py +++ b/backend/app/storage.py @@ -20,6 +20,11 @@ from urllib.request import Request, urlopen from .config import APP_BRAND +from .connector_policy import ( + is_atlassian_cloud_origin, + is_official_connector_origin, + is_official_oauth_token_endpoint, +) from .connectors._redaction import redact_error_message from .database import connect, sqlite_vec_status from .embeddings import VECTOR_DIMENSIONS, configured_embedding_model, embed_text, embed_text_result, embedding_hash, embedding_json, embedding_source_text, embedding_status, warmup_embedding_provider @@ -3169,6 +3174,9 @@ def _path_event_summary(path: str) -> dict[str, Any]: class CortexStore: def __init__(self, db_path, vault_path: str | Path | None = None): self.db_path = Path(db_path) + # StoreRegistry sets this on sharded stores. Direct CortexStore instances + # are local by default, preserving the standalone/test contract. + self.hosted_mode = False resolved_vault_path = Path(vault_path).expanduser() if vault_path else self.db_path.parent self.vault = CortexVault(resolved_vault_path, self.db_path) self.vault.ensure() @@ -3216,6 +3224,40 @@ def __init__(self, db_path, vault_path: str | Path | None = None): # embedding-model change re-embeds via _ensure_vector_index/_enqueue_reembed_all). self._reprocess_captures_on_extractor_change() + def _require_local_runtime(self, operation: str, *, test_adapter: bool = False) -> None: + if self.hosted_mode and not test_adapter: + raise ValueError(f"{operation} is available only in local mode") + + def _require_hosted_connector_origin( + self, + source: str, + value: str | None, + *, + test_adapter: bool = False, + ) -> None: + if not self.hosted_mode or test_adapter: + return + if not is_official_connector_origin(source, str(value or "")): + raise ValueError(f"Custom {source} API origins are disabled in hosted mode") + + def _require_hosted_jira_cloud_origin(self, value: str, *, test_adapter: bool = False) -> None: + if not self.hosted_mode or test_adapter: + return + if not is_atlassian_cloud_origin(value): + raise ValueError("Hosted Jira connections require an HTTPS *.atlassian.net site URL") + + def _require_hosted_oauth_token_endpoint( + self, + source: str, + value: str, + *, + test_adapter: bool = False, + ) -> None: + if not self.hosted_mode or test_adapter: + return + if not is_official_oauth_token_endpoint(source, value): + raise ValueError(f"Custom {source} OAuth token endpoints are disabled in hosted mode") + def _ensure_memory_occurrences_column(self) -> None: with connect(self.db_path) as conn: try: @@ -5183,6 +5225,11 @@ def complete_google_oauth( resolved_client_secret = _google_oauth_config_value(normalized_source, "CLIENT_SECRET", client_secret) resolved_redirect_uri = _google_oauth_redirect_uri(normalized_source, redirect_uri) resolved_token_endpoint = str(token_endpoint or "").strip() or _google_oauth_config_value(normalized_source, "TOKEN_ENDPOINT") or GOOGLE_OAUTH_TOKEN_ENDPOINT + self._require_hosted_oauth_token_endpoint( + normalized_source, + resolved_token_endpoint, + test_adapter=request_token is not None, + ) if not resolved_client_id: raise ValueError("Google OAuth client ID is not configured") normalized_code_verifier = str(code_verifier or "").strip() @@ -5407,6 +5454,11 @@ def complete_managed_oauth( resolved_client_secret = _managed_oauth_config_value(normalized_source, "CLIENT_SECRET", client_secret) resolved_redirect_uri = _managed_oauth_redirect_uri(normalized_source, redirect_uri) resolved_token_endpoint = str(token_endpoint or "").strip() or _managed_oauth_config_value(normalized_source, "TOKEN_ENDPOINT") or MANAGED_OAUTH_TOKEN_ENDPOINTS[normalized_source] + self._require_hosted_oauth_token_endpoint( + normalized_source, + resolved_token_endpoint, + test_adapter=request_token is not None, + ) if not resolved_client_id: raise ValueError(f"{provider.title()} OAuth client ID is not configured") normalized_code_verifier = str(code_verifier or "").strip() @@ -5700,6 +5752,7 @@ def _source_credential_payload_with_fresh_oauth_token( raise ValueError(f"{source} access token expired and refresh configuration is missing") if source == "notion" and not client_secret: raise ValueError(f"{source} access token expired and refresh configuration is missing") + self._require_hosted_oauth_token_endpoint(source, token_endpoint) scope = str(payload.get("scope") or "").strip() secrets_to_redact = [ @@ -6274,6 +6327,7 @@ def sync_obsidian_vault( ) -> dict[str, Any]: from .connectors.obsidian import OBSIDIAN_SOURCE, scan_vault, vault_identity + self._require_local_runtime("Obsidian vault sync") if processing not in {"sync", "async"}: raise ValueError("processing must be sync or async") identity = vault_identity(vault_path) @@ -6521,6 +6575,7 @@ def sync_agent_sessions( """ from .connectors.agent_sessions import AGENT_SESSIONS_SOURCE, scan_agent_sessions + self._require_local_runtime("Coding-agent session sync") if processing not in {"sync", "async"}: raise ValueError("processing must be sync or async") resolved_account_id = (source_account_id or "").strip() or stable_id("sacct_", f"{user_id}:{AGENT_SESSIONS_SOURCE}:local") @@ -6680,6 +6735,7 @@ def write_obsidian_pages( render_readme, ) + self._require_local_runtime("Obsidian write-back") resolved_path = str(vault_path or "").strip() if not resolved_path: for account in self.list_source_accounts(user_id): @@ -6886,6 +6942,9 @@ def sync_github_account( ) -> dict[str, Any]: from .connectors.github import GITHUB_SOURCE, fetch_github_records + self._require_hosted_connector_origin( + "github", api_base_url or "https://api.github.com", test_adapter=request_json is not None + ) if processing not in {"sync", "async"}: raise ValueError("processing must be sync or async") try: @@ -7084,6 +7143,11 @@ def sync_gmail_account( ) -> dict[str, Any]: from .connectors.gmail import GMAIL_SOURCE, fetch_gmail_records + self._require_hosted_connector_origin( + "gmail", + api_base_url or "https://gmail.googleapis.com/gmail/v1", + test_adapter=request_json is not None, + ) if processing not in {"sync", "async"}: raise ValueError("processing must be sync or async") try: @@ -7273,6 +7337,11 @@ def sync_google_drive_account( ) -> dict[str, Any]: from .connectors.google_drive import GOOGLE_DRIVE_SOURCE, fetch_google_drive_records + self._require_hosted_connector_origin( + "google-drive", + api_base_url or "https://www.googleapis.com/drive/v3", + test_adapter=request_value is not None, + ) if processing not in {"sync", "async"}: raise ValueError("processing must be sync or async") try: @@ -7464,6 +7533,11 @@ def sync_outlook_account( ) -> dict[str, Any]: from .connectors.outlook import OUTLOOK_SOURCE, fetch_outlook_records + self._require_hosted_connector_origin( + "outlook", + api_base_url or "https://graph.microsoft.com/v1.0", + test_adapter=request_json is not None, + ) if processing not in {"sync", "async"}: raise ValueError("processing must be sync or async") try: @@ -7648,6 +7722,9 @@ def sync_slack_account( ) -> dict[str, Any]: from .connectors.slack import SLACK_SOURCE, fetch_slack_records + self._require_hosted_connector_origin( + "slack", api_base_url or "https://slack.com/api", test_adapter=request_json is not None + ) if processing not in {"sync", "async"}: raise ValueError("processing must be sync or async") try: @@ -7837,6 +7914,11 @@ def sync_readwise_account( ) -> dict[str, Any]: from .connectors.readwise import READWISE_SOURCE, fetch_readwise_records + self._require_hosted_connector_origin( + "readwise", + api_base_url or "https://readwise.io/api/v2", + test_adapter=request_json is not None, + ) if processing not in {"sync", "async"}: raise ValueError("processing must be sync or async") try: @@ -8006,6 +8088,10 @@ def sync_calendar_account( ) -> dict[str, Any]: from .connectors.calendar import CALENDAR_SOURCE, fetch_calendar_records + self._require_local_runtime( + "Calendar file/feed sync", + test_adapter=read_text is not None or request_text is not None, + ) if processing not in {"sync", "async"}: raise ValueError("processing must be sync or async") try: @@ -8185,6 +8271,11 @@ def sync_raindrop_account( ) -> dict[str, Any]: from .connectors.raindrop import RAINDROP_SOURCE, fetch_raindrop_records + self._require_hosted_connector_origin( + "raindrop", + api_base_url or "https://api.raindrop.io/rest/v1", + test_adapter=request_json is not None, + ) if processing not in {"sync", "async"}: raise ValueError("processing must be sync or async") try: @@ -8367,6 +8458,7 @@ def sync_zotero_account( ) -> dict[str, Any]: from .connectors.zotero import ZOTERO_SOURCE, fetch_zotero_records + self._require_local_runtime("Zotero local API sync", test_adapter=request_json is not None) if processing not in {"sync", "async"}: raise ValueError("processing must be sync or async") try: @@ -8553,6 +8645,11 @@ def sync_linear_account( ) -> dict[str, Any]: from .connectors.linear import LINEAR_SOURCE, fetch_linear_records + self._require_hosted_connector_origin( + "linear", + api_url or "https://api.linear.app/graphql", + test_adapter=request_json is not None, + ) if processing not in {"sync", "async"}: raise ValueError("processing must be sync or async") try: @@ -8723,6 +8820,7 @@ def sync_jira_account( ) -> dict[str, Any]: from .connectors.jira import JIRA_SOURCE, fetch_jira_records + self._require_hosted_jira_cloud_origin(site_url, test_adapter=request_json is not None) if processing not in {"sync", "async"}: raise ValueError("processing must be sync or async") try: @@ -8901,6 +8999,11 @@ def sync_notion_account( ) -> dict[str, Any]: from .connectors.notion import NOTION_SOURCE, fetch_notion_records + self._require_hosted_connector_origin( + "notion", + api_base_url or "https://api.notion.com/v1", + test_adapter=request_json is not None, + ) if processing not in {"sync", "async"}: raise ValueError("processing must be sync or async") try: diff --git a/backend/tests/test_hosted_trust_boundaries.py b/backend/tests/test_hosted_trust_boundaries.py new file mode 100644 index 00000000..fe82ec56 --- /dev/null +++ b/backend/tests/test_hosted_trust_boundaries.py @@ -0,0 +1,118 @@ +from __future__ import annotations + +from dataclasses import replace +from pathlib import Path +import tempfile +import unittest + +from fastapi import HTTPException + +from backend.app import main as main_module + + +class HostedTrustBoundaryTests(unittest.TestCase): + def setUp(self) -> None: + self.original_settings = main_module.settings + main_module.settings = replace( + self.original_settings, + shard_mode="user", + require_scoped_api_tokens=True, + ) + + def tearDown(self) -> None: + main_module.settings = self.original_settings + + def test_hosted_mode_rejects_server_filesystem_operations(self) -> None: + with self.assertRaises(HTTPException) as caught: + main_module._require_local_filesystem_access("Path-based import") + + self.assertEqual(caught.exception.status_code, 403) + self.assertIn("local mode", str(caught.exception.detail)) + + def test_hosted_mode_rejects_custom_credential_bearing_origin(self) -> None: + with self.assertRaises(HTTPException) as caught: + main_module._require_hosted_connector_origin("slack", "http://127.0.0.1:8080") + + self.assertEqual(caught.exception.status_code, 422) + self.assertIn("Custom slack API origins", str(caught.exception.detail)) + + def test_hosted_mode_accepts_the_official_connector_origin(self) -> None: + main_module._require_hosted_connector_origin("slack", "https://slack.com/api/") + main_module._require_hosted_connector_origin("github", None) + + def test_hosted_mode_accepts_only_official_oauth_token_endpoints(self) -> None: + main_module._require_hosted_oauth_token_endpoint( + "gmail", + "https://oauth2.googleapis.com/token", + ) + main_module._require_hosted_oauth_token_endpoint("notion", None) + + with self.assertRaises(HTTPException) as caught: + main_module._require_hosted_oauth_token_endpoint( + "outlook", + "http://169.254.169.254/latest/meta-data", + ) + self.assertEqual(caught.exception.status_code, 422) + + def test_hosted_jira_accepts_atlassian_cloud_only(self) -> None: + main_module._require_hosted_jira_cloud_origin("https://acme.atlassian.net") + + for unsafe in ( + "http://acme.atlassian.net", + "https://127.0.0.1", + "https://acme.example.com", + "https://user:pass@acme.atlassian.net", + "https://acme.atlassian.net:8443", + ): + with self.subTest(unsafe=unsafe), self.assertRaises(HTTPException): + main_module._require_hosted_jira_cloud_origin(unsafe) + + def test_local_mode_preserves_desktop_and_test_overrides(self) -> None: + main_module.settings = replace(self.original_settings, shard_mode="local") + + main_module._require_local_filesystem_access("Path-based import") + main_module._require_hosted_connector_origin("slack", "http://127.0.0.1:8080") + main_module._require_hosted_jira_cloud_origin("http://localhost:8080") + + def test_sharded_store_enforces_policy_below_http_routes(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + hosted_settings = replace( + self.original_settings, + db_path=root / "control.sqlite", + vault_path=root / "control.vault", + shard_root=root / "shards", + shard_mode="user", + require_scoped_api_tokens=True, + ) + registry = main_module.StoreRegistry.from_settings(hosted_settings) + hosted_store = registry.store_for_user("alice") + + self.assertTrue(hosted_store.hosted_mode) + with self.assertRaisesRegex(ValueError, "Custom slack API origins"): + hosted_store.sync_slack_account( + "alice", + token="xoxb-test", + channels=["C123"], + api_base_url="http://169.254.169.254/latest/meta-data", + ) + with self.assertRaisesRegex(ValueError, "local mode"): + hosted_store.sync_obsidian_vault("alice", vault_path="/etc") + with self.assertRaisesRegex(ValueError, "local mode"): + hosted_store.sync_calendar_account("alice", feed_url="http://127.0.0.1/private.ics") + with self.assertRaisesRegex(ValueError, "OAuth token endpoints"): + hosted_store._source_credential_payload_with_fresh_oauth_token( + "alice", + {"id": "account-1", "source": "gmail"}, + { + "access_token": "expired", + "access_token_expires_at": "2000-01-01T00:00:00Z", + "refresh_token": "refresh-secret", + "client_id": "client-id", + "token_endpoint": "http://169.254.169.254/latest/meta-data", + }, + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/backend/tests/test_macos_ui_quality_contract.py b/backend/tests/test_macos_ui_quality_contract.py index 3758fa27..6513390a 100644 --- a/backend/tests/test_macos_ui_quality_contract.py +++ b/backend/tests/test_macos_ui_quality_contract.py @@ -55,8 +55,9 @@ def test_required_sign_in_wall_teaches_sources_and_ai_tool_setup(self) -> None: self.assertIn("ScrollView", source) def test_native_apple_button_is_full_width_and_entitlement_gated(self) -> None: - # The Apple button renders whenever the build carries the applesignin entitlement - # (canUseNativeAppleSignIn). It must NOT also depend on the /v1/auth/providers list: + # The shared Apple button renders whenever the build carries the applesignin + # entitlement (AppleSignInSupport.isAvailable). It must NOT also depend on the + # /v1/auth/providers list: # native SIWA has no web client_secret, so that list deliberately never contains "apple", # and gating on it made SIWA permanently dead code while GitHub/Google browser buttons # rendered — the exact Guideline 4.8 violation. The native endpoint @@ -64,10 +65,10 @@ def test_native_apple_button_is_full_width_and_entitlement_gated(self) -> None: # no Apple client id, so the button is a real control, never a dead one. source = CORTEX_CLOUD_AUTH.read_text(encoding="utf-8") - self.assertIn("hasAppleSignInEntitlement", source) + self.assertIn("enum AppleSignInSupport", source) self.assertIn("com.apple.developer.applesignin", source) self.assertIn(".frame(maxWidth: .infinity, minHeight: 44, maxHeight: 44)", source) - self.assertIn("if canUseNativeAppleSignIn {", source) + self.assertIn("if AppleSignInSupport.isAvailable {", source) # 4.8 regression guard: SIWA must never again be gated on the web-provider list. self.assertNotIn("canUseNativeAppleSignIn && backendOffersApple", source) # Provider-aware sign-in: a real labeled button per configured provider (never a diff --git a/backend/tests/test_rerank_eval.py b/backend/tests/test_rerank_eval.py index 92ba1296..38514fb8 100644 --- a/backend/tests/test_rerank_eval.py +++ b/backend/tests/test_rerank_eval.py @@ -11,8 +11,12 @@ def test_no_regression_under_model2vec(self): prev = os.environ.get("CORTEX_EMBEDDING_PROVIDER") os.environ["CORTEX_EMBEDDING_PROVIDER"] = "model2vec" try: - from backend.app.embeddings import embedding_status + from backend.app.embeddings import embedding_status, warmup_embedding_provider + # Probe the configured provider before deciding whether this environment + # can run the semantic floor. Without warmup, status may still report the + # requested provider even though the optional package/model cannot load. + warmup_embedding_provider() if embedding_status().get("provider") != "model2vec": self.skipTest("model2vec embedder unavailable in this environment") from scripts.rerank_eval import run_rerank_eval diff --git a/deploy/README.md b/deploy/README.md index e76c5e07..2365c7b6 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -27,27 +27,39 @@ hundreds of beta users fit comfortably here and the upgrade is a snapshot-restor That uploads the current tree and runs `bootstrap.sh` on the box: OS hardening + firewall, Caddy with automatic HTTPS, Python venv, secret generation (admin token, - KEK, signing key — printed ONCE at the end; store them in a password manager), - systemd services for the API + background worker, nightly WAL-safe backups, done. + KEK, signing key — stored in root-readable files and deliberately not printed), + systemd services for the API + background worker, nightly WAL-safe encrypted backups, + done. 4. **Verify:** `curl https://api.signindoppl.com/health` → `{"status":"ok",...}` and open `https://api.signindoppl.com/ready`. -5. **Escrow the KEK** (printed by bootstrap): copy `/etc/cortex/kek` into your password - manager AND one offline place. If the box dies and the KEK is lost, every user's - encrypted credentials are unrecoverable — that is the point of the design. +5. **Escrow both recovery secrets** from an interactive, non-logged root session: + - `/etc/cortex/kek` decrypts stored connector credentials. + - `/etc/cortex/backup-age.key` decrypts backup archives. + + Keep both in a password manager and offline recovery location, separately from the + backup bucket. Do not paste them into deployment logs or shell history. Losing either + can make a full restore impossible. ## What this beta configuration deliberately does -- `CORTEX_AUTH_AUTOVERIFY=1`: accounts activate at signup with **no email server**. - Flip it off (and set up Postmark + `CORTEX_AUTH_EMAIL_MODE=smtp`) before public launch — - unverified emails mean no password-recovery channel. +- `CORTEX_AUTH_AUTOVERIFY=0`: accounts stay pending until email verification. Configure + Postmark/SES (or another SMTP provider) before accepting public signups. - GitHub login works the moment you create a (2-minute, no-review) GitHub OAuth app and set the two env vars in `/etc/cortex/cortex.env`; Google login needs the consent-screen publishing review (1–2 weeks) so leave it for later. - Free tier only; no billing. -- Backups are nightly, WAL-safe, kept 7 days **on the box** plus whatever Hetzner's - VM backup snapshots. Add true offsite (rclone target in `backup.sh`) in week one. +- Backups are nightly, WAL-safe, age-encrypted, and kept 7 days **on the box** plus + Hetzner VM snapshots. Set `BACKUP_RCLONE_REMOTE` for an offsite encrypted copy. + +To inspect a recovery archive on a clean machine: + +```bash +mkdir restore +age --decrypt -i backup-age.key cortex-YYYYMMDD-HHMMSS.tar.gz.age \ + | tar -xz -C restore +``` ## Updating the running backend diff --git a/deploy/backup.sh b/deploy/backup.sh index 38358efc..3ab5658d 100755 --- a/deploy/backup.sh +++ b/deploy/backup.sh @@ -1,20 +1,30 @@ #!/usr/bin/env bash # Nightly Cortex backup: WAL-safe `.backup` of every SQLite database (raw copies of live # WAL files are NOT consistent), plus vault/attachment trees. Keeps 7 nights locally. -# The KEK is deliberately NOT included — key escrow is separate from data backups by -# design (docs/ACCOUNTS_ENCRYPTION_DESIGN.md): a stolen backup must stay unreadable. +# Archives are encrypted to an age recipient; the private identity and Cortex KEK are +# deliberately separate from the backup data. # # Offsite (recommended in week one): set BACKUP_RCLONE_REMOTE in /etc/cortex/cortex.env # (e.g. "r2:cortex-backups") after `rclone config`; each archive is then copied off-box. set -euo pipefail +umask 077 SHARD_ROOT="${CORTEX_SHARD_ROOT:-/var/lib/cortex/shards}" DEST_ROOT="/var/lib/cortex/backups" STAMP="$(date -u +%Y%m%d-%H%M%S)" STAGING="$DEST_ROOT/staging-$STAMP" -ARCHIVE="$DEST_ROOT/cortex-$STAMP.tar.gz" +ARCHIVE="$DEST_ROOT/cortex-$STAMP.tar.gz.age" -mkdir -p "$STAGING" +if [ -z "${BACKUP_AGE_RECIPIENT:-}" ]; then + echo "BACKUP_AGE_RECIPIENT is required; refusing to create a plaintext backup" >&2 + exit 1 +fi +if ! command -v age >/dev/null; then + echo "age is required to encrypt Cortex backups" >&2 + exit 1 +fi + +install -d -m 0700 "$DEST_ROOT" "$STAGING" trap 'rm -rf "$STAGING"' EXIT # 1. Consistent snapshots of every SQLite DB under the shard root (shards, control @@ -25,31 +35,31 @@ while IFS= read -r -d '' db; do sqlite3 "$db" ".backup '$STAGING/db/$rel'" done < <(find "$SHARD_ROOT" -name '*.sqlite' -print0 2>/dev/null) -# 2. Vault trees (Markdown + JSON records + credentials blobs — credential payloads are -# CXE1-encrypted at rest, so this archive stays safe without the KEK). +# 2. Vault trees (Markdown + JSON records + encrypted credential blobs). Memory content +# is plaintext at rest, which is why the whole archive is encrypted below. while IFS= read -r -d '' vault; do rel="${vault#"$SHARD_ROOT"/}" mkdir -p "$STAGING/vault/$(dirname "$rel")" cp -R "$vault" "$STAGING/vault/$rel" done < <(find "$SHARD_ROOT" -maxdepth 3 -type d -name '*.vault' -print0 2>/dev/null) -# 3. Config snapshot (env WITHOUT the KEK file). -mkdir -p "$STAGING/etc" -cp /etc/cortex/cortex.env "$STAGING/etc/cortex.env" 2>/dev/null || true - -tar -czf "$ARCHIVE" -C "$STAGING" . +# Never copy /etc/cortex/cortex.env: it contains operator, OAuth, signing, and SMTP +# credentials. Deployment configuration should be reconstructed from the example and +# separately escrowed secrets. +tar -cz -C "$STAGING" . | age -r "$BACKUP_AGE_RECIPIENT" -o "$ARCHIVE" +chmod 0600 "$ARCHIVE" echo "backup written: $ARCHIVE ($(du -h "$ARCHIVE" | cut -f1))" -# 4. Retention: keep 7 nights locally. -ls -1t "$DEST_ROOT"/cortex-*.tar.gz 2>/dev/null | tail -n +8 | xargs -r rm -f +# 3. Retention: keep 7 nights locally. +ls -1t "$DEST_ROOT"/cortex-*.tar.gz.age 2>/dev/null | tail -n +8 | xargs -r rm -f -# 5. Optional offsite copy. +# 4. Optional offsite copy. Only the encrypted archive leaves the machine. if [ -n "${BACKUP_RCLONE_REMOTE:-}" ] && command -v rclone >/dev/null; then rclone copy "$ARCHIVE" "$BACKUP_RCLONE_REMOTE/" --quiet echo "offsite copy: $BACKUP_RCLONE_REMOTE/" fi -# 6. Optional dead-man ping (set BACKUP_PING_URL to a healthchecks.io check). +# 5. Optional dead-man ping (set BACKUP_PING_URL to a healthchecks.io check). if [ -n "${BACKUP_PING_URL:-}" ]; then curl -fsS -m 10 "$BACKUP_PING_URL" >/dev/null || true fi diff --git a/deploy/bootstrap.sh b/deploy/bootstrap.sh index 7ee6eda8..26e37850 100755 --- a/deploy/bootstrap.sh +++ b/deploy/bootstrap.sh @@ -15,7 +15,7 @@ echo "==============================================================" export DEBIAN_FRONTEND=noninteractive apt-get update -qq apt-get install -y -qq python3 python3-venv python3-pip ufw fail2ban unattended-upgrades \ - sqlite3 curl debian-keyring debian-archive-keyring apt-transport-https gnupg + sqlite3 curl age debian-keyring debian-archive-keyring apt-transport-https gnupg id -u cortex >/dev/null 2>&1 || useradd --system --home /srv/cortex --shell /usr/sbin/nologin cortex mkdir -p /srv/cortex/releases /var/lib/cortex/shards /var/lib/cortex/backups /etc/cortex @@ -70,6 +70,22 @@ else FIRST_INSTALL=0 fi +# Backups use a dedicated public-key recipient. The private age identity is required +# only for disaster recovery and must be escrowed separately from backup archives. +BACKUP_AGE_IDENTITY=/etc/cortex/backup-age.key +if [ ! -f "$BACKUP_AGE_IDENTITY" ]; then + age-keygen -o "$BACKUP_AGE_IDENTITY" + chown root:root "$BACKUP_AGE_IDENTITY" + chmod 0400 "$BACKUP_AGE_IDENTITY" + BACKUP_IDENTITY_CREATED=1 +else + BACKUP_IDENTITY_CREATED=0 +fi +BACKUP_AGE_RECIPIENT="$(age-keygen -y "$BACKUP_AGE_IDENTITY")" +if ! grep -q '^BACKUP_AGE_RECIPIENT=' "$ENV_FILE"; then + printf 'BACKUP_AGE_RECIPIENT=%s\n' "$BACKUP_AGE_RECIPIENT" >> "$ENV_FILE" +fi + # --- 4. Python env + release switch ------------------------------------------- python3 -m venv /srv/cortex/venv 2>/dev/null || true /srv/cortex/venv/bin/pip install --quiet --upgrade pip @@ -99,11 +115,16 @@ curl -fsS "http://127.0.0.1:8766/health" -H "Authorization: Bearer $(grep '^CORT echo "==> Public check (TLS may take ~30s on first issue): https://$API_DOMAIN/health" echo "==============================================================" -echo " DONE. Store these NOW (shown once):" +echo " DONE." if [ "$FIRST_INSTALL" = "1" ]; then - echo " Admin token : $(grep '^CORTEX_API_KEY=' "$ENV_FILE" | cut -d= -f2)" - echo " KEK (escrow offline + password manager): $(cat /etc/cortex/kek)" + echo " New admin token: stored in $ENV_FILE (not printed)" + echo " New KEK : stored in /etc/cortex/kek (not printed)" +fi +if [ "$BACKUP_IDENTITY_CREATED" = "1" ]; then + echo " Backup identity: stored in $BACKUP_AGE_IDENTITY (not printed)" fi +echo " Escrow the KEK and backup identity separately using an interactive," +echo " non-logged session. Do not copy them into deployment output." echo " Env file : /etc/cortex/cortex.env" echo " API : https://$API_DOMAIN (health/ready)" echo " Services : systemctl status cortex-api cortex-worker caddy" diff --git a/deploy/cortex.env.example b/deploy/cortex.env.example index 0fa7cfb8..91d9e17b 100644 --- a/deploy/cortex.env.example +++ b/deploy/cortex.env.example @@ -18,12 +18,12 @@ CORTEX_HOSTED_RUNTIME_TIER=sharded_sqlite CORTEX_WORKER_MODE=external CORTEX_SYNC_SIGNING_KEY=__SIGNING_KEY__ -# --- accounts + auth (BETA profile: signup without email infrastructure) --- +# --- accounts + auth (public-safe default) --- CORTEX_AUTH_ENABLED=1 -# Beta only: activate accounts at signup (no verification email). Turn OFF and set up -# CORTEX_AUTH_EMAIL_MODE=smtp + a transactional provider before PUBLIC launch. -CORTEX_AUTH_AUTOVERIFY=1 -CORTEX_AUTH_EMAIL_MODE=log +# Keep accounts pending until their email address is verified. Configure SMTP below +# before accepting public signups. +CORTEX_AUTH_AUTOVERIFY=0 +CORTEX_AUTH_EMAIL_MODE=smtp CORTEX_AUTH_RATE_LIMIT_PER_MINUTE=30 # GitHub login (works instantly once you create a GitHub OAuth app; callback URL: # https://api.signindoppl.com/v1/auth/oauth/github/callback) @@ -50,6 +50,12 @@ CORTEX_AUTH_RATE_LIMIT_PER_MINUTE=30 #CORTEX_SMTP_PASSWORD= #CORTEX_SMTP_FROM_ADDR=Cortex +# --- encrypted server backups --- +# bootstrap.sh generates this public recipient and stores the private age identity +# separately at /etc/cortex/backup-age.key. Never put the private identity here. +#BACKUP_AGE_RECIPIENT=age1... +#BACKUP_RCLONE_REMOTE=r2:cortex-backups + # --- per-user encryption (KEK generated by bootstrap; ESCROW IT OFFLINE) --- CORTEX_KEK_FILE=/etc/cortex/kek diff --git a/deploy/macmini/README.md b/deploy/macmini/README.md index 051449b5..d811fe8e 100644 --- a/deploy/macmini/README.md +++ b/deploy/macmini/README.md @@ -14,7 +14,8 @@ any router ports** via Cloudflare Tunnel. genuinely fine; for a big public launch, weigh the uptime risk. - **Backups MUST go off the machine.** The mini is the whole system now — if it dies or is stolen, local backups die with it. Ship backups to R2/B2 (below). -- **Escrow the KEK OFF the mini.** Same reason. (deploy/macmini/setup.sh prints it.) +- **Escrow the KEK OFF the mini.** Same reason. The setup script stores it in a + protected file and deliberately does not print it. - **ISP terms.** Cloudflare Tunnel is outbound-only (no inbound ports), which sidesteps port-blocking, but some residential ISP ToS still discourage "servers." Low-traffic betas are rarely an issue; know your ISP's rules. @@ -24,8 +25,8 @@ From the repo on the mini: ``` bash deploy/macmini/setup.sh ``` -This installs deps, generates your admin token + encryption KEK (printed once — -save them), writes `~/CortexServer/cortex.env`, and loads two launchd services +This installs deps, generates your admin token + encryption KEK in protected +files (they are deliberately not printed), writes `~/CortexServer/cortex.env`, and loads two launchd services (`com.cortex.api`, `com.cortex.worker`) that start at login and restart on crash. The API listens only on `127.0.0.1:8766` (never exposed directly). @@ -71,10 +72,18 @@ Quick test without a domain/account: `cloudflared tunnel --url http://127.0.0.1: prints a temporary `https://.trycloudflare.com` URL (ephemeral). ## Step 4 — Off-machine backups (do not skip) -The bundled backup job keeps local copies; add an offsite target so a dead mini -doesn't take the data with it. Easiest: a Backblaze B2 / Cloudflare R2 bucket + -`rclone`, then a nightly launchd job running the same WAL-safe snapshot logic as -`deploy/backup.sh` (adapt paths to `~/CortexServer`). Escrow the KEK separately. +Install `age` (`brew install age`) and generate a recovery identity: + +```bash +age-keygen -o ~/CortexServer/backup-age.key +age-keygen -y ~/CortexServer/backup-age.key +``` + +Put the printed public recipient in the backup job as `BACKUP_AGE_RECIPIENT`; escrow +the private identity and the Cortex KEK separately from the backup bucket. Add an +offsite Backblaze B2 / Cloudflare R2 target with `rclone`, then run the WAL-safe +`deploy/backup.sh` logic nightly (adapt paths to `~/CortexServer`). The script refuses +to create plaintext archives. ## Step 5 — Point the app + web at it Same as any hosted deployment: users open `https://api.signindoppl.com/account/signup` diff --git a/deploy/macmini/setup.sh b/deploy/macmini/setup.sh index 10b745f1..7d28974a 100755 --- a/deploy/macmini/setup.sh +++ b/deploy/macmini/setup.sh @@ -61,10 +61,10 @@ CORTEX_HOSTED_RUNTIME_TIER=sharded_sqlite CORTEX_WORKER_MODE=external CORTEX_SYNC_SIGNING_KEY=$SIGNING_KEY -# accounts (BETA: signup works with no email server; set 0 + SMTP for public) +# accounts (public-safe: configure SMTP before accepting signups) CORTEX_AUTH_ENABLED=1 -CORTEX_AUTH_AUTOVERIFY=1 -CORTEX_AUTH_EMAIL_MODE=log +CORTEX_AUTH_AUTOVERIFY=0 +CORTEX_AUTH_EMAIL_MODE=smtp # BETA ergonomics: captures are immediately retrievable (skip the Review inbox) so a new # user's first capture -> ask returns a cited answer without a manual approval step. @@ -170,9 +170,9 @@ done echo "==============================================================" if [ "${FIRST:-0}" = "1" ]; then - echo " SAVE THESE NOW (shown once):" - echo " Admin token : $(grep '^CORTEX_API_KEY=' "$ENV_FILE" | cut -d= -f2)" - echo " KEK (escrow OFFLINE, NOT with backups): $(cat "$KEK_FILE")" + echo " New admin token: stored in $ENV_FILE (not printed)" + echo " New KEK : stored in $KEK_FILE (not printed)" + echo " Escrow the KEK offline from an interactive, non-logged session." fi echo " Services : launchctl list | grep com.cortex" echo " Logs : $LOG_DIR/" diff --git a/docs/APPLE_RELEASE.md b/docs/APPLE_RELEASE.md index 556f726c..8ccef592 100644 --- a/docs/APPLE_RELEASE.md +++ b/docs/APPLE_RELEASE.md @@ -8,7 +8,7 @@ a packaging script. |---|---|---| | Packager | `macos/package_app_store.sh` | `macos/package_release.sh` | | Sandbox | Yes (App Sandbox entitlement) | No | -| Account / sync | No account | Sign-in required; sync optional | +| Account / sync | Sign-in required; memory sync over Swift `URLSession` | Sign-in required; sync optional | | Outbound HTTPS connectors | No — TLS stripped from the bundle | Yes | | MCP setup | Guided **manual** (no config writes) | **Automatic** config install | | Distribution | App Store Connect review | Signed + notarized + stapled DMG/ZIP | @@ -16,10 +16,11 @@ a packaging script. ## Hybrid strategy -The Mac App Store build is **LOCAL-FIRST ONLY**. It is the sandbox-compliant, -review-safe build: +The Mac App Store build is **local-first and account-gated**. It is the +sandbox-compliant, review-safe build: -- No cloud account and no sign-in. +- Required account sign-in and memory sync use the native Swift networking + layer. Local ingestion and retrieval still run inside the sandbox. - No outbound HTTPS connectors. In `app-store` mode `macos/build.sh` strips `_ssl*.so` and `ssl.py` from the bundled Python. `urllib`/`http.client` guard `import ssl`, so the backend degrades cleanly to loopback/HTTP only. The local @@ -185,7 +186,8 @@ communication): - **No auto-MCP-connect** — MCP is set up via guided **manual** steps; the app never writes into other apps' config files. -- **No cloud** — no account, no sign-in. +- **Account required** — sign-in and memory sync use native Swift networking; + connector networking in the bundled Python runtime remains disabled. - **No HTTPS connectors** — the TLS stack (`_ssl`, `ssl.py`) is removed from the bundle, so outbound HTTPS connectors are disabled. - Persistent access to external, user-selected vault folders is disabled until diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 24d0ad81..005b8534 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1,6 +1,48 @@ # Cortex Architecture -## Current Prototype +## Runtime Boundaries + +Cortex has two runtimes around one storage and retrieval core. The packaged +macOS app starts a loopback-only standard-library server. The optional hosted +plane uses FastAPI, account authentication, and per-user sharded stores. + +```mermaid +flowchart LR + subgraph Desktop["Local desktop runtime"] + UI["SwiftUI app"] + LocalAPI["standalone_server.py
127.0.0.1 only"] + MCP["Scoped MCP proxy"] + end + subgraph Core["Shared Python core"] + Review["Capture + Review"] + Retrieval["Hybrid retrieval
cite or abstain"] + Store["CortexStore"] + end + subgraph Data["User-owned local data"] + Vault["Markdown / JSON vault"] + Index["SQLite FTS5 + sqlite-vec"] + end + subgraph Hosted["Optional hosted account + sync plane"] + FastAPI["FastAPI"] + Registry["StoreRegistry
per-user shards"] + Workers["Scheduled sync workers"] + end + UI --> LocalAPI + MCP --> LocalAPI + LocalAPI --> Review --> Store + LocalAPI --> Retrieval --> Store + Store <--> Vault + Store <--> Index + FastAPI --> Registry --> Store + Workers --> Registry +``` + +The desktop app and hosted plane are separate trust boundaries. A path supplied +to the hosted API names a file on the server—not on the user's Mac—so +filesystem connectors and path-based imports are local-only. Credential-bearing +hosted connectors are restricted to their official service origins. + +## Legacy Prototype (Reference Only) The original prototype has these pieces: @@ -11,9 +53,14 @@ The original prototype has these pieces: - `mcp_server.py`: legacy prototype local stdio MCP server; packaged builds use the app-bundled `scripts/cortex_mcp_stdio.py` proxy instead - `ui.py`: Streamlit memory chat -## Productized MVP +These root-level modules are retained for reference and are not used by the +packaged app. New product work belongs in `backend/app/`, `macos/`, the SDKs, or +the integration packages. -The MVP adds a backend service and a native macOS client while preserving the extraction schema. +## Current Product + +The current product adds a backend service and native macOS client while +preserving the extraction schema. ``` macOS app @@ -115,20 +162,49 @@ Release pipeline downloadable release artifacts ``` -## Hosted Backend Migration +## Request Lifecycle + +```mermaid +sequenceDiagram + participant Source as Connected source + participant API as Local or hosted API + participant Review as Review policy + participant Store as Vault + index + participant Tool as AI tool / MCP client + + Source->>API: Capture or sync records + API->>Review: Extract typed candidate memories + alt review required + Review-->>API: Pending until user approves + else trusted explicit import + Review-->>API: Auto-approved by policy + end + API->>Store: Persist source, memory, provenance, audit event + Tool->>API: Search / context / Ask with scoped token + API->>Store: Hybrid retrieval + policy filters + alt cited evidence is sufficient + API-->>Tool: Bounded context with memory IDs and citations + else evidence is missing + API-->>Tool: Explicit abstention + end +``` + +## Hosted Backend and Scale Path -The local SQLite storage is intentionally swappable. +The current hosted beta uses the same `CortexStore` behind `StoreRegistry`, +which assigns isolated SQLite/vault shards per user. Postgres/pgvector is a +future scale target, not the current hosted implementation. -| Local Beta | Hosted Beta | -|---|---| -| User-owned local vault + SQLite index | FastAPI service with Postgres | -| FTS5 keyword search | Postgres full-text search + pgvector | -| local review status | hosted review workflow | -| local install tokens | OAuth/login + scoped API tokens | -| local vault files | durable object storage exports/backups plus relational memory rows | -| localhost API and local MCP | HTTPS API and hosted MCP | +| Local desktop | Current hosted beta | Future scale target | +|---|---|---| +| User-owned vault + SQLite index | Per-user vault/SQLite shards | Relational memory store + durable object storage | +| FTS5 + sqlite-vec | FTS5 + sqlite-vec per shard | Postgres full-text + pgvector, if benchmarks justify it | +| Loopback server + local MCP | FastAPI HTTPS + scoped account tokens | Same public contracts behind horizontally scaled services | +| Local review state | User-isolated hosted review state | Durable queues and multi-region operations | -Local beta remains SQLite/vault-first. For the 10k-user hosted path, FastAPI plus Postgres/pgvector is the default unless benchmarks prove a separate vector store is needed. +Local mode remains vault/SQLite-first. The migration seam is the store registry +and public API contract; a future database change should not alter connector, +review, citation, or client behavior. See `docs/MEMORY_BACKEND_BLUEPRINT.md` for the layered memory model and scale path across SQLite, sqlite-vec, libSQL/Turso, Postgres/pgvector, Qdrant, and LanceDB. diff --git a/docs/BENCHMARKS.md b/docs/BENCHMARKS.md new file mode 100644 index 00000000..1c7a8063 --- /dev/null +++ b/docs/BENCHMARKS.md @@ -0,0 +1,66 @@ +# Cortex Benchmarks + +Cortex treats evaluation as executable product behavior, not a marketing +number. The repository ships deterministic, offline harnesses for retrieval, +context packing, adaptation, token calibration, routing, and end-to-end agent +tasks. They run on synthetic fixtures with known answers so regressions fail in +CI without sending personal data to an external model. + +## Latest reproducible run + +Run on 2026-07-30 with Python 3.12 on Apple Silicon. All commands used the +repository's checked-in fixtures and made no model API calls. + +| Gate | Cases / checks | Result | Selected metrics | +|---|---:|---|---| +| Retrieval | 163 labeled queries | Pass | top-1 accuracy 1.000; recall@3 1.000; precision@3 0.947 | +| Relevance monotonicity | 160 reordered cases / 41 pairs | Pass | pairwise concordance 1.000; reordered top-1 1.000 | +| Context packing | 10 tasks / 30 checks | Pass | citation coverage 1.000; MRR 1.000; nDCG@k 0.888; no-leak 1.000; budget adherence 1.000 | +| Session replay | 2 sessions / 9 turns | Pass | recall@3 1.000; prefetch hit rate 0.750; delta token savings 28.5%; no resend/leak 1.000 | +| Adaptation | 7 memories / 10 derived rules | Pass | readiness score 100 | +| Token calibration | Claude + Cursor profiles | Pass | mean profile MAPE 0.0158 vs flat-estimator MAPE 0.0743 | + +## Reproduce + +```bash +make setup +.venv/bin/python scripts/retrieval_eval.py +.venv/bin/python scripts/context_pack_eval.py +.venv/bin/python scripts/adaptation_eval.py +.venv/bin/python scripts/token_calibration_eval.py +``` + +`make check` runs the fast pre-PR subset. The full CI workflow adds routing, +adapter, delivery, agent-task, connector, docs, security, and packaging gates. + +## What these numbers mean + +- **Top-1 accuracy / recall** measure whether the known relevant fixture is + returned at the expected rank. +- **Citation coverage** requires packed evidence to retain provenance. +- **No-leak** fails when a deliberately disallowed cross-project memory appears. +- **Budget adherence** checks the serialized context against its token budget. +- **Session token savings** compares delta delivery with naively resending a + full context pack every turn. +- **Calibration MAPE** measures the error between a profile's estimate and the + harness token count. + +## Limitations + +These are deterministic regression benchmarks, not proof of real-world answer +quality. The fixtures are synthetic, comparatively small, and shaped around +known Cortex behaviors. The token-calibration harness uses simulated reference +counts rather than live vendor tokenizers. Runtime varies by hardware and warm +cache state, so this page does not claim a production latency percentile. + +Before making broad quality claims, Cortex still needs: + +1. a versioned, consented corpus of real user tasks; +2. blinded human relevance judgments; +3. cold- and warm-cache latency percentiles on release hardware; +4. scale curves across vault sizes and concurrent hosted tenants; and +5. confidence intervals across repeated runs where nondeterminism is enabled. + +When results are published, record the commit, fixture version, hardware, +configuration, repetitions, and raw output. Do not compare numbers produced by +different harness versions as if they were the same experiment. diff --git a/docs/FAQ.md b/docs/FAQ.md new file mode 100644 index 00000000..6f121cdf --- /dev/null +++ b/docs/FAQ.md @@ -0,0 +1,77 @@ +# Cortex FAQ + +## Is Cortex local? + +The vault, SQLite index, embeddings, retrieval, Ask, and profile computation run +on the Mac. The direct beta requires account sign-in, and optional sync sends +content to the hosted account plane. Client-side zero-access encryption exists +but is opt-in and off by default. + +## Does Cortex call an LLM to answer questions? + +No generative model is bundled or called to write Ask responses. Ask composes +from retrieved excerpts and either returns citations or abstains. An optional +Anthropic key can be used for extraction in backend development, and an +optional OpenAI key can be used for embeddings, but neither is required for the +packaged local retrieval path. + +## Where is memory stored? + +The direct app uses: + +```text +~/Library/Application Support/Cortex/Cortex.vault/ +``` + +The Markdown/JSON records are authoritative. SQLite, FTS5, and vector indexes +are rebuildable. + +## Why did Ask return no answer? + +That is usually the cite-or-abstain gate working. Approve relevant items in +Review, verify the source synced successfully, and ask a more specific question. +Do not weaken the gate merely to force an answer. + +## Which Macs are supported? + +The current Swift target is Apple Silicon on macOS 13 or later. There is no +Intel/universal build. + +## Why does the app require sign-in if retrieval is local? + +The current direct build uses the account for identity and optional capture +sync. Local retrieval and the user-owned vault stay on the Mac. + +## Does managed Notion, Google, or Microsoft sign-in work? + +The OAuth flows are implemented, but the current direct build ships their +client IDs empty. Use an export or the supported pasted-token path until a +release explicitly configures the provider. + +## Can Cortex import PDFs? + +The parser supports PDFs when `pypdf` is installed. It is not bundled in the +current app, so PDF text extraction is not an advertised shipped capability. + +## Which AI tools can connect? + +Cortex can configure MCP access for Claude Desktop, Cursor, Windsurf, Zed, +Cline, Roo Code, VS Code Copilot, and Claude Code. Other tools can use the local +HTTP API or the Python/TypeScript SDK source packages. + +## Are the SDKs published? + +The SDKs are present and tested in this repository. Treat editable local +installation as the supported contributor path until the package guides say a +registry release is available. + +## What is pairwise digital-twin evaluation? + +It is an experimental evaluation mode that compares two candidate responses +against cited evidence about the user. It lives on +`feat/pairwise-twin-eval` and is not part of the current `main` release. + +## How do I report a bug or security problem? + +Use [SUPPORT.md](../SUPPORT.md) for normal issues and +[SECURITY.md](../SECURITY.md) for private vulnerability reports. diff --git a/docs/INSTALLER_AND_UPDATES.md b/docs/INSTALLER_AND_UPDATES.md index 6f828b6e..21de3fde 100644 --- a/docs/INSTALLER_AND_UPDATES.md +++ b/docs/INSTALLER_AND_UPDATES.md @@ -311,9 +311,9 @@ manual QA. ## Why Manual Updates First -Manual updates are acceptable for early local beta because: +Manual updates are acceptable for the current beta because: -- there is no hosted account service yet +- the shipped account service does not need control of the app bundle - internal QA builds may still be ad-hoc signed - background installation requires a dedicated update framework and separate update-signing policy diff --git a/docs/OPEN_SOURCE_READINESS.md b/docs/OPEN_SOURCE_READINESS.md new file mode 100644 index 00000000..d5952837 --- /dev/null +++ b/docs/OPEN_SOURCE_READINESS.md @@ -0,0 +1,90 @@ +# Open-Source Readiness Review + +Last reviewed: 2026-07-30 + +This is an engineering self-assessment of the repository, not a security +certification. The review used only the checked-out source tree and local +tooling. It did not depend on repository-administrator access, hosted secrets, +or live connector accounts. + +## Outcome + +The source tree is substantially easier to understand, run, test, and +contribute to. It is ready for public code review and community development. +The hosted service and signed release process remain **conditionally ready**: +the code now enforces important trust boundaries, but the external release +configuration and operational drills listed below still need owner evidence. + +## Scorecard + +Scores are a directional rubric from 0 (absent) to 100 (excellent), based on +repository evidence. + +| Area | Before | Current | Evidence | +|---|---:|---:|---| +| Documentation accuracy | 72 | 91 | Status-labelled docs, corrected account/offline/release claims, FAQ, troubleshooting | +| First-run developer experience | 35 | 89 | Python 3.12 bootstrap, lock file, root `make` workflow, actionable failures | +| Contribution experience | 15 | 88 | Contributing, conduct, support, security policy, issue forms, PR template | +| Examples and evaluation | 28 | 85 | Four runnable examples and reproducible benchmark report | +| CI coverage | 67 | 86 | Backend, SDK, OpenClaw, plugin, docs, distribution, security, and quality gates | +| Security defaults | 58 | 82 | Hosted path/origin boundaries, encrypted backups, safer public auth defaults | +| Project discoverability | 45 | 63 | Clear README navigation and project status; hosted repository metadata remains external | + +## Material Improvements + +- Made Python 3.12 setup reproducible through `make setup` and + `requirements-dev.lock`. +- Added a concise public contribution path, community standards, issue forms, + support boundaries, roadmap, and changelog. +- Added runnable Python examples for search, memory workflows, multi-agent + context, and tool discovery. +- Published deterministic benchmark results with exact reproduction commands + and limitations. +- Added CI coverage for both SDKs and the OpenClaw context plugin. +- Corrected public documentation that overstated offline, account-free, update, + and hosted behavior. +- Blocked hosted tenants from local filesystem connectors and arbitrary + connector origins at both HTTP and storage execution boundaries. +- Changed deployment backups to fail-closed `age` encryption and removed + environment secrets from backup archives. + +## Local Verification + +The fast contributor gate, documentation checks, deterministic benchmarks, +Python SDK tests, dependency consistency check, shell syntax checks, Python +compilation, CI YAML parsing, and focused hosted-security regressions pass. + +The complete backend suite was also attempted in the restricted review +environment. A clean run excluding only the real-localhost-server modules +passed 1,877 cases and all 407 parameterized subtests (7 skipped); a focused +scheduled-connector and hosted-boundary pass added 134 passing cases and 34 +subtests. Modules that start real localhost servers cannot bind sockets in that +environment, so those modules require a normal local shell or CI runner for +final confirmation. This is an environment restriction, not a request for +broader repository permissions. + +## Remaining Release Blockers + +1. Have counsel replace the legal-entity and jurisdiction placeholders and + approve the privacy policy and terms. +2. Validate managed OAuth, email delivery, domain configuration, and connector + contracts with release-owned accounts. +3. Run an encrypted backup restore drill, including separately escrowed + database and `age` recovery keys. +4. Run the complete socket-enabled backend suite and JavaScript package gates + in CI or a normal development shell. +5. Exercise signed artifact download, signature, notarization, and update + handoff against the actual release artifacts. +6. Replace synthetic-only quality claims with consented human judgments, + repeated trials, confidence intervals, and scale/latency curves. +7. Configure repository topics, description, homepage, ownership rules, and + newcomer issues through the hosting service. +8. Capture current-product screenshots and a short demo after the next signed + build so visual documentation reflects the actual shipped UI. + +## Recommendation + +**Approve for an open-source development branch and external review. Do not +represent the hosted service as fully production-approved yet.** Production +approval should follow once the legal, credentialed integration, disaster +recovery, socket-enabled CI, and signed-release evidence above is recorded. diff --git a/docs/PHASE2_SYNC_DESIGN.md b/docs/PHASE2_SYNC_DESIGN.md index faf4a75b..c64a9f1a 100644 --- a/docs/PHASE2_SYNC_DESIGN.md +++ b/docs/PHASE2_SYNC_DESIGN.md @@ -1,7 +1,3 @@ -I have verified all the load-bearing claims against the actual code. I have everything I need to produce the design. - ---- - # Phase-2 Local→Hosted Background Push-Sync — Implementation Design All claims below are grounded in the actual code. Key verified facts: @@ -216,4 +212,4 @@ Reuse `beginMenuBarWork/endMenuBarWork` (`CortexApp.swift:3971/3982`) around the - **Local backend not running:** the local read leg goes through `request()` (`CortexApp.swift:7293`), which already restarts the backend on retriable connection errors for idempotent GETs (`CortexApp.swift:7312-7316`). If still down, tick fails cleanly, cursor untouched, retries next cadence. - **Cursor integrity:** using `captures.rowid` (monotonic int) instead of the event-hash cursor avoids the ISO-timestamp tiebreaker ties and the `cursor_not_found` reset (`storage.py:15716`) that plague `/v1/sync/changes`. Cursor is cleared on sign-out so a new account never inherits a stale watermark. -**Files to touch:** `backend/app/models.py:11`, `backend/app/main.py:645` & ~`:1962`, `backend/app/storage.py` (new `capture_change_page`; reuse `save_capture:8887`, `record_sync_receipt`), `macos/Sources/CortexPushSync.swift` (new), `macos/Sources/CortexApp.swift:3625,3994,2981`, `macos/Sources/CortexCloudAuth.swift:471,522,625`. \ No newline at end of file +**Files to touch:** `backend/app/models.py:11`, `backend/app/main.py:645` & ~`:1962`, `backend/app/storage.py` (new `capture_change_page`; reuse `save_capture:8887`, `record_sync_receipt`), `macos/Sources/CortexPushSync.swift` (new), `macos/Sources/CortexApp.swift:3625,3994,2981`, `macos/Sources/CortexCloudAuth.swift:471,522,625`. diff --git a/docs/README.md b/docs/README.md index 7044d281..0db03929 100644 --- a/docs/README.md +++ b/docs/README.md @@ -32,6 +32,9 @@ When documents disagree, use this order of authority: | MCP and external tools | [MCP_INTEGRATIONS.md](MCP_INTEGRATIONS.md) · [EXTERNAL_INTEGRATIONS.md](EXTERNAL_INTEGRATIONS.md) | | Connector readiness | [CONNECTOR_COVERAGE_READINESS.md](CONNECTOR_COVERAGE_READINESS.md) | | Trust and privacy | [TRUST_CONTROLS.md](TRUST_CONTROLS.md) · [SECURITY_REVIEW.md](SECURITY_REVIEW.md) | +| Benchmarks | [BENCHMARKS.md](BENCHMARKS.md) | +| Open-source readiness | [OPEN_SOURCE_READINESS.md](OPEN_SOURCE_READINESS.md) | +| FAQ and troubleshooting | [FAQ.md](FAQ.md) · [TROUBLESHOOTING.md](TROUBLESHOOTING.md) | | Optional encrypted sync | [E2EE_SYNC_DESIGN.md](E2EE_SYNC_DESIGN.md) · [CXE1_WIRE_FORMAT.md](CXE1_WIRE_FORMAT.md) | ## Release and Operations diff --git a/docs/SECURITY_REVIEW.md b/docs/SECURITY_REVIEW.md index 1cf3eeff..3f7b7819 100644 --- a/docs/SECURITY_REVIEW.md +++ b/docs/SECURITY_REVIEW.md @@ -1,5 +1,62 @@ # Security review — accounts / OAuth / data surface (2026-07-07) +> Follow-up review: 2026-07-30. The original review below is retained for +> history; its earlier "no IDOR found" conclusion did not cover server-local +> path inputs and was incomplete. The follow-up closed the discovered +> boundaries and added regression tests. + +## Follow-up fixed (2026-07-30) + +### CRITICAL — hosted path inputs could read API-server files + +Hosted import analysis/import, Obsidian sync/write-back, coding-agent session +harvesting, and calendar file inputs accepted paths on the API server. In a +sharded deployment, that confused a user's local-machine boundary with the +server's filesystem and could expose cross-tenant or deployment data readable +by the service account. + +**Fix:** all path-based operations now fail closed unless +`CORTEX_SHARD_MODE=local`. The check exists at the FastAPI boundary and again +inside sharded `CortexStore` instances so scheduled jobs and MCP/tool calls +cannot bypass it. Hosted imports must use uploaded content rather than server +paths. Regression coverage: +`backend/tests/test_hosted_trust_boundaries.py`. + +### HIGH — connector credentials could be sent to arbitrary origins + +Credential-bearing connector requests accepted caller-controlled +`api_base_url`, `api_url`, Jira `site_url`, calendar feeds, and OAuth token +endpoints. That created SSRF and credential-exfiltration paths. + +**Fix:** hosted connector calls now allow only each provider's canonical HTTPS +API origin. Hosted Jira is restricted to HTTPS `*.atlassian.net`; local-only +Zotero, calendar feed/file, Obsidian, and agent-session operations are blocked +on sharded stores. Custom OAuth token endpoints are rejected by hosted HTTP +routes. The storage-level policy also covers saved scheduled connector jobs and +MCP/tool calls. Local mode retains explicit test/development overrides. + +### HIGH — backups contained plaintext memory and production secrets + +The server backup copied `/etc/cortex/cortex.env` and produced an unencrypted +archive. **Fix:** the environment file is excluded, backup directories and +files use `0700`/`0600` permissions under `umask 077`, and the script refuses +to run without an `age` public recipient. Bootstrap generates a dedicated age +identity whose private half must be escrowed separately; offsite copies contain +only encrypted archives. + +### HIGH — public deployment defaults auto-verified email + +Both hosted environment templates now default to +`CORTEX_AUTH_AUTOVERIFY=0` and SMTP mode. Existing installations must migrate +their persisted environment file; changing the example does not rewrite an +already-provisioned deployment. + +### MEDIUM — query-string capture could leak tokens and content to logs + +`GET /capture` accepted a token and captured content in the URL. Hosted mode +now rejects query-string capture and requires `POST /capture`; the local +compatibility surface remains available for loopback clients. + Adversarial review across 6 lenses (auth/session, OAuth+SSRF, multi-tenant isolation, admin/secrets, injection, macOS sandbox). Every candidate finding was independently verified; false positives dropped. **5 confirmed** findings. Multi-tenant isolation, admin auth, injection, and the macOS @@ -33,11 +90,11 @@ URL. Now both require an `http(s)` scheme before opening. ## Deferred (documented; not yet fixed) -### MEDIUM — `CORTEX_AUTH_AUTOVERIFY=1` enables email squatting +### MEDIUM — `CORTEX_AUTH_AUTOVERIFY=1` enables email squatting (deployment default fixed 2026-07-30) Beta profile skips email verification, so an attacker can pre-register a victim's email + password; when the real owner later tries Google/Apple, the "never silent auto-link" rule returns `link_required` and effectively locks them out of their own address. -**Fix at public launch:** set `CORTEX_AUTH_AUTOVERIFY=0` and require a real emailed verify token +**Required operator action:** set `CORTEX_AUTH_AUTOVERIFY=0` and require a real emailed verify token (the flow already exists) before an account becomes active — i.e. wire real email (Postmark/SES). Acceptable for a closed beta; **must** be flipped before opening signups broadly. @@ -47,8 +104,9 @@ when present. Replay is already bounded by the token's audience (our bundle id) Hardening (server-issued challenge nonce round-tripped by the native client) is optional; low risk. ## Verified clean -- **Multi-tenant isolation:** per-user shard resolution + scoped-token enforcement (`require_agent_access`) - on every data path; no IDOR found. +- **Multi-tenant identity routing:** per-user shard resolution + scoped-token enforcement + (`require_agent_access`) were clean in the original scope. The later + server-filesystem issue above was outside that scope and is now regression-tested. - **Admin:** `admin_auth` is a constant-time compare of `CORTEX_API_KEY`; a user token cannot reach `/v1/admin/*`; no secret leaks in responses. - **Injection:** account/metrics SQL is parameterized (incl. the new `last_active_at` subqueries and diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md new file mode 100644 index 00000000..7a656214 --- /dev/null +++ b/docs/TROUBLESHOOTING.md @@ -0,0 +1,89 @@ +# Troubleshooting + +## `make setup` says Python 3.12 is missing + +The macOS system `python3` may be 3.9 and is not supported by this repository. +Install Python 3.12 and point setup at it: + +```bash +make setup PYTHON=/absolute/path/to/python3.12 +``` + +Verify without creating a virtual environment: + +```bash +PYTHON_BIN=/absolute/path/to/python3.12 ./scripts/bootstrap_dev.sh --check +``` + +## `make run` cannot import FastAPI or uvicorn + +Run `make setup` first. `make run` intentionally uses `.venv/bin/python` instead +of silently falling back to the system interpreter. + +## Port 8766 is already in use + +The packaged app may already be running. Either quit Cortex or use a different +development port: + +```bash +CORTEX_PORT=8877 make run +``` + +Keep clients pointed at the same base URL. + +## The app opens but the full local engine is unavailable + +The default `./macos/build.sh` output is a development/UI build without the +bundled Python interpreter or Model2Vec weights. A release-like build requires +the Python 3.12 framework and `CORTEX_BUNDLE_PYTHON=1`; follow +[APPLE_RELEASE.md](APPLE_RELEASE.md). Do not distribute an ad-hoc development +build as if it were the notarized release. + +## First launch is blocked by sign-in + +The direct build sets `CortexRequireAccount=true`. Confirm the Mac can reach the +configured hosted API and that system time is correct. The sample-notes preview +is temporary and does not disable the requirement for later launches. + +## Ask returns an abstention or empty result + +1. Confirm the source shows a successful sync. +2. Approve relevant pending items in Review. +3. Ask a specific question using terms present in the source. +4. Check storage/retrieval health in Connections & Privacy. + +An abstention is expected when Cortex cannot attach relevant citations. + +## Semantic retrieval reports the hash fallback + +Development builds may not contain the Model2Vec package or weights. Release +packaging enforces the real model. Use `scripts/check_vector_runtime.py` against +the packaged app before making semantic-retrieval claims. + +## An MCP client cannot connect + +- Confirm Cortex is running and `/health` responds on loopback. +- Reinstall the integration from Connections & Privacy. +- Restart the client after its config changes. +- Check that the token is scoped for the requested operation. +- Never paste the token into a public issue. + +See [MCP_INTEGRATIONS.md](MCP_INTEGRATIONS.md) for client-specific paths. + +## A connector sign-in option is missing + +GitHub device flow is configured. Google, Microsoft, and Notion managed OAuth +flows require client IDs that are empty in the current direct build. Use the +documented token/export fallback; see +[CONNECTOR_COVERAGE_READINESS.md](CONNECTOR_COVERAGE_READINESS.md). + +## I need help without sharing memory + +Generate a sanitized support bundle from Connections & Privacy or: + +```bash +.venv/bin/python scripts/export_support_bundle.py --mode live +``` + +Review it before sharing. Never attach the vault, raw exports, or credentials. +See [SUPPORT.md](../SUPPORT.md). diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 00000000..3bc45c9d --- /dev/null +++ b/examples/README.md @@ -0,0 +1,76 @@ +# Cortex Examples + +These examples use the dependency-free Python SDK against a running Cortex +server. They are intentionally provider-neutral: Cortex supplies cited memory; +your application decides which model or agent framework consumes it. + +## Run Locally + +Terminal 1: + +```bash +make setup +make run +``` + +Terminal 2: + +```bash +export CORTEX_API_KEY=dev-local-key +.venv/bin/python examples/minimal_search.py "release checklist" +``` + +`dev-local-key` is accepted only because `make run` starts a loopback-only +development server with the explicit insecure-development flag. For the +packaged app, copy a scoped token from Connections & Privacy and never commit +it. + +## Examples + +| Example | What it demonstrates | +|---|---| +| [`minimal_search.py`](minimal_search.py) | Smallest search request with cited results | +| [`memory_workflow.py`](memory_workflow.py) | Search, cite-or-abstain Ask, and a task-specific context pack | +| [`multi_agent_context.py`](multi_agent_context.py) | Give planner/researcher/writer roles separate bounded context | +| [`tool_catalog.py`](tool_catalog.py) | Fetch the OpenAI-compatible tool catalog without calling a model | + +Every script accepts `CORTEX_BASE_URL` and `CORTEX_API_KEY`. The default base +URL is `http://127.0.0.1:8766`. + +## Integration Pattern + +```text +user task + │ + ▼ +request a cited Cortex context pack + │ + ├── no evidence → abstain or ask the user + │ + └── cited evidence → pass the bounded pack to your model/agent + │ + ▼ + keep memory IDs/citations +``` + +For model-specific function-calling examples, see the +[Python SDK guide](../sdk/python/README.md) and +[TypeScript SDK guide](../sdk/typescript/README.md). + +## Best Practices + +- Mint the narrowest token scopes the integration needs. +- Request bounded context for the current task instead of dumping the vault. +- Preserve memory IDs and source URLs when a result affects an answer. +- Treat an explicit abstention as a successful safety outcome. +- Keep the user's newest instruction above stored memory. +- Use synthetic data in demos and tests. + +## Anti-Patterns + +- Do not read the SQLite file or vault behind the user's back. +- Do not turn search results into factual claims after citations were dropped. +- Do not use the development token outside the local development server. +- Do not automatically approve high-risk connector content without explaining + the trust policy. +- Do not retry an abstention with broader and broader private context. diff --git a/examples/memory_workflow.py b/examples/memory_workflow.py new file mode 100644 index 00000000..cfbe7cf6 --- /dev/null +++ b/examples/memory_workflow.py @@ -0,0 +1,34 @@ +#!/usr/bin/env python3 +"""Run the common search -> cited Ask -> bounded context workflow.""" + +from __future__ import annotations + +import json +import os +import sys +from typing import Any + +from cortex_client import CortexClient + + +def show(label: str, value: Any) -> None: + print(f"\n## {label}") + print(json.dumps(value, indent=2, sort_keys=True)) + + +def main() -> None: + task = " ".join(sys.argv[1:]).strip() or "Draft a release checklist from prior decisions" + client = CortexClient( + base_url=os.environ.get("CORTEX_BASE_URL", "http://127.0.0.1:8766"), + token=os.environ.get("CORTEX_API_KEY", ""), + ) + show("Search", client.search(task, top_k=5)) + show("Cited answer or abstention", client.ask(task, top_k=8)) + show( + "Task context", + client.context(task, intent="draft", token_budget=1200, surface="example"), + ) + + +if __name__ == "__main__": + main() diff --git a/examples/minimal_search.py b/examples/minimal_search.py new file mode 100644 index 00000000..70945a15 --- /dev/null +++ b/examples/minimal_search.py @@ -0,0 +1,23 @@ +#!/usr/bin/env python3 +"""Search local Cortex memory and print the cited JSON result.""" + +from __future__ import annotations + +import json +import os +import sys + +from cortex_client import CortexClient + + +def main() -> None: + query = " ".join(sys.argv[1:]).strip() or "release checklist" + client = CortexClient( + base_url=os.environ.get("CORTEX_BASE_URL", "http://127.0.0.1:8766"), + token=os.environ.get("CORTEX_API_KEY", ""), + ) + print(json.dumps(client.search(query, top_k=5), indent=2, sort_keys=True)) + + +if __name__ == "__main__": + main() diff --git a/examples/multi_agent_context.py b/examples/multi_agent_context.py new file mode 100644 index 00000000..2962c07e --- /dev/null +++ b/examples/multi_agent_context.py @@ -0,0 +1,39 @@ +#!/usr/bin/env python3 +"""Build separate bounded context for three collaborating agent roles.""" + +from __future__ import annotations + +import json +import os +import sys + +from cortex_client import CortexClient + + +ROLES = { + "planner": ("plan", 900), + "researcher": ("recall", 1400), + "writer": ("draft", 1100), +} + + +def main() -> None: + task = " ".join(sys.argv[1:]).strip() or "Prepare the next Cortex beta announcement" + client = CortexClient( + base_url=os.environ.get("CORTEX_BASE_URL", "http://127.0.0.1:8766"), + token=os.environ.get("CORTEX_API_KEY", ""), + ) + packs = { + role: client.context( + f"{role}: {task}", + intent=intent, + token_budget=budget, + surface=f"example-{role}", + ) + for role, (intent, budget) in ROLES.items() + } + print(json.dumps(packs, indent=2, sort_keys=True)) + + +if __name__ == "__main__": + main() diff --git a/examples/tool_catalog.py b/examples/tool_catalog.py new file mode 100644 index 00000000..21efe037 --- /dev/null +++ b/examples/tool_catalog.py @@ -0,0 +1,29 @@ +#!/usr/bin/env python3 +"""Inspect the OpenAI-compatible Cortex tool catalog.""" + +from __future__ import annotations + +import json +import os + +from cortex_client import CortexClient + + +def main() -> None: + client = CortexClient( + base_url=os.environ.get("CORTEX_BASE_URL", "http://127.0.0.1:8766"), + token=os.environ.get("CORTEX_API_KEY", ""), + ) + tools = client.openai_tools() + summary = [ + { + "name": tool["function"]["name"], + "description": tool["function"].get("description", ""), + } + for tool in tools + ] + print(json.dumps(summary, indent=2, sort_keys=True)) + + +if __name__ == "__main__": + main() diff --git a/requirements-dev.lock b/requirements-dev.lock new file mode 100644 index 00000000..0e9f0ea4 --- /dev/null +++ b/requirements-dev.lock @@ -0,0 +1,37 @@ +# Exact Python 3.12 development/test environment. +# Refresh intentionally after reviewing upstream release notes: +# python -m pip install -r backend/requirements.txt pytest +# python -m pip freeze +annotated-doc==0.0.5 +annotated-types==0.8.0 +anthropic==0.120.2 +anyio==4.14.2 +argon2-cffi==25.1.0 +argon2-cffi-bindings==25.1.0 +certifi==2026.7.22 +cffi==2.1.0 +click==8.4.2 +cryptography==49.0.0 +distro==1.9.0 +docstring_parser==0.18.0 +fastapi==0.141.1 +h11==0.16.0 +httpcore==1.0.9 +httpx==0.28.1 +idna==3.18 +iniconfig==2.3.0 +jiter==0.16.0 +packaging==26.2 +pluggy==1.6.0 +pycparser==3.0 +pydantic==2.13.4 +pydantic_core==2.46.4 +Pygments==2.20.0 +pytest==9.1.1 +python-dotenv==1.2.2 +sniffio==1.3.1 +sqlite-vec==0.1.9 +starlette==1.3.1 +typing-inspection==0.4.2 +typing_extensions==4.16.0 +uvicorn==0.52.0 diff --git a/scripts/bootstrap_dev.sh b/scripts/bootstrap_dev.sh new file mode 100755 index 00000000..c135b4ae --- /dev/null +++ b/scripts/bootstrap_dev.sh @@ -0,0 +1,58 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +PYTHON_BIN="${PYTHON_BIN:-python3.12}" +VENV_PATH="${CORTEX_VENV:-$ROOT/.venv}" +CHECK_ONLY=0 + +if [[ "${1:-}" == "--check" ]]; then + CHECK_ONLY=1 +elif [[ $# -gt 0 ]]; then + echo "Usage: scripts/bootstrap_dev.sh [--check]" >&2 + exit 2 +fi + +if ! command -v "$PYTHON_BIN" >/dev/null 2>&1; then + cat >&2 <&2 </dev/null 2>&1; then + PYTHON_BIN="$(command -v python3.12)" +else + echo "Cortex requires Python 3.12. Run 'make setup' first." >&2 + exit 1 +fi + +VERSION="$("$PYTHON_BIN" -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")')" +if [[ "$VERSION" != "3.12" ]]; then + echo "Cortex requires Python 3.12; '$PYTHON_BIN' reports Python $VERSION. Run 'make setup'." >&2 + exit 1 +fi + +"$PYTHON_BIN" -m uvicorn app.main:app --reload --host 127.0.0.1 --port "$CORTEX_PORT" diff --git a/site/faq.html b/site/faq.html index d4aad380..3a29169d 100644 --- a/site/faq.html +++ b/site/faq.html @@ -27,11 +27,6 @@

Support

FAQ & support

-

- Draft — pending legal review before public launch. These answers describe - how the software actually behaves; the privacy answer below is a plain summary of the - Privacy page, not a legal statement. -

Short answers to the questions we hear most. Still stuck? Email support@trydoppl.com. See also the @@ -51,7 +46,7 @@

macOS won't let me open the app

Is my data private? Is it encrypted?

    -
  • Local app (the default): your memory stays on your Mac and needs no account. It works with the network off, and nothing is sent to us.
  • +
  • Local memory (the default): the shipped beta requires a one-time account sign-in, but ingestion, retrieval, and your memory vault stay on your Mac unless you enable sync.
  • Cortex Cloud (optional): your data is stored in an isolated per-account store and is encrypted at rest with a key unique to your account. This is not end-to-end or "zero-knowledge" encryption: Cortex servers can read your memory in order to build embeddings, retrieve, and serve cited context to your AI tools. That is what makes the cloud features work.
  • Full detail — including deletion by crypto-erasure and what connectors read — is on the Privacy page.
@@ -97,7 +92,7 @@

A connector says it needs to reconnect — what do I do?

Does it work offline?

    -
  • Yes. In local mode (the default) Cortex runs entirely on your Mac and works with the network off.
  • +
  • After the required sign-in, local ingestion and retrieval work with the network off. Signing in again, syncing, hosted retrieval, and connector authorization need a connection.
  • Cortex Cloud features (syncing, hosted retrieval) need a connection, since they run against the hosted backend.
@@ -105,7 +100,7 @@

Does it work offline?

How do I update the app?

    -
  • Cortex checks for updates and will prompt you when a new version is available; follow the prompt to install it.
  • +
  • Cortex checks for updates and links you to the current download. It does not replace the running app automatically.
  • You can also download the latest version from the download section and replace the app in your Applications folder.
diff --git a/site/privacy.html b/site/privacy.html index 71653745..5dcb3fa1 100644 --- a/site/privacy.html +++ b/site/privacy.html @@ -33,17 +33,17 @@

Cortex privacy

lawyer-reviewed policy.

- Cortex has two ways to run, and your data is handled differently in each. The - local-first macOS app keeps your memory on your own Mac and needs no - account. Cortex Cloud is an optional hosted tier you sign in to; it - exists to sync and serve your memory to AI tools, and is described honestly below. + Cortex has two data paths, and your data is handled differently in each. The + shipped local-first macOS app requires account sign-in, while keeping + its memory vault and retrieval engine on your Mac. Cortex Cloud is the + optional hosted sync and retrieval tier described below.

Local app — the default

  • Memory lives on your Mac at ~/Library/Application Support/Cortex/Cortex.vault/ (or a folder you choose).
  • -
  • The local app requires no account and no cloud service. It works with the network off.
  • +
  • The shipped beta requires a one-time account sign-in. After sign-in, local ingestion and retrieval work with the network off; sign-in and optional sync require the network.
  • Cortex reads clipboard content only when you trigger a capture, and a local notes folder only after you choose it.
  • It does not record your screen, listen through the microphone, or capture ambient activity.
  • Stored: memories you approve, plus extracted tasks, entities, topics, graph edges, settings, audit events, and local backups — all as readable files plus a rebuildable index.
  • diff --git a/site/terms.html b/site/terms.html index fabc7af4..f7311a57 100644 --- a/site/terms.html +++ b/site/terms.html @@ -55,7 +55,7 @@

    1. Eligibility and acceptance

    2. What Cortex is

      -
    • Local app (the default): a macOS application that keeps your memory on your own Mac and needs no account. It works with the network off. Nothing in these Terms about accounts, servers, or cloud processing applies while you use Cortex only locally.
    • +
    • Local-first app (the default): a macOS application that requires account sign-in but keeps its memory vault and retrieval engine on your own Mac. After sign-in, local ingestion and retrieval work with the network off. Cloud-processing terms apply only to account metadata and content you choose to sync.
    • Cortex Cloud (optional): a hosted service you sign in to. It syncs your memory and serves it back to your AI tools with cited context. Using it means your data is processed on our servers as described below and in the Privacy page.
    @@ -118,12 +118,11 @@

    8. Availability

    9. Paid plans (future)

    -

    Cortex may launch a paid tier. When it does, the following applies to paid plans:

    -
      -
    • Billing: payments are handled by a third-party merchant of record ([billing provider — confirm]), who processes your payment and, where a merchant of record, collects and remits applicable taxes. We receive payment metadata (not your full card details). Subscriptions renew automatically until cancelled; you can cancel at any time and keep access through the paid period you already bought.
    • -
    • Refunds: [REFUND POLICY PLACEHOLDER — state the window and conditions, e.g. "full refund within 14 days of a charge if the service was unused," and confirm the billing provider's own refund handling.]
    • -
    • EU/UK right of withdrawal: if you are a consumer in the EU/UK, you have a statutory 14-day right to withdraw from a purchase of digital services. Where you ask us to begin the service immediately, you acknowledge you may lose that right once the service is fully performed, to the extent the law allows. Nothing here limits your mandatory statutory rights.
    • -
    +

    + Cortex does not currently offer a public paid plan. Before charging users, we will + publish the billing provider, renewal, cancellation, refund, and applicable + withdrawal terms and ask affected users to accept the updated Terms. +

    From 7f27dc4482ee9503e3d8b1a4cdbf084fa7ffc6ce Mon Sep 17 00:00:00 2001 From: VamikaSinghal Date: Thu, 30 Jul 2026 04:54:55 -0700 Subject: [PATCH 3/6] Harden Cortex for open-source review --- .github/workflows/ci.yml | 10 +- CHANGELOG.md | 28 +- Makefile | 10 +- README.md | 4 +- backend/app/authn.py | 80 ++- backend/app/config.py | 3 + backend/app/connectors/github.py | 7 +- backend/app/connectors/gmail.py | 5 +- backend/app/connectors/google_drive.py | 5 +- backend/app/connectors/jira.py | 5 +- backend/app/connectors/linear.py | 5 +- backend/app/connectors/notion.py | 5 +- backend/app/connectors/outlook.py | 5 +- backend/app/connectors/raindrop.py | 5 +- backend/app/connectors/readwise.py | 5 +- backend/app/connectors/slack.py | 5 +- backend/app/connectors/zotero.py | 5 +- backend/app/delivery.py | 136 +++-- backend/app/embeddings.py | 4 +- backend/app/hosted_readiness.py | 93 ++++ backend/app/http_security.py | 65 +++ backend/app/main.py | 450 ++++++++++++++--- backend/app/mcp_tools.py | 2 +- backend/app/models.py | 14 + backend/app/oauth_broker.py | 10 +- backend/app/oidc_registry.py | 11 +- backend/app/ratelimit.py | 32 +- backend/app/source_ingest.py | 99 +++- backend/app/standalone_server.py | 166 ++++-- backend/app/storage.py | 73 ++- backend/app/vault.py | 476 +++++++++++------- backend/app/webauth.py | 115 ++++- backend/tests/test_auth_http.py | 159 +++++- backend/tests/test_authn_sessions.py | 45 ++ backend/tests/test_billing.py | 22 +- backend/tests/test_billing_stripe.py | 11 +- backend/tests/test_delivery.py | 23 + backend/tests/test_email_sender.py | 9 +- backend/tests/test_embeddings.py | 6 +- backend/tests/test_fastapi_contract.py | 125 ++++- backend/tests/test_hosted_readiness.py | 64 +++ backend/tests/test_http_security.py | 70 +++ backend/tests/test_import_scale_bounds.py | 62 +++ backend/tests/test_memory_integrity.py | 32 +- backend/tests/test_oidc_registry.py | 20 + backend/tests/test_public_pages.py | 5 +- backend/tests/test_ratelimit.py | 46 ++ backend/tests/test_standalone_server.py | 120 ++++- backend/tests/test_turnstile.py | 56 ++- backend/tests/test_vault_concurrency.py | 120 +++++ backend/tests/test_web_account.py | 8 + deploy/README.md | 3 + deploy/backup.sh | 68 ++- deploy/cortex.env.example | 8 + deploy/macmini/setup.sh | 57 ++- docs/ACCOUNTS_ENCRYPTION_DESIGN.md | 9 +- docs/API_LIFECYCLE.md | 84 ++++ docs/ARCHITECTURE.md | 11 +- docs/BENCHMARKS.md | 8 +- docs/EXTERNAL_INTEGRATIONS.md | 22 +- docs/LOCAL_VAULT_FORMAT.md | 114 ++++- docs/MCP_INTEGRATIONS.md | 4 +- docs/NAMING_AND_OWNERSHIP.md | 59 +++ docs/OPEN_SOURCE_READINESS.md | 196 ++++---- docs/PORTABLE_MEMORY_PROTOCOL_V2.md | 2 +- docs/README.md | 5 +- docs/benchmarks/2026-07-30-local.json | 83 +++ examples/README.md | 25 +- examples/fixtures/demo_captures.json | 26 + examples/memory_workflow.py | 2 +- examples/minimal_search.py | 2 +- examples/multi_agent_context.py | 2 +- examples/seed_demo.py | 96 ++++ .../obsidian-cortex-plugin/package-lock.json | 2 +- packages/obsidian-cortex-plugin/package.json | 2 +- packages/openclaw-cortex-context/README.md | 4 +- .../openclaw-cortex-context/package-lock.json | 4 +- packages/openclaw-cortex-context/package.json | 5 +- scripts/bootstrap_dev.sh | 8 +- scripts/examples_smoke.py | 206 ++++++++ scripts/rerank_eval.py | 20 +- sdk/PUBLISHING.md | 7 +- sdk/README.md | 15 +- sdk/python/README.md | 14 +- sdk/python/cortex_client/client.py | 71 ++- sdk/python/tests/test_client.py | 79 ++- sdk/typescript/README.md | 12 +- sdk/typescript/package-lock.json | 4 +- sdk/typescript/src/index.ts | 89 +++- sdk/typescript/test/client.test.ts | 143 +++++- site/faq.html | 2 +- site/subprocessors.html | 12 +- site/terms.html | 4 +- 93 files changed, 3833 insertions(+), 697 deletions(-) create mode 100644 backend/app/http_security.py create mode 100644 backend/tests/test_http_security.py create mode 100644 docs/API_LIFECYCLE.md create mode 100644 docs/NAMING_AND_OWNERSHIP.md create mode 100644 docs/benchmarks/2026-07-30-local.json create mode 100644 examples/fixtures/demo_captures.json create mode 100644 examples/seed_demo.py create mode 100644 scripts/examples_smoke.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a1c760f8..f047229d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,7 +17,9 @@ jobs: python: name: Python checks and backend tests runs-on: ubuntu-latest - timeout-minutes: 10 + # The backend suite alone approaches ten minutes on some runners; this job + # also runs SDK, examples, and evaluation gates. Preserve failure headroom. + timeout-minutes: 30 steps: - name: Check out repository @@ -56,6 +58,9 @@ jobs: - name: Run Python SDK tests run: python -m pytest sdk/python/tests -q + - name: Run public examples against an isolated server + run: python scripts/examples_smoke.py + - name: Run retrieval quality regression gate run: python scripts/retrieval_eval.py @@ -141,6 +146,9 @@ jobs: - name: Audit top-level dependencies for known vulnerabilities run: pip-audit --strict -r requirements.txt + - name: Audit packaged backend runtime dependencies + run: pip-audit --strict -r backend/runtime-requirements.txt + distribution: name: Distribution site and update manifest runs-on: ubuntu-latest diff --git a/CHANGELOG.md b/CHANGELOG.md index dbbb847d..8f00f05c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,9 +14,11 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). guidance. - Runnable Python SDK examples for search, memory workflows, multi-agent context, and tool discovery. -- Reproducible benchmark documentation with metrics, commands, and explicit - limitations. +- Benchmark documentation with deterministic regression metrics, commands, and + explicit evidence limitations. - CI gates for the Python and TypeScript SDKs and the OpenClaw context plugin. +- A generated OpenAPI contract test and explicit public API lifecycle policy. +- Deterministic local example data for validating the first-run search flow. ### Changed @@ -25,6 +27,10 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - SDK and plugin metadata points to `trace-cortex/cortex-app`. - Hosted deployment, Apple release, account, offline, update, and legal documentation now matches the behavior in code. +- `POST /v1/context` now returns the same identity fields as the corresponding + read-scoped `GET` request; exporting identity remains a separate permission. +- Mac mini installation uses a dedicated Python 3.12 virtual environment and + the current public API hostname. ### Security @@ -35,9 +41,23 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). use restrictive filesystem permissions, and fail closed without a recipient. - Public deployment examples default to verified email and disabled automatic account verification. +- Credential-bearing HTTP clients now refuse cross-origin redirects. +- Hosted readiness fails closed unless connector-credential encryption is + enforced and an active keyring is available. +- Password hashing concurrency and in-process rate-limiter cardinality are + bounded to reduce memory-exhaustion risk. +- ZIP imports enforce an aggregate uncompressed-byte budget. +- Vault read-modify-write operations and backup snapshots use an inter-process + lock, preventing silent data loss with multiple workers. +- Synchronous exports reject corpora above a configurable preflight size + instead of constructing multiple unbounded in-memory copies. ## [0.2.0] - 2026-07-24 +Release build `51` was produced from source commit +[`0f7e33ec58264b9694f4219379d7240313d9936b`](https://github.com/trace-cortex/cortex-app/commit/0f7e33ec58264b9694f4219379d7240313d9936b) +on `feat/connect-gate-redesign`, as recorded in the published update manifest. + ### Added - Developer ID signed and Apple-notarized direct-download beta. @@ -50,5 +70,5 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - Scoped MCP capabilities, default redaction, audit trails, and tamper-evident memory history. -[Unreleased]: https://github.com/trace-cortex/cortex-app/compare/v0.2.0-51...HEAD -[0.2.0]: https://github.com/trace-cortex/cortex-app/releases/tag/v0.2.0-51 +[Unreleased]: https://github.com/trace-cortex/cortex-app/compare/0f7e33ec58264b9694f4219379d7240313d9936b...HEAD +[0.2.0]: https://github.com/doppl-tech/releases/releases/tag/v0.2.0-51 diff --git a/Makefile b/Makefile index 2166403a..18405ecd 100644 --- a/Makefile +++ b/Makefile @@ -2,7 +2,7 @@ PYTHON ?= python3.12 VENV ?= .venv VENV_PYTHON := $(VENV)/bin/python -.PHONY: help setup run test check docs-check connector-check clean +.PHONY: help setup run test check docs-check connector-check examples-check clean help: @printf '%s\n' \ @@ -12,10 +12,11 @@ help: ' make test Run the backend test suite' \ ' make check Run the fast local pre-PR checks' \ ' make connector-check Validate all 13 connector contracts' \ + ' make examples-check Run every public Python example against an isolated server' \ ' make clean Remove local Python caches (keeps .venv)' setup: - PYTHON_BIN="$(PYTHON)" ./scripts/bootstrap_dev.sh + PYTHON_BIN="$(PYTHON)" CORTEX_VENV="$(abspath $(VENV))" ./scripts/bootstrap_dev.sh run: @test -x "$(VENV_PYTHON)" || { echo "Missing $(VENV_PYTHON). Run 'make setup' first." >&2; exit 1; } @@ -35,10 +36,15 @@ connector-check: @test -x "$(VENV_PYTHON)" || { echo "Missing $(VENV_PYTHON). Run 'make setup' first." >&2; exit 1; } "$(VENV_PYTHON)" scripts/check_connector_baseline.py +examples-check: + @test -x "$(VENV_PYTHON)" || { echo "Missing $(VENV_PYTHON). Run 'make setup' first." >&2; exit 1; } + "$(VENV_PYTHON)" scripts/examples_smoke.py + check: docs-check "$(VENV_PYTHON)" scripts/retrieval_eval.py "$(VENV_PYTHON)" scripts/adaptation_eval.py "$(VENV_PYTHON)" -m pytest sdk/python/tests -q + "$(VENV_PYTHON)" scripts/examples_smoke.py clean: find backend scripts sdk -type d -name __pycache__ -prune -exec rm -r {} + diff --git a/README.md b/README.md index 5b37f4fe..3b35b981 100644 --- a/README.md +++ b/README.md @@ -153,7 +153,7 @@ Approved memory goes in; a calibrated model of how you operate comes out. `consolidate_memory` tool) and requires the maintenance permission, which is off by default. - **It compounds as an asset you own.** Pin a pack and it becomes **sha256-addressed and replayable**, byte-verified on every read, so an agent can prove exactly what it acted on. Your model lives as plain - Markdown plus an index rebuildable from those files alone — vendor-portable, inspectable, exportable as + Markdown memories plus durable JSON/JSONL records, with an index rebuildable from that vault — vendor-portable, inspectable, exportable as a signed bundle, and erasable in one act. Switch assistants and it comes with you. ## Also inside @@ -255,7 +255,7 @@ build needs `CORTEX_BUNDLE_PYTHON=1` and a python.org framework install at `/Library/Frameworks/Python.framework/Versions/3.12`; release packaging lives in `macos/package_release.sh`. -CI runs the full gate set on every push — see [`.github/workflows/ci.yml`](.github/workflows/ci.yml) +CI runs the full gate set for pull requests and pushes to `main` — see [`.github/workflows/ci.yml`](.github/workflows/ci.yml) for the authoritative command list. [`SETUP.md`](SETUP.md) covers the app-side development loop. ## Repository layout diff --git a/backend/app/authn.py b/backend/app/authn.py index 2dadd61a..7f700e65 100644 --- a/backend/app/authn.py +++ b/backend/app/authn.py @@ -32,7 +32,10 @@ import hashlib import hmac import json +import os import secrets +import threading +from contextlib import contextmanager from datetime import datetime, timedelta, timezone from typing import Any, Callable, Optional @@ -79,6 +82,31 @@ def __init__(self) -> None: super().__init__("rate limited") +# Argon2id uses 64 MiB per operation. Hosted deployments accept concurrent auth +# requests, so an unbounded thread-pool burst can exhaust the service memory limit +# before per-identifier rate limits help. Bound all password work in this process; +# multi-worker deployments multiply this small cap rather than the request burst. +def _password_work_limit() -> int: + try: + configured = int(os.environ.get("CORTEX_PASSWORD_HASH_CONCURRENCY", "2") or "2") + except ValueError: + configured = 2 + return max(1, min(configured, 8)) + + +_PASSWORD_WORK_SLOTS = threading.BoundedSemaphore(_password_work_limit()) + + +@contextmanager +def _password_work_slot(): + if not _PASSWORD_WORK_SLOTS.acquire(timeout=5): + raise RateLimited() + try: + yield + finally: + _PASSWORD_WORK_SLOTS.release() + + # -------------------------------------------------------------------------- # Token discipline (mirrors sharding.TokenControlIndex exactly) # -------------------------------------------------------------------------- @@ -168,9 +196,10 @@ def __init__( # -- minting ----------------------------------------------------------- def hash(self, password: str) -> str: - if self._hasher is not None: - return self._hasher.hash(password) - return self._scrypt_hash(password) + with _password_work_slot(): + if self._hasher is not None: + return self._hasher.hash(password) + return self._scrypt_hash(password) def _scrypt_hash(self, password: str) -> str: salt = secrets.token_bytes(16) @@ -194,28 +223,29 @@ def _scrypt_derive(password: str, salt: bytes, ln: int, r: int, p: int) -> bytes def verify(self, phc: str, password: str) -> bool: if not phc or not isinstance(phc, str): return False - if phc.startswith("$argon2"): - if self._argon2_verifier is None: - return False - try: - return bool(self._argon2_verifier.verify(phc, password)) - except ( - _argon2_exceptions.VerificationError, - _argon2_exceptions.InvalidHashError, - ValueError, - ): - return False - if phc.startswith("$scrypt$"): - parsed = self._parse_scrypt(phc) - if parsed is None: - return False - ln, r, p, salt, expected = parsed - try: - derived = self._scrypt_derive(password, salt, ln, r, p) - except (ValueError, MemoryError): - return False - return hmac.compare_digest(derived, expected) - return False + with _password_work_slot(): + if phc.startswith("$argon2"): + if self._argon2_verifier is None: + return False + try: + return bool(self._argon2_verifier.verify(phc, password)) + except ( + _argon2_exceptions.VerificationError, + _argon2_exceptions.InvalidHashError, + ValueError, + ): + return False + if phc.startswith("$scrypt$"): + parsed = self._parse_scrypt(phc) + if parsed is None: + return False + ln, r, p, salt, expected = parsed + try: + derived = self._scrypt_derive(password, salt, ln, r, p) + except (ValueError, MemoryError): + return False + return hmac.compare_digest(derived, expected) + return False @staticmethod def _parse_scrypt(phc: str) -> tuple[int, int, int, bytes, bytes] | None: diff --git a/backend/app/config.py b/backend/app/config.py index c05c46b4..8d375531 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -80,6 +80,8 @@ class Settings: auth_access_ttl_seconds: int = 0 # 0 = authn.py default (1h) auth_refresh_idle_ttl_seconds: int = 0 # 0 = authn.py default (30d sliding) auth_refresh_absolute_ttl_seconds: int = 0 # 0 = authn.py default (90d absolute) + # Fail closed for hosted account creation until approved legal text is live. + legal_terms_approved: bool = False auth_email_mode: str = "log" # "log" (console sink) | "smtp" # SMTP delivery (used only when auth_email_mode == "smtp"). Read from # CORTEX_SMTP_*. If mode is "smtp" but no host is configured, AuthRuntime @@ -277,6 +279,7 @@ def load_settings() -> Settings: auth_access_ttl_seconds=max(0, int(os.environ.get("CORTEX_AUTH_ACCESS_TTL_SECONDS", "0") or "0")), auth_refresh_idle_ttl_seconds=max(0, int(os.environ.get("CORTEX_AUTH_REFRESH_IDLE_TTL_SECONDS", "0") or "0")), auth_refresh_absolute_ttl_seconds=max(0, int(os.environ.get("CORTEX_AUTH_REFRESH_ABSOLUTE_TTL_SECONDS", "0") or "0")), + legal_terms_approved=_truthy_env("CORTEX_LEGAL_TERMS_APPROVED"), auth_email_mode=(os.environ.get("CORTEX_AUTH_EMAIL_MODE", "log").strip().lower() or "log"), smtp_host=os.environ.get("CORTEX_SMTP_HOST", "").strip(), smtp_port=max(1, int(os.environ.get("CORTEX_SMTP_PORT", "587") or "587")), diff --git a/backend/app/connectors/github.py b/backend/app/connectors/github.py index 39cb0ce5..9802b6e4 100644 --- a/backend/app/connectors/github.py +++ b/backend/app/connectors/github.py @@ -5,8 +5,9 @@ import re from typing import Any, Callable from urllib.parse import urlencode, urlparse -from urllib.request import Request, urlopen +from urllib.request import Request +from ..http_security import open_same_origin from ._redaction import connector_error_payload @@ -45,7 +46,7 @@ def _github_form_post(url: str, form: dict[str, str]) -> dict[str, Any]: headers={"Content-Type": "application/x-www-form-urlencoded", "Accept": "application/json"}, method="POST", ) - with urlopen(request, timeout=30) as response: # noqa: S310 — fixed GitHub OAuth endpoints only + with open_same_origin(request, timeout=30) as response: return json.loads(response.read().decode("utf-8")) @@ -418,7 +419,7 @@ def fetch_github_records( def _request_json(url: str, headers: dict[str, str]) -> Any: request = Request(url, headers=headers, method="GET") - with urlopen(request, timeout=30) as response: # noqa: S310 - user-provided token, trusted GitHub API URL by default. + with open_same_origin(request, timeout=30) as response: return json.loads(response.read().decode("utf-8")) diff --git a/backend/app/connectors/gmail.py b/backend/app/connectors/gmail.py index 66355477..da574520 100644 --- a/backend/app/connectors/gmail.py +++ b/backend/app/connectors/gmail.py @@ -9,9 +9,10 @@ import re from typing import Any, Callable from urllib.parse import quote, urlencode -from urllib.request import Request, urlopen +from urllib.request import Request from ..config import APP_BRAND +from ..http_security import open_same_origin from ._redaction import connector_error_payload @@ -170,7 +171,7 @@ def fetch_gmail_records( def _request_json(url: str, headers: dict[str, str]) -> Any: request = Request(url, headers=headers, method="GET") - with urlopen(request, timeout=30) as response: # noqa: S310 - trusted Gmail API URL by default. + with open_same_origin(request, timeout=30) as response: return json.loads(response.read().decode("utf-8")) diff --git a/backend/app/connectors/google_drive.py b/backend/app/connectors/google_drive.py index 524c4e49..21de43d7 100644 --- a/backend/app/connectors/google_drive.py +++ b/backend/app/connectors/google_drive.py @@ -7,9 +7,10 @@ import re from typing import Any, Callable from urllib.parse import quote, urlencode -from urllib.request import Request, urlopen +from urllib.request import Request from ..config import APP_BRAND +from ..http_security import open_same_origin from ._redaction import connector_error_payload @@ -185,7 +186,7 @@ def fetch_google_drive_records( def _request_value(url: str, headers: dict[str, str]) -> Any: request = Request(url, headers=headers, method="GET") - with urlopen(request, timeout=30) as response: # noqa: S310 - trusted Google API URL by default. + with open_same_origin(request, timeout=30) as response: body = response.read().decode("utf-8", errors="replace") content_type = response.headers.get("Content-Type", "") if "application/json" in content_type: diff --git a/backend/app/connectors/jira.py b/backend/app/connectors/jira.py index 9f735f26..293f663a 100644 --- a/backend/app/connectors/jira.py +++ b/backend/app/connectors/jira.py @@ -7,8 +7,9 @@ import re from typing import Any, Callable from urllib.parse import urlsplit, urlunsplit -from urllib.request import Request, urlopen +from urllib.request import Request +from ..http_security import open_same_origin from ._redaction import classify_error_message, connector_error_payload @@ -184,7 +185,7 @@ def fetch_jira_records( def _request_json(url: str, headers: dict[str, str], body: dict[str, Any]) -> Any: request = Request(url, data=json.dumps(body).encode("utf-8"), headers=headers, method="POST") - with urlopen(request, timeout=30) as response: # noqa: S310 - trusted Jira site URL provided by the user. + with open_same_origin(request, timeout=30) as response: return json.loads(response.read().decode("utf-8")) diff --git a/backend/app/connectors/linear.py b/backend/app/connectors/linear.py index 14301206..3a84c65e 100644 --- a/backend/app/connectors/linear.py +++ b/backend/app/connectors/linear.py @@ -4,8 +4,9 @@ import json import re from typing import Any, Callable -from urllib.request import Request, urlopen +from urllib.request import Request +from ..http_security import open_same_origin from ._redaction import classify_error_message, connector_error_payload, redact_error_message @@ -170,7 +171,7 @@ def fetch_linear_records( def _request_json(url: str, headers: dict[str, str], body: dict[str, Any]) -> Any: request = Request(url, data=json.dumps(body).encode("utf-8"), headers=headers, method="POST") - with urlopen(request, timeout=30) as response: # noqa: S310 - trusted Linear API URL by default. + with open_same_origin(request, timeout=30) as response: return json.loads(response.read().decode("utf-8")) diff --git a/backend/app/connectors/notion.py b/backend/app/connectors/notion.py index 282009dd..39d6f8c8 100644 --- a/backend/app/connectors/notion.py +++ b/backend/app/connectors/notion.py @@ -5,8 +5,9 @@ import re from typing import Any, Callable from urllib.parse import urlencode -from urllib.request import Request, urlopen +from urllib.request import Request +from ..http_security import open_same_origin from ._redaction import connector_error_payload @@ -160,7 +161,7 @@ def fetch_notion_records( def _request_json(url: str, headers: dict[str, str], body: dict[str, Any] | None, method: str) -> Any: data = json.dumps(body or {}).encode("utf-8") if body is not None else None request = Request(url, data=data, headers=headers, method=method) - with urlopen(request, timeout=30) as response: # noqa: S310 - trusted Notion API URL by default. + with open_same_origin(request, timeout=30) as response: return json.loads(response.read().decode("utf-8")) diff --git a/backend/app/connectors/outlook.py b/backend/app/connectors/outlook.py index 8c84863a..20221f78 100644 --- a/backend/app/connectors/outlook.py +++ b/backend/app/connectors/outlook.py @@ -7,9 +7,10 @@ import re from typing import Any, Callable from urllib.parse import quote, urlencode -from urllib.request import Request, urlopen +from urllib.request import Request from ..config import APP_BRAND +from ..http_security import open_same_origin from ._redaction import connector_error_payload @@ -161,7 +162,7 @@ def fetch_outlook_records( def _request_json(url: str, headers: dict[str, str]) -> Any: request = Request(url, headers=headers, method="GET") - with urlopen(request, timeout=30) as response: # noqa: S310 - trusted Microsoft Graph URL by default. + with open_same_origin(request, timeout=30) as response: return json.loads(response.read().decode("utf-8")) diff --git a/backend/app/connectors/raindrop.py b/backend/app/connectors/raindrop.py index 8abb17fb..44b7c7e8 100644 --- a/backend/app/connectors/raindrop.py +++ b/backend/app/connectors/raindrop.py @@ -5,8 +5,9 @@ import re from typing import Any, Callable from urllib.parse import urlencode -from urllib.request import Request, urlopen +from urllib.request import Request +from ..http_security import open_same_origin from ._redaction import connector_error_payload @@ -186,7 +187,7 @@ def fetch_raindrop_records( def _request_json(url: str, headers: dict[str, str]) -> Any: request = Request(url, headers=headers, method="GET") - with urlopen(request, timeout=30) as response: # noqa: S310 - trusted Raindrop API URL by default. + with open_same_origin(request, timeout=30) as response: return json.loads(response.read().decode("utf-8")) diff --git a/backend/app/connectors/readwise.py b/backend/app/connectors/readwise.py index 0588164d..359d2a23 100644 --- a/backend/app/connectors/readwise.py +++ b/backend/app/connectors/readwise.py @@ -5,8 +5,9 @@ import re from typing import Any, Callable from urllib.parse import urlencode -from urllib.request import Request, urlopen +from urllib.request import Request +from ..http_security import open_same_origin from ._redaction import classify_error_message, connector_error_payload, redact_error_message @@ -154,7 +155,7 @@ def fetch_readwise_records( def _request_json(url: str, headers: dict[str, str]) -> Any: request = Request(url, headers=headers, method="GET") - with urlopen(request, timeout=30) as response: # noqa: S310 - trusted Readwise API URL by default. + with open_same_origin(request, timeout=30) as response: return json.loads(response.read().decode("utf-8")) diff --git a/backend/app/connectors/slack.py b/backend/app/connectors/slack.py index 3ddfc161..c6ec34fd 100644 --- a/backend/app/connectors/slack.py +++ b/backend/app/connectors/slack.py @@ -7,8 +7,9 @@ import re from typing import Any, Callable from urllib.parse import urlencode -from urllib.request import Request, urlopen +from urllib.request import Request +from ..http_security import open_same_origin from ._redaction import connector_error_payload @@ -409,7 +410,7 @@ def _fetch_thread_reply_records( def _request_json(url: str, headers: dict[str, str]) -> Any: request = Request(url, headers=headers, method="GET") - with urlopen(request, timeout=30) as response: # noqa: S310 - user-provided token, trusted Slack API URL by default. + with open_same_origin(request, timeout=30) as response: return json.loads(response.read().decode("utf-8")) diff --git a/backend/app/connectors/zotero.py b/backend/app/connectors/zotero.py index 833924f9..27306042 100644 --- a/backend/app/connectors/zotero.py +++ b/backend/app/connectors/zotero.py @@ -6,8 +6,9 @@ import re from typing import Any, Callable from urllib.parse import urlencode -from urllib.request import Request, urlopen +from urllib.request import Request +from ..http_security import open_same_origin from ._redaction import connector_error_payload @@ -180,7 +181,7 @@ def fetch_zotero_records( def _request_json(url: str, headers: dict[str, str]) -> Any: request = Request(url, headers=headers, method="GET") - with urlopen(request, timeout=30) as response: # noqa: S310 - trusted Zotero local/Web API URL by default. + with open_same_origin(request, timeout=30) as response: return json.loads(response.read().decode("utf-8")) diff --git a/backend/app/delivery.py b/backend/app/delivery.py index 8949097b..8189cbe5 100644 --- a/backend/app/delivery.py +++ b/backend/app/delivery.py @@ -11,18 +11,18 @@ - is gated at the endpoint by the export scope + the allow_agent_exports trust toggle (default off) and audited. -Stdlib-only (urllib + socket + ipaddress). The HTTP sender is injectable for deterministic tests. +Stdlib-only (http.client + socket + ipaddress). The HTTP sender is injectable for deterministic tests. """ from __future__ import annotations import ipaddress +import http.client import json import os import socket +import ssl from typing import Any, Callable -from urllib.parse import urlparse -from urllib.request import Request, urlopen - +from urllib.parse import SplitResult, urlsplit DELIVERY_KINDS = ("brief", "digest", "context") _DEFAULT_TIMEOUT = 15 @@ -32,35 +32,107 @@ def _allow_internal_targets() -> bool: return os.environ.get("CORTEX_DELIVERY_ALLOW_INTERNAL", "").strip().lower() in {"1", "true", "on", "yes"} -def is_safe_webhook_url(url: str) -> tuple[bool, str]: - """SSRF guard. Returns (ok, reason). Refuses non-http(s), credential-bearing, and — unless - CORTEX_DELIVERY_ALLOW_INTERNAL is set (local dev) — any target that resolves to a loopback, - private, link-local, reserved, or multicast address. Public targets must be https.""" +def _validated_webhook_target(url: str) -> tuple[SplitResult | None, str | None, str]: + """Validate and resolve once, returning the exact address to connect to.""" raw = str(url or "").strip() - parsed = urlparse(raw) + parsed = urlsplit(raw) if parsed.scheme not in {"http", "https"}: - return False, "URL must be http(s)" - if "@" in (parsed.netloc or ""): - return False, "credentials in URL are not allowed" + return None, None, "URL must be http(s)" + if parsed.username is not None or parsed.password is not None: + return None, None, "credentials in URL are not allowed" host = parsed.hostname if not host: - return False, "URL has no host" - if _allow_internal_targets(): - return True, "" + return None, None, "URL has no host" try: - infos = socket.getaddrinfo(host, parsed.port or (443 if parsed.scheme == "https" else 80), proto=socket.IPPROTO_TCP) - except OSError: - return False, "host does not resolve" + port = parsed.port or (443 if parsed.scheme == "https" else 80) + except ValueError: + return None, None, "URL has an invalid port" + try: + infos = socket.getaddrinfo(host, port, type=socket.SOCK_STREAM, proto=socket.IPPROTO_TCP) + except (OSError, ValueError): + return None, None, "host does not resolve" + addresses: list[str] = [] for info in infos: try: ip = ipaddress.ip_address(info[4][0]) except ValueError: - return False, "unresolvable address" - if ip.is_loopback or ip.is_private or ip.is_link_local or ip.is_reserved or ip.is_multicast or ip.is_unspecified: - return False, "target resolves to an internal address" - if parsed.scheme != "https": - return False, "public targets must use https" - return True, "" + return None, None, "unresolvable address" + # ``is_private`` does not cover every non-public range (notably + # 100.64.0.0/10 shared address space). Public delivery accepts only + # globally routable addresses. + if not _allow_internal_targets() and not ip.is_global: + return None, None, "target resolves to an internal address" + addresses.append(str(ip)) + if not addresses: + return None, None, "host does not resolve" + if not _allow_internal_targets() and parsed.scheme != "https": + return None, None, "public targets must use https" + return parsed, addresses[0], "" + + +def is_safe_webhook_url(url: str) -> tuple[bool, str]: + """Refuse unsafe webhook targets and require public HTTPS.""" + parsed, _address, reason = _validated_webhook_target(url) + return parsed is not None, reason + + +class _PinnedHTTPConnection(http.client.HTTPConnection): + def __init__(self, host: str, port: int, pinned_address: str, *, timeout: int) -> None: + self._pinned_address = pinned_address + super().__init__(host, port, timeout=timeout) + + def connect(self) -> None: + self.sock = socket.create_connection( + (self._pinned_address, self.port), + self.timeout, + self.source_address, + ) + + +class _PinnedHTTPSConnection(http.client.HTTPSConnection): + def __init__(self, host: str, port: int, pinned_address: str, *, timeout: int) -> None: + self._pinned_address = pinned_address + super().__init__( + host, + port, + timeout=timeout, + context=ssl.create_default_context(), + ) + + def connect(self) -> None: + raw_socket = socket.create_connection( + (self._pinned_address, self.port), + self.timeout, + self.source_address, + ) + # TLS verifies the original hostname even though the TCP connection is + # pinned to the already-validated address. + self.sock = self._context.wrap_socket(raw_socket, server_hostname=self.host) + + +def _post_to_pinned_target( + parsed: SplitResult, + address: str, + body: bytes, + headers: dict[str, str], + *, + timeout: int, +) -> int: + host = parsed.hostname or "" + port = parsed.port or (443 if parsed.scheme == "https" else 80) + connection_cls = _PinnedHTTPSConnection if parsed.scheme == "https" else _PinnedHTTPConnection + connection = connection_cls(host, port, address, timeout=timeout) + target = parsed.path or "/" + if parsed.query: + target += f"?{parsed.query}" + request_headers = {**headers, "Host": parsed.netloc} + try: + connection.request("POST", target, body=body, headers=request_headers) + response = connection.getresponse() + response.read(4096) + return int(response.status) + finally: + connection.close() def build_delivery_payload( @@ -101,8 +173,8 @@ def deliver_webhook( ) -> dict[str, Any]: """Deliver a payload to a webhook (POST JSON). SSRF-guarded. `request_fn(url, body, headers) -> status_code` is injectable for tests. Returns {ok, status, reason}.""" - safe, reason = is_safe_webhook_url(url) - if not safe: + parsed, pinned_address, reason = _validated_webhook_target(url) + if parsed is None or pinned_address is None: return {"ok": False, "status": 0, "reason": reason} body = json.dumps(payload, ensure_ascii=False).encode("utf-8") headers = {"Content-Type": "application/json", "User-Agent": "Cortex-Delivery/1"} @@ -110,9 +182,13 @@ def deliver_webhook( if request_fn is not None: status = int(request_fn(url, body, headers)) else: - req = Request(url, data=body, headers=headers, method="POST") # noqa: S310 — SSRF-guarded above - with urlopen(req, timeout=timeout) as response: # noqa: S310 - status = int(getattr(response, "status", 0) or 0) - except OSError as exc: + status = _post_to_pinned_target( + parsed, + pinned_address, + body, + headers, + timeout=timeout, + ) + except (OSError, http.client.HTTPException, ssl.SSLError) as exc: return {"ok": False, "status": 0, "reason": f"delivery failed: {exc}"} return {"ok": 200 <= status < 300, "status": status, "reason": "" if 200 <= status < 300 else f"HTTP {status}"} diff --git a/backend/app/embeddings.py b/backend/app/embeddings.py index 5d1cd135..67e7d214 100644 --- a/backend/app/embeddings.py +++ b/backend/app/embeddings.py @@ -13,6 +13,8 @@ from pathlib import Path from typing import Any +from .http_security import open_same_origin + VECTOR_DIMENSIONS = 384 VECTOR_MODEL = "cortex-hash-v1" @@ -298,7 +300,7 @@ def _openai_embedding(text: str, dimensions: int) -> EmbeddingResult: method="POST", ) try: - with urllib.request.urlopen(request, timeout=_openai_timeout_seconds()) as response: + with open_same_origin(request, timeout=_openai_timeout_seconds()) as response: body = json.loads(response.read().decode("utf-8")) except urllib.error.HTTPError as exc: detail = exc.read().decode("utf-8", errors="replace") diff --git a/backend/app/hosted_readiness.py b/backend/app/hosted_readiness.py index ba3d9355..3d289937 100644 --- a/backend/app/hosted_readiness.py +++ b/backend/app/hosted_readiness.py @@ -26,6 +26,15 @@ def hosted_readiness_contract(settings: Settings, runtime: dict | None = None) - checks = [ _scoped_token_check(hosted_mode, requires_scoped_tokens), + _credential_encryption_check( + hosted_mode, + bool(getattr(settings, "require_encrypted_credentials", False)), + runtime, + ), + _legal_terms_check( + hosted_mode, + bool(getattr(settings, "legal_terms_approved", False)), + ), _public_base_url_check(hosted_mode, settings.public_base_url), _sync_signing_key_check(hosted_mode, settings.sync_signing_key), _hosted_database_check(hosted_mode, settings.hosted_database_url, runtime_tier, shard_mode), @@ -50,6 +59,90 @@ def hosted_readiness_contract(settings: Settings, runtime: dict | None = None) - } +def _credential_encryption_check( + hosted_mode: bool, + enforcement_enabled: bool, + runtime: dict | None, +) -> dict: + if not hosted_mode: + return { + "name": "credential_encryption", + "status": "ok", + "detail": "Local mode may keep connector credentials in the user-owned local vault.", + } + evidence = (runtime or {}).get("credential_encryption") if isinstance(runtime, dict) else None + keyring_available = bool( + isinstance(evidence, dict) and evidence.get("keyring_available") + ) + remaining_plaintext = ( + evidence.get("remaining_plaintext") if isinstance(evidence, dict) else None + ) + scan_complete = bool(isinstance(evidence, dict) and evidence.get("scan_complete")) + unreadable_files = ( + int(evidence.get("unreadable_files") or 0) if isinstance(evidence, dict) else 0 + ) + invalid_records = ( + int(evidence.get("invalid_records") or 0) if isinstance(evidence, dict) else 0 + ) + if ( + enforcement_enabled + and keyring_available + and remaining_plaintext == 0 + and scan_complete + and unreadable_files == 0 + and invalid_records == 0 + ): + return { + "name": "credential_encryption", + "status": "ok", + "detail": ( + "Hosted credential encryption is enforced, the runtime keyring is " + "available, and the complete credential scan found no plaintext." + ), + } + missing = [] + if not enforcement_enabled: + missing.append("CORTEX_REQUIRE_ENCRYPTED_CREDENTIALS=1") + if not keyring_available: + missing.append("an available KEK-backed runtime keyring") + if remaining_plaintext is None: + missing.append("a complete plaintext-credential scan") + elif remaining_plaintext != 0: + missing.append(f"migration of {remaining_plaintext} remaining plaintext credential(s)") + if not scan_complete: + missing.append("complete (non-truncated) credential scan coverage") + if unreadable_files: + missing.append(f"repair of {unreadable_files} unreadable credential file(s)") + if invalid_records: + missing.append(f"repair of {invalid_records} invalid credential record(s)") + return { + "name": "credential_encryption", + "status": "blocked", + "detail": "Hosted readiness requires " + " and ".join(missing) + ".", + } + + +def _legal_terms_check(hosted_mode: bool, approved: bool) -> dict: + if not hosted_mode or approved: + return { + "name": "legal_terms", + "status": "ok", + "detail": ( + "Hosted legal terms are marked approved by the operator." + if hosted_mode + else "Local mode does not expose hosted public account creation." + ), + } + return { + "name": "legal_terms", + "status": "blocked", + "detail": ( + "Public signup is disabled until counsel-approved Terms and Privacy " + "are deployed and CORTEX_LEGAL_TERMS_APPROVED=1 is set." + ), + } + + def _scoped_token_check(hosted_mode: bool, requires_scoped_tokens: bool) -> dict: if hosted_mode and not requires_scoped_tokens: return { diff --git a/backend/app/http_security.py b/backend/app/http_security.py new file mode 100644 index 00000000..9b3195d4 --- /dev/null +++ b/backend/app/http_security.py @@ -0,0 +1,65 @@ +"""Safe HTTP primitives for requests that carry credentials. + +``urllib`` forwards request headers across redirects by default, including +``Authorization``. Connector and OAuth requests must therefore reject any +redirect that changes origin; otherwise an API response could exfiltrate a +user's bearer token or client secret to another host. +""" + +from __future__ import annotations + +from typing import Any +from urllib.error import HTTPError +from urllib.parse import urljoin, urlsplit +from urllib.request import HTTPRedirectHandler, Request, build_opener + + +def _origin(url: str) -> tuple[str, str, int | None] | None: + parsed = urlsplit(str(url or "").strip()) + if ( + parsed.scheme not in {"http", "https"} + or not parsed.hostname + or parsed.username is not None + or parsed.password is not None + ): + return None + try: + explicit_port = parsed.port + except ValueError: + return None + port = explicit_port + if port is None: + port = 443 if parsed.scheme == "https" else 80 + return parsed.scheme, parsed.hostname.lower(), port + + +class SameOriginRedirectHandler(HTTPRedirectHandler): + """Allow redirects only when scheme, hostname, and effective port match.""" + + def redirect_request( # type: ignore[override] + self, + req: Request, + fp: Any, + code: int, + msg: str, + headers: Any, + newurl: str, + ) -> Request | None: + resolved = urljoin(req.full_url, str(newurl or "")) + if _origin(req.full_url) != _origin(resolved): + raise HTTPError( + req.full_url, + code, + "cross-origin redirect blocked for credential-bearing request", + headers, + fp, + ) + return super().redirect_request(req, fp, code, msg, headers, resolved) + + +def open_same_origin(request: Request, *, timeout: float = 30) -> Any: + """Open a request while preventing credentials from crossing origins.""" + + if _origin(request.full_url) is None: + raise ValueError("credential-bearing request URL must be absolute HTTP(S) without user info") + return build_opener(SameOriginRedirectHandler()).open(request, timeout=timeout) diff --git a/backend/app/main.py b/backend/app/main.py index 958b6205..337f25ae 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -10,7 +10,9 @@ import json import logging import os +from pathlib import Path import secrets +import threading import time from typing import TYPE_CHECKING, Any from urllib.parse import parse_qs, urlencode @@ -19,6 +21,8 @@ from fastapi.encoders import jsonable_encoder from fastapi.responses import HTMLResponse, JSONResponse, PlainTextResponse, RedirectResponse, StreamingResponse from fastapi.middleware.cors import CORSMiddleware +from fastapi.routing import APIRoute +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from .accounts import iso_utc, utc_now from .authn import ( @@ -37,6 +41,7 @@ ) from .extractor import extract_context from .hosted_readiness import hosted_readiness_contract +from .http_security import open_same_origin from .mcp_tools import ( CORE_TOOL_NAMES, MCP_TOOL_SURFACES, @@ -55,6 +60,7 @@ from .observability import metrics, route_label from .models import AgentSessionsSyncRequest, AgentSessionsSyncResponse, APITokenListResponse, APITokenRegistrationRequest, APITokenRegistrationResponse, APITokenRevokeResponse, AskResponse, BackupResponse, CalendarSyncRequest, CalendarSyncResponse, CaptureRequest, CaptureResponse, ContextReuseRequest, ContextReuseResponse, DataLifecycleReportResponse, DiagnosticsResponse, GitHubRepositoryDiscoveryRequest, GitHubRepositoryDiscoveryResponse, GitHubSyncRequest, GitHubSyncResponse, GmailSyncRequest, GmailSyncResponse, GoogleDriveSyncRequest, GoogleDriveSyncResponse, GoogleOAuthCompleteRequest, GoogleOAuthCompleteResponse, GoogleOAuthStartRequest, GoogleOAuthStartResponse, GraphResponse, JiraSyncRequest, JiraSyncResponse, JobRunResponse, LinearSyncRequest, LinearSyncResponse, ListResponse, MaintenanceResponse, ManagedOAuthCompleteRequest, ManagedOAuthCompleteResponse, ManagedOAuthStartRequest, ManagedOAuthStartResponse, MCPTokenRegistrationRequest, MCPTokenRegistrationResponse, MemoryQualityResponse, NotionSyncRequest, NotionSyncResponse, ObsidianVaultSyncRequest, ObsidianVaultSyncResponse, OutlookSyncRequest, OutlookSyncResponse, ProductLoopResponse, QueuedCaptureResponse, RaindropSyncRequest, RaindropSyncResponse, ReadwiseSyncRequest, ReadwiseSyncResponse, ReliabilityReportResponse, RepairStorageResponse, SearchResponse, SettingsResponse, SettingsUpdateRequest, SlackChannelDiscoveryRequest, SlackChannelDiscoveryResponse, SlackSyncRequest, SlackSyncResponse, SourceAccountListResponse, SourceAccountRequest, SourceAccountResponse, SourceAccountSyncRequest, SourceAccountSyncResponse, SourceAnalyzeRequest, SourceAnalyzeResponse, SourceImportDeleteResponse, SourceImportRequest, SourceImportResponse, SourceReadinessResponse, StatsResponse, SupportBundleResponse, SyncChangeFeedResponse, SyncCursorListResponse, SyncCursorRequest, SyncCursorResponse, SyncDeviceListResponse, SyncDeviceRequest, SyncDeviceResponse, SyncReceiptListResponse, SyncReceiptRequest, SyncReceiptResponse, VaultRebuildResponse, VectorRebuildResponse, ZoteroSyncRequest, ZoteroSyncResponse from .models import UserListResponse, UserProvisionRequest, UserProvisionResponse, UserStatusResponse +from .models import ContextRequest from .models import CaptureChangePage, SyncIngestRequest, SyncIngestResponse from .models import SyncDeletionPage, SyncDeletionApplyRequest, SyncDeletionApplyResponse from .models import GradeAnswerRequest, WouldIRequest, DraftAsMeRequest, GradeTwinPredictionRequest @@ -66,7 +72,7 @@ from .oidc_registry import OidcError, OidcProviderRegistry from .ratelimit import TokenBucketRateLimiter from .sharding import StoreRegistry -from .storage import BACKEND_VERSION +from .storage import BACKEND_VERSION, ExportSizeLimitError from .webauth import ( FAVICON_SVG, PUBLIC_PAGE_CSP, @@ -82,6 +88,8 @@ metrics.configure(settings.observability_enabled) store = StoreRegistry.from_settings(settings) rate_limiter = TokenBucketRateLimiter(settings.rate_limit_per_minute) +_credential_evidence_cache: dict[str, Any] = {"key": None, "at": 0.0, "value": None} +_credential_evidence_cache_lock = threading.Lock() store.ensure_vault_backfilled(settings.default_user_id) if settings.mcp_api_key: store.ensure_mcp_token( @@ -92,7 +100,38 @@ token_id="tok_local_mcp", ) -app = FastAPI(title=f"{APP_BRAND} API", version="0.1.0") +def _stable_operation_id(route: APIRoute) -> str: + """Generate IDs from the public HTTP contract, not Python handler names.""" + methods = "_".join(sorted(method.lower() for method in route.methods)) + normalized_path = ( + route.path.strip("/") + .replace("/", "_") + .replace("{", "by_") + .replace("}", "") + .replace("-", "_") + or "root" + ) + return f"{methods}_{normalized_path}" + + +app = FastAPI( + title=f"{APP_BRAND} API", + version="0.1.0", + generate_unique_id_function=_stable_operation_id, +) +_bearer_scheme = HTTPBearer( + auto_error=False, + scheme_name="BearerAuth", + description="Origin-bound API, MCP, session, or operator bearer token.", +) + + +def _authorization_value( + credentials: HTTPAuthorizationCredentials | None, +) -> str | None: + if credentials is None: + return None + return f"{credentials.scheme} {credentials.credentials}" def _cors_origins() -> list[str]: @@ -379,14 +418,116 @@ def _global_token_user_id(x_cortex_user: str | None) -> str: return requested_user or settings.default_user_id +def _credential_encryption_evidence( + *, + max_files: int = 20_000, + max_file_bytes: int = 2_000_000, +) -> dict[str, Any]: + """Return complete on-disk evidence for the hosted credential release gate. + + Credential files are per vault (one per bucket/user shard), so scanning the + small aggregate files is both more complete and cheaper than walking the + control-plane user list and materializing every store. Corrupt, unreadable, + or over-limit storage fails closed in hosted readiness. + """ + keyring = getattr(store, "keyring", None) + evidence: dict[str, Any] = { + "enforcement_enabled": bool(settings.require_encrypted_credentials), + "keyring_available": bool(keyring is not None and getattr(keyring, "available", False)), + "remaining_plaintext": 0, + "files_scanned": 0, + "scan_complete": True, + "unreadable_files": 0, + "invalid_records": 0, + } + shard_root = Path(store.router.shard_root) + if not shard_root.exists(): + return evidence + for path in shard_root.rglob("credentials.json"): + if evidence["files_scanned"] >= max_files: + evidence["scan_complete"] = False + break + evidence["files_scanned"] += 1 + try: + if path.stat().st_size > max_file_bytes: + evidence["invalid_records"] += 1 + continue + payload = json.loads(path.read_text(encoding="utf-8")) + except (OSError, UnicodeDecodeError, json.JSONDecodeError): + evidence["unreadable_files"] += 1 + continue + if not isinstance(payload, dict): + evidence["invalid_records"] += 1 + continue + users = payload.get("users") + if not isinstance(users, dict): + evidence["invalid_records"] += 1 + continue + for user_credentials in users.values(): + if not isinstance(user_credentials, dict): + evidence["invalid_records"] += 1 + continue + for record in user_credentials.values(): + if not isinstance(record, dict): + evidence["invalid_records"] += 1 + continue + plaintext = record.get("payload") + encrypted = record.get("payload_cxe1") + # Any retained plaintext copy is a release blocker even if a + # valid encrypted envelope is also present. + if isinstance(plaintext, dict): + evidence["remaining_plaintext"] += 1 + elif plaintext is not None: + evidence["invalid_records"] += 1 + if encrypted is None: + if plaintext is None: + evidence["invalid_records"] += 1 + continue + if not isinstance(encrypted, str) or not encrypted: + evidence["invalid_records"] += 1 + continue + try: + envelope = bytes.fromhex(encrypted) + except ValueError: + evidence["invalid_records"] += 1 + continue + # Validate the self-describing CXE1 envelope shape without + # decrypting or mutating it during a readiness probe. + if len(envelope) < 38 or envelope[:4] != b"CXE1": + evidence["invalid_records"] += 1 + continue + kek_id_length = envelope[4] + minimum_length = 4 + 1 + kek_id_length + 4 + 12 + 16 + if kek_id_length == 0 or len(envelope) < minimum_length: + evidence["invalid_records"] += 1 + return evidence + + +def _cached_credential_encryption_evidence(*, ttl_seconds: float = 30.0) -> dict[str, Any]: + """Bound repeated readiness probes while keeping the release gate fail-closed.""" + cache_key = str(Path(store.router.shard_root).resolve()) + now = time.monotonic() + with _credential_evidence_cache_lock: + if ( + _credential_evidence_cache["key"] == cache_key + and now - float(_credential_evidence_cache["at"]) < ttl_seconds + and isinstance(_credential_evidence_cache["value"], dict) + ): + return dict(_credential_evidence_cache["value"]) + value = _credential_encryption_evidence() + _credential_evidence_cache.update({"key": cache_key, "at": now, "value": dict(value)}) + return value + + def _hosted_readiness_contract() -> dict[str, Any]: runtime: dict[str, Any] = {} - runtime_storage_status = getattr(store, "runtime_storage_status", None) - if callable(runtime_storage_status): - runtime["storage"] = runtime_storage_status() if settings.shard_mode != "local": + runtime_storage_status = getattr(store, "runtime_storage_status", None) + if callable(runtime_storage_status): + runtime["storage"] = runtime_storage_status() runtime["control_plane"] = store.control_plane_status() runtime["worker_queue"] = store.hosted_job_health() + runtime["credential_encryption"] = _cached_credential_encryption_evidence() return hosted_readiness_contract(settings, runtime=runtime) @@ -478,7 +619,12 @@ def _enforce_memory_quota(user_id: str) -> None: ) -def auth(request: Request, authorization: str | None = Header(default=None), x_cortex_user: str | None = Header(default=None)) -> str: +def auth( + request: Request, + credentials: HTTPAuthorizationCredentials | None = Depends(_bearer_scheme), + x_cortex_user: str | None = Header(default=None), +) -> str: + authorization = _authorization_value(credentials) if not authorization or not authorization.lower().startswith("bearer "): raise HTTPException(status_code=401, detail=f"Missing or invalid {APP_BRAND} API token") token = authorization.split(" ", 1)[1].strip() @@ -503,7 +649,11 @@ def auth(request: Request, authorization: str | None = Header(default=None), x_c raise HTTPException(status_code=401, detail=f"Missing or invalid {APP_BRAND} API token") -def mcp_auth(authorization: str | None = Header(default=None), x_cortex_user: str | None = Header(default=None)) -> dict[str, Any]: +def mcp_auth( + credentials: HTTPAuthorizationCredentials | None = Depends(_bearer_scheme), + x_cortex_user: str | None = Header(default=None), +) -> dict[str, Any]: + authorization = _authorization_value(credentials) if not authorization or not authorization.lower().startswith("bearer "): raise HTTPException(status_code=401, detail=f"Missing or invalid {APP_BRAND} MCP token") token = authorization.split(" ", 1)[1].strip() @@ -526,10 +676,57 @@ def mcp_auth(authorization: str | None = Header(default=None), x_cortex_user: st raise HTTPException(status_code=401, detail=f"Missing or invalid {APP_BRAND} MCP token") -def admin_auth(authorization: str | None = Header(default=None)) -> bool: +def tools_auth( + credentials: HTTPAuthorizationCredentials | None = Depends(_bearer_scheme), + x_cortex_user: str | None = Header(default=None), +) -> dict[str, Any]: + """Authenticate the REST tool adapter with either scoped token audience. + + `/mcp` remains MCP-token-only. The SDK's `/v1/tools/*` adapter is part of + the REST surface, so a scoped `cxa_` token must be able to use it alongside + search/context without requiring a second client instance and token. + """ + authorization = _authorization_value(credentials) + if not authorization or not authorization.lower().startswith("bearer "): + raise HTTPException( + status_code=401, + detail=f"Missing or invalid {APP_BRAND} API or MCP token", + ) + token = authorization.split(" ", 1)[1].strip() + if settings.api_key and hmac.compare_digest(token, settings.api_key): + user_id = _global_token_user_id(x_cortex_user) + return { + "user_id": user_id, + "token_id": "admin", + "label": f"{APP_BRAND} app token", + "audience": "admin", + "scopes": ["read", "write", "export", "maintenance", "destructive"], + "admin": True, + } + scoped = store.authenticate_mcp_token(token, user_id=x_cortex_user) + if scoped is None: + scoped = store.authenticate_api_token(token, user_id=x_cortex_user) + if scoped: + if x_cortex_user and scoped["user_id"] != x_cortex_user: + raise HTTPException( + status_code=403, + detail=f"{APP_BRAND} token does not match requested user", + ) + _enforce_rate_limit(scoped["user_id"]) + return scoped + raise HTTPException( + status_code=401, + detail=f"Missing or invalid {APP_BRAND} API or MCP token", + ) + + +def admin_auth( + credentials: HTTPAuthorizationCredentials | None = Depends(_bearer_scheme), +) -> bool: """Gate control-plane/admin operations (user provisioning, listing) behind the operator's global CORTEX_API_KEY. Scoped per-user tokens can never perform these actions.""" + authorization = _authorization_value(credentials) if not authorization or not authorization.lower().startswith("bearer "): raise HTTPException(status_code=401, detail=f"Missing or invalid {APP_BRAND} admin token") token = authorization.split(" ", 1)[1].strip() @@ -624,10 +821,16 @@ def _save_capture_from_values(content: str, source: str, title: str | None, sour def root() -> Response: """Branded landing at the domain root. Account CTAs render only when auth is enabled (the hosted plane); a plain local backend just shows the download + developer health hints.""" + create_account_cta = ( + ' Create an account\n' + if _public_signup_enabled() + else "" + ) account_cta = ( '
    \n' ' Sign in\n' - ' Create an account\n' + f"{create_account_cta}" "
    \n" if settings.auth_enabled else "" @@ -684,7 +887,8 @@ def _public_footer(links: list[tuple[str, str]]) -> str: parts = [ f'{html.escape(label)}' for href, label in links - if settings.auth_enabled or not href.startswith("/account/") + if (settings.auth_enabled or not href.startswith("/account/")) + and (_public_signup_enabled() or href != "/account/signup") ] return ' \n" @@ -752,11 +956,12 @@ def privacy_page() -> Response: return _public_html(render_public_page("Privacy Policy · Doppl", body)) -# Plain-language Terms + Privacy that reflect how Doppl ACTUALLY works (local-first storage, per-user -# encryption, crypto-shred deletion, no data sale). A solid, honest baseline the founder should have -# reviewed by counsel before broad launch; far better than the 404 the signup page linked to. +# Plain-language beta disclosures. These are not a substitute for counsel review. Keep claims scoped +# to behavior that is implemented today; in particular, connector credentials have per-user +# envelope encryption, while broader hosted memory-content encryption is still being rolled out. _TERMS_BODY = ( "

    Terms of Service

    \n" + '

    Draft — pending legal review before public launch.

    \n' '

    These terms cover your use of Doppl (the “Service”), a personal AI-memory ' "app and the account that syncs it. By creating an account you agree to them.

    \n" "

    Eligibility

    \n" @@ -783,12 +988,13 @@ def privacy_page() -> Response: "

    You can stop using Doppl and delete your account any time. We may suspend accounts that " "violate these terms.

    \n" "

    Contact

    \n" - '

    Questions about these terms? Email ' - "support@signindoppl.com.

    \n" + '

    Questions about these terms? Email ' + "support@trydoppl.com.

    \n" ) _PRIVACY_BODY = ( "

    Privacy Policy

    \n" + '

    Draft — pending legal review before public launch.

    \n' '

    Doppl is built local-first: your memory lives on your device, and your ' "account exists to identify you and sync your own data. Here’s exactly what that means.

    \n" "

    What we collect

    \n" @@ -801,27 +1007,39 @@ def privacy_page() -> Response: "This is stored for you and synced to your devices.\n" "
\n" "

How your content is protected

\n" - "

Your memory is stored with per-user isolation and encrypted at rest with a key unique to " - "your account. We don’t sell your data, we don’t share it with advertisers, and we don’t use it " - "to train models for other people.

\n" + "

Your hosted memory is stored with per-user isolation. Connector credentials are " + "encrypted at rest with a key unique to your account; broader encryption of hosted memory " + "content is still rolling out. The hosted service can read hosted memory to index and retrieve it. " + "We don’t sell your data, share it with advertisers, or use it to train models for other " + "people.

\n" "

Deletion & your control

\n" - "

You can delete your account from your account page at any time. Deletion " - "crypto-shreds your encryption keys — making your stored content permanently " - "unreadable — and removes your data. You can also sign out of individual sessions.

\n" + "

You can delete your account from your account page at any time. Deletion destroys the " + "per-user key for encrypted connector credentials and removes your hosted memory and derived " + "data through the normal deletion path. You can also sign out of individual sessions.

\n" "

Third parties

\n" "

If you sign in with Google, GitHub, or Apple, we receive only the basic profile " "(identifier and email) needed to create your account. Optional payment processing, if you " "subscribe, is handled by a third-party processor — we never store your card details.

\n" "

Contact

\n" - '

Privacy questions or a data request? Email ' - "support@signindoppl.com.

\n" + '

Privacy questions or a data request? Email ' + "privacy@trydoppl.com.

\n" ) @app.get("/health") def health() -> dict[str, Any]: payload = store.health_payload(mode="fastapi", auth=bool(settings.api_key)) - payload["hosted_readiness"] = _hosted_readiness_contract() + if settings.shard_mode == "local": + payload["hosted_readiness"] = _hosted_readiness_contract() + else: + # Liveness must be constant-time. The full tenant-wide credential audit + # belongs to /ready, where its result is bounded by a short cache. + payload["hosted_readiness"] = { + "status": "deferred", + "hosted_mode": True, + "shard_mode": settings.shard_mode, + "detail": "Full hosted release checks are available from /ready.", + } return payload @@ -2288,20 +2506,6 @@ def context_pack(query: str = "", limit: int = Query(default=12, ge=1, le=50), s return Response(content=store.context_pack(user_id, query=query, limit=limit, sector=sector), media_type="text/markdown") -def _bearer_has_export_scope(request: Request) -> bool: - """Whether the (already-authenticated) bearer may see the identity/persona layer. - The admin app token always may; scoped API tokens need the export scope. Read-only - callers still get a pack — the identity layer degrades to a visible omission record.""" - authorization = request.headers.get("authorization") or "" - token = authorization.split(" ", 1)[1].strip() if " " in authorization else "" - if not token: - return False - if settings.api_key and hmac.compare_digest(token, settings.api_key): - return True - scoped = store.authenticate_api_token(token) - return bool(scoped and "export" in set(scoped.get("scopes") or [])) - - def _context_response(pack: dict[str, Any] | str, format: str) -> Any: if format == "markdown": return Response(content=pack, media_type="text/markdown") @@ -2352,31 +2556,26 @@ def get_context( @app.post("/v1/context", response_model=None) -def post_context(body: dict[str, Any], request: Request, user_id: str = Depends(auth)) -> Any: - format = str(body.get("format") or "json").strip().lower() - if format not in {"json", "markdown", "smp"}: - raise HTTPException(status_code=422, detail="format must be json, markdown, or smp") +def post_context(body: ContextRequest, user_id: str = Depends(auth)) -> Any: + format = body.format response_format = "smp" if format == "smp" else "text" internal_format = "json" if format == "smp" else format - model = str(body.get("model") or "") or None - try: - token_budget = int(body.get("token_budget") or 2000) - except (TypeError, ValueError): - raise HTTPException(status_code=422, detail="token_budget must be an integer") pack = store.assemble_context( user_id, - str(body.get("task") or ""), - surface=str(body.get("surface") or "agent"), - token_budget=token_budget, - sector=str(body.get("sector") or "") or None, - project=str(body.get("project") or "") or None, - as_of=str(body.get("as_of") or "") or None, - intent=str(body.get("intent") or "") or None, - include_identity=_bearer_has_export_scope(request), + body.task, + surface=body.surface, + token_budget=body.token_budget, + sector=body.sector, + project=body.project, + as_of=body.as_of, + intent=body.intent, + # Identity/persona is distilled memory and follows the same read-scope policy as GET, + # standalone, MCP, and both SDKs (which use this POST transport). + include_identity=True, format=internal_format, - pin=bool(body.get("pin")), - session_id=str(body.get("session_id") or "") or None, - **_assemble_context_ext_kwargs(response_format, model), + pin=body.pin, + session_id=body.session_id, + **_assemble_context_ext_kwargs(response_format, body.model), ) return _context_response(pack, internal_format) @@ -3021,12 +3220,18 @@ def reconcile_vault_edits(user_id: str = Depends(auth)) -> dict[str, Any]: @app.get("/v1/export.json") def export_json(user_id: str = Depends(auth)) -> dict[str, Any]: - return store.export_json(user_id) + try: + return store.export_json(user_id) + except ExportSizeLimitError as exc: + raise HTTPException(status_code=413, detail=str(exc)) from exc @app.get("/v1/export.md") def export_markdown(user_id: str = Depends(auth)) -> Response: - return Response(content=store.export_markdown(user_id), media_type="text/markdown") + try: + return Response(content=store.export_markdown(user_id), media_type="text/markdown") + except ExportSizeLimitError as exc: + raise HTTPException(status_code=413, detail=str(exc)) from exc @app.get("/v1/integrity/digest") @@ -3088,14 +3293,20 @@ def get_working_canvas_node(session_id: str, node_id: str, include_raw: bool = T def export_manifest(user_id: str = Depends(auth)) -> dict[str, Any]: """Phase D: a verifiable manifest (integrity head + record counts + payload sha256) for the portable export. Read-only — it attests the export without containing its content.""" - return store.export_manifest(user_id) + try: + return store.export_manifest(user_id) + except ExportSizeLimitError as exc: + raise HTTPException(status_code=413, detail=str(exc)) from exc @app.get("/v1/export/bundle") def export_portable_bundle(user_id: str = Depends(auth)) -> dict[str, Any]: """Phase D: the whole memory as one self-verifying, restorable object (full export payload + integrity manifest). Export-scoped — it carries the full corpus out of Cortex custody.""" - return store.export_portable_bundle(user_id) + try: + return store.export_portable_bundle(user_id) + except ExportSizeLimitError as exc: + raise HTTPException(status_code=413, detail=str(exc)) from exc @app.post("/v1/export/verify") @@ -3395,10 +3606,13 @@ def _session_data_plane_user(token: str, x_cortex_user: str | None) -> str: return user_id -def session_auth(authorization: str | None = Header(default=None)) -> dict[str, Any]: +def session_auth( + credentials: HTTPAuthorizationCredentials | None = Depends(_bearer_scheme), +) -> dict[str, Any]: """Dependency for the session-authed account surface (/v1/auth/*). Returns verify_session's payload: {'account', 'session_id', 'user_id', 'client'}.""" runtime = _auth_runtime_or_404() + authorization = _authorization_value(credentials) if not authorization or not authorization.lower().startswith("bearer "): raise HTTPException(status_code=401, detail=GENERIC_AUTH_FAILURE) token = authorization.split(" ", 1)[1].strip() @@ -3460,7 +3674,7 @@ def _verify_turnstile(request: Request, token: str) -> None: method="POST", ) try: - with urllib.request.urlopen(req, timeout=5) as resp: # noqa: S310 (fixed https URL) + with open_same_origin(req, timeout=5) as resp: body = resp.read().decode("utf-8") outcome = json.loads(body) except (urllib.error.URLError, TimeoutError, ValueError, OSError) as exc: @@ -3536,11 +3750,35 @@ def _default_oauth_redirect(provider: str) -> str: return f"{settings.public_app_url.rstrip('/')}/v1/auth/oauth/{provider}/callback" +def _public_signup_enabled() -> bool: + return settings.shard_mode == "local" or bool(settings.legal_terms_approved) + + +def _require_signup_consent(payload: dict[str, Any]) -> None: + if not _public_signup_enabled(): + raise HTTPException( + status_code=503, + detail=( + "Public signup is disabled until approved Terms and Privacy " + "text is deployed." + ), + ) + if settings.shard_mode != "local" and ( + payload.get("terms_accepted") is not True + or payload.get("age_confirmed") is not True + ): + raise HTTPException( + status_code=422, + detail="Hosted signup requires explicit Terms acceptance and age confirmation.", + ) + + # --------------------------------------------------------- email + password @app.post("/v1/auth/signup") def auth_signup(payload: dict[str, Any], request: Request) -> dict[str, Any]: runtime = _auth_runtime_or_404() + _require_signup_consent(payload) _auth_rate_limit(runtime, "signup", request) # Bot/DoS gate: verify the Cloudflare Turnstile token (if enabled) BEFORE any # argon2id hashing so a bot flood can't burn CPU. No-op when unconfigured. @@ -3620,8 +3858,11 @@ def auth_refresh(payload: dict[str, Any], request: Request) -> dict[str, Any]: @app.post("/v1/auth/logout") -def auth_logout(authorization: str | None = Header(default=None)) -> dict[str, Any]: +def auth_logout( + credentials: HTTPAuthorizationCredentials | None = Depends(_bearer_scheme), +) -> dict[str, Any]: runtime = _auth_runtime_or_404() + authorization = _authorization_value(credentials) if not authorization or not authorization.lower().startswith("bearer "): raise HTTPException(status_code=401, detail=GENERIC_AUTH_FAILURE) try: @@ -3659,6 +3900,8 @@ def auth_password_reset_confirm(payload: dict[str, Any], request: Request) -> di runtime.service.confirm_password_reset( str(payload.get("token") or ""), str(payload.get("new_password") or "") ) + except RateLimited: + raise HTTPException(status_code=429, detail="rate limited") except ValueError as exc: raise HTTPException(status_code=422, detail=str(exc)) from exc except AuthError as exc: @@ -3707,6 +3950,9 @@ def auth_oauth_start( request: Request, redirect_uri: str = Query(default="", max_length=500), app_flow: str = Query(default="", max_length=120), + signup: bool = Query(default=False), + terms_accepted: bool = Query(default=False), + age_confirmed: bool = Query(default=False), ) -> Any: runtime = _auth_runtime_or_404() _auth_rate_limit(runtime, "oauth_start", request) @@ -3715,11 +3961,21 @@ def auth_oauth_start( # it and treat this as an ordinary web sign-in (mints a session for THIS browser only), so a # victim's OAuth completion can never be attached to an attacker-owned poll flow. bound_flow = app_flow if _app_flow_cookie_valid(request.cookies.get(APP_FLOW_COOKIE_NAME), app_flow) else "" + signup_consent = False + if signup: + _require_signup_consent( + { + "terms_accepted": terms_accepted, + "age_confirmed": age_confirmed, + } + ) + signup_consent = True try: result = runtime.oidc.start( provider, redirect_uri or _default_oauth_redirect(provider), app_flow_id=bound_flow or None, + signup_consent=signup_consent, ) except OidcError as exc: raise HTTPException(status_code=404, detail=str(exc)) from exc @@ -3772,6 +4028,30 @@ def _fail(detail: str, status: int) -> Any: except OidcError as exc: _auth_logger.info("oauth callback rejected for %s: %s", provider, exc) return _fail(GENERIC_AUTH_FAILURE, 401) + existing_identity = runtime.control_store.get_identity( + provider, + str(identity["subject"]), + ) + existing_email_account = None + if ( + existing_identity is None + and identity.get("email") + and identity.get("email_verified") + ): + existing_email_account = runtime.control_store.get_account_by_email( + str(identity["email"]).strip().lower() + ) + if ( + existing_identity is None + and existing_email_account is None + and not identity.get("signup_consent") + ): + # Refuse before find_or_challenge_identity, because its signup branch + # persists an account and identity. + return _fail( + "Create an account and accept the Terms before using this provider.", + 409, + ) try: result = runtime.service.find_or_challenge_identity( provider, @@ -3783,6 +4063,13 @@ def _fail(detail: str, status: int) -> Any: ) except AuthError as exc: return _fail(str(exc), 401) + if result["action"] == "signup" and not identity.get("signup_consent"): + # OAuth from the login page must not silently create an account. The + # signup page starts a state-bound flow only after explicit consent. + return _fail( + "Create an account and accept the Terms before using this provider.", + 409, + ) if result["action"] == "link_required": # Never-silent-auto-link: the user must authenticate with the existing # method, then complete the link via POST /v1/auth/oauth/{provider}/link. @@ -3803,7 +4090,7 @@ def _fail(detail: str, status: int) -> Any: ) account = result["account"] if str(account.get("status") or "") == "active": - # OAuth auto-signup with a provider-verified email activates directly; + # OAuth signup with bound consent and a provider-verified email activates directly; # provision the user now (no tokens minted). _ensure_account_provisioned(account) app_flow_id = identity.get("app_flow_id") @@ -3851,6 +4138,11 @@ def auth_oauth_native(provider: str, payload: dict[str, Any], request: Request) except OidcError as exc: _auth_logger.info("native oauth rejected for %s: %s", provider, exc) raise HTTPException(status_code=401, detail=GENERIC_AUTH_FAILURE) from exc + if runtime.control_store.get_identity( + provider, + str(identity["subject"]), + ) is None: + _require_signup_consent(payload) try: result = runtime.service.find_or_challenge_identity( provider, @@ -4380,9 +4672,16 @@ def auth_delete_account( credential = runtime.control_store.get_password_credential(account_id) if credential is not None: supplied = str((payload or {}).get("password") or "") - if not supplied or not runtime.service.engine.verify( - str(credential["password_hash"]), supplied - ): + try: + password_verified = bool( + supplied + and runtime.service.engine.verify( + str(credential["password_hash"]), supplied + ) + ) + except RateLimited: + raise HTTPException(status_code=429, detail="rate limited") + if not password_verified: raise HTTPException(status_code=401, detail=GENERIC_AUTH_FAILURE) shred: dict[str, Any] | None = None if runtime.keyring is not None and runtime.keyring.available: @@ -4450,6 +4749,8 @@ def auth_claim(payload: dict[str, Any], request: Request) -> dict[str, Any]: str(payload.get("password") or "") or None, display_name=str(payload.get("display_name") or "")[:160], ) + except RateLimited: + raise HTTPException(status_code=429, detail="rate limited") except ValueError as exc: raise HTTPException(status_code=422, detail=str(exc)) from exc except AuthError as exc: @@ -4538,6 +4839,7 @@ def _plaintext_credential_records(vault: Any, user_id: str) -> list[str]: # (tests/reboot) takes effect. "" when Turnstile is unconfigured -> the # signup page is byte-identical to today. turnstile_site_key=lambda: settings.turnstile_site_key if settings.turnstile_enabled else "", + signup_enabled=_public_signup_enabled, # Signs the app-login flow-binding cookie (login-CSRF guard for the desktop OAuth handoff). app_flow_cookie=_app_flow_cookie, ) @@ -4702,8 +5004,8 @@ def _safe_tool_error_message(exc: Exception) -> str: # Universal adapter surface (hosted mirror of standalone_server.py's /v1/tools/*): the same tool # catalog, reachable by any function-calling app over plain HTTP against api.signindoppl.com -# instead of a local server. Authed exactly like /mcp (Bearer -> scoped context via mcp_auth), so -# a read-only MCP token gets a read-only surface here too. GET /v1/tools/schema projects the +# instead of a local server. The REST adapter accepts API or MCP scoped tokens while `/mcp` +# remains MCP-only; either read-only token gets the same read-only tool surface. GET /v1/tools/schema projects the # catalog to openai/anthropic/openapi/mcp; POST /v1/tools/call dispatches through the SAME # call_tool/_require_tool_access path /mcp uses — advertisement is never authorization, call_tool # re-checks every scope regardless of what tools_for_scopes chose to show. Every call records an @@ -4711,7 +5013,7 @@ def _safe_tool_error_message(exc: Exception) -> str: @app.get("/v1/tools/schema") def hosted_tools_schema( format: str = Query(default="openai"), - context: dict[str, Any] = Depends(mcp_auth), + context: dict[str, Any] = Depends(tools_auth), ) -> dict[str, Any]: token_scopes = None if context.get("admin") else list(context.get("scopes") or []) base_url = settings.public_base_url.rstrip("/") or "http://127.0.0.1:8766" @@ -4722,7 +5024,7 @@ def hosted_tools_schema( @app.post("/v1/tools/call") -def hosted_tools_call(body: dict[str, Any], context: dict[str, Any] = Depends(mcp_auth)) -> dict[str, Any]: +def hosted_tools_call(body: dict[str, Any], context: dict[str, Any] = Depends(tools_auth)) -> dict[str, Any]: tool_user = context["user_id"] token_scopes = None if context.get("admin") else list(context.get("scopes") or []) tool_name = str(body.get("name") or "") @@ -4745,7 +5047,7 @@ def hosted_tools_call(body: dict[str, Any], context: dict[str, Any] = Depends(mc @app.post("/v1/tools/{tool_name}") -def hosted_tools_call_named(tool_name: str, body: dict[str, Any] | None = None, context: dict[str, Any] = Depends(mcp_auth)) -> Any: +def hosted_tools_call_named(tool_name: str, body: dict[str, Any] | None = None, context: dict[str, Any] = Depends(tools_auth)) -> Any: # Per-tool path matching the OpenAPI operationIds (/v1/tools/{name}): the URL names the tool, # the whole body is the arguments — mirrors standalone_server.py's dispatch for custom-GPT # actions / Zapier-style connectors that call one operationId per tool rather than the diff --git a/backend/app/mcp_tools.py b/backend/app/mcp_tools.py index 22aaefaa..cf68805b 100644 --- a/backend/app/mcp_tools.py +++ b/backend/app/mcp_tools.py @@ -1908,7 +1908,7 @@ def tools_for_scopes(token_scopes: list[str] | None = None, *, surface: str = "c def _require_tool_access(store: CortexStore, user_id: str, name: str, token_scopes: list[str] | None = None) -> None: for capability in tool_required_capabilities(name, scoped=token_scopes is not None): if token_scopes is not None and capability not in token_scopes: - raise PermissionError(f"MCP token is not scoped for {capability} actions.") + raise PermissionError(f"Token is not scoped for {capability} actions.") store.require_agent_access(user_id, capability) diff --git a/backend/app/models.py b/backend/app/models.py index 2ea4ba30..4897cf9b 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -1105,6 +1105,20 @@ class ContextReuseResponse(BaseModel): product_loop: dict[str, Any] +class ContextRequest(BaseModel): + task: str = Field(default="", max_length=500) + surface: str = Field(default="agent", max_length=40) + token_budget: int = Field(default=2000, ge=1, le=100_000) + intent: str | None = Field(default=None, max_length=16) + sector: str | None = Field(default=None, max_length=120) + project: str | None = Field(default=None, max_length=160) + as_of: str | None = Field(default=None, max_length=40) + format: Literal["json", "markdown", "smp"] = "json" + model: str | None = Field(default=None, max_length=80) + session_id: str | None = Field(default=None, max_length=120) + pin: bool = False + + class SettingsResponse(BaseModel): review_new_captures: bool allow_pending_in_context: bool diff --git a/backend/app/oauth_broker.py b/backend/app/oauth_broker.py index 7bedea56..5892dbb2 100644 --- a/backend/app/oauth_broker.py +++ b/backend/app/oauth_broker.py @@ -30,10 +30,11 @@ from dataclasses import dataclass, field from typing import Any, Callable from urllib.parse import urlencode -from urllib.request import Request as URLLibRequest, urlopen +from urllib.request import Request as URLLibRequest from urllib.error import HTTPError, URLError from .config import APP_BRAND +from .http_security import open_same_origin # The route handlers below annotate `request: "Request"`. Because `from __future__ import # annotations` makes every annotation a STRING, FastAPI resolves "Request" against THIS module's @@ -43,8 +44,10 @@ # import at startup. Guarded so the broker's own fastapi-free logic still imports without fastapi. try: # pragma: no cover - fastapi is a hosted-plane dependency from fastapi import Request + from fastapi.responses import JSONResponse except ImportError: # pragma: no cover Request = Any # type: ignore[assignment,misc] + JSONResponse = Any # type: ignore[assignment,misc] # --- Provider blueprints ---------------------------------------------------- @@ -301,7 +304,7 @@ def _http_token_request(token_url: str, form: dict[str, str], headers: dict[str, req_headers = {"Content-Type": "application/x-www-form-urlencoded", **headers} request = URLLibRequest(token_url, data=data, headers=req_headers, method="POST") try: - with urlopen(request, timeout=30) as response: # noqa: S310 — provider token endpoints only + with open_same_origin(request, timeout=30) as response: raw = response.read().decode("utf-8") except HTTPError as exc: try: @@ -322,8 +325,7 @@ def _http_token_request(token_url: str, form: dict[str, str], headers: dict[str, def register_oauth_broker_routes(app: Any, registry: OAuthBrokerRegistry | None = None) -> OAuthBrokerRegistry: """Attach /oauth/broker/* routes to the hosted FastAPI app. Safe to call always: with no providers configured the endpoints simply return 503 'not configured'.""" - from fastapi import HTTPException, Request - from fastapi.responses import JSONResponse + from fastapi import HTTPException broker = registry or OAuthBrokerRegistry() diff --git a/backend/app/oidc_registry.py b/backend/app/oidc_registry.py index 5e7653e3..6e733402 100644 --- a/backend/app/oidc_registry.py +++ b/backend/app/oidc_registry.py @@ -47,6 +47,7 @@ from cryptography.hazmat.primitives.asymmetric import padding, rsa from .accounts import ControlStore, iso_utc, utc_now +from .http_security import open_same_origin OAUTH_FLOW_KIND = "oidc" # accounts.FLOW_KINDS row used for OAuth state DEFAULT_STATE_TTL_SECONDS = 600 # ~10 minutes, single-use @@ -167,7 +168,7 @@ def urllib_transport(request: dict[str, Any]) -> dict[str, Any]: ) timeout = float(request.get("timeout") or DEFAULT_HTTP_TIMEOUT_SECONDS) try: - with urllib.request.urlopen(req, timeout=timeout) as response: # noqa: S310 - scheme checked above + with open_same_origin(req, timeout=timeout) as response: return { "status": int(getattr(response, "status", 200) or 200), "headers": dict(response.headers.items()), @@ -284,11 +285,12 @@ def _json_object(raw: bytes, what: str) -> dict[str, Any]: class OidcProviderRegistry: """Config-driven provider registry + the start/complete OAuth engine. - - ``start(provider, redirect_uri, app_flow_id=None)`` persists the + - ``start(provider, redirect_uri, app_flow_id=None, signup_consent=False)`` persists the single-use flow row (state = flow_id) and returns the authorize URL. - ``complete(provider, state, code, ...)`` consumes the flow, exchanges the code (PKCE verifier included), and returns the VERIFIED provider - assertion: {subject, email, email_verified, display_name, app_flow_id}. + assertion: {subject, email, email_verified, display_name, app_flow_id, + signup_consent}. The caller (main.py wiring) maps that onto accounts via AccountsService.find_or_challenge_identity — never here. """ @@ -348,6 +350,7 @@ def start( redirect_uri: str, *, app_flow_id: Optional[str] = None, + signup_consent: bool = False, ) -> dict[str, Any]: definition = self._require_enabled(provider) redirect = str(redirect_uri or "").strip() @@ -372,6 +375,7 @@ def start( "pkce_verifier": pkce_verifier, "redirect_uri": redirect, "app_flow_id": str(app_flow_id or "") or None, + "signup_consent": bool(signup_consent), }, secret_hash=None, expires_at=iso_utc(now + timedelta(seconds=self.state_ttl_seconds)), @@ -417,6 +421,7 @@ def complete( identity = self._oidc_identity(definition, token_response, nonce=str(payload.get("nonce") or "")) identity["provider"] = definition.name identity["app_flow_id"] = payload.get("app_flow_id") or None + identity["signup_consent"] = bool(payload.get("signup_consent")) return identity def verify_native_id_token( diff --git a/backend/app/ratelimit.py b/backend/app/ratelimit.py index 01c00efe..6c6c68ed 100644 --- a/backend/app/ratelimit.py +++ b/backend/app/ratelimit.py @@ -2,6 +2,7 @@ import threading import time +from collections import OrderedDict from typing import Callable @@ -21,6 +22,8 @@ def __init__( *, burst: int | None = None, time_fn: Callable[[], float] = time.monotonic, + max_buckets: int = 50_000, + idle_ttl_seconds: float = 600.0, ) -> None: self.rate_per_minute = max(0, int(rate_per_minute or 0)) self.rate_per_second = self.rate_per_minute / 60.0 @@ -28,7 +31,9 @@ def __init__( self.capacity = float(burst if burst is not None else max(1, self.rate_per_minute)) self.enabled = self.rate_per_minute > 0 self._time_fn = time_fn - self._buckets: dict[str, list[float]] = {} + self.max_buckets = max(1, int(max_buckets or 1)) + self.idle_ttl_seconds = max(1.0, float(idle_ttl_seconds or 1.0)) + self._buckets: "OrderedDict[str, list[float]]" = OrderedDict() self._lock = threading.Lock() def check(self, key: str) -> tuple[bool, float]: @@ -40,9 +45,15 @@ def check(self, key: str) -> tuple[bool, float]: with self._lock: state = self._buckets.get(normalized) if state is None: + if not self._evict_for_new_bucket(now): + # Never reset an actively blocked key just to admit a new + # attacker-controlled identity. A saturated limiter fails + # closed until an existing bucket expires. + return False, self.idle_ttl_seconds tokens, last = self.capacity, now else: tokens, last = state + self._buckets.move_to_end(normalized) tokens = min(self.capacity, tokens + (now - last) * self.rate_per_second) if tokens >= 1.0: self._buckets[normalized] = [tokens - 1.0, now] @@ -52,6 +63,25 @@ def check(self, key: str) -> tuple[bool, float]: return False, 60.0 return False, max(0.0, (1.0 - tokens) / self.rate_per_second) + def _evict_for_new_bucket(self, now: float) -> bool: + # OrderedDict is access-ordered, so expired entries are clustered at the + # front. A fully refilled bucket carries no meaningful enforcement + # state and is also safe to reclaim before the longer idle TTL. + # Saturation cleanup is deliberately bounded: a flood of novel keys + # must not turn every check into an O(max_buckets) scan under the mutex. + # Non-reclaimable entries rotate to the back so later calls continue + # the sweep instead of repeatedly examining the same oldest bucket. + scan_budget = min(128, len(self._buckets)) + for _ in range(scan_budget): + bucket_key, (tokens, last) = next(iter(self._buckets.items())) + elapsed = max(0.0, now - last) + refilled = min(self.capacity, tokens + elapsed * self.rate_per_second) + if elapsed > self.idle_ttl_seconds or refilled >= self.capacity: + self._buckets.pop(bucket_key, None) + else: + self._buckets.move_to_end(bucket_key) + return len(self._buckets) < self.max_buckets + def reset(self, key: str | None = None) -> None: with self._lock: if key is None: diff --git a/backend/app/source_ingest.py b/backend/app/source_ingest.py index 22504ed7..efa67f41 100644 --- a/backend/app/source_ingest.py +++ b/backend/app/source_ingest.py @@ -12,7 +12,7 @@ import threading import time import zipfile -from collections import OrderedDict +from collections import OrderedDict, deque from dataclasses import dataclass, field from datetime import datetime, timedelta, timezone from email import policy @@ -656,17 +656,20 @@ def _source_fair_order(records: list[SourceRecord]) -> list[SourceRecord]: sources). A single source keeps parse order. This ordering is consistent regardless of window, so paginating with an offset never skips or reorders records between pages.""" - buckets: dict[str, list[SourceRecord]] = {} + buckets: dict[str, deque[SourceRecord]] = {} for record in records: - buckets.setdefault(record.source, []).append(record) + buckets.setdefault(record.source, deque()).append(record) if len(buckets) <= 1: return list(records) ordered: list[SourceRecord] = [] - bucket_lists = list(buckets.values()) - while any(bucket_lists): - for bucket in bucket_lists: + active = list(buckets.values()) + while active: + next_active = [] + for bucket in active: + ordered.append(bucket.popleft()) if bucket: - ordered.append(bucket.pop(0)) + next_active.append(bucket) + active = next_active return ordered @@ -756,9 +759,9 @@ def _source_fair_record_cap(records: list[SourceRecord], max_records: int) -> li if len(records) <= max_records: return records - buckets: dict[str, list[SourceRecord]] = {} + buckets: dict[str, deque[SourceRecord]] = {} for record in records: - buckets.setdefault(record.source, []).append(record) + buckets.setdefault(record.source, deque()).append(record) if len(buckets) <= 1: return records[:max_records] @@ -768,7 +771,7 @@ def _source_fair_record_cap(records: list[SourceRecord], max_records: int) -> li for bucket in buckets.values(): if not bucket: continue - selected.append(bucket.pop(0)) + selected.append(bucket.popleft()) added = True if len(selected) >= max_records: break @@ -1257,23 +1260,63 @@ def _asset_from_path(path: Path, name: str | None = None) -> list[SourceAsset]: _MAX_INNER_ZIP_DEPTH = 2 _MAX_ZIP_MEMBER_COUNT = 20_000 _MAX_INNER_ZIP_BYTES = MAX_TEXT_BYTES * 8 +_MAX_ZIP_TOTAL_BYTES = MAX_TEXT_BYTES * 8 +_MAX_ZIP_ARCHIVE_BYTES = MAX_TEXT_BYTES * 8 + + +def _zip_declared_member_count(tail: bytes) -> int | None: + """Read the classic EOCD member count without materializing ZipInfo objects.""" + marker = b"PK\x05\x06" + offset = tail.rfind(marker) + while offset >= 0: + if len(tail) >= offset + 22: + comment_length = int.from_bytes(tail[offset + 20 : offset + 22], "little") + if offset + 22 + comment_length == len(tail): + count = int.from_bytes(tail[offset + 10 : offset + 12], "little") + # ZIP64 uses 0xffff as a sentinel; such an archive already exceeds our + # member budget and is intentionally rejected. + return _MAX_ZIP_MEMBER_COUNT + 1 if count == 0xFFFF else count + offset = tail.rfind(marker, 0, offset) + return None def _assets_from_zip(path: Path) -> list[SourceAsset]: try: + if path.stat().st_size > _MAX_ZIP_ARCHIVE_BYTES: + return [] + with path.open("rb") as handle: + handle.seek(max(0, path.stat().st_size - 65_557)) + declared_members = _zip_declared_member_count(handle.read()) + if declared_members is None or declared_members > _MAX_ZIP_MEMBER_COUNT: + return [] with zipfile.ZipFile(path) as archive: - return _assets_from_open_zip(archive, str(path), depth=0, budget=[_MAX_ZIP_MEMBER_COUNT]) + return _assets_from_open_zip( + archive, + str(path), + depth=0, + member_budget=[_MAX_ZIP_MEMBER_COUNT], + byte_budget=[_MAX_ZIP_TOTAL_BYTES], + ) except (zipfile.BadZipFile, OSError): return [SourceAsset(name=path.name, display_path=str(path), filesystem_path=path)] def _assets_from_open_zip( - archive: zipfile.ZipFile, display_root: str, *, depth: int, budget: list[int] + archive: zipfile.ZipFile, + display_root: str, + *, + depth: int, + member_budget: list[int], + byte_budget: list[int], ) -> list[SourceAsset]: assets: list[SourceAsset] = [] - for info in archive.infolist(): - if budget[0] <= 0: + infos = archive.infolist() + if len(infos) > member_budget[0]: + return assets + for info in infos: + if member_budget[0] <= 0 or byte_budget[0] <= 0: break + member_budget[0] -= 1 if info.is_dir(): continue name = info.filename @@ -1282,33 +1325,45 @@ def _assets_from_open_zip( if Path(name).suffix.lower() == ".zip": # Expand a nested export archive one more level (Notion multi-part # exports), guarding against deep nesting and oversized inner zips. - if depth >= _MAX_INNER_ZIP_DEPTH or info.file_size > _MAX_INNER_ZIP_BYTES: + if ( + depth >= _MAX_INNER_ZIP_DEPTH + or info.file_size > _MAX_INNER_ZIP_BYTES + or info.file_size > byte_budget[0] + ): continue - budget[0] -= 1 try: inner_bytes = archive.read(info) + byte_budget[0] -= len(inner_bytes) + declared_members = _zip_declared_member_count(inner_bytes[-65_557:]) + if declared_members is None or declared_members > member_budget[0]: + continue with zipfile.ZipFile(io.BytesIO(inner_bytes)) as inner: assets.extend( _assets_from_open_zip( - inner, f"{display_root}::{name}", depth=depth + 1, budget=budget + inner, + f"{display_root}::{name}", + depth=depth + 1, + member_budget=member_budget, + byte_budget=byte_budget, ) ) except (KeyError, RuntimeError, zipfile.BadZipFile, OSError): continue continue - budget[0] -= 1 - truncated = info.file_size > MAX_TEXT_BYTES + read_limit = min(MAX_TEXT_BYTES, byte_budget[0]) + truncated = info.file_size > read_limit try: if truncated: # Oversized members used to be skipped entirely — silent - # whole-file loss. Stream the first MAX_TEXT_BYTES and - # mark the asset truncated instead. + # whole-file loss. Stream only the per-file/aggregate bytes + # still available and mark the asset truncated instead. with archive.open(info) as member: - data = member.read(MAX_TEXT_BYTES) + data = member.read(read_limit) else: data = archive.read(info) except (KeyError, RuntimeError, zipfile.BadZipFile): continue + byte_budget[0] -= len(data) assets.append(SourceAsset(name=name, display_path=f"{display_root}::{name}", data=data, read_truncated=truncated)) return assets diff --git a/backend/app/standalone_server.py b/backend/app/standalone_server.py index 97489a15..bbd49b0c 100644 --- a/backend/app/standalone_server.py +++ b/backend/app/standalone_server.py @@ -5,6 +5,7 @@ import binascii import html import hmac +import ipaddress import secrets import json import os @@ -41,7 +42,7 @@ tools_for_scopes, ) from .sharding import StoreRegistry -from .storage import BACKEND_VERSION +from .storage import BACKEND_VERSION, ExportSizeLimitError settings = load_settings() @@ -1000,12 +1001,12 @@ def _dispatch(self, method: str, path: str, params: dict[str, list[str]]) -> Non return # Universal adapter surface: the same tool catalog, reachable by any function-calling - # app over plain HTTP. Authed like /mcp (Bearer → scoped context) and enforced per-tool - # by call_tool, so a read-only token gets a read-only surface. GET /v1/tools/schema + # app over plain HTTP. The REST adapter accepts API or MCP scoped tokens while /mcp + # remains MCP-only; call_tool still enforces each tool's scope. GET /v1/tools/schema # projects the catalog to openai/anthropic/openapi/mcp; POST /v1/tools/call (generic) # and POST /v1/tools/{name} (per-tool, matches the OpenAPI operationIds) dispatch tools. if path == "/v1/tools/schema" and method == "GET": - context = self._auth_mcp() + context = self._auth_mcp(allow_api=True) if not context: return token_scopes = None if context.get("admin") else list(context.get("scopes") or []) @@ -1018,7 +1019,7 @@ def _dispatch(self, method: str, path: str, params: dict[str, list[str]]) -> Non self._send_json({"detail": str(exc)}, status=HTTPStatus.UNPROCESSABLE_ENTITY) return if path.startswith("/v1/tools/") and method == "POST": - context = self._auth_mcp() + context = self._auth_mcp(allow_api=True) if not context: return tool_user = context["user_id"] @@ -2590,21 +2591,94 @@ def _dispatch(self, method: str, path: str, params: dict[str, list[str]]) -> Non if path == "/v1/context" and method in {"GET", "POST"}: if method == "POST": body = self._json_body() - task = str(body.get("task") or "") - surface = str(body.get("surface") or "agent") - intent = str(body.get("intent") or "") or None - sector = str(body.get("sector") or "") or None - project = str(body.get("project") or "") or None - as_of = str(body.get("as_of") or "") or None + required_text = (("task", ""), ("surface", "agent"), ("format", "json")) + optional_text = ("intent", "sector", "project", "as_of", "model", "session_id") + invalid_text = next( + ( + name + for name, default in required_text + if not isinstance(body.get(name, default), str) + ), + None, + ) + if invalid_text is None: + invalid_text = next( + ( + name + for name in optional_text + if body.get(name) is not None and not isinstance(body.get(name), str) + ), + None, + ) + if invalid_text is not None: + self._send_json( + {"detail": f"{invalid_text} must be a string"}, + status=HTTPStatus.UNPROCESSABLE_ENTITY, + ) + return + task = body.get("task", "") + surface = body.get("surface", "agent") + intent = body.get("intent") or None + sector = body.get("sector") or None + project = body.get("project") or None + as_of = body.get("as_of") or None output_format = str(body.get("format") or "json").strip().lower() - model = str(body.get("model") or "") or None - pin = bool(body.get("pin")) - pin_session_id = str(body.get("session_id") or "") or None + model = body.get("model") or None + pin_session_id = body.get("session_id") or None + raw_pin = body.get("pin", False) + if isinstance(raw_pin, bool): + pin = raw_pin + elif raw_pin in (0, 1): + pin = bool(raw_pin) + elif isinstance(raw_pin, str) and raw_pin.strip().lower() in { + "0", "1", "false", "true", "off", "on", "no", "yes", + }: + pin = raw_pin.strip().lower() in {"1", "true", "on", "yes"} + else: + self._send_json( + {"detail": "pin must be a boolean"}, + status=HTTPStatus.UNPROCESSABLE_ENTITY, + ) + return try: - token_budget = int(body.get("token_budget") or 2000) + raw_budget = body.get("token_budget", 2000) + if isinstance(raw_budget, bool): + raise ValueError + token_budget = int(raw_budget) except (TypeError, ValueError): self._send_json({"detail": "token_budget must be an integer"}, status=HTTPStatus.UNPROCESSABLE_ENTITY) return + bounded_text = ( + ("task", task, 500), + ("surface", surface, 40), + ("intent", intent, 16), + ("sector", sector, 120), + ("project", project, 160), + ("as_of", as_of, 40), + ("model", model, 80), + ("session_id", pin_session_id, 120), + ) + oversized = next( + ( + (name, limit) + for name, value, limit in bounded_text + if value is not None and len(str(value)) > limit + ), + None, + ) + if oversized is not None: + name, limit = oversized + self._send_json( + {"detail": f"{name} must be at most {limit} characters"}, + status=HTTPStatus.UNPROCESSABLE_ENTITY, + ) + return + if token_budget < 1 or token_budget > 100000: + self._send_json( + {"detail": "token_budget must be between 1 and 100000"}, + status=HTTPStatus.UNPROCESSABLE_ENTITY, + ) + return else: task = (params.get("task") or [""])[0] surface = (params.get("surface") or ["agent"])[0] @@ -3268,6 +3342,8 @@ def _dispatch(self, method: str, path: str, params: dict[str, list[str]]) -> Non # Malformed body escaped a per-endpoint handler (many read the body before their own # try): return 422 like the framework server, not a 500. self._send_json({"detail": str(exc)}, status=HTTPStatus.UNPROCESSABLE_ENTITY) + except ExportSizeLimitError as exc: + self._send_json({"detail": str(exc)}, status=HTTPStatus.REQUEST_ENTITY_TOO_LARGE) except Exception as exc: self._send_json({"detail": self._safe_error_message(exc)}, status=HTTPStatus.INTERNAL_SERVER_ERROR) @@ -3344,22 +3420,6 @@ def _handle_mcp(self, context: dict) -> None: except Exception as exc: self._send_json({"jsonrpc": "2.0", "id": request.get("id"), "error": {"code": -32000, "message": self._safe_error_message(exc)}}) - def _bearer_has_export_scope(self) -> bool: - """Whether the (already-authenticated) bearer may see the identity/persona layer of a - context pack. The admin app token always may; scoped API tokens need the export scope. - Read-only callers still get a pack — identity degrades to a visible omission record.""" - authorization = self.headers.get("Authorization", "") - if not authorization.lower().startswith("bearer "): - return False - token = authorization.split(" ", 1)[1].strip() - if settings.api_key and hmac.compare_digest(token, settings.api_key): - return True - try: - scoped = store.authenticate_api_token(token) - except TypeError: - scoped = None - return bool(scoped and "export" in set(scoped.get("scopes") or [])) - def _auth_user(self, method: str, path: str) -> str | None: authorization = self.headers.get("Authorization", "") if not authorization.lower().startswith("bearer "): @@ -3398,7 +3458,7 @@ def _auth_user(self, method: str, path: str) -> str | None: self._send_json({"detail": f"Missing or invalid {APP_BRAND} API token"}, status=HTTPStatus.UNAUTHORIZED) return None - def _auth_mcp(self) -> dict | None: + def _auth_mcp(self, *, allow_api: bool = False) -> dict | None: authorization = self.headers.get("Authorization", "") if not authorization.lower().startswith("bearer "): self._send_json({"detail": f"Missing or invalid {APP_BRAND} MCP token"}, status=HTTPStatus.UNAUTHORIZED) @@ -3424,9 +3484,28 @@ def _auth_mcp(self) -> dict | None: scoped = store.authenticate_mcp_token(token, user_id=self.headers.get("X-Cortex-User")) except TypeError: scoped = store.authenticate_mcp_token(token) + if scoped is None and allow_api: + try: + scoped = store.authenticate_api_token( + token, + user_id=self.headers.get("X-Cortex-User"), + ) + except TypeError: + scoped = store.authenticate_api_token(token) if scoped: + requested_user = self.headers.get("X-Cortex-User") + if requested_user and scoped["user_id"] != requested_user: + self._send_json( + {"detail": f"{APP_BRAND} token does not match requested user"}, + status=HTTPStatus.FORBIDDEN, + ) + return None return scoped - self._send_json({"detail": f"Missing or invalid {APP_BRAND} MCP token"}, status=HTTPStatus.UNAUTHORIZED) + audience = "API or MCP" if allow_api else "MCP" + self._send_json( + {"detail": f"Missing or invalid {APP_BRAND} {audience} token"}, + status=HTTPStatus.UNAUTHORIZED, + ) return None def _auth_token(self, token: str | None, *, required_scope: str = "write") -> str | None: @@ -3521,6 +3600,14 @@ def _send_bytes(self, data: bytes, status: HTTPStatus = HTTPStatus.OK, media_typ def serve(host: str = "127.0.0.1", port: int | None = None) -> None: + if not _standalone_host_is_loopback(host) and os.environ.get( + "CORTEX_ALLOW_NONLOCAL_STANDALONE", "" + ).strip().lower() not in {"1", "true", "yes", "on"}: + raise ValueError( + "The dependency-light standalone server is local-only. Bind to a " + "loopback address or explicitly set CORTEX_ALLOW_NONLOCAL_STANDALONE=1 " + "after reviewing its local trust model." + ) resolved_port = port or int(os.environ.get("CORTEX_PORT", "8766")) server = ThreadingHTTPServer((host, resolved_port), CortexRequestHandler) worker_thread = _start_standalone_worker() @@ -3530,6 +3617,19 @@ def serve(host: str = "127.0.0.1", port: int | None = None) -> None: server.serve_forever() +def _standalone_host_is_loopback(host: str) -> bool: + normalized = str(host or "").strip().strip("[]").lower() + if normalized == "localhost": + return True + try: + address = ipaddress.ip_address(normalized) + except ValueError: + return False + if isinstance(address, ipaddress.IPv6Address) and address.ipv4_mapped is not None: + return address.ipv4_mapped.is_loopback + return address.is_loopback + + def main() -> None: parser = argparse.ArgumentParser(description=f"Run the dependency-light {APP_BRAND} local backend.") parser.add_argument("--host", default="127.0.0.1") diff --git a/backend/app/storage.py b/backend/app/storage.py index 3dd31bf8..59911540 100644 --- a/backend/app/storage.py +++ b/backend/app/storage.py @@ -17,7 +17,7 @@ from pathlib import Path from typing import Any, Iterable, NamedTuple from urllib.parse import parse_qsl, quote, unquote, urlencode, urlsplit, urlunsplit -from urllib.request import Request, urlopen +from urllib.request import Request from .config import APP_BRAND from .connector_policy import ( @@ -28,6 +28,7 @@ from .connectors._redaction import redact_error_message from .database import connect, sqlite_vec_status from .embeddings import VECTOR_DIMENSIONS, configured_embedding_model, embed_text, embed_text_result, embedding_hash, embedding_json, embedding_source_text, embedding_status, warmup_embedding_provider +from .http_security import open_same_origin from .query_plan import ( build_query_plan, context_hop_deadline_ms, @@ -64,6 +65,19 @@ BACKEND_VERSION = "0.1.0" HEALTH_CONTRACT = 3 +try: + MAX_SYNCHRONOUS_EXPORT_BYTES = max( + 1_000_000, + int(os.environ.get("CORTEX_MAX_SYNC_EXPORT_BYTES", "25000000") or "25000000"), + ) +except ValueError: + MAX_SYNCHRONOUS_EXPORT_BYTES = 25_000_000 + + +class ExportSizeLimitError(ValueError): + """The corpus is too large for an in-memory synchronous export.""" + + BACKEND_FEATURES = ( "local-vault", "capture-surfaces", @@ -1696,7 +1710,7 @@ def _request_oauth_token(token_endpoint: str, form: dict[str, str]) -> dict[str, headers={"Accept": "application/json", "Content-Type": "application/x-www-form-urlencoded"}, method="POST", ) - with urlopen(request, timeout=30) as response: # noqa: S310 - caller supplies trusted OAuth token endpoint. + with open_same_origin(request, timeout=30) as response: return json.loads(response.read().decode("utf-8")) @@ -1742,7 +1756,7 @@ def _request_oauth_broker_exchange( headers={"Accept": "application/json", "Content-Type": "application/json"}, method="POST", ) - with urlopen(request, timeout=30) as response: # noqa: S310 - app-configured broker endpoint. + with open_same_origin(request, timeout=30) as response: return json.loads(response.read().decode("utf-8")) @@ -1767,7 +1781,7 @@ def _request_basic_json_oauth_token( headers=request_headers, method="POST", ) - with urlopen(request, timeout=30) as response: # noqa: S310 - caller supplies trusted OAuth token endpoint. + with open_same_origin(request, timeout=30) as response: return json.loads(response.read().decode("utf-8")) @@ -18588,8 +18602,8 @@ def assemble_context( (search, decision_history, entity_neighborhood, open_tasks, recent) under a per-intent budget allocator. Every included item is citation-backed (`_has_source_citation`); superseded facts are never served; drops are counted - and visible, never silent. `include_identity=False` (read-only tokens) replaces - the identity layer with an explicit omission record instead of erroring. + and visible, never silent. Callers that deliberately set `include_identity=False` + replace the identity layer with an explicit omission record instead of erroring. With `pin=True` (Phase 2) the assembled pack is also persisted as an immutable, content-addressed artifact: canonical JSON bytes hashed with sha256, written @@ -20117,9 +20131,48 @@ def graph(self, user_id: str, limit: int = 150) -> dict[str, Any]: redact_sensitive=bool(user_settings["redact_sensitive_context"]), ) + def _estimated_export_bytes(self, conn: Any, user_id: str) -> int: + """Conservative preflight before materializing several full corpus copies. + + SQLite ``length(TEXT)`` counts Unicode code points, not serialized + bytes. Canonical portable JSON uses ``ensure_ascii=True``; a non-BMP + code point can therefore expand to a 12-byte surrogate pair. The + 12x multiplier is the real worst case, and the per-row allowance covers + keys, separators, nulls and envelope metadata. The 25 MB default leaves + headroom for the portable bundle's canonical and base64 copies. + """ + raw_bytes = 0 + row_count = 0 + for table in ("captures", "memories", "tasks", "entities", "graph_edges"): + columns = [ + str(row["name"]) + for row in conn.execute(f'PRAGMA table_info("{table}")').fetchall() + ] + length_terms = " + ".join( + f'COALESCE(length("{column}"), 0)' for column in columns + ) + row = conn.execute( + f'SELECT COUNT(*) AS row_count, ' + f'COALESCE(SUM({length_terms}), 0) AS raw_bytes ' + f'FROM "{table}" WHERE user_id = ?', + (user_id,), + ).fetchone() + row_count += int(row["row_count"] or 0) + raw_bytes += int(row["raw_bytes"] or 0) + return raw_bytes * 12 + row_count * 1024 + + def _enforce_synchronous_export_limit(self, conn: Any, user_id: str) -> None: + estimated = self._estimated_export_bytes(conn, user_id) + if estimated > MAX_SYNCHRONOUS_EXPORT_BYTES: + raise ExportSizeLimitError( + "Export is too large for the synchronous API " + f"(estimated {estimated} bytes; limit {MAX_SYNCHRONOUS_EXPORT_BYTES})." + ) + def export_json(self, user_id: str) -> dict[str, Any]: user_settings = self.settings(user_id) with connect(self.db_path) as conn: + self._enforce_synchronous_export_limit(conn, user_id) captures = [self._capture_from_row(row) for row in conn.execute("SELECT * FROM captures WHERE user_id = ? ORDER BY captured_at DESC", (user_id,)).fetchall()] imports = self.list_imports(user_id, limit=100) memories = [self._memory_from_row(row) for row in conn.execute("SELECT * FROM memories WHERE user_id = ? ORDER BY captured_at DESC", (user_id,)).fetchall()] @@ -21119,6 +21172,10 @@ def reconcile_vault_edits(self, user_id: str) -> dict[str, Any]: divergence and reconcile the SQLite index to match the current Markdown state, re-running the vault rebuild only when something actually changed (so idle watcher fires are cheap). """ + with self.vault.read_snapshot(): + return self._reconcile_vault_edits_locked(user_id) + + def _reconcile_vault_edits_locked(self, user_id: str) -> dict[str, Any]: markdown_records = self.vault.iter_memory_markdown_records(user_id) markdown_sig: dict[str, str] = {} for record in markdown_records: @@ -21270,6 +21327,10 @@ def _backfill_memory_markdown(self, user_id: str) -> int: return written def rebuild_index_from_vault(self, user_id: str) -> dict[str, Any]: + with self.vault.read_snapshot(): + return self._rebuild_index_from_vault_locked(user_id) + + def _rebuild_index_from_vault_locked(self, user_id: str) -> dict[str, Any]: tombstone_counts = self.vault.apply_tombstones(user_id) imports = list(self.vault.iter_records("imports", user_id)) source_accounts = list(self.vault.iter_records("source_accounts", user_id)) diff --git a/backend/app/vault.py b/backend/app/vault.py index c2ec98c3..27b32b48 100644 --- a/backend/app/vault.py +++ b/backend/app/vault.py @@ -8,6 +8,7 @@ import tempfile import threading import zipfile +import fcntl from datetime import datetime, timedelta, timezone from pathlib import Path, PurePosixPath from typing import Any, Iterable @@ -23,22 +24,61 @@ ) -# A process-wide lock per vault root. In hosted/bucket mode many request threads (plus the -# background worker) share ONE vault and do read-modify-write on the same root-level JSON files -# (settings.json, credentials.json, manifest.json, events.jsonl). Without serialization those -# RMW cycles lose updates — last writer wins on a stale read — silently dropping a user's -# settings or a source credential. Keyed by the absolute root path so it serializes even across -# separate CortexVault instances that happen to point at the same directory. -_VAULT_LOCKS: dict[str, threading.RLock] = {} +# A reentrant thread + process lock per vault root. Hosted deployments run multiple API workers +# plus a background worker against the same bucket vault. A threading.RLock alone does not +# serialize those processes: concurrent read-modify-write cycles silently lose settings, +# credentials, and events. The outermost acquisition takes an advisory flock; nested calls in the +# same thread reuse it. Cortex targets macOS/Linux, where fcntl.flock is available. +class _VaultInterProcessRLock: + def __init__(self, root: Path): + self.root = Path(root) + self._thread_lock = threading.RLock() + self._local = threading.local() + + def __enter__(self) -> "_VaultInterProcessRLock": + self._thread_lock.acquire() + depth = int(getattr(self._local, "depth", 0)) + if depth == 0: + handle = None + try: + self.root.mkdir(parents=True, exist_ok=True) + handle = (self.root / ".cortex-vault.lock").open("a+b") + fcntl.flock(handle.fileno(), fcntl.LOCK_EX) + self._local.handle = handle + except Exception: + if handle is not None: + handle.close() + self._thread_lock.release() + raise + self._local.depth = depth + 1 + return self + + def __exit__(self, exc_type: Any, exc: Any, traceback: Any) -> None: + depth = int(getattr(self._local, "depth", 1)) - 1 + self._local.depth = depth + try: + if depth == 0: + handle = getattr(self._local, "handle", None) + if handle is not None: + try: + fcntl.flock(handle.fileno(), fcntl.LOCK_UN) + finally: + handle.close() + del self._local.handle + finally: + self._thread_lock.release() + + +_VAULT_LOCKS: dict[str, _VaultInterProcessRLock] = {} _VAULT_LOCKS_GUARD = threading.Lock() -def _vault_lock_for(root: Path) -> threading.RLock: +def _vault_lock_for(root: Path) -> _VaultInterProcessRLock: key = os.path.abspath(os.path.expanduser(str(root))) with _VAULT_LOCKS_GUARD: lock = _VAULT_LOCKS.get(key) if lock is None: - lock = threading.RLock() + lock = _VaultInterProcessRLock(Path(key)) _VAULT_LOCKS[key] = lock return lock @@ -90,6 +130,7 @@ class CredentialEncryptionRequiredError(RuntimeError): # user's own note dropped into Journal/ is never mistaken for a generated page (and never deleted). _DAILY_DATE_RE = re.compile(r"\d{4}-\d{2}-\d{2}") BACKUP_DENY_FILENAMES = { + ".cortex-vault.lock", ".env", ".netrc", "credentials.json", @@ -130,13 +171,21 @@ def __init__(self, root: Path, index_path: Path, cipher: Any | None = None, enfo # enforcement -> writes plaintext exactly as before). Threaded in alongside # the cipher via StoreRegistry.store_for_user in hosted mode. self.enforce_encryption = bool(enforce_encryption) - # Phase 1: also mirror each memory as a human-readable Markdown note (frontmatter+body) - # so the vault opens in any Markdown editor and is owned/portable. Additive — the JSON records - # remain the source of truth for now; a Markdown write never breaks the JSON write. + # Each memory has a human-readable Markdown representation (frontmatter+body). + # Editable memory fields in Markdown win during rebuild; JSON retains immutable/system + # fields and remains authoritative for non-memory record types. self.markdown_mirror = True # Serializes read-modify-write of the shared root-level JSON files (see _vault_lock_for). self._lock = _vault_lock_for(self.root) + def read_snapshot(self) -> _VaultInterProcessRLock: + """Return the vault-wide lock for a multi-read consistent snapshot. + + Callers that derive destructive decisions from several filesystem + observations must hold this context across the entire observation. + """ + return self._lock + @property def manifest_path(self) -> Path: return self.root / "manifest.json" @@ -169,51 +218,52 @@ def tombstones_dir(self) -> Path: return self.root / "deletion_tombstones" def ensure(self) -> None: - self.root.mkdir(parents=True, exist_ok=True) - for directory in VAULT_DIRECTORIES: - (self.root / directory).mkdir(parents=True, exist_ok=True) - if not self.events_path.exists(): - self.events_path.touch() - if not self.settings_path.exists(): - self._write_json( - self.settings_path, - { - "vault_record_type": "settings", - "vault_record_version": VAULT_VERSION, - "updated_at": vault_now(), - "users": {}, - }, - ) - if not self.manifest_path.exists(): - self._write_json( - self.manifest_path, - { - "name": f"{APP_BRAND} Vault", - "format": VAULT_FORMAT, - "version": VAULT_VERSION, - "created_at": vault_now(), - "updated_at": vault_now(), - "source_of_truth": "json_records_and_events", - "index_role": "rebuildable_local_search_index", - "index_path": self._relative_or_absolute(self.index_path), - "directories": list(VAULT_DIRECTORIES), - }, - ) - else: - manifest = self._read_json(self.manifest_path, {}) - manifest.update( - { - "format": manifest.get("format", VAULT_FORMAT), - "version": manifest.get("version", VAULT_VERSION), - "updated_at": vault_now(), - "source_of_truth": "json_records_and_events", - "index_role": "rebuildable_local_search_index", - "index_path": self._relative_or_absolute(self.index_path), - "directories": list(VAULT_DIRECTORIES), - } - ) - self._write_json(self.manifest_path, manifest) - self._ensure_sync_scaffolding() + with self._lock: + self.root.mkdir(parents=True, exist_ok=True) + for directory in VAULT_DIRECTORIES: + (self.root / directory).mkdir(parents=True, exist_ok=True) + if not self.events_path.exists(): + self.events_path.touch() + if not self.settings_path.exists(): + self._write_json( + self.settings_path, + { + "vault_record_type": "settings", + "vault_record_version": VAULT_VERSION, + "updated_at": vault_now(), + "users": {}, + }, + ) + if not self.manifest_path.exists(): + self._write_json( + self.manifest_path, + { + "name": f"{APP_BRAND} Vault", + "format": VAULT_FORMAT, + "version": VAULT_VERSION, + "created_at": vault_now(), + "updated_at": vault_now(), + "source_of_truth": "markdown_memories_and_json_records_and_events", + "index_role": "rebuildable_local_search_index", + "index_path": self._relative_or_absolute(self.index_path), + "directories": list(VAULT_DIRECTORIES), + }, + ) + else: + manifest = self._read_json(self.manifest_path, {}) + manifest.update( + { + "format": manifest.get("format", VAULT_FORMAT), + "version": manifest.get("version", VAULT_VERSION), + "updated_at": vault_now(), + "source_of_truth": "markdown_memories_and_json_records_and_events", + "index_role": "rebuildable_local_search_index", + "index_path": self._relative_or_absolute(self.index_path), + "directories": list(VAULT_DIRECTORIES), + } + ) + self._write_json(self.manifest_path, manifest) + self._ensure_sync_scaffolding() def _ensure_sync_scaffolding(self) -> None: """Make the vault safe + pleasant to sync (git/iCloud/Syncthing) and to open in any Markdown editor. @@ -233,6 +283,7 @@ def _ensure_sync_scaffolding(self) -> None: "*.db\n" "*.db-*\n" "credentials.json\n" + ".cortex-vault.lock\n" "*.log\n" "backups/\n" ".*.tmp\n" @@ -253,7 +304,7 @@ def _ensure_sync_scaffolding(self) -> None: " change; add a note and it becomes a memory; delete one to remove it.\n" "- `captures/`, `entities/`, `tasks/`, ... — supporting records.\n" f"- The SQLite index and `credentials.json` are {APP_BRAND}'s private working files —\n" - " the index is rebuildable from these notes and `credentials.json` holds secrets,\n" + " the index is rebuildable from the Markdown and JSON records; `credentials.json` holds secrets,\n" " so both are excluded from sync by `.gitignore`.\n\n" f"Even if {APP_BRAND} goes away, these Markdown files stay readable and yours.\n" ) @@ -305,18 +356,22 @@ def write_capture_bundle( entities: list[dict[str, Any]], edges: list[dict[str, Any]], ) -> dict[str, str]: - self.ensure() - written: dict[str, str] = {} - written["capture"] = str(self.write_capture(capture)) - for memory in memories: - written[f"memory:{memory['id']}"] = str(self.write_memory(memory)) - for task in tasks: - written[f"task:{task['id']}"] = str(self.write_task(task)) - for entity in entities: - written[f"entity:{entity['id']}"] = str(self.write_entity(entity)) - for edge in edges: - written[f"edge:{edge['id']}"] = str(self.write_edge(edge)) - return written + # A backup/delete/restore must observe either the whole capture-derived + # bundle or none of it. The lock is reentrant, so the individual write + # helpers can retain their own safety boundary. + with self._lock: + self.ensure() + written: dict[str, str] = {} + written["capture"] = str(self.write_capture(capture)) + for memory in memories: + written[f"memory:{memory['id']}"] = str(self.write_memory(memory)) + for task in tasks: + written[f"task:{task['id']}"] = str(self.write_task(task)) + for entity in entities: + written[f"entity:{entity['id']}"] = str(self.write_entity(entity)) + for edge in edges: + written[f"edge:{edge['id']}"] = str(self.write_edge(edge)) + return written def write_portable_memory_bundle( self, @@ -384,14 +439,15 @@ def write_context_pack(self, pack_sha: str, canonical_bytes: bytes) -> Path: _write_record, no envelope keys are injected: byte-identity is the whole contract — sha256(file bytes) == pack_sha must hold forever, so replay/verify can prove integrity. Content-addressed files are immutable: an existing file is left untouched.""" - path = self.context_pack_path(pack_sha) - if path.exists(): + with self._lock: + path = self.context_pack_path(pack_sha) + if path.exists(): + return path + path.parent.mkdir(parents=True, exist_ok=True) + temp = path.with_name(path.name + ".tmp") + temp.write_bytes(canonical_bytes) + temp.replace(path) return path - path.parent.mkdir(parents=True, exist_ok=True) - temp = path.with_name(path.name + ".tmp") - temp.write_bytes(canonical_bytes) - temp.replace(path) - return path def read_context_pack(self, pack_sha: str) -> bytes | None: path = self.context_pack_path(pack_sha) @@ -406,14 +462,15 @@ def working_canvas_evidence_path(self, raw_sha256: str) -> Path: return self.root / "working_canvas" / "evidence" / sha[:2] / f"{sha}.txt" def write_working_canvas_evidence(self, raw_sha256: str, raw_bytes: bytes) -> Path: - path = self.working_canvas_evidence_path(raw_sha256) - if path.exists(): + with self._lock: + path = self.working_canvas_evidence_path(raw_sha256) + if path.exists(): + return path + path.parent.mkdir(parents=True, exist_ok=True) + temp = path.with_name(path.name + ".tmp") + temp.write_bytes(raw_bytes) + temp.replace(path) return path - path.parent.mkdir(parents=True, exist_ok=True) - temp = path.with_name(path.name + ".tmp") - temp.write_bytes(raw_bytes) - temp.replace(path) - return path def read_working_canvas_evidence(self, raw_sha256: str) -> bytes | None: try: @@ -659,11 +716,15 @@ def delete_source_credential(self, *, user_id: str, source_account_id: str) -> b return True def write_memory(self, record: dict[str, Any]) -> Path: - kind = safe_segment(record.get("kind"), "memory") - path = self.root / "memories" / kind / f"{safe_segment(record.get('id'), 'memory')}.json" - written = self._write_record(path, "memory", record) - self._write_memory_markdown(record) - return written + # JSON + Markdown are one logical memory representation. Holding the + # vault lock across both prevents backups from capturing a mismatched + # pair during an edit. + with self._lock: + kind = safe_segment(record.get("kind"), "memory") + path = self.root / "memories" / kind / f"{safe_segment(record.get('id'), 'memory')}.json" + written = self._write_record(path, "memory", record) + self._write_memory_markdown(record) + return written def memory_note_short_id(self, memory_id: str) -> str: """Stable 12-hex (48-bit) fingerprint of a memory id, used as the note-filename @@ -751,6 +812,10 @@ def migrate_memory_note_filenames(self, user_id: str | None = None) -> int: (atomic rename, not a re-render, so no content is ever lost — a stale generated-links block simply refreshes on the note's next write). Scoped to user_id when the vault is shared. Best-effort; returns the number of notes renamed.""" + with self._lock: + return self._migrate_memory_note_filenames_locked(user_id) + + def _migrate_memory_note_filenames_locked(self, user_id: str | None = None) -> int: if not self.markdown_mirror: return 0 base = self.root / "memories" @@ -835,40 +900,42 @@ def write_entity_markdown(self, page: dict[str, Any]) -> Path | None: if not entity_id: return None try: - target = self.entity_moc_path(page) - short = self.entity_moc_short_id(entity_id) + with self._lock: + target = self.entity_moc_path(page) + short = self.entity_moc_short_id(entity_id) + for folder in ENTITY_MOC_DIRECTORIES: + base = self.root / folder + if not base.exists(): + continue + for path in base.glob(f"*--{short}.md"): + if path != target: + try: + path.unlink() + except OSError: + pass + atomic_write_text(target, render_entity_moc_markdown(page)) + return target + except Exception: + return None + + def prune_entity_moc_pages(self, keep_short_ids: set[str]) -> int: + """Delete MOC pages whose entity hash8 is not in keep_short_ids (entity deleted/merged away).""" + with self._lock: + removed = 0 for folder in ENTITY_MOC_DIRECTORIES: base = self.root / folder if not base.exists(): continue - for path in base.glob(f"*--{short}.md"): - if path != target: + for path in base.glob("*.md"): + stem = path.stem + short = stem.rsplit("--", 1)[-1] if "--" in stem else "" + if short and short not in keep_short_ids: try: path.unlink() + removed += 1 except OSError: pass - atomic_write_text(target, render_entity_moc_markdown(page)) - return target - except Exception: - return None - - def prune_entity_moc_pages(self, keep_short_ids: set[str]) -> int: - """Delete MOC pages whose entity hash8 is not in keep_short_ids (entity deleted/merged away).""" - removed = 0 - for folder in ENTITY_MOC_DIRECTORIES: - base = self.root / folder - if not base.exists(): - continue - for path in base.glob("*.md"): - stem = path.stem - short = stem.rsplit("--", 1)[-1] if "--" in stem else "" - if short and short not in keep_short_ids: - try: - path.unlink() - removed += 1 - except OSError: - pass - return removed + return removed # ---- N3 Home page / N4 Journal / N5 Canvas: machine-owned vault pages (outside memories/) ---- @@ -878,9 +945,10 @@ def write_home_markdown(self, page: dict[str, Any]) -> Path | None: if not self.markdown_mirror: return None try: - target = self.root / HOME_PAGE_FILENAME - atomic_write_text(target, render_home_markdown(page)) - return target + with self._lock: + target = self.root / HOME_PAGE_FILENAME + atomic_write_text(target, render_home_markdown(page)) + return target except Exception: return None @@ -895,9 +963,10 @@ def write_daily_markdown(self, page: dict[str, Any]) -> Path | None: if not date: return None try: - target = self.daily_markdown_path(date) - atomic_write_text(target, render_daily_markdown(page)) - return target + with self._lock: + target = self.daily_markdown_path(date) + atomic_write_text(target, render_daily_markdown(page)) + return target except Exception: return None @@ -914,20 +983,21 @@ def prune_daily_pages(self, keep_dates: set[str]) -> int: within the regenerated window). ONLY ever deletes files whose stem is a YYYY-MM-DD date — a user's own note dropped into Journal/ (e.g. reading-list.md) is never touched. Days outside the caller's window are also never pruned (callers pass the full considered set).""" - removed = 0 - base = self.root / DAILY_DIRECTORY - if not base.exists(): - return 0 - for path in base.glob("*.md"): - if not _DAILY_DATE_RE.fullmatch(path.stem): - continue # not a generated day page (user-authored) -> never delete - if path.stem not in keep_dates: - try: - path.unlink() - removed += 1 - except OSError: - pass - return removed + with self._lock: + removed = 0 + base = self.root / DAILY_DIRECTORY + if not base.exists(): + return 0 + for path in base.glob("*.md"): + if not _DAILY_DATE_RE.fullmatch(path.stem): + continue # not a generated day page (user-authored) -> never delete + if path.stem not in keep_dates: + try: + path.unlink() + removed += 1 + except OSError: + pass + return removed def canvas_path(self, name: str = CONSTELLATION_CANVAS_FILENAME) -> Path: stem = safe_segment(name[:-7] if name.endswith(".canvas") else name, "Constellation") @@ -940,9 +1010,10 @@ def write_canvas(self, doc: dict[str, Any], name: str = CONSTELLATION_CANVAS_FIL if not self.markdown_mirror: return None try: - target = self.canvas_path(name) - atomic_write_text(target, json.dumps(doc, ensure_ascii=False, indent=2) + "\n") - return target + with self._lock: + target = self.canvas_path(name) + atomic_write_text(target, json.dumps(doc, ensure_ascii=False, indent=2) + "\n") + return target except Exception: return None @@ -995,12 +1066,13 @@ def patch_sync_cursor(self, cursor_id: str, updates: dict[str, Any]) -> bool: return self._patch_first("sync_cursors", cursor_id, updates) def patch_memory(self, memory_id: str, updates: dict[str, Any]) -> bool: - patched_json = self._patch_first("memories", memory_id, updates) - # Keep the Markdown note in sync — it's the rebuild source of truth, so a patch that - # only touched JSON (e.g. status -> archived, superseded_by) would otherwise be reverted - # on the next Markdown-sourced rebuild. Also patches Markdown-native memories with no JSON. - patched_markdown = self._patch_memory_markdown(memory_id, updates) - return patched_json or patched_markdown + with self._lock: + patched_json = self._patch_first("memories", memory_id, updates) + # Keep the Markdown note in sync — it's the rebuild source of truth, so a patch that + # only touched JSON (e.g. status -> archived, superseded_by) would otherwise be reverted + # on the next Markdown-sourced rebuild. Also patches Markdown-native memories with no JSON. + patched_markdown = self._patch_memory_markdown(memory_id, updates) + return patched_json or patched_markdown def _patch_memory_markdown(self, memory_id: str, updates: dict[str, Any]) -> bool: if not self.markdown_mirror: @@ -1032,9 +1104,12 @@ def delete_import(self, import_id: str) -> bool: return self._delete_first("imports", import_id) def delete_memory(self, memory_id: str) -> bool: - removed_json = self._delete_first("memories", memory_id) - removed_markdown = self._delete_memory_markdown(memory_id) - return removed_json or removed_markdown + # JSON and Markdown are one logical representation; a backup must not + # observe the delete between the two filesystem mutations. + with self._lock: + removed_json = self._delete_first("memories", memory_id) + removed_markdown = self._delete_memory_markdown(memory_id) + return removed_json or removed_markdown def delete_task(self, task_id: str) -> bool: return self._delete_first("tasks", task_id) @@ -1118,6 +1193,12 @@ def iter_tombstones(self, user_id: str | None = None) -> Iterable[dict[str, Any] return self.iter_records("deletion_tombstones", user_id) def apply_tombstones(self, user_id: str) -> dict[str, int]: + # A tombstone can delete a graph of related records. Freeze that full + # logical operation so a backup never captures only a subset. + with self._lock: + return self._apply_tombstones_locked(user_id) + + def _apply_tombstones_locked(self, user_id: str) -> dict[str, int]: self.ensure() counts = { "applied": 0, @@ -1213,22 +1294,27 @@ def apply_tombstones(self, user_id: str) -> dict[str, int]: return counts def iter_events(self, user_id: str | None = None) -> Iterable[dict[str, Any]]: - if not self.events_path.exists(): - return [] - events: list[dict[str, Any]] = [] - with self.events_path.open("r", encoding="utf-8") as handle: - for line in handle: - try: - payload = json.loads(line) - except json.JSONDecodeError: - continue - if user_id is not None and payload.get("user_id") != user_id: - continue - events.append(payload) - return events + with self._lock: + if not self.events_path.exists(): + return [] + events: list[dict[str, Any]] = [] + with self.events_path.open("r", encoding="utf-8") as handle: + for line in handle: + try: + payload = json.loads(line) + except json.JSONDecodeError: + continue + if user_id is not None and payload.get("user_id") != user_id: + continue + events.append(payload) + return events def create_zip_backup(self, timestamp: str, sqlite_backup_path: Path) -> Path: self.ensure() + with self._lock: + return self._create_zip_backup_locked(timestamp, sqlite_backup_path) + + def _create_zip_backup_locked(self, timestamp: str, sqlite_backup_path: Path) -> Path: backup_path = self.backups_dir / f"cortex-vault-{timestamp}.zip" with zipfile.ZipFile(backup_path, "w", compression=zipfile.ZIP_DEFLATED, compresslevel=6) as archive: for path in sorted(self._iter_backup_files()): @@ -1275,6 +1361,10 @@ def _should_include_backup_file(self, path: Path) -> bool: def delete_backups(self) -> dict[str, Any]: self.ensure() + with self._lock: + return self._delete_backups_locked() + + def _delete_backups_locked(self) -> dict[str, Any]: deleted = 0 bytes_deleted = 0 for path in sorted(self.backups_dir.glob("*")): @@ -1287,6 +1377,10 @@ def delete_backups(self) -> dict[str, Any]: def prune_backups(self, *, keep_latest: int = 20, max_age_days: int = 0) -> dict[str, Any]: self.ensure() + with self._lock: + return self._prune_backups_locked(keep_latest=keep_latest, max_age_days=max_age_days) + + def _prune_backups_locked(self, *, keep_latest: int = 20, max_age_days: int = 0) -> dict[str, Any]: keep_latest = max(0, int(keep_latest)) max_age_days = max(0, int(max_age_days)) backups = sorted( @@ -1321,6 +1415,10 @@ def prune_backups(self, *, keep_latest: int = 20, max_age_days: int = 0) -> dict } def delete_user_records(self, user_id: str, *, include_backups: bool = False) -> dict[str, Any]: + with self._lock: + return self._delete_user_records_locked(user_id, include_backups=include_backups) + + def _delete_user_records_locked(self, user_id: str, *, include_backups: bool = False) -> dict[str, Any]: self.ensure() counts: dict[str, int] = { "imports": 0, @@ -1457,6 +1555,10 @@ def delete_user_records(self, user_id: str, *, include_backups: bool = False) -> return counts def restore_from_zip_backup(self, backup_path: Path) -> dict[str, Any]: + with self._lock: + return self._restore_from_zip_backup_locked(backup_path) + + def _restore_from_zip_backup_locked(self, backup_path: Path) -> dict[str, Any]: self.ensure() backup_path = Path(backup_path).expanduser().resolve() backups_dir = self.backups_dir.resolve() @@ -1595,8 +1697,8 @@ def diagnostics(self) -> dict[str, Any]: def _write_record(self, path: Path, record_type: str, record: dict[str, Any]) -> Path: # Underscore-prefixed keys are internal, in-memory render inputs (e.g. a memory's # _link_names / _backlinks used to build the note's [[wikilinks]]); they must never enter - # the durable JSON source-of-truth (a rebuild reads these files back). The Markdown mirror - # still sees the full record because _write_memory_markdown runs on the original dict. + # durable JSON system representation. The Markdown memory representation still sees the + # full record because _write_memory_markdown runs on the original dict. payload = { "vault_record_type": record_type, "vault_record_version": VAULT_VERSION, @@ -1606,6 +1708,10 @@ def _write_record(self, path: Path, record_type: str, record: dict[str, Any]) -> return self._write_json(path, payload) def _patch_first(self, record_dir: str, record_id: str, updates: dict[str, Any]) -> bool: + with self._lock: + return self._patch_first_locked(record_dir, record_id, updates) + + def _patch_first_locked(self, record_dir: str, record_id: str, updates: dict[str, Any]) -> bool: self.ensure() base = self.root / record_dir for path in base.rglob(f"{safe_segment(record_id)}.json"): @@ -1626,6 +1732,10 @@ def _patch_first(self, record_dir: str, record_id: str, updates: dict[str, Any]) return False def _delete_first(self, record_dir: str, record_id: str) -> bool: + with self._lock: + return self._delete_first_locked(record_dir, record_id) + + def _delete_first_locked(self, record_dir: str, record_id: str) -> bool: self.ensure() base = self.root / record_dir for path in base.rglob(f"{safe_segment(record_id)}.json"): @@ -1645,6 +1755,10 @@ def _delete_first(self, record_dir: str, record_id: str) -> bool: return False def _delete_matching_records(self, record_dir: str, predicate) -> tuple[int, list[str]]: + with self._lock: + return self._delete_matching_records_locked(record_dir, predicate) + + def _delete_matching_records_locked(self, record_dir: str, predicate) -> tuple[int, list[str]]: self.ensure() base = self.root / record_dir if not base.exists(): @@ -1724,25 +1838,29 @@ def _count_json_records(self, directory: str) -> int: return len(list(base.rglob("*.json"))) if base.exists() else 0 def _write_json(self, path: Path, payload: dict[str, Any]) -> Path: - path.parent.mkdir(parents=True, exist_ok=True) - # Temp name must be unique per writer: the shipping server is multi-threaded (one PID), - # so a pid-only temp path lets two concurrent writes to the same target collide — one - # thread's os.replace moves the shared temp out from under the other, raising - # FileNotFoundError or installing a half-written file. Thread id + randomness makes each - # write private; the temp is unlinked on failure so a crash mid-write leaves no litter. - tmp_path = path.with_name(f".{path.name}.{os.getpid()}.{threading.get_ident()}.{os.urandom(4).hex()}.tmp") - try: - with tmp_path.open("w", encoding="utf-8") as handle: - json.dump(payload, handle, ensure_ascii=False, indent=2, sort_keys=True) - handle.write("\n") - os.replace(tmp_path, path) - except OSError: + # Every durable JSON mutation participates in the same process/file + # lock used by backups, deletion and restore. This is intentionally at + # the primitive boundary so new record types cannot bypass the freeze. + with self._lock: + path.parent.mkdir(parents=True, exist_ok=True) + # Temp name must be unique per writer: the shipping server is multi-threaded (one PID), + # so a pid-only temp path lets two concurrent writes to the same target collide — one + # thread's os.replace moves the shared temp out from under the other, raising + # FileNotFoundError or installing a half-written file. Thread id + randomness makes each + # write private; the temp is unlinked on failure so a crash mid-write leaves no litter. + tmp_path = path.with_name(f".{path.name}.{os.getpid()}.{threading.get_ident()}.{os.urandom(4).hex()}.tmp") try: - tmp_path.unlink() + with tmp_path.open("w", encoding="utf-8") as handle: + json.dump(payload, handle, ensure_ascii=False, indent=2, sort_keys=True) + handle.write("\n") + os.replace(tmp_path, path) except OSError: - pass - raise - return path + try: + tmp_path.unlink() + except OSError: + pass + raise + return path def _read_json(self, path: Path, default: Any) -> Any: try: diff --git a/backend/app/webauth.py b/backend/app/webauth.py index 4c167aeb..d8885002 100644 --- a/backend/app/webauth.py +++ b/backend/app/webauth.py @@ -295,8 +295,13 @@ def _page(title: str, body: str, script: str, *, head_extra: str = "") -> str: ) -def _html_response(document: str, *, csp: str = _ACCOUNT_CSP) -> HTMLResponse: - response = HTMLResponse(content=document) +def _html_response( + document: str, + *, + csp: str = _ACCOUNT_CSP, + status_code: int = 200, +) -> HTMLResponse: + response = HTMLResponse(content=document, status_code=status_code) response.headers["Content-Security-Policy"] = csp response.headers["Referrer-Policy"] = "same-origin" return response @@ -357,13 +362,18 @@ def render_public_page(title: str, body_html: str) -> str: # page's provider buttons, which are rendered from GET /v1/auth/providers and # escaped below. -def _login_body(provider_buttons_html: str) -> str: +def _login_body(provider_buttons_html: str, *, signup_enabled: bool = True) -> str: divider = ( '
or
\n' f'
{provider_buttons_html}
\n' if provider_buttons_html else "" ) + signup_link = ( + ' \n' + if signup_enabled + else ' \n' + ) return ( ' class="login">\n' '
\n' @@ -381,13 +391,13 @@ def _login_body(provider_buttons_html: str) -> str: " \n" f"{divider}" ' \n' - ' \n' + f"{signup_link}" "
\n" "
" ) -def _signup_body(turnstile_site_key: str = "") -> str: +def _signup_body(turnstile_site_key: str = "", provider_buttons: str = "") -> str: """Signup page body. When ``turnstile_site_key`` is set, embeds the Cloudflare Turnstile widget div (the widget writes its token into a ``cf-turnstile-response`` field the JS reads and forwards to the API); @@ -398,6 +408,11 @@ def _signup_body(turnstile_site_key: str = "") -> str: turnstile_widget = ( f'
\n' ) + provider_section = ( + '
or
\n' + provider_buttons + if provider_buttons + else "" + ) return ( ' class="signup">\n' '
\n' @@ -421,6 +436,7 @@ def _signup_body(turnstile_site_key: str = "") -> str: "
\n" '
\n' " \n" + f"{provider_section}" ' \n' "
\n" " " @@ -521,7 +537,7 @@ def _signup_body(turnstile_site_key: str = "") -> str: " \n" '
\n' "

Delete account

\n" - '

This permanently crypto-shreds your keys and deletes your data. Type ' + '

This destroys your connector-credential encryption keys and deletes your hosted data. Type ' "DELETE to confirm.

\n" ' \n' ' \n' @@ -535,14 +551,25 @@ def _signup_body(turnstile_site_key: str = "") -> str: ) -def _provider_buttons_html(providers: list[dict[str, Any]], app_flow: str = "") -> str: +def _provider_buttons_html( + providers: list[dict[str, Any]], + app_flow: str = "", + *, + signup: bool = False, +) -> str: """Render a 'Continue with X' button per configured provider. Only the provider name/display_name are interpolated, both escaped. When an app-login flow id is present (the desktop 'Continue with ' handoff opened /account/login?app_flow=...), it is threaded into each OAuth start URL so the callback can complete that flow server-side and the app polls the tokens out. ``app_flow`` is pre-sanitized by the caller to [A-Za-z0-9_] so it is URL-safe.""" - suffix = f"?app_flow={app_flow}" if app_flow else "" + query = [] + if app_flow: + query.append(f"app_flow={app_flow}") + if signup: + query.append("signup=1") + suffix = f"?{'&'.join(query)}" if query else "" + css_class = "button secondary oauth-signup" if signup else "button secondary" parts: list[str] = [] for row in providers: name = str(row.get("provider") or "") @@ -552,7 +579,7 @@ def _provider_buttons_html(providers: list[dict[str, Any]], app_flow: str = "") safe_name = html.escape(name, quote=True) safe_display = html.escape(display) parts.append( - f'' + f'' f"Continue with {safe_display}" ) return "".join(parts) @@ -635,6 +662,19 @@ def _provider_buttons_html(providers: list[dict[str, Any]], app_flow: str = "") var form = document.getElementById('signup-form'); if (!form) return; var status = document.getElementById('status'); + Array.prototype.forEach.call(document.querySelectorAll('.oauth-signup'), function (link) { + link.addEventListener('click', function (ev) { + ev.preventDefault(); + if (!document.getElementById('tos').checked) { + setStatus(status, 'You must agree to the Terms to continue.', 'error'); + return; + } + var target = new URL(link.href, window.location.origin); + target.searchParams.set('terms_accepted', 'true'); + target.searchParams.set('age_confirmed', 'true'); + window.location.href = target.toString(); + }); + }); form.addEventListener('submit', function (ev) { ev.preventDefault(); var email = document.getElementById('email').value; @@ -651,7 +691,12 @@ def _provider_buttons_html(providers: list[dict[str, Any]], app_flow: str = "") // widget writes its token into a hidden 'cf-turnstile-response' field. // Forward it so the server can verify before doing any argon2 work. When // Turnstile is off there is no such field and this is a no-op. - var body = { email: email, password: password }; + var body = { + email: email, + password: password, + terms_accepted: true, + age_confirmed: true + }; var tsField = form.querySelector('[name="cf-turnstile-response"]'); if (tsField) { if (!tsField.value) { @@ -962,6 +1007,7 @@ def register_web_account_routes( list_providers: Callable[[], list[dict[str, Any]]], turnstile_site_key: Callable[[], str] | None = None, app_flow_cookie: Callable[[str], str] | None = None, + signup_enabled: Callable[[], bool] | None = None, ) -> None: """Register the /account* browser pages on ``app``. @@ -987,6 +1033,14 @@ def _turnstile_key() -> str: except Exception: return "" + def _signup_enabled() -> bool: + if signup_enabled is None: + return True + try: + return bool(signup_enabled()) + except Exception: + return False + @app.get("/account", response_class=HTMLResponse) @app.get("/account/login", response_class=HTMLResponse) def account_login(request: Request) -> Response: @@ -1019,7 +1073,13 @@ def account_login(request: Request) -> Response: ) return redirect buttons = _provider_buttons_html(providers, app_flow=app_flow) - resp = _html_response(_page("Sign in · Doppl", _login_body(buttons), "app.js")) + resp = _html_response( + _page( + "Sign in · Doppl", + _login_body(buttons, signup_enabled=_signup_enabled()), + "app.js", + ) + ) # Bind THIS browser to the desktop app-login poll flow: set a signed cookie that the OAuth # start requires before it will attach an authenticated account to app_flow (main.py # _app_flow_cookie / auth_oauth_start). Prevents a login-CSRF / flow-fixation takeover where a @@ -1034,18 +1094,45 @@ def account_login(request: Request) -> Response: @app.get("/account/signup", response_class=HTMLResponse) def account_signup() -> Response: - runtime_or_404() + runtime = runtime_or_404() + if not _signup_enabled(): + return _html_response( + _page( + "Account creation unavailable · Doppl", + ' class="signup">\n' + '
\n' + "

Account creation is unavailable

\n" + "

The service operator has not yet published approved Terms and " + "Privacy text. Existing users can still sign in.

\n" + ' \n' + "
\n" + " ", + "app.js", + ), + status_code=503, + ) + try: + providers = runtime.oidc.enabled_providers() + except Exception: + providers = [] + provider_buttons = _provider_buttons_html(providers, signup=True) site_key = _turnstile_key() if site_key: head_extra = f' \n' document = _page( "Create your account · Doppl", - _signup_body(site_key), + _signup_body(site_key, provider_buttons), "app.js", head_extra=head_extra, ) return _html_response(document, csp=_SIGNUP_CSP_TURNSTILE) - return _html_response(_page("Create your account · Doppl", _signup_body(), "app.js")) + return _html_response( + _page( + "Create your account · Doppl", + _signup_body(provider_buttons=provider_buttons), + "app.js", + ) + ) @app.get("/account/verify", response_class=HTMLResponse) def account_verify() -> Response: diff --git a/backend/tests/test_auth_http.py b/backend/tests/test_auth_http.py index 5e5ab56a..a1e543e0 100644 --- a/backend/tests/test_auth_http.py +++ b/backend/tests/test_auth_http.py @@ -10,7 +10,7 @@ - enumeration-resistant response shapes - cxs_ works on data routes but is REJECTED by /v1/admin/* and /mcp - activation provisions the user (registry row + usable shard, NO auto tokens) -- OAuth callback journeys against a faked transport: auto-signup, login, and +- OAuth callback journeys against a faked transport: consented signup, login, and the never-silent-auto-link link_required challenge - app start/poll handoff: poll_secret single-use, no token in any URL - self-serve cxa_ mint (account_id linkage) that then authenticates @@ -85,6 +85,7 @@ def setUp(self) -> None: shard_mode=self.shard_mode, default_user_id="hosted-default", require_scoped_api_tokens=True, + legal_terms_approved=True, auth_enabled=True, accounts_db_path=None, auth_email_mode="log", @@ -126,7 +127,13 @@ def _last_flow_token(self, kind: str) -> str: def _signup_and_verify(self, email: str = "user@example.com") -> dict[str, Any]: response = self.client.post( - "/v1/auth/signup", json={"email": email, "password": PASSWORD} + "/v1/auth/signup", + json={ + "email": email, + "password": PASSWORD, + "terms_accepted": True, + "age_confirmed": True, + }, ) self.assertEqual(response.status_code, 200, response.text) self.assertEqual(response.json(), {"ok": True, "next": "verify_email"}) @@ -144,6 +151,35 @@ def _login(self, email: str = "user@example.com", password: str = PASSWORD) -> d self.assertEqual(response.status_code, 200, response.text) return response.json() + def test_hosted_signup_requires_explicit_legal_and_age_consent(self) -> None: + response = self.client.post( + "/v1/auth/signup", + json={"email": "no-consent@example.com", "password": PASSWORD}, + ) + self.assertEqual(response.status_code, 422, response.text) + self.assertIsNone( + self.runtime.control_store.get_account_by_email("no-consent@example.com") + ) + + def test_hosted_signup_is_disabled_until_legal_text_is_approved(self) -> None: + original = main_module.settings + main_module.settings = replace(original, legal_terms_approved=False) + try: + page = self.client.get("/account/signup") + self.assertEqual(page.status_code, 503) + response = self.client.post( + "/v1/auth/signup", + json={ + "email": "legal-block@example.com", + "password": PASSWORD, + "terms_accepted": True, + "age_confirmed": True, + }, + ) + self.assertEqual(response.status_code, 503, response.text) + finally: + main_module.settings = original + def _bearer(self, token: str) -> dict[str, str]: return {"Authorization": f"Bearer {token}"} @@ -190,8 +226,21 @@ def _google_claims(self, nonce: str, **overrides: Any) -> dict[str, Any]: claims.update(overrides) return claims - def _oauth_start(self, app_flow: str | None = None) -> tuple[str, str]: + def _oauth_start( + self, + app_flow: str | None = None, + *, + signup: bool = False, + ) -> tuple[str, str]: params = {"app_flow": app_flow} if app_flow else {} + if signup: + params.update( + { + "signup": "true", + "terms_accepted": "true", + "age_confirmed": "true", + } + ) started = self.client.get("/v1/auth/oauth/google/start", params=params) self.assertEqual(started.status_code, 200, started.text) state = started.json()["state"] @@ -262,7 +311,13 @@ def test_autoverify_beta_mode_activates_at_signup(self) -> None: main_module.settings = replace(main_module.settings, auth_autoverify=True) try: response = self.client.post( - "/v1/auth/signup", json={"email": "beta@example.com", "password": PASSWORD} + "/v1/auth/signup", + json={ + "email": "beta@example.com", + "password": PASSWORD, + "terms_accepted": True, + "age_confirmed": True, + }, ) self.assertEqual(response.status_code, 200, response.text) # Identical generic shape to the non-autoverify path — no enumeration signal. @@ -287,7 +342,13 @@ def test_autoverify_off_by_default_keeps_pending(self) -> None: # Default (public) profile: signup stays pending_verification; the safety default. self.assertFalse(main_module.settings.auth_autoverify) response = self.client.post( - "/v1/auth/signup", json={"email": "pending@example.com", "password": PASSWORD} + "/v1/auth/signup", + json={ + "email": "pending@example.com", + "password": PASSWORD, + "terms_accepted": True, + "age_confirmed": True, + }, ) self.assertEqual(response.status_code, 200, response.text) account = self.runtime.control_store.get_account_by_email("pending@example.com") @@ -295,10 +356,22 @@ def test_autoverify_off_by_default_keeps_pending(self) -> None: def test_enumeration_resistant_shapes(self) -> None: first = self.client.post( - "/v1/auth/signup", json={"email": "dupe@example.com", "password": PASSWORD} + "/v1/auth/signup", + json={ + "email": "dupe@example.com", + "password": PASSWORD, + "terms_accepted": True, + "age_confirmed": True, + }, ) second = self.client.post( - "/v1/auth/signup", json={"email": "dupe@example.com", "password": PASSWORD} + "/v1/auth/signup", + json={ + "email": "dupe@example.com", + "password": PASSWORD, + "terms_accepted": True, + "age_confirmed": True, + }, ) self.assertEqual(first.status_code, second.status_code) self.assertEqual(first.json(), second.json()) @@ -390,7 +463,26 @@ def test_github_oauth_start_uses_public_app_callback(self) -> None: ["http://127.0.0.1:8766/v1/auth/oauth/github/callback"], ) - def test_auto_signup_then_login_via_google(self) -> None: + def test_login_oauth_cannot_silently_create_an_unknown_account(self) -> None: + fake = self._install_fake_google() + state, nonce = self._oauth_start() + fake["claims"] = self._google_claims( + nonce, + sub="google-no-consent", + email="no-oauth-consent@example.com", + ) + callback = self.client.get( + "/v1/auth/oauth/google/callback", + params={"code": "auth-code", "state": state}, + ) + self.assertEqual(callback.status_code, 409, callback.text) + self.assertIsNone( + self.runtime.control_store.get_account_by_email( + "no-oauth-consent@example.com" + ) + ) + + def test_consented_signup_then_login_via_google(self) -> None: fake = self._install_fake_google() providers = self.client.get("/v1/auth/providers") self.assertEqual(providers.status_code, 200) @@ -398,8 +490,8 @@ def test_auto_signup_then_login_via_google(self) -> None: self.assertIn("google", names) self.assertNotIn("openai", names) - # Unknown identity with a provider-verified email: auto-signup, ACTIVE. - state, nonce = self._oauth_start() + # Unknown identity with a provider-verified email and bound consent: signup, ACTIVE. + state, nonce = self._oauth_start(signup=True) fake["claims"] = self._google_claims(nonce) callback = self.client.get( "/v1/auth/oauth/google/callback", params={"code": "auth-code", "state": state} @@ -496,7 +588,7 @@ def test_app_start_poll_handoff(self) -> None: # value directly to simulate the browser holding it. from backend.app import main as _main self.client.cookies.set("df_af", _main._app_flow_cookie(flow_id)) - state, nonce = self._oauth_start(app_flow=flow_id) + state, nonce = self._oauth_start(app_flow=flow_id, signup=True) authorize_url = self.runtime.control_store.get_flow(state) assert authorize_url is not None fake["claims"] = self._google_claims(nonce, sub="google-sub-app") @@ -729,6 +821,9 @@ def test_backfill_ensures_deks_and_migrates_plaintext(self) -> None: vault.cipher = cipher raw = json.loads(vault.credentials_path.read_text(encoding="utf-8")) self.assertIn("payload", raw["users"][user_id]["legacy-acct"]) + before = main_module._credential_encryption_evidence() + self.assertEqual(before["remaining_plaintext"], 1) + self.assertTrue(before["scan_complete"]) response = self.client.post("/v1/admin/encryption/backfill", headers=ADMIN) self.assertEqual(response.status_code, 200, response.text) @@ -741,9 +836,51 @@ def test_backfill_ensures_deks_and_migrates_plaintext(self) -> None: record = raw_after["users"][user_id]["legacy-acct"] self.assertIn("payload_cxe1", record) self.assertNotIn("payload", record) + after = main_module._credential_encryption_evidence() + self.assertEqual(after["remaining_plaintext"], 0) + self.assertTrue(after["scan_complete"]) read_back = vault.read_source_credential(user_id=user_id, source_account_id="legacy-acct") self.assertEqual(read_back["payload"]["token"], "legacy-plaintext") + def test_readiness_counts_plaintext_even_beside_an_envelope(self) -> None: + account = self._signup_and_verify(email="partial-migration@example.com") + user_id = account["user_id"] + vault = main_module.store.store_for_user(user_id).vault + cipher = vault.cipher + vault.cipher = None + try: + vault.write_source_credential( + user_id=user_id, + source_account_id="partial-acct", + source="notion", + payload={"token": "still-plaintext"}, + ) + finally: + vault.cipher = cipher + raw = json.loads(vault.credentials_path.read_text(encoding="utf-8")) + raw["users"][user_id]["partial-acct"]["payload_cxe1"] = "00" + vault.credentials_path.write_text(json.dumps(raw), encoding="utf-8") + + evidence = main_module._credential_encryption_evidence() + + self.assertEqual(evidence["remaining_plaintext"], 1) + self.assertGreaterEqual(evidence["invalid_records"], 1) + + def test_readiness_evidence_fails_closed_when_scan_is_truncated(self) -> None: + account = self._signup_and_verify(email="scan-limit@example.com") + vault = main_module.store.store_for_user(account["user_id"]).vault + vault.write_source_credential( + user_id=account["user_id"], + source_account_id="encrypted-acct", + source="notion", + payload={"token": "encrypted"}, + ) + + evidence = main_module._credential_encryption_evidence(max_files=0) + + self.assertFalse(evidence["scan_complete"]) + self.assertEqual(evidence["files_scanned"], 0) + class AuthDisabledTests(unittest.TestCase): """Default boot (no auth env): the auth surface must be absent and the diff --git a/backend/tests/test_authn_sessions.py b/backend/tests/test_authn_sessions.py index e84590d8..33c4e91c 100644 --- a/backend/tests/test_authn_sessions.py +++ b/backend/tests/test_authn_sessions.py @@ -11,11 +11,14 @@ import tempfile import threading +import time import unittest from datetime import datetime, timedelta, timezone from pathlib import Path +from unittest.mock import patch from backend.app.accounts import SQLiteControlStore +from backend.app import authn as authn_module from backend.app.authn import ( ACCESS_TOKEN_PREFIX, GENERIC_AUTH_FAILURE, @@ -129,6 +132,48 @@ def test_dummy_verify_always_false(self) -> None: self.assertFalse(engine.dummy_verify("anything")) self.assertFalse(engine.dummy_verify("anything")) # cached dummy path + def test_password_work_is_bounded_under_concurrency(self) -> None: + state_lock = threading.Lock() + active = 0 + max_active = 0 + + class SlowHasher: + def hash(self, _password: str) -> str: + nonlocal active, max_active + with state_lock: + active += 1 + max_active = max(max_active, active) + try: + time.sleep(0.03) + return "$argon2id$test" + finally: + with state_lock: + active -= 1 + + engine = PasswordEngine(**CHEAP_SCRYPT) + engine._hasher = SlowHasher() + errors: list[BaseException] = [] + + def worker() -> None: + try: + engine.hash("bounded-password") + except BaseException as exc: # noqa: BLE001 - assert all worker failures + errors.append(exc) + + with patch.object( + authn_module, + "_PASSWORD_WORK_SLOTS", + threading.BoundedSemaphore(2), + ): + threads = [threading.Thread(target=worker) for _ in range(8)] + for thread in threads: + thread.start() + for thread in threads: + thread.join(timeout=2) + + self.assertEqual(errors, []) + self.assertEqual(max_active, 2) + # --------------------------------------------------------------------------- # Token discipline diff --git a/backend/tests/test_billing.py b/backend/tests/test_billing.py index 56482d24..56fcac3b 100644 --- a/backend/tests/test_billing.py +++ b/backend/tests/test_billing.py @@ -235,6 +235,7 @@ def setUp(self) -> None: shard_mode="user", default_user_id="hosted-default", require_scoped_api_tokens=True, + legal_terms_approved=True, auth_enabled=True, accounts_db_path=None, auth_email_mode="log", @@ -275,7 +276,15 @@ def _last_flow_token(self, kind: str) -> str: raise AssertionError(f"no {kind} delivery found") def _signup_login(self, email: str = "buyer@example.com") -> dict[str, Any]: - r = self.client.post("/v1/auth/signup", json={"email": email, "password": PASSWORD}) + r = self.client.post( + "/v1/auth/signup", + json={ + "email": email, + "password": PASSWORD, + "terms_accepted": True, + "age_confirmed": True, + }, + ) self.assertEqual(r.status_code, 200, r.text) v = self.client.post("/v1/auth/verify-email", json={"token": self._last_flow_token("email_verify")}) self.assertEqual(v.status_code, 200, v.text) @@ -370,6 +379,7 @@ def setUp(self) -> None: shard_mode="user", default_user_id="hosted-default", require_scoped_api_tokens=True, + legal_terms_approved=True, auth_enabled=True, accounts_db_path=None, auth_email_mode="log", @@ -412,7 +422,15 @@ def test_webhook_404_when_unconfigured(self) -> None: def test_account_plan_reports_billing_disabled(self) -> None: email = "nb@example.com" - r = self.client.post("/v1/auth/signup", json={"email": email, "password": PASSWORD}) + r = self.client.post( + "/v1/auth/signup", + json={ + "email": email, + "password": PASSWORD, + "terms_accepted": True, + "age_confirmed": True, + }, + ) self.assertEqual(r.status_code, 200, r.text) token = None for item in reversed(self.runtime.outbox): diff --git a/backend/tests/test_billing_stripe.py b/backend/tests/test_billing_stripe.py index 93386484..3e932a5d 100644 --- a/backend/tests/test_billing_stripe.py +++ b/backend/tests/test_billing_stripe.py @@ -291,6 +291,7 @@ def setUp(self) -> None: shard_mode="user", default_user_id="hosted-default", require_scoped_api_tokens=True, + legal_terms_approved=True, auth_enabled=True, accounts_db_path=None, auth_email_mode="log", @@ -329,7 +330,15 @@ def _last_flow_token(self, kind: str) -> str: raise AssertionError(f"no {kind} delivery found") def _signup_login(self, email: str = "buyer@example.com") -> dict[str, Any]: - r = self.client.post("/v1/auth/signup", json={"email": email, "password": PASSWORD}) + r = self.client.post( + "/v1/auth/signup", + json={ + "email": email, + "password": PASSWORD, + "terms_accepted": True, + "age_confirmed": True, + }, + ) self.assertEqual(r.status_code, 200, r.text) v = self.client.post("/v1/auth/verify-email", json={"token": self._last_flow_token("email_verify")}) self.assertEqual(v.status_code, 200, v.text) diff --git a/backend/tests/test_delivery.py b/backend/tests/test_delivery.py index 62d0be32..c18d0098 100644 --- a/backend/tests/test_delivery.py +++ b/backend/tests/test_delivery.py @@ -3,6 +3,7 @@ import tempfile import unittest from pathlib import Path +from unittest.mock import patch from backend.app.delivery import build_delivery_payload, deliver_webhook, is_safe_webhook_url @@ -14,9 +15,11 @@ def test_rejects_internal_and_insecure(self): "https://127.0.0.1/hook", "http://10.0.0.5/hook", "https://169.254.169.254/latest/meta-data", + "https://100.64.0.1/hook", # shared CGNAT space is not globally routable "http://8.8.8.8/hook", # public but not https "ftp://example.com/x", "https://user:pass@8.8.8.8/x", # credentials in URL + "https://8.8.8.8:not-a-port/x", "not a url", ): ok, _ = is_safe_webhook_url(url) @@ -41,6 +44,26 @@ def test_ssrf_blocked_never_calls_sender(self): self.assertFalse(result["ok"]) self.assertEqual(calls, []) + def test_sender_connects_to_the_address_that_passed_ssrf_validation(self): + resolution = [ + (2, 1, 6, "", ("8.8.8.8", 443)), + ] + pinned_calls = [] + + def fake_post(parsed, address, body, headers, *, timeout): + pinned_calls.append((parsed.hostname, address, timeout)) + return 204 + + with ( + patch("backend.app.delivery.socket.getaddrinfo", return_value=resolution) as resolver, + patch("backend.app.delivery._post_to_pinned_target", side_effect=fake_post), + ): + result = deliver_webhook("https://hooks.example.test/path", {"a": 1}) + + self.assertTrue(result["ok"]) + self.assertEqual(resolver.call_count, 1) + self.assertEqual(pinned_calls, [("hooks.example.test", "8.8.8.8", 15)]) + class DeliveryEvalGateTests(unittest.TestCase): def test_full_gate_passes(self): diff --git a/backend/tests/test_email_sender.py b/backend/tests/test_email_sender.py index d91d94c3..dec5df5f 100644 --- a/backend/tests/test_email_sender.py +++ b/backend/tests/test_email_sender.py @@ -271,7 +271,8 @@ def tearDown(self) -> None: def test_signup_calls_sender_once_with_verification_recipient(self) -> None: resp = self.client.post( - "/v1/auth/signup", json={"email": "new@example.com", "password": PASSWORD} + "/v1/auth/signup", + json={"email": "new@example.com", "password": PASSWORD, "terms_accepted": True, "age_confirmed": True}, ) self.assertEqual(resp.status_code, 200, resp.text) self.assertEqual(len(self.recording.calls), 1) @@ -285,7 +286,8 @@ def test_signup_calls_sender_once_with_verification_recipient(self) -> None: def test_send_failure_does_not_break_signup_and_keeps_outbox(self) -> None: self.recording.fail = True resp = self.client.post( - "/v1/auth/signup", json={"email": "flaky@example.com", "password": PASSWORD} + "/v1/auth/signup", + json={"email": "flaky@example.com", "password": PASSWORD, "terms_accepted": True, "age_confirmed": True}, ) self.assertEqual(resp.status_code, 200, resp.text) self.assertEqual(len(self.recording.calls), 1) @@ -311,7 +313,8 @@ def test_misconfigured_smtp_degrades_to_log(self) -> None: # The sender must be a LogEmailSender, not an SmtpEmailSender. self.assertIsInstance(self.runtime.email_sender, LogEmailSender) resp = self.client.post( - "/v1/auth/signup", json={"email": "beta@example.com", "password": PASSWORD} + "/v1/auth/signup", + json={"email": "beta@example.com", "password": PASSWORD, "terms_accepted": True, "age_confirmed": True}, ) self.assertEqual(resp.status_code, 200, resp.text) # outbox has the canonical token record — nothing lost. diff --git a/backend/tests/test_embeddings.py b/backend/tests/test_embeddings.py index 72ec45fc..4ec8ae01 100644 --- a/backend/tests/test_embeddings.py +++ b/backend/tests/test_embeddings.py @@ -54,7 +54,7 @@ def fake_urlopen(request, timeout): "OPENAI_API_KEY": "sk-test", }, ): - with patch("backend.app.embeddings.urllib.request.urlopen", side_effect=fake_urlopen): + with patch("backend.app.embeddings.open_same_origin", side_effect=fake_urlopen): result = embed_text_result("Remember the search backend decision.") self.assertEqual(result.provider, "openai") @@ -82,7 +82,7 @@ def test_openai_provider_falls_back_to_hash_when_not_strict(self) -> None: "OPENAI_API_KEY": "sk-test", }, ): - with patch("backend.app.embeddings.urllib.request.urlopen", side_effect=TimeoutError("slow")): + with patch("backend.app.embeddings.open_same_origin", side_effect=TimeoutError("slow")): result = embed_text_result("Fallback should keep local search usable.") self.assertEqual(result.provider, "hash") @@ -100,7 +100,7 @@ def test_openai_provider_raises_when_strict(self) -> None: "OPENAI_API_KEY": "sk-test", }, ): - with patch("backend.app.embeddings.urllib.request.urlopen", side_effect=TimeoutError("slow")): + with patch("backend.app.embeddings.open_same_origin", side_effect=TimeoutError("slow")): with self.assertRaises(TimeoutError): embed_text_result("Strict mode should expose provider failures.") diff --git a/backend/tests/test_fastapi_contract.py b/backend/tests/test_fastapi_contract.py index 0ec72849..dc4510d2 100644 --- a/backend/tests/test_fastapi_contract.py +++ b/backend/tests/test_fastapi_contract.py @@ -17,6 +17,7 @@ from fastapi.testclient import TestClient from backend.app import main as main_module +from backend.app.keyring import LocalKekProvider, UserKeyring from backend.app.provenance import sign_shared_write from backend.tests.test_decision_history import CURRENT_DECISION_ID, CURRENT_SOURCE_URL, seed_decision_history_fixture @@ -82,6 +83,62 @@ def _assert_connector_ask_citation( self.assertIn("line=", citation["source_url"]) self.assertIn("excerpt=", citation["source_url"]) + def test_openapi_schema_generates_with_unique_operation_ids(self) -> None: + schema = app.openapi() + self.assertEqual(schema["openapi"], "3.1.0") + self.assertIn("/v1/context", schema["paths"]) + self.assertIn("/oauth/broker/exchange", schema["paths"]) + + operation_ids = [ + operation["operationId"] + for path_item in schema["paths"].values() + for operation in path_item.values() + if isinstance(operation, dict) and "operationId" in operation + ] + self.assertTrue(operation_ids) + self.assertEqual(len(operation_ids), len(set(operation_ids))) + self.assertEqual( + schema["paths"]["/v1/context"]["post"]["operationId"], + "post_v1_context", + ) + bearer = schema["components"]["securitySchemes"]["BearerAuth"] + self.assertEqual(bearer["type"], "http") + self.assertEqual(bearer["scheme"], "bearer") + self.assertIn( + {"BearerAuth": []}, + schema["paths"]["/v1/context"]["post"]["security"], + ) + request_schema = schema["paths"]["/v1/context"]["post"]["requestBody"][ + "content" + ]["application/json"]["schema"] + self.assertEqual( + request_schema["$ref"], + "#/components/schemas/ContextRequest", + ) + + def test_context_post_rejects_unbounded_or_invalid_request_data(self) -> None: + headers = {"Authorization": "Bearer test-token"} + too_large = self.client.post( + "/v1/context", + json={"task": "x" * 501}, + headers=headers, + ) + self.assertEqual(too_large.status_code, 422) + + invalid_budget = self.client.post( + "/v1/context", + json={"task": "bounded", "token_budget": 100_001}, + headers=headers, + ) + self.assertEqual(invalid_budget.status_code, 422) + + invalid_format = self.client.post( + "/v1/context", + json={"task": "bounded", "format": "xml"}, + headers=headers, + ) + self.assertEqual(invalid_format.status_code, 422) + def test_capture_get_invalid_token_returns_unauthorized_page(self) -> None: response = self.client.get("/capture", params={"token": "wrong-token", "content": "Remember this."}) @@ -188,6 +245,21 @@ def test_health_exposes_sharding_contract(self) -> None: self.assertEqual(hosted_readiness["global_token_user_switching"], "allowed_local_compatibility") self.assertEqual(hosted_readiness["checks"][0]["name"], "scoped_api_tokens_required") + def test_hosted_health_defers_tenant_wide_readiness_scan(self) -> None: + original_settings = main_module.settings + main_module.settings = replace(original_settings, shard_mode="bucket") + try: + with patch( + "backend.app.main._cached_credential_encryption_evidence", + side_effect=AssertionError("liveness must not scan tenant credentials"), + ): + response = self.client.get("/health") + finally: + main_module.settings = original_settings + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json()["hosted_readiness"]["status"], "deferred") + def test_ready_requires_scoped_api_tokens_for_hosted_shard_modes(self) -> None: original_settings = main_module.settings main_module.settings = replace(original_settings, shard_mode="bucket", require_scoped_api_tokens=False) @@ -413,6 +485,8 @@ def test_hosted_ready_requires_runtime_scoped_token_control_plane(self) -> None: default_user_id="hosted-default", shard_mode="bucket", require_scoped_api_tokens=True, + require_encrypted_credentials=True, + legal_terms_approved=True, public_base_url="https://api.cortex-hq.com", sync_signing_key="sync-signing-key", hosted_database_url="postgresql://cortex:secret@db.cortex.internal/cortex", @@ -426,6 +500,14 @@ def test_hosted_ready_requires_runtime_scoped_token_control_plane(self) -> None: ) main_module.settings = hosted_settings main_module.store = main_module.StoreRegistry.from_settings(hosted_settings) + main_module.store.keyring = UserKeyring( + root / "keyring.sqlite", + LocalKekProvider( + env={ + "CORTEX_KEK": base64.b64encode(bytes(range(32))).decode("ascii"), + } + ), + ) user = "hosted-ready-contract" response = self.client.get("/ready") @@ -1044,8 +1126,23 @@ def test_context_engine_endpoint_and_mcp_tool(self) -> None: headers={"Authorization": f"Bearer {read_token}", "X-Cortex-User": user}, ) self.assertEqual(scoped.status_code, 200) - scoped_identity = next(layer for layer in scoped.json()["layers"] if layer["layer"] == "identity") + scoped_payload = scoped.json() + scoped_identity = next(layer for layer in scoped_payload["layers"] if layer["layer"] == "identity") self.assertIsNone(scoped_identity.get("omitted")) + scoped_post = self.client.post( + "/v1/context", + json={"task": "Atlas database decision"}, + headers={"Authorization": f"Bearer {read_token}", "X-Cortex-User": user}, + ) + self.assertEqual(scoped_post.status_code, 200) + scoped_post_payload = scoped_post.json() + scoped_post_identity = next( + layer for layer in scoped_post_payload["layers"] if layer["layer"] == "identity" + ) + self.assertIsNone(scoped_post_identity.get("omitted")) + scoped_payload.pop("generated_at", None) + scoped_post_payload.pop("generated_at", None) + self.assertEqual(scoped_payload, scoped_post_payload) # The MCP core tool round-trips through /mcp with the same engine. mcp_token = "cxm-context-tool-token" @@ -2031,6 +2128,32 @@ def test_scoped_api_token_prevents_user_header_impersonation_when_required(self) ) self.assertEqual(scoped.status_code, 200) + tool_headers = { + "Authorization": f"Bearer {scoped_token}", + "X-Cortex-User": "alice", + } + tool_schema = self.client.get( + "/v1/tools/schema", + headers=tool_headers, + ) + self.assertEqual(tool_schema.status_code, 200, tool_schema.text) + self.assertTrue(tool_schema.json()["schema"]) + tool_call = self.client.post( + "/v1/tools/call", + json={"name": "search_memory", "arguments": {"query": "nothing"}}, + headers=tool_headers, + ) + self.assertEqual(tool_call.status_code, 200, tool_call.text) + self.assertEqual(tool_call.json()["tool"], "search_memory") + + # The REST token remains invalid on the MCP transport. + mcp_rejected = self.client.post( + "/mcp", + json={"jsonrpc": "2.0", "id": 1, "method": "tools/list"}, + headers=tool_headers, + ) + self.assertEqual(mcp_rejected.status_code, 401) + # A read/write token still cannot pull the raw bulk dump (distilled reads are allowed). export_blocked = self.client.get( "/v1/export.json", diff --git a/backend/tests/test_hosted_readiness.py b/backend/tests/test_hosted_readiness.py index 243505ba..21fecfc6 100644 --- a/backend/tests/test_hosted_readiness.py +++ b/backend/tests/test_hosted_readiness.py @@ -21,6 +21,8 @@ def ready_hosted_settings(self, **overrides) -> Settings: values = { "shard_mode": "bucket", "require_scoped_api_tokens": True, + "require_encrypted_credentials": True, + "legal_terms_approved": True, "public_base_url": "https://api.cortex-hq.com", "sync_signing_key": "sync-signing-key", "hosted_database_url": "postgresql://cortex:secret@db.cortex.internal/cortex", @@ -37,6 +39,14 @@ def ready_hosted_settings(self, **overrides) -> Settings: def ready_runtime(self) -> dict: return { + "credential_encryption": { + "enforcement_enabled": True, + "keyring_available": True, + "remaining_plaintext": 0, + "scan_complete": True, + "unreadable_files": 0, + "invalid_records": 0, + }, "control_plane": { "active_api_tokens": 1, "active_mcp_tokens": 1, @@ -73,6 +83,7 @@ def test_hosted_mode_blocks_until_production_controls_are_configured(self) -> No self.assertEqual(contract["global_token_user_switching"], "blocked") blocked = {check["name"] for check in contract["checks"] if check["status"] == "blocked"} self.assertIn("scoped_api_tokens_required", blocked) + self.assertIn("credential_encryption", blocked) self.assertIn("public_base_url", blocked) self.assertIn("sync_signing_key", blocked) self.assertIn("embedding_provider", blocked) @@ -96,6 +107,49 @@ def test_hosted_mode_still_blocks_without_runtime_control_plane_evidence(self) - blocked = {check["name"] for check in contract["checks"] if check["status"] == "blocked"} self.assertEqual(blocked, {"control_plane_scoped_tokens"}) + def test_hosted_mode_blocks_when_credential_encryption_is_not_enforced(self) -> None: + contract = hosted_readiness_contract( + self.ready_hosted_settings(require_encrypted_credentials=False), + runtime=self.ready_runtime(), + ) + check = next( + item for item in contract["checks"] if item["name"] == "credential_encryption" + ) + self.assertEqual(check["status"], "blocked") + self.assertIn("CORTEX_REQUIRE_ENCRYPTED_CREDENTIALS=1", check["detail"]) + + def test_hosted_mode_blocks_when_keyring_is_unavailable(self) -> None: + runtime = self.ready_runtime() + runtime["credential_encryption"]["keyring_available"] = False + contract = hosted_readiness_contract(self.ready_hosted_settings(), runtime=runtime) + check = next( + item for item in contract["checks"] if item["name"] == "credential_encryption" + ) + self.assertEqual(check["status"], "blocked") + self.assertIn("KEK-backed runtime keyring", check["detail"]) + + def test_hosted_mode_blocks_until_plaintext_credential_scan_is_clean(self) -> None: + runtime = self.ready_runtime() + runtime["credential_encryption"]["remaining_plaintext"] = 3 + contract = hosted_readiness_contract(self.ready_hosted_settings(), runtime=runtime) + check = next( + item for item in contract["checks"] if item["name"] == "credential_encryption" + ) + self.assertEqual(check["status"], "blocked") + self.assertIn("3 remaining plaintext credential", check["detail"]) + + def test_hosted_mode_blocks_incomplete_or_unreadable_credential_scan(self) -> None: + runtime = self.ready_runtime() + runtime["credential_encryption"]["scan_complete"] = False + runtime["credential_encryption"]["unreadable_files"] = 1 + contract = hosted_readiness_contract(self.ready_hosted_settings(), runtime=runtime) + check = next( + item for item in contract["checks"] if item["name"] == "credential_encryption" + ) + self.assertEqual(check["status"], "blocked") + self.assertIn("non-truncated", check["detail"]) + self.assertIn("unreadable credential file", check["detail"]) + def test_hosted_mode_blocks_worker_queue_attention_state(self) -> None: runtime = self.ready_runtime() runtime["worker_queue"] = { @@ -213,6 +267,8 @@ def setUp(self) -> None: public_base_url="https://api.cortex-hq.com", shard_mode="bucket", require_scoped_api_tokens=True, + require_encrypted_credentials=True, + legal_terms_approved=True, sync_signing_key="sync-signing-key", worker_mode="external", observability_enabled=True, @@ -222,6 +278,14 @@ def setUp(self) -> None: def sqlite_runtime(self) -> dict: return { + "credential_encryption": { + "enforcement_enabled": True, + "keyring_available": True, + "remaining_plaintext": 0, + "scan_complete": True, + "unreadable_files": 0, + "invalid_records": 0, + }, "control_plane": { "active_api_tokens": 1, "active_mcp_tokens": 1, diff --git a/backend/tests/test_http_security.py b/backend/tests/test_http_security.py new file mode 100644 index 00000000..f56c2562 --- /dev/null +++ b/backend/tests/test_http_security.py @@ -0,0 +1,70 @@ +from __future__ import annotations + +from email.message import Message +from io import BytesIO +import unittest +from urllib.error import HTTPError +from urllib.request import Request + +from backend.app.http_security import SameOriginRedirectHandler, _origin + + +class SameOriginRedirectTests(unittest.TestCase): + def setUp(self) -> None: + self.handler = SameOriginRedirectHandler() + self.headers = Message() + self.fp = BytesIO() + + def redirect(self, source: str, target: str) -> Request | None: + request = Request( + source, + headers={"Authorization": "Bearer secret", "Accept": "application/json"}, + ) + return self.handler.redirect_request( + request, + self.fp, + 302, + "Found", + self.headers, + target, + ) + + def test_allows_same_origin_absolute_and_relative_redirects(self) -> None: + absolute = self.redirect( + "https://api.example.com/v1/items", + "https://api.example.com/v2/items", + ) + relative = self.redirect("https://api.example.com/v1/items", "/v2/items") + + self.assertEqual(absolute.full_url, "https://api.example.com/v2/items") + self.assertEqual(relative.full_url, "https://api.example.com/v2/items") + self.assertEqual(absolute.get_header("Authorization"), "Bearer secret") + + def test_treats_default_and_explicit_ports_as_the_same_origin(self) -> None: + redirected = self.redirect( + "https://api.example.com/v1/items", + "https://api.example.com:443/v2/items", + ) + self.assertEqual(redirected.full_url, "https://api.example.com:443/v2/items") + + def test_blocks_cross_host_port_and_scheme_redirects(self) -> None: + for target in ( + "https://attacker.example/steal", + "https://api.example.com:8443/steal", + "http://api.example.com/steal", + "https://user:pass@api.example.com/steal", + ): + with self.subTest(target=target), self.assertRaisesRegex( + HTTPError, + "cross-origin redirect blocked", + ): + self.redirect("https://api.example.com/v1/items", target) + + def test_rejects_malformed_or_credentialed_initial_origins(self) -> None: + self.assertIsNone(_origin("file:///tmp/private")) + self.assertIsNone(_origin("https://user:pass@example.com/data")) + self.assertIsNone(_origin("https://example.com:invalid/data")) + + +if __name__ == "__main__": + unittest.main() diff --git a/backend/tests/test_import_scale_bounds.py b/backend/tests/test_import_scale_bounds.py index b6ed7b9a..12954473 100644 --- a/backend/tests/test_import_scale_bounds.py +++ b/backend/tests/test_import_scale_bounds.py @@ -10,6 +10,7 @@ from __future__ import annotations import json +import io import tempfile import unittest import zipfile @@ -19,7 +20,9 @@ MAX_TEXT_BYTES, SourceAsset, _assets_from_zip, + _assets_from_open_zip, _format_csv_export, + _zip_declared_member_count, _parse_mbox, _parse_single_asset, _parse_twitter_archive, @@ -94,6 +97,65 @@ def test_zip_member_past_cap_is_truncated_not_skipped(self) -> None: self.assertFalse(tiny.read_truncated) self.assertEqual(tiny.read_bytes(), b"tiny note") + def test_zip_import_enforces_an_aggregate_uncompressed_byte_budget(self) -> None: + payload = io.BytesIO() + with zipfile.ZipFile(payload, "w") as archive: + archive.writestr("one.md", b"12345678") + archive.writestr("two.md", b"abcdefgh") + payload.seek(0) + + with zipfile.ZipFile(payload) as archive: + assets = _assets_from_open_zip( + archive, + "bounded.zip", + depth=0, + member_budget=[10], + byte_budget=[10], + ) + + self.assertEqual([asset.name for asset in assets], ["one.md", "two.md"]) + self.assertEqual(sum(len(asset.read_bytes()) for asset in assets), 10) + self.assertEqual(assets[0].read_bytes(), b"12345678") + self.assertEqual(assets[1].read_bytes(), b"ab") + self.assertTrue(assets[1].read_truncated) + + def test_zip_member_budget_counts_directories_and_hidden_entries(self) -> None: + payload = io.BytesIO() + with zipfile.ZipFile(payload, "w") as archive: + archive.writestr("one/", b"") + archive.writestr(".hidden", b"secret") + archive.writestr("visible.md", b"note") + payload.seek(0) + member_budget = [3] + + with zipfile.ZipFile(payload) as archive: + assets = _assets_from_open_zip( + archive, + "bounded.zip", + depth=0, + member_budget=member_budget, + byte_budget=[100], + ) + + self.assertEqual(member_budget, [0]) + self.assertEqual([asset.name for asset in assets], ["visible.md"]) + + def test_zip_with_excessive_declared_members_is_rejected_before_import(self) -> None: + zip_path = self.root / "metadata-pressure.zip" + with zipfile.ZipFile(zip_path, "w") as archive: + for index in range(20_001): + archive.writestr(f"d{index}/", b"") + + self.assertEqual(_assets_from_zip(zip_path), []) + + def test_zip_member_preflight_ignores_eocd_marker_inside_comment(self) -> None: + payload = io.BytesIO() + with zipfile.ZipFile(payload, "w") as archive: + archive.writestr("visible.md", b"note") + archive.comment = b"comment-with-PK\x05\x06-marker" + + self.assertEqual(_zip_declared_member_count(payload.getvalue()), 1) + class TwitterBatchingTests(unittest.TestCase): """The 1000-tweet cap is now a batch size: one record per 1000 tweets.""" diff --git a/backend/tests/test_memory_integrity.py b/backend/tests/test_memory_integrity.py index 29f1b420..6ab3526f 100644 --- a/backend/tests/test_memory_integrity.py +++ b/backend/tests/test_memory_integrity.py @@ -4,10 +4,12 @@ import tempfile import unittest from pathlib import Path +from unittest.mock import patch from backend.app.database import connect, init_db from backend.app.extractor import extract_context -from backend.app.storage import CortexStore +from backend.app import storage as storage_module +from backend.app.storage import CortexStore, ExportSizeLimitError class MemoryIntegrityTests(unittest.TestCase): @@ -67,6 +69,34 @@ def test_empty_history_folds_to_genesis_only(self) -> None: # With zero events the head is genesis folded zero times: exactly the genesis constant. self.assertEqual(digest["chain_head"], self.store.INTEGRITY_CHAIN_GENESIS) + def test_synchronous_export_refuses_corpus_above_preflight_limit(self) -> None: + self.store.save_capture( + user_id=self.user_id, + content="large export evidence " * 200, + source="note", + source_url="note://large-export", + title=None, + extracted=extract_context("large export evidence"), + ) + with patch.object(storage_module, "MAX_SYNCHRONOUS_EXPORT_BYTES", 100): + with self.assertRaises(ExportSizeLimitError): + self.store.export_json(self.user_id) + + def test_export_preflight_is_conservative_for_non_bmp_unicode(self) -> None: + self.store.save_capture( + user_id=self.user_id, + content="😀" * 10_000, + source="note", + source_url="note://unicode-export", + title="Unicode", + extracted=extract_context("😀" * 10_000), + ) + with connect(self.db_path) as conn: + estimated = self.store._estimated_export_bytes(conn, self.user_id) + payload = self.store.export_json(self.user_id) + canonical_bytes = len(self.store._canonical_export_bytes(payload)) + self.assertGreaterEqual(estimated, canonical_bytes) + def test_new_event_advances_the_head(self) -> None: ids = self._seed(3, approve=1) before = self.store.integrity_digest(self.user_id) diff --git a/backend/tests/test_oidc_registry.py b/backend/tests/test_oidc_registry.py index b023eb77..0e896d8c 100644 --- a/backend/tests/test_oidc_registry.py +++ b/backend/tests/test_oidc_registry.py @@ -260,6 +260,26 @@ def test_happy_path_verifies_and_sends_pkce_verifier(self) -> None: self.assertEqual(exchanges[0]["code"], "auth-code") self.assertEqual(exchanges[0]["redirect_uri"], "https://app.example/callback") + def test_signup_consent_is_bound_to_and_consumed_with_oauth_state(self) -> None: + started = self.registry.start( + "google", + "https://app.example/callback", + signup_consent=True, + ) + flow = self.store.get_flow(started["state"]) + assert flow is not None + payload = json.loads(flow["payload_json"]) + self.assertIs(payload["signup_consent"], True) + self.id_token_claims = self._claims(payload) + + identity = self.registry.complete( + "google", + state=started["state"], + code="auth-code", + ) + + self.assertIs(identity["signup_consent"], True) + def test_state_is_single_use(self) -> None: started, payload = self._start() self.id_token_claims = self._claims(payload) diff --git a/backend/tests/test_public_pages.py b/backend/tests/test_public_pages.py index ad98cf76..f59c2bb6 100644 --- a/backend/tests/test_public_pages.py +++ b/backend/tests/test_public_pages.py @@ -54,7 +54,10 @@ def test_legal_pages_have_real_content(self) -> None: self.assertIn("16 years", terms) # age gate matches the signup ToS checkbox privacy = self.client.get("/privacy").text self.assertIn("Privacy Policy", privacy) - self.assertIn("crypto-shred", privacy) # reflects the real deletion behavior + self.assertIn("Connector credentials are", privacy) + self.assertIn("encrypted at rest", privacy) + self.assertIn("removes your hosted memory", privacy) + self.assertNotIn("whole-memory crypto", privacy.lower()) def test_download_points_at_the_public_notarized_artifact(self) -> None: # The download button must hand out the Developer ID signed + notarized DMG from the diff --git a/backend/tests/test_ratelimit.py b/backend/tests/test_ratelimit.py index b5ee5dc5..dafcc748 100644 --- a/backend/tests/test_ratelimit.py +++ b/backend/tests/test_ratelimit.py @@ -46,6 +46,52 @@ def test_reset_clears_bucket(self) -> None: limiter.reset("alice") self.assertTrue(limiter.check("alice")[0]) + def test_bucket_map_fails_closed_instead_of_resetting_active_keys(self) -> None: + limiter = TokenBucketRateLimiter( + 60, + burst=1, + max_buckets=3, + idle_ttl_seconds=100, + time_fn=lambda: 0.0, + ) + for key in ("a", "b", "c"): + self.assertTrue(limiter.check(key)[0]) + self.assertFalse(limiter.check("d")[0]) + self.assertEqual(list(limiter._buckets), ["a", "b", "c"]) + # Flooding novel keys cannot evict and reset a key that has exhausted + # its bucket. + self.assertFalse(limiter.check("a")[0]) + + def test_idle_buckets_are_evicted_before_active_buckets(self) -> None: + clock = {"t": 0.0} + limiter = TokenBucketRateLimiter( + 60, + burst=1, + max_buckets=3, + idle_ttl_seconds=10, + time_fn=lambda: clock["t"], + ) + limiter.check("old-a") + limiter.check("old-b") + clock["t"] = 11.0 + limiter.check("new") + self.assertEqual(list(limiter._buckets), ["new"]) + + def test_fully_refilled_bucket_is_reclaimable_before_idle_ttl(self) -> None: + clock = {"t": 0.0} + limiter = TokenBucketRateLimiter( + 60, + burst=1, + max_buckets=1, + idle_ttl_seconds=600, + time_fn=lambda: clock["t"], + ) + self.assertTrue(limiter.check("finished")[0]) + self.assertFalse(limiter.check("new")[0]) + clock["t"] = 1.0 + self.assertTrue(limiter.check("new")[0]) + self.assertEqual(list(limiter._buckets), ["new"]) + if __name__ == "__main__": unittest.main() diff --git a/backend/tests/test_standalone_server.py b/backend/tests/test_standalone_server.py index 072f1b82..c7c501e3 100644 --- a/backend/tests/test_standalone_server.py +++ b/backend/tests/test_standalone_server.py @@ -549,6 +549,17 @@ def export_manifest(self, user_id: str) -> dict: "record_counts": {"captures": 3, "memories": 2, "tasks": 0, "entities": 0, "edges": 0, "imports": 0}, } + def export_json(self, user_id: str) -> dict: + return { + "user_id": user_id, + "captures": [], + "memories": [], + "tasks": [], + "entities": [], + "edges": [], + "imports": [], + } + def export_portable_bundle(self, user_id: str) -> dict: self.export_portable_bundle_calls.append(user_id) return { @@ -2860,6 +2871,19 @@ def test_oversized_request_body_returns_413(self) -> None: request.urlopen(req, timeout=5) self.assertEqual(ctx.exception.code, 413) + def test_oversized_synchronous_export_returns_413_not_500(self) -> None: + headers = {"Authorization": "Bearer test-token"} + with mock.patch.object( + self.fake_store, + "export_json", + side_effect=standalone_server.ExportSizeLimitError("export is too large"), + ): + req = request.Request(self.base_url + "/v1/export.json", headers=headers) + with self.assertRaises(error.HTTPError) as ctx: + request.urlopen(req, timeout=5) + self.assertEqual(ctx.exception.code, 413) + self.assertIn("export is too large", ctx.exception.read().decode("utf-8")) + def test_standalone_worker_enabled_only_for_local_inline_mode(self): with mock.patch.dict(os.environ, {}, clear=True): standalone_server.settings = Settings( @@ -2887,6 +2911,18 @@ def test_standalone_worker_enabled_only_for_local_inline_mode(self): with mock.patch.dict(os.environ, {"CORTEX_STANDALONE_WORKER_ENABLED": "1"}): self.assertTrue(standalone_server._standalone_worker_enabled()) + def test_standalone_server_refuses_non_loopback_bind_by_default(self): + for host in ("0.0.0.0", "::", "192.0.2.10", "public.example"): + with self.subTest(host=host), mock.patch.dict(os.environ, {}, clear=True): + with self.assertRaisesRegex(ValueError, "local-only"): + standalone_server.serve(host, 9876) + + def test_standalone_loopback_detection_handles_ipv4_ipv6_and_mapped_addresses(self): + for host in ("localhost", "127.0.0.1", "127.9.8.7", "::1", "::ffff:127.0.0.1"): + with self.subTest(host=host): + self.assertTrue(standalone_server._standalone_host_is_loopback(host)) + self.assertFalse(standalone_server._standalone_host_is_loopback("0.0.0.0")) + def test_standalone_worker_tick_drains_memory_jobs_without_source_scheduling(self): result = standalone_server._run_standalone_worker_tick(limit=7) self.assertEqual(result["processed"], 1) @@ -4597,6 +4633,47 @@ def test_scoped_api_token_prevents_user_header_impersonation_when_required(self) self.assertEqual(payload["results"][0]["content"], "Layer-aware result") self.assertEqual(self.fake_store.search_calls[-1]["user_id"], "alice") + api_headers = { + "Authorization": "Bearer cxa-standalone-token", + "X-Cortex-User": "alice", + } + with request.urlopen( + request.Request( + self.base_url + "/v1/tools/schema", + headers=api_headers, + ), + timeout=5, + ) as response: + self.assertEqual(response.status, 200) + self.assertTrue(json.loads(response.read().decode("utf-8"))["schema"]) + + tool_request = request.Request( + self.base_url + "/v1/tools/call", + data=json.dumps( + {"name": "search_memory", "arguments": {"query": "voice"}} + ).encode("utf-8"), + headers={**api_headers, "Content-Type": "application/json"}, + method="POST", + ) + with request.urlopen(tool_request, timeout=5) as response: + tool_payload = json.loads(response.read().decode("utf-8")) + self.assertEqual(response.status, 200) + self.assertEqual(tool_payload["tool"], "search_memory") + + with self.assertRaises(error.HTTPError) as context: + request.urlopen( + request.Request( + self.base_url + "/mcp", + data=json.dumps( + {"jsonrpc": "2.0", "id": 1, "method": "tools/list"} + ).encode("utf-8"), + headers={**api_headers, "Content-Type": "application/json"}, + method="POST", + ), + timeout=5, + ) + self.assertEqual(context.exception.code, 401) + # A token without export scope is still denied the raw bulk dump (distilled reads like # context-pack are now allowed with read scope). with self.assertRaises(error.HTTPError) as context: @@ -4817,7 +4894,34 @@ def test_context_engine_routes_on_shipping_server(self) -> None: # Admin bearer sees the identity layer. self.assertIs(call["include_identity"], True) - # POST parity + markdown content type. + # A read-scoped token gets the same identity policy over GET and POST. + scoped_headers = { + "Authorization": "Bearer cxa-standalone-token", + "X-Cortex-User": "alice", + } + with request.urlopen( + request.Request(self.base_url + "/v1/context?task=scoped+get", headers=scoped_headers), + timeout=5, + ) as response: + self.assertEqual(response.status, 200) + scoped_get = self.fake_store.assemble_context_calls[-1] + self.assertIs(scoped_get["include_identity"], True) + + with request.urlopen( + request.Request( + self.base_url + "/v1/context", + data=json.dumps({"task": "scoped post"}).encode("utf-8"), + headers={**scoped_headers, "Content-Type": "application/json"}, + method="POST", + ), + timeout=5, + ) as response: + self.assertEqual(response.status, 200) + scoped_post = self.fake_store.assemble_context_calls[-1] + self.assertIs(scoped_post["include_identity"], True) + self.assertIn(("alice", "read"), self.fake_store.require_agent_access_calls) + + # Admin POST parity + markdown content type. with request.urlopen( request.Request( self.base_url + "/v1/context", @@ -4835,6 +4939,20 @@ def test_context_engine_routes_on_shipping_server(self) -> None: self.post_json("/v1/context", {"task": "x", "format": "yaml"}) self.assertEqual(context.exception.code, 422) + for invalid in ( + {"task": {"nested": "object"}}, + {"task": "x", "token_budget": 0}, + {"task": "x", "pin": {"truthy": True}}, + {"task": "x", "format": {"not": "text"}}, + ): + with self.subTest(invalid=invalid), self.assertRaises(error.HTTPError) as context: + self.post_json("/v1/context", invalid) + self.assertEqual(context.exception.code, 422) + + with self.post_json("/v1/context", {"task": "x", "pin": "false"}) as response: + self.assertEqual(response.status, 200) + self.assertIs(self.fake_store.assemble_context_calls[-1]["pin"], False) + def test_context_pack_verify_route_on_shipping_server(self) -> None: # Phase 2b: POST /v1/context/packs/{sha}/verify reaches store.verify_context_pack, # is maintenance-scoped for API tokens, and maps ValueError -> 404. diff --git a/backend/tests/test_turnstile.py b/backend/tests/test_turnstile.py index 70c14275..fb4d0741 100644 --- a/backend/tests/test_turnstile.py +++ b/backend/tests/test_turnstile.py @@ -12,7 +12,7 @@ or invalid token is rejected (400/403) BEFORE the account is ever created; with Turnstile disabled signup works with no token (no-op). -The siteverify HTTP call is monkeypatched (urllib.request.urlopen) so no network +The siteverify HTTP call is monkeypatched at the guarded transport boundary so no network is touched; the argon2 signup path runs against the real FastAPI app in auth-enabled hosted mode (same discipline as test_web_account / test_billing). """ @@ -26,7 +26,6 @@ import secrets import tempfile import unittest -import urllib.request from dataclasses import replace from pathlib import Path @@ -79,6 +78,7 @@ def setUp(self) -> None: shard_mode="user", default_user_id="hosted-default", require_scoped_api_tokens=True, + legal_terms_approved=True, auth_enabled=True, accounts_db_path=None, auth_email_mode="log", @@ -134,7 +134,15 @@ def test_signup_page_has_no_widget_or_cf_csp(self) -> None: def test_signup_succeeds_with_no_token(self) -> None: email = "nobot@example.com" - resp = self.client.post("/v1/auth/signup", json={"email": email, "password": PASSWORD}) + resp = self.client.post( + "/v1/auth/signup", + json={ + "email": email, + "password": PASSWORD, + "terms_accepted": True, + "age_confirmed": True, + }, + ) self.assertEqual(resp.status_code, 200, resp.text) self.assertTrue(self._account_exists(email)) @@ -147,11 +155,11 @@ class TurnstileEnabledTests(_HostedAppTestCase): def setUp(self) -> None: super().setUp() - self._orig_urlopen = urllib.request.urlopen + self._orig_open_same_origin = main_module.open_same_origin self._siteverify_calls: list = [] def tearDown(self) -> None: - urllib.request.urlopen = self._orig_urlopen + main_module.open_same_origin = self._orig_open_same_origin super().tearDown() def _patch_siteverify(self, *, success: bool) -> None: @@ -166,7 +174,7 @@ def fake_urlopen(req, *args, **kwargs): payload["error-codes"] = ["invalid-input-response"] return _FakeSiteverifyResponse(payload) - urllib.request.urlopen = fake_urlopen + main_module.open_same_origin = fake_urlopen # ----------------------------------------------------------- page markup def test_settings_turnstile_enabled(self) -> None: @@ -195,7 +203,13 @@ def test_valid_token_passes_signup(self) -> None: email = "human@example.com" resp = self.client.post( "/v1/auth/signup", - json={"email": email, "password": PASSWORD, "turnstile_token": "tok-good"}, + json={ + "email": email, + "password": PASSWORD, + "turnstile_token": "tok-good", + "terms_accepted": True, + "age_confirmed": True, + }, ) self.assertEqual(resp.status_code, 200, resp.text) self.assertTrue(self._account_exists(email)) @@ -208,7 +222,15 @@ def test_valid_token_passes_signup(self) -> None: def test_missing_token_rejected_before_signup(self) -> None: self._patch_siteverify(success=True) # would pass if reached email = "notoken@example.com" - resp = self.client.post("/v1/auth/signup", json={"email": email, "password": PASSWORD}) + resp = self.client.post( + "/v1/auth/signup", + json={ + "email": email, + "password": PASSWORD, + "terms_accepted": True, + "age_confirmed": True, + }, + ) self.assertEqual(resp.status_code, 400, resp.text) # No account created, and siteverify never called (rejected before hash). self.assertFalse(self._account_exists(email)) @@ -219,7 +241,13 @@ def test_invalid_token_rejected_before_signup(self) -> None: email = "badtoken@example.com" resp = self.client.post( "/v1/auth/signup", - json={"email": email, "password": PASSWORD, "turnstile_token": "tok-bad"}, + json={ + "email": email, + "password": PASSWORD, + "turnstile_token": "tok-bad", + "terms_accepted": True, + "age_confirmed": True, + }, ) self.assertEqual(resp.status_code, 403, resp.text) self.assertFalse(self._account_exists(email)) @@ -230,11 +258,17 @@ def test_siteverify_network_error_fails_closed(self) -> None: def boom(req, *a, **k): raise OSError("network down") - urllib.request.urlopen = boom + main_module.open_same_origin = boom email = "offline@example.com" resp = self.client.post( "/v1/auth/signup", - json={"email": email, "password": PASSWORD, "turnstile_token": "tok"}, + json={ + "email": email, + "password": PASSWORD, + "turnstile_token": "tok", + "terms_accepted": True, + "age_confirmed": True, + }, ) self.assertEqual(resp.status_code, 403, resp.text) self.assertFalse(self._account_exists(email)) diff --git a/backend/tests/test_vault_concurrency.py b/backend/tests/test_vault_concurrency.py index a3e9688b..b09aefce 100644 --- a/backend/tests/test_vault_concurrency.py +++ b/backend/tests/test_vault_concurrency.py @@ -1,14 +1,38 @@ from __future__ import annotations import json +import fcntl +import multiprocessing import tempfile import threading +import time import unittest from pathlib import Path from backend.app.vault import CortexVault +def _multiprocess_vault_writer( + root: str, + index_path: str, + worker_id: int, + writes: int, + start: multiprocessing.synchronize.Event, +) -> None: + vault = CortexVault(Path(root), Path(index_path)) + start.wait(timeout=10) + for offset in range(writes): + sequence = worker_id * writes + offset + vault.write_settings(f"process-user-{sequence}", {"sequence": sequence}) + vault.write_source_credential( + user_id="process-user", + source_account_id=f"process-account-{sequence}", + source="github", + payload={"token": f"secret-{sequence}"}, + ) + vault.append_event({"user_id": "process-user", "type": "process-test", "seq": sequence}) + + class VaultConcurrencyTests(unittest.TestCase): """The shipping server is multi-threaded (one PID) and in bucket mode many tenants share one vault. These tests guard the two concurrency hazards the round-2 review found: (a) a pid-only @@ -113,6 +137,102 @@ def test_concurrent_append_events_none_lost(self) -> None: events = [e for e in self.vault.iter_events() if e.get("type") == "test"] self.assertEqual(len(events), count, "lost or corrupted event lines under concurrent append") + def test_multiprocess_read_modify_write_preserves_all_records(self) -> None: + process_count = 4 + writes_per_process = 16 + expected = process_count * writes_per_process + context = multiprocessing.get_context("spawn") + start = context.Event() + processes = [ + context.Process( + target=_multiprocess_vault_writer, + args=( + str(self.vault.root), + str(self.vault.index_path), + worker_id, + writes_per_process, + start, + ), + ) + for worker_id in range(process_count) + ] + for process in processes: + process.start() + start.set() + for process in processes: + process.join(timeout=30) + self.assertFalse(process.is_alive(), "vault writer process did not terminate") + self.assertEqual(process.exitcode, 0) + + settings = json.loads(self.vault.settings_path.read_text(encoding="utf-8")) + credentials = json.loads(self.vault.credentials_path.read_text(encoding="utf-8")) + events = [ + event for event in self.vault.iter_events() + if event.get("type") == "process-test" + ] + self.assertEqual(len(settings.get("users") or {}), expected) + self.assertEqual( + len(((credentials.get("users") or {}).get("process-user") or {})), + expected, + ) + self.assertEqual(len(events), expected) + self.assertEqual({event["seq"] for event in events}, set(range(expected))) + + def test_backup_shared_lock_freezes_ordinary_record_writes(self) -> None: + lock_path = self.vault.root / ".cortex-vault.lock" + started = threading.Event() + finished = threading.Event() + + def write_record() -> None: + started.set() + self.vault.write_source_account( + { + "id": "source-during-backup", + "user_id": "u", + "source": "github", + } + ) + finished.set() + + with lock_path.open("a+b") as backup_lock: + fcntl.flock(backup_lock.fileno(), fcntl.LOCK_SH) + thread = threading.Thread(target=write_record) + thread.start() + self.assertTrue(started.wait(timeout=1)) + time.sleep(0.05) + self.assertFalse(finished.is_set(), "vault write bypassed the backup lock") + fcntl.flock(backup_lock.fileno(), fcntl.LOCK_UN) + thread.join(timeout=2) + + self.assertTrue(finished.is_set()) + + def test_backup_shared_lock_freezes_direct_markdown_pruning(self) -> None: + self.vault.write_daily_markdown({"date": "2026-07-30", "items": []}) + page = self.vault.daily_markdown_path("2026-07-30") + self.assertTrue(page.exists()) + lock_path = self.vault.root / ".cortex-vault.lock" + started = threading.Event() + finished = threading.Event() + + def prune_page() -> None: + started.set() + self.vault.prune_daily_pages(set()) + finished.set() + + with lock_path.open("a+b") as backup_lock: + fcntl.flock(backup_lock.fileno(), fcntl.LOCK_SH) + thread = threading.Thread(target=prune_page) + thread.start() + self.assertTrue(started.wait(timeout=1)) + time.sleep(0.05) + self.assertFalse(finished.is_set(), "Markdown prune bypassed the backup lock") + self.assertTrue(page.exists(), "page changed while the backup freeze was held") + fcntl.flock(backup_lock.fileno(), fcntl.LOCK_UN) + thread.join(timeout=2) + + self.assertTrue(finished.is_set()) + self.assertFalse(page.exists()) + if __name__ == "__main__": unittest.main() diff --git a/backend/tests/test_web_account.py b/backend/tests/test_web_account.py index 7a759b0b..75561bcb 100644 --- a/backend/tests/test_web_account.py +++ b/backend/tests/test_web_account.py @@ -63,6 +63,7 @@ def setUp(self) -> None: shard_mode="user", default_user_id="hosted-default", require_scoped_api_tokens=True, + legal_terms_approved=True, auth_enabled=True, accounts_db_path=None, auth_email_mode="log", @@ -267,6 +268,11 @@ def test_signup_page_requires_tos_age_checkbox(self) -> None: self.assertIn("/terms", html) self.assertIn("/privacy", html) self.assertIn('autocomplete="new-password"', html) + self.assertIn("/v1/auth/oauth/google/start?signup=1", html) + self.assertIn('class="button secondary oauth-signup"', html) + js = self.client.get("/account/app.js").text + self.assertIn("target.searchParams.set('terms_accepted', 'true')", js) + self.assertIn("target.searchParams.set('age_confirmed', 'true')", js) def test_reset_page_points_at_reset_endpoints(self) -> None: html = self.client.get("/account/reset").text @@ -419,6 +425,8 @@ def test_oauth_callback_delivers_tokens_to_browser_in_url_fragment(self) -> None "account": {"account_id": "acc_1", "status": "login", "primary_email": "signin@example.com"}, } with mock.patch.object(runtime.oidc, "complete", return_value=identity), mock.patch.object( + runtime.control_store, "get_identity", return_value={"identity_id": "ident_1"} + ), mock.patch.object( runtime.service, "find_or_challenge_identity", return_value=result ), mock.patch.object(runtime.service, "mint_session", return_value=session): r = self.client.get( diff --git a/deploy/README.md b/deploy/README.md index 2365c7b6..a51a2e01 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -46,6 +46,9 @@ hundreds of beta users fit comfortably here and the upgrade is a snapshot-restor - `CORTEX_AUTH_AUTOVERIFY=0`: accounts stay pending until email verification. Configure Postmark/SES (or another SMTP provider) before accepting public signups. +- `CORTEX_LEGAL_TERMS_APPROVED=0`: public account creation and hosted readiness stay + blocked. Change this only after approved Terms and Privacy text is deployed; both + password and OAuth signup require explicit terms and age consent. - GitHub login works the moment you create a (2-minute, no-review) GitHub OAuth app and set the two env vars in `/etc/cortex/cortex.env`; Google login needs the consent-screen publishing review (1–2 weeks) so leave it for later. diff --git a/deploy/backup.sh b/deploy/backup.sh index 3ab5658d..f6adc58e 100755 --- a/deploy/backup.sh +++ b/deploy/backup.sh @@ -11,9 +11,8 @@ umask 077 SHARD_ROOT="${CORTEX_SHARD_ROOT:-/var/lib/cortex/shards}" DEST_ROOT="/var/lib/cortex/backups" -STAMP="$(date -u +%Y%m%d-%H%M%S)" -STAGING="$DEST_ROOT/staging-$STAMP" -ARCHIVE="$DEST_ROOT/cortex-$STAMP.tar.gz.age" +STAMP="$(date -u +%Y%m%d-%H%M%S)-$$" +SERVICE_USER="${CORTEX_SERVICE_USER:-cortex}" if [ -z "${BACKUP_AGE_RECIPIENT:-}" ]; then echo "BACKUP_AGE_RECIPIENT is required; refusing to create a plaintext backup" >&2 @@ -23,11 +22,57 @@ if ! command -v age >/dev/null; then echo "age is required to encrypt Cortex backups" >&2 exit 1 fi +if ! command -v flock >/dev/null; then + echo "flock is required to coordinate Cortex backups" >&2 + exit 1 +fi +if ! command -v sqlite3 >/dev/null || ! command -v tar >/dev/null || ! command -v runuser >/dev/null; then + echo "sqlite3, tar, and runuser are required to create Cortex backups" >&2 + exit 1 +fi -install -d -m 0700 "$DEST_ROOT" "$STAGING" -trap 'rm -rf "$STAGING"' EXIT +install -d -m 0700 "$DEST_ROOT" +exec 9>"$DEST_ROOT/.backup.lock" +if ! flock -n 9; then + echo "another Cortex backup is already running" >&2 + exit 1 +fi +STAGING="$(mktemp -d "$DEST_ROOT/.staging-$STAMP.XXXXXX")" +ARCHIVE="$DEST_ROOT/cortex-$STAMP.tar.gz.age" +ARCHIVE_TMP="$(mktemp "$DEST_ROOT/.archive-$STAMP.XXXXXX")" -# 1. Consistent snapshots of every SQLite DB under the shard root (shards, control +trap 'rm -rf -- "$STAGING"; rm -f -- "$ARCHIVE_TMP"' EXIT + +# 1. Vault trees (Markdown + JSON records + encrypted credential blobs). Memory content +# is plaintext at rest, which is why the whole archive is encrypted below. +# Snapshot vaults BEFORE the keyring/control databases: credential creation persists +# its wrapped key before its encrypted vault blob. This order ensures a backed-up +# ciphertext can never be newer than the backed-up keyring needed to decrypt it. +while IFS= read -r -d '' vault; do + rel="${vault#"$SHARD_ROOT"/}" + target="$STAGING/vault/$rel" + mkdir -p "$target" + # Coordinate with CortexVault's cross-process writer lock. A shared lock + # freezes this vault while tar snapshots it and excludes the lock inode itself. + ( + # Never open/chown this service-owned path as root: the service account can + # replace it with a symlink. Create and open it only with service-account + # privileges, so even a hostile path swap cannot alter a root-owned target. + if [ ! -e "$vault/.cortex-vault.lock" ]; then + runuser -u "$SERVICE_USER" -- touch -- "$vault/.cortex-vault.lock" + fi + if [ -L "$vault/.cortex-vault.lock" ] || [ ! -f "$vault/.cortex-vault.lock" ]; then + echo "unsafe vault lock path: $vault/.cortex-vault.lock" >&2 + exit 1 + fi + runuser -u "$SERVICE_USER" -- \ + flock -s "$vault/.cortex-vault.lock" \ + tar -C "$vault" --exclude='./.cortex-vault.lock' -cf - . \ + | tar -C "$target" -xf - + ) +done < <(find "$SHARD_ROOT" -maxdepth 3 -type d -name '*.vault' -print0 2>/dev/null) + +# 2. Consistent snapshots of every SQLite DB under the shard root (shards, control # registry, token index, accounts, keyring metadata) — .backup handles live WAL. while IFS= read -r -d '' db; do rel="${db#"$SHARD_ROOT"/}" @@ -35,18 +80,11 @@ while IFS= read -r -d '' db; do sqlite3 "$db" ".backup '$STAGING/db/$rel'" done < <(find "$SHARD_ROOT" -name '*.sqlite' -print0 2>/dev/null) -# 2. Vault trees (Markdown + JSON records + encrypted credential blobs). Memory content -# is plaintext at rest, which is why the whole archive is encrypted below. -while IFS= read -r -d '' vault; do - rel="${vault#"$SHARD_ROOT"/}" - mkdir -p "$STAGING/vault/$(dirname "$rel")" - cp -R "$vault" "$STAGING/vault/$rel" -done < <(find "$SHARD_ROOT" -maxdepth 3 -type d -name '*.vault' -print0 2>/dev/null) - # Never copy /etc/cortex/cortex.env: it contains operator, OAuth, signing, and SMTP # credentials. Deployment configuration should be reconstructed from the example and # separately escrowed secrets. -tar -cz -C "$STAGING" . | age -r "$BACKUP_AGE_RECIPIENT" -o "$ARCHIVE" +tar -cz -C "$STAGING" . | age -r "$BACKUP_AGE_RECIPIENT" -o "$ARCHIVE_TMP" +mv "$ARCHIVE_TMP" "$ARCHIVE" chmod 0600 "$ARCHIVE" echo "backup written: $ARCHIVE ($(du -h "$ARCHIVE" | cut -f1))" diff --git a/deploy/cortex.env.example b/deploy/cortex.env.example index 91d9e17b..828a57cf 100644 --- a/deploy/cortex.env.example +++ b/deploy/cortex.env.example @@ -20,6 +20,9 @@ CORTEX_SYNC_SIGNING_KEY=__SIGNING_KEY__ # --- accounts + auth (public-safe default) --- CORTEX_AUTH_ENABLED=1 +# Deliberately blocks public signup and /ready until counsel-approved Terms +# and Privacy text is deployed. Operators must review before changing to 1. +CORTEX_LEGAL_TERMS_APPROVED=0 # Keep accounts pending until their email address is verified. Configure SMTP below # before accepting public signups. CORTEX_AUTH_AUTOVERIFY=0 @@ -58,10 +61,15 @@ CORTEX_AUTH_RATE_LIMIT_PER_MINUTE=30 # --- per-user encryption (KEK generated by bootstrap; ESCROW IT OFFLINE) --- CORTEX_KEK_FILE=/etc/cortex/kek +CORTEX_REQUIRE_ENCRYPTED_CREDENTIALS=1 # --- fair use: non-zero ON PURPOSE (0 disables) --- CORTEX_RATE_LIMIT_PER_MINUTE=120 CORTEX_DEFAULT_MEMORY_QUOTA=20000 +# Reject in-memory synchronous exports before they can exhaust an API worker. +CORTEX_MAX_SYNC_EXPORT_BYTES=25000000 +# Each Argon2id operation uses 64 MiB. Two slots per API worker bound auth bursts. +CORTEX_PASSWORD_HASH_CONCURRENCY=2 # --- retrieval quality: real on-device embeddings, zero API cost --- CORTEX_EMBEDDING_PROVIDER=model2vec diff --git a/deploy/macmini/setup.sh b/deploy/macmini/setup.sh index 7d28974a..9d973a75 100755 --- a/deploy/macmini/setup.sh +++ b/deploy/macmini/setup.sh @@ -6,15 +6,16 @@ # # bash deploy/macmini/setup.sh # Env overrides: CORTEX_DATA_DIR (default ~/CortexServer), CORTEX_PORT (8766), -# CORTEX_PUBLIC_HOST (default api.trydoppl.com), CORTEX_SETUP_DRY_RUN=1 (write -# files but don't launchctl load — for inspection/testing). +# CORTEX_PUBLIC_HOST (default api.signindoppl.com), +# CORTEX_PYTHON_BIN (default python3.12), CORTEX_SETUP_DRY_RUN=1 (write files +# but don't launchctl load — for inspection/testing). set -euo pipefail REPO_DIR="$(cd "$(dirname "$0")/../.." && pwd)" -PYTHON="$(command -v python3)" DATA_DIR="${CORTEX_DATA_DIR:-$HOME/CortexServer}" +VENV_DIR="$DATA_DIR/venv" PORT="${CORTEX_PORT:-8766}" -PUBLIC_HOST="${CORTEX_PUBLIC_HOST:-api.trydoppl.com}" +PUBLIC_HOST="${CORTEX_PUBLIC_HOST:-api.signindoppl.com}" ENV_FILE="$DATA_DIR/cortex.env" KEK_FILE="$DATA_DIR/kek" LOG_DIR="$DATA_DIR/logs" @@ -22,6 +23,32 @@ AGENTS_DIR="$HOME/Library/LaunchAgents" API_LABEL="com.cortex.api" WORKER_LABEL="com.cortex.worker" +PYTHON_BIN="${CORTEX_PYTHON_BIN:-python3.12}" +if ! command -v "$PYTHON_BIN" >/dev/null 2>&1; then + echo "!! Cortex requires Python 3.12; '$PYTHON_BIN' was not found." >&2 + echo " Install Python 3.12 or set CORTEX_PYTHON_BIN=/absolute/path/to/python3.12." >&2 + exit 1 +fi +PYTHON_BIN="$(command -v "$PYTHON_BIN")" +PYTHON_VERSION="$("$PYTHON_BIN" -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")')" +if [ "$PYTHON_VERSION" != "3.12" ]; then + echo "!! Cortex requires Python 3.12; '$PYTHON_BIN' reports Python $PYTHON_VERSION." >&2 + exit 1 +fi + +mkdir -p "$DATA_DIR/shards" "$DATA_DIR/control" "$LOG_DIR" "$AGENTS_DIR" +if [ ! -x "$VENV_DIR/bin/python" ]; then + echo "==> Creating dedicated Python environment at $VENV_DIR" + "$PYTHON_BIN" -m venv "$VENV_DIR" +fi +PYTHON="$VENV_DIR/bin/python" +VENV_VERSION="$("$PYTHON" -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")')" +if [ "$VENV_VERSION" != "3.12" ]; then + echo "!! Existing environment at $VENV_DIR uses Python $VENV_VERSION; expected 3.12." >&2 + echo " Move that environment aside and rerun setup." >&2 + exit 1 +fi + echo "==============================================================" echo " Cortex Mac mini setup" echo " repo: $REPO_DIR" @@ -30,8 +57,6 @@ echo " data: $DATA_DIR" echo " host: https://$PUBLIC_HOST (via Cloudflare Tunnel -> 127.0.0.1:$PORT)" echo "==============================================================" -mkdir -p "$DATA_DIR/shards" "$DATA_DIR/control" "$LOG_DIR" "$AGENTS_DIR" - # --- secrets, generated ONCE --- if [ ! -f "$ENV_FILE" ]; then ADMIN_TOKEN="cxop_$(openssl rand -hex 32)" @@ -63,6 +88,7 @@ CORTEX_SYNC_SIGNING_KEY=$SIGNING_KEY # accounts (public-safe: configure SMTP before accepting signups) CORTEX_AUTH_ENABLED=1 +CORTEX_LEGAL_TERMS_APPROVED=0 CORTEX_AUTH_AUTOVERIFY=0 CORTEX_AUTH_EMAIL_MODE=smtp @@ -74,10 +100,13 @@ CORTEX_AUTO_APPROVE_CAPTURES=1 # per-user encryption CORTEX_KEK_FILE=$KEK_FILE +CORTEX_REQUIRE_ENCRYPTED_CREDENTIALS=1 # fair use (non-zero on purpose) CORTEX_RATE_LIMIT_PER_MINUTE=120 CORTEX_DEFAULT_MEMORY_QUOTA=20000 +CORTEX_MAX_SYNC_EXPORT_BYTES=25000000 +CORTEX_PASSWORD_HASH_CONCURRENCY=2 # on-device embeddings (free, private) CORTEX_EMBEDDING_PROVIDER=model2vec @@ -100,12 +129,24 @@ ensure_env() { fi } ensure_env CORTEX_AUTO_APPROVE_CAPTURES 1 +ensure_env CORTEX_REQUIRE_ENCRYPTED_CREDENTIALS 1 +ensure_env CORTEX_LEGAL_TERMS_APPROVED 0 +ensure_env CORTEX_PASSWORD_HASH_CONCURRENCY 2 +ensure_env CORTEX_MAX_SYNC_EXPORT_BYTES 25000000 +# Migrate only the former shipped default. Deliberate operator overrides are +# preserved, while existing installs receive the safer in-memory export cap. +if grep -q '^CORTEX_MAX_SYNC_EXPORT_BYTES=100000000$' "$ENV_FILE"; then + sed -i.bak \ + 's/^CORTEX_MAX_SYNC_EXPORT_BYTES=100000000$/CORTEX_MAX_SYNC_EXPORT_BYTES=25000000/' \ + "$ENV_FILE" + rm -f "$ENV_FILE.bak" + echo " lowered the former default CORTEX_MAX_SYNC_EXPORT_BYTES to 25000000" +fi chmod +x "$REPO_DIR/deploy/macmini/run-api.sh" "$REPO_DIR/deploy/macmini/run-worker.sh" # --- Python deps --- -echo "==> Installing backend dependencies into $PYTHON" -"$PYTHON" -m pip install --quiet --upgrade pip +echo "==> Installing backend dependencies into $VENV_DIR" "$PYTHON" -m pip install --quiet -r "$REPO_DIR/backend/requirements.txt" -r "$REPO_DIR/backend/runtime-requirements.txt" uvicorn || { echo "!! pip install failed; check $PYTHON"; exit 1; } diff --git a/docs/ACCOUNTS_ENCRYPTION_DESIGN.md b/docs/ACCOUNTS_ENCRYPTION_DESIGN.md index 21ac9101..9121d7e9 100644 --- a/docs/ACCOUNTS_ENCRYPTION_DESIGN.md +++ b/docs/ACCOUNTS_ENCRYPTION_DESIGN.md @@ -229,14 +229,19 @@ No JWTs anywhere (OWASP: session state belongs server-side; revocation at 1M = o One additive branch: after the global-key check, before the scoped-token path, `cxs_` prefix dispatches to `AccountsService.authenticate_session()` → returns the account's `user_id` with full user scopes but **never admin**. Session auth joins `accounts.status` AND `users.status` so suspension is enforced on every request. **CSRF enforced centrally in this dependency (graft, judge 3):** cookie-authenticated state-changing requests require the custom `X-Cortex-Client` header (plus `__Host-cortex_session` cookie: Secure, HttpOnly, SameSite=Lax) — no new endpoint can ship without the check. `last_seen_at` written at most once per 5 min per session. ### Endpoints (new `/v1/auth` router included from main.py) -- `POST /v1/auth/signup` `{email, password}` → account `pending_verification` + verification mail (rate-limited + blocklist BEFORE hashing). +- `POST /v1/auth/signup` `{email, password, terms_accepted, age_confirmed}` → account + `pending_verification` + verification mail (rate-limited + blocklist BEFORE hashing). + Hosted signup is unavailable until the operator marks reviewed legal text approved. - `POST /v1/auth/verify-email` `{token}` → activate; **on activation, self-serve provisioning** calls the same internals as `StoreRegistry.provision_user` (sharding.py:819): `register_user` + lazy shard materialization + DEK creation — but does **not** auto-mint `cxa_`/`cxm_`. - `POST /v1/auth/login` `{email, password}` → `{cxs_, cxr_, account}`; uniform generic 401 on any failure (no enumeration/timing oracle). - `POST /v1/auth/refresh` (rotate; reuse ⇒ family revocation), `POST /v1/auth/logout` (revoke session), `GET /v1/auth/session` (whoami). - `POST /v1/auth/password/reset/request` + `/confirm` — single-use hashed tokens in `auth_flows`, 30-min TTL. - `GET /v1/auth/providers` — enabled `oidc_providers` rows for dynamic login buttons (the AI-vendor slot's UI half). - `GET /v1/auth/oauth/{provider}/start` → authorize URL; state+nonce+PKCE verifier persisted in `auth_flows` (dedicated table, NOT the connector-flow `remember_oauth_pending` machinery — callback is unauthenticated). -- `GET /v1/auth/oauth/{provider}/callback` → code exchange + full id_token verification → identity match ⇒ login; no match ⇒ `link_required` challenge or fresh signup per section 2 rules. +- `GET /v1/auth/oauth/{provider}/callback` → code exchange + full id_token verification + → identity match ⇒ login; verified-email collision ⇒ `link_required`; unknown identity + ⇒ signup only when terms/age consent was bound into the single-use OAuth state by the + signup page. OAuth started from the login page never silently creates an account. - `POST /v1/auth/oauth/{provider}/link` (session-authed) / `DELETE .../unlink` (refuse removing last method without a password). - `POST /v1/auth/app/start` → `{flow_id, browser_url, poll_secret}`; macOS app opens `browser_url` in `ASWebAuthenticationSession`; app polls `POST /v1/auth/app/poll {flow_id, poll_secret}` until it gets the token pair (poll_secret salted-hashed in `auth_flows`; **no token ever rides a redirect URL**). Refresh token → Keychain. Email+password login from the app is a plain POST, no browser. - `GET/POST/DELETE /v1/auth/tokens` — self-serve mint/list/revoke of the EXISTING `cxa_`/`cxm_` tokens via `create_api_token`/`create_mcp_token` (sharding.py:746/757), backfilling `scoped_token_index.account_id`. Sessions become the factory for PATs; the operator `/v1/admin/users` surface (main.py:1852) remains for support. diff --git a/docs/API_LIFECYCLE.md b/docs/API_LIFECYCLE.md new file mode 100644 index 00000000..e5feeeec --- /dev/null +++ b/docs/API_LIFECYCLE.md @@ -0,0 +1,84 @@ +# API Lifecycle and Compatibility + +Status: **Current beta policy** + +Last reviewed: 2026-07-30 + +## Supported Surfaces + +During beta, the compatibility contract covers only: + +- the `/v1/context`, `/v1/search`, `/v1/ask`, and `/v1/tools/*` HTTP surfaces + exercised by both SDK and contract tests; +- the generated OpenAPI document at `/openapi.json`; +- the Python package `doppl-cortex-client`; +- the TypeScript package `@doppl-tech/cortex-client`; +- the documented MCP tools and their JSON schemas. + +Other `/v1` routes are documented beta surfaces but do not yet carry a +repository-wide compatibility guarantee. Internal Python modules, SQLite +tables, vault implementation details, and routes without `/v1` are not stable +extension points unless another current document explicitly says otherwise. +Experimental branch documentation is not part of the released contract. + +## Versioning + +Cortex is currently beta. Additive response fields, new endpoints, new optional +request fields, and new enum values may ship in a minor release. Consumers must +ignore response fields they do not recognize. + +A change is breaking when an existing valid request stops working, a field is +removed or changes meaning/type, an authentication scope becomes insufficient, +or an SDK method changes incompatibly. Breaking changes require one of: + +1. a new API namespace such as `/v2`; +2. a new SDK major version; or +3. a documented security emergency where preserving the old behavior would + leave user data or credentials exposed. + +Security tightening may reject requests that were never within the documented +trust boundary, such as credential-bearing redirects to another origin. + +## Deprecation + +For non-emergency changes: + +1. mark the surface deprecated in the OpenAPI description, SDK docs, and + changelog; +2. keep it functional for at least one published minor release; +3. provide a replacement and migration example; +4. remove it only in a major SDK release or a new API namespace. + +The HTTP response may include `Deprecation`, `Sunset`, and `Link` headers once a +removal date is known. A deprecation is not complete if only a source-code +comment announces it. + +## Contract Verification + +CI generates the OpenAPI schema and fails on duplicate operation IDs. Operation +IDs are derived from the public HTTP method and path (for example, +`post_v1_context`), never from a Python handler name, so internal refactors do +not rename client operations. Contract tests also require the bearer security +scheme and typed bounded request model on protected SDK surfaces. SDK tests pin +method, path, authentication header, query, body, and same-origin redirect +behavior for their supported calls. A proposed API change should update: + +- FastAPI and standalone-runtime contract tests where both expose the surface; +- Python and TypeScript SDK tests; +- the changelog and affected current documentation; +- migration notes when compatibility is not additive. + +The generated schema is necessary but not sufficient: the loopback standard +library runtime does not derive its routes from FastAPI, so parity requires +explicit cross-runtime tests. + +A checked-in breaking-schema diff is still required before the API exits beta. +Until then this policy protects the highest-use surfaces but is not evidence of +complete historical compatibility. + +## Authentication and Redirects + +Clients must treat tokens as origin-bound credentials. First-party HTTP clients +do not forward them to a different scheme, hostname, or effective port during a +redirect. API deployments should use a stable canonical origin rather than +depending on redirects. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 005b8534..7d286320 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -319,9 +319,14 @@ Default packaged app path: ~/Library/Application Support/Cortex/Cortex.vault/ ``` -The vault contains human-readable JSON records for imports, source accounts, sync cursors, captures, memories, tasks, entities, and graph edges, plus `settings.json`, `events.jsonl`, attachments, exports, backups, and `index.sqlite`. - -`POST /v1/maintenance/rebuild-index-from-vault` clears the current user's index rows and rebuilds them from the vault records. This is the recovery path if the local index is corrupted or if a future sync process materializes records before rebuilding search. +The vault contains human-readable JSON records for imports, source accounts, +sync cursors, captures, memories, tasks, entities, and graph edges. Memories +also have editable Markdown notes; rebuild merges Markdown memories with the +other durable JSON/JSONL records under the precedence rules in +`LOCAL_VAULT_FORMAT.md`. SQLite is a rebuildable index, not the sole authority. + +`POST /v1/maintenance/rebuild-index-from-vault` clears the current user's index +rows and rebuilds them from that merged durable representation. See `docs/LOCAL_VAULT_FORMAT.md`. diff --git a/docs/BENCHMARKS.md b/docs/BENCHMARKS.md index 1c7a8063..1c78babe 100644 --- a/docs/BENCHMARKS.md +++ b/docs/BENCHMARKS.md @@ -6,11 +6,17 @@ context packing, adaptation, token calibration, routing, and end-to-end agent tasks. They run on synthetic fixtures with known answers so regressions fail in CI without sending personal data to an external model. -## Latest reproducible run +## Latest deterministic regression snapshot Run on 2026-07-30 with Python 3.12 on Apple Silicon. All commands used the repository's checked-in fixtures and made no model API calls. +Machine-readable provenance and limitations are committed in +[`benchmarks/2026-07-30-local.json`](benchmarks/2026-07-30-local.json). This was +one run from a dirty review tree based on commit `8eedd5c`; timing and controlled +cache state were not captured, so it is reproducible input/result evidence, not +a latency benchmark or a release artifact. + | Gate | Cases / checks | Result | Selected metrics | |---|---:|---|---| | Retrieval | 163 labeled queries | Pass | top-1 accuracy 1.000; recall@3 1.000; precision@3 0.947 | diff --git a/docs/EXTERNAL_INTEGRATIONS.md b/docs/EXTERNAL_INTEGRATIONS.md index 2685ebcb..a02df399 100644 --- a/docs/EXTERNAL_INTEGRATIONS.md +++ b/docs/EXTERNAL_INTEGRATIONS.md @@ -4,10 +4,20 @@ Cortex is local-first: it runs a small server on your Mac at `http://127.0.0.1:8 integration talks to *that* — your memory never leaves your machine unless you explicitly send it. This doc covers every way an external app can use your Cortex memory as context. -All requests authenticate with a scoped token in `Authorization: Bearer `. Tokens are minted -in the Cortex app (or `POST /v1/pair` / `POST /v1/integrations/mcp-token`) and carry scopes -(`read`, `write`, `export`, `maintenance`, `destructive`). **Advertisement is never authorization** — -every call is re-checked against the token's scopes and your Connections & Privacy trust toggles. +Cortex has two scoped token audiences, both sent as +`Authorization: Bearer `: + +- `cxa_` tokens authenticate the REST API, including `/v1/context`, + `/v1/search`, `/v1/ask`, and `/v1/tools/*`. Use these with the Python and + TypeScript SDKs. +- `cxm_` tokens authenticate only the MCP JSON-RPC endpoint at `/mcp` and its + stdio bridge. Do not put an MCP token in an SDK. + +Tokens are minted in the Cortex app (or the matching integration-token +endpoint) and carry scopes (`read`, `write`, `export`, `maintenance`, +`destructive`). **Advertisement is never authorization** — every call is +re-checked against the token's scopes and your Connections & Privacy trust +toggles. ## 1. MCP (Claude Desktop, Claude Code, Cursor, VS Code) @@ -61,7 +71,7 @@ MCP: Example (OpenAI Python): ```python import openai, requests -BASE, TOK = "http://127.0.0.1:8766", "" +BASE, TOK = "http://127.0.0.1:8766", "" tools = requests.get(f"{BASE}/v1/tools/schema?format=openai", headers={"Authorization": f"Bearer {TOK}"}).json()["schema"] resp = openai.chat.completions.create(model="...", messages=[...], tools=tools) # For each tool call, POST /v1/tools/call {name, arguments} and feed the result back. @@ -73,7 +83,7 @@ resp = openai.chat.completions.create(model="...", messages=[...], tools=tools) (`@doppl-tech/cortex-client`, fetch-based) wrap the above: ```python from cortex_client import CortexClient -cx = CortexClient(token="") # base_url defaults to loopback +cx = CortexClient(token="") # base_url defaults to loopback pack = cx.context("prep for the Acme sync") # cited context pack answer = cx.ask("what did we decide about pricing?") # cite-or-abstain tools = cx.openai_tools() # ready for tools=[...] diff --git a/docs/LOCAL_VAULT_FORMAT.md b/docs/LOCAL_VAULT_FORMAT.md index 3ef0df9f..2a31effb 100644 --- a/docs/LOCAL_VAULT_FORMAT.md +++ b/docs/LOCAL_VAULT_FORMAT.md @@ -49,11 +49,20 @@ Cortex.vault/ USER_ID/ SOURCE/ sync_*.json + sync_devices/ + USER_ID/ + device_*.json + sync_receipts/ + USER_ID/ + DEVICE_ID/ + receipt_*.json memories/ decision/ mem_*.json + decision-summary--a1b2c3d4e5f6.md style/ mem_*.json + writing-style--b2c3d4e5f6a1.md negative/ mem_*.json preference/ @@ -82,14 +91,34 @@ Cortex.vault/ import/ imp_*.json attachments/ + context_packs/ + HASH_PREFIX/ + SHA256.json backups/ cortex-vault-*.zip exports/ + People/ # generated, rebuildable Markdown maps + Projects/ + Orgs/ + Topics/ + Journal/ + Cortex — Start Here.md + Constellation.canvas + credentials.json # private; excluded from vault sync and backups ``` -## Source Of Truth +## Persistence and Authority + +The vault has three persistence layers with different jobs: -These files are canonical: +1. Human-readable Markdown is authoritative for accepted, user-editable memory + fields. +2. JSON is authoritative for every non-memory record and is the machine + companion/fallback for memories. +3. SQLite is a derived query index. It is never the only durable copy of a + record. + +The following JSON and JSONL files are canonical: - `manifest.json`: vault format, version, index role, and directory contract - `settings.json`: user behavior settings such as review flow and Ask memory depth @@ -97,14 +126,44 @@ These files are canonical: - `imports/**/*.json`: import session history, selected path summaries, source counts, bounded record previews, linked capture IDs, status, errors, and delete markers - `source_accounts/**/*.json`: connector account metadata, health state, policy metadata, last sync time, last error, and disconnect state - `sync_cursors/**/*.json`: incremental sync cursor values, high-water marks, per-cursor state, last completion time, and last error +- `sync_devices/**/*.json` and `sync_receipts/**/*.json`: durable sync-device state and applied-operation receipts - `captures/**/*.json`: raw source text and capture lifecycle state -- `memories/**/*.json`: extracted atomic memory records - `tasks/**/*.json`: open loops and questions - `entities/**/*.json`: people, projects, organizations, and topics - `graph_edges/**/*.json`: relationships between sources, memories, tasks, and entities - `deletion_tombstones/**/*.json`: hard-delete markers that prevent older backups from restoring explicitly deleted records - -`index.sqlite` is important but not canonical. If the index is damaged or deleted, Cortex can rebuild it from the vault records. +- `attachments/**/*`: user-owned attachment bytes linked from durable records + +`credentials.json` is a private working file containing connector credentials. +It is excluded from the generated sync ignore rules and from backup archives. +`context_packs/` contains immutable, content-addressed audit artifacts; those +packs may sync with the vault but are not restored as canonical user records. +The top-level map-of-content pages, `Journal/`, and `Constellation.canvas` are +generated views and can be regenerated from durable records. + +Memory records intentionally have a dual representation: + +- `memories/**/*.md` is the user-facing memory. Its body is `content`; accepted + YAML frontmatter fields include summary, kind/layer, confidence, importance, + status, source references, topics, entity IDs, and temporal fields. +- `memories/**/*.json` preserves the complete machine record and provides + system fields or values not accepted from Markdown. It is also the recovery + fallback if a note is missing or cannot be parsed. + +On rebuild, records are joined by memory `id`. JSON is loaded first and the +parsed Markdown fields are overlaid on it, so Markdown wins when the same +accepted field exists in both. Fields intentionally projected or stripped by +the Markdown parser—such as generated `tags`, derived `trust_score`, +`recorded_at`, and `superseded_at`—continue to come from JSON. Generated Links +and Backlinks sections are stripped while parsing and regenerated; editing +those generated blocks does not change indexed memory content. + +The `id`, `user_id`, and `capture_id` frontmatter values are identity metadata, +not ordinary content. Do not hand-edit them. A note whose `user_id` no longer +matches the account being rebuilt is outside that account's rebuild scope. + +`index.sqlite` is important but derived. If the index is damaged or deleted, +Cortex can rebuild it from the durable vault files using the precedence above. ## Record Guarantees @@ -124,6 +183,11 @@ Import records describe a batch created from selected local paths. They keep pat Memory records include both `kind` and `layer`. `kind` preserves the atomic type (`claim`, `decision`, `event`, `preference`, `style`, `negative`, `observation`, or `summary`), while `layer` controls retrieval behavior (`semantic`, `episodic`, `style`, `decision`, `preference`, or `negative`). +Normal Cortex writes persist the JSON companion first and then render the +Markdown note. A Markdown-rendering failure does not invalidate an already +durable JSON write; the next backfill can recreate the note. Conversely, a +valid Markdown-only memory can be added to SQLite during reconciliation. + ## Rebuild Flow Use the maintenance endpoint: @@ -134,15 +198,33 @@ POST /v1/maintenance/rebuild-index-from-vault The rebuild process: -1. Reads import sessions, source accounts, sync cursors, captures, memories, tasks, entities, graph edges, settings, and events from the vault. -2. Clears the current user's SQLite index rows. -3. Re-inserts normalized records. -4. Rebuilds FTS rows. -5. Rebuilds vector rows when `sqlite-vec` is available. -6. Appends an `index.rebuilt_from_vault` event. +1. Applies deletion tombstones before loading records. +2. Reads import sessions, source accounts, sync cursors, captures, tasks, + entities, graph edges, settings, and events from JSON/JSONL. +3. Reads memory Markdown and memory JSON, merges matching IDs with Markdown + precedence, and retains JSON-only memories as a recovery fallback. +4. Clears the current user's derived SQLite rows. +5. Re-inserts normalized records and rebuilds FTS rows. +6. Rebuilds vector rows when `sqlite-vec` is available. +7. Appends an `index.rebuilt_from_vault` event. This makes support and disaster recovery straightforward: preserve the vault folder, rebuild the index. +### Hand edits and deletion + +Use the vault reconciliation action after editing Markdown. Once the one-time +Markdown backfill has completed, reconciliation can treat a genuinely missing +note as a requested deletion. A corrupt-but-present note is preserved for +recovery, and a safety threshold blocks a suspicious mass disappearance +instead of tombstoning most of a vault. + +Deleting only a `.md` file and immediately calling the raw rebuild endpoint is +not a hard delete: the rebuild deliberately retains a JSON-only memory. Use +Cortex's delete flow (or successful post-backfill reconciliation), which +removes both representations and writes a deletion tombstone. Tombstones take +precedence during restore and rebuild so an older backup cannot resurrect a +hard-deleted record. + ## Legacy Migration Older local builds used: @@ -169,7 +251,7 @@ The backend then backfills an empty vault from the copied index by writing captu - import sessions - source accounts and sync cursors - captures -- memories +- memory Markdown notes and JSON companions - tasks - entities - graph edges @@ -179,11 +261,15 @@ The backend then backfills an empty vault from the copied index by writing captu Backups intentionally exclude prior backups to avoid recursive archives. -Hard-delete operations remove the selected memory, capture, or import batch from the current SQLite index and current vault JSON records. They also write a deletion tombstone so an older backup cannot silently restore that item later. Capture tombstones also block derived memories, tasks, and graph edges from being restored. Import tombstones block the import session plus captures and derived records linked through `import_id`. +Hard-delete operations remove the selected memory, capture, or import batch from the current SQLite index and current vault files, including both representations of a memory. They also write a deletion tombstone so an older backup cannot silently restore that item later. Capture tombstones also block derived memories, tasks, and graph edges from being restored. Import tombstones block the import session plus captures and derived records linked through `import_id`. Backup archives can be removed with `DELETE /v1/backups`, and `DELETE /v1/user-data` removes current user records plus backups by default. New backups also run count-based pruning using `CORTEX_BACKUP_RETENTION_COUNT` (default `20`) and optional age-based pruning with `CORTEX_BACKUP_RETENTION_DAYS` (default `0`, disabled). If a user explicitly keeps backups during a full local reset, item-level tombstones are preserved so future restore operations still honor earlier hard deletes. -`POST /v1/backups/restore-latest` restores canonical vault records from the newest backup archive, validates member paths before extraction, skips the archived SQLite snapshot, reapplies current deletion tombstones, removes tombstoned records, and rebuilds the current SQLite index from the restored JSON/JSONL files. +`POST /v1/backups/restore-latest` restores durable vault records from the newest +backup archive, validates member paths before extraction, skips the archived +SQLite snapshot, reapplies current deletion tombstones, removes tombstoned +records, and rebuilds the index from restored Markdown memories plus the +remaining JSON/JSONL records. ## Privacy Notes diff --git a/docs/MCP_INTEGRATIONS.md b/docs/MCP_INTEGRATIONS.md index fd3e1d02..fff7c621 100644 --- a/docs/MCP_INTEGRATIONS.md +++ b/docs/MCP_INTEGRATIONS.md @@ -28,8 +28,8 @@ catalog, so agents pick the right tool on the first try: - `get_context` — the context assembly engine: a token-budgeted, cited pack of constraints, decisions, facts, entity context, procedures, identity, open loops, and recency, shaped by task intent (`answer`/`act`/`draft`/`plan`/`recall`). Call this first before doing work. - Also available over REST as `GET`/`POST /v1/context` (read scope; the identity layer alone - requires export scope and degrades to a visible omission record without it). + Also available over REST as `GET`/`POST /v1/context`; the complete pack, including its + distilled identity layer, requires read scope. - `ask_memory` — cite-or-abstain answer to a specific question (never an uncited guess). - `search_memory` — keyword/semantic search with retrieval diagnostics. - `get_entity_context` — everything known about one person/project/org/topic + its graph diff --git a/docs/NAMING_AND_OWNERSHIP.md b/docs/NAMING_AND_OWNERSHIP.md new file mode 100644 index 00000000..7d548476 --- /dev/null +++ b/docs/NAMING_AND_OWNERSHIP.md @@ -0,0 +1,59 @@ +# Naming and Ownership + +Status: **Current** + +Last reviewed: 2026-07-30 + +This repository contains historical names from more than one distribution +surface. Use the following map instead of inventing another namespace. + +| Name | Meaning | Where it is canonical | +|---|---|---| +| Cortex | Product and open-source project | UI, docs, Python modules, `CORTEX_*` configuration | +| `trace-cortex/cortex-app` | Canonical source repository | Source links, issues, contribution docs | +| `doppl-tech/releases` | Current signed binary release repository | Update and direct-download metadata | +| `api.signindoppl.com` | Current hosted API/auth origin | Hosted examples and deployment configuration | +| `doppl-cortex-client` | Python SDK distribution name | Python package metadata and install commands | +| `@doppl-tech/cortex-client` | Planned TypeScript SDK distribution name | npm package metadata and install commands | +| `@doppl-tech/openclaw-context` | Planned OpenClaw context-engine adapter distribution name | npm package metadata and OpenClaw install docs | +| `@doppl/cortex-obsidian-plugin` | Private, unpublished historical workspace package name | Obsidian plugin build metadata only | +| `com.cortex.doppl` | Existing signed macOS bundle identifier | Apple signing, updates, and release manifests | + +The `doppl-tech` package namespace, binary-release repository, hosted domain, +and bundle ID are distribution identifiers; they do not rename the open-source +project. The planned public npm packages use `@doppl-tech` because `@cortex` +belongs to an unrelated registry account. Registry availability is not +ownership: the release operator must create or verify control of the +`@doppl-tech` npm organization before publishing. The private Obsidian package +keeps its historical name until it has a public release plan; if published, it +should migrate to the same verified public scope. + +New environment variables, user-facing feature names, and protocol fields +should use `Cortex` unless a package registry or release surface requires one +of the distribution identifiers above. Do not casually change the macOS bundle +ID: signed-app identity, Keychain access, updates, and upgrades depend on it. + +## Token Prefixes + +Prefixes communicate credential type but are not authorization by themselves: + +| Prefix | Credential | +|---|---| +| `cxa_` | Scoped Cortex HTTP API token | +| `cxm_` | Scoped MCP token | +| `cxs_` | Hosted account access token | +| `cxr_` | Hosted account refresh token | + +Examples must use the prefix matching the surface. Never use a real token in +documentation, tests, issue reports, or support bundles. + +## Ownership Boundaries + +- Maintainers of this repository own source compatibility and review. +- Release operators own signing identities, notarization, hosted secrets, + domains, email delivery, and recovery drills. +- Legal owners must approve terms, privacy language, entity names, and + jurisdiction. Source review cannot substitute for that approval. +- Connector providers own their upstream APIs. A connector is not production + ready until its live contract has been exercised with a release-owned test + account. diff --git a/docs/OPEN_SOURCE_READINESS.md b/docs/OPEN_SOURCE_READINESS.md index d5952837..b69c1be7 100644 --- a/docs/OPEN_SOURCE_READINESS.md +++ b/docs/OPEN_SOURCE_READINESS.md @@ -2,89 +2,113 @@ Last reviewed: 2026-07-30 -This is an engineering self-assessment of the repository, not a security -certification. The review used only the checked-out source tree and local -tooling. It did not depend on repository-administrator access, hosted secrets, -or live connector accounts. - -## Outcome - -The source tree is substantially easier to understand, run, test, and -contribute to. It is ready for public code review and community development. -The hosted service and signed release process remain **conditionally ready**: -the code now enforces important trust boundaries, but the external release -configuration and operational drills listed below still need owner evidence. - -## Scorecard - -Scores are a directional rubric from 0 (absent) to 100 (excellent), based on -repository evidence. - -| Area | Before | Current | Evidence | -|---|---:|---:|---| -| Documentation accuracy | 72 | 91 | Status-labelled docs, corrected account/offline/release claims, FAQ, troubleshooting | -| First-run developer experience | 35 | 89 | Python 3.12 bootstrap, lock file, root `make` workflow, actionable failures | -| Contribution experience | 15 | 88 | Contributing, conduct, support, security policy, issue forms, PR template | -| Examples and evaluation | 28 | 85 | Four runnable examples and reproducible benchmark report | -| CI coverage | 67 | 86 | Backend, SDK, OpenClaw, plugin, docs, distribution, security, and quality gates | -| Security defaults | 58 | 82 | Hosted path/origin boundaries, encrypted backups, safer public auth defaults | -| Project discoverability | 45 | 63 | Clear README navigation and project status; hosted repository metadata remains external | - -## Material Improvements - -- Made Python 3.12 setup reproducible through `make setup` and - `requirements-dev.lock`. -- Added a concise public contribution path, community standards, issue forms, - support boundaries, roadmap, and changelog. -- Added runnable Python examples for search, memory workflows, multi-agent - context, and tool discovery. -- Published deterministic benchmark results with exact reproduction commands - and limitations. -- Added CI coverage for both SDKs and the OpenClaw context plugin. -- Corrected public documentation that overstated offline, account-free, update, - and hosted behavior. -- Blocked hosted tenants from local filesystem connectors and arbitrary - connector origins at both HTTP and storage execution boundaries. -- Changed deployment backups to fail-closed `age` encryption and removed - environment secrets from backup archives. - -## Local Verification - -The fast contributor gate, documentation checks, deterministic benchmarks, -Python SDK tests, dependency consistency check, shell syntax checks, Python -compilation, CI YAML parsing, and focused hosted-security regressions pass. - -The complete backend suite was also attempted in the restricted review -environment. A clean run excluding only the real-localhost-server modules -passed 1,877 cases and all 407 parameterized subtests (7 skipped); a focused -scheduled-connector and hosted-boundary pass added 134 passing cases and 34 -subtests. Modules that start real localhost servers cannot bind sockets in that -environment, so those modules require a normal local shell or CI runner for -final confirmation. This is an environment restriction, not a request for -broader repository permissions. - -## Remaining Release Blockers - -1. Have counsel replace the legal-entity and jurisdiction placeholders and - approve the privacy policy and terms. -2. Validate managed OAuth, email delivery, domain configuration, and connector - contracts with release-owned accounts. -3. Run an encrypted backup restore drill, including separately escrowed - database and `age` recovery keys. -4. Run the complete socket-enabled backend suite and JavaScript package gates - in CI or a normal development shell. -5. Exercise signed artifact download, signature, notarization, and update - handoff against the actual release artifacts. -6. Replace synthetic-only quality claims with consented human judgments, - repeated trials, confidence intervals, and scale/latency curves. -7. Configure repository topics, description, homepage, ownership rules, and - newcomer issues through the hosting service. -8. Capture current-product screenshots and a short demo after the next signed - build so visual documentation reflects the actual shipped UI. - -## Recommendation - -**Approve for an open-source development branch and external review. Do not -represent the hosted service as fully production-approved yet.** Production -approval should follow once the legal, credentialed integration, disaster -recovery, socket-enabled CI, and signed-release evidence above is recorded. +Review posture: independent, reject by default + +Review baseline: source commit `8eedd5c` on the local +`docs/repo-refresh` branch. The corrections described below are the review +delta from that baseline; this document must be updated with a new baseline +before it is reused as evidence for a later release. + +This is an evidence-based engineering review of the checked-out source tree, +not a security certification. It used local source and local test tooling only; +it did not require repository administration, hosted secrets, or live +connector accounts. + +## Verdict + +**Accept for open-source development and external code review. Reject for an +unqualified production-hosted launch.** + +The repository now has credible local setup, tests, examples, security +boundaries, and operator documentation. Production approval is still blocked +by unverified external controls and by architectural work that should not be +hidden behind documentation claims. + +## What Was Corrected + +- Cross-origin redirects can no longer carry first-party connector, OAuth, + model-provider, delivery, OIDC, or SDK bearer credentials to another origin. +- Webhook delivery connects to the exact public address that passed validation, + retaining the original hostname for HTTP and TLS verification. +- All vault mutations now participate in the same inter-process freeze used by + backup, restore, and account deletion. Regression tests cover aggregate + multi-process writes, ordinary record writes, and direct Markdown pruning. +- ZIP imports enforce both member-count and aggregate uncompressed-byte + budgets, including nested archives. +- Password hashing concurrency and in-process rate-limiter cardinality are + bounded. +- Hosted readiness fails closed when credential encryption enforcement or its + runtime keyring is absent, the complete credential scan is truncated or + unreadable, or any plaintext credential remains. +- Large synchronous exports use a conservative Unicode-aware storage preflight, + default to a 25 MB source-data cap, and fail with a bounded `413` response + instead of materializing an unbounded corpus. +- Backup snapshots coordinate with vault writers and publish atomically. +- Public signup fails closed until the operator records legal approval; password + and OAuth signup consent is explicit and OAuth consent is bound into the + single-use state. +- `cxa_` HTTP API tokens work on the REST-style `/v1/tools/*` surface while + `/mcp` remains restricted to `cxm_` MCP tokens. +- `POST /v1/context` is typed and bounded. OpenAPI operation IDs are stable + method/path identifiers, unique in tests, and protected routes publish a + bearer security scheme. +- Mac mini setup uses a dedicated Python 3.12 virtual environment. +- Examples include an idempotent, loopback-only synthetic seed. +- Privacy and terms copy no longer says all memory content is encrypted when + the implementation currently guarantees encryption only for hosted + connector credentials. +- SDK/plugin licenses and token examples now agree with repository policy. + +## Priority Findings + +| Priority | Finding | Current disposition | +|---|---|---| +| P0 | Multi-process vault read-modify-write lost data | Fixed and regression-tested | +| P0 | Public privacy copy overstated encryption scope | Corrected; legal approval still required | +| P1 | Credential headers could cross origins on redirects | Fixed across backend and SDK transports | +| P1 | Hosted credential encryption could fail open | Readiness now requires complete runtime scan evidence and zero remaining plaintext records | +| P1 | ZIP and export paths could exhaust memory | ZIP fixed; synchronous export bounded; streaming/background export remains future work | +| P1 | Argon2 bursts could exhaust process memory | Per-process concurrency bounded; deployment worker count still needs capacity planning | +| P1 | Backup could race with vault writes | Fixed with coordinated locks and atomic archive publication | +| P1 | OAuth could create an account without explicit current legal consent | Fixed with signup gating and consent-bound single-use state | +| P1 | Webhook DNS could change after SSRF validation | Fixed by pinning the validated address through the connection | +| P1 | Existing memory-count quota is check-before-write, raceable, and batch/queue blind; stored bytes are unbounded | Open production blocker; move reservation/enforcement into the storage transaction | +| P1 | Export preflight and serialization do not share one database snapshot; portable proof re-exports | Open consistency and memory-safety blocker | +| P1 | Signup consent is not stored with policy version/hash and acceptance time | Open legal-audit blocker | +| P1 | `backend/app/storage.py` is an oversized subsystem with broad responsibilities | Open maintainability risk; split only with characterization tests | +| P2 | Python runtime dependencies allow version ranges | Open reproducibility risk; audit now covers the runtime manifest | +| P2 | Several GitHub Actions use mutable major-version tags | Open supply-chain hardening item; pin reviewed commit SHAs | + +## Evidence Required Before Production Approval + +1. Replace route-level memory quota checks with atomic storage-transaction + reservation/enforcement based on actual new active IDs; cover concurrent + captures, multi-memory extraction, sync batches, idempotent upserts, and + queued jobs. Add an enforced stored-byte quota as part of the same control. +2. Run the credential migration and retain release evidence that hosted + readiness observed a complete, readable scan with zero plaintext records. +3. Put export estimate, filtering, imports, stats, payload, and portable proof + on one database snapshot; derive the proof from the already-built payload, + then replace large in-memory exports with streaming or background jobs. +4. Persist the accepted Terms/Privacy version, policy hash, age assertion, and + acceptance timestamp on the account and in the audit event. +5. Characterize and split the storage/API god modules without changing their + contracts. +6. Lock packaged runtime dependencies and pin third-party CI actions to reviewed + commit SHAs. +7. Have counsel approve legal entity, jurisdiction, terms, and privacy copy. +8. Exercise OAuth, email delivery, domains, and every supported connector with + release-owned accounts. +9. Complete an encrypted backup/restore drill with separately escrowed keys. +10. Verify signed artifacts, notarization, update metadata, and rollback on the + actual release channel. +11. Run load/soak tests using the intended worker count, storage topology, and + realistic corpus sizes. + +## Review Standard + +Production approval requires evidence, not configuration-shaped objects or +optimistic prose. A test that simulates a keyring is useful for contract +coverage but is not proof that a deployment holds the right key. Likewise, +synthetic retrieval benchmarks guard regressions but do not establish product +quality for a diverse user population. diff --git a/docs/PORTABLE_MEMORY_PROTOCOL_V2.md b/docs/PORTABLE_MEMORY_PROTOCOL_V2.md index 99b56b8a..cdc1a65c 100644 --- a/docs/PORTABLE_MEMORY_PROTOCOL_V2.md +++ b/docs/PORTABLE_MEMORY_PROTOCOL_V2.md @@ -4,7 +4,7 @@ Status: **published implementation specification** Protocol name: `cortex-portable-memory` Protocol version: `2` Outer bundle version: `v3` -Reference implementations: Cortex Python backend and `@cortex/openclaw-context` TypeScript verifier +Reference implementations: Cortex Python backend and `@doppl-tech/openclaw-context` TypeScript verifier ## 1. Purpose diff --git a/docs/README.md b/docs/README.md index 0db03929..5e2d52b4 100644 --- a/docs/README.md +++ b/docs/README.md @@ -2,7 +2,9 @@ This index separates the current product contract from operator runbooks, forward-looking designs, and experimental feature branches. It was last audited -against `main` on 2026-07-30. +against source commit `8eedd5c` plus the local `docs/repo-refresh` review delta +on 2026-07-30. It does not claim that unmerged branch content is already in +`main`. ## Status Rules @@ -25,6 +27,7 @@ When documents disagree, use this order of authority: | Area | Documentation | |---|---| | Architecture | [ARCHITECTURE.md](ARCHITECTURE.md) | +| API lifecycle and naming | [API_LIFECYCLE.md](API_LIFECYCLE.md) · [NAMING_AND_OWNERSHIP.md](NAMING_AND_OWNERSHIP.md) | | Product loop and onboarding | [SIMPLE_PRODUCT_LOOP.md](SIMPLE_PRODUCT_LOOP.md) · [FIRST_RUN_ONBOARDING.md](FIRST_RUN_ONBOARDING.md) | | Local vault | [LOCAL_VAULT_FORMAT.md](LOCAL_VAULT_FORMAT.md) | | Context protocol | [CMP_PROTOCOL.md](CMP_PROTOCOL.md) · [PORTABLE_MEMORY_PROTOCOL_V2.md](PORTABLE_MEMORY_PROTOCOL_V2.md) | diff --git a/docs/benchmarks/2026-07-30-local.json b/docs/benchmarks/2026-07-30-local.json new file mode 100644 index 00000000..da7c248c --- /dev/null +++ b/docs/benchmarks/2026-07-30-local.json @@ -0,0 +1,83 @@ +{ + "schema_version": 1, + "recorded_at": "2026-07-30", + "purpose": "deterministic regression snapshot; not a production latency or quality claim", + "source": { + "baseline_commit": "8eedd5ceab5c1410c714a0c1ee19f7b1a25864e5", + "working_tree_dirty": true, + "branch": "docs/repo-refresh" + }, + "environment": { + "python": "3.12.13", + "os": "Darwin 25.5.0 arm64", + "hardware": "Apple M1 Pro, 10 cores, 16 GB RAM", + "network_used": false, + "model_api_calls": false + }, + "method": { + "repetitions": 1, + "timing_captured": false, + "cache_state": "not controlled", + "configuration": "repository defaults and checked-in synthetic fixtures" + }, + "harness_sha256": { + "scripts/retrieval_eval.py": "142fe72f413e6512cd0438ec64871012ecbeeb8e4c7a8aceef43faed5fe2636c", + "scripts/context_pack_eval.py": "5afda9a70d77264778b13a504f7d2668543031c0a0d2d7e53bc01cef6b6146fd", + "scripts/adaptation_eval.py": "35f5ebf44d01cfd5adf49b557b2ea1c1791ddc97182f901928490457cc7c53b9", + "scripts/token_calibration_eval.py": "ff7952abcdae2f99cdd7aed0d3dbca7ee5fd3752d152b4361ff74a60b09e1f0d", + "scripts/token_calibration_fixture.json": "138b82a0c45fb667cee00c11f91fcc2c067676a16522b571605621035a57838e" + }, + "results": { + "retrieval": { + "status": "ok", + "cases": 163, + "top1_accuracy": 1.0, + "recall_at_3": 1.0, + "precision_at_3": 0.947 + }, + "relevance_monotonicity": { + "status": "ok", + "cases": 160, + "pairs": 41, + "pairwise_concordance": 1.0, + "reordered_top1": 1.0 + }, + "context_packing": { + "status": "ok", + "tasks": 10, + "checks": 30, + "citation_coverage": 1.0, + "mrr": 1.0, + "ndcg_at_k": 0.888, + "no_leak": 1.0, + "budget_adherence": 1.0 + }, + "session_replay": { + "status": "ok", + "sessions": 2, + "turns": 9, + "recall_at_3": 1.0, + "prefetch_hit_rate": 0.75, + "delta_token_savings": 0.285, + "no_resend_or_leak": 1.0 + }, + "adaptation": { + "status": "ok", + "seeded_memories": 7, + "derived_rules": 10, + "readiness_score": 100 + }, + "token_calibration": { + "status": "ok", + "mean_profile_mape": 0.015815, + "flat_estimator_mape": 0.074255, + "relative_improvement": 0.787018 + } + }, + "limitations": [ + "The run predates the final review commit and records a dirty-tree delta from the baseline.", + "Only one repetition was run, so variance and confidence intervals are unavailable.", + "Wall-clock timing and controlled cold/warm cache state were not captured.", + "The fixtures are synthetic and do not establish real-user answer quality." + ] +} diff --git a/examples/README.md b/examples/README.md index 3bc45c9d..b8a998f9 100644 --- a/examples/README.md +++ b/examples/README.md @@ -10,20 +10,31 @@ Terminal 1: ```bash make setup -make run +CORTEX_AUTO_APPROVE_CAPTURES=1 make run ``` Terminal 2: ```bash export CORTEX_API_KEY=dev-local-key -.venv/bin/python examples/minimal_search.py "release checklist" + +.venv/bin/python examples/seed_demo.py +.venv/bin/python examples/minimal_search.py "When does Project Atlas ship?" ``` `dev-local-key` is accepted only because `make run` starts a loopback-only -development server with the explicit insecure-development flag. For the -packaged app, copy a scoped token from Connections & Privacy and never commit -it. +development server with the explicit insecure-development flag. +`seed_demo.py` refuses to write anywhere except a loopback origin and +refuses non-development tokens. Automated isolated test harnesses that +deliberately use a different token must opt in with +`CORTEX_ALLOW_DEMO_SEED=1`. The seeder idempotently loads three synthetic +records from +[`fixtures/demo_captures.json`](fixtures/demo_captures.json). For the +packaged app, copy a scoped `cxa_` REST token from Connections & Privacy for +the read-only examples and never commit it; do not run the demo seeder against +a packaged or personal vault. The fixture includes a release decision, a +storage decision, and an explicit +writing preference, all about or usable with the Project Atlas example task. ## Examples @@ -35,7 +46,9 @@ it. | [`tool_catalog.py`](tool_catalog.py) | Fetch the OpenAI-compatible tool catalog without calling a model | Every script accepts `CORTEX_BASE_URL` and `CORTEX_API_KEY`. The default base -URL is `http://127.0.0.1:8766`. +URL is `http://127.0.0.1:8766`; the query/task examples consistently default +to Project Atlas so the checked-in fixture produces deterministic, cited +output without extra arguments. ## Integration Pattern diff --git a/examples/fixtures/demo_captures.json b/examples/fixtures/demo_captures.json new file mode 100644 index 00000000..f6d23d49 --- /dev/null +++ b/examples/fixtures/demo_captures.json @@ -0,0 +1,26 @@ +[ + { + "capture_id_override": "cap_demo_project_atlas_release", + "captured_at": "2026-07-01T12:00:00+00:00", + "content": "Project Atlas ships on Thursday after the rollback drill. The release owner is Mina.", + "source": "cortex-demo", + "source_url": "https://example.invalid/cortex-demo/project-atlas", + "title": "Project Atlas release decision" + }, + { + "capture_id_override": "cap_demo_storage_decision", + "captured_at": "2026-07-02T12:00:00+00:00", + "content": "We decided to keep the local vault as the source of truth and treat the SQLite search index as rebuildable.", + "source": "cortex-demo", + "source_url": "https://example.invalid/cortex-demo/storage", + "title": "Storage architecture decision" + }, + { + "capture_id_override": "cap_demo_writer_preference", + "captured_at": "2026-07-03T12:00:00+00:00", + "content": "I prefer launch updates that use short paragraphs, lead with the decision, and include links to primary evidence.", + "source": "cortex-demo", + "source_url": "https://example.invalid/cortex-demo/writing", + "title": "Launch writing preference" + } +] diff --git a/examples/memory_workflow.py b/examples/memory_workflow.py index cfbe7cf6..1def2d59 100644 --- a/examples/memory_workflow.py +++ b/examples/memory_workflow.py @@ -17,7 +17,7 @@ def show(label: str, value: Any) -> None: def main() -> None: - task = " ".join(sys.argv[1:]).strip() or "Draft a release checklist from prior decisions" + task = " ".join(sys.argv[1:]).strip() or "When does Project Atlas ship?" client = CortexClient( base_url=os.environ.get("CORTEX_BASE_URL", "http://127.0.0.1:8766"), token=os.environ.get("CORTEX_API_KEY", ""), diff --git a/examples/minimal_search.py b/examples/minimal_search.py index 70945a15..4d9d3a3f 100644 --- a/examples/minimal_search.py +++ b/examples/minimal_search.py @@ -11,7 +11,7 @@ def main() -> None: - query = " ".join(sys.argv[1:]).strip() or "release checklist" + query = " ".join(sys.argv[1:]).strip() or "When does Project Atlas ship?" client = CortexClient( base_url=os.environ.get("CORTEX_BASE_URL", "http://127.0.0.1:8766"), token=os.environ.get("CORTEX_API_KEY", ""), diff --git a/examples/multi_agent_context.py b/examples/multi_agent_context.py index 2962c07e..9c5ab800 100644 --- a/examples/multi_agent_context.py +++ b/examples/multi_agent_context.py @@ -18,7 +18,7 @@ def main() -> None: - task = " ".join(sys.argv[1:]).strip() or "Prepare the next Cortex beta announcement" + task = " ".join(sys.argv[1:]).strip() or "Prepare the Project Atlas release update" client = CortexClient( base_url=os.environ.get("CORTEX_BASE_URL", "http://127.0.0.1:8766"), token=os.environ.get("CORTEX_API_KEY", ""), diff --git a/examples/seed_demo.py b/examples/seed_demo.py new file mode 100644 index 00000000..69c4d756 --- /dev/null +++ b/examples/seed_demo.py @@ -0,0 +1,96 @@ +#!/usr/bin/env python3 +"""Idempotently seed synthetic data into a loopback-only Cortex dev server.""" + +from __future__ import annotations + +import json +import os +from pathlib import Path +import sys +from urllib.error import HTTPError, URLError +from urllib.parse import urlsplit +from urllib.request import HTTPRedirectHandler, Request, build_opener + + +FIXTURE = Path(__file__).with_name("fixtures") / "demo_captures.json" +LOOPBACK_HOSTS = {"127.0.0.1", "::1", "localhost"} + + +class _NoRedirect(HTTPRedirectHandler): + def redirect_request(self, req, fp, code, msg, headers, newurl): # type: ignore[override] + return None + + +def _validated_base_url(raw: str) -> str: + value = raw.strip().rstrip("/") + parsed = urlsplit(value) + try: + _ = parsed.port + except ValueError as exc: + raise ValueError(f"invalid CORTEX_BASE_URL port: {exc}") from exc + if ( + parsed.scheme not in {"http", "https"} + or parsed.hostname not in LOOPBACK_HOSTS + or parsed.username is not None + or parsed.password is not None + or parsed.path not in {"", "/"} + or parsed.query + or parsed.fragment + ): + raise ValueError( + "seed_demo.py only writes to an absolute loopback Cortex origin " + "(for example http://127.0.0.1:8766)" + ) + return value + + +def main() -> int: + try: + base_url = _validated_base_url( + os.environ.get("CORTEX_BASE_URL", "http://127.0.0.1:8766") + ) + except ValueError as exc: + print(f"error: {exc}", file=sys.stderr) + return 2 + token = os.environ.get("CORTEX_API_KEY", "dev-local-key").strip() + if token != "dev-local-key" and os.environ.get("CORTEX_ALLOW_DEMO_SEED") != "1": + print( + "error: seed_demo.py requires the canonical dev-local-key; " + "set CORTEX_ALLOW_DEMO_SEED=1 only for an intentional isolated test server", + file=sys.stderr, + ) + return 2 + captures = json.loads(FIXTURE.read_text(encoding="utf-8")) + opener = build_opener(_NoRedirect()) + + saved = 0 + for capture in captures: + request = Request( + f"{base_url}/v1/captures", + data=json.dumps(capture).encode("utf-8"), + method="POST", + headers={ + "Accept": "application/json", + "Authorization": f"Bearer {token}", + "Content-Type": "application/json", + }, + ) + try: + with opener.open(request, timeout=10) as response: + payload = json.loads(response.read().decode("utf-8")) + except HTTPError as exc: + detail = exc.read().decode("utf-8", "replace") + print(f"error: Cortex returned HTTP {exc.code}: {detail}", file=sys.stderr) + return 1 + except URLError as exc: + print(f"error: could not reach Cortex at {base_url}: {exc.reason}", file=sys.stderr) + return 1 + saved += 1 + print(f"seeded {payload.get('capture_id', capture['capture_id_override'])}: {capture['title']}") + + print(f"done: {saved} deterministic synthetic captures are ready") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/packages/obsidian-cortex-plugin/package-lock.json b/packages/obsidian-cortex-plugin/package-lock.json index a4bf740e..e2b89c91 100644 --- a/packages/obsidian-cortex-plugin/package-lock.json +++ b/packages/obsidian-cortex-plugin/package-lock.json @@ -7,7 +7,7 @@ "": { "name": "@doppl/cortex-obsidian-plugin", "version": "0.1.0", - "license": "UNLICENSED", + "license": "MIT", "devDependencies": { "@types/node": "^24.0.10", "esbuild": "^0.28.1", diff --git a/packages/obsidian-cortex-plugin/package.json b/packages/obsidian-cortex-plugin/package.json index 720de657..cc510134 100644 --- a/packages/obsidian-cortex-plugin/package.json +++ b/packages/obsidian-cortex-plugin/package.json @@ -3,7 +3,7 @@ "version": "0.1.0", "private": true, "description": "Obsidian plugin package for syncing a local vault into Cortex memory.", - "license": "UNLICENSED", + "license": "MIT", "type": "module", "main": "main.js", "scripts": { diff --git a/packages/openclaw-cortex-context/README.md b/packages/openclaw-cortex-context/README.md index 70753779..61ba6d27 100644 --- a/packages/openclaw-cortex-context/README.md +++ b/packages/openclaw-cortex-context/README.md @@ -1,4 +1,4 @@ -# `@cortex/openclaw-context` +# `@doppl-tech/openclaw-context` Reference OpenClaw `contextEngine` adapter for [Cortex](../../README.md). It has two modes: @@ -119,7 +119,7 @@ Bundle-mode context also exposes the verified signer id, source-tenant SHA-256 b The independent TypeScript protocol verifier is also exported: ```ts -import { verifyPortableMemoryBundle } from "@cortex/openclaw-context/portable"; +import { verifyPortableMemoryBundle } from "@doppl-tech/openclaw-context/portable"; const result = verifyPortableMemoryBundle(bundle, expectedSigningKeyId); if (!result.verified) throw new Error(result.error ?? "verification failed"); diff --git a/packages/openclaw-cortex-context/package-lock.json b/packages/openclaw-cortex-context/package-lock.json index 9686db47..d4ce816a 100644 --- a/packages/openclaw-cortex-context/package-lock.json +++ b/packages/openclaw-cortex-context/package-lock.json @@ -1,11 +1,11 @@ { - "name": "@cortex/openclaw-context", + "name": "@doppl-tech/openclaw-context", "version": "0.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "@cortex/openclaw-context", + "name": "@doppl-tech/openclaw-context", "version": "0.1.0", "license": "MIT", "devDependencies": { diff --git a/packages/openclaw-cortex-context/package.json b/packages/openclaw-cortex-context/package.json index 5d830999..7bb4ac7e 100644 --- a/packages/openclaw-cortex-context/package.json +++ b/packages/openclaw-cortex-context/package.json @@ -1,5 +1,5 @@ { - "name": "@cortex/openclaw-context", + "name": "@doppl-tech/openclaw-context", "version": "0.1.0", "description": "OpenClaw contextEngine adapter for live Cortex and signed portable-memory bundles.", "type": "module", @@ -44,6 +44,9 @@ "url": "https://github.com/trace-cortex/cortex-app.git", "directory": "packages/openclaw-cortex-context" }, + "publishConfig": { + "access": "public" + }, "peerDependencies": { "openclaw": ">=2026.6.11" }, diff --git a/scripts/bootstrap_dev.sh b/scripts/bootstrap_dev.sh index c135b4ae..102253f6 100755 --- a/scripts/bootstrap_dev.sh +++ b/scripts/bootstrap_dev.sh @@ -43,16 +43,16 @@ if [[ "$CHECK_ONLY" == "1" ]]; then fi "$PYTHON_BIN" -m venv "$VENV_PATH" -"$VENV_PATH/bin/python" -m pip install --upgrade pip "$VENV_PATH/bin/python" -m pip install -r "$ROOT/requirements-dev.lock" "$VENV_PATH/bin/python" -m pip install -e "$ROOT/sdk/python" cat < int: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: + sock.bind(("127.0.0.1", 0)) + return int(sock.getsockname()[1]) + + +def _run_example(path: str, env: dict[str, str], *arguments: str) -> str: + completed = subprocess.run( + [sys.executable, path, *arguments], + cwd=ROOT, + env=env, + check=True, + capture_output=True, + text=True, + timeout=30, + ) + return completed.stdout + + +def _get_json(url: str, token: str) -> dict: + request = Request( + url, + headers={ + "Accept": "application/json", + "Authorization": f"Bearer {token}", + }, + ) + with urlopen(request, timeout=5) as response: # noqa: S310 - loopback test server + return json.loads(response.read().decode("utf-8")) + + +def _contains_demo_source(value: object) -> bool: + if isinstance(value, dict): + if value.get("source") == "cortex-demo": + return True + if "example.invalid/cortex-demo/" in str(value.get("source_url") or ""): + return True + return any(_contains_demo_source(item) for item in value.values()) + if isinstance(value, list): + return any(_contains_demo_source(item) for item in value) + return False + + +def _workflow_sections(output: str) -> dict[str, object]: + sections: dict[str, object] = {} + for chunk in output.split("\n## ")[1:]: + label, raw = chunk.split("\n", 1) + sections[label.strip()] = json.loads(raw.strip()) + return sections + + +def main() -> int: + port = _available_loopback_port() + base_url = f"http://127.0.0.1:{port}" + with tempfile.TemporaryDirectory(prefix="cortex-examples-") as temp: + vault = Path(temp) / "vault" + env = dict(os.environ) + env.update( + { + "CORTEX_ALLOW_INSECURE_DEV_TOKEN": "1", + "CORTEX_ALLOW_DEMO_SEED": "0", + "CORTEX_API_KEY": "dev-local-key", + "CORTEX_AUTO_APPROVE_CAPTURES": "1", + "CORTEX_BASE_URL": base_url, + "CORTEX_DB_PATH": str(vault / "index.sqlite"), + "CORTEX_VAULT_PATH": str(vault), + "PYTHONPATH": os.pathsep.join( + filter( + None, + [str(ROOT / "sdk" / "python"), env.get("PYTHONPATH", "")], + ) + ), + } + ) + server = subprocess.Popen( + [ + sys.executable, + "-m", + "uvicorn", + "backend.app.main:app", + "--host", + "127.0.0.1", + "--port", + str(port), + "--log-level", + "warning", + ], + cwd=ROOT, + env=env, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + try: + deadline = time.monotonic() + 15 + while True: + if server.poll() is not None: + raise RuntimeError(f"example server exited with status {server.returncode}") + try: + with urlopen(f"{base_url}/health", timeout=0.5) as response: # noqa: S310 + if response.status == 200: + break + except URLError: + if time.monotonic() >= deadline: + raise RuntimeError("example server did not become healthy in 15 seconds") + time.sleep(0.1) + + unsafe_seed_env = {**env, "CORTEX_API_KEY": "cxa_not_for_demo_seeding"} + unsafe_seed = subprocess.run( + [sys.executable, "examples/seed_demo.py"], + cwd=ROOT, + env=unsafe_seed_env, + check=False, + capture_output=True, + text=True, + timeout=10, + ) + if unsafe_seed.returncode != 2 or "requires the canonical dev-local-key" not in unsafe_seed.stderr: + raise AssertionError("seed_demo.py did not reject a non-development token") + + seed = _run_example("examples/seed_demo.py", env) + if "done: 3 deterministic synthetic captures are ready" not in seed: + raise AssertionError("seed_demo.py did not report all fixture captures") + first_stats = _get_json(f"{base_url}/v1/stats", "dev-local-key") + second_seed = _run_example("examples/seed_demo.py", env) + if "done: 3 deterministic synthetic captures are ready" not in second_seed: + raise AssertionError("seed_demo.py was not repeatable") + second_stats = _get_json(f"{base_url}/v1/stats", "dev-local-key") + stable_counts = ("captures", "memories", "decisions", "tasks", "entities", "edges") + if any(first_stats.get(key) != second_stats.get(key) for key in stable_counts): + raise AssertionError( + "seed_demo.py changed stored record counts when run a second time: " + f"{first_stats} -> {second_stats}" + ) + + search = json.loads(_run_example("examples/minimal_search.py", env)) + results = search.get("results", []) + if not results or not _contains_demo_source(search): + raise AssertionError("minimal_search.py did not return the cited demo memory") + + workflow = _workflow_sections(_run_example("examples/memory_workflow.py", env)) + expected_sections = {"Search", "Cited answer or abstention", "Task context"} + if set(workflow) != expected_sections: + raise AssertionError( + f"memory_workflow.py returned {sorted(workflow)}, expected {sorted(expected_sections)}" + ) + answer = workflow["Cited answer or abstention"] + context = workflow["Task context"] + if not isinstance(answer, dict) or answer.get("status") != "cited": + raise AssertionError("memory_workflow.py did not produce a cited answer") + if not _contains_demo_source(workflow["Search"]): + raise AssertionError("memory_workflow.py search omitted the demo source") + if not answer.get("citations") or not _contains_demo_source(answer): + raise AssertionError("memory_workflow.py answer omitted source-bearing citations") + if not isinstance(context, dict) or not context.get("citations"): + raise AssertionError("memory_workflow.py context omitted citations") + if not _contains_demo_source(context): + raise AssertionError("memory_workflow.py context omitted the demo source") + + packs = json.loads(_run_example("examples/multi_agent_context.py", env)) + if set(packs) != {"planner", "researcher", "writer"}: + raise AssertionError("multi_agent_context.py did not return all three role packs") + for role, pack in packs.items(): + if not isinstance(pack, dict) or not pack.get("citations"): + raise AssertionError(f"multi_agent_context.py {role} pack omitted citations") + if not _contains_demo_source(pack): + raise AssertionError( + f"multi_agent_context.py {role} pack omitted the demo source" + ) + + tools = json.loads(_run_example("examples/tool_catalog.py", env)) + if not tools or not all(item.get("name") for item in tools): + raise AssertionError("tool_catalog.py did not return named tools") + finally: + server.terminate() + try: + server.wait(timeout=5) + except subprocess.TimeoutExpired: + server.kill() + server.wait(timeout=5) + + print("examples smoke: all public Python examples passed") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/rerank_eval.py b/scripts/rerank_eval.py index f7a62bb0..1c8656dc 100644 --- a/scripts/rerank_eval.py +++ b/scripts/rerank_eval.py @@ -206,22 +206,16 @@ def main(argv: list[str] | None = None) -> int: ), ) args = parser.parse_args(argv) - provider = embedding_status().get("provider") - if provider != "model2vec": + configured_provider = embedding_status().get("provider") + # embedding_status reports configuration, not whether model loading + # succeeded. Probe the active implementation before deciding run/skip. + active_provider = embed_text_result("rerank eval availability probe").provider + if active_provider != "model2vec": if args.forbid_skip: - print(json.dumps({"status": "failed", "reason": f"--forbid-skip: model2vec unavailable (provider={provider}); refusing to skip the semantic-retrieval gate"}, indent=2)) + print(json.dumps({"status": "failed", "reason": f"--forbid-skip: model2vec unavailable (configured={configured_provider}, active={active_provider}); refusing to skip the semantic-retrieval gate"}, indent=2)) return 1 - print(json.dumps({"status": "skipped", "reason": f"model2vec unavailable (provider={provider})"}, indent=2)) + print(json.dumps({"status": "skipped", "reason": f"model2vec unavailable (configured={configured_provider}, active={active_provider})"}, indent=2)) return 0 - if args.forbid_skip: - # embedding_status() reports the CONFIGURED provider; the runtime silently degrades to the - # keyword-hash embedder when the model can't actually load. Under --forbid-skip, prove the - # ACTIVE provider is really model2vec before trusting the eval — hash could still clear the - # keyword-friendly floor and paint this gate green without any real semantics. - active = embed_text_result("rerank eval demo-integrity probe").provider - if active != "model2vec": - print(json.dumps({"status": "failed", "reason": f"--forbid-skip: model2vec configured but the active embedder degraded to '{active}' (model failed to load); the eval would silently measure hash embeddings"}, indent=2)) - return 1 with tempfile.TemporaryDirectory() as tmp: summary = run_rerank_eval(Path(tmp) / "cortex.db", Path(tmp) / "vault") print(json.dumps(summary, indent=2)) diff --git a/sdk/PUBLISHING.md b/sdk/PUBLISHING.md index 52e29765..081861e6 100644 --- a/sdk/PUBLISHING.md +++ b/sdk/PUBLISHING.md @@ -22,9 +22,10 @@ that has changed: The original placeholders (`cortex-client` on PyPI, `@cortex/client` on npm) were both **already taken by unrelated parties** — `@cortex` and `@cortex-memory` are both claimed npm -scopes owned by other accounts. `@doppl-tech` matches this repo's real GitHub org -(`github.com/doppl-tech`), so it doubles as a trust signal for anyone verifying the package's -provenance. +scopes owned by other accounts. `@doppl-tech` is the project's package and signed-release +namespace. Source provenance points to +[`trace-cortex/cortex-app`](https://github.com/trace-cortex/cortex-app), while binary artifacts +are published from `doppl-tech/releases`. ## Python — PyPI (`doppl-cortex-client`) diff --git a/sdk/README.md b/sdk/README.md index 2ffab6bf..bf1b865f 100644 --- a/sdk/README.md +++ b/sdk/README.md @@ -28,10 +28,13 @@ Cortex runs **on the user's machine**. Both clients default to `http://127.0.0.1 (loopback) — the address the macOS app serves on. Nothing leaves the device unless you point `base_url` / `baseUrl` elsewhere. -Authentication is a bearer token (`Authorization: Bearer `) with an optional -`X-Cortex-User` header to select a user in multi-user deployments. The token's scopes -determine which tools are allowed; the server enforces scopes on every call regardless -of what the schema advertises, so a read-only token gets a read-only surface. +Authentication uses a scoped `cxa_` REST bearer token +(`Authorization: Bearer `) with an optional `X-Cortex-User` header to +select a user in multi-user deployments. The token's scopes determine which +tools are allowed; the server enforces scopes on every call regardless of what +the schema advertises, so a read-only token gets a read-only surface. `cxm_` +tokens are reserved for the `/mcp` transport and must not be used with these +SDKs. ## Relationship to MCP @@ -39,9 +42,9 @@ Cortex exposes its tools two ways over the **same catalog and the same scope enforcement**: - **MCP** (`POST /mcp`) — JSON-RPC (`initialize` / `tools/list` / `tools/call`), for MCP - clients like Claude Desktop and Cursor. + clients like Claude Desktop and Cursor; authenticates with a `cxm_` token. - **Universal HTTP** (`/v1/tools/*`) — plain HTTP for any function-calling app. This is - what these SDKs use. + what these SDKs use; authenticates with a `cxa_` token. So the two transports are interchangeable: a tool called over MCP and the same tool called via `call_tool` / `callTool` run identical server-side logic and honor the same diff --git a/sdk/python/README.md b/sdk/python/README.md index 7030ba9a..f512d1c0 100644 --- a/sdk/python/README.md +++ b/sdk/python/README.md @@ -9,7 +9,8 @@ function-calling app (OpenAI, Anthropic, or a custom agent loop) can drive it fr tool definition. This client wraps that HTTP surface. - Default base URL: `http://127.0.0.1:8766` (local loopback — Cortex is local-first). -- Auth: `Authorization: Bearer `, optional `X-Cortex-User` header. +- Auth: `Authorization: Bearer `, optional `X-Cortex-User` + header. `cxm_` tokens are only for `/mcp`, not this SDK. - Same routes work against the hosted plane — pass `base_url="https://api.signindoppl.com"` and a token minted there. The hosted server (`backend/app/main.py`) mirrors the local server's `/v1/tools/schema` and `/v1/tools/call` request/response shapes exactly, including @@ -34,7 +35,7 @@ dependencies. ```python from cortex_client import CortexClient, CortexError -client = CortexClient(base_url="http://127.0.0.1:8766", token="ctx_your_token") +client = CortexClient(base_url="http://127.0.0.1:8766", token="cxa_your_token") # Cited answer to a specific question (never an uncited guess). print(client.ask("What database do we use?")) @@ -68,7 +69,10 @@ except CortexError as exc: Any non-2xx response raises `CortexError(status, detail)`, where `detail` is the server's `{"detail": ...}` payload (a string or an object). Transport failures (server -unreachable) raise `CortexError` with `status == 0`. +unreachable) and invalid base URLs raise `CortexError` with `status == 0`. Base URLs +must be absolute `http://` or `https://` URLs without embedded user information. +Queries and fragments are rejected as well; put request parameters on SDK +methods instead of the base URL. ## OpenAI function-calling @@ -80,7 +84,7 @@ import json from openai import OpenAI from cortex_client import CortexClient -cortex = CortexClient(token="ctx_your_token") +cortex = CortexClient(token="cxa_your_token") oai = OpenAI() tools = cortex.openai_tools() # Cortex tool catalog as OpenAI function schemas @@ -115,7 +119,7 @@ a `tool_result` block: import anthropic from cortex_client import CortexClient -cortex = CortexClient(token="ctx_your_token") +cortex = CortexClient(token="cxa_your_token") client = anthropic.Anthropic() tools = cortex.anthropic_tools() diff --git a/sdk/python/cortex_client/client.py b/sdk/python/cortex_client/client.py index b9eca4d6..ff184d86 100644 --- a/sdk/python/cortex_client/client.py +++ b/sdk/python/cortex_client/client.py @@ -15,8 +15,8 @@ import json from typing import Any, Optional from urllib.error import HTTPError, URLError -from urllib.parse import urlencode -from urllib.request import Request, urlopen +from urllib.parse import urlencode, urljoin, urlsplit +from urllib.request import HTTPRedirectHandler, Request, build_opener __all__ = ["CortexClient", "CortexError"] @@ -24,6 +24,51 @@ DEFAULT_TIMEOUT = 30.0 +def _origin(url: str) -> tuple[str, str, int] | None: + try: + parsed = urlsplit(url) + except ValueError: + return None + if ( + parsed.scheme not in {"http", "https"} + or not parsed.hostname + or parsed.username is not None + or parsed.password is not None + ): + return None + try: + port = parsed.port + except ValueError: + return None + return parsed.scheme, parsed.hostname.lower(), port or (443 if parsed.scheme == "https" else 80) + + +class _SameOriginRedirectHandler(HTTPRedirectHandler): + """Never forward a Cortex bearer token to another origin.""" + + def redirect_request(self, req, fp, code, msg, headers, newurl): # type: ignore[override] + resolved = urljoin(req.full_url, str(newurl or "")) + if _origin(req.full_url) != _origin(resolved): + raise HTTPError( + req.full_url, + code, + "cross-origin redirect blocked for credential-bearing request", + headers, + fp, + ) + return super().redirect_request(req, fp, code, msg, headers, resolved) + + +def _safe_urlopen(request: Request, *, timeout: float): + if _origin(request.full_url) is None: + raise CortexError( + 0, + "Invalid Cortex base URL: expected an absolute HTTP(S) URL " + "without user info, a query, or a fragment", + ) + return build_opener(_SameOriginRedirectHandler()).open(request, timeout=timeout) + + class CortexError(Exception): """Raised when the Cortex server returns a non-2xx response or is unreachable. @@ -54,7 +99,7 @@ class CortexClient: timeout: Per-request socket timeout in seconds. Example: - >>> client = CortexClient(token="ctx_...") + >>> client = CortexClient(token="cxa_...") >>> answer = client.ask("What database do we use?") >>> hits = client.search("release checklist", top_k=5) """ @@ -66,7 +111,23 @@ def __init__( user: Optional[str] = None, timeout: float = DEFAULT_TIMEOUT, ) -> None: - self.base_url = base_url.rstrip("/") + normalized_base_url = base_url.strip().rstrip("/") + try: + parsed_base_url = urlsplit(normalized_base_url) + except ValueError: + parsed_base_url = None + if ( + _origin(normalized_base_url) is None + or parsed_base_url is None + or bool(parsed_base_url.query) + or bool(parsed_base_url.fragment) + ): + raise CortexError( + 0, + "Invalid Cortex base URL: expected an absolute HTTP(S) URL " + "without user info, a query, or a fragment", + ) + self.base_url = normalized_base_url self.token = token self.user = user self.timeout = timeout @@ -114,7 +175,7 @@ def _request( headers=self._headers(json_body=body is not None), ) try: - with urlopen(request, timeout=self.timeout) as response: + with _safe_urlopen(request, timeout=self.timeout) as response: raw = response.read() except HTTPError as exc: raise self._error_from_http(exc) from exc diff --git a/sdk/python/tests/test_client.py b/sdk/python/tests/test_client.py index c021493d..b352c0fa 100644 --- a/sdk/python/tests/test_client.py +++ b/sdk/python/tests/test_client.py @@ -1,7 +1,7 @@ """Unit tests for the Cortex Python SDK. -These tests DO NOT hit the network. They monkeypatch ``urllib.request.urlopen`` (the -one call the SDK makes) so we can assert the exact URL, method, headers, and JSON body +These tests DO NOT hit the network. They monkeypatch the SDK transport seam so we can +assert the exact URL, method, headers, and JSON body the client builds, and verify parsing of canned responses plus error raising. Run: python3 -m pytest sdk/python/tests/test_client.py @@ -14,6 +14,7 @@ import os import sys from urllib.error import HTTPError, URLError +from urllib.request import Request import pytest @@ -57,7 +58,7 @@ def __call__(self, request, timeout=None): @pytest.fixture def recorder(monkeypatch): rec = _Recorder() - monkeypatch.setattr(client_module, "urlopen", rec) + monkeypatch.setattr(client_module, "_safe_urlopen", rec) return rec @@ -103,6 +104,25 @@ def test_base_url_trailing_slash_stripped(recorder): assert recorder.request.full_url.startswith("http://127.0.0.1:8766/v1/search") +@pytest.mark.parametrize( + "base_url", + [ + "not-a-url", + "file:///tmp/cortex", + "https://user:password@api.signindoppl.com", + "http://127.0.0.1:not-a-port", + "https://[::1", + "https://api.signindoppl.com?tenant=other", + "https://api.signindoppl.com#fragment", + ], +) +def test_invalid_base_url_raises_documented_cortex_error(base_url): + with pytest.raises(CortexError) as excinfo: + CortexClient(base_url=base_url, token="secret") + assert excinfo.value.status == 0 + assert "Invalid Cortex base URL" in str(excinfo.value.detail) + + # -- search ---------------------------------------------------------------------------- def test_search_url_method_and_topk(recorder): @@ -117,7 +137,7 @@ def test_search_url_method_and_topk(recorder): def test_search_parses_canned_response(monkeypatch): payload = {"query": "q", "results": [{"id": "m1", "content": "hi"}]} rec = _Recorder(json.dumps(payload).encode("utf-8")) - monkeypatch.setattr(client_module, "urlopen", rec) + monkeypatch.setattr(client_module, "_safe_urlopen", rec) client = CortexClient(token="t") result = client.search("q") assert result == payload @@ -165,7 +185,7 @@ def test_context_defaults(recorder): def test_call_tool_body_and_unwrap(monkeypatch): canned = {"tool": "search_memory", "result": {"results": [1, 2, 3]}} rec = _Recorder(json.dumps(canned).encode("utf-8")) - monkeypatch.setattr(client_module, "urlopen", rec) + monkeypatch.setattr(client_module, "_safe_urlopen", rec) client = CortexClient(token="t") result = client.call_tool("search_memory", {"query": "x", "top_k": 2}) # URL / method / body @@ -189,7 +209,7 @@ def test_call_tool_defaults_arguments_to_empty(recorder): def test_tools_schema_default_format_and_unwrap(monkeypatch): schema = [{"type": "function", "function": {"name": "get_context"}}] rec = _Recorder(json.dumps({"schema": schema}).encode("utf-8")) - monkeypatch.setattr(client_module, "urlopen", rec) + monkeypatch.setattr(client_module, "_safe_urlopen", rec) client = CortexClient(token="t") result = client.tools_schema() assert rec.request.get_method() == "GET" @@ -199,7 +219,7 @@ def test_tools_schema_default_format_and_unwrap(monkeypatch): def test_openai_tools_uses_openai_format(monkeypatch): rec = _Recorder(json.dumps({"schema": []}).encode("utf-8")) - monkeypatch.setattr(client_module, "urlopen", rec) + monkeypatch.setattr(client_module, "_safe_urlopen", rec) client = CortexClient(token="t") client.openai_tools() assert "format=openai" in rec.request.full_url @@ -207,7 +227,7 @@ def test_openai_tools_uses_openai_format(monkeypatch): def test_anthropic_tools_uses_anthropic_format(monkeypatch): rec = _Recorder(json.dumps({"schema": []}).encode("utf-8")) - monkeypatch.setattr(client_module, "urlopen", rec) + monkeypatch.setattr(client_module, "_safe_urlopen", rec) client = CortexClient(token="t") client.anthropic_tools() assert "format=anthropic" in rec.request.full_url @@ -233,7 +253,7 @@ def _fake(request, timeout=None): def test_403_raises_cortex_error_with_detail(monkeypatch): monkeypatch.setattr( client_module, - "urlopen", + "_safe_urlopen", _raise_http_error(403, "Cortex API token requires write scope"), ) client = CortexClient(token="t") @@ -246,7 +266,7 @@ def test_403_raises_cortex_error_with_detail(monkeypatch): def test_401_raises_cortex_error(monkeypatch): monkeypatch.setattr( client_module, - "urlopen", + "_safe_urlopen", _raise_http_error(401, "Missing or invalid Cortex API token"), ) client = CortexClient(token="") @@ -258,7 +278,7 @@ def test_401_raises_cortex_error(monkeypatch): def test_error_detail_can_be_object(monkeypatch): detail = {"status": "needs_configuration", "hosted_readiness": {"status": "error"}} - monkeypatch.setattr(client_module, "urlopen", _raise_http_error(503, detail)) + monkeypatch.setattr(client_module, "_safe_urlopen", _raise_http_error(503, detail)) client = CortexClient(token="t") with pytest.raises(CortexError) as excinfo: client.ask("q") @@ -270,7 +290,7 @@ def test_transport_error_raises_cortex_error_status_zero(monkeypatch): def _fake(request, timeout=None): raise URLError("Connection refused") - monkeypatch.setattr(client_module, "urlopen", _fake) + monkeypatch.setattr(client_module, "_safe_urlopen", _fake) client = CortexClient(token="t") with pytest.raises(CortexError) as excinfo: client.search("q") @@ -278,5 +298,40 @@ def _fake(request, timeout=None): assert "Connection refused" in str(excinfo.value.detail) +def test_cross_origin_redirect_is_blocked_before_bearer_token_can_move(): + handler = client_module._SameOriginRedirectHandler() + request = Request( + "https://api.signindoppl.com/v1/search", + headers={"Authorization": "Bearer secret"}, + ) + with pytest.raises(HTTPError, match="cross-origin redirect blocked"): + handler.redirect_request( + request, + io.BytesIO(), + 302, + "Found", + {}, + "https://attacker.invalid/collect", + ) + + +def test_same_origin_relative_redirect_is_allowed(): + handler = client_module._SameOriginRedirectHandler() + request = Request( + "https://api.signindoppl.com/v1/search", + headers={"Authorization": "Bearer secret"}, + ) + redirected = handler.redirect_request( + request, + io.BytesIO(), + 307, + "Temporary Redirect", + {}, + "/v1/search-next", + ) + assert redirected is not None + assert redirected.full_url == "https://api.signindoppl.com/v1/search-next" + + if __name__ == "__main__": raise SystemExit(pytest.main([__file__, "-v"])) diff --git a/sdk/typescript/README.md b/sdk/typescript/README.md index 42aad9b4..9aa31240 100644 --- a/sdk/typescript/README.md +++ b/sdk/typescript/README.md @@ -9,7 +9,8 @@ function-calling app (OpenAI, Anthropic, or a custom agent loop) can drive it fr tool definition. This client wraps that HTTP surface. - Default base URL: `http://127.0.0.1:8766` (local loopback — Cortex is local-first). -- Auth: `Authorization: Bearer `, optional `X-Cortex-User` header. +- Auth: `Authorization: Bearer `, optional `X-Cortex-User` + header. `cxm_` tokens are only for `/mcp`, not this SDK. - Same routes work against the hosted plane — set `baseUrl: "https://api.signindoppl.com"` and use a token minted there. The hosted server (`backend/app/main.py`) mirrors the local server's `/v1/tools/schema` and `/v1/tools/call` request/response shapes exactly, including @@ -42,7 +43,7 @@ import { CortexClient, CortexError } from "@doppl-tech/cortex-client"; const cortex = new CortexClient({ baseUrl: "http://127.0.0.1:8766", - token: "ctx_your_token", + token: "cxa_your_token", }); // Cited answer to a specific question (never an uncited guess). @@ -81,6 +82,9 @@ try { Every method is `async`. Any non-2xx response rejects with `CortexError` carrying `.status` and `.detail` (the server's `{"detail": ...}` payload — a string or an object). Transport failures / timeouts reject with `CortexError` and `.status === 0`. +Credential-bearing redirects are handled manually: GET/HEAD follows at most +three same-origin redirects, while cross-origin redirects and redirects for +body-bearing requests are rejected before the token can be replayed. All methods accept a generic type parameter for the expected response shape, e.g. `await cortex.search<{ results: Memory[] }>("q")`. @@ -94,7 +98,7 @@ back through `cortex.callTool`: import OpenAI from "openai"; import { CortexClient } from "@doppl-tech/cortex-client"; -const cortex = new CortexClient({ token: "ctx_your_token" }); +const cortex = new CortexClient({ token: "cxa_your_token" }); const oai = new OpenAI(); const tools = await cortex.openaiTools(); // Cortex catalog as OpenAI function schemas @@ -126,7 +130,7 @@ input_schema }` shape. Route `tool_use` blocks back through `callTool` and reply import Anthropic from "@anthropic-ai/sdk"; import { CortexClient } from "@doppl-tech/cortex-client"; -const cortex = new CortexClient({ token: "ctx_your_token" }); +const cortex = new CortexClient({ token: "cxa_your_token" }); const client = new Anthropic(); const tools = await cortex.anthropicTools(); diff --git a/sdk/typescript/package-lock.json b/sdk/typescript/package-lock.json index 15c42748..7b88ff0f 100644 --- a/sdk/typescript/package-lock.json +++ b/sdk/typescript/package-lock.json @@ -1,11 +1,11 @@ { - "name": "@cortex/client", + "name": "@doppl-tech/cortex-client", "version": "0.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "@cortex/client", + "name": "@doppl-tech/cortex-client", "version": "0.1.0", "license": "MIT", "devDependencies": { diff --git a/sdk/typescript/src/index.ts b/sdk/typescript/src/index.ts index e5d0632b..8bf25501 100644 --- a/sdk/typescript/src/index.ts +++ b/sdk/typescript/src/index.ts @@ -67,6 +67,8 @@ export interface CortexClientOptions { const DEFAULT_BASE_URL = "http://127.0.0.1:8766"; const DEFAULT_TIMEOUT_MS = 30_000; +const MAX_SAME_ORIGIN_REDIRECTS = 3; +const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]); /** * Raised when the Cortex server returns a non-2xx response or is unreachable. @@ -103,7 +105,7 @@ function stringifyDetail(detail: unknown): string { * * @example * ```ts - * const cortex = new CortexClient({ token: "ctx_..." }); + * const cortex = new CortexClient({ token: "cxa_..." }); * const answer = await cortex.ask("What database do we use?"); * const hits = await cortex.search("release checklist", 5); * ``` @@ -116,7 +118,26 @@ export class CortexClient { private readonly fetchImpl: typeof fetch; constructor(options: CortexClientOptions = {}) { - this.baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, ""); + const candidateBaseUrl = options.baseUrl ?? DEFAULT_BASE_URL; + let parsedBaseUrl: URL; + try { + parsedBaseUrl = new URL(candidateBaseUrl); + } catch { + throw new CortexError(0, "Cortex baseUrl must be an absolute HTTP(S) URL"); + } + if ( + !["http:", "https:"].includes(parsedBaseUrl.protocol) || + parsedBaseUrl.username || + parsedBaseUrl.password || + parsedBaseUrl.search || + parsedBaseUrl.hash + ) { + throw new CortexError( + 0, + "Cortex baseUrl must be an absolute HTTP(S) URL without credentials, query, or fragment", + ); + } + this.baseUrl = parsedBaseUrl.toString().replace(/\/+$/, ""); this.token = options.token ?? ""; this.user = options.user ?? null; this.timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS; @@ -166,27 +187,63 @@ export class CortexClient { const timer = setTimeout(() => controller.abort(), this.timeoutMs); let response: Response; + let currentUrl = url; + let redirectCount = 0; try { - response = await this.fetchImpl(url, { - method, - headers: this.headers(hasBody), - body: hasBody ? JSON.stringify(options.body) : undefined, - signal: controller.signal, - }); + for (;;) { + response = await this.fetchImpl(currentUrl, { + method, + headers: this.headers(hasBody), + body: hasBody ? JSON.stringify(options.body) : undefined, + // Keep redirect handling inside this client. That lets us prove that a + // bearer token is only replayed to the same origin. + redirect: "manual", + signal: controller.signal, + }); + if (!REDIRECT_STATUSES.has(response.status)) break; + + if (method !== "GET" && method !== "HEAD") { + throw new CortexError( + response.status, + `Redirect blocked for credential-bearing ${method} request`, + ); + } + const location = response.headers.get("location"); + if (!location) { + throw new CortexError(response.status, "Redirect response did not include a Location header"); + } + if (redirectCount >= MAX_SAME_ORIGIN_REDIRECTS) { + throw new CortexError(response.status, "Too many Cortex API redirects (maximum 3)"); + } + + let redirectedUrl: URL; + try { + redirectedUrl = new URL(location, currentUrl); + } catch { + throw new CortexError(response.status, "Redirect response included an invalid Location URL"); + } + if (redirectedUrl.origin !== new URL(currentUrl).origin) { + throw new CortexError( + response.status, + "Cross-origin redirect blocked for credential-bearing request", + ); + } + currentUrl = redirectedUrl.toString(); + redirectCount += 1; + } + const text = await response.text(); + const parsed = parseMaybeJson(text); + if (!response.ok) { + throw new CortexError(response.status, errorDetail(parsed, text, response.statusText)); + } + return parsed as T; } catch (err) { + if (err instanceof CortexError) throw err; const reason = err instanceof Error ? err.message : String(err); throw new CortexError(0, `Could not reach Cortex at ${this.baseUrl}: ${reason}`); } finally { clearTimeout(timer); } - - const text = await response.text(); - const parsed = parseMaybeJson(text); - - if (!response.ok) { - throw new CortexError(response.status, errorDetail(parsed, text, response.statusText)); - } - return parsed as T; } // -- tool catalog -------------------------------------------------------------------- diff --git a/sdk/typescript/test/client.test.ts b/sdk/typescript/test/client.test.ts index fbb10119..66a89cb1 100644 --- a/sdk/typescript/test/client.test.ts +++ b/sdk/typescript/test/client.test.ts @@ -20,10 +20,18 @@ interface RecordedRequest { method: string; headers: Record; body: string | undefined; + redirect: RequestRedirect | undefined; } /** A minimal fetch stub: records every call and returns a queued response (FIFO). */ -function makeFetchStub(responses: Array<{ status: number; body: unknown; ok?: boolean }>) { +function makeFetchStub( + responses: Array<{ + status: number; + body: unknown; + ok?: boolean; + headers?: Record; + }>, +) { const calls: RecordedRequest[] = []; let index = 0; const fetchStub = (async (input: RequestInfo | URL, init?: RequestInit) => { @@ -39,16 +47,23 @@ function makeFetchStub(responses: Array<{ status: number; body: unknown; ok?: bo method: init?.method ?? "GET", headers, body: typeof init?.body === "string" ? init.body : undefined, + redirect: init?.redirect, }); const queued = responses[Math.min(index, responses.length - 1)]; index += 1; const status = queued.status; const ok = queued.ok ?? (status >= 200 && status < 300); const text = typeof queued.body === "string" ? queued.body : JSON.stringify(queued.body); + const responseHeaders = new Map( + Object.entries(queued.headers ?? {}).map(([key, value]) => [key.toLowerCase(), value]), + ); return { ok, status, statusText: ok ? "OK" : "Error", + headers: { + get: (name: string) => responseHeaders.get(name.toLowerCase()) ?? null, + }, text: async () => text, } as unknown as Response; }) as typeof fetch; @@ -61,15 +76,16 @@ test("toolsSchema issues GET /v1/tools/schema?format= with bearer auth and const { fetchStub, calls } = makeFetchStub([ { status: 200, body: { schema: [{ type: "function", function: { name: "search_memory" } }] } }, ]); - const client = new CortexClient({ baseUrl: "https://api.signindoppl.com", token: "cxm_test_token", fetch: fetchStub }); + const client = new CortexClient({ baseUrl: "https://api.signindoppl.com", token: "cxa_test_token", fetch: fetchStub }); const schema = await client.toolsSchema("anthropic"); assert.equal(calls.length, 1); assert.equal(calls[0].method, "GET"); assert.equal(calls[0].url, "https://api.signindoppl.com/v1/tools/schema?format=anthropic"); - assert.equal(calls[0].headers["Authorization"], "Bearer cxm_test_token"); + assert.equal(calls[0].headers["Authorization"], "Bearer cxa_test_token"); assert.equal(calls[0].headers["Accept"], "application/json"); + assert.equal(calls[0].redirect, "manual"); assert.ok(!("Content-Type" in calls[0].headers), "GET requests must not send Content-Type"); assert.deepEqual(schema, [{ type: "function", function: { name: "search_memory" } }]); }); @@ -101,13 +117,13 @@ test("callTool issues POST /v1/tools/call with {name, arguments} body and unwrap const { fetchStub, calls } = makeFetchStub([ { status: 200, body: { tool: "search_memory", result: { results: [{ id: "m_1" }] } } }, ]); - const client = new CortexClient({ baseUrl: "https://api.signindoppl.com", token: "cxm_test_token", fetch: fetchStub }); + const client = new CortexClient({ baseUrl: "https://api.signindoppl.com", token: "cxa_test_token", fetch: fetchStub }); const result = await client.callTool("search_memory", { query: "release checklist" }); assert.equal(calls[0].method, "POST"); assert.equal(calls[0].url, "https://api.signindoppl.com/v1/tools/call"); - assert.equal(calls[0].headers["Authorization"], "Bearer cxm_test_token"); + assert.equal(calls[0].headers["Authorization"], "Bearer cxa_test_token"); assert.equal(calls[0].headers["Content-Type"], "application/json"); assert.deepEqual(JSON.parse(calls[0].body ?? "{}"), { name: "search_memory", arguments: { query: "release checklist" } }); assert.deepEqual(result, { results: [{ id: "m_1" }] }); @@ -214,6 +230,123 @@ test("baseUrl trailing slashes are stripped", async () => { assert.ok(calls[0].url.startsWith("https://api.signindoppl.com/v1/search")); }); +test("constructor rejects unsafe or malformed base URLs with CortexError", () => { + for (const baseUrl of [ + "not-a-url", + "file:///tmp/cortex", + "https://user:secret@example.com", + "https://example.com?token=secret", + "https://example.com/#fragment", + ]) { + assert.throws( + () => new CortexClient({ baseUrl, fetch: makeFetchStub([]).fetchStub }), + (err: unknown) => err instanceof CortexError && err.status === 0, + baseUrl, + ); + } +}); + +test("GET follows at most same-origin redirects and retains bearer auth", async () => { + const { fetchStub, calls } = makeFetchStub([ + { + status: 307, + body: "", + headers: { Location: "/v1/search-relocated?query=atlas&limit=8" }, + }, + { status: 200, body: { results: [{ id: "m_atlas" }] } }, + ]); + const client = new CortexClient({ + baseUrl: "https://api.signindoppl.com", + token: "cxa_redirect_test", + fetch: fetchStub, + }); + + const result = await client.search<{ results: Array<{ id: string }> }>("atlas"); + + assert.deepEqual(result, { results: [{ id: "m_atlas" }] }); + assert.equal(calls.length, 2); + assert.equal( + calls[1].url, + "https://api.signindoppl.com/v1/search-relocated?query=atlas&limit=8", + ); + assert.equal(calls[1].headers["Authorization"], "Bearer cxa_redirect_test"); + assert.equal(calls[1].redirect, "manual"); +}); + +test("cross-origin redirect is rejected before bearer auth can be replayed", async () => { + const { fetchStub, calls } = makeFetchStub([ + { + status: 302, + body: "", + headers: { Location: "https://attacker.invalid/collect" }, + }, + ]); + const client = new CortexClient({ + baseUrl: "https://api.signindoppl.com", + token: "cxa_secret", + fetch: fetchStub, + }); + + await assert.rejects( + () => client.search("atlas"), + (err: unknown) => { + assert.ok(err instanceof CortexError); + assert.equal(err.status, 302); + assert.match(String(err.detail), /Cross-origin redirect blocked/); + return true; + }, + ); + assert.equal(calls.length, 1); +}); + +test("POST redirects are rejected rather than replaying a request body or token", async () => { + const { fetchStub, calls } = makeFetchStub([ + { status: 307, body: "", headers: { Location: "/v1/context-relocated" } }, + ]); + const client = new CortexClient({ + baseUrl: "https://api.signindoppl.com", + token: "cxa_secret", + fetch: fetchStub, + }); + + await assert.rejects( + () => client.context("Project Atlas"), + (err: unknown) => { + assert.ok(err instanceof CortexError); + assert.equal(err.status, 307); + assert.match(String(err.detail), /Redirect blocked.*POST/); + return true; + }, + ); + assert.equal(calls.length, 1); +}); + +test("GET stops after three same-origin redirects", async () => { + const { fetchStub, calls } = makeFetchStub([ + { status: 307, body: "", headers: { Location: "/redirect/1" } }, + { status: 307, body: "", headers: { Location: "/redirect/2" } }, + { status: 307, body: "", headers: { Location: "/redirect/3" } }, + { status: 307, body: "", headers: { Location: "/redirect/4" } }, + ]); + const client = new CortexClient({ + baseUrl: "https://api.signindoppl.com", + token: "cxa_secret", + fetch: fetchStub, + }); + + await assert.rejects( + () => client.search("atlas"), + (err: unknown) => { + assert.ok(err instanceof CortexError); + assert.equal(err.status, 307); + assert.match(String(err.detail), /Too many Cortex API redirects/); + return true; + }, + ); + assert.equal(calls.length, 4); + assert.ok(calls.every((call) => call.headers["Authorization"] === "Bearer cxa_secret")); +}); + // -- error propagation ---------------------------------------------------------------- test("a non-2xx JSON {detail} response rejects with CortexError carrying status + detail", async () => { diff --git a/site/faq.html b/site/faq.html index 3a29169d..9e9e4754 100644 --- a/site/faq.html +++ b/site/faq.html @@ -56,7 +56,7 @@

Is my data private? Is it encrypted?

How do I connect the app to my account?

  • Open the app's Settings → "Cortex Cloud" section.
  • -
  • Enter the hosted URL (for example https://api.trydoppl.com).
  • +
  • Enter the hosted URL (for example https://api.signindoppl.com).
  • Sign in with your email and password, or use "Sign in with browser" for Google/GitHub. The "Create an account" link opens the signup page.
  • The app stores your session securely in the macOS Keychain and refreshes it automatically. Power users can still paste a raw endpoint and token in the manual fields.
diff --git a/site/subprocessors.html b/site/subprocessors.html index 47f606b6..c58f9d6b 100644 --- a/site/subprocessors.html +++ b/site/subprocessors.html @@ -75,10 +75,14 @@

Subprocessors

A "subprocessor" is a third party that processes Cortex Cloud user data on our behalf. - The local app uses no subprocessors — it runs entirely - on your Mac. The table below applies only to the optional hosted - Cortex Cloud tier. See the Privacy page for - how data is handled overall. + The installed app keeps its memory vault, ingestion, embeddings, and retrieval engine on + your Mac by default. Account creation and sign-in, managed connector authorization, + optional sync, update checks, and support links can still contact Cortex-hosted or + third-party services. The table below covers processors used to operate the optional + hosted Cortex Cloud tier; a service you connect directly (for example + Google, GitHub, or Notion) processes data under its own terms rather than as a Cortex + subprocessor. See the Privacy page for how data is handled + overall.

diff --git a/site/terms.html b/site/terms.html index f7311a57..69dece72 100644 --- a/site/terms.html +++ b/site/terms.html @@ -94,7 +94,7 @@

5. Your content and the license you give us

6. How your data is processed (AI processing disclosure)

    -
  • When you use Cortex Cloud, your content is processed on our servers to build embeddings, run retrieval, and assemble cited context. This means Cortex servers can read your memory in order to serve you. Your data is encrypted at rest with a per-user key; this is not end-to-end or "zero-knowledge" encryption. The Privacy page states this plainly.
  • +
  • When you use Cortex Cloud, your content is processed on our servers to build embeddings, run retrieval, and assemble cited context. This means Cortex servers can read your memory in order to serve you. Connector credentials are encrypted at rest with a per-user key; broader encryption of hosted memory content is still rolling out. This is not end-to-end or "zero-knowledge" encryption. The Privacy page states this plainly.
  • By default the hosted deployment generates embeddings with an on-device model and uses no third-party AI processor. If the operator enables a cloud AI model, that provider is disclosed on the Subprocessors page.
  • Context is shared with an AI tool only when you connect it, and only within the permissions you grant.
@@ -112,7 +112,7 @@

7. Connectors and connected sources

8. Availability

  • Cortex Cloud is provided on an "as is" and "as available" basis. The free tier carries no uptime commitment or service-level agreement. We may change, suspend, or discontinue features, and we perform maintenance that can cause downtime.
  • -
  • The local app keeps working regardless of the hosted service's availability.
  • +
  • After setup and a successful sign-in, the installed app can continue local ingestion and retrieval over data already stored on your Mac during a hosted-service outage. Account creation, first sign-in, token renewal, managed connector authorization, optional sync, update checks, and other network-backed features may be unavailable until the relevant service recovers.
From 5aa2e94e4d1b1993d41059143c62b0f1e6f0c092 Mon Sep 17 00:00:00 2001 From: VamikaSinghal Date: Sat, 1 Aug 2026 20:48:32 -0700 Subject: [PATCH 4/6] Harden release, docs, and developer experience --- .editorconfig | 21 + .github/ISSUE_TEMPLATE/bug_report.yml | 2 + .github/ISSUE_TEMPLATE/installation_help.yml | 58 ++ .github/ISSUE_TEMPLATE/showcase.yml | 43 ++ .github/REPOSITORY_METADATA.md | 42 ++ .github/assets/cortex-demo.gif | Bin 0 -> 305520 bytes .github/assets/cortex-review.png | Bin 0 -> 282695 bytes .github/assets/social-preview.png | Bin 0 -> 130591 bytes .github/workflows/ci.yml | 95 +-- .github/workflows/publish-sdks.yml | 12 +- .gitignore | 7 + CHANGELOG.md | 15 + CONTRIBUTING.md | 94 ++- Makefile | 64 +- README.md | 262 ++++++-- SETUP.md | 55 +- SUPPORT.md | 15 +- backend/README.md | 33 +- backend/app/config.py | 11 +- backend/app/connectors/agent_sessions.py | 8 +- backend/app/embeddings.py | 58 ++ backend/app/main.py | 122 ++-- backend/app/models.py | 15 +- backend/app/obsidian_writeback.py | 4 +- backend/app/sharding.py | 7 +- backend/app/standalone_server.py | 96 +-- backend/app/storage.py | 191 ++++-- backend/app/vault.py | 8 +- backend/requirements.lock | 531 ++++++++++++++++ backend/runtime-requirements.lock | 387 ++++++++++++ backend/tests/test_config.py | 18 + backend/tests/test_context_engine.py | 13 + backend/tests/test_fastapi_contract.py | 59 +- backend/tests/test_phase2_context_packs.py | 11 +- backend/tests/test_retrieval_quality.py | 71 +++ backend/tests/test_standalone_server.py | 84 ++- backend/tests/test_vector_dim_migration.py | 55 ++ deploy/bootstrap.sh | 5 +- deploy/macmini/setup.sh | 6 +- deploy/systemd/cortex-api.service | 11 + deploy/systemd/cortex-backup.service | 2 + deploy/systemd/cortex-worker.service | 11 + deploy/update.sh | 5 +- docs/ADDING_A_CONNECTOR.md | 226 +++++++ docs/API_LIFECYCLE.md | 6 + docs/APPLE_RELEASE.md | 20 +- docs/ARCHITECTURE.md | 23 +- docs/BENCHMARKS.md | 35 +- docs/CODE_MAP.md | 68 ++ docs/MCP_INTEGRATIONS.md | 16 + docs/OPEN_SOURCE_READINESS.md | 6 +- docs/README.md | 11 +- docs/SOURCE_IMPORTS.md | 41 +- docs/TROUBLESHOOTING.md | 18 +- docs/benchmarks/2026-07-30-local.json | 12 +- examples/README.md | 52 +- examples/memory_workflow.py | 27 +- examples/minimal_search.py | 62 +- legacy/README.md | 17 + capture.py => legacy/capture.py | 2 +- github_store.py => legacy/github_store.py | 2 +- ingest.py => legacy/ingest.py | 2 +- .../instrumentation.py | 2 +- mcp_server.py => legacy/mcp_server.py | 2 +- redis_store.py => legacy/redis_store.py | 2 +- requirements.txt => legacy/requirements.txt | 1 + ui.py => legacy/ui.py | 2 +- macos/Sources/CortexApp.swift | 2 +- macos/Sources/QuickCapture.swift | 2 +- macos/build.sh | 11 +- requirements-dev.in | 6 + requirements-dev.lock | 588 ++++++++++++++++-- scripts/bootstrap_dev.sh | 17 +- scripts/bundled_runtime_smoke.py | 167 +++++ scripts/check_distribution_site.py | 66 +- scripts/check_markdown_links.py | 75 +++ scripts/context_pack_eval.py | 31 +- scripts/dev_backend.sh | 39 ++ scripts/examples_smoke.py | 73 ++- scripts/retrieval_eval.py | 28 +- sdk/README.md | 7 +- sdk/python/README.md | 44 +- sdk/python/cortex_client/client.py | 45 +- sdk/python/tests/test_client.py | 29 + sdk/typescript/README.md | 39 +- sdk/typescript/src/index.ts | 40 +- sdk/typescript/test/client.test.ts | 30 + site/app.js | 10 +- site/downloads/distribution.json | 20 +- 89 files changed, 4085 insertions(+), 546 deletions(-) create mode 100644 .editorconfig create mode 100644 .github/ISSUE_TEMPLATE/installation_help.yml create mode 100644 .github/ISSUE_TEMPLATE/showcase.yml create mode 100644 .github/REPOSITORY_METADATA.md create mode 100644 .github/assets/cortex-demo.gif create mode 100644 .github/assets/cortex-review.png create mode 100644 .github/assets/social-preview.png create mode 100644 backend/requirements.lock create mode 100644 backend/runtime-requirements.lock create mode 100644 docs/ADDING_A_CONNECTOR.md create mode 100644 docs/CODE_MAP.md create mode 100644 legacy/README.md rename capture.py => legacy/capture.py (99%) rename github_store.py => legacy/github_store.py (99%) rename ingest.py => legacy/ingest.py (99%) rename instrumentation.py => legacy/instrumentation.py (98%) rename mcp_server.py => legacy/mcp_server.py (99%) rename redis_store.py => legacy/redis_store.py (99%) rename requirements.txt => legacy/requirements.txt (78%) rename ui.py => legacy/ui.py (99%) create mode 100644 requirements-dev.in create mode 100644 scripts/bundled_runtime_smoke.py create mode 100644 scripts/check_markdown_links.py diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 00000000..99c54e94 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,21 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true + +[*.{py,swift}] +indent_style = space +indent_size = 4 + +[*.{js,json,ts,yml,yaml}] +indent_style = space +indent_size = 2 + +[Makefile] +indent_style = tab + +[*.md] +trim_trailing_whitespace = false diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 85878388..af408c96 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -56,6 +56,8 @@ body: attributes: label: Safety checklist options: + - label: I searched existing issues and checked the troubleshooting guide. + required: true - label: I removed private memory, credentials, tokens, and personal identifiers. required: true - label: This is not a security vulnerability. diff --git a/.github/ISSUE_TEMPLATE/installation_help.yml b/.github/ISSUE_TEMPLATE/installation_help.yml new file mode 100644 index 00000000..50dff461 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/installation_help.yml @@ -0,0 +1,58 @@ +name: Installation help +description: Report a reproducible setup, launch, or first-run failure. +title: "[Install]: " +body: + - type: markdown + attributes: + value: | + Start with `make doctor` and the troubleshooting guide. Do not paste tokens, private memory, vault files, or account details. + - type: dropdown + id: path + attributes: + label: Installation path + options: + - Downloaded macOS DMG + - make demo / backend development + - macOS source build + - SDK or plugin + validations: + required: true + - type: input + id: environment + attributes: + label: Environment + description: OS version, architecture, and relevant Python/Node version. + placeholder: "macOS 15.5 arm64; Python 3.12.13" + validations: + required: true + - type: textarea + id: command + attributes: + label: Command or action that failed + render: shell + validations: + required: true + - type: textarea + id: output + attributes: + label: Sanitized output + description: Include the exact error and `make doctor` output when applicable. + render: text + validations: + required: true + - type: textarea + id: attempted + attributes: + label: What you already tried + description: Link the troubleshooting step if one applied. + - type: checkboxes + id: checks + attributes: + label: Safety and duplicate checks + options: + - label: I checked docs/TROUBLESHOOTING.md and searched existing issues. + required: true + - label: I removed tokens, private data, account details, and vault contents. + required: true + - label: This is not a security vulnerability. + required: true diff --git a/.github/ISSUE_TEMPLATE/showcase.yml b/.github/ISSUE_TEMPLATE/showcase.yml new file mode 100644 index 00000000..17c128e0 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/showcase.yml @@ -0,0 +1,43 @@ +name: Integration showcase +description: Share something you built with Cortex so others can learn from it. +title: "[Showcase]: " +body: + - type: markdown + attributes: + value: | + Thanks for building with Cortex. Share the pattern, not private data: + use synthetic examples and remove tokens, personal memories, and vault paths. + - type: input + id: project + attributes: + label: Project + description: What did you build? + placeholder: A coding agent that retrieves cited architecture decisions + validations: + required: true + - type: textarea + id: outcome + attributes: + label: What it unlocks + description: Describe the user or developer outcome in a few sentences. + validations: + required: true + - type: textarea + id: integration + attributes: + label: How Cortex is used + description: Which API, MCP tool, SDK, connector, or example did you start from? + validations: + required: true + - type: input + id: link + attributes: + label: Public link + description: Optional repository, demo, article, or screenshot URL. + - type: checkboxes + id: privacy + attributes: + label: Privacy check + options: + - label: I removed credentials, personal memories, exports, and identifying vault data. + required: true diff --git a/.github/REPOSITORY_METADATA.md b/.github/REPOSITORY_METADATA.md new file mode 100644 index 00000000..f3eea0e9 --- /dev/null +++ b/.github/REPOSITORY_METADATA.md @@ -0,0 +1,42 @@ +# GitHub Repository Metadata + +Keep the repository's GitHub settings aligned with the public product contract. +These fields are not derived from files, so a maintainer must update them in +**Settings → General** when the positioning changes. + +## Description + +> Local, cited memory for Claude, Cursor, ChatGPT, and MCP agents—review what it learns and keep a readable vault you own. + +## Website + +Use the canonical product or documentation URL only when it is maintained and +matches the current open-source release. Otherwise leave this blank rather than +linking to an outdated landing page. + +## Topics + +```text +ai-memory +local-first +mcp +ai-agents +rag +personal-knowledge-management +sqlite +swiftui +python +privacy +``` + +## Social preview + +Upload [`assets/social-preview.png`](assets/social-preview.png). The same image +appears at the top of the README, so link previews and the repository's +two-minute visitor story stay visually consistent. + +## Release copy + +Lead with a concrete user outcome, name the supported platform, link the +changelog, and repeat any material beta limitation. Do not use benchmark +results as real-world quality claims. diff --git a/.github/assets/cortex-demo.gif b/.github/assets/cortex-demo.gif new file mode 100644 index 0000000000000000000000000000000000000000..9d6f31fdac51395d78f4223fe6322e33de707c89 GIT binary patch literal 305520 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%0A0k--MBb!<&MpY)9E5OS+# zJ$9j%tL9Vr>8q=Vx>XCZHTd+ZRXWs6bS<^*r|sIR^^>)yeBqX%Yi7^Z9BQ7##I89_ z{dAmKb8SI5pRA>>uDP?Vdx}1_&in3VwC?j{J&I!eS?Id2(dzTW^_MkIjdRyuO|8Ev z>3qGm?hmogKU@!B+Xy7cg^O+k8`(=MY=k`92z&Ag6&kt`o@dYhej}o1BPujIa%$u4 zi9O?wjTo}ccQskDY@2Z^4kSXG@lQ4r*D@2HZ6+sfUc)z2YBoQLW_;+`Ok3L|(O6H1 zY-K)UOe5RM7IoCU*!*O)mHU_>`^i>*s3VcbRzcoYVM|R;%~sKr<5tR6@yS-{Nn{CR zyPVA_9=2VfvR!q8s5IKHdFBM`*s4w3uJ0kJ$=h!1+4e8nZd!AyUEFSg>{Q%r1AiGx z>2^9)b_!*7x}G@aJ>KaF-O2Xb>C1CYPv05n*-2^H8Cr8rT-^Bz*?o7jgJg4wrrRA; z*$tQ3op|CB@_2VDbT`0ncRJ7IP5SO^&#rIFE^2(hYl8Yi<&=dpD4M(ThD0yZc?LeKgg5eu;gIr|vvv z`;f4Gj+gt;e0SCl`#8P(Obz>Z>+bZk`vh1A)EE0Ob`J`w10vM}Qi%hSryfLR2V`Lf z1TPOL@;x9Q4ybw$&>IeD)(u;%buRGs=%V2pEqkhY=dy~>1DX4mr%N{8`^`^8qQVH{>_CHd~_omG_ z(&+W3Z#~jl_h$Tl^Z?6;8Fi$??!!udtf%V3E_-b7)Q8jJ*eJ}0+yB@k--p*C%B01<`1~9icAo6EpOSx$QG5Q8e?RrxIcOd5kM}aL zF0RNfvP$=|?_Hd!T;#az5D_gpr)?KZDpPMln}^6$2hUm@A9 zI`(!tRj-DOuDVNidY)hPgEyR6%F!5g z+X5xZWI z{Q*-?6xBV?osEM}^0rLx;5-h8ECcae#ud(+hpa=%LQltv9DcKXO_g|lxKr=RqMo4; zM#SrQ#4(nmktF1@b;LPQpaa(^c0A^qDl)6}+}%3no-UL89Kq{!!ZTat_^rZa`-E4w z!hL&}_r&%XEU>vwm#!Fl-MSsoPJ>CAbdwFkvsl)V;&h<*{@aKR( zxy7$5@uQ6~#(NpA*Agc?v(Nb#e_czS?f-}|qIbKIx;WZmPPEv&k-j>co*OK6L&^Bf z9sT~jw}+Coy9A3dz%i-plLD}~>KKCXtrIeX?r4ZHhKS3XFose()-9`$d!8{O7$eSN z!k9n!GDScVlbIrU8_pIZ_y@$8qksDtGrtwzI(rl)d2z-ZTYOBx@=lp5d?r?vtDYq; zV_}*lURR^vDf}UTmqerN6p3&rf)RCc%-Vf@8*!9sa|&AUj$jg6UI?hQ#O4cX}^5^YF|*aOS67 z)PBy-iol^S`1Ijrp>J+>1J{?S&$C=bv!wQnT)n4ikd243UeTr)qUN(}}evgp+rN8|C6<C#9u5Qf%!--$G-?w2>OS$V{aW}a;qJ*vly4X^v1-pMgFc<9E zd7LWPyW%=8*f;A(Bh)|s)?5fRFp!=qG}vA;FErH9LUVVx{HyuhuLX;#cSo{*&EG{P z-_Qt;#t=Lb9z)Qj36J~pEeKCMlc5!vbbIhfWXk?=n#eZ`*9DPj6F*wf8Qr&!L}xY9 z(?sXwOBO`uC0c0jE!_S3=-wjFV%oj$%)b`yEz#W2iY=28JQn*wK$kAIg2lHe_7fyS zC%$@q|FQVm;iGi%^=+3$@r|`NbP}6V66O+Hns3u3w#SMWC3Xgz=_Gf1h967*8cIu- z+C+Ex-=)ve{pn@S zvtuk|F7k*|WiC$_7GC=jqO~zRduQscr?HkBLz8FycJ&>>#NSVC8mZ#G5&4 zrxpLIn-g2++l=KA9gYj-C|$Z^2-jlCCeDC%acH~?Gbmyr zC`Bk!IeJ72>|~RhLVEW~@I0GJUVMU*4DG|L=OzttS zSu*KS&^9@o_x`~=EH?zqo zyHmd83}nPNJKB;t=c(wo=i09y=x3l-@W9lYfhaYu8Az7>H-yMF1@(IiCm%T&kx>k8GPcJ{H#}9Xw=MQR9QG#{I!bN z{F=+8lWVH{eRa_jY3@5_|k1d&lot1-~AOBQjZug&8=hlDB z{QN$-JU@Ob$4rmRfBOoIJOgIT)My_d#EcGi4feKlx7Pz&3^3zdS6x(FRrLR2z_dC7 z5{xHsnjJiw8~_`}qtV`@!5(m8-0PqIw^|G!!Bm%jt|-kfDf|R1KmS98`QyQ4rT`<) zfB^#xJO9&tajvj+`iDv5P-69$Nn>AV2{Y2vqnfx(hZnc>3zs(rEAkF_`#;8AdrFQ>?3gE|RJy+Iz zrmW$u^p_&@mmH(&`nMmW>?o^fFJtk@NWo4@-d5@#63pKojLc(k=|_MHb8Ex=@n-ZM zn&_GuY6C0K|IlL8ZU>*$6{XBXB~3&nj77xs?}};*s%ppzYw_RJ-~$wyf0bjD|7G@B zPUdeXMo^XapH2*)0vC_Wtro*A$qu+OLgKvtNsbW`mMT z=HtX3nK0t(Xh}Y*)*nC!b`hE{5glrWJUkHAaqE?SzVIC7T{$FC+vM2KW=zdiW9zzo z?lO#Lg~?fDsTV>*cpBe8np-voftl@Gjq}x&=~j_i=2%NEN-vK!5`3m`lhXOBWQEE0 zrAiQkY#D@!53@Tvik^9}JEpk*B`OP@Qy%^WvRi|$8zSnk?e4c*TN*=FXBlse1GCn$ zLJ?Y?{ggiY!A!q;W%QQcr2Vms!{YDkd(B%Sm=v;d-*+c6OEWlNFULMTzTA4=dbL~X zU!hNX<(}U-?tOEndr*jI4G@9&P)^2(7A^By=KhQ zNmfu3Q8rL4jAwDU8-1Yr0ADr*LZ|^I5mL)?dn6D6d&8t*2(rUBghfykF%Uf347H?MT1lUHuu*^1n6uPjKJR_p zPy2zHa)KcQ8e&8G1!HdV;v4)gU*OQjGjq%$&?pN*8ksD+pJV_v##{Y9h9_~B?B@c3 zc0*)5M4yu(`ZGcHW^D+zN6s?GCMbikM5r4rIFxt?M?6p-RU*G95PHj)DBaLi_Wgaow|oYR427N znZ+(1Umnsb!6_X#z;~wy%-S2sIOAxacx_%7<)e{T=i%>W$$eR1#;?j%f^TV)4CoBy zJuAH#mwyx^6wO#*g=6E3bJx2q;zW=vSLXA^vI!2!^#xhw*$1~bVK2T1CU)YO$J%H^ z8Wsy)*S2okVg=FT%nwCqHpwu$JwNVRz+W$jJO1gUIwL-yx9AnHfr-QCy`-PzO@xre zR=rCyPb^sH)BR;5ogne!@{iDDygr(>m>|50PPFHlQoq6#_MA$CWJKd70?M^*wN^f* z{_efw=fU?>%Mx@iDxK|Q|LHO38mo^L&d^r>y)w(r3^6@anAFb02>)z6S}|{qN1zU~ zAg7hgoIosI+D@2lB#Hg>4Es`>f(Lp(0bllVWQX_BRn%)!#qTM!;e32(qx(TDrz|6{ zPT0s&(fT?)1Uq&>)!Irf7W{MD!_siul)Z^%({BjMLK|APB3MZC*RLeSbqjRVcJQJaoCV+%%%R+qFw!5ny!0m<09wKY4auR-KVI= z`Ayfy?bLKaaM`9GY0Qw>@o&9acT2Bv zUjrz9SZ-@L#u}&&ZT0LJUkojC?~(l0(euLd>T_*u_E~Q-oUo%N8^X zG}LiA)b%XXog&N=uw*ELolU}g|78mr%_A&83=wRC2=zrEk`X){h=_mPf{qdkPyB}^ zV{8(hdKR8Z5piqDoWavgA_|ft{@Q|u+xwSLMEb`Ki?bLs@mLJg*q;Y<5MT?sJ{FrY7VrFa3wnAZmdNyW z3wjC~Nm2ifX69Xw+TWH;BJ@4Wi(5;^-~K(<%&jHk*!N!G&lYrA!Mne=pshIKq|R@* zpanMK6inlRkaQj*PObjdlHpK`e?XZ4STa{w2?j52Etyh8tl7-1B~y|WZz-N=d!Ara z?+^5C?Ep)rKKAKne+SM$kI!%=5Xc3vWX?m~`YB!9(eVN8#S(18Ku9l^L}`))tUgDm zhrZe*J9I|}$pK10GJwSrXX4Y@CsQDjBY`dG@Q~=w#OA=PECb9#9S7YVobox*+aW3A z{6pDHa8?R2%o2<)m}CqBjh4Ybynv@ter%&mea4XpGXcrCe=MTJch(_rSI5YOfuVAk zmzg*ufYwrmi|IiKY-(ZytR4)bF`T#=rH%=fg8;jV{||Tf71h+kw|#!65JDg%R1pON zNbkLO1f&^IdXXj|D54^wsDvI65G+WOUP2GONCy!SkX{w(AVm=rm6qB1zn}ZPpJ&a? zJ99X*mS=jPYXxy{f1m5C&;}r|5Nm)Q@I-;g4<!c_We}q!30!r;Dry1)cx=u|x_%FyK0L?iFx5ntpb$Tz!tz z(}2;9rcoRMSe&rnDY$D}5N$j}F#!_i^=Id~=Nx#uTuvdB=LuoKi)qDR2!>F`<6Y*s z&YJL+iq;o+ty~WMmdb8~E%t@Lfh1X4G7DwDyfKOiN`M_rSIxH30hv^X)PNR&N!Jjo zvQc$}s@6Wp7PHHh5W&Lm)d;B)-oP4r+e&4I7n|V6uZ>Rh03)7S4oyHoB_S?&6p37W zS~#7`7jW|hP7o@NY^wadvwrL6yKIpB1M650s$mQ@l+3!HQ8f{?X|}0Mx01=1u(htQ zDkz`>aiXU(tAE~$qDUeDECL|~$dhX`X&d_*gCLqUR5z+&^9@<28go?YS@-J;y&K`q zHK3BINUjGH&APbqTfgZ6U#d9T8n*)sRXhyb#g^Y_gbrcY@IVV5ONTFjPm(bkq@hP_ z5j3w(Z&V;4m9+=W8I-73%>WR)Q9WymXg^ionF>rBVt2P+vF?B+M+bF*#VzrvFyM440$(SB3YNbdPLFY)Kn;jIOQ+l(S=6-o6 zxCpC^RlHinnAUamT>CN}lAx3f5MaZ8Kq!LMktXG!d)xhD5iZ@%kL?Q12KF|AQm8Ta)z^=Tvf`VRXB z>B0t1_mA55%PaPer}a;lgiJQ|&mQ*UCi~}92bPS37wiYt(gsig1M43KzI@2*J{;Il zegB5-{rCItfAt0LrM*A=@E+4MaC~}joo*27Ft}(uhWn9JU3Qkvrzw(y)LiA}#WnN}czE9HO| zsZJR(Q`tYAj_%vRAy@`q()7N&_=}1b-=bkKqs0B8D|RONWzV!E1nLWO!6U550Hmz} z&DI_6`JuP@<2Gdaka7pp?C=`aPXEQLn~wM~jP&tC^GD_w8Xyw^S_7In^6JZ`so1)W zm!IrDXEUr7pYbYbHN;Zg`=cSL%mCTq#I`vYR`H}!#C6(S*HV=5iNjR0^6Drb6n zNr-$*(9gj-pLRMeNeJ~IAn!E0A6eeCk9h?E-6~iV&<>TFLl$Ksg69Z@b97(ksHD=D zZL)dF>b<5AC+3Qn;@={)P~Vg?E=gi2MW4(}+OciT$Uv02VV<2+URYWltO9W*9&x1* zg>Zsg(nQz}E;y$no;R#`o94)`Q-NYq)ew6W5Bjza*gTACoAz)5Yxo$V?8yGj5Sp<8*HqUB(846i(F@?MNfNAOl&C@v$Y{9!zYkOObwu6AJ zCc;h<;%tk(bM-SO9%3fGY+}+*?VCo_>e>tJ@@I!^Z?Uc&kshvhJoUoPkpX6a^re{$ zA>iQgE<&pxOK(OV0Oa>>=ouFoe0bC5Q+X$)AkzoCb}d=nsdIO( ztWgsCDGnmvM3V8|sQdEqNemkuT`5$mOm;SdmP|#I+8iq?0R|gk!6=B+_S@N(_Yc>p zCATSf`m2)YgF4WY7E`bg?>3-nlZqN3QN+uZz`vd;tT%*8y7pPOdTYjRIN|VI+R7d_dB{cGT_^8&|1T&fxUE|O4{@j)qI&+#;C^QPfAiX zdMd!el|9Lf@8^oEjUO@XYC@3sZa8tawGBXNQY8V~2S0znAE8j&7?-wYEMb~(sV3z7 z_psI)T|(7P5!2R}pDwEPl`2@$tDoAj8;t{diWP5bxPIE5npa6@`eFj1Qrw*NgN!Ic zF167goq*U6c24)AXvi4Q*5ne};K2uz2emg;>cX$@f1}Lok7azX+s}B~{H^)JRoHY2B<+A6&;llffw^Oj)7YiCj!-Rx zq&<>=)QzS9IHw=i=?Ic6CmA7gLkqgmj3*Fy11ST81Tlw}u#*>7j8o*U&2}YUSTzN= zppW6~|9uNOTsX9-?{mj#niUJWVW%t@9gCDI$#aLVCy~y#?~FN+6btLS_vCpY3OJeS z!T#NdyzUe(>%t?sw9az%+!L|ad!6_;E2Da6S{dep>sp9%(E`WrG@D)bftPAQ2oBrt zp2K-2oiV~cr1=4F!P!9H;35hnl&<~cobJQ#3FXt z!3|jm^%=4z45KTl^>+(8*}QWz9mUua&V5%?`*M~&oXp)4x>TV>aPSMff{xSW@r+z$ z-GKrYxE=Rk+C3#A8mK44X+%7uX$$#E&zK{Z`bEo$8pY}o3Ptrgd3MsDfSTztq67Q7 zH&s*Kw+NX*aG_kE+Cd+F!Ug=NcJ)n36S)buDieiIVKSylS3BLQ<~ zqJ@?7*v;nfS^x+Ui>qwQ8*N61ETQ_&cP7o`gZ1M`RM0aH`xdTj#Lz4%9v=`4uJ+qSi4T7xL*#< z0>A11&U|oiq~JtE(EoPwq)gRAZt!nRFTmkgZltevd(6tC z`SyfP&f)DTkAi0Znd|y${vYnFHsARaME%g=W2it&z~W<_L6)q|I?gM=E(tf5Ztq~>%ogAuB+$fXW7x~^nvsJ9lYmK`!8TuEJyqwkgd|=5 zJ+bhDA%N3I>b2eX#c-+n=M+c*wCKrrf#(D*zW9a5Vhh~F0vi{T9)q(otPGD#v|N}@ zJke7cAG&s4*5ve^>^Dh%H?$%Y7fk^hX5|I9_>B1+bH;{79qGITZJ}HazAN&oiS7vs z4Q@t93jk()X8e2Y-n+;Db z*QMiM7UtnYPBuEw=Orn9UKV+7Q)3@PhSt`EY2YZGy!Umx5Q+w(&#)oJr>2roAKRP7-@T1Y!awM`R#&Z!G2qD`w@?*uoF z*HwEW^whrSR?3*Mhb-5$DY0#kGtsjHX)8%>J2{N~PJ}b*%|Vs@n0dCJ38-y-W^h@X zr{W@LaXq=wufTaGb*4H>@$D5;Kg+O;V>n6kBqNlyo6H`p*+DIfiwYNYreXOOhVX(U zWE{(6GIOt^VxuqSNy`;1imobsOFqvf4bR&xSykhHV!_KPS7_vbw6Upw|IpTggykk_ zF%;o_#Yf67dgptZHbl|m<{8oh;Ec+HQPEvPT$Cy1a3i0>ZnKO2%s19Hw8J1!Hqj8K zXTBi_el3O$cGimM%OwWpZ!s#m-xyQzT_|e;nrO{hl~^OHD_RlEhwoq_3TiRbYBA`0 zwjm$?>f(*rMrgZ@zu*zta%OsEJv7!M-}TRT>W^5w?6clN8tV{bpBu6i0q#n4Z-hf^0uawt1F zN8pCI!q*scAWtV)w7V=v?(}60VILRE z`9o_nW;JLH9M2JoK}cgPFgp(IGCxPZQ3;4TR}aDDxQY0354ia*05yY6w9_m!kqlt+V&>dc@(Yc^0J= z?fv)=b)6Ip*BnBZCo9-nvWuRlenVrB8*1?8%yXun3T=D?Uz08!;@CACTF?4Mimx^; zsN8wm&7B>IB_42%dus8+O?^XG!{)Jf1G-M^e2bTHTjHzZqvITUqSDhA%`{GsW7EE! z@U*GIcJ=KD2mOthx+WfNjf3&AtsC(#I5w!2?)F-J@elmzVDd}P%|u8WrhL(j{_M7= z20RO5^SvTf*(UE)44M7~c6OWiwccnaD~<-y(a`V=!YrtbMqdr#7ANbRU2pr@5ZJZD ztfZ>rh)I~dqx6Hhu#@&`Ipy9*9k<<=)BEDzSRF~}-_lI7zRB(Ih8@Xi# zyGZ8JWP2cYxwY?&%z#*wcb(mlQ4$fh~0H* zZ4yz-N$t+fGDI%%$Q8*LhGg1o!+apHK?pe1kAunF-%^anYPLvMGNvd_9ltaS$JTMtFrsW&T`BqM?*;plpJSfHL6yN26jCO|q zPmmDoa9TnCDh;MSRBW23!j6dEZaWd9=Wny%+6odm0>P6VMnoS=W#o@iRBwXmpn z9sVZ0hnjLr7UHMc9@zCpXjot2ew3SHOOPg1BNG|;!62@$Jd@OO%UJ(TSao=x_G(d{ zz4Y6sn>|5uZ!m{%t}LCK%*v1z%A0aUDz~Ycf2URk-#G-(oICR-8HYf(6^3Ga!*Pbn zyF@JR91(!uV~%2*gCzw7E90AA`kk8)QH>_`h9~ug8oaB&rx8Jdu}!Ld*k}%FGmM@z zBx2PUX5_$G>5PD066j!Z^AvK4{J(l#2Lb0yWXFd(sNt*p8zoT}dlC2^1kslojSoxCsSlSl8NF&i zS=EN95|Hf#J*qUXN}M}AunNa0X3lT;akJy8`K>}5^7 zkA4oxzTXfqdkX_jC> zjbRHv5wAkbG#9xx3XDZ*Z<*=lAqAhCKL)jp8j}CExzT+Z{7dt*uSZ2c4(r;@_ogp=E%M%WsqxeF1K zVGdhkm$nE_N7R?m(%{c}2?jG7z)gv8zP8YdMUeu7fKnzL&qj4#P=#;J!jJ$xb(yF? z2z-Htcin^Pkxj41N`Wlq5|!n!ur-Kd&~u?DB};aO0Yj1?MK2&E@<*BB5%mo|t6OI$ z&WDuq5#i@aFqfE+ zGjX15bwAH0_4QO5e#-OZxK}vw_O*#Su~vJhCT^-t_)!(4a8#t0mZX&;3vOElJhv)- zRG!3VYyV+Ff6eO5FDuEKv06El{cAcj5`Ht&?pGMnGX;VLO^71mq65*N7<`g67(|F{ zgG;sPZo(o5t)~F6%Y8Xs8(kGk8LcdLk`CA`JsVm?wd>9HLzh z?8G*3q7C*m8yVsSVF0EAd@YU5KfL`B3UZm2QY5AKv;K<&$=n{M5rN`TshUFZH#810F zkdx0p8e}?vL)f(RsQTuZ>o-lZlepQnEq|Qkr{kYzv>eLti;vaCXAfrI@5*41p9clY z;NaXz)_m5Q`P1Xh{iJ!0FEng>^Eg(QTCI6*`2}7*7eSGQGwuug{tHx}=LHfMgz{aO zZ5M>Q7evPwa)}FKdkYfz3*v}HDSlU8)b)Rgty+uvdB`WBWmi&@GO-iPcTZzz87B&mDpUJuP68)q@Da)<+uG{Wg zH^p;H@it4vSKOS}v*`1qZ10hm&`V94(%6~Whvb2+TRkE8#?F=bE6E_-Qs_{ z71Jzd60^l@=gqt4RcCm*VrkYN6rSh_Exh;C;+RH~kvDgZo1Pe~Ch3?H?@o0RfetWA zwdOUeo?E0@wPSnzu5CGaaM^)B$SOO!`Pd-wmC2^HQxU{~ZPo7%Pou|GS+_&cNGgUH+0u$Dh~J+x3_Oy1JpEVJ z?tELz0Irzl<|XVQbq9f;wnU#_WiaQ@naK4VgEN7IV5NYkC1prm6@;L#!drX2Vu*Lp z!z*cQ>q&y^4(eWa&Us}7@JG1_wAKh+Ycxx>rhdipp7j;YOO(j7ACYJ9A<=7L7dv&? z!$VoH!Tj}bbl}-OzAkIe%{OsZ8=*zT3uk-PiMHY*i8qCko)n6746s@wYp09qc8bD% zNv|S^=tKU_lA=t;tryXSo8h{YS~Q$}JJNWLK=omZm`|)SFXT9+ZeJ04=_&L0MgvII#I3Lbf0{AHpsAdHKA+U!4AlvZOa6L#P4j0gZ0`J>s(F2;Bc%I z<3 zurNvPMu>C0DU&Ie+Ut-yBUARpwrqum%&2iw*GhWXMS6d5Eq37g{>*iFa;31&g^#mx zl1wrXzx6~iwI7b!l?{%;H>Uoob)r>>P^88TcA{g#qFtaWzGv?zg`z}^Od5aKf4VLH zO5RmRUQEH;`>C93m7Hcx)LAd6z*?(cG12(xnC$C~P_{k)s!_MEBX%m^K7Eh+ywM#L z5b{&af5Dx2Gj&}}rB4P{=Ug0Rom%7L;cxLu=9cTv=>2Nz=L%-GRwT!A6^zfI=W3B| zb)ka(M9&{VSox*&D9Xj`JwuU^aQsizcR%lhj^T3@yj6a=j>tz46dq|aDxBDuWe2(6Gl3>G)=LLRsv}*A!;UA~ zvLlhXn6V(7N=35@Au{SBk8+Mqqea0t`D&Tc$0F6c$V4lp7bn`}WA?-Al_To!5qtJ; zwMMBEg3Dipp|`z#%XO0pDiM>vL)U(%SO-RU9%L@oN2i#6gEwx79^CyROD-o?X%MR? z4o+4bt@zkrvr}v&N9yaIr#j5{~8~6^R>8;9b&Sth;T%1<@ zK;xH`ZEX<7lm%d=6!|qKF281?#5sQv7gVD z|1pES;fIDh)TQE+8^3}oc^j|QJKS@76xO0|*_zy}b#PYJ;GywJ(x?s1UnQgb3m3+q zZeVG{DC}u>Idj>A0eQw(8hZLj_3{bpN>-_NAi3aNHFAEBh3 z&lmw$i^%Z06II7;`0Yp1b40+0$|cFsIYAp-&GWjPXKawDlz4U1&lf58FK*pfmS;cK zThi$C%hcts7QIW$sr$D$XM5XC*FC#%<;k^m1HOzW*9!Uc%(DIX5*eD`Wephd6JivAEWFgoge4?LZm2H zv)lQ2Z}zgNva3F^Q14N6(PsLl+trWY9SRNMiIbaleL+}XYv=H`4SXc$60PU%@7U1j zp-bjJvymBO9=MeI(zHFH!Vo03i|A+P4a8Yu~Nf>y_tf? zP_d(A?kETb#J1tzcWKV)P);N(rAgv zS=>+=`}ye0`eaKE{20bV+-~s`W=WM&ypQmO2k34cb05cih2FX*^8TOrwGxp=r%CyFfbNBc=9`dktB415M4dH(3=uv;ctA>nr(sLMe zr-#XCzdbD>+>xaNqd22^WwmrKw09$B$xT6{8!RmCk+jeuh}ofpC(YgPW}nrbE`TS5 zDj^I(G-EKB6}jYQ*vI+ZEIyzI0(UOztvhYQAX`*{yGFV1I+K>96ThYiA zG7*__j-_PkpN|OcGsxD(3JCXr%cTv^@WW5w6_>S1N&Xy(YfkfaNdvnfO6SN8t|F zgGquiwZ$6}J_IJL7{)I=6iF``EN&4ThHxh%O~H{I_M}kx39Aq-e@xJi_=3}dkZ7}j z?7U%~9{6)#b3;vTBFo((M7Hu+5x*O=JI7hdX<1*(D*iA=c5Cf3szXRN-!>MjbITfG z&X#Duxsrib&gagj3_s^AHM9BXWwmO6t?RHT^Q+mg$`ZCHGC#}Ao=K(uj2uQJSd=^y z+prmF95-VQW9Jjp;!$Lv;hW1+(qs`f1K-vbOUjqsD)q*jrcRI5THXMkw;4IVMd`C? z!JZMXS_>GBhg@}~IBin?|hats|COzJfwxc=xw}rh~wrrWS z&7Wpp%oF7!UNI}S9hYBaU{~zm)%=D$qZ-Wcd%o-r%K_$K!K(G}$Jcf1pF>nK_3iYg zG$$2`f;HKwgBg?}xZHt|lfUNKV$T?;xKJcRRXcJz42lh4aG0=SpcnH@c#H zh&^Og*a-eEZ%hZJHwZUca#fYiuZX=7!y=~8Fu;<9$Y$wZTQP=n5Hr8gZevGOXqDbu z?#**KZU^%qd5j-3RQdIsPEb%e*?an~U+ncX*J?KQ^pmr`krFRIpH)aPsLs@()deO3 zfZOACaWeXGFE=kMsBoXW_A#`obPE)%QXQAO9xE|Dqxsx7di#eqqHY9z$+hU!M-6|4 zCd!R#vE8p`_gd;~n#A<{ew%51tWWH9E1+k~r2cf-o?2<}M%>lG4w}=*>h_x038GBE zEOV=3w~TmVyjRo5*Y#bD+D@x+o%BzOlsYb*6CXY(yr08*U8&FY*y+K$4>`fub|xR2 z-PEj3wmNLL)cwZY>s9yHssbB^o|gGd4Bh{18l*g$BYvBVd$4ZVs=TEb_oMVy^M)hW zy|GsDy^5UN&u$tj(<5d6GvN=md|Df)KK-4A|Ki`OGMmNtvuU^a>qCml9PIo~Gi}Ru zgof$@Q+YrN!^3ZJL8?o<62ISCQF6aMYX!rY<-hwMf7}5_6W4Sk_JfDVt@6OJnTJ1HC~DiGkAKb5w*KnYP~UlLdblLh`g&eDzJ=vOl7*IyoN zWnzwT*oUvt2yO;eXB0gpi-A9j(FeyUp2eg-50&R;Hsxm7%V4(TW_6!GaL8iy&pHvo zb>d+bTipEjaPE`o-0Z9wC-b>a)y{8~W}WKJI-SpXdLWBqc7A1yoAV1d7rPT?j~j=` z{_;5s$C}MO&B4u?%_Gh;&Bw#5&U41)C9fV2pJn#2Nj9H*Hhc^D{JE?= zx!Uqe%+k3!dby0+xw@8aSo7So?z!|XxqALfbT@PLBhJu<)SV0K$Tc2!gAC`ItmIPWb4~Y_j=trdNAMj&^6;#e_Zjlc`15{o=b6hd?@8rd z(Bu1|m3Ptd@~&B)g?rvN=R8aQ<*zsLtRncn1m{^NUfxW|v&rZC3dympT|QTtXV<-K z*pX*HzHBs{=diMDypZR(w`{VLcL}j#3e9(7T{+K~@65k~=gD`GUon%;chy@l*Uopd zT)AMLf7yNIq6;P8-G9a6X1+(nie*T?XX1+06Yvaj#X39Rt9HevGT*y<#WpVd>bUz( zR^GLhmAUqOpFQ`L`TXk$55eJlU)F+6NWl$$k4El-oALsC3-hA9Ryqx&>m#Jp*S2b)jec1c+^{Tsju1$6zjED2=*3F@i-%XP zMHD7=^Cu@>@lF>?$+xsoU2#1Y%H+?_ zr1x@WEy_ahXY+eq5*N-{$;(mqa?mTv?dHp~^s;jl&d<-w_wlmvFDi)OBSm;w#R(U> z=M|=VS>zWL>G2iUdR=G{F5%Aur`FBJi%JoEWh-9izX+GFHx;RI&#iIvKI>jwt9Pc(-&^OQNIidUeVn&eVsQiFOk=*c zMw!T~6-v&l7H_rg;-+ri=5cS8S&^3foR%-%N_)kv5xi}Ps|xg@?e014oLA-eOFHy; zJLRv+sEc-q=X9BF%DIqUrwewwmqh!BzNsa>c_6MPg35{8nCOe9a=NFFqh^6|MPP7P3MwF(-iA{|cPNj>b=a)`@5t^wj&1ez(fGGMf zAeJ>=`jJz3cBM4?i`XamqEE+SxrnkkQ{j2mvOG@l1^1!_aq$BAvc-qOOL}D_Q}N}* zqGd;lc#(VAN}2Gge_8QE@wM)vwK(z8#Inz`!t42EWo69;^gkKO?r{7@v@3p z@h|+vU%rS}?Uj917uiOXSJO*;vn>9`DN)N`zT+dZD_>rxF7Z90_`9h@gJt=Tbdf#x z@ZtK`JGtaoG!7yQoLU#(OO%6a4d4zUEVezal~43G%L}uQhqEh zO4%##JeC0TO8|`IYgRJkl4v-;!eFP>PLG#`d^k zJF(*A2?_QBshu*ZQxfH;S}S(DD^BZ6a7;-3n3dwZRL=ReVsEd4>y8ADTKX5gH233j zZm!DT{FOWf61)o12kO#iTFcLzuROG@ek^D6qQ)je&X!V*&6l&QtFdd9vwu@#|6a~vqQ>Eq zoa1VZ<5#&$KYutHoz?t@yvu|4Q2#Rw`9H|}=IZR{rRk+l<8vQ|!4f2h-KWR;CPsQe z<_==_H=T{Gud4nxy8R#8{@-0Y=+|G>m4i6GwyL%ay%;Y#Q==h&C$A7eX5}0y~k9hhx@_%I`|HHfgDMyz62YD~N^q=Y->^1&NsHa)~ zsW$#!A$4#+B;mq8P8}>bf>izSqmch@;sd~}fKRn%*}s4b=Zn=??ZF|6upONk}(mTd)fGKgqkd#y{#^ z$3yLpdUsXPbOC?-Q-Aye-$Ban>}cg+XJKz+ZU^QeE%E<&ceC?`U<2}>03;ZR{Db5F z%6K*FKQTx!014I~&+4fCyZiVDxU0yk;N?Nc{%;vCYa#&#BS9GtviN_)cS&6lum|~X z`wk`{g_QUd8(n)Bp;h9UnSk$1=c?~-?!^8XfwTv@iQ`}(OZl$Oi1ss3Ac)5jL|fu@Gt z)xjL23{DlLf#uOE?@t5GuYP`=OSPqZjZtg*y}LG2eCd75&5hvYnRglITU(9}e!Ym( zNNQ~bsT)Et^Ty(9g}GoPKWQ$6?)dV{VDoUE`LHZJ6-eH-HCbp*`x|Kg|B}2b$@Iuy z5qc+DIaoC2ZQfeOR!Ba{Zc$P(nO9#dP`1a5q9j zM9M<;;_Sqm5B)*%zFB)}10?SvUqSNzLZW4U-P@-FYxjRb-qoAF^_(}kf3c0F`<&!U z_NOtaviCP7J24NPct>TW-m4lYXibqj-j6sfMq;E(;vb#Y5GeJ-Ywt>iVnRG&&y>72 zaW&_QO4_0?G|ZWG(p^sDdDcvNCKXD5-wnn*<(^Y2Z#%c1-A(7|bm7sICl~^sxD-iI z^Y2u<9omhNr;L6dF{?_9M9ZW}8TDv#(Ek`yfBlENkCfJ(SA2KaIUru{wexl?B1$ZI zCgNeLjQpWwv~cz|IkZPjDYO%lTe3q$U*0}s601_J3=vnK3r20GR>>G`i)2BMA{c98U%zOYeQx$XI<@}4Rd@94S!DRM)8rqo@PgUOui5+PH`OFXqngL}Z!Bi&LF`}mpb z`GtX;P$!sO zd7g6YNk>u+Cz))9cLZv$M$10?%%py1IIX7XGG|SpN$Sg;ead>-0nPn{3FURQh04rIn}XEM*HR z1Zj)oG=B)x_7pq*Tz~iVw-z<-tO%ou{3Q4RFDLeco1Y3}>7I+_!6rD)hy19luG{&y z&i2|c30A&~c$OMV<1UsE-IRl@S` z%1VaxyExvUv1WmbFAnv73zeCx5cr*kGG|Haq2o%Zz~y_H2i_dpE1T#FVB}M`G3&L? zW)$3iR{G$9^rK9AzEdKk&GYs1F|ob8TSU9(Vhy^tvmPQ@Z^P{`@KZIWvY8&sP@VDr6uWLGMNrE!Zk?0s?i ztqB}Y(#v3wUaj*$$>3 zOws=NjlQ@sDV)pFDSG*{f}m#-1b0#DQRz|GZDs6hVPl3ZTipRfD4Z+(#goXBblNOA zU}!ME7{O92oM!G+6hPO`2L1qn!98x+D4j4SD}}81h#zisAn@ z4C#sG4G8^D@~*8Iru&z?pGXQds1Gxq3^V-|hGz>m|0fLji`6jTU*z5KSGdz(@-CJX z?iLW?nH1qwA93|Bc^CHl?_tP*u$%Rf4}V1x*oYy2$vbo?G9rl>UH@NUNNFN5UNP#K z^}opblccD$$*AF_NcdXJXu#hvWI)V#eeCRC@}4&tyI}p7yekrylHxWe32Lkmv5niC#8|_9qEIJj@#mL!L~yMnkwB6>^6r1Z2^-z%Zmu z)hdM-Br}08$6*OCz{lHBY)Ix*aQ5BQ z?z?ICiHWQRfb3vu24M&BCRua&T0Xfk%rWx&5Bj@^AZ)$sD;6>L=-lnxKj zwLz;0X^hHQ3`wbq-vEp+rbH+eh6IGLkh@OFR|tT+FM^5;L5$J>f@8UPlWx3hrBRsPZQb<4(HmsE99|a8G#efR~u0;qS9tF$FS5n4~ z#sk8H^vWrW5)KH#V~enWU}hFHixx=%QlXwGzzO~VmOFVkOUq(VTCn;VLZ05kQN#MMc^`F6=(#Za7einZdTfy3~gmEfr7cOeT-;) zDOClUYA~&ey?kdnUN$9oC6k)pDG3F@SFgNKki-Up2tJs$2t@F0=;?T}ETM3q0b`_$ zQmgP-Ra72(fK|%l0Cp# zgYA+L3JuxVkX-yZgHaQR_+3T!E=7S%ZUYf~CX^<%dU}oya6*886dpUQks(&UxSHl& zwW(bWZ)z3)0S1Pxfe)34tJJ!qs+4cM*aSa^xLnGcq}PR2puvQ019vr11-^BOQR#4B zAkY_J#F42W)gc@CX+G)DnN+n9R*5a5J`5ukTw8cV%PA#8w?3ig5uws@nljP_OByR7RoHJ2VSxrj7$ z1g1Vaz*NP8SUW(}2}@gC^3DqiC(u$=06|3rRD30Z(p~{;ZzfjM zr_R&}zN!#aY1%YOc^ya#5E^WaN?g~2R_rQc9|3}R>@GfO`~Wi<4`9eeuq-HzWFhF_ z8{5!OB6QObJL3z?-fR2R&^8cNwK`M3QB(?TFWsJQ!5zMMeSqP|1KIeT#yNlnXuS6B zRg4N&$O&i+#K>j>@w6#k(@n=TfM_NOnt-??Oenw>A`da8!a$N?iJCE1^J>wnNA15r z;28~tU+pSG6?3P(zNyr)jD@^NP6jfe(0G7hy=hPd+du#&8mK+C0YO}&?LjewfHLk& z_86r0ntQD*N^l1Ndfzu5a{$A3Dix#)feqI4059tf9 zV96ywrSZF9+FnXqgLxm6RuiBFvYt2fUAg=AQD5KS>3)l~K3D>xMznu2t>4I?fBHlJ zY|P`{-~9`!1KvITOZNxX=t5W01~xwo@YfG~IsJa8BzRl({oeieSnKz{n%*DY&!7MB zCk!b{dJmF!gh@H|%Rv-J2)uca{=s`R7>3*$U{D)keK5fCV2FLI|77zJr&vG7(GZWr zTW&B6iSFQgFf1(AAk;i8{;^W*XjodTSc;-HB5#r<2ZkZ9r71O!sBg7U4o9><4ydV( z>NUUCdN69(KXk5n)D%5zax`jgGHj+cW*Iwd@nFohf7qsZ%n?1}a5Uy(GUBW@?jAdG z`N6nX|Hzf*aUb+(&HngJwXCyh6aFSoZa)CMd&1r3iHFw`9vn@Cs3lxen~ZqyJov$6 zv{-yp^JHA}*)TR7vq@ z!sh8RwV2Z8Pn|_go#^3;_H*nkFIW}CB0&d_%&ho?LQ~2Voj;}Bg`QQ#q@JUNReX73Sjab5)mk>j8}kWT(Z01` zwpIw#l`;(+myV)<;NxD%T=i&6E7J25_1q%NjQ%kJ4N;r{rv@;RNLo#l#(f~VcN@F8 zji5jPEQR&X+meBO)_wXScovGJg5}?CgJq#V-rH_#nf*W5yU(B|_h2l+b%oklv(77X*X^0t7_5fJpC6iuB$)(yM^drKunaC`fkh`+n9s`&no0Is3fW zFZK*G|M4X=;xJ>F-}iIjm7SmyyYTAX?LPqItR2VRkT6=1QU+tl5FoY?A#WbwV<`Om zKGq%5pSYm%xrfAn4I|ZF zOHFVA?71Ox+KxxofjBxh{6=Ns)@Y6?;5R<{R>9A+2?-Mc@wK=TvqFeDS*6nJl@~XXi z?h)}tO!sf}^zSvjo0zG#mf&V;LDFw00Pu99P0bD9^xH>%Q4nLtRl5GBaung?yvqRW zsqO%vUom#A&L*7h4tkwtgA78PfGMl>f9jFIV>rn702@9|~-TYCT-+O3g9Jd#+mc!=J8; zZy$9>F<6|Vk8^GMlc{ha`u)LoG`SsYA=`Z(crNZ=!9a*~x&xU&N9hX-s3G9{^>`X{ zUuSXVJ(;8afP85HBz6nCRB--R68L>9vz7koY(gEy&0{L=&e20uc7ZfC4i7}8_=&p{+DHG)b zzdH8OyR=1eXq4stxQMJQE`U5aWEhAwq7B0WV5SG5vflh%=S^qT#%wa*{` zO7!<>926d~KY(EYU3Eunkd#h=EYT0rTE>0uim9`tnF*weM!|$x5IAT?*mgD@ zh#O3aO97CyuIa{;b*~%dY(Hl06_Ne&jD!23NAssB*(a@^ORmbF9}QiJGwa)dM#R43 z$$9nSb9I~$ev^$B%{$nS5%ieY~)Namm4pz&=0xPgjSccwA1nGJT5 z83b2i8bN~0`%FWlL|{4{PVKh}q(c&8h9OD$PWPGdH+w160^wxlU>1CIsK#hJuj7exHm(cP2LK z4T}foMx?EuH_e}sP2%U+#Dc{3*bqxi4jcWL%FDtyD|1$l3K)fC5Lvt)1)Et>plakq zQbO2(1(kEXdNJu;Zs)2*hOIIRRl~sq^~daPM6X|IED9uBv^p3wf?>LSI*ISfiwu-U z$yE%wQ()4&MiLKFjdPq*O)%fKhmT)95OI8!J>a;aaPN&dL2>#Wo?WD?bgC`yZrXrl zk!$F^SB}-y8HUd|4dY*HxrU-W(rXWjMV5ttcZd*o#<^oyz)DzpiFaRW%h`$0*}atF zM&Ky#D+_BD=sXD`&yp+WP_#0@CAG-|`I6!b%^mbd(clmXEko|!ol#BzKnVpl0Oafj z36Jp{GkzTJNq%m9ANMhZMW!K|BdZ5N<$y)g(|{w-+6yFZfe`k(pcH-`=)uzlXlw(q z@N8=F5<Jne^JKjG^e(*hl&|*M^i-;wh%shf6kMh2Y(s-hlz704PfXgBSL3)ZIG2=+# zY!5e4topXZg{mc5R&*<)ri+aUyVdRp9(;ZprM8#ceL#xS8ENg_j<=9+K;1IZ)h*NJ zj-U6T+`?EKDqG~Sa3x$D?-e21JxrB$DRDo@G$}iBJ0KTBI=z74_svlx&vGT!Cq0)t@iU5qxV{a~DlkBVV2b zYuOU6Vqq>a(Y4X!*lXaEmSF-VZ=~6=<-TTm+v4C8yGFU1!M43u4uZ+YXuU>3< z9TdeM1AId6l`QS@Vsd02apaHfJCuN7g$kbmG4TDbvIpG^f6clL3Jt~x>geABG zWm&icP|>i)f!z^|1o_ca0*Q_<>;k6jZ=!R9e;+8kx%Zwt4_++%7Pnf!0h?OiUs+`R z5DChhMhl4*Q2WGz;k`59m|;qh9y_xa-tRpWVzUmiNM*O5vAlTlqmYe3SB80Wl!zr7 zm$aAETi>RW0C8S}#(Jz_-SlBeBKF-;$Y(i8hm0mGSarF_mmKcQnJvzXBEIL3AGl@C zS>OfcqTAxsb82mI4s1X&VjFm=H~mb$EpWlm*$2i>uW^X1Q8FRI@sTw2n}h~ZL-KcZ zBCD`~F?(Y7gr=WFs@P#Kn1iS#NSDL%>~YXHqx|wYCy@cy8*G7{(CS60tY1qZI{NED#134u3gjnm3!#e|7C38)A{ z0H+K*f;L=ca;ziPH^v!-AY(>a=SfGBLa@bpX86Dkwb&It1?}5lLQ)*6?|8p_8l_J_ z(zWssz8FGx6t*RZ+5to(*Go|Zr*#KOw|4%*nsDD8)oC z<#D%^Un1IwbLfnR^ty~_AE37Z;DgEHVZ0n)e4U=~H<VQBSn?aV8{0>x6J64H*04z12vc}it z+9MW>lgo^`u8u~!VCtdhgm;7EW2z-^Rj7)}9sU@@xB(5KQahD?FWRBbw!yGz73qN? z{QUlTP!Co}FOgp(2`nmX9|`V3##E}A_O*wYN8R-a4@4<>W+{2m4<@6qX+T4f8O643 zZ~^W@5j)vPj8^9WqI6(CPVSA&>qWv~k>(>34NCO(UNGT3xKLu}gbzcX9 zr+(^z4Un+wVF7{>+2a^TR9Bc3wuB2RlwTu-U8oG8K@zC0<_>y61l4VTW;uW|;zm=h zqY>IlQWHak^9<;^6thFj$|v>=xoX&SI1#BZ#F8Lc3uK=G$k#-b7^6xZloF-^=?);t zJe3?2mdIrc^)mJc(XpT(pc-vZtud(e8ozC=^4bD*ACA}}@7lhKu5$;~JAn3l73|-P zRX7m5#ft{aFM&j zP(T;i5flKmi$W=t*mW5o$S0CvKpc2^X@mCsboU)V!qb52;D&Oy7G$l?8b84&;AAAJ?v^p=XkZe#f&Yc#eaHB+cl5h;SuCvKfI1+^r;V9o?#SktaV2TE(2WjQ6X>~YET2X z1(?1eE=k%PmWTukMMVk~niGl(l)=&WOeT|EW(}`IBVCL{f(^~y02ERhKgd>yRRUx& zBa3C7w)&V!$XR5qA-^NYAp;;hjUS1^=O2OBXw~Q56$3KFvdYg!>d!uU0}y!dp zj*g~;UW;KZi=5;Fk%3Xf`vB+(5~hzWm{A-A4+r;PYkJ|D18{qcupNt$+@RS=FU;Ru z&@6+W8wNbJG-Jf%=#LiBPX6~}FjaasK!g-~B(wTQ0Wv`o$h~2HmQd5ezbeQ<{et&m)=f2&MXK1|6xOxXDF{L50~ z5MCZN8;+)02Bg1?_MqgLH33bFTMqmLZBsroNgeA7o zqH6Y4ms?^^+Y(gU;!n3RXYssI+tN+jvUpoEL)-Fu_O=V#3V1t^_5ghwfr`_Pdejbq&kn3)M^hnBBaMLScOHfE>^2X}ePF5{9-U4UlVOv?b+3byOaUch zthK8xU6AcN@2yf&R@UV7hc}s=ywLNR-7emjZWIJI3Xb&}j%CXQB4eAT5_XZxn*s%v z`G8MZL0Cfjyb7-!OZC|#+G5!%;54K-2XABDOA_tM=8DSLjM+E$r9a!kRA)(@@&Z5G zGQP13jdja;;HMHR#1XE#4^%e~2MYngJe;Rw-jfQ5l zo#nh;h+19BP|mr_F3<4jf}RqDt7%xrI;NLt-``io6W8*}d5N%c@^|1LdJzqjE0-U2 z$r*_zvx&fI@6=^`^G3HQHjOiXQl;SBW%(C@JlSL7!Hz;^BzQ#7DF?2KB2AAX$ZLw# zc!y6jg00Z&nvf^vm>BN!X5WSAO%0Vxp+Ic>9=J)tH3aLRWP~&RUFm=$D(~)WfjPd9 z3&^iWxRAM`@!bQA#lmrd(#vav{lT}c#U@(@aPgh7<7?oxYFp%v;_mf}op?{oS48_Io7WnxWjrTIgB>=F zBq)P?6xb(P=W4CsNdUPQYR0}9<5@pPwG@=&hAbx1mM2#njGnufm>wg|zl8NvV(`W8 zE%#khzCA|6oSw3_`10+XugNr3sLV2xcOF95(U$`J&7p3=F-M$MdtYMjfBS8K(=P7% zoIoQFFi1~;TVID~57Np2>^uT-mrdJQAer@%dR|D*<@rb(5w5dx!l{!x-%iLQgecz# zkt_*6^)QP(iT3x8`EyutK(juPMdYsq`LEpN6Lb?l<26e&U+)E{WseDUWx_z8VZ4SlcfzAH0DeU7b; zAAE;@cE)25Qg42D*jD`~?ahr|UMUjq7^igj!B%JDYLpU?CGF9WaGWB^ z1ZHvRJO1qo^TD!Dqimjf6E@jye=peY^1jCB-uUDlt!2*85GX4)8shI{-&}C{)S3E% z!80H-u{l9gJoL!Ow9@JMb+cw>5m>7E&dxbzmE8o%LOFJLYwT=IF?MP#0s z==%np;3gzf^W*+Y>o1b=OMZI1_5|MVb8%X8+M$&xQn`Un`TJe8hX8BXUS6JTIgUnK7 zzZ*a6vwxPJ!CvMmU9JbJbOu$KUF4hN(7e=2l`&$&8HX=D*)~%I*{CF_N2j+%!Bh>&WfdUw*W&wMQD03b^rD?+ehOc znw=M;qKTP-k(W_mm!C~F!C4ZW7qa~8W&}Uc;0q}JB@Sj75?6Oa)Mk| zpezYm6pX=jMU3A0@L)!GmK^wFulpX98{P&%@yaT5rd!sGfU3Xfh z*+vjjTwmoL|JyL+zmj*;@{XqOV}~Y>2a~CHwBvlSSQ(_qIZ;rV%}En4)x-_A-~2-$ z*D1hnX7T61X=)E5s5koPxZ7c)4hEUD@-QcY)Oj;-hVtFq#bj* zBl-H&d=?!vXr71U>>-klqqc~WvWwx8`MIrZ70$Dl=s48EmaQ{+&o-Gu9=r>6PIh69 zHT)&T0gY|Fx0m6gMzgCFz}>_Z{Yh9Rv5@#7W*G9dYa35#n#eMbiTgYCwBRb$OC=Gs zp9mHwovsX}9J$n86NPc1Ed=?g9FQ27dFBkkB0|uv|H_bf+=|+sS5{l>;m4+3#hC{j zt^r8K`H#xqR-Sph8z)|I_t2z#LxG9p$%xwaokS2ElTYwGD1=&|{0-O(L2lJL8o`Dg zXCn7(!|)x=N^_9EadOJ2{f_Rp2(83cRvWyH{-X4OU|z73(17#=g1%E!+x&^KLHOy< z@F2`EBu{0B$hh(I58n+7s>PD11Wou76(2fG#~CC^P~x(Z53=!@3~6tG-|Jr&(uobL z!S-7uCTVc)ZDnSist9RgWj4O>zdt=evS7)UZ_HHOxM1{Cq*sVUoQ%HLSh*65j-=JQ z5u(QSp5lYrjE7_bE*UO#jr>9F5Ojz6`lq6kY;kkmbX4c`GR~jQ>2<=+T3mxz>CoA| z?0z|!gW@-#zl#M6t!g#g-4iF358Q25=Wt$PYX6_}m#&5eDAIkU9&G@T(Z`mp_sU1#7_oH9HrfN#%uxywS|KPnO9?wM^`? z=r2}^Me-Ne&q%r;dxc?%zn7^Mjx>r1Sx1JUtilLaBCn7E#3Fu2EolhIR$&PGQ7Ls; zjt_H*dL#=2a+xckyda@CO2c?v(~uORUNFh#z?OD+cjWOmW@_z^-&xa>E(f1HWlr5t z;+A3(bl;H1(ZI*25bckc_hMEND}W@TnimY}Vof`?4veE!?O!?EquLMu3N$1UFt@oE z@O$`!L>bpd7FEfIUN&N}8^AmF2WcttuupF4s;#tvOag%sxK_5^q67{e>C2{HfS6ww zwP0Zq@&RC+Fd4Lqk1d=mLILkC3BLn?{)<*@SqEqc5my)I5In+4_ly*#DI!FU?kVDw zJ{XPGzsvij2SCq>{SpidPa~JwF<3l{yd(DB%FC-au{8cxPe=U@t6+Ou2ZyfUpMayXv()A3sVa zit7Wi+>ituyrki>?ge+te7){Ag1XfPtp&kj)fY%*)lGb^Cz$wbv13c`3LUAQO9GU?r`-UVNo|4jVFMv~e2=;bfSreqHNl%&Gk?A}ci$`!m~qM=Bro^ENDT1k zkcn6Nz6}*)TWl*T%%{DExKvzvFgdPrx>qvaG)Ib>||Syki4TF|cRhM zz+#6$KCh%y@#9Zl`gUwHTEFg7BsP$82N1la_%$}26lY5cy(gmji&C8U?Dbs7o`kdJ zh-dX#_F2bg1+?^Z1aW;9VduV@qRebc^^fAYtbOgjllPzHMxBS*K5Yy2)&C^#2{Ma+ zCGRPn$4-i}E&cK5jrEw@*D%>NlA`mLxz4|n_jQH;Oy0w~E_%rSN#0xKF9$@s{!ZR& z7&k_Y&|TltFv&Ys!{vl;*I5p_V^fmk_Y55apahe=8|eLBsQ<`bVD`X050n=;fLHc5 zeeA6Z`N`UapY#-hcRUy>no&5ak9LE?;V_fi?M11=CsOHa=Tg6ouz9Jwf z=Q4ZZiY3`X$l7pi7&lSHm|_DFM|e2!sTjg+4BI#`&l|h6KE!3lxBCbPkHt}-;bz$| z*y#t=V&Q@eaacbAzkk}d$qU!tFzR7GnBEw5$vgDVBUbx}|5OvCnNSIf6w-!^z_6%V z&`g96V~Nt@? zgLFg)MHgk+xoZO)Bm@6?a2AZb)Ch}I}Lcg0KMnA$WRuIDOJrw4BT>RKfcLq50d+zFEbCW9LlPaar zt2p{EIL6S~(Xjin6e(kZc!l?@Km=<@IGY$2+kF)kT?bVw2Q@|@n>K`Ond*r_FTGqo z1o`T&N*`AhMiiKMsx+=Wkk$6ZCSsP*8OIsw0^KmFXxfj;S29(Oh;&IL%q-f8Tcz{$S6*3(`WeLy|xJy%O zBxk9Fx6iTY6*I~l@fg#}3k-M?7)##a(o-&dLICs>07{!frs||OgF9L5qq3*Ju-V!4H zMc23ri)fmRQy-4F1H@-8letum(tGcS>4yigz$)Dm=QCN5zm2= zpAup2d0ygq38`BZMdyU@S*W;n-b2Sbqz*gQmVny!0y>Ut#iC%l!w$#zN?vi0 zzzM_S@p`#Ljgw0E3BIt@xUPZ9Q0`;MazCgeBT*O5*KlYDI93F?H%Pg6EAuPuyXli1 z2L`(L9p*svD;ulbS8XecQL#v%ZlonF3b<)x362-~taz!Bxy-0K!5@|JDRXDk?il<{ zz3Huk-QYHOBO8?e7Eqr87Z>xLA$UaeVKW#h_E)G@Z_^6U3%^poswoFniqU6KU>yEWafQe{Roq_PN}tdIJ5I_Yk#LXE7=?;)1?IQ)DzYu;px!$RT9D9+}l)1eV0+2nvn<7NM;5Bys8Exggh$|FM zw8woXl?QA>%Ohm8W+)&(AV9_{Cp;GouLD{LZPU-QaTrUz21+vwNIMG784kqtc*~B~ zLHJYs;|-Lg=lFi6immK-JE+{(0WzT@WKU@zxbL9is%(lr$Cd#8j-IL>!3tF$=$-En z<9AG=r}6Se{5nS5qJ}sYK9u7;RNdd_AD<#@Qq>Skk2FX$Hd$z8BJIegRK%$K7gGIS zQ&l}v7Z4rv5jQi%a!r*n@%w3_21#>xw4elB@}gYw=2(hj7qz@EN_yxmdS*h0j(Gmo zEV$Fmq$JIzj>^#yO&Rcb!TuhVxNNQH)DkpRG&H~7XoOnKS%n7G1GJfjQaGv;0~K=X zLnF^b6V8rrB4h|1vELU<%`G)N6y~=Un;U(HATmcd4!l~(Od~Ty>SV+{qKT3o#Q3*L z6AuQ+GAkJ}VOlh$w5sJXQTTimbussn9@FOBM@k0Lmc`IYtqR4jiSdw#C+z;H&;h>t z^N<~AVR8d4$X+Rs9?fycOlo|s$kSMNU&V;e!nib3HywFm%z59+|Kvxe5%gzm9oP zll#(n=wOq_=cXs$Bn~$uJkb(f_!lLYOdk(77m5%` ze{0F7_|5*#k^uo1Zl28n(anL*5`hWL&vFB-(j|jxB!kbYgPJ8nhA+apnnRYFp9e5M z|I{4%P4Z}0G7K#V56<(!mqJiq@?A9}xLU%|4B>(;5sHCp;!;RGsmSzfq?uHdb08H- zOH@EhwDrfxuohH;)O4g&Os-U{*12hs)Qjf8kFQ%^47bFs(#1`+y!>=ozaSO=O)5bz zHU2^>5g+~Q@VX_Dx-}_+KANdDS+LcKOFBhSI+cz-NnQGt*)vn^)>qE0X^OaMo~@Wx zL&HxxBSHGLS#wIdbY|(ZF;r`2vvg{G>zmcF;o#4`?Hx<)yEE;bpRcwz+q*7;rQfx8<9B$Uwf9hmd?M@UFATZUKQ@>SC?DE?iet;t~KfybiS^$?-&ZWe&^dU9DQ9M(J_*K-H_5TT6*1B*fG|8 z-BjN(K78HW-!ZXt-7?ql{_}O~cE{w!bz4o#6#gG;<&J6UPM5Qe87_GgTj#8#{0>>? zoVt9gQs=zc^LWwD1!wsP`_9FH=h;4;OVQ7f5uMBF@~Z}&E2YmpQ#wC1|5?iKTpgBA z#_3#J`m^5Kx&FDcWTW%rMQ6lG=O_Fw1fXk!IuuUYwaKMm%G|Xjd1EZpwXNP|1nJr_ zLx&pbckMcN9o2R21>E%7c72ZSdOg>g!Z-5%~b=2G~w z6uS9I;VXXE*KeVpF1o&bmOsG{TPIOG9qv433R~ss{$477CK&9@}kt0&+;i`@B{!ytapvxXfu#tlOME z{r0IhnmVlEci`FQnNl;Ry6d2eZ=1uzAFV>4UtOH8mgMG!VYY2}R!MrW!2HvYcA%Vo zFYY~e(n=shoPHmeX9CYcs=42=}+c%rX44u!$%+!%>dc*T+Mg`EiDtL&;gQV|;Zx6=VFZ z$Ft)_P1lAK!lP9@6CyL(MmoupmUHhVH$%$aOC40DOiF*5Fwzq_U7MRye0xkjtppY~ zoK_~&p%{izddwS8+)xMNxix-STz%W}$8^m*mmq!3cjhh8irmD%^0v9ue5l+ov4xXbSK%s z^n54v0rAt_G~30WyBUw;{r57x)qd_}JtOx2oP!wp@i{Lh)Nj8aN$tme(d!%EgOdEF zvxBnoP~XFfI<>RIs@5Byqw3zF??<(x@jl1zW=y{yH>?o*d}-QTJpIygkmLQe?bPY? zYsW7}?{8gLpHIH^fNQ)?`p5!KP6qBNdYulj;Gdk1@JxAr9}`ah_I*Oy+VgBuN%Gs- zw8rO|%~=C$pC9uN(x3cXw6*^FbNR94lk*SW&R>75JzEOC_=vc;y4Z-J4!PV)l0;u$ z?z}b&`L&lHaQ$n)JU!(1VO{g}@8i~`kgKn~7uQ!Oqtwr@zt2c!Z~R#4{&s%8Iehfz z@*phW&+k)7^v(4zGsVpttc>hmxQTG9dp1~P8Lx0S6A_rRXB?L0RD89>aM)`bTw$qH zLZ`$CdueNYrNF8`k%`E%6&tX@elmGUVkFN4TSD8B5|W|BsNiR|cf13W==Kt$@2=RA zAeIxE@RCr9uWiYaxYJlTg=4fF?8ve&2bk3iV&#kMD18G}`4{zHSj5>z7`$X~u%{EfRFg8*kN1tUVfeBPk=qkS1n<~m{ka?l;29?VVRK+Xp9R2F z7WI-|#96a3tSG?LM4qP(HhSV{n6kd@z*T>tp>L{}in&|m(OHo*E)jWF>EL*3 z`D<)r&@ipD(DA-+fR^Q=ZhCv%Bf;k@GB$Xk{{8Gu2XW6Pra6pWXWKi8ie6~DnCfQE z#aW8KU6FEk67^o)aoX(o^}Z+4DC;?!v-H_hUB5-0?4vjf+4U8Pz#&nOvm)n}<7ZQ~ zyGFT>*p@+8;P*eV#PY`I_t69&Ca%cD^4r&#pmYz%$&|$k%7Oc2d=+Ye24aPoNN#o6 zLs)#cSW!4TkD3;NYBB+^n5S_|6TeR{-Sk))qO`Bw>|zwRoLVaX^FR+lYhs*F{Hjb< z{LmmN)3{jRRk@D;p;11qNxAN;3X{e|le$ckw@+SGKKglR)=O(z7yqitQM^)5*2lE5 z`qf(x|09b{TC>*4SJnQFM~_Z3%{srD)R2Nf0G3}s&|Sc-G;}Ks85?TfN<-At6qwQ$ z4W@_HZYbVLL$}h9;alFHyj3cryzFlqCtHr{`fjD6TWRQ48oHH+Zl$4HY3NoOf@4d; zf#z_$KsYD^PS64;T80x}!bxcmccl;%<_M}l1Wg8l_Es7i2#g!ah#PB(yOoA+rJ-AC z=vErKm4H1N^)xK{+MaPZJ%~*$Zcu?%a_W>AkUe_)Ebv*lbstz6OD>cFVmuU-8?V(P-z5~AyM`|tM(aJgP>omu|I zssIx4_FhNRg8m#PC|p$vqJQ!2|K?&-WL+1zE?WDH%^pK=!!Xj47ys+g7(iJSD@PZ5 zmI+6~CVZKx47d@2n7v_8Rfq%7>BE_^Hoo1nxqdl`%{zjnpbARX1170`G2rM9QhWe$*9%VShgHJ!io$Tyzy;z01B!L@lCcjr-$4q) zv54FuciEMxmB&J0FL$dp6&p6`z8ikI45vi(;7MU|p7cW}Y#Bb{*uqpf-Bs8FKzO9HMi2r9u=rZr$At5a#D^sl-sM>Ef2*D0C!~uqZgc6H1 zX4w@#u&e(kBt(IMg!;A8kh-xBdTH!xc)My_4vi{1Dj3ih0|^!FXks9tc?XlHMWShr zTS|o`$YfkEgnZHrc~qq0JOX#e-pxE;BM??`jYHx79x8<(J<(I_P{N%sWF6eK#Aaq- z?$yS_!wtleHR;D+&TH?Xp-_X_3G4+3VNnCnBnl3QCgBo$VxWWO9%O_W3I@u%^x|gh zup+Qz2m2`<01xc$q-PL`i0#>{?I~xJK>Sl&Gj}|qN+5B2%83!U!1qch99(W?4=11} zv5ISTiT6f{%b`<%3#W4sXKLe~+YsjiJm)9LB@g1AH}f4!Py{3lKp7XvADS166TP^p zb6-^kX?p++w%E8d%MdR}a7BdRera%DX-EYnk(jbZA{J)d7s5@7LrKi?WjTPt#VxQ0 zeMd#4b{xW77Jh>h!3dZ(=u0Fc|qTvud+(wL)kL!7ny$@``E)q~{x#@3F8*dMC>$p46glEzkKiplx z_hr4JZ}w63!`LzyY{PgY(g8%IF!*X?#Qi2Bw+MV}fE=(JIe)8>!#|XBqC0O+*cVsT z_?4zeh~Uy7R%7c^3x(FWz?e$aRzT`8lvEi+ufgUH?~XgPJ&FIZ;2vkP*K_YnpTL(@ z@v4a9Lstl}qKu%@*xe{~ytPP6sA@b}hNx?YL`nNgMFRnT$xvU9MPt`S?~n4UlY9tV zdt`uabU9js%3bJf*=W!G{6O-Zj#MVzhH>L<@Mw?^?%GDgT46PCmKlNLJ8F>)4N+v zz^_ioubHk!!2d9VvW`@U{xO35i>_ za;;qpF+RbCx;WAb0gCrQSAyj7Ugur4OCEyZWxM~vf|3OPg#`_={DlQ=poOmb1piG7 zTKGDK(Sn%0WqB}Kkg?}~YC#_V(t;@e(SjW7Fj~;PT-;&*jwNlzAU26wfclWG$8;{+~|Js@{Kbf}S;CoFEvV zg+^&66ypS)IC59}VVodX$`6baq<(gg%=3ZVN?5zT-q0*tmKydA3_Cd;HPjO~G5c8} zCXT3Gg$G*iK5R4#6E`=T--#4|kly%!>AQJu?PvTY) zmJdIRTVFIYx6Daw}za!hyR_NqEeiyM2;)5@F@OB;h&! z^a(~9GW+=C;;9EdMjDdbx+Cd5CE+94?0b=oA%_5RlKy7R=Mqf_ShI%(MjDblv<*PG zNcsj#K1&Z+RK4(d(;QTDF_$mtU5}B5nx|U>Jja`#f4X=-Bk8#<8FtY;d=TJ$)eI*I z?8Ul#N-h=771(m`GLY{tX(&v%#St$`~726xe ztsC^(8@H_*t=pT@t(rXBn}e;I!`fS{ty&V=TcK91x$SMaXpZ!n_V&4;_U`tM?VygS z_Rh1Q&QI-KIKf@t+PlevyV31EY{5Mw9lfH#y-Xc_(BMA7j((%ye#MRf```h+jzQnx zLF}`+ zl3PxG>zI0DF@^4!E`2mg(m6BiHN(_7n;S4I*g2>5Xj-vz-ritduXCYBcfq=IF-3RL zvvcXle=MwX+4s>>Lg$K`=1Oko2U*yMn$A@^*lKs@8a`}os&n05b^TN4M?KY#-#S0Z zs(wOuZg8n?kaTUnQQ2hb+7eXR671TR?Aqp2*irA=)I%%m5_auccYSj1+B=f}9MH8M zrm#QWxu4Lrn%;F#DSuenwStj`UUVLHcP(M0AvgIiOI-^XX-KQ{>$k2sj5NfDk%qcw zFwzhqMjGm#!bn3$a%Ym=?=jNQc*hUD?s1GXR4I4v+&zkshF)}Bgmn*Nq#-xCU+LY0 z7->kW<9AJWKSmnj!$?Ejy%=eT5F-tBcVncXBia8wX$W{L4c$sZx6;t9H1vO+G(_f( z+!8B=ESgRF#)@X6ElQ=ol}-le`i6baau!v0nF_|M&r)A2W!@~F3LBfv!DcAq!F8RE za5c=ovD~MM{yZJ6;Qeefi+fT4ND*M=7f(BgQd6QeOo~q};t)U3Fu?G~)v3kz{SUNk zX^mb_rk02|9_V;y8fAa|4{1p7-=v|~1~#q#r8Lw@kOT+(Z3wadH-^ynFWaZbn74Iu zwE69D9>$HT|DKY6uQK?BW z$uA=lUPi^mgvX)~G12gts8Cd7SX4xC6e1uT9)x)QG&ICNB+&b*pT|=lcRwHZCrwUI z8XZ0A9o#YRor7DQy<4rlTaDf0DqELI8(~32dZg3Rn_!U(eQ<8_(0LtW`5p}eY{+~Js*2}ID5N0 zJaKn&e{AdGWaDUOX>aqu%F@*GfuV(&fw`%HnX!(Ev7U*Mu91PZfxec$o~E7-Oh;2i zTLTL7R)%>gt9vS`KT%TifT+1cR9zLJE(*#{au7Qih^>sG4dzKJT1zWfNy}SG$y-XQ z>MLldE5p>3RG|tgO0vpIa!L?cMFnYjIZ0Vf30Y}T88dMyBT)%GVKHq%QB466*nMF& zej!zUK^4CHiah-C+da#=*hD%+0{eMbE@Z z&%{B;$WF)3#>B)%$IL>{#6(BWe2&4#j>LRbp#qp^naxl;W?&1}ua ziiJw^de`-_#>%A{oBmh^t){A#dY8EhhBx}9tKp!fv23m8>h<=(v%~fA=9*94a2!HL z?LgkXW|?g&tBIDn?cqc=tsL#vce~>mM~rIP`rdm}dC(V(I&BRHb7e-AQEjj5YZhwl z$8&Vro4%~J_$r?7K5YK>sV9PvNw=frbf{y!OJTC3_3ZO>p;j)Vtn}pWe7)PJ$TEE%$9U&@+YDP*i6Ng~>$Qpq+ypYQqo_dVzP@BHzcInQ&>^W67! zU)SsHS6(H5*Q0z#F4rnvNx|vzZ2QN;@Q+mrL`AjV@&%SJ^glY@%4=UQrpiC^^S`S5 zdp)1XW}xU8v(Ua#mh1b--z!<`w<6ul#^4h^OQhpdRb~030N29Zf1gq<;+Y1Q9m~$O zf39u&_$csy8A3W{HoE>w)tpU?x|u+yo;snb24RsiHH{Ntf%%Hs<{8J@Cc^{O&fjx7 zuNw2(n_Z^yO;~sxVL74N>*UbrPj90vbXrw@kog(=Ew^4@h-1DDaahEp4GtJ<8gEaXFfNE8vCvSyA~XKEcO#ua0q~4aya}lFWH1^;5fF zik-oOyoSusld75gg-1UA>Z^YSj{yfWg%=9k{Mr_>7g-gvI2s3aF%}T0jLF4IPfoXP z<1G}5e6Ry@SmJTk%?_D~Fo)00+?2iGDg2OJE^raXyTioCIJ_6V@b6VlcTe&qHk*bV z2fBjsXE*MJxqopbaspzrqspG8`0_cE;1na5G@vjEkhqDHtRizI3T_xM^u891 z=MR+KLeYz7>G^JxQebSd)bMBWD8On#yL>RxI{v!hA!@p9h)mIzd zoZ(D)N~3~N%d&Vf)m$!$C;7_Pc#-)wF7brMIL*aOwzG(;a%zBeR)Xx+M_3FOZbe~s zqtu~^HUzfqQ#9LVRWJte+n}(DXE9*Cpb3aOa9Ol%v`no+k>7!_d~b{IvV#zaqF(Y{ zVbJ}hTF(8GWrRR|Ivu3#k8k!ooV+oiXr=Pa6)n>6Q=u-1O`ForWr?^UULCY{{NB&e z2j(~PblBH4t=w!+c?P6sLbK)8w~vy^=Wpt^(urzGBz7Vg#;3jUR z_oG?ik4Ys&8l9WewS^SZAUPAxUK+QrGXhguN_(~0tj;b>l&X9!q{ggKI!;fOOG_?(N%V8nSFf8%udd6>E+pOgJ0KdzQyaT z+MXRb(|09%E8p#Y`s?P zIc>F>8<|QR?|EDO0_BDmTw%=FKPtq>fua{g&%2M#He2|`%0|#nkKBJ!Uv*G@Mbc$q zN?xyZ=TVI>ch>wT5hePmx7FCfJbb-wOC3;3R88|vmTKdTv-gK{;(l0+r)PG?Ltfv# zM`8@7!8$lktKY?bS-O=Z!;1Iv34eY(i}0#d0ddb2!)mwi!q&yzPF2adwzK@RlaN(G zfx^B?mFo6%DPC8-02T*(#XE5ZRYzMDylas#VL8; z@la$)P#g0QUk*U~8_`U~!i~PWjT)3pX*m)9KQ_AAm`k%iX#;)RH@N|iY^8e0c)-$$ zWB~;0%EyK=(>pg$@3)zi@T80dd*bfit9fIYB21$#-iVo!3y@3G2(wYdd0dNQb<7Rv z@2a$b>7*IKAr@@LP}nMAk^{wG%v;QW)?M}BG-C@xY+QaR5P^xuyGygC(>1~B?*mY> zecj7>VUO!QdRT5o%#THdwZwnr3r0!xntu9SPc{y!u->{glcM{6nltF)>l;&ro-{)iab$qj$qD9oh8#bx>$yF@dD}iiCgBs@=v~p z%f`+^9kq{%WHNKZc~EIib>&?_D&sk=hN?Hx^CKs^ANHjQQk#>SpRsw6Ns4UeIurNA z-nDx??=!8~A~5r(?Nbe0wiB*Zofp>ydOBv~akoxfJNS}BGMIZFm05Q)@2g*r-RxA| zt>)W#-$GJH7e3hk?RX=u@7>e6^!L^6{?Vu33EW-FZdrf(L=U&9-(SAt8{c_mD501b z-L4pX8?nM6Kwxg=?=dfV|m^Pg0J$##XqJ&;Y$?%a6P zy}J5?&AUvatM8MTJI@^sf95myHynC)UymRD>0};krS|NvJ1}?OFb{Xedk+7OGY|Ke zOhD-W8$!@ZB>ew0gy2Mi*bWgTM3P36WL-(};UvXel5!hKWjI=5j-(+()_RC8`(*IZ)vPGU`4VlO&r z&^756K9OBszJTN@2Jr&*dgBv?VKtCok?KFAJrtY!H^vDIddA zHgZ#bo=y2Ym$JE&vSlstohNnMHT92m>eukp-L}+ybke`MR6v*p7Ea1X)8Kb#2;oF# zE{$WJ#wAQ;|3~B1PvftO<#9_Bx}PTE7ITEpOZ-bi#ZzIz>9TI=@-HajchZ&H(^cAu zO7rO&!Wmk^@iO`ux_2_rOnsvI{S2e|43j%ihW|2f`kCfc@xNR%t?y^reiG4X&$R!S zX)B!hkp(aZvRv+D{ZY(Xv&!Ne&Vu`=EH2fyrKO>Rv95N1FFUx0xS&@7E`U7v8%KZklh*NmED0D=Jw1h_9N8w03da?kwB zx>%K)*M1*8oU88$Qr19vRzU$2C)_m`9G+Xyo>wCrd#XJez6>e3bAM8U^+Q9Jc?hcj z9k8NvKcInF>7Z6TatFz(+yKBdKv=IlG%?Q#3Ao{r4^R+p0&uJsI^PhL%K&rI03j6Y z2R#qM)GVC2Q#fyfbgs(p`jp?RQ~-Bm*Fk|;P#_&I_S!o|JO0rR;`xL^Ks(6$Iq^{d z3L+8$yQ>6aXFwf70G_REFp*mw05rW|u5>6zFR%xqLngS#g;`;aFi;4{iGi@91AGjQ zPwP4S%h2RyC_fF1rQL^sN~CQ|<@tF=L!XS8%%4ev625^p6WZO~t zwY3PY$qINuTI2K1R{^VA+&dT$Kr3ZpU;w}d0hLJ@ASZhPEk;4kJU31Q$iO4%NI)}$ z8`8(lj=#U2!wM{eH#Lx(3@{Y+khxWU-Js%D!ULCtvU4xXJlV>@%!}-hE$|Nwd zM5DkiqAK@grqz6OY6E~kK>#}FfEEQYKlYC|>`9Xi^ch0{Qq1Hdi{*#Q74UdX=j$%z3_P z(*Ox&G{ZHkMfO_kUN#!^WqqN8p@fGY1am$aAmxuE3pD`25LiwFt50Xnm_NWugR#=V z+=&lV_MTWaG+z%yuKarvVwf*G^4KP@1=jc&zt{R#>9PL%ET%0?=59;G@kS;c#Es{= z_oj(?^fZBTR{(JFLP7zEPiK36bsx71`Orf``B_LTF#I4j5oKogMZAH~~a+ErVg~m7|G5=Mq~I zj&g7&z(S&(i}|_D`RoAGtId+VmSz(*livnu1T^u;Ur5l9D1D|E5JFZ#q#zs&uE7`V zZ$hFpff`sbCo53-C$R@gz7_2Y)$e37z$|!Hk(cbpdV!R{ zZqVmWk3H^#FYSM~Ae>v>P%LZ5Nn{bS?1ll-2>>31MCH>e?kXcQlmWs*zwTB)m}lU` zXe+9#BZUpJy~mw#u8Oy7(A%c(1059k0vf0U0rvurHL@gYdclqCY;?eu26GGnX)bqe z2-C+zfI>nIkrkGVf#JJ4H`bv59^|l$aKM8^P(vLZ&#vx2yIj!vsJe|wfPwL#AP$(7 zNTD6GYZ!0S=c>%Au>}NHft*6}T{(t(jt_UDAneP9XF|Arl6t{dE^Z`9Tm@Nxgm7X2 zP9)?$y>Kxe5JbW@mviB1FRK?{*6%;B%6|a^74dihJb1|2(cx!ZnJJ?q&$~uP7w^CQ z9PQLFGG;V3y-0fDJ~me{c07D+VR39pjI_8vwqo>3KYzmcH#7;H}&Uve272MlC#)UMbB z|F`+eDJ>lR5du@{1z?z>p;5B)GO$uj>aOJ*J6P-=n@8ul2CQVkv_Xxrh?j1h-lU@L-SIBn0SWhD06E*Td`h3cUcXUZ@)i zCfxh*V;r}q5%QeIbBH4+n`o);Qi-1@GH~gm`PRqlChOX-TE#?wOUlT^;PqsaHuGL6 zvKM-887hPz?e*w9eL+NzU*`{$LWG!ml@u-2u*aYy)aY z#CarBWD`~lh2Ytz?`*Dk{!!8Z+1E#f=T)x)z!(am$mG`A+Vqh82m}2wSN~(>0Nr5& zc5VNh9;tz9asdRW{^mOvPn{e0%=v523?@2G?RA(JfeS!CvBz7?6ZwA8Kx~9(-}N>- zw*Zb1Xg0EDo#o$d2P@|mw~JSWfX15w^^OP8tMuq?_0H_IM|_a+1}h1=(U<1S(f^F@ zeSHzNgO+*+AP=UO_JzBfZU!QEn1!G%mY)kP;K2lta|74L6%c-l+cBiPxB=M3a{(c& zB}p$f{13>9PXb1sBz^yGUcaCBZUp$@u#6$WI3>(;@$Yf5oZ==FA;%T-1OZjg!Cc1= zmMP-KQBYy0RqJ%QQxDAV-zZhj?sn#_VU$P_Q7mL68bPo&lLq*C35_-z_~I_Ej?P}7 z_hGWJ=u-2HIS786@800f-;$T6w_)K#F#Rg(R6d&Xvkw9Ts*H98T-`SKr52hEVem?S z4eEH#y@NxTnCq5Te+;LjWr6Dt?`M+dJGKqUeMb2_a9f&x!w{t)?88rs9WS`KG)s7& zHN)A54+WtzL7RIltWuF%MAY`QDAo9H`gv+y^rMRaIhzAy7LML-E_IjaHJ;P%UezxUa*8JzEn5(0N3{a|EC(Je&>kLOasKJ%yID z|MKTS3AM;wC?L8iWz7Z{DQ)Dzof%drTOhmyBf|1ij=-Iyn&`17_qjf!jV13%bLg&nxvzH&A zISbR8tPjvYVq%Xs;JrzpLm31+NSRQ=qo^zd=#mWLWqAR(wJz^T-UKTM;6h(Xvu2Z1 zWCPR?*=+tYavLxheIkT_R}9ci7XYmFcu8~s1(t8BEP(;8s1WI6V6->N&vL8`bregJ z?Rtp?=i=s@aW8v91iV3^hrYg=Go)Gw5Wor|cS=b7e{jP@N6GNVJ{b;{`j@lL`nJHwAt1=lt3(#*;Px9M>d%dCj_C=0y8Ozt$3BJ~qwq9z%E8L`mb3BM zr?)b5U90JOWg-7#2pQjgtLc63_PgU9&%hw>%91oi=eC@tcR5$O@2nS@w%^{UQ1=s8 zaSEps;I7otQ=fZSOe23hm$ZxcIc{Zo@AvB~J@OH2UE0dQUWy zec&AvipGGCF$gS_GbfR47`QHj$jyHNkV0eGTo@!lmHSZ5HY}4Xj6s%kPl997INodq zMWKd@aBRaJd%}njb$-u&8Eqyqs}Zf)a*^v+o0)_zcbqZQ8ySNh96g{>t<1f7vlL}z zv_6VCP)cl8K|>1NW-?R_khC3cNP#y#aoyEhXbAm`qJ3Rc6mv=B%f&QKM2S?jwTuW$ zF%Mr*FH~;cN^C-?5F6wreP{Z^#f7;XZ2EQ7J#8Opn=6(>K^U$N{MxeB2FgjgIB8W8=4O+{ua#Er?4y!ciIHsQm3b0Mm47UQaPp?) zNE0txMEMK3KPb&z5%slA|Ci?Va`OJ)hnG`RY|{LdndUk3ZsitD^J%{JKKaC|kJkM* zPp_qy7KnWMcw)w1%TnIA0C}eZTNht=)3Wq|kWGd0U7OtSsM4ZWoeI|AfV9ZEDSCB) zg7?z>B%^RF)N|NO3rRYO3!w|lF0M-cZgU{p4RBvVVFg$492AY|Vq{h&?$rx!x0<6Z zCICV8N{uH!n?OCI4;-`9fSVD1p!!1wxUBK9PSX$R+*K7ek|Go7x}jA{s@9_4(%4SW zFf3P*u|_Z~w^Jq&Y8YbX!rt$c)9+l4=u_gxG{hJxUMi{Kx3*9>;7KJpm&zI=>~Fn1 zUBqh+^$&e`Vh06B2GRMCmi5A1KNC1P9-^2LN3qzPKhJ8z!y00YRXj{ZCetQc%J0e| z+%AYr-9JWYBJ-m;Wf&PY$}k4t6f)ov9Qv_6t6f#WBiRh#(?F-c=Pi&_zcI1a%V&(X9)h?3Ao^{C$?7nr<* z!_Anc^!s0Z#4qS|zWJsWS$zE6zsa`2jVyA+{FfzZNjId8=Z%aa71~ZPeJohQIy+`` zjR_C$g_(qqZgu*}TV`0jw~J+2Zm=XyL%1YzwXkfTQZeCP<3cmT`eTfkP>xc`$PJ7? zjQ5ib_uyo%+BH&iBTmzfLan#0pB1}$@L3n7dbw?Gy$I(;?e z+oqm{pQ15uW^&5^nCkWtFDbm?OrF_h=}<*pCZ9(gMO&K&7Y35VEkJHEJv7tHCnApl)Mwa?lO+&iSNR{nYT2c5NcE+%gy|>?2Pe$-bOew7hLBXX^Bp&7TDzX7Cc{1kpsd?RLA13Z8DK};t6 zj@iq9p@qK|KcTPfO~aZ%O0iePc399nX=Qr% zQ0KoJV7n#4PF<7YVT*?d%CoL<8j(@7zXo$@f&Y?JJU`i0L@aQw{GGVay?SmvVnfOF zPg9Y{Z_Ve%VZNTs+>nSqP8Rk`{;(ntNU<`%1ct+EZtkp-N<{Tkc3F< z5aV_NoUCyPQiB@iS`MM2eJ80UeDW5T;A#vI4iVcV zN)@i@6D=^Ipks9uYcRIPF%NJw{M{8`p>>-v@(}ngY%+-U(zF? z{kY5NmPEv}iz=4ZPGz2>axS43u3d=pgF~E*qaf_s*}S3l50F?CMXZ5^Xied9#hf~A zwHjcRjC$_AICQ~?8YF9Zsh~1)dN97jYWAF!FTaJ8kM%%!Umv1EQz5rDMQ{N_q&eILTa9=z zM8ZDH1Aau&^pkv9s*asmfzLoR-X@!$8kAthY#xs8wN4(TrgFSUGwRRHw6QLE;qQaE zjOdLA>r=C~SW-AywbJxoXHWF79eH__v~0@kY#nqTWhN8sUhK1|?bs~^Ax`Yr z`OE=bdA%#6W$mNX<LOh)^Mr{4B) z8c7|*xqX?SwoMtyAxy5eauS?Lxzo1Er(-a2+_^9bJcS%SZGF#0;W72n%<0QIRJTB9 z3Vp`xl(Xt(#PwUwzB4YikEgRbpPaIox?{}o1DbT*JBABMb|l0&DyqHrsm&XJaJinj zlRh4stFHU7o+uQ=1IRbkkY{h^KEu$u1ZBCdu8}<7vH{e>uaV|;j+?^(} z8bS>`)o_}sY=$P6t2S`9xun9}(&XGShE$KqK%By3gz`0m58{Zbu5o^IaaUugZwc}D z=5q_@y@#I3{32`^bW`YLu{l6ohgwf|bEUcE6`t3qKEKg%KEe7tHOngip;|xHhebuROb2R;OOr{eyo8Eho2CJ#uwjuP|@%e9naM>OGMzp z54j7!1g7$|An--P;Yli&#Jit5RL6x%o+ngxwWSZImMFtZ-@YyJRAQiYOJCL$kIl*m zT2e(mdVW_$@E>|nFc*Hy!Tzi{Pv7!h(zFntSKQ%XMJGHYix)>ntf+qU9>ja}%)ON5 zj6a$8LU3}0sb={>`%j{}%cT_b2i@2YXgI5G_J_pq4+hUZ7(GB3%zpT9RM7K-B77Aq zzlsayFwy;=;XYr4x$mk~tdB+5s&)CQ?d%7eC#!a|KHAS#PyJbSu$;1ouQ|%E9phhf zvRrdEsXXnn<`Q<<%zN!@_L`e~iEH`V`Dd3!o7UVvu6bG(d;FoQr%S8hp6~WaTWV)r zG7-MGiSqrk#smJClIv|7A!FRXlGT1GrShR6F6*kUFs5UW6MW_MZlZ4z;%1bupVQR2 zh*E3b4^*mxR;=vuEd<=K(hRY%-hKX;{9hjve%iNJ$do}xe0#9&16Grut2)o-r;^^_|#OT=0zIh-F6b@Z|g z=E(Z9@h{Sk)+-mzz#=z!=q`wR45CqQuffbH{f=4x+X>jab0l3vdW$91`y^Z&g^pIS&eRNw2nb9F4~)qTYV#FnpQXm! zP|l2~Cee-?6e4aBJa|u?NT@viO4;@3i=&A~oYiP;zbWJ8N){6WoxsN&Klyz2r!8~7 z{OX%$S4jkw?w>W|($wp4)s_;-WAAE2tU|yjL|lkE)ub}>Qf0?jXQ&F`CnB49O$d`MaI|X-mqx#jb4yl0)r3Jc8U3xZgWAG&?c*`k=9RwR1&zOZ;%tBxm8W3MOs>xon|MYc< zzt>UEn^14Gq}hM!e3(lSM?}xfQM@z-$(q6gZ68gjCd(f$K0aY2+H7)JgHNHJNequO zHI&Dm{}~}lRgTtd970Pm-4ZHU#O54AFc_VTI)w{!pCX(2@=v@RusrdlaqRH%|*Rv;U zkWcht(+v?`KYO5i?-k=?$usW-LgJVXLNlbZSm*-g-TKjnL7_szj`ovczWc1F5-D_8P=P36<@L|m%pg34Dt$J zkJETc^y?xGzlp4L<3h}~pCA^vyt*!aCs28hgx-6eX#8U2%Vek}1u*K9F}jjN_0H4j zx}rL1MlILttax{u3>ddnbakjxJFkSt_mWlTjLs|*CN`(rqY<+*)LKDmT@GSU0WtL5 zuqN>lo19~J#$I3QZ2PZ0vf@55w~N%fAI;d$ZPjl}H0X0cj0hSsC-e+k_PScH9JDc6 z7MeRBTJ1_WMi|PE&+NzjZ9bX=b9Wo0-XzF+p(vsnkN`TB(;aaUF;WB}_f2aXXdrIQ z0XW2834SIC75z&P!KU43q<-+#uHkLyB@;qPFe=t~nV?ObZs+SG0|@otLmri#`Y}N= z8*4DKg~py2GpgI5)h0LAp9F(lWo%45?Bb=cMZ9USm3f}Zb~O8qkwTu3$oY@<|-hvT^?*f&uf zTVr$4rv>b5tY>R*EpF?RwSj-tmbxPT{`~gkuljN?=^t}f`D>ttcfl#0sGW?7ah77j zZqOW%vL1_#?jiDI)T^CPGeE!$*V8Tt8o*Mo0`l6(({abAv|?19|Cn;$iaPxI1myUv zRyjs+yJ7v>+G^HKp_1p$W6z(Z%0#=nN9ujQ*-aEQP04EW@Dh!RKdx^7TsX7E^k^O~ zh!e4vC6^$7RiysAXIIcg7$MOlv^E=~AZ(-#Mw``-wt#Y2C!&r{IA&pCA zmZDpZ9z@amuC!!Nc@}xI>s)&4?ziSXai+Yx@0!GNsYaS3Z*W+1In73GzSP4<*}Srt zTGGCebx~wmqA=n{RYm^HyizGHS@}h2Mnv+bPcE4fd5Y!m6@LcGKiO8)*bIXbw0JvnN}2ddT8#bMkWtf`Ai6EBWApbbc*1I#gBCT{E7pkp-T>BOdl`?& zz90N@z2cLys8z`i-d}cf@4Vci3on*7CYs2Iy?Cu8bu1+S=7M9ad_`ONHy-P08nFjl*eK8fpJRNEfq&0WJX z4`sAo!mn$T;N(QMwMOB5+c)E$D?inqI%Rm!a^V@S@Jy43>Z&DfZ})>GVpP#r)x}RJ zoyTbY=jBm&tCRGg+I-1e;90idiS6S zq{)8&B76w_SOF)&{w?jVsbBRe!4*lz;5#WH!rRY|aEhowV?&L7s_sg=#*-Ot{SRqAS?24ImY_kCs{6|D4m7G>wI> z;D@zCL()4oMj|Bccy4oOJ^ieCO=|Z;5q6&iL*m~ez-^Yn0yxvXc{wh9#&eu?Bhep9 zqe%F3bFI>8k)cH0k7h;Kx#I4QP$ZE}+nVEwt64D(MS|%+M|@=Tr-p-Wu%#WV%)8xp zcrcFAvwOF@H7S9xO^v{07GwRaMfH=GO<-6S)=8})4wpuhkG2e!L2X}~XiN#~`Y@Fl zXVu{^D=a}B_=9lbMu%gBL(aP&do%sCfcl(MPigo>uNNFOc3DL1s2zZnxAB6Htfb*G znZ?Hu+s5N_oez@R>DtV~f2@>Wl&N89X{X;C(@@T}r-(3`Lz1qUKf1H``GX|nUn=%k zdWhgcuQKO~t!texK{tD_6szl& zb8aae)xKDU?NzcTZIBD}MG`bV5wpERer1XjCk3*RIJ6sH$vpCr!)E{MJL&sV^7H7) zyZeY?5^{9l%A|%kg4rMA@3Kbg|E_frG^I{+5a+{rpWg}5ds6?PDWB|Uvcm?F4 zB2BZ3aG#H=kqz(p8*_)4$Ga1HNR=TR`o=IUpFgrOeWu3m%&^K&gG8MmO#L05VI^G* z+^K5?d48i;WplFEd-WDXf^q!cG4Im_pED~=EH1O@ExWk(-il*m{p!VjcW%&^Qg_)n z@Z7p|A}7L3<`QZEXBYT-`q*H7 zwd=QPNKC$rVJMa(fe<6z+dw>>8CFyAv>Mx;2H{%83e>a>aLJQIpS|jDq>Wjhe<$)f z2HE=P?AT|Qb?-ObyECYau}}7QKQAsF>b4C!4#NBCR98hD#}#@JZe?WtpE9(M&9{Lp zB{s!*vu!}4e8lcMb3^`Dc!<=CVKYG-BgRPfQYP#oi(to=_3d<_vtf>K$zBv#oK6%7 zY8uh}iiGooT;%K-uE(V}f=sYLyeB9+F-7@xfEeud2bh~q7nmD?@3R+j>qxe z?-|nG9UkiSLe#cAA9toeANqLrN~u2-P!~UM&FKAB$#uP8KSh@-MJ`GPaXlM#p|Yz3RhJ zo&@?2+A`z;iVa8q_H6>%&+4&p!DEBWwF+XPI(fFB5~*H4a+eb~=pI|kTfoKt453-i zi(O29t_~rWUX%yN09+O#(m?c-El$|+LTY(=yGnT=q^3hI@OW7mNq7-a|a^2 zfO1^3FfJH5I5Sif(|gQ$Mky&vY4nou?2x*4HcDs!QHoV=151ZEVN1a*v_7RSuvBiZ zAdrCFn7R6XHtYQ}?31%NH6Jq*pG6c{P>gh3ktB@Dz*);$oRhT-D8-`s;e@_hmvck_ z@G)L$QeAZ4+${K85-l)3tS0`1n zkEn6RxqQbA6%LrYu!u!L(=a2Z1t3ebyUuN;TIXv+gNZtG*&mJK{AyY~%-bTQCq)iqHtkp{o}u~23dAN;X!>W0DGTm3iPu;`TwrMB!WP4RbZA( zS1b)fD2HgED+#=nMCI9IvLvfH5W<@8d@V8{-AQiJCe|g6)o4Oh15|=Ej zSLaPLDY?Anr|62m20s-@vpZYE_Jz#PQ#P#FhUm&M0g4oZWjAb1Oz}?{Y-Y?>h`rBB zaw@O#nKp~E1bgf-%)|O~nWHjRyBwQ~EtdXl>iXGj=hY+L!D12Xha!UySNzvQl+WiX z=l%Kop-SoKPQ9{Yy+uV_c7^+@lv`3+?&PvWFrNFFQ=RNAFR%KQV%#kw5!WcxPo=(v zUV&HJ5}*8uCD-^)M%ocvI67rV+xnrcT1+TIh{A~E2nz~qKSnX$05W(6PXu998sg%E zhTD0~J9VkQG$~j2d50s-`?{;^O=?y^U+QH?vKtmswue)l**$mSbHvt7b6-AGlr(d% zNo8Cp?TU9wVw)Qs%bsR^sq5DB_GOKRl;)p&dQd=qWlfEb`It*yp)0%8Dv&SRk7%PU zbxDmcB)nQsajo)wd8jgId3D96rXEp!$=SMb@xfOXCdzclB8{dx07tzdjG5Ig$CU8P2^Q5m~>wM#>6l4k9|8(cD2 zs|EdRO2w`LHI`z*jMA8uXB<;5OzCBJ;zyAD-$PVfF9h)snXqwnH5|)Zl;o913S+Sf%*0j|~c}(XRY{ z^RNF6yY0jwegZB$h|o-H;DPeT`;|0p95gu|cBgHZ@^A~&xW9dx2pBvJ7!(npz=bKG zlSuHC1(a_Psy?Wk)N6<&3FZd1cJQPmqj^{)2(X0ZHFWcyJ6_f^OeQ9VgE z(VSCWYQE$a%4zpijLt>AGm6X=(}L4x#+hTgTkt5T&Es^N-&0mVCYFQ$yYj^;-Rn4G zO)Nb#lq=o3Hsi$A8N+tJ%x9AczR+V#GEM+0SUoK`Wg$}ExEq2IHMmndp^cxuy4blUJoITd3-trUNT=t<^+XgXB%7X#%`Pi;(>~zlc z_kv1}S{@BKch>|8qc(hRWxG3O-X6=wQ3dbZ7mTPni>3X%bib+f7?o1>6X!Zd(wQTB zl--DTx%vK#Q-qJ}W32#~zw+r^?2c9}tl8sh(+PE`_-ZeFnSuK%sKp1>hBU_(H$@H| zRj9T!-pHLu{iUp~WmwJhkm($z-nWt*b8B(aOnTv3Ix{CHN-5FI!=e|SN#$3n=1(m( z7d?VDC|NO^G#h`4Lq0%x1*J{%bwWPgyl7?&bSToJcqaTe( zeJwE*K4;D?S|t3&>dJZR)NPMYZM#5dnoodj>dUqlE$y{&rvh)FSek6O^DLpa3>9v@ zd3rO^{at*}yF$gs&PRrj-V)o&L|-xDP&N3>?IT0zR+77pYwD39^m{?`v0KG|hS2YL zo%20MhS05%;>YfrKOVtBT)!>m zN?WWt^oS9PyAmE3-9|MFr>3^WONPf6wIx8p|1*T%-9BQ3k~(iEjkhHi-A;bjmU8!Y z%J;U^v$s>3%r=_gZ5nrbn&9m;$@cVLVd?7a8Lz`KOxrV?!!jNIy#WD7hER53dsdG} zR;x9iB8IQ#R&J_(uLmTr;!a1_orIwF{3~~I&i|!w7lM!avO-;lcP&QR}~a?udfsx4!SpWGVrOP%m_@SCzpdfCdDRsgOZ}EddPJfwBAsKeqIL z1hoT31ddQ}qI*QSjDrHu%f4!18U(JrhU3`&_k(KS($L|V3E^`kfHW}dJ~&I+jw71L zaQu#U5*ZEv>_>U#8hubGL4bpuXF0;I9RsVR90LG#O2>m#gC}O3Kn+H=!0;HV4+1a= zd_i-MnHER@03{7rbs0L$b^h4cO>j4ZAuyar&9&N{_eCj<*Yul;9yf7Ynl=DncM`Vk z=o%O~1bg=YKOdA}7&*uz2AB}`9;EI~ZS0x0khuXse$aX7%aE}Kh7;N=Gi^9LU{oAu zn7B^>kcQap0d9bxs%YMK^Iu<)htDN2;NFKD9f096&mkvYc23Iz7`4bjHXjU@x<5tR z-EuuJG6LA?k)t0hx#~0lDn@`#W5(6Z zi_d~fWdOKq-=sQ40RuoINgiAM6@rwC5ds$7%gu3UN;QGdx<5VaelOF#BG~!hL-%Hp ziH8@CT^j@TBJdAFW<||EUW;0fHu)|YwW1znxr6&)8nyZ%W`=3Tsg1#kVY0vItM6c7 z`~YB0#2J~c-#x@>59G5)VfjsdGovi|yCM7p$S9`FA*$p946{RHd`}<*GLiDo2HHh4 zfK^Ib_NRt7E;7woMNvbF+LC~{2WzLHz)AL!jwKxWO{*^w0P22qDG3DTaM{8RuG3bm za(_MYvStv5$`Z1}EL2$NHs%i;?v`g7WHAHIRG)41o$1I5JX?REFIK=XJIJm1%0&6^ z&xy6_k53{rQxtO>S*I;T?`c=t0U>>bltq6cp>iMUjK^^rpBU5O3HWu2x>CSbZ18M17u@;?Ytk!xDklvrtjYXNPPdis z@ZosPDWw;CozQEg%qe@LR^{1uKHtxsnRYSTb}R;b${xqamaR~T0=sVMHnAaJ^`eJ4P29r;1(kJ**0K8WL z61uRS6K*FYb3ByBtUerm?TRPpp3_Re(lZw zovgDJpUu;-CfJE*SDTj~^5**8gi2ckl9693@B@;i?X}!|lQbwv@P@j7q5spG7%GW8 z)zZFBYhHorS6XUo^+ASkT-)auP(@%kEXXV2oXF+n#kg~y_LovAhL~&GBPdTtMy?qQ z%pfs$U5{j)gSlIySl=rEciJaK!wcsA1Oh3TgyxXbu_#<%Vz3Q-i%tHC`%XormcY-* z?umO*Pw`Lc6c?!YeRXX6MYRr5ZlUkGKUK7-umk%G+-$d~MQBj;LA3j6Ki$L(@iZCZ%Bk=tcM3=L7fE4Q|?e)ea+AC#2Y-6AH`nU9IqG|;|=2ySp zy3ilr(GTK}i={qO%>Mv-2Q}XOg{?8iK-zmM{&{WMHYuj*TQxljk}|*;WXv&ya^qlf z?q`--RIYAmh~Y8=WdcjH^iKBW)IHSeY+%@B?wv31&DPxIN5_gums)6yhucgP!^z7|^SXKr03qKcW zPA&3#e}GqC7AgJ&$%B0Q;|Eaj!+yPyVwP9j#%bv^CW;} zvF|G9QZnB~k@)gWTe9v)gRzr_fW|HpgQAOg7FJS&W_h}UHt;c_P{*5|X>|=uSPRTkR#Q zFn4hO?=!kaZ@$>oUviYW$xru`-CjO?#z1AYL8Kd$CnBD zGxF2L0@DuD5cf$oO8`mtrbpORmot@}EH&AbsF*Uu{&Q!RK}vs%E|t!V%7?2cp0=^nrWSdFg>3*M8#MktnFrU) z+&G}3qLIa8bfNW)-8KE3{r0t(>`?>dt<(wCPhu`$z|z=Q`w|wwn&ZITIwYtpmRU;5 zjG7*nX%Orp6{R@Dj5ZJhWNn#+fd%5QX2C0gQU>TRivj*kC=|H?uh(A$>rC?MYM=l~ zvEpmz*M4hv9bb=H5PZ&mPuBDrPMzc_2=XOvmw)3?J!cd(b?uZU_eD>JqSwk)vIXpm zY{bP@k-Rra=Sj=@9CZ^BZsZrY_h{)k`*oV^*io&Y(qFP8h@|I&;5ksM^<+<`>nnv> zjZc*}XJp%>+#sMqk=>Nkg4?(6)9+t;iB&9^sgf9pslEzpFTME$=<16z0 zGe3ksJWeSCBsCWkkxjp^>T!eod!I3J7xQK|e^|MYtERuPw#ui+26X9?)EQUPNFR1@ zwhi5FpDLm-0})c6$uX~$=ORFYvL)1<2{1)Y1djU>h)&59C({z0PlqXCw)Ol|#R8@R z^pSFQ!JKq;^r@m?XDZuZeIagU6x>P-<$%CY!I-F~d}e0ZeEW6z7TCI=*24hF<@=cO z{viLSCsdX3EC3cSiv9v;n*>-|mO>^~bax=glL=P+;31W>0`VT<>^w}sXl0f}Vah8& zY{@}7QfsG-|BEwKefXS_kPh8VC<~8mpiXP|R`#oKdjSw&Uc3zEs2W|KPY!uuG^m$p zGAhH00c9ZR_NAy-M-I4uZwfF;A`pkH*q_K!BXqK-I}#jF&s>Bv9)B!AIe1yW`t>+| zP2UGfHfey`JeF=+=Ho_DarOg3{nXZ&Q;AZpoBbeV0ocZ{8yZE;jRUxm)EUcy7#C`Z z1ptYm5^({cN_pw;fWQlPao@xRO4)BB!SqP*&0_XzwF^04LBkXwMhC=GJgG$k@PWXn zYcydSl*u24RxpcGF$m$T^Fe1*fSxE;B$htpK46DOf5N-dM^`V+H?dj*^KnZ(4-KPlB#3&^-n&5 z`V|G?#$^<3a!zxIuo%5jh?1Qt-EEtrRG3x$#wsMbXaHwnC3 z=f=3OsOhkZJrhWcMa7c%HT{H4{CLEhgskL+?b1wn{Dd!W3BOwxa`6+n(Ij%#TIjaC zXkc1_o1bXJmZ(*eFy2orPCg{GNi0=fJkw7+w@G~DCr_cgM1`M3b(2Kome?bC$yPtf z&L&ACgk-P0)QF$dSd&ycOzMTa^gBQ4*(Pbo8HtbbGAn*E-xP zZ#JC+Daca!%NlK+gKx_+E6B0E;bQQYwD6bE-&VBqS8`-guxVCuQBc0FAbZtc`S!N5uZ^^qf=WoUN`${e(6&mn zKRV7vl-P_;RZz`*BarN`TDYxRlFpN_pjO?i_DDfwaZ9b$U%iv%Y-O|hfP%)zwp6pf z#*1x@sr1@D1HKGfUbvvuK13wT#KT#qMmAt-Z_6gO+|gOfufvFfWF0!{+}j& zs{jMX7HPW{0~bZZp)G^!Erz#Q(Z9D10|ShX(hNg(jPP$oA{33|T8ypyjFS{iGIvm^ z0VahzCbNwuB>|?@EnF2Xrj3ebZ`Ms)Tg-Y}Mmu)QMgq*+CCtZm%%@ljo-10+wpgTk zSbS7GzrxDvA8>wc=X^5#`ON^!gB|JFQcI8$w%gQ_sujz?CJEccvISbbsy@%PYlX6N zJ)>kT&W06jwU*mGe@@9p^__)EtBuaCxwew6$vZQnR$Hr9Q%Yvy_|D`)tDQ@$@l_@J z8<|EPt@ggHhIf=Mgk%~}A{2a}K1E`V%hZc)bw~}=rDQ4OTss|#$y^eMd7$J}ZKq91 zRT=}eC^&Oxre=HV#eqN#O1v_bsZLRvr&`rsDP5j@r%K6L7F*F2uKD{rm2a(AHg}aN zfy)7#GR18M?J1I#oZ;+>l;nk3S%CsLbJ@#tw7H<}%26U1@hn-2H>)fuOXcg1ZITz-uDeW2Tvc|zp)5fGo_+1b?+Z|B0=o4ihc(x?D z&4-yIflyHJ+h(^6X$FJfio-M}F?k5-IDx>1B%3-G5?ynPMs-+TOlC3S;9$?ij zO9_1(RRoOM16}?|UQxMwjYKN1u!*&hBv#cxU_;%rdF ztcvf)KM`~)_f}MVzPH~y+Pk-@a%VRv@@HG*iHbJ}jhnxVgQIUV?Bgbs@oeZ@TpjrC zJv<8SB^*qsZ6ipdZ_1&G`FDw`=o@6sePV)glmXh)q$4V9FUkt-VHX_j)fRmj?e2n( zxp+6`2KxH#{TMUl*g&*fNJp&tUMwE%8XZiMY$GM1uce~n&fbm7MY|O4$I&UrSD>A% zJK~Ra;~UXeTZ0pRwkGtVuMD6Q=K~YR(3fBACr&6Oy+dD`?MUk0OTnWBk+j@Tu z?X-zb&JRpJKs%o7CnqSSP^mh=J5$1TQ!=s`(bJd7UBQ zl;Emav0%i}uD7$GKD%Iw%aj6!T;47C7!oqCT9h78^xe^Z@t}zKzG#=r_@MK_ZKVgG zyb!9;;s@=;3{IiUY9+NnC0yKLyoV+2Dy716Vd7n-Lw_g%Yq(Nq*{k+4O{WMQwetC( zaue=*7Ki2ER4VM|?m2cgns;HWiIx6*JI-TdAS2kZv>Tvwl<;8<@^k zSo$=YsR^*oH-c1}$U61H`3d6PO$Ke;D=HwkQ@`~_zQ>)jQr_L|5l8KK^^WMUj=1iQq@#{h_0G(&&fMfNnj-JRXty+_>x>OCW2J!9QHFOGU5yQL<>dS|VCjRmBwBw5q(2f}B1q%dafWp?Id{4>F4jGK%su9^|F|i!`NprumuiIhjcm z&on(HF5`Yw%Kg~n1Y%+wG2uT{(@2VH8cU3b#RvUw(AB=16h4(=r}}hy{KKdI8*G|2a^|Ls7(TX%guDd?)duh-wgsi%*(=Rd;fotxLZZn)m`_}e*ky-aaV zuU@)v$O6d6|No{^gt+TkBbv z8BmN=GZS4CBRyk7T?2jX{|cTO{so?@+A02b?UXXyD2)+Et0+*wQza!iNi`7)cq*nO z^k1pdbN?Ial;WKJgPaOWp#DeX6oulWAg8=2?*B7#%JDxsr%#;!pF5{j{~tT2{tf?& zbISPj|9$6_isGE&v1}CQGzP;4stAsyQgjA zQ`4W1Znr&0S~#^omwnN`+Vsw&b=sPAyG{1Y#O>Jcw4Cc5w4cl#ckb@Zv$Yzx{`9Wf zxYn`Ovvc_KLEk}q_wP327T_T7fTcdJF`3 zUy}EpLT(K)=%BA9VPB7$Zwq~ZjttRb%oLIYb>7Mjoznt6+Ig+xI5hfP2a{%jwK$e- zwQPOvoM6)jD;;7ma1)>uA6b2em`ERvS!UhK9_=|Up1vMtFn1_Rn6=Q~w)x9B)odHm z^exjz2>w}Cd~4lo2xC$a!zYYlc+dNP@#3e9;Rk(A`D3Z7_hNgZwkWZw)y3URq`mm_ zG(D5fCF=xX?PF|*T5bCkGhsE>scp-y1>LbJ9az)YTIipsgx?B2e#e^_gKa%TRb2NndJ2BYJOjvpuf$kAsQonn!2 zVotzJy+=bgiC<2lNQS@QXEH}wg~F|PUFw`IH-d+S>>2-YPOBJs#(iSL!%+{hIl9J5 z2DW-N8A3N)89IX$EDPtN(J4~NP38{2#OU(~NU)xF;KlmzN%944*oL6}gcRF0${mY|B zY)ppDan<1Lk1ShPo5$X?6_-$UMjh^=(`6-TB^dE+v$2}{ehe-=3?HK}eKWlfuVqo@ z!dCihC*B_adbd8%BHns)q`~V$;Ej+9{z<7#yKGu{ap&Ieqi^S4r!v@=FU8+lndy3` zZq?ds`lH55vHN^e%tS4OzSeSl1>V07eykHG!qhO9Awu3*IMbElC44x30lIhhUeslY zzFP?wKW^Qn)^?y4@@_~hZkTRuu%o?Upf=0Z!rm~|d&^NF?9xkuvc-dw_0-evwxx=# z#WWUcj&}3uk<(|yr*jsVXg4#iKQCs_G`nRQamI?Pw541j1?AXMd+z8HhQHpCffKua4gC#0S;v9dBfQIT*ucZO8rS5f6(bBos1Mq+^cZ zD#6^Wx;;u9)4QMO^M0nA@%+j8fr0WE(7K_Ltva|*MU%t6EDeA0Do#}LO^lFV@-0G- zek&NrfEc+l+_^9$#OEGw(DQI3jmyZ+)!wp64tn-fh#aG~y!hF^Ziq|FR?K2jXYm#f z>-o3HiVNk^*5xi94?JCA&rV!9c^5~n${dakB({i6Rb0oy82Y zbj-|Ix}6YB-wLq!pKVjCa~UnNefz>3HOwGpm0+g7*5T(__&5*&1;t8!$mR^{L?zwq zzd>oDJ}geJe(p?DOPqZfv&T?g$O?-O3uTq!>3hJtcHDOMYUigwTbBr71%)1cIU=`U zO)jR8O6SZZm=>k{<9mslV1!D}00ZI7CieyoK}|lRj}WQ3?@90%|9~5Qp5jB-XGlDw zgU%Q9409^@d1=xWCA)AwK-v za_!z97qtJpZ9d&?_}%r>)$(XAGH9>uRqwh5>FDFp>7Uadnj5i}$FA@v`=gep)%WX< zzp9h5&m;SGDkC4R+K>-tw9|H*DZeGz^XL=P6Y@S2^mLQrocwStX^B?&ADmO_ zU>xkfIj3i;5Ujy?&Kx{9rQSP(=lgHYDYXk;Vum2I|KFU`Dx6dYQR}}rr#Vq~riiBh z**SHHwuceSga5-hb>@t@HiLIW|GRVA5p!!MM$I9{`yc1jA@*)?Y_J%@C+BbHbUzkH zaZViwVQ5lJFo~4&w{z-1qByUl6wbIbigOB!zV8s1lM|QM@t1ST85~!{8DEP2+c~X@ z{T&ov)A5gU8e6;{--Q0hIgM%zPU!0R+c{muckU+){^OhmM-KeEbK3PE&S~7goYTAi za!x&?|K*&b9sb=p9mW6R{M$JVP6p7)`xNJNCi#?tpKd0DRa2niDTfXzu-p_{t|Xey z6vl%T#6e;;XDX{>D*HhKLr5xjXDZJDiEB0$$(1G$7t61jCK8e+rWzxhn zG+S@Dg4~ji-~tfT%GbxfHr?$6{(z zTwNRscM9mwMsCH8NMI5yfPux0LFs&;Ce9SqG@F8-N&tYAEGz~IrJn?j7XQLewGcoO z7C}G&TmVfx0EJ^gFMJ{sZ-9&&V8#o1sy=y;%~CnFGD`f>AYQ^s3$VwRaF3UOU0@I& zka020*arkjfzjkuIMqbLZz3`PKoMKemYW5aWhCR6C_NsBLndDeA1RAS2LNd`#G8IV zrlGK6mP!3@|Fj4JNI5e>HtFbO^L8ozX$p8v7O_UgfuTS-d7~1qRuz5o0VP(s(e?25 z;X`mL9i$k%A&b~3215{q7ei~RYBKFQaY+pT%>tMrQ=RxkGOHmdhHWZWH85G}4T8|& zDk=V{>fiq9IV>=RMVJA=6Kte}53s9>nDYTrLhHs%5fgKD4`#t_@wMPZc()ot-Upb+ zBIcX{#gv*KU5|aH@@b73V{qV*13rH@r~GI@NH#Xa)q_jwj}MtB94c4`B!*)V!GeBuHT{^Y z@Gi+Q*#KbJx~IAI7_wRubP+Cu1wv+MB8rCw?Ko2-WcWD4s9E-Lo z!XTjqTH9itP|6?HOr{X18!gm$2(-WHT6cv^Ez8X@MnMcPfo*nFN1!DU1+oAq7M5KM zznh;k?+CzrU@#0A<@G@8Y=_ol3(*U)!PP-HTcG^2?P5YJRY|?;*-mPwHr0VFW=Wb` zM@%X6^+`ue0sv@vqeJ_M31|RXH<)55)+|LqZ2;dr>Z-4Ow9NrrxrnfuXnjoA-B{ak zd!ke5i0N_yi?d|saDINq5mUz`WN$oc4FGggI`bu)3cV;8FG5ka>((rjC>H3&BA7S8 zr{q6?xKCrsVJ~Sjd+Ml-?A(O*1xsA)*$B&hHbDmu=@-=>4cca0q<)1>3{X#&+jW#f4pLfnD+s`b3YM~%6WFg zB<2jzn{~M!&~Z)zC6bY=-A^E(fhI2|^a21jM_fJZ*@QnNs3FV&pb#6Gmr|7=fhdyz zQs&@V<_H~)VJ@kmLN!3rr=KiaQ=vBKM4v4z#VAmmV~~=44hb;e=v)qpxsSX1QUEvr zjctH(_JgOq9xzEDYD`9{dMsN+mn6P zqmEcYfLJI*Ya?800e~(5QaJc(brDSp$ZV3a;0B`S*F?Y6^VARBHJ1>lsvru#Ea&{R zu;7K`!Pu+17oUz_)P8?VKMH+G|MF}2OZCwgt35A&6%c=Xc)7vz>fHXztxK==GzdH4 zuMT@&apk-^K7Iv!#GUd?LN8B3(38}KleC4Au--|=lgXOUQiSFd>&I7LE>YyLDUIMM z?vGP^g~>c8Qv#Z=_ufwmMZDg;^;)d=^=jQ~sgu{Ae!Z5}d~=a3^+qw`&4$+-bnhG2 z+BX^}Z+`xuP^WL*&c4=YagA&m^AAkXL6?G-vn4DAeifrsr%<@9dAN*@BbVrPbMD z&AAU^bLA0p?>y(KdgoqNQK-|oXRC7!n)8EV^DPnc-JbIuz4I+q^F1f?k5=c!J)yKd zt>=wDJZ1RMEd4>O3IK`$HS|O8#SfE@KD4}ev4MpN{H36R1Q)F>cR4jB5EBNfQ3=%FtVnAjHDv};5xh;>Cre4Oco^Rys-VPK27#7{8=7@VBA=S zEIt4vilI&j(V}eezAvUuh!5WjA)8BhuQpdlxKKYpQw$>Fn5cc>Kj0I-NG9t1MX$?) z>^@ECMY>$fn3Y!$rv!OuzG*JR92!)@n#tX zLlt)hnvR&1oq;}VMK)>iybMDN{t*`fA0MPE9QI3n~*xq2rb7-izh=Ac=;4Fp5b8*o16u zh)pi7IfFHh))v;Mk7F0X&@sH;Z03#gd=_%86P2KaGh&%*fyumaNJU{;S8-^H! zpcpfp8Fpf!bh5Q%94H+DdPz?M+Za;3^IMt1q2jjhlw=K>*4WT3BlPXTmqC)Ta3xfVm8edEfJx=cf5Gu0RRfIYDA;z{k5k9uRc>_ z?zwul?aGsbd%xLj%v$XeShgBKbhu|H-+uQYKosL83-M^T@{qn3&bYx8wS#9xh(yPiH6Jl%6zV>=z~gp*+X$A%G#EEXxhW0s|ZH9DXy@{<%iczX4v1JyUIC#-4iHgo@!E9Tahot=&2VeI~?1fRZzk*6J^|`OFdduw3P-+lj`mdfoxJtfvdU8Uhk;Q;$cxseiOM#GS#Pw<% zl;DO&HWJ!Yan#%HXTUgLY-kAxPdoUH!<%ssiqk#qi~%vPGmrOP=e+S085gxs%>mrx zTF^t@Z5Sw{5mY4U((w|1mp!XlCbw{`xTvF#b%kvgE~(I z3rbJ99zUm}gENH_CAZMIEe4!@pa9}t zIZ_zN!IAW!jjy2WArdIce=Oe6;R^ki>5D!i!r4gwDZ4)(?vfKxzN z;;VS*MyJ}(N8P7$cYx7T{{ol@vQl7T=wYrgQC4*T1eJF#E##%$CsF*`;E)@$9+iJP zrxDc;{l9(GJ|nxT8N3)P+84Z(^zkHkrA$Bov`!n(dVi&|vM=;!{WGnq<>qgnL~qF= za8x?%F1ju(0YA0=c1}-9+k>?45ohGwpfFmW;wYo@X%y#_Oydk#L9{`CIj8#0V73kn zScc-9Mma-;(K;1MDFlvTXKJOtoYR`81YW2M%-}EQ)CbA0=t67vmvh<>CKljA|F?76 z;3)fV>HU5_-pl;+*OWus4cg6e-SWhrY-hBF-Y}8fyjGfL1Ft{=)Dz_SPybN$;tI zt58=?ulELWSyPD~`hvNmu(SRxQ%RO_v1jL3@0TPJXv>1*dFF#fH1hk%b!`IH)!%3zNExJhp|=tAPMw!{$xd=d%ysw=+c?=I!8BqU2$W=t+b3vdOpus5lT&?e4Z`Q#xeZjH?)~ce`eQ=Ar_8St{Bs?s?sIuGw zixp~jR$5JurHALa7nvVa9v{wT;2Ez!Sko7qD$g}9g|Z6n9$V@J3QQIA1eDI3C+Ve^ zPVHGKiu0Ys8L+6mc9XU(7}tj@Y%N5K1*4dr?~|0Yr$84b6|xJ5g$$W=8MH_)w3mH` z2SSi|u;d!uJlDP!}sPDjO|Xh3B+B8PYJ9ixGErVF*$pS?B(+t)c+b0i%N~EjSvF zVb%&VIp>B&$e_&wuw4~rqlA6y_&b9BVoGAvQ@+6sC}RNu>BFzX417m$DT%5ZJ0*5Pt^W_pU3EB-CW7ZF|13*lrM180Bdh!||*Q$Ptd0 z3$gp_<;BM*-oekp5UfW*BX`#HxcfI25&js~JG(<%?(|>I`2fjKE+0Ho!2;D;C>sxg zOccL@qLmJIW?vB=4N*_IQuMsN@6U^K-gE*kv==#txMfIT9C7sG1v+f?oYAtdQd&mE zX{5&tSi4PQk@hNjh~E2m&P4cE#S6K8dn5Jt;IEw-H9C5!;9FA+LfAu<74qI` zAau_jj`^=Q6%JfNFoxwk6O`EuG`Rinm-|j5waZhQxs*}-WElg$kH3!LcP8y{=hw=- zraEJjQ~`)`{;&87PXf^v=Yq0Y+FC8|e*B3)R-2)!|9gTy_7Fv%t!0eXLeLV!%OENuu+~Qw zDM`=XE+Mng^%0b%QyluGz`4=>%cs71=ITpQ@0Hto8NBZ$cYi1|RLe0<%8UG}L;FpZ zq*_``({oCkKHs7AINEwr9NUXrMHr%KzXViq=>NF#3}y@`#7Y<+tx^^z4v{It=&T{g z+bLiVnrn>WJSQR$;jp)rT6>jrd~1+J%%?ILDwR>EO?M9siy7|}HT{yWWZY9W2dmv$ zj|kDrDzKT6^N5a#!u7k^t?b5fEGj;zEU&MCBCT~OvEa0%hJ$wX2_Jc{<%m}>{mIFX zu+zPc_kHVB1Kpp<13{lfKOO*1hc`~YM=Lq&GhFHS(%$dKyIk6Sh!J1q>PunF!+t)T z2~xM%!859?qu!obAyDXGdd8Mgq=P5KaAxv&F>?5rrn8T*uT6wu94DR-orzMim=g*W z=KNk8lW#18vT<6GYOMqxf>u9F%LiP&1^Kgto6w8f z9jx>Vi;-?1SaMRY2$F;@4T_jQNj9c1Y0@=nu((5<+G-iudqd7~87`i8|mhMvp7jq;}}*B)Qj z@6oJ^v#cV7a>A@r2$A|dxNtE7Pkcn;(;M_d`1*dV{Q}OeAV^miTXe; zHVMxPk{J6(2$KdIWkVXaW17dY8B(OoWj!5weQloMNWiqDM^wMOKP!AJhbKP&`B>qv z!7_c*3)bU-b#RZ4{zw>60}U&?W!4@gUxqQ0m>m0*A6I{A1pB6{)NP_Id?M1>6rG`$ z7V!M7?ATOJ-w$=l63Zu{EnP}Df=3lRZ@KcRJ~d-X9iFqF&|v;xm?YL#a z>mi_N#EtbsY##blf#r?cbHHP-j!E9QXm43q&;o!(_B07nlu}CqXO%=Sgg6>218c=D z;1I_!^#(!^Ovl*&qy?O2587fR2?;>+zPZX#L#kOW6wuSN?YBMy1 z;8;cH4$MjHb${zZ^jm#tm|?W4C6%3(u$_lNoWBpjaTL6$0J|j(yS@ARL5{sat9_z_ z^7#{zi!jOTh3&JZxVvk%jQel>c;8v`b_LnkX;3*#=UCRv$S}c(*0^YE#JL{5K@)*;GnhRJy*vT8=Rdj=m&El~`{`cT;* zh$@_7p`-PR7z%z$1qi~rSRhdbR0^DL2}ml&nZ^wX2*X-jhEfZKAC0=~z#cZ2rs>Go zK$O{#;adW1gKM#3#JWdvz9+F+f5WEXYE5!jc@h66|C89*q?!EDnWVtz`zyF)rM?Hz z(Z#%Tcyx@$5%A}SRz@*Iasel-341{0RFY)>gzD13NnGcO14U4!sHTklj)g0;je(9i znT~nys-Hx(^xv)@7$Hr(_)z#_eq?PvO3{g%j#z&A9j@YI&7_U&B2E()`;pC>Y&$`e z!4YJzlI@pcrB}fWJfxt7k3JjEy=b5LwnAh{U>OX=rRtK=$;SoWPwk;s2*K29RRnP) z#BMT{5E^SMOO#l}4XeXkb0AVaV5wk8zZ$H*3oMSE4<2<0`EkYZ#g)ZC$ZJAVD?c!^}6;0ubh9oWrfp^j8*%u!jYqiw`(m{!&v7;}JuNHQ>n2#?e z1J`!1UnLqZ?iYSeZn%1^M0Ha5aqit0aOcN!W7U8itx;%s9Gk%Xxi4e|L)Z75Gu56G zQg-o9rsE>=ONtMd)I^e$+FVO=mNdRDY1z|i?74>2yuwhNQyE&!Kh9|p?cdJnvN1cY zQP#3)+p^gy%#?zheswdRUZxDab%X?jI_aOLve%GJHnE7V_IMBK&Be7$D))s4N>l`me4AgFm}9M&Xgt)G5FhTG#4 z>X!Xiv$3#NFd8;%7ciazJ;_oJkp z7v!zf3rKpRm@90L;!0%LD?|_vwP@2H!>hEumzdmeyjc}_Pb}LIzJ3W3#RI$NX%U~r zQ1J97i{OolhKE5u+U(RUl|}G~27<;a&SCO~zsAqDzMm&PKTA`7%Eq ziR&-qrG~Agp8k+t*^ASp_@~ZPOgZbP*WV>0q!uNl68mpYfa8DNzq5XNXYi(eN19|; zljM5E9j2HGJmSvtX6gCU*u9v?g|DT-pS#Lj*VYVL7OZ_FvSb=23cvnFlnFE^D0{92&6@S}tK*GhNYqdrK?3Ub}W@i@MUUd7A?we?B>Y zvhe17nH;lVBdkn8E4w>#W7m@IE*F2koosw~q-Sa5)tDnBbRW$xEn(M0&(V3p2 zoQ~XXG(n6;)ZyvP1;8e_R?d^C@`6SQy>(io8|V=f6eOAXrlVNz<|~;FqFG0@}!VjwAV%muNd+}we z%wL1z?${GngFTM++e=kSx8H@Cc_v+`7vvFT3|5sp?!lDE2@ub@ieMb*M*f69NqFZBw&UdhY-E$*SDM)O&Xj$ax>jPcBhLK29Wdr#pX z1z}SPq`?<7rE5TES$Wx~mln}~a!SFS-_8e}(9B7~=4-?Tympon> z>`iu_>iu~ruA$WT+i`>sHlP9xkoL^>k!P?mei-?QDCsph_Kk;OqUZ5SSa=5KNq5-E%Fpgu$-chI<4EJa z5rx~eqPp(e$8vWv3pu9OLO52HBpFob2W~$9DXgwto8^zp3!<%i4?Gav)m7> zzD6eaMDFvGcz3QCWJhovlfFvDf1rZ-gS)xjyje`_i=^f7BQ<%W^rNp#>)Td6H5(!E zoZ2p5d-z3x@e%T-YWn8#6Kx)B({s+`@k&ZXph2y=Nq9J4YPW>N)BDvp$)m4)=*G^~ zu`=3_L#R`#UVh@OCfm1hey)af+`OebFza$`sp&JPB(&VpNTNBFS~N-crh&2!TWAue z-|}F)^|$-~=A6=yS!8bQ%YGS-=TP9|`Q_X2G|l&>=J#?-L#^{ghoJGrc`HWTnXscX zBA-8dT&xS{c4riRA``$avV=_Zx4+Lk;l7(5wiv6X6>)b-RzmPj))?Ep$@Cn^&MHq$ zR9^Nwq49}amF7D7OZz_lBgv4U#{madUD}2knSv@3E&8OV$YwEL;{sNZ7uxShcN|#l zEoSq3v+Yjl*llT6sjaZhO`(~p2XC6 zb*|Jcs2c62uK`(8D>T<8L0oMVK}4FWI1#=@&kDNm^tjL$JkxlyV#4!l>SGS=R3#$c z-7r?fV&-(Mu(EDCr|bFD#_zTj!kRB1Za)I`|%mius3S<0a5_iX2fao_H6 ze+)zeaN~686*pExkE`7@m)X?tceod)JWog3 z6gPkMvz8oC<%hI~nYUexhT`2b-ab+Id7ItfYV2(v{m8Qh=qFhIiXu6-R2{>o=n@VC zKDO_u18v_|YaGi`zU!(fJ_f@v>@prZAU?KeK?Sq>5D`jMER>gd22sd2^^N)Zz|$*; z*-x)GM!b48wqK}|dlDV>$ns_(#=htEzZtt+`9vFSL&deTQMhO2?tpL7HDg@5pwbY~ zEJ3qwU(QEX@(vH!*S-}qIG~r9aFWbhw{f4e0pWJ689hC$?38_nqFp4xlpbT#pZKgr zay9bpAxkyatztox_25~7v}Bpl z%81`YQv34z>@&Jp0%D+qF=RhO+|38|HD!>;v#LKa80619U|-a+rvv6j2xvt;KtPCm z^zL`UjGm+P?}|N8F&@ySQ+>v|-!y`_>x;S-P^P;8>-xUgn0)WJ>|Hs{=rPwi|MPv^ z+2Kk=Xv-qxB`h;^Fo$i6>$-`T*tl--CY3x-^|`lb7>w;&qa%epb(k5VahuYilhD`X zByV5+^J564*@Hz@E&%fKUrf)smn#R(Jk$M@N-c(rCq%nl*JsQfjh20#8a`oj+Y_F8 z@oSng&;E7USjXq2U)Hieg9obxzzmE9*Y0sFT{rlOq_fY7|fS#n#0BHYw?$eQE* zc*SA?_U2$Jc{n_kGhU~v%BM;wx7xm~i|sKYm&(}#Pt6ErZ$RioXP9r&T6qpY%AdcL zBm2_#`z=85m#-S!<9=JhzFXCB33hoc{(U_1E`9vDIR0NB2^xXh^>A?PWGggpyGGO^7HG3Oo?0-{{F;oG zO#!o)?^Bk?rl!ufP1nme>e6gA#M>``=uj?`ZXQ;%6YfGS4IN&`5@{bE-&3k{T{CkD zx#&Rhw6D@vs<}*1|L}$Hf=>AYz}aOMFPq^KDZ7lh{9ApFq=6p%RM8L}NS64*oZd0) zK%_sv_Lh&uK8h=370lX|LgQX%jruvG%ORWlitUJ2_W8^ZN2znPLJQ$M&LoDnA3-Hk ztiwsR5TV)Mn8mb8v$OB3fu2YEGU z9Sh89_3dwMCb(+27W^VY`E!8&i5_FRO#+G)Razr@I3be zMQ6DI=117&s(t(RQYdh{*i!e0u*$*M#Wy>;m*`!&-f#*Po(5E9>m`zvF6q!dOp3La zJhLjBd>}NxzSll<`%7SR_ea66-}!ZfuwxAZ+7^%UMrzj917phzC>2Xbkjw_2rDuU! zl)dL9r{VnhuV?oM&u&jzbrHAaco$`jRJ(NO<%5w^to*)I+A>?I={?I(%Z@RVf<(JS z6fK|S!8>npURh`MuPO|kFZ>^G7jE}Biv3na{y|bH*s`qJC>Y5n`qE!_ezzJi-1(+V zX1ChrL`=K>-<;FlpI6ACO^+G>aZa_>=iU_gH`e~+oG!`y>HJJu3;4@9ef+0)yZ6`K z-+wu$`vWX}8$zeui+?$%3VoaRv+2L^Q=C&9a^F@KfB5n_igW7Uw_Q{j`c<3aoTm5f zJk-{Rx1>0y&3(I%uU=WbN^wr#^ic*uO@7^}Kl-@c_ov`h?{DI{<3*OMzx%aAgPmEE zH*#9{2`G*bF~*2*Hct-8T77Sqr8x`UADNF44yU831SNcMwCZQHn?~>4t0?|_xE@J< z*TAo&@-boYsbc();Jc-M9*D%t_*?H=Kf9F^*vkhG-q|K1ia|~M;K2tFE$5x`A`3kK zb)eYr!@N}kiDlKn8S9+%3%&zeXuvHvlZv-Mv1R4mx}R z7^xN$`g_ihD-5)AFgWc~CYe`=$HmNiWYo3M|BJZ$?rP#;7k)n}Kga-laFGA|f5>#n7ZGMMS!Qh*SYZL_l)l@80{l_kPx1&pGS-an4$K z1(_r>nYpg-$Fb2L@#1LU;*YMZ7jP+Gn|%}U0qXpNaW%n-*U7#3gFi2QR55E(B)yo? zP+K+&I-F%UXE_)}=09^~%*kk%m^y`s`vcqsaGsbRM&EFb6?XA9UQTvhMl{)FWFI#U z6ne6NLiBOEhcm`#i>y0eAUQHZ40ut4yh3Yy8;LT+jIch4tqQDVh2uw+2w0Y&JUEz* zdsy?q8W?9i1cd7(mwBaU^e0=mYowr9zo_1tK<)zjt2OaAF0_hk{9t~;ByG>D{o-Co zSqYq!A(>Gfv4UCxyKoV^c=I=Pn(2a`D3M%SGjP8tlf19wz?&^XVx32RT}N*nO*$gt6)w`x&o*X>w)$Hy)$=PuC4k9- z!*L-!Y$rXk7Z76U!uJ_7`a}m4Iehsz)Avt=u}hhrkE`xVnQ0)4*{Oh&yr2n65M2Q# z>s(~>04XQEwJ5<^eRDM%A-kse%!Gc*uvtK~Q-BO47)AZXQtp|qs+&o9sl(G{RX)m( zoR%D3R{UN0*G?jMf{})DADkp&o6aNHkT7(LHcNuJ)^0eNygi=SIi(>)#o)B<#n5uT zc#dg$1uJc)TQnC(#7x}A)dX!0oTX+(IO9~5`;+oJdT7RxbYhK+UAaeg<_+iclv+`b z=D=XEvax&828tLtl|tVynMVHHcrdf!vn+(h^^w(4ql0zGV!(z)gAb*k4^D;90? zQ=L{JkCF0j&~q>P+m~~1KPvg^FmwCs&oA{})NI-~6|}b9NQSejwu@M~(+cr0Sn4*+ zqKvieN+M)LL^4P=F_tOJm{Eh-9)?+0s<$aSI79q1{YXDDCa)DOkvdbllvus}@1G5*?EDb|iJ7QH{u3y4gcsMf%Zk>sOHX!q1am z(O-@{-+rGm7A7^}-O%4mgKqgAJ1GI3I?-*j)ISm>pf#++(?$E0d%#J@Z(2eJapo^u zLJfvf#QlwT+Lpz76dh`qDL)QTq`-(52gA*slBnOU9M`9ukU=-u88xeWq@k$>tj1z12U<7BuBb~kyHnm~eljJH@T43% zU1u=nIw>0|@s=1;mB77Efwi8Jz`hyGYI34GB}X}LE-lADQH4AeL$mt6>$(PA=B9Ss zt?soJe~8=ZnNFBxmt1G(o}!`55u(|bFuj#Ypnmq{rjvQn7gk%9k;JT&#JOP4J%DCZ zPk$HPzgx6-scZDG4*eau?KrgEN^@VYU&?UoTlq!gw`M5YqIRqgaT2%^bs$ywePDlP zHhTX4=c4=Tdi!f8-;y@J{!E_?yY_|{K+FT{Q5vM9JT_6mbnq`92_E~MzayE+;Vg-8 zbQRpt%Ho#Bboww;T!S=M+dh|@8Rs7ox`K(!YddE%6On8Cs2DhUx0dv0t>f4v=%WR? z4*M-Ih#zps9{hRtUJKzhL9tDcx(%q+q5SKPp4J`4-Z5jE(tDeJ>4v$$qguz{ z_d`?SDw5B2C82uN9#oH=c$=nC=F{5s1RP56md>Q~4+=5(!jzFD+E`ndzdBvR{=N0q zdzOlGU@7*71yU{alVQ17(Ci%3Kz>dgy>~!ZR1^LOr$nLnX7q0z~-Ebc5frIDNTu z;*#y2Q|q~RH9!}qRFoB1pA~fVjiBu{p|stUTdbj9u0|WM#{5}-D0$8IYQII4D>~)6 zqkXpc16FkZ_vov$X7eA#OQBrWAhL6~l<*gfBu>VRRrbtB_-n?z6k4+Mnmzj^8A82M zpFLtBCa!7FKtJ#47Kiwfb&__!+5sV3Q?I{N*G9(7CP0X-u^>0P@?k+UwBCShQ&qMh z!#1aYt@J8d>1e)|B=66Kj@IQtEotA0B-;kJ{DF$sr%cO7mekweA zWx=Ch$-ihO&e?5!;Ii(xCiG9|R9a8@y23SLs-t$v@&U1Ps$hL{>1s5wbJ}!cVcNRz zZ|BtZI&lS)*f}LePKRvAiJemg2M#^^uf)!2lVfSW1EI-@R?&g%pU&yaCTC&An@Y{s zhG{RoodaURktw`n}4yym%6*nGFv@8Fr@z1NB! z<4^AmDtdnO+j`sV`L+4JBlrFN<_Co1BR>T%S|#rv4i8wAe1uyTxmtXbS{~+c5mTqW zW-T+iN{<|s{2&F7ZYvRQ$qjq9JdSShr{yH3P6G-b$7U!6)+z--o&>fi1rI9y=xho8 zNX!7_dwpsN-TyT>sT4*~!gR!hktv6>v^sok2^Vg~o=sw;TO+jm_2iXtW+xFw%8`z( z;kL?A4^G1FDH9{7A;k5TWdEReWn$zsAWu22*8lNK<@ndik2;hS#{D0TDkpwY_Fhm< z+CRC!qnu1o_BT zsrbM%22vG95Gf-PQUnN1x2mShE# z5>7t=0J%E0kp&D#0{|jC=69gWIsm{i%5}IV9^Do-vlpW(?yyFAzOsg(}8otY84wUShh6hS(>qQd^Z&B-TiIt#InD7?PE~diiq`3ovj1HcG*h zO5l2I6HBGX-;P3{*%;HdPN#HrsTd@=b!W@<&Y8vYcy&Al8Z8fC=wsA3nu2cjUoT!MB+hC0G)fEfdquci8wlsr?3h{BUKkWRM4+f78{{b{QwCoK+=yK zx{k}g4knkugWU0mx?nwDEL0yU47`yB@B$KONdQTS?1!un=by7wRwy-BIW*CjuGzG% zxigIovWpd#P$#Eea%BMQ3NYYx-s$u&ef_iiD@51-Vm9l~-19&4EnQ9`AymqE6e~V2 z>$mhQ{%^NbMjPGVz5F9YS6*jP{EybG=4K&uRtpJI2dK_}FI?8jCySysM`|%*0jG#h zL9Z7Jp%dazP5EA!1W@=DFTdVB-`KT4MmqmdYd`)%%QbZCp(gUYgAC);6GleI2c6J) zilByZX1Asv6bU*q4ufP+sW&et^JqsgsyuNuNE5aww`zOhW|SrE{3cx`?UqTNk_G8| zHmci0l!EF~5TwXzZ;~Dz1w{%di4Q?8CjWWp1pyD?i(c^nfao>;S^>bH=}g4=b-j}o zJG&PsmQR3%RA8;OE(XdMo8k^2`}m<)daLD2UQk{JfbxKglMJ8I%l&A7W68)u^vZMd zXO?C6SZW=*MH7KKj4K^ICBF|)RFPS2+v+1K?RhG{JzaUd@xFa*dPyP_#vfomyM>3T z#KsvL4z=$sv@(83WLhr0`DHF!r5A*8HsIrfL($u(3ROoL2h6bFN{Oz$sZ{hZZ# zbU9cIsAF~2heeR8i6a;yTd({jor|mXIj0M(qm---_U+E^*0T@1XE)*~h zq_F6xGmx0fy$P-T*}6X|b_EwBEc<{*^u;TBBNSnE@-iZFy<&);*3icw z!{10hf_h_q6?|Bd-!KwUGbUGNPTP<^9(G<L|3?=Z1I5t2rHZ=6kxMU2I z=p0VnL!kH$t9hC}f2!4!;dI67<15GxM)`3et`8CbF`f#-7$fI#Ao|lglS_|R6R$4^ zY*l|;W^}Zon<)v#6Amjl2?52JdvrEnjH3u@r6;1lxS!ptkcx$ujb=8A&y&jfga=@R zN0%s*Z2cYH+;kP>lu?c$R!e)3NfGQI3}>Y31uG_nmOgtWf=aKoB9{ToCdfoHv9Si> zd^Wp=lX0-V9T#Y7{W*%q^o}DfWzG($SKab-Ey0n5wfH$h96K)yRd!T~Bj-?VsoRY> z5IBT}T>w-0DUSrc zZ&mgef24dj@pc?M|8R&PN3RTJ9n-THWBW5a)gL5_AdC4K#3rgplKMdaEN}YBR~}=S z`Cv3}p_OWY+vcX>qY#^2+S?N1L-{C*@T4-wJqU{K^I^GE+v#~T@>qZ zmybuAr>_|Rcyv;k5U-KP8Ur2Q8`(^{7?eN>-3?2)h8zYKn>$KKuh5HArVC?C<)r;I_Zs+Jk!^eh^UY5xUXqXCxHn_|ULE~qL5c;jy?v`j8 zB{d8ayeJMAJdfZ6uk>s272%vn)3OH`g;5r``}fK&M;izhrL zyJ-yFaBi~OM|6*w8$=GQglU*W)Y4jkr`?A7{Do{qAg3aD*6eek`Dwa9sm2_Mxe5)n zK-1KVN^i&)7#vs`w5v&fCi!T~NTtPy!5&E21yFRG$MMAp45&_CJcSfH!OG;MivM^h zl8&(ml%Z)=Y)rk*aXHRB*BVK_00ZhTc)|Rx{q*Whyr@vI>3I5|FE<4Y0B8hpTfl5w9Bf(`UtvE%IzW)MPeErrwDaxi*IaoZy7)z5F#8 zB*`v9iO#>r@cyG1qc=%@&7X(JJFm}FjFgc`7oou?u28x~FhKK62p??pWT#{uBs#Qw zoM1$^E+o()MlR*>K43m<+N^U}gFhfloSu*aEfG9S5t4T^ctQZz@sq}YF1tI;{^)hZ zTPoDKk!)+nVd|Qw{SJsC)GDqs6P*np>ETn!jEr~&raGhx90j8G>W6m*XhRNu(*~bo zuaMm!<>wqR0V<;TQ2_qDXs#*F!j*$;V%AS(ikw^@?<#%+Md|}@i||DO5$4Dan)A8( z;%q+g`}IB=9Q4fUQoIXDBk~$3E_iSYAR6;7-bAc)H8$a^gDh#0#)V%Y` zK&c?-U5n)0Y4Ngym{SXEE0T?aB-IV&1s}z&pa{^;HYVzo+u3|P0<7Qqp5L_4r87{^ zY<*$VQ+Y8dW@s>1MJD<_$BKBv1`ULNM*<{3tQ&4w^&>Fb)Z8OUZ^CK#>^eQBw9MDT zppAle(KMz;cy|W}aCFUIebAm-?SX9$l3uz{WU}|7#qCyJ=VDWQcTFw{4D}5&UK#3P zSW1;-_`t2~g=`Jfz&b(@vqHlsZ`9uHDiL%txX^xTuYQ>%Arxqy`TZVs=Keuf5Qtu^ zP#_I9hqAKb_})4p0T(1p2agAJ!7`j>ig3F9#WNmyG4y~;_)A!?oe*64vK|Z|QYm!F z=KcOdH@R`FD1gPhoqmPS&iLz35R?!L7*Sad;PO|BEHqF3(%&5!)M^Oo!%gT}Q*o3Y z7}zRb#~e%eF{t9tlD}i%dlm4b7d+T)pf0dWyO(^G3MpYP=sh?7^q6vkX)7RFUH4TQ zNJwN&ZS_!pre*nOZK1#|#5Rmn4)dtGe|g6#3>y3f3wIPifRM`l^fGvGt3l}Y)z_+q z*=7M>e{k{M7EQK}`;H~ILy*VoW20=AQ=tF=mQy19o-Djbod`xBoA<+5&551kqM7E) zja1y`7>X67Slv97k`n`PVn}wA`P5fQB=CR$1}e1rTN%rheIEn{02<^aDlt1RUQ!|A z^)!OiYqd-Lo+|?RgWzF0CRvG4lVT%kXeOt5+4XTk&_3+BHn@s9%&dIDXd%mV zm99x%RyQ-8oX?xd%{#MymA!8DZ;wFIxjI7qN3n&&VRj zC)&U#`JGQzj%QMqU!{RxgNXgel`F~#7&Hi&d>62=%+iw+ywUKtb86tj_pi>WX9Jhp zccDi$U<0z&&7CVT{|R!Qb9h1OO;B6PaU4+Z%i%4tQ*P^Tg2A3o&%fslGn zuX9DJtwD-^SIVAG0fxbrAF|6M0UHdQk$A0$0Pc#Tx8rsFdjL)VM$segRO%r1L*QFD zoW2OqOhfym2}Zpa%DkfM*QgZsgAV)Nep_7S@}A0`o0JJduX&o_ZZXIe0a&N7ANEi^ z;N_hT|K?IJ^ol{3VtnG;GZljp<|955stR(8bqx!w<{hk-iGb$b7uBCXz5rGZt&M72 zyK2{ad)@cU$}Ox>-+10QgBW*(XslGWzT2N)65#GJcA!=x=nCi z%TJq6+tXWV+s12O?Ijf{;-Sz6>a^p;!lA4yfV>kNN%hv86eQ$C!Z*2TyovAy0j>yM z&J}}&Y7)c>AcKKv9wMlU5EY+UC>T@>Ca*>9F4#axSuxay2_O+Zh$tQqL69!v_?puZ z?MFr+gfWBcP{p)ieL6oUW%x!cSPt2!$7l9Yo^<}m^z{CiyN+HRy#t9Va{y^jDW_I;w zy3{I|8Gs!XGwAC>-_)6#{WND_GF5RgP3$!yNCU<{URWCpT7+BaGq_yKYv9i;)=z$= zVb)7;h_U=-&K<=tR4#w@jM27<$>1y4h6J09#1gTd8;{U7ydU^+2}5^3n7gGnxibn* z=xEH|3^pZ~Q@tri57vgEm1w{`jF`9dAV3P>tqbOq%_x1fOJ=`}a9W^Kk6{-=_T zbB`Raymah%WF05CuvupN&D9(oX`?z|122K<1H2xXZ*eg05XfpSZ5cu^?PhfFva>W{NiDD1G6y&-O&wt*fOB*cKf^1prxRtwOe4@ zK#siiBb{Rr!}pJ_Q~H^p?r(1J?COo*g1N&{?(h;X_?p)Rh@2T1k#VBu!ym5zpc9-2 z#fDRo%RN05bU0nNU-q3Uwa2Tddq=#}dZ%I1^j1yWXcnwV#)(195ud4$QrvY2&94NR{10xBx&6?=@iM<`ryG zpGN{YCD1yHy)hlLikm$i%VZiD%B;DS9<53xC~vfqK-;-Qq&{2Mz0 z+mt2qzY+aphSH3@uWlzXQ55%(ay6ExQn+w#!)-uErp9GewunWwy&n&l# z^aj@d8A)%bN_~=>?gyzO$+M$gsEXWAyT0<4w! zE@Q!MqB+W915dv3WIH{+>kK;L$+_Q7UWPVk?hnu~|V9=|34J;W;CVf8L4o_BL#=6Vp0WlSOWq zKMhLoq7(X2f4Z6_jJkFQ0}2GbC!`eQ3~2Oz5@cJUTU(-aSqs=5b?W;3fGSZt)S1L# z{chJsHSFqddm;7E(EzU4-jJtnmIU;>M~#Nd6oFWHC=cG3Ke zIMx`?GG2Z(J6~(x(-?wgOlo5~HMNC1Y2IH}ez(MgAw7zxmq$WwJs3W%K8$ITyw2i4 zc$WrZR+yV5e@z4aB*ePR0AAZ|+N11S5CL-#=_l+-s!W0)pY_tM?4?D+88~)^k2~Rw zC+_XrwJ$6yPm@;;KF;a%?*HUGAU8RnEBQWZLh^2Joth@YQ}?JNjo19W+`OCAg5RgUAM)F|RI?Ek8_=62FkvB-fWSJ=`Z;Ea_8zAtM%AnF zu7%oHUu-|T)1y^#tPcLwt;t(b`&~+!*AM~7Z}OCcBLeFWpL{#?yWo-E*HIZ4S5Gcl zGIgk0RRHyp@amw%s6>vWol!bqDUlmx>%ABq3I?CRBN!e?NS3&I4toFr=)DQ-8kR?? z4Ch^yis$))jP=*sjL?<>j0$=;o(wmewrjonXQdfd>v zn8cJR6lWv!b6$>z_y8dM>)G$49gf$@TCXquoS&T$Xq_-%8dfVAQUa+C!b3;`L2Gsy zn21O182HFsU^LBCWPc-v=czhC`&J-@xz*8ug#2z#za2A1hijTVfow_%l@*Ze8>)w3I}* zELC5u?Y*3wx;c_WS^md(AcK%RuaGeDZ!oFRt3KOMoQy#s#c%H^Y#c4Vi1I3?yM-ZL z!}P|N%OaISCGl!=vCp3dt3Lizk}$Gfk#1#Ua3^#3_xj>(7oU+UIhTdnToX)>S;WeC z-m=|}eA*D`9L3=`#3L-Be!vf3IKgjg-EG(s%4aZ*d-hC@@UxOPnp=_mS+!-rY3j?{ z^Q1M#3d@E?zeB(jNb3~)*ghw?!Pm7&~>~V|DgNy+vtPW7Z;RuVMCYbvWM@Kq4Znd_oiBsM?L0d)CyBpj){+E zY#zLQ)Ti^9TJi0*XcND=TZM_obB@p7D!#edLj8ErXWZoRlHVt;nFas-w~s%C5~%%G zICmudSEG26{MX{7-!ZHvXwd|$KQS|Hn@YN#6tI!|;9bDi3xdP9fX$L*)4;8AVd&{r zW$nAbotp82)18KK)1bZPADm}aOoub+!yw4o{hgRuz%xDD_a@^@`xMvLjW4^}^%o zg2>p5$<1O=eIYWD5+22riOsz%BVJMRb~mW*#q@D*d$}kr7So2o`a^!o#GoZg=pM@V z^NV@M8skjp^I!uasKZ#xsuHGf{Q>d2-tlr{d@QeFdIY*a*#uV!ev(1=H}aLK7*eNQWqcqYnYFmk2RCnMchP~4Gx)RkV0*&3FyY9yv1Fi z(B`eTUU7}yU7XKV#>wCMlsqihttwYtARqU?`=j7{)pL#42IE274-41x1+@s|6W}kG zil7<7%9L>v=G^kdR0HcM4vI;AP5BZ=-F38h+@zKRLB5o8;Ip0@#gtO0e3^j4XMLl% zDe0&3&m@}G3~edi3w6tv%PXuI--~ByePGozh+>-MLcRj6yJ{XEHx1`jcwziu z#WIiL158t)((3Ap)yudKAP0q)c3(bS>!6r94N<6aee}tCG;ZccfkL$h$tRlyijTWp z3N^kN%XT|)AHOUq)CLPKJDgF>F8@)e!)2@>DdT6UX%y=d?3Y|PC~sJuUTR3cy6Y?+ ze*>hc*qC3icuS4a=1qv=t1^v6ccXY4e1T$9)zZRUTT0vZF2&|oo(mrL;%yt36k9s? z=I;kl+Ex8gZ0#5PT1u&%F#TxP_hr2O@Q8TFV)c?&2c<(= zh*IY|*W#nm_~pSwrLNtkxqt;q$DV4X?xT!PK|ArEI^HV1J|FlPdPcbt@J9&`b@+&( zOjv<(Dfdv3PGC8xoS!Ty^)mX+M~Ek^zOqy9<6It&QloO2Hc{>usF{m4%3ozDZS56v z@W$Ehu95aBzaf7rn@H*LnS)GuQ1zR1a!~#!9!Og^irXhR-edikPi1(^UM_8PXI(g2 zX~goIRL0BvCGneW?KaCkz8$+?Hn1vV!(5kg;_q!-B2#*M_nTziLjHnMUt6=!(}x~A z9$#nHRK{N{T`Dx%`KlGIID!2pQA}Adr^Dag5a&Sfb>-OGe1lS*iY<_TMtN_`h)nT) z?lBzKJ&G6}-Q#(@{S4)X(^(=U%3Z+JY$Yh!}0}J&2ZHT>B={ zHd-+Gu(YFO+u<>KVeea1ui7$sfl?Rc-Tfdk`A?@8!mrP|#={`zPr*F?8kF}B{E2t= z_K0ui$E}0t!b@xPJi-Iw-0$LVcIL5v@>f**c6bk~zW$z5Ww`Lp_cWzTU&PFWMr|F& zvigFv0`0kNX{y*u4j)9_i-+UvJl(%6~ zsvlJD{CuhAyKO^uAW^vRlm3ih*O~Tkw#C+u-Yj$X&fvkrxn)0!2(EA5w`Jzq93CI6 zJ^2>!Np@bV_g6RB?|nJ)&Xr_ie_D=<19OMY7>4l^AvJD{f3kW(Er;}wEq8m)FS89T z+S7^n(4#Wxt}V0V)9II?KdT;e?Ksk&&5nj1zpCxpdyss#xD)!TbH8gpjP`tmGVG*Z zy8AFW`FuTEGi>BR_m4u_KbuBjr_;6FKWmf!?A{AITioyd)%GAC^Z$%G*xz3H-?W3T zYjgigJ6KzqUR`{@vN-i=Ve)@759Vja=01%5OFo#H9C|;xi#@VqqtMM>tflHBs5Od^3$QkYd-kY1dd zOjHmGa-S4tC+20R7Gxz56@>i13PMs=Y79|9NKJ}LPKYED2yrptvC$a60Z+ev&;LTuK9#?k&i*n=BaO|M_GxNc=a#1E`2O)QBI zx6O_IB`cT)=^H=RHS|Rr{CD|4_nwx{Ee)iLD$-e1%jqBU;2-e7$U@i5`myRoAfiOpVgzzC zIdNtd6(u-xx6QRk^8gY4Xu%ox&xy(#?jK{XR&SPGktW@mPC#$Fuczt@y(kMkxA^io z&6q?~@Cy1`+j@retryQ?p6dQw&oVR+Ew{Xy+4d#Jz5InxkbU`yVD{x$QDIBBnYN7r z|BV;I;UZE$h4S@=RW>Xh<+gn-juEPKzvHR+^J`u>g{p`}`g<#xxaz9vX7S9XZM(X4qw=0*wb)OwD)g|JKy}l*)}8uZ zjR*s!JKBvB4HGpV?peOBVSQ1@#(uS`e$wxrt(2m1TBeku_2<1dkDmsfO^golk_r9y z7kJyjSn(R{agwc6*SVj9q~&fi$4&?9UGe?SKc`vJ-K*R8?)SoNe@f#){JM2fIBrhw zFiPRs`Wx$-orgnaCc##CrZ)yM1?*`FdGhgy$q4!+C;oK#q@k*Q!8Irojv8rpGm+nL z*GWg_U(^A0b)&!nlnF*}afXYhZc}EW;?+Ky=od!KaTqdauv%O)cS-YXe(N4CZj3;l zIGaeD^mDy6vwi&M(Jb{Zf&dMVVa#IM`%5}+b9vD~51BMCM7NbbimgQzD(AyN{6&TE@G>>^-zT#%uxM9df1X2pJ<%D&W#5q#Z6EyZeeiHE~@r zzc!F)L6AuC=6TVhdG$RS$l(zu(|>-gayb4z)k`8yDmiH0aQN|*rE5!4mlcV0gbUQy zLgmH-CepaiHg9X4ZzYFm#x9-R{feJ*8+lmxZ|dO6%X!s?VyF(_2Dkb_E*t1Y-Yv+- zp!=KEoB->=d7t#NYZIvhv~z^RkpP_`kvf2C#}p!ZVGyfAn#U`!VLkg{^84u%OC^Gf2e~9YLKBRy;jc~nxS85XhisfP?V6zTO@VK9Y{0Dm#m)M z*sd{Wm;g*VUCrx@nbTalJ5{4_Jm4zs$Y$he+9*6#R{j`SRkQ_rQdW*Oc%D^7vPQh6F@h&xQTgt1BF3hBtyQ!6W14SfV@-se zvB+g~i1x?!`JcjbUN}1S%4@uSW3_ONID0a+*&Nl3@{rLThpd_F98_TUTsVcT^Y%2Vb%fA_NP@pNBYeAVvD-RmE5A11}fU!k?D)!tG_)ziCWXz+fNgzZqa5lD4&^@fD5 zfSqwBN7$O&RtYCMWxL>-Xk$GMYOtvsYNf(tp57`C{%y?wgaI#+Nxoz=+$AyidPVajalN5+%1)#l-qD zz>)Yjx>C0WK71ljT*B|LM$@0v=`GF`bzkA#4hI>0dWC)5v*W&R^(lV&rDa3^^=(&; z@sY`=6AU8d>$bt+z41@~Q4eC3gT+Q$XHJ++**1Mc8e%tUl<7u%zow?yW$t6GhqOd} zZtj1H=Rzx{D-PA0`PsdDSfdln*C#6#aK5_V-J1RjU7!2zo>Ag~G zK0>y)!EiX=aIm{JL%V2jaM>jE`gS1|JKpVEUI9e}i4g{SSV4wF7#qBuhsoM z%dBDj65;oRR@RSFvp?IP@1D+#cOT0}{@Dv6ocV8S5>5uqLM38fpKqFJCB3V?IPQ3@ zy&LxW{3~tI$$P@ZNn!GzALE3J=;xC8bQrKQ25OD@AE^UI>u~1(kvdSuQdnR`vaw>V zSc(6UI&g;z|4-C`VJnVfHp2J>XU-aFsT^rVqz?Rvd7Vfb;)lL9(%~fXFLj_CbpMAsxPKDm^$&F*RT1S&qz`~RCd$efKW`cLY>`6RYH`#-6JY;489se|}<7^d}K)WJ!73Tr(6Kd6HQYmtPZ z)`YRygm)(i(iOz1rNn9L#2NoYm`LJmYvST;;&N-k{7K@Pa?-kW(hJt4jqIfD)}&qk zn9bRw1J>m4%E`A+l8*e7PqLFwvvJ3*$rmTd09%UWY%=&p3TZ$Jj4d3RlR`E3mpWKK zMD5@&b+A$q&v)Z5buh7+D$@3sIw-1mBE^uRTG81o{*$Z-m7MggsK_nmA8-f&2b&gUiva*n3bhCI$WqA3fM63KV>~s*5V^zwKz;xUgVPSHz`q+59>*3!WwYkm3YLBstloilq(E4_ z0G1R;CqAD!u$XB!PZrfAg}|#ol{yY z0JO-!z2-}aVQOj_$nKrIKju&f!Hb;4n-TIDX*ex}j#gR*SQ{>H&3$?APA2CGj0yu>0sz4YT9`-) zBozW6a!V+Hq-|yeJP>7j+4jYT1DAp zMsNu6)7#NBe?SGSKuKYMv@M(?zR6~^$^NWq>P4MJ`zx?EfWk1Kkbs75i^plBYJJuo z5tKNf`Z(NdNLdfzrGTt}qU*?uQd)C|pOU5ku_?symqs%7rt~1F^Sw4wx}tR3wtJL~ zp5m=4ci{&2TFUMf`{yx)h&EskTAp&WDxA>@%78Y^;ia~4pbls~pe1gBL=qJQ020g5 z+0#)2Wd&{;!QIs0uf;oijXG0qblg9q^%8IKwQZm2$a{W9+hGnRTgdtb07e9SI|1k_ zI-?~^fspnQqi0<{Xb}Bf8M3$Dht~(``ni>E%sKVt}fIXR~y$BjoR>-Oid?f|g80q1r>Md65 zK3A*#Y1j)Zs)eZcrR8)H4*=Oj{q7lU?*xcOrUTOYx<(DoXWV&w0D&WW5T~?W?A>4r zKvm`ir&S@qff6L#Y8MoYhlQJiSK={d zM1%z(Nd!nZkziv?eKO~8n%!{0Xs?bsJO%-9!iR&s$`5|OsXNb9%zvIm-0CUH7C0b# z*gjajFj#v=+am*^N&yf^*s<5pGYR+?B#0U42ZJbN0TA>6kj0Y|)mPX7C}f4E`VM?T z;_bA@+nBMzOSawM!y;xUBEkR(2{Uo>YBy+(eyGb}}*Ch9!Ns8G?gvOLW z*GpxaDUpIH`jBz4r75W;98o)v(|AuoVEaoQD2HJ93f`-GPN*%tM=p(PaZRH&#&ztc z_5ZxNTrh1ci8orBHur2XcUeR!=zE&$w%3 z-!7Q(u+O@;G~-2`b(8C(Z}s#;`;Y#6(~k>221|YjTKb6b{1C=9i>v<7X)_zM)cBxa zHeoLR4L9|4Dq!wb-AS*k`{uq>(sSusHTMVRUJ6 z!aiY~YiW8f{(W_XX&vB188z3n^np;Y_^}{@(yPPB`uTm?Wy*u4F|P4LFPPv2K;i{b zop@&}0;9*^4cwPioj;vaFZXMV&H`Yt3?#A+W`qENkTBMmvDAuB7k^e16KDnTKsyq? zgD3vNQ?LxJq-?Ix)+{Sl{R1Ib&`^0bFb}OJZ?5to}b6jL@HbKGoX;v83s7PNStX{4nQ{Qm|t~m_YbL1DC>#f*31>NVM#H0!AQ9kIJlYtLz~nzjWPW#wSN)EEF^4m)vz z=hYSVOTd1ZH%Ss`W)(X@%JB?6E96kR@|!m0x96WjyvbAHk;6?c zDzjGlbW4+>3}slRXG1(`fyt1a#7!YGI2GwE?Q@51RXPe%1jslAhLHi=+F(xV0Mm2Y zH~`=@uaKlHcQGu#6<1CYzLo0$&HuJl#JwTjSqef{fWvo6Yj!GZp(f@alOmWIakhURU9`WQ?xqo-;%|&PtAbs%pVlSa`ucV{9 zj_?~KJ<$V}r4>hhhsb`Hl=l-$-z*QoIt6NRfH8#780~Z1tUicTn?)>Bti)IqX+d_?`^6d zXdkq6j6%XG0T~!%70ho8pEs{T6@Ek)4ymlZCM|B<4FLRef5}P1#cknyl!qksP)Hr+ zB^eM?Ee84k%!dC(K12=_RevGw7$PCp2(*_b4hi@ZlGPI*+TV{Hq3omeF~z_3Y~bqv zU?2mh#jKIY)RExE{3Jb?$20)qHmT$gJsF$64BgwbpZ@k4O&fgiC-sn)1roXe zc(D7|IS*k0?og-`Lc0ViauTwhWB%L{$C$@#+-|vTRe81AVIcSF&b1o*u22$=YrEDB zZf~FJ=3U#fY4%*|jx@@cwR7<%x$n-Kfk}x%!Q}1Y(pS-786=lpu(h?$V;)YxAF3#Z z4(iO809I*UCbJS8;HO_}Fm<*7o_cZNm-PD#fO+LK8ipkB_Ao#gs5qVUKlS*95JC;d=BBcEwL(O96r2 z`61M6(iZ5G7~dF(_AYhH;|J9*Lak+h?ES!-l09Jvui|26xQ7&qT)3D4=sOTn?iDb{N-n zn?1Z7k(Y_5UK(uLE zao^mHp6{4SHEk{&3Br@Sh59@v0a^dKjg1pKX|#RtnncOYdr+`t0lC*p3I*Mgfu;(| zu&!Lj!s##&P1eMeOO0;F-}C5{v&A02{!{$EVphgTEb7ccxt zNFekUdJDbx-jmQmmENTHE+8T(LJ}Z^jsk*;8mfRGMNmZ0&=C<)kS0nmA}SyXk0N=a zzkBa`|AUz|XJ*dYXYcQ4<02vr#C^Pk&;28v1(=G$g05O>J)DyUCYR~WDWo|vM6|V( zB*rkd(H+_HoK`=oZA59?>*=Cu4TP3u4j|s*T#%$=WNJlZd2{5fh~*;p>V;4kKLoXf z1_LYMMIe@OygHVcN7v}ZLh;NqeOBjQGInPYm|5z9OlzNE1_nSy6a$9t;m*c%e;Ixs z4;)K%3oHzve+E|HBD9d!+a~(Ho@@!4MeiSS>(?4qREBo3uZ;+JclNZ@=M_p|oSmtwEsX!50 zt_3D3W4&Lhu!vxcn75Ea@l`r50`hGowQ*K_W!~}eh|a@cm4{m|)))O)Mv+o#zfe|A z57q@P!DQF5)ejy5ISBfh(| zykPp?iq&?U-2#(AULQjs*+d-yKI62E;AXkKsTiy~mEoMu5lY7Mt`{*8+1^nH%o;6> z7Fa!7QH;(qCX&x8-!{~#iH_9|@H~hmIQg}UjwG4zv|?#pr>pNdhc~!U2c8~qUugAL z#K?YL$Mygs1dzO~<7|xE)nY zYP>6$_vA1I0?Fg6}G~zL1`t7d`v9CWpowpOGGOI zHg+IR9eLJ*y$pFlZ+sW;uv%SPK7G})qud%pWNfB_y+aGpznV zHHUL?%b4!cq&x0mhHTUduI89l-Yzx~WkAPXJU4jl0kjsR7=~sXoFciNzb%x%h2epW zxcyWu+ISjCx;|~k7fpb#s#xKs#aSGY8mLR33av1e@9h7KSxg}a2~i%b4%-iqc*9*B zufV(zOT21~{W2~kkTDU9u`a`<>uv`k-c6zfW-e24v_QmR(f}OqRay{pYmTy)*iNCW ze2Wn|yG-c2yYAxcK@vlI1StI8EW% z{@5v6&Oj{7Z1%=JWc~^&g>`xP^3c!35&h+ss!N-U&vytGJqLxxMI#oe2c>kEk2Otw zQ>(N@1CvZl7Ll|sex`G1tC$;9kChgBdI*W2p8uNnJuci97a~?o+*mVq5z}NARB+-HAy*+@y5q%48MQF zz#2#hL`B|@u26*_;7*Ijq=O}3JPJe_8)WGmAJ-A7<(jodZofzzw z{&7t5(=C{6Yr1teLBZ*f)p9?bS3evSa1^rA-5(aT36a55vd=dWn z*=zy8#Gc>eKGH-p$b>j)VsvOys)j18r&F3k<8;U#GDL@(8l&maL4j58QatNWiHpqr3j8>!0ON2j9f64 zid(Q~C^C=eFpnC~i8&b-fQ<~wDM!PJ0+Z>b2vtgeb*4O<#la;EaBH;19upgtnKwN| z{%&z0Xry*$6y?>JFQH64$x5~vF83-3h8HM!nVIF81W$oOj_KUHaC0wB%R1`mrn5q@ zH5x^9rahK;_rZ7xnC3C~YQ}KlAyKx7CJbMxjsx~q5H@u(Mrg$kC=d-uW7$ADMZbta8JkYKJ3dd+eYUJ$kbhiyo-$Vk9+ zj=`*pNVb!#MH|>h1tNz5s@TbP2EsU3@+$4vwuE6EkD*x(qAu)aA;`p64aR+q2^@Li zL(|B22_h)vbs?MG>d81IkL@NCOZ%F^)2CpYJWM=8mLeuS?W|m7PfkHM!J~_SPok53 zZ}9AQR)WstdmETyc{+ZSP8HVf)P>{AAnGrJljam6mY?d%^x`md3Nm)7Olsrm_BMkK zd|&K?2f)m?sn~)8q8HD`1!LoK;P5t-AML%EZup3k2#vOHDxYMKBr-y~)9f9%Un%hf zkC!Gv^E5J5kr>?ETkdz06v&SpxlVf~TNtO=8v@c1&->>80sXcU7*ls(AZM0X7=?^O=P6>EV(@;_bPd z-nk6e{22ic*KxdpN~XJerh0jLK_i`J7fvfI)1w~BB0wC{r^$(il0M9&VKN2ZtDZX2 zaK6g90qwkA>Ymf-E=!)fGwxZw;aS>EqdKZA^As29_{hmVy?v-X@ef@@*L=l?`8%1) zswU}b`VTdV-Eebr1-Emq8P2<7<^|k6zF04KxG$J<5rfJ#WbIpzZxQw8@B*!GGmxIO z!Jg-%7DqlXkj*?wgXi(b^He6G>Oz0lJC8g0GQ+l-Bep7|(Zn}go>iKjLjvmKE?&5# z1$p&!N<*^?0qWBh>%LSp8msbAN)cAYkuJwZ)4%XO&5urnE&X@(%o=l6l4--mj2V3y z8hL<5ONKBh3EK$v*}5=#5Z|vi30A<+xJ_mi;8gGD19T^XVB^+M9G(y)+RwF^Xm`1?%sO=3^cjENq!kWu7;#41xLO0 zLG?6$e&F9W+Te*FKMmysFZ+Ch`tA$CPTu*R{_w@k)iqer@b}<+dzMZW7arhaWrk9HktzBUP859oi5re+xm zJEN)7vj*$tgmuhmj{g6sg9%adztq9H-GrF!&vkq54TnTVE2?u~9!v!fsLp}wqji_W z4fpH8D8dHSIXD|K=Q+ILy}1!Iy+L&j{I0k7a&HD`h4_nY2D)wr)7AuqY=#m-bmKR} z>NX>!s>79XtsNz-u8ncwYdlVh&;8_&ejpbAXj8F2O+_l~ZQ%D31|xH+-DU<3Zbgc6J&np~r-gZL z1p0kPQLPL>qrdk)_fZ{_C9U5ELoCDSQm<&SIW4Y(5qF27OnAFRHmt2YRIOusFlQ?} z%tZ|eb;m$8xghM{cDGjPi_EwA1(}gm!scgPe)n*qHp&!XQt{w!4Eoh zo%EG%Q1Gj{0Kas_F1$t(G$UmsMMH8UBxY+SaJ@?LbGH(HJ9HP^tjDJKRdVVwo7CEWHv1B1=3>w`J)w>RjO?;m2CC(~8;vfPMxt-Kh!4^8b04_@a? zf4Zdj0H$~%{4H%#>Gh83P=LY+Ug1xSd}zt+w|J%fDs>#`o0o(hv_)Foj|HQ}5rbZiDMzvzyPvqK}i)XdgA_4Kw4SrC;q}Ya04c)+VCh5y* zR02X#*BMB6nrmSg7<7BOAanOXj%SwpmL_J+P@%;Gx0gk^aT}&8uC8wKutxkFp&WAg zl+H*TYJ9fST7W2_-oEfB>ms%@t^J|J%ZC}r^F7|@ojK=KpXXef1XmX9icq7a^b@$n zqa8l0IsJZ_hB>hP9ebZQL38%3m9(a^z9!Xhsn2g$;5u*ldd{r$yxwDtuHWZ9*9>j4 zRBUglbBd@5yQvv&wc9^F6iD}!`K{WRb3uhJ(M#Z`fbYF0qnv)6TDI?8rlYhLI1e7w z(*~wueugpPox=#IcN^H8xLkAlDqXgfhAkOX^0?}hNBf7g? zm5fg!hjP-aXcVNBYd$xIzJ4d}`y>CU-ldCfh1ITCZ~dS&KyvxlEv1kA&qA9VH$LZC z!_I~3mPM|loJ&d#oz5%k9(2^n*ytwE1dfG7+17Lu$}i$Z^$xwd3(o2Ndg_Az_Ac!8 zOkA#BKI2+?uueyGukKGR*#sxnw7NjtMp^mSo_U0p_9@L}k$M6A|?S>>H zzI-Em(P9dM=u8HEJTKY6P5d$KmV2>Y!Txue-{lW4jqvWz5}zLt{0wLB6QsW!g}}9A zq+2%Ai9%oW8y|nlz!*JUGQ!RM#&2Kl6M?;(nN#-D;92|f!q~j2&Io^e$(Z@_{iGB; z@uYM+u(xBGHXtS(nN8`EEkSInJYwHO~^ZL+ro}v9^2{6fbg5 zk3RGpzp))*lsCX$z>}qHf?l6eZ3bs?~ za?TzV(^D1pw(h%?=*0Oe zmycS69Q!K|4V>Gc$3^FbRuY90dpo&&!_#W%gwozN58t``@PoZTT7*!NcOtDfj1QZW zts|B#c{52YN48OC&Y#$f;l030A668r?R?ct!M*hj>zNzS5Sz%0z2B__+!nFs^50@h z7`Q&MnbPz87!A7>r8tCP3*-{J=RqkouDw|6qdksey79tZJlH~eTlGVpaHjiYsi*MB zhOr-^#K${v^izJaM{QXiJhUgC>Bi_dXt~Ht_{O?-q8y~2lyNP2yM*E~CRHl@;6+J= zkz7gr-$}OFvgnvv?wWLNtA?xuZs#1&+m~``??d{2i9L9-(=Ff9`^zdI_xZubwlSL= zeBQ)U<{-d=#a@PgzR5m09gh$*4+lg9Kei3uB zz3W48_l?`G3s*)B^Jgg3F*JXUsoaa%+PdZ08)(KqBGa@duieF=eCAWb{!o2Pe?s)5 z_cLbS>^R+0c2Y-v%}P4e{o<6SJR53f+^Rk_+ z_r2#_UqAm9jklKay$RlZYRz12KDvkICNxyrK)}WRA#aCqLmY#}p3VC*HSNLvHv;f0 zk1y1^PPuE8=i%b$E}A|6aP>?Y`)4f+M|soE(z=OPP9F~reh8_t)Lx|>)UOSN3bSnZ z9~lSV^>$kkLn-~2tUVaaCg|(Me+`}>pTIIE#++KVp>}coF$&Am@dRBgzYZ0&Xz09T z-SzoYW3P|V*kE5;&-4o#GK||Zn2m};v<2^eb|LUGL z_RHkOccTtI@88NNj4{)cR(zred6BE_ji)szsV9WP;D9Ob6Tfg<%d;YMm zC%>`2gWkDloW5R&1K=bSEDU-2LRmR}4sRsbo+AKhM(lm|i$&LD>dHun1eE6=kA|{^ z-!4?*5%dEwxA${D+Y@#7Tc%bhpn5v%8De}iYw))UPVCnB}$p|~hM=T}Joh%IBG{|U0 zGVO(8CFgBcTE+A2X*2oEocig|czDTTQ8 zz@o52qWUIj7h~(?#1>L7dO?&gSe70=&V+WnMnT;3d0YL`@aKLZ)L+`ZM^_+d5nve? zg^5&?xUnpprL>jxX32bX`7>=bQiiftE~XZ`Uzxe7#XY%@N@{&@CI0px4a-h;Z%>C9 z!&w=qf@d6^ENMk8(P4;rFgC}%Yn53$c0e~bDP19f)o#1n(eT$i!F9ZfyPaGs#HiM< zKG?za2QEQT?i_Pzk*nd3X@&~K?72SXui>)r%v7-3&y;a)cX)hap|*I5Mz)75g(Xhb zCfLg97Qs<8Q1P>eP=K4pvP(YrNW=3AJzB|oBbHvUiu+2lKx;}ZsO4+&F*yG@uD1$nkpvkFsOyi)0v4o zL5w=&0eQc;EG2hSB(8bLkMZTzk%|7M&GWFBvW4k;=A}xFrJuc+@pI)99d*&T=BV(< z_up$)X@lKk-Nhy@GTFQ{Dp`g-#aBqj)fRRK*YvOD z$(>ml^Z41uk5yRj?m{uDYGKkI^SzXQ?J;&+4W_w6f-2PXasAq!Pz#C5IOg zeX&Q-KUvAK>7LA@omBlu&Xl)z6~x;XGxckt{9@nBBq)E=-X_8LOUbBgy1#79TYO~CnlMFYG;L_FXe5rq z&E7=SvOpt2frjseAHl)p)V$zj&!*OM-HBwigNpAxTSgS!$#kW|%A<%aQ)9ii#Sac^ z)q6kjQ?KS9b7^}djw;HRVsDR8F^*DAkAQ1Tse7}i;{Y9{kg%Wddq*;om0Qxp%`qN zupUq3{-q8KcY5u9mHK9x9(38cINjxm3a4A@C^P(e-uUY~5$5;Fvd53M*5z;hK5>;8 z>v#4II`I)TX7u!U_O)91O5Og)uz(1jp6C~x$vlnY7cB`}gdejc6JOQlV{n3-bE@}k z<3Gk8l*2V;inoie`yfTqyXPE_)uJDRoqLZgLf)$p-| z`UM7Op1#R6iUrw4JC^4yV#}Lr5i>yi$J!MNK}x@hQt` z*&ijbDmdeBp(7??B_{dIUyx8GR0Rsx%}~I)qN2p&iIBgx!5umorK)JiDuR1e)YVSN zD?m!mR4GS{T{v)^Z)NWThXMu{{{oG*1YM9UGA;qLge}``m>FigG)_r((J@tX?&DE# zk+11v?y7w6yr#J@bQYDN=GDjFMZqhO@J8$z^2@-rQM_yrh);nN)$=Z*jr%G+M*mvB z{_vW?Okg(&OobOTZm!Y%88D>khd--LNcc36J6{t@@{oj{d&hS&6U>)k+~Z~eNTdvv zp=^TWx5bTriKG02@R~dpm)5OT1Fc;pY`XiIZ5EB2K)mF$mQ_KR(Hira8gy%owPz4k zrN*QVY?`{xoDt*$Dl?I(wG*p$Rl1}0lc8)X;;b2lV%93)yz?^XSkj4v62v$fb)n8Y zAoy|A`Kz5Z^PV=Iqznky;A|U5n5R+5J{p1gDJyvX6Ue3?x2eBy2x`y|!ww0(6r$@Q z73oulsz`^!-fAxco*qmVxuj&ZTy2zN?3k*JP z2MldqS5c&NF4G#J(hWE%*mDOeoPrYnu$IXM;WiD@gAZJit4q2T>b*$jvA4EZ1aXnq zVQ&OR4;hu_rBmxHxlfjiRnj%tGg1$v>G@0ru~*TsH=(CAz{-Yk*yL?eNS{J|AoVT{ zwz;k9y<_W9hxgE7K2I736QhEg7&6O0HHK1 zA^c`N0bM`9Ok_l*go|ZgXhoNn9ych$kuqVX1hiP4+OCr)G zlSjfdU`|Di!A#b-|KjMro<#^s!uY=uuvn!gh8d2+-#=*YA!L$+z_#9(tk zc8k%A45di?w6J+xU*Ps91(F3Z1w-J?=+L_VFl4Mv?*4_joz@t+?lJFsn+$DO%lt(O zreJrJaW(T!7`@FYO}&EE?mVnudsKO5tFFfK9`YqH%*+XmdJyu0{D{G#qUynd?1TQ| ziplI#-KM7L9N8$)&6<9$LE%_C|AFDL{_F2sxEF`&BaFB`4k%|f3tk5d;*Gy*bUy>}I|t;j?rLt1gTRI* z@Fk4!DC02R^ylfoPmT+>Te%vo#f@tgTo6onh`DXOu4Hkza0E(&)1s*r*{Oo+X~kT1_TJtzCQ?+mZBdFz zeb@qJi(?h@{AqJstVL}_K+aoLGZ#HeY52odxVQ?>*$-bDG1k5CD~i`SSDkmzL&fof zp^CRqdDl@jWif4Tzp0SQ?9j!`va`{j3hg9=F0`UCJfX4~+U`7w;XD^rhlMy@h41a< z!QF#oA$ZUn-gevg*wv*VD5A0kdD`X)jn6)aOEy%y6I_F5p7Yje7m+s=it1w?Ei&nu zmEts&C{s(nqUMnqFK4_9=gg2iJL7vwBS#70VSPAtB`(LgJShS4Aanllg=@sC`EM2mjJ}sXGHnzcX}odceyvCGK3_f8peB*O;Rc2O2H|^L-gl+q zsP^6k83uv2W_+82nqS+ETNhUE>Sh!V@s)_azcGJ4cJ=$i{qu&m&RYQVEdklmrzQ_K zz8eW7=sw&xpva*NJZ~|J-xumI;Z(P@xUE`E{FaT`#%;VVZ%9Wd3W?_KJ4g8 zw9QtB-M0?Eag8$+4STu^1OIi{^K??FgHCo$M`cYXZB0ShPA3a0b_!yk<3hwtlEAb^83J z4itR)Z|qDQU~XPga1(n_agpL2cJKN{-q9n z&i;=&ICJ|Sb@21e|EPm4!T(VQBR{nMQU?MRv45$9qpw#V{iP0g)c;2vObY#@4xD@v z{!s@b9{;F=7T7=P;Bi37KkDFn=0ED-qU-;tgS$Tx|568!TPOcg2bX?pV_B1u7sDtQ#)IsDw>LC0dbrAZGItczp9R&WP4*dU72fiRt(BEYeyA48R5|w3Bb&)tds445s*I5j2NA02rXsZ|M2KKmZ${OU!7Y zn6|N=slG+jip2u!WKjtC-jPn-ckfhbbK`md9EC@9nNS%5sl&0hYG+OeR-b6fAxJjt z=`Mar`Sn)fw{gb+D?qNTcJBI?X{du`fsn=7^q+pee0|_oHh=??`o9nBE8FOI3Jm8^ zSq4G8$@l;rb*yC1s`nOL3Lq;JO&Ri#sIK2{=XCdyP7J90K#lXzR;DySRw}_V96xP4 z`aP9&LNo=KWHL0Ub1jIT`M3)#3dkq=gW>>LkvC)f>2H})G6aF7Qvqow464I$bP`x7 z8F^P8a>uu~RKT>XEX7Qqu3`iEi9+R+SDWWbIixjmnu*gG0oyjtbnJR%4qCSxP(&zwF)!>@OoGO&F9*$U$+ zlPMB}K{8i7dH3Gv`dTKd7t&Y|4}_V_bEeH)9DKnEF){!%s31W>WH$HoEsHW8# z-=@z3<#>rjIaX#@Nh5&mSP$yM1GorMy-DA&oat$R3fZldB8WAiy>M_y?;Q3w)5~?- zBPjIsT~uk^pDhTMAf0z7Oh*+$HY-;PUl>e&BWjurSM(Y0YFisAiYQe}_|F8_XD!ZN zfc8TdRY{fk3|Dx3-yFIiBPyLR&!ga1h6R$g1C#N~)jrx8@^+lC?QgPE)EshPSt(_N{sI8q~^6${U$yRYM)9MCW z5zM+hqb6*O99K<$!Jo=xu*dV`X%@WnriSo-B@WF7wH=eM(w2L|;6RXj$89(hY_|&j zeF_42F^Kg+R>qter*c(5%j}s=;Yf_G@!#OAty@#?^m+OZEES9SU7iBZm>xm_153sd zTE}hazQC)tuJge+qG#qqEAw3!I#^7LJWMlN)Mm>SjNh zNP7ME{<@}87=fng38tJb_F2b=U-9MGyLdsXo8Iudu8p*`UTQ~TH$Hv$6m8X>dAyc= z{?#v1kp)4t;X(@FN65O@{Qm3H{fV>RdjI<>@I2ynGv9p@^z}nLhZh6kajwlMIco4D zRI`E6lQ#m&)KT0g84qB<$nwqqMmWJUx?&TOYVMftxjIKZqybJ}uOF}U%UJO|g!oFE zF~yNZ#GJBYk9H=1nxHX|GD$H{4nw{X1vt5fcChg!s~>7#;u7hTGBeyT_%m%YuWv)V zD0KV)lcoMi$1b}?a`b9q!||U@K zLA)M9+}MDy$k;`-Q6OaR*%5tEC@noLRImtQOC0x(IB&UriULHL%wUl6A}HlNBLGp-ml=4K6(ErN)f$R2m3sVWGcl%LarPM!9fc09VI{OG zARAp>lxO+&HSUmaVyHYZK<~H^RNtg0Q*uTMh^3DjXBS{yo_e=X|L81<1bApzt=8+H zPf}De@QZg@DU!rdd>4DR0u$Mu%2wED6kRLrSeI12VQG81h8y>^cU;&fMe{k}Q^&=i zTmc0pHP|2#wv1xCF!VhkjzVLFsilRJtjQmB8xykqG>aV9E-sp#pZmtf1S6rd5~V_MvqcJ z0NljA-^dk4(5!gLBBI(&*9*&Lt_o6<%XneYa!4C}kP5Q8u>{iE<%uv~`WEv`66S4cYQPc_9plA+`Bm5h>K zVF3ULavyGn-Sjr)XnpOl-CO z7Y)9#Zeq_*r=n&yzKLaF!P@B|rMEl3OR525-kepIfe4_BKi)$i{>|<_4J;$#eM7-o z3d6@Q=>l3!dFx9}A1d|zyCHRq=MRTe@5JTn?;&4vWZg52&5)V~a+R{pX~0D^CW2M8 ztN?8lX%%FoBE}|p{N3yr02pI4*q(c$o_dQRff@u`A(5(aAKY5zg|LH{$>aa|6;Fdb zV>wh9pu6Xv@i{(DWEx3~2=V!T>$11e8GuYnnuN(vg9dFVFqf&j6U2H}za8H)-B-~@ z!RhNMTiM&XKdmNAe|FojSB+d^2~`3@pZ3Gc zFB4XAtdI{=w~Q~nwFFlbC)tA-Flez~)WLwB+;0X)x|17Dm!B`)*+KoDcq@saVT=VB z^044b;Hz*bb>}Q_@$`G8$e%!zp2tu3L3(~aEaNtLla$uZB4!UT!9ZSfJJJx?9+o$W zBKDZnN6TUlf_s6Fl)|$R7%~PkQ#>yz!|Cu}U;iK~DX3OcSI38Yw zvB~Zu^h4+;0roZF?g>d(6`DV0w3Eu<2=a{5>gmBb$}eRjS3l5T_9_7I?y^!23n?dO zZg+*!@*+S;EZeSV{37e=dg1AAAd~+jlRTUJsWQ4tlzv$m9DoJM#()&;e(kBS{)lCL zhyQgaSQrg}n~1FRacg_cY@)fe>~ZXTZS2|;tamr~PO&UPZ5$%Gg6$}Qb`ZaoC5$|4SWc(u**wiE_pNr4A^x z2q85w$@st2fi<(3qMEp7ytwXLA@+7Ebs%9DFA=^kZb_vM)FfRF{%q`2BW_eS__z(;<%nB-NkhvY!X|!QkR^GIC*gc0;C z{t`dY=eE_1zE-lqNrjC~9!kYVXilVKv4`4ub$xXXBPY+r7s{FsH0d~HRNFmv`#=?2 z&IRluR#;F1N^}MX1rTr;M7y+>-fI)<`cN-c6wE9$I|SbEwv1PhSMf#w};FFbg);I1%VRZGiHeUF+zN@9TWGN6Q@D_9o`nU;w- zG=I2;SeSHhpEmQKmfV%Q;`j+{glvsiqby`%^5K@6MYnlZ(dr2%`qK!#JrKt-%_%~d z)>#(fPw)51*gF6of;f>_hI#kX{RvB}nqH_oGz)aLHEgwA(y%M;Kl2b~-)V1kw!~gK zh*QT8bPX$F?hoO>LW)|gY2G2ohnQk#(d#9mQ6)67heRW;hH+;TU1w3=&h?U2J|hiN zSqCEx6m$*_q4gI=J9R-c9q%W|guSCdI-5>?a$pp5rBE=kXcwFOdY3mEvaU@-yd7*p z9Y!(Y+KGWlQZ)*XR!x~#_3Ffz_WVJ0HtYbFhNCVxA(5`8K=Ym0FOv}M zsRcaYkajP(Q$MIu-z+uGCHcs&sWG8HVGARkiTP+!d)u+KNy|VO#)y|&au@BV8>m&r z?C?n3%RfYMVO4Q8K#8Se0$?_>H_>z-&{PVLOcj?^OZ?H(uiTFwz|1Hvqm{M;jDoa& zglYWuG)5zM#gz?aN*rvGOpB{6oP{ zDongmvdKieWJ*7qy*%#=|Y&U-*5N`3zRo0hk5rY{v2LpD@ zs}5pAALPv9hplBpj%=GuMe~j-uW1L@KE9k)b%}4|k|YLHPbV@Ns0`_Qm^7P_QZ|Eq ze8x}PIO2R#oLLVOKAzHN=O<}oaFj$r+59{g)A!iHeW?F%HuZ#mXsnv- zsifa-9dBC`!coc~p>FBzY9_N-AXWvqvn)aefjM3oO~__Z{Ig!z;q`bezwU@2rOa9L zFTc()>bD6WRlxPmNn*PY8FPrnq@Y$_+@G=giNXEthlsG<#35l8Q%4ftEQE*X^FYgHW?AyEnaP=atDnfDz zzWOzOEih$lE2T9d(S3`KQki;f^*Om#j)bGlITi^)oTvC-Hozoy1YVM9;Jx<}(JC#S zpqp6Qf1R=FvOyG(=ub^o}}cm5NpN*2X?qz2PFhTTh%@<&isYqeqdgaBc4KM;7?b!)96pc@hLYsN8;G|P8y z%%gfEy}8Td`UL&GEX=(&v9_^@hVZe=-sr|oTOJlFp4=J!{wB*)l0@DO^ly<= zmnYWQ?cR7&NB9%=ji?ACdsic3!u&$fWP zn&GQ`_jbsJmt`oM`n5Tm_xr?~vfkGnK5aVIAGPvoa-C_OylkZ5)xfn_qg3(i$xbbp zF+W+q!OpABsi(D9KklNsM-O99_isB8>Fa4Fy8=ms-_J5bj)Mi~%?y6(_w-syLPYig z7|!&ZxvJZB=6Tu64*Q@0e+1|bZa{kEB_ntoC)1%6(7`JR%Km(_Q2LQ~z%ALH0q_2? z(yECU9_=Vec}{B(ytk*-Lc2XN=L?NaoLE z=vh%-p-1ETqm)b3VEI=wQEh>9^-K3(H-+z>u585m0~HeV1Tx+Ayb*Z+i*ird#?Ioq zW=?xQgw+sG$}+U@;qGA2)A3~m+3u#<=euI3@>kj`sg*t2jlk^EUPOn@blje#vj8Yd z!Mv|;RIJD7k-O;iy70skv2h#Ost<^iUc{K;i^)Gmi?SapZ_j?ZvarP1mHGQkDL(tT z;rLeil1EDOL)sa}fY)7p6UJZ%aBD zqPAas43M^<*IQxgf4_YNl!Wb26@{Ql-@_DS3T)@@#^djq=?M5ahkv;N%-GN(XFMOz zd!s+rmu@{Tu;>I)16_{1BdH-^ z`OAkN&1w`O=*}C(`LWY^AH)LWicAF+AkTmG*7gy^r)dhknELqhl#kJ`XY;J`k$%q* zK}JTcZ+8pDouA#AGH9OL7TzoVvQH5`So>I@+`spMXW!d97JIs`$6hJr~cRedtZH(wEFW+=G5VI5P-*8QyBR3GE^KH0D5+Q4coT0^|%m*FQbWiu?jB&d4~+bLKLDN}DUT zP@>%MN0i-StinbHX-Eda%k2zJID}pGIK5_NLBU6h_MO?KM_&9sd&U zsvm+xibQ8gbcmYdDi$g{SB-y?JTH+DVfI+^GM}y6hYRy7QdUgtyJZj^iy)85zgB`+ zp1rG%q-Ba{*6lfBa^hS2rEs2lxa+dqA+gIUW1dWUM*gD?zD9j~#CWdf;4gJBS{3=1I7mop0QE2VRXxj8|FVZ9ycPK*0VE&D71-x@sB$Ak<%3aQt3I<`Ej+^UUDxr zmi`ZQFr;H6vw zb?WkB)fRTzD6xL<{=+2-+V1_w+Mm3vF$Rw=4t7vujmSMmhcLxF|K^Xs`r3JNl_wsn z|JK8+{wUCnc+=K>`P`FPbA20fn%&!j2N%BTR*`ZgFI@o!0jr;+y@3)+_yB#q&*TLX5md%SfJI*DQYkEtvm)YGKEt7bM8`mgHN2oap7e+8U#i9qsFpsWlOBH zS&{(Y4%-9GFXk`+0qEYWGC>d~-IdneY>}Kdc~u$H2Y(HLgo`pv5T@)Z^s5h=VB{!& zRq?Y5VzE780u(85Xs#V!KvY$zImflZ4BzP`lUQ~z&;{_DWvu*1m@NrCII+nY!r1Kh>K^Ny-@0AcPT5$7r#>cLLACbo_ zkoJ&_0Z8B*Nh?w=fky8~V}v=~L0vL@0%rg}9!YzY=n>x9_kyEuZpucB(Wm?NEHAGO zM_pG7R2!#mn-xaaU#SP`^g9wZN}hR;5Z5PJBnd+W?bE(2!hS_GtywbtGJcGdB6h%g z8Q>WODPzzh!cXGhYUKb}j|_3FtBL?QnlclR08RQvvmekFkr zYC`WwC-l%e(t8P@QUn1(MU-%v~_=uF|zu7mygYLqBo$(H^8QgoR@Rd*^D*(FF zHlnMkIGGGa(IQ;{uo@b-$J7^~$u^`61!0Ml9f)&K)Oy+mlS_js`Nu@`hIV8YS;2#G z_Z7Abom>T0OCIOpT?9xzWhJG6ub04InS)jLqWm{Ti> zN^jh{4!<%kNPc2P2oFoE=H)6=r#9%u@^S&zubx?U3(bhA%hp)3?-)7C>6=~hWEydm z$Tl%I-4sPBShL*|dGNUH3U3W(#)<*8PAEVN)1by1bK=wy5&)I5BeiKW8U2;I`0O(C zkZ@QR2~dmUC5%IjbCPn(Pm*kr^|j`!7im7Eg+t@|SO7I5{e_;yY!q?qiGTMmvQQkK zbQh>5P!CDL32Fsy6E!xBgz@C!NG&ssjLXz$>Gp{bfEo$>`+$+FP&nPg71B6QEEE_g zs#3G+q2qt(R#RcO8))gze(8zR&CR)e%+$xt@8uP{xU{cf**abF^QxH9Iwh;K0*k{w zt+XeW%lTCoiOC+8)izOmCz+SG8Tfs-_X`uJRX4>b4AXy}C`W$6^+m-aaCY%i6mJ01 zFyGzhKF_x%&$(VLsAS$q?5$sQv$8?Kcz5qVJI-t#O>K}9F>Rm4ey&W1e$n9Cb&UrO zGC?gP%mJ@S))vE0&1r+qZ$(FWKLQzj|c#jC{h>#t)Q? z{Lnywrzt`qvY~{K)IIDZJuini6DBE}yX__sC-fMFBZQp{w}aK7%VK&;<_;L;$7W4I z6GVWg8G|`M1gCbQ?2$n2%|dvbrWuOR^sQTm!W!amFA}%n3ql zT-`4dA2r!a3IGfo#UPPH*77;IK)t9;V%j_Yk@s;dg)rah(r9RRG_)|5IW+RpN3{b+ zo1{lW2yfEO#G$%zyy~$F4zaHOI@(=u<)fHJvzWsB@Ms3ji`Ol;qKr@Wh8Wt3)F& za^$#1hf^Hg3ZP#=s&2*FjY(p8pL8aWtZinYnV10DLvUmzv1YOU&P+IO4#{u6Huv(X zxOvJ71o$;aCq$ZjDd`klETjIMBqY-&x)$P7I`vZM zHWC0*fUS)0Iw`G~KF!%l+!q`JKXW?rKEmjJ5>z7bo-|P)5zt@CcQ=b#Su5?KUcA%& zcmrJggW~x5p7?k4nsX^CMFjOqaAmaI%i ziJXdnY_YZYlcKEL?^!g{>S#2ujz(zg0XRX{f%&ZG0kLi0GxgTufr8Ap-9~hMg1i1Y z%cRCl3R>;&a(M3}rn7Q6Q}gHU0;GvBQVjTBq;3Cq|BbAS+4s-i`QqcBS|c)>SVO-D zu8~Hxe&>Eolux z!rL9>IZ+8sNkzg7MWkdowbsc3_oIYb0TCNk@-xJ3Le{Ke3RSjE-4t9tsz5yZg(wT) zJ^#X5qlllS2u@82TCv|Puy5Kg79dMnXQD+26)tEN62eY37P4=hl@uUezI$14!jw*V zmhCJ^XXJ{|z@+07O9;8742lNifCrN~tf{v6s=|vur!VKMt^6Pb!Gdhf8U=HA3tog5 zkoUzgcY{;(Ucw1!mYfVUHIbQFtOX=wcn0vz*5`!4Jq0y9jdH=w7XVPzz*@OgE_s?S z&GL+Q6-)!r*VwS8oFUQ_D<-ZPy^;ZB)M(}%it7t2pwu8SARg{j{)$CIL=B9`cy#Ys zm1R?x8=K)@EwTtaX#sdaVa4E~1l_CGsaQCO3Z|C$MW2QcquCn`?)yeksPFC4z*%Va9IX5<#~;Y@XLI~7ZqN3(4$9o14DH3LG`jX5fW+`iXb(3ZJp?O zokVD24*;18WOY18h_PykYF689f^G0x$+IW)KM*Lo0I3 z%3jXT(TzbN9=dKb?-KHrZd*4~7v-b_C`dYuKcfqxKo{0xMn${_L4`)j9=8FA_SG}E ztzqHch)@i&4*>{xW-DX;gVf*7oFnFhs~5Le1pNHUtjrgK-jZ6gR+G1R{eICkbWQFQ z5m=mxuOX)iO?mCyu^ih0RmZFPlK6oW`9>cg#)aQXNWkGM5D3P*o~w&kz00lQX4f!X zWjUmg;HfHAG;wwN37gP8>}FbOGAisq3bX-+72~|X+Btj|6I0mQ0NZPyAWv-_Zrl9Y zW)kb6B+U9pDPbDg<-OPvCfp2_09BiTUJPeMvlN6V2$R!M$V|{vuJ*X@m#t45-7GY? z6^d9!vRIhWTT)jb3~3VgX>A@LeiGk^O>~7&)8;rb)bGQB=x90iKyqBvcWP<97+ik; zf_MLyP*e45%-)WS{*HogyaqE02S0B!lTkb9A8ANCfY<=yIDlB30meKr7@f;3_qD$S z^e$T%U(rBa`NxJSDv2p}f(aVhZ`R(A+JB!}JIJ@nDAY|OmXsXAHW+3*s5xCrhRS`j zI)D)w43E=J-FO#p!Nf+*tX4>GTiBn})qmnMkPCXxOFnX^vbTp_k*$iiXKB)-)PddZ~G zzzgP-4+GonLT3b!Ql?@8`6TokA~mWPN&_* z51BlesP3cGN3&BSAfTtI)9QjDk64B5xEJGwex66i6mg48z7crFdF>ow`s1Ud29GS3 zM^SQG`{}g4idGu{s1>kwt4$LchlfA7%T)dJF?pPV z?40g2GwRE!VpvNtF!Am<$rTI$-?P4VGVAB2EoBU76+Jq)q@r^Gp%q!LbI)2O&*}Bd z<+@pkPtRNYa5yOZ*!pA9;9+a(07t4JYiTdX^@kz@TG7*Gb(`Uk0vvt#IscFCD z*qU`ss&GVuYQBJS9-z!YfWa$Q5+TdxQdBf6X&>LpbX;GaSCL?y;9x~{2E4(4GJZ@R zxd$Ri{IoYBbMSQHNK|6Mle_>y*2#&+EA~S zRTbONcHhudmC=gdFdW@59+fiK-7pjTVi7EfHu+*1|HWEN;zsosyWKDLm*TcmU!6?8 z-f8FzJb>Dek zy(1a6GrYSq<}OJ%g_|<@!NT+7WBiZ#UDRy#kLBGT;NBmf#dg^z#qZ9_Y0YUTlxpV=uO2m*n*xd4eqI z*d83dKccZmZMsi?b)PE7(49lI8=cjwO$^on;zAw9%&~Wl?5N^j~x|u;E#;qzcMe6 z%uIhJsQ$X1@Cz6G%X;irXvZ%*__6=xFGtg3FV*8a3CFI%$M?sMojQ));J6J-;b+-jHy9 z?eTfb*tzkW^SAH|ox}5P(+hQ}i~fWQ#m5)#$1bGaT#Uf~h#g)`nEtsQeEKor&-36v z^J9O6RsSr*FPRSiteRd@OI?0RxTJV|`F-q?_|4@HI375>+&9Ia5}eNjJpSMj{&Wn# zU5EbzN8pL5c&)}))Wb>X#hmBHS2c0ed?v+K6KmRW%rfp9a}(>jiCla%Jo-i(`l*7~ ztDWa3zZhmp+!-ylp89H>tKhS{F+WxMA(=mfiqB?x)2vuOP3+FX^mmIgvyuY}#gALp zE3F&czbt&*wybgNZ>&_D*|Dy_|FQbc;>?em%`(I9N^Z{X+O-G#+WoR9w`SWJN(|?> zo!fKli`GrQwlufz^bZ`HOY3>(Lzaxk*QNQ#pN5}nC-B=X9NnK5*DSreyzuM6T&=|G zbi2i4x1|=J$0ctUe|xNSN5J9pIVWBl@6*IP-EvPKZ%&k$>h9#8`QUfv8$5ox=AHZP zt@bB8|DJadaPKS3w^Uo`!$e!hHqacVa^`}s3I1YlnmR|Atv@2NFGwLI0K ztlkJA;w!118f3yE2@>Q|)1I1C)-g4j2u-@$UbwFGz7`#&h=ex7P4E4;^!BM*}}?u07J{CpN6pN4`j{s}d-!KQIt!D3>%8Ydbn9 z6YZpXY$Pep`^rdq>e`z!)U5ZRu{^u0l!@YY++!1^!|BIH%D;~e#{|!bywMu5boFR0 z+NyoDHmlE(md=$l?@0=tk|T2?#(HTB6Qv&>7N(l?^g0x}GQY0jDb=-ouHSfN(Qw_$ zNqhIY^@E1%ayA|xeP;g{eEDVR&@e1xbvssj($XpAdb+N2hR^Z*LS%%mjq7%Dqs_xN z#@jY-ZND1Z?sn4q+4eT`{kHW^l9aRaU0l9z=eM3F$M65G;rFMMt$siIV5!L_dkiFa z(>|13wwV(PzhSi!&gkp^Ir2)n)hC=lqx^D=_>9$3oZPYeV*FJG=7mIES%vvz(;Lil zsg}N{GwJr}%rlwy8c(OQJ!a;oa{Z1^C-XxX7A6X!WLw9J5^gMv6=(Rij=p@JzA#c& z+SvM`{PoPjaAni+*-&*S1M5)Dfb98T-PjG*!TMQWrGduJ={Mi~YW~)!)ZhAZ=4M~} z@8h7}4m?9{PZxBZS%{V2Q(r#oj6C0^u+%@tOF;ws$M2pp=J$IBT)FhubCetO-^lrv z7!X?E7aT}hlJF^T>x}^F_9B)uTF6n9d@ui2vs>z zM?|8-d3YU3jr}#~UZNv}ogK;TPBa)b2)!&^r$jJOLgy4=>Hz?1h|0iGM?b$D?rt3X zSl{0vyw>)%SN{itxVgUYmqGmekzgSHZvn)AqYozsCrA6oKlF|b_Y4no{L3yRn1cjH zkia0m>uDhXhy?wxy}72fvAVgTvZ=nZv97$K_SKu$Wp%GhYpY+ru6R-Ts*u1P66iyM zc=)m~gJ2yJphJRkn4g{WFXb>dJ(i#xW~C=&rp9I@MG}<5FU*f|9{|ye`UjeHx2K-vUe`Fz5U$AA3%Tq971 z|GyANAFD4HsxAFQQ_@Eb<^J%l`+Y}iPxHU5p{It9yBYyBB*2Ky zw{7k1Zrrpsx4vOUzz{Y53vu|LFrtaRv5}6EzNVqRhW~N9UM*p6)msWG|4@m_269S@e;Gs-udomdL_T(=D_2-KI9RW+FtBklv#~Q1^g}jgdKPv@7B)s^R)&9*5LpHozbGBN%)39-}h|GR|f@_$%DJgoU|5+cX{4-(=sK|=IH(-S1bNHuyGn=ee`|4Io_ zVv1DuzRZ-q?4VBDjo12rT5lKk+;zED`_5py%B`XgFt(}eLya_T)_uI!Fk_B$Yedlu zyG4B`W7=&d`(#+%^6Pk!zCo{H+r4t{cJq(+BLei-aT?S1vxQ9eAw9w5iM!U#LK9Qd z_eUbPVBl^Vxx@hPRqcH-S7_a0Tcj~Y_q!Ees(P+9Y?KNgay&ow;5|wMIV)vF%VTeMQ&uc-A4+Wasa(HRq`Fo{L zZ#4I$nuRe<5t@c6o#YQ`XK#h!MH_on+;}44Jim{mN6g+tLaAiJCt?QG*mpJ8Tn7-p zmwZ0w{WewjxpX*Oksnvr;CR=Nzp3E!3_l}&WBLPb-Dfq*=0_0Y7oev))j zHPvxwJMi~(%uk8jM;H5Q3NOOo`DIuoMB6#h2qo^s`x(`|9Q`oGo!sOYE{P{J*P7Gk z;)CGsIbj`V@-+!Jnq8iNhbUc+_TCG~q^?f=BO#8GG`pvElbjqx9~KCxu%SI3$Y7rH zos@Ka)Ak7b_}HFe%dhj|$LkQ)SIYwK8Eq6V#Vm?gmrILyg@rzDg*O&vfBfJ`d8HYI zlstNmy4w}q$y`LM4PvIkhjvjuH(H0P5Df_uDjWGsJtX`_2F&Aow3?e3jABhB)xAXA z&>gQ>yC;jl$iC8HRv4`mLf7=ARlhJ;+x`<$6P_<2!_7h+ZNk;V7*wZ8Ry`3P+55t@ z=gvFHW1?rS(z;ZqgPKh4LJ>mY*DSPTiVYN!Wj`{lbBgY#7-dfg+_=bLisROL?o30k zwyFgWUxOlhZbR#UA=$h7!!|nm0%bV$bLo@!`J7;qwCxgw;1;@@EGg**q_dCAsl)DU zJwmoDeR1N{zXiQ; z{c$Qe>=UX?WM*il@v@I4X1rUgS4b=2V?%-N{fb+^%FSO?(|LNP2~TaZgh>*O&}uMm z>kpS-Z+G={jw#t3jNh8|a%*@L*RYkHUp~)kmyUc+dEc@(?Rrsaoxm6K{#$pFW(rV` z6HEjlRI1dm4fw#GT0dh4_kOGS#5Kdl`UX*Vrvk`btJ}~IFauZ1MVpe;*qX#=p{~Cd zY=_QfDkZ#Qup2%d(}7VA{8S1*a9o^#-=a9V!6xATef9{RH4^}g7z^iSFreo}EfKINiC zD*GtpGLyv-xP)GY-GHC^JjHEJ8tI}|A-uW$mWg*(;5kz@iV|e>Xt#ObZedODUch?( zMrO^)nn0{Ea~L1VZolF8ukqdU&ej^tU9l=70=p{nZF?i-D;F42 ztZu)c;K4&7>_beR=w>F8FLY9y$OOM=I+w0_fjOYZ)3Hrc+rPFrBLA~}(Dpa^9G>xB z;{tA1^hW-NmQl6A7un3;bsyWF3HXY+rZYF{J>E$iaWt8Vw6Np8TjL&{zZXi?BcwsI zMTP8fiQry&qD%_WrjV)860#|5c;0M7l5a(fe20eEoA$m{t%XvR&C$tC7tofU^ld~2 zby2U4lKi2xY|@9hYyV~#{#qm3*r1}$d+y8%yHTSMpSMG(>S1Yiq~E5EJR~J&wr_a1 zTxDJ5ilSxHmsqss9@?T1o{Xa%2a~ZNw;)BfJWq9V)X}+IZ-sI9y#?|JvQDArgT*-<^t$_bjrXa>NqjUMP7Ru5fWJ^7%@@u-x-$xHd$x1I1^ zuf!(iWsV2U-?)SKq^erCtUQM9lji=awj5Zw1wXfF6mzZ3$uo1f+P2U=bllL!E@c_g zHupaNPRpW-q*rI#r{AM~0~>FDV&Ls7`vLL9RvySd48kkSAvAt zc{V#nkPz{I_7VsZqAC7tksu+Cbzc4^NQmcnJivtcZxUiAhL9yeb%H@Kh0@4}{%<5i z7X&2}me&f)@Bcp~M36jI(ke{WKTJL|OtCpkc`i)#BuwpuL7FLC%PL&QKU^;}oFE|@ z&4n|{hnxI&36TyKVKet%Bt)h#TdPQy%t+UNmk_~Tk)HBV-d0h*|44{z$f&@%sNj<* z3=@tZAwH4+HwiJO8K-N7i~A1=G4%u!Z}p!NqL)i>{=Z9zzW)CyA*$kHs+nTzDX(#SLc04K>Fl_{V)Xi5q8nHaQnLD*snPoXdP> zZ1rq`AR(SSTRFilG5wVgzxv11%ExaKB*eM+UH_1+lX!xJcx078AP$cS5@K`0DO1A5 zKN8~L=D$gZ?_e>omcJ5Wqh2Bva}te0QY0*iE+C01E6KwpiDf>C<21=;Fo{zknb$h` zSy>`~Ra+v{iID3A39%)?@>Q_4V5s9Oj1mCY5hTRZ2>bWU zdEdgZ-x>C6z(5yB1IXY50A@5Ll}iQ;mk~6d86O?~ z^cAfm07P>l9KVwip~2Cw(wy(7C!A&#%!egq(K4ukArk3V0br^I6BB@mW6rK&&bfW)+Z=0hDJ2 z)QW{*GZ@4Zq=kWLd4eFGuuJ9=bCwXm6_E!3(p@=Kr0Jw9lmz0C0RYfvaAyE`g+k;4 z0IwoqstXX?D+G7L6;MDf8c~h_ggN05E(-F3tQ`_E00aJkLj3Rq6JcK9J^!%{@eBEO zXC;n`FYg4t1h0}o62ZGD#BL%Og2}ZwdsV=aW+aG-sR2j`Bleu&iZ1@as&^q^Ms9A1h7 zm{*|B5oE69nUmH4%mGHI9vs;qq;56=H4ydKCa71wuhN@9r79>(BfhJS_5lL>paI94 zW0gbe6i{<_uK{b=4CVqOIH`!enoC=Naso&N04@aFumJ3;)cQiW>N_J~eiNasgeWv@ zEf%iwWo|KLZO}3VqJ!FMTAK0WWD0xLPV-d29wagLMe;cu3P7L}uRYJ;7oK3^L~>$k zkT^O7PQcJ&%O+!)Y7N^>b86+i!0hUO zLPQxMfjmbDz$sS98X*+gdyR7H5Xy-3a`4$XJZIq*vvU8vmhNRvaHv8iumT2;167!` z(6&BsAvtLxpyB|#od^=fwCL}YgFMN`T{9gMVa}L-sSm+ggia6CfTJgf8$oobSjmhU z@L_BJ!Te4@84-c!1Q@#pOkDG>vAw_E(rrIPDue)e3WA-wavYZGZyO?7$HC;N{Ck}6 zhqj$WtCZ9z5U&WLunSD322j?33-0{~`D=nqsDbbqK;dAQ-pV7_v*sW9S~0*Dv| zLhbb@FMUAkzwg=kFsVH9S}%u49@=d*vd}&v2^(3u7+IkR`@}Z7VLQq%Kl(LybgMFS zvwd{;ViYkqx<`-@jW7qcV<)*|XMSU6OJkQ5Ar}{8V3qNrz-j`62xA|c$sMOy9w$(q z1P1YxVuHqQV$bNWg!nyff@OJP<--IA`{XpmB)8pUve6`e-Xv4pBtb%q9-b6qpW+~& zlEm9h6&Os(=1pBjPbn@>wX}_^vQPi|Fr{HP9iTt0lQ(S}J#DZ&Jw`EY!v1mL!?d~G zM@{{YH}XCzM}M?g{wVYQ<1O|X5z?94b~AkXGk5c5IHG4xAAA{NF?B35}?dEp$<|6avzToC!mghbV&c(CO&%)-D?dHez=F{`$ z-{a=9m*;y1=kwVY+F%Psb_?}-3or8)s&EVC%L^|D7pmD83t@}QNFuVDdTp1*X3@ne z<3*-2LIX7*(OlvVTIv~Dtfm~)7L=E2`7PzBO`L8+IA)TQ~Jq!@YC*(Ppmtn0GbrK zN=e>Tb5Z*F@!;n_Bg?E8U-+R5lLM~&3pyY%QnXR6@dJZjI z^?bj|LAB=npa86%mv{k}y47S|k(Q?hrtkzfNz)S3$~*xc2WS*0(Sqx@G&L}YYSZ^tkwA8lFn*zkxEqdi0ECqZ zeCg)S06bi5Q_+!(cn?r>fH@I%?AG9u0>JMH4DbX!6spxF`)0IN>=g6TH~w4T=(pg@ z4G47+j{zc;YBPPbS%H&?20;|1M#O^HAOa0CpoolYKuCwL_db1OjRP2W7}8^25ggO} zvkfNB75C7Un803qS81>TVr~y$^XvukYxYY&v!XyC5yYt)1iVMFZ3%uUh>)(SroA8q zR$fhA_L)8T5taLcdpPTnLFuANXZkCUU_loi0p};2XrmBk!C;9`RQ!lHBfB42D_6i= z-Je;iCr<%Q3=?d(vd5tEEgPV<2cKU7_Jl*uo&j2T6U6d3DehIURt->b250IbrQRb2 zOF&_sy&U4ZoE`{(^T2~Ty>+OnD8nTKxo5EjR^E_=L2CcB6LttW5?r87_#jg~vgP9OeJSQ~u zH5qv#oG|FD_W2!A;h)*hjUKE`j~6ch(8^~uED>k}>4I483L_=?ir7T2X~hsE#J)_k zb)9&2m0%)f^9{XZKI43?Eb}i0>7sTW;mj6ajk0Bgsd^PY@T-#xplE8iCZM2bN^y-! zJvD9QOXDoElsPd?2oPh${T4Zvh9G8L2q>;yvyYL&FVG{>$<$=AY7sO(F7@0P;_^#+ zFyT8$=q zDshm;wA%lU4I2JRt*eq3zZF{sFdbyNfUiP*@zG@)P|faf8vUF`m(LG*;h?}c&een{fN*#slP$i;}UR2ssr z*fo1?(gI5-r7Tdb<|y7x=xzR_yEHT;m@CRd#F*<)VDCA{qk>h{lMTZ%Rcib}EO!gt zQWtv+hci+;0%o%cJf!LQ3ha<+XoE@31;zEaqIm1{coMWE_4$+($52nS9Ja z<(-D>mi7@cH>@q)CTm=!5naS`WT%B#5JIF30TP4pB%#EZfF`Wbt6~}e>e5JKihQI? z1Q>8?CK)h@>QMla$aIPaqN2zM;(S3^B(1!BC03|g8ste0P;af(}#E*0UGue2#5MN8s@d=BWq4o;}0Qs&$G~UV+ zLy+Qtey;YfqGhZw5!3)r@qms@;k8>egvJ@Yl|K7~ExUvQ5}K~kZ_mRxT3zZuXd816 z?o2hduw&K0^=9Xbjc_Pp+#$~Q5z>L%f)mrVmMK1<{CGs}Tl-=9-DBmWpL&C8-czQ! zDn1`A@6mhD*gq%55j|0&!$>#`sQ4}WeS7;m$@aI3e_~~~8ixLZo)(3_@urbKK|)jw z{B8*i4&0`-DNUs2M0SPJ2xT1x?hgobJ~?EpxP0M{YB_-E!7_V4fjJh<%QiE}8$55TAVdcL_0@c8xmiuY|a-r=M#iUo79B z+2<5v9gnrSFwmsPsPpnz%?K6Hp+spukw!cuZ$FKLHr@lOr@T9@A`*CPg=h5Bb*#`OdF4ikblxk zC}KHlB!ZwwWFeUk(+jP*T4DiYH(7SsfV#}1u~dW9B4k9Fg>^PUe#VLR2BGTP=n$7P z6v~m+Reqx-J2B&(_e+^PnB?SMIFFhm<#3IQCO*{cSs-sQkLY)OzPhRWOX)IVeK6}f z(=%E0DvW)cNWC0Q9GzMO!8`H6?4Ocdiz4!U7ACsmNc+GtDS5hBu3wP#Jz>maa{47{ zZ6z1!1FJq7-uj}Cf~ErJv*8pCWxB>&y0on~Ek)}kzOrdkmtmsK9k68DNMaTH`2+?3 z6-I_vr9z<+b`CD;O-b5dx-B&_W81<8Qqo<|*r25ibE3TSO$}ac2{J|LLefcsB~fK) zx5e*F%Tb2qH&Bn_Gg_@D?$#oCsb*_r#6|AaEOu3p<`y_*mWzTz{E-J4i6}*Lv&E(w zdl^fVh*fhJ`zE*gc*U*bR>ls}2`!TH#Ax$r5ZM(V@)2o|?Ki?+G{5Zb*LgGX{HtH* zpFcm<%E)z{kzC|CE_g2jx)n{;CBtQn>rwTOv;PYu?&pamJ&W5=M7HqA&4~HEZL`J> zHbIW^+|_k1B7QagerpAdOi#$&kTmt{aA_1z3wiP-oM^5Ws)O<%u0p%gMCT46H5ixV z69c^qVJ2cQ?sMuW?mnhgHu`AAD$WQmMEDiA6SYL@Di^Hu*~C|4PYK06TkMk;r1vui zRZ><%#lwGInRLK1xGsXk#=|&|=CIT>?bP1!ffIO?OflYZ$6U!~9>0kp3H-Aysw_LH zlX%0D)qTJg-!pgI4qsLb< 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 literal 0 HcmV?d00001 diff --git a/.github/assets/cortex-review.png b/.github/assets/cortex-review.png new file mode 100644 index 0000000000000000000000000000000000000000..737a55887d4a1446e6d059a3490f799a9975b3a9 GIT binary patch literal 282695 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 z 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%0Akn-XlX1n61^}TbdMvzHuUZcg)5jg- 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

^NVh2CBNlUlWn=TY_dU@c|I4_# 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

0=1B*I_<2*@s| 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 M%JU~hkImlx2Uhw3+5i9m literal 0 HcmV?d00001 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f047229d..d27961c2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,31 +23,25 @@ jobs: steps: - name: Check out repository - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 with: python-version: "3.12" - name: Install backend test dependencies run: | python -m pip install --upgrade pip - python -m pip install -r requirements-dev.lock + python -m pip install --require-hashes -r requirements-dev.lock - name: Compile Python sources run: | python -m compileall -q \ backend \ examples \ - scripts \ - capture.py \ - github_store.py \ - ingest.py \ - instrumentation.py \ - mcp_server.py \ - redis_store.py \ - ui.py + legacy \ + scripts - name: Run backend unit tests # pytest is the canonical runner: conftest.py provides fixtures + temp-dir @@ -61,6 +55,9 @@ jobs: - name: Run public examples against an isolated server run: python scripts/examples_smoke.py + - name: Smoke-test the packaged standalone runtime + run: python scripts/examples_smoke.py --quickstart --runtime standalone + - name: Run retrieval quality regression gate run: python scripts/retrieval_eval.py @@ -83,14 +80,14 @@ jobs: run: python scripts/delivery_eval.py # EMBEDDINGS DEMO-INTEGRITY GUARD (CI-visible skip): this job installs - # backend/requirements.txt only, which does NOT provision the model2vec package or the + # requirements-dev.lock, which does NOT provision the model2vec package or the # potion-base-8M weights, so the model2vec-seeded gate legitimately skips here. We do NOT # pass --forbid-skip (that would break CI without adding new model-provisioning # infrastructure); instead the skip is made EXPLICIT: the eval output is captured and a # status=skipped result emits a GitHub notice annotation, so a skip shows up in the job - # log/summary rather than passing silently. Strict enforcement lives in the release path, - # where the model is guaranteed present: macos/package_release.sh exports - # CORTEX_REQUIRE_MODEL=1 and runs check_vector_runtime.py --require-model2vec. + # log/summary rather than passing silently. Strict enforcement lives in both the macOS + # release-runtime CI job below and the credentialed release path; both require the model + # and run check_vector_runtime.py --require-model2vec. - name: Run model2vec retrieval-lift gate (explicit skip when model not provisioned) run: | python scripts/rerank_eval.py | tee "$RUNNER_TEMP/rerank_eval.json" @@ -105,7 +102,9 @@ jobs: run: python scripts/check_connector_baseline.py - name: Check beta docs currency - run: python scripts/check_docs_current.py + run: | + python scripts/check_markdown_links.py + python scripts/check_docs_current.py - name: Run backend beta smoke run: python scripts/backend_beta_smoke.py @@ -123,10 +122,10 @@ jobs: steps: - name: Check out repository - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 with: python-version: "3.12" @@ -143,11 +142,14 @@ jobs: - name: Audit backend dependencies for known vulnerabilities run: pip-audit --strict -r requirements-dev.lock + - name: Audit hosted runtime dependencies + run: pip-audit --strict -r backend/requirements.lock + - name: Audit top-level dependencies for known vulnerabilities - run: pip-audit --strict -r requirements.txt + run: pip-audit --strict -r legacy/requirements.txt - name: Audit packaged backend runtime dependencies - run: pip-audit --strict -r backend/runtime-requirements.txt + run: pip-audit --strict -r backend/runtime-requirements.lock distribution: name: Distribution site and update manifest @@ -156,10 +158,10 @@ jobs: steps: - name: Check out repository - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 with: python-version: "3.12" @@ -176,10 +178,10 @@ jobs: steps: - name: Check out repository - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - name: Set up Node - uses: actions/setup-node@v4 + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: node-version: "22" cache: npm @@ -207,15 +209,15 @@ jobs: steps: - name: Check out repository - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 with: python-version: "3.12" - name: Set up Node - uses: actions/setup-node@v4 + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: node-version: "22" cache: npm @@ -242,24 +244,41 @@ jobs: run: ${{ matrix.release_gate }} macos-app: - name: macOS app build + name: macOS release-runtime build and smoke runs-on: macos-latest - timeout-minutes: 15 + timeout-minutes: 30 steps: - name: Check out repository - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - # Dev-mode build: CORTEX_BUNDLE_PYTHON=0 means no bundled interpreter and no bundled - # model2vec model, so build.sh prints its loud "EMBEDDINGS FALLBACK" warning here — that is - # expected and non-fatal. CORTEX_REQUIRE_MODEL is deliberately NOT set: this CI job does not - # provision the model; only release packaging (macos/package_release.sh) enforces it. - - name: Build app bundle + - name: Set up Python framework + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + with: + python-version: "3.12" + + - name: Locate the configured Python framework + run: | + FRAMEWORK_PREFIX="$(python -c 'import sysconfig; print(sysconfig.get_config_var("PYTHONFRAMEWORKPREFIX") or "")')" + FRAMEWORK_SOURCE="$FRAMEWORK_PREFIX/Python.framework/Versions/3.12" + test -x "$FRAMEWORK_SOURCE/bin/python3.12" + test -f "$FRAMEWORK_SOURCE/Python" + echo "CORTEX_PYTHON_FRAMEWORK_SOURCE=$FRAMEWORK_SOURCE" >> "$GITHUB_ENV" + + # This is release-like except for Apple credentials: CI uses ad-hoc signing, + # but bundles the actual interpreter, native wheels, and offline semantic model. + - name: Build release runtime app bundle env: - CORTEX_BUNDLE_PYTHON: "0" + CORTEX_BUNDLE_PYTHON: "1" + CORTEX_REQUIRE_MODEL: "1" CORTEX_CODESIGN_IDENTITY: "-" CORTEX_CODESIGN_TIMESTAMP: "0" run: ./macos/build.sh - - name: Verify ad-hoc signed app - run: codesign --verify --deep --strict --verbose=2 macos/build/Cortex.app + - name: Verify release runtime and ad-hoc signature + run: | + python scripts/check_vector_runtime.py \ + --app macos/build/Cortex.app \ + --require-model2vec + python scripts/bundled_runtime_smoke.py --app macos/build/Cortex.app + codesign --verify --deep --strict --verbose=2 macos/build/Cortex.app diff --git a/.github/workflows/publish-sdks.yml b/.github/workflows/publish-sdks.yml index de57c084..ec161c3b 100644 --- a/.github/workflows/publish-sdks.yml +++ b/.github/workflows/publish-sdks.yml @@ -42,10 +42,10 @@ jobs: steps: - name: Check out repository - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 with: python-version: "3.12" @@ -66,7 +66,7 @@ jobs: run: python -m twine check dist/* - name: Upload build artifacts - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: doppl-cortex-client-python-dist path: sdk/python/dist/* @@ -95,10 +95,10 @@ jobs: steps: - name: Check out repository - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - name: Set up Node - uses: actions/setup-node@v4 + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: node-version: "20" cache: npm @@ -125,7 +125,7 @@ jobs: run: npm pack --dry-run - name: Upload build artifacts - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: doppl-tech-cortex-client-typescript-dist path: sdk/typescript/dist/* diff --git a/.gitignore b/.gitignore index 793dfbef..30ef9430 100644 --- a/.gitignore +++ b/.gitignore @@ -4,7 +4,14 @@ venv/ .venv/ __pycache__/ *.pyc +.coverage +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ +htmlcov/ +*.egg-info/ .DS_Store +.idea/ node_modules/ npm-debug.log* backend/data/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 8f00f05c..6eef44c6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,11 +19,19 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - CI gates for the Python and TypeScript SDKs and the OpenClaw context plugin. - A generated OpenAPI contract test and explicit public API lifecycle policy. - Deterministic local example data for validating the first-run search flow. +- A one-command disposable `make demo` path and read-only `make doctor` + prerequisite check. +- A packaged-standalone runtime smoke gate, connector authoring guide, and + contributor code map. +- Full CMP context options in both SDKs, including SMP/Markdown projections, + model profiles, session deltas, pinning, project/sector scopes, and `as_of`. ### Changed - Repository documentation now distinguishes implemented capabilities from release-configured capabilities. +- The archived Redis/Voyage/Streamlit prototype now lives under `legacy/` + instead of competing with current entry points at repository root. - SDK and plugin metadata points to `trace-cortex/cortex-app`. - Hosted deployment, Apple release, account, offline, update, and legal documentation now matches the behavior in code. @@ -31,6 +39,13 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). read-scoped `GET` request; exporting identity remains a separate permission. - Mac mini installation uses a dedicated Python 3.12 virtual environment and the current public API hostname. +- Evaluation commands print concise summaries by default and expose full + per-case diagnostics through `--json`. +- Dependency-free retrieval recognizes bounded owner/DRI and + launch/release/rollout paraphrases and demotes explicit non-answer templates. +- Vector-index compatibility now fingerprints provider, model/revision, native + dimensions, local asset identity, and embedding text recipe; same-dimension + model swaps rebuild and re-embed instead of mixing vector spaces. ### Security diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 277c90e5..a45d8449 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -17,32 +17,40 @@ request. Requirements: +- Git and Make - Python 3.12 -- Xcode command line tools for the macOS app -- Node.js 22 for the Obsidian and OpenClaw packages +- Node.js 18+ only for TypeScript SDK work; Node.js 22 for Obsidian/OpenClaw +- Xcode command line tools only for macOS app work ```bash git clone https://github.com/trace-cortex/cortex-app.git cd cortex-app -make setup -make check +make doctor +make demo ``` +`make demo` runs `make setup` automatically when `.venv` is missing, then +proves capture → retrieval → cited Ask against disposable synthetic data. `make setup` creates `.venv` and fails with an actionable message if the chosen interpreter is not Python 3.12. It installs the exact versions in -`requirements-dev.lock`; dependency updates should refresh that file +`requirements-dev.lock` and verifies every downloaded artifact hash; +dependency updates should refresh that file intentionally. Use an explicit interpreter when necessary: ```bash make setup PYTHON=/absolute/path/to/python3.12 ``` -Start the hosted-plane development API: +Start the local FastAPI development API: ```bash make run ``` +This uses `backend/data/Cortex.vault/`, not the installed application's +personal vault. If port 8766 is occupied, the command prints an available port +and the matching `CORTEX_BASE_URL` export. + For the macOS app: ```bash @@ -54,19 +62,53 @@ The default app build is a development bundle without the release interpreter or embedding model. See [SETUP.md](SETUP.md) and [docs/APPLE_RELEASE.md](docs/APPLE_RELEASE.md) for release-like builds. +## Good First Contributions + +You can contribute without an account or a personal Cortex vault. Useful, +contained starting points include: + +- reproduce an installation failure and improve the diagnostic or + troubleshooting step; +- add a synthetic import fixture and a parser regression test; +- make one runnable example easier to understand without tying it to a model + provider; +- add an adversarial retrieval or privacy case to an existing evaluation + harness; or +- fix a broken, ambiguous, or stale documentation path. + +If your change adds a source, follow [Adding a Cortex +Connector](docs/ADDING_A_CONNECTOR.md). For unfamiliar areas, the +[contributor code map](docs/CODE_MAP.md) links product surfaces to their first +implementation file and focused test. + +For documentation-only work, `make docs-check` is the relevant local gate. +For code changes, open or reference an issue first when public behavior, +storage, security, or an API contract would change. If you are unsure whether +an idea fits, a focused feature request with a concrete use case is welcome. + ## What to Test -Run the smallest relevant set while developing, then the pre-PR gate: +Run the smallest relevant set while developing, then the pre-PR gate. The full +backend suite currently takes roughly ten minutes on a laptop; it is not the +first command a new contributor needs to run. ```bash -make test -make connector-check make check +make test ``` -Additional surfaces: +Choose additional checks by the files you changed: ```bash +# Documentation only +make docs-check + +# Connectors +make connector-check + +# Packaged standalone runtime +make runtime-check + # Python SDK .venv/bin/python -m pytest sdk/python/tests -q @@ -84,8 +126,40 @@ codesign --verify --deep --strict --verbose=2 macos/build/Cortex.app CI is authoritative. If a check is too expensive or platform-specific to run locally, say exactly what you did run in the pull request. +`make check` is the required fast pre-PR gate and includes the public examples +against both FastAPI and the packaged standalone runtime. Run `make test` for a +broad backend change; an SDK-only change may use its focused suite plus +`make check`. + +### Refreshing the Python lock + +`backend/requirements.txt` contains the direct runtime requirements. +`requirements-dev.lock` freezes their complete Python 3.12 dependency graph +plus pytest for reproducible contributor and CI installs. +`backend/requirements.lock` freezes the hosted/API runtime, while +`backend/runtime-requirements.lock` freezes the dependency-light runtime +bundled into the macOS app. All three locks include PyPI artifact hashes. + +Regenerate both locks in an isolated temporary environment, then review every +version and hash change: + +```bash +make lock-python +make setup +make check +``` + +`make lock-python` pins its temporary lock compiler too, so generated output is +stable across contributor machines. Do not refresh locks as an unrelated side +effect of another change. + ## Engineering Expectations +- Follow `.editorconfig`: UTF-8, LF endings, final newlines, four-space Python + and Swift indentation, and two-space JSON/YAML/TypeScript indentation. +- Use type hints on new Python public boundaries and descriptive names over + abbreviations. Cortex does not yet impose a repository-wide autoformatter; + avoid unrelated mechanical reformatting. - Preserve local-first defaults and cite-or-abstain behavior. - Treat the vault as authoritative and SQLite/vector indexes as rebuildable. - Keep local and hosted HTTP surfaces behaviorally aligned. diff --git a/Makefile b/Makefile index 18405ecd..839440d3 100644 --- a/Makefile +++ b/Makefile @@ -2,32 +2,68 @@ PYTHON ?= python3.12 VENV ?= .venv VENV_PYTHON := $(VENV)/bin/python -.PHONY: help setup run test check docs-check connector-check examples-check clean +.PHONY: help doctor setup demo run run-standalone test check docs-check connector-check examples-check runtime-check lock-python clean help: @printf '%s\n' \ 'Cortex contributor commands:' \ + ' make doctor Check local prerequisites without changing anything' \ ' make setup Create .venv with Python 3.12 and install test dependencies' \ - ' make run Start the FastAPI development server on 127.0.0.1:8766' \ + ' make demo Set up if needed, then run an isolated cited-answer demo' \ + ' make run Start the local FastAPI dev server (suggests a free port on conflict)' \ + ' make run-standalone Start the stdlib engine shipped inside the macOS app' \ ' make test Run the backend test suite' \ ' make check Run the fast local pre-PR checks' \ + ' make docs-check Validate documentation, site links, and release metadata' \ ' make connector-check Validate all 13 connector contracts' \ ' make examples-check Run every public Python example against an isolated server' \ + ' make runtime-check Smoke-test the standalone runtime shipped in the macOS app' \ + ' make lock-python Regenerate hash-locked Python dependency graphs' \ ' make clean Remove local Python caches (keeps .venv)' +doctor: + @printf '%s\n' 'Cortex backend prerequisites:' + @command -v git >/dev/null 2>&1 \ + && printf '%s\n' ' [ok] git' \ + || { printf '%s\n' ' [missing] git — install Xcode Command Line Tools or Git' >&2; exit 1; } + @PYTHON_BIN="$(PYTHON)" ./scripts/bootstrap_dev.sh --check + @printf '%s\n' ' [optional] Node.js 18+ is needed for the TypeScript SDK; Node.js 22 for plugins' + @printf '%s\n' ' [optional] Xcode Command Line Tools are needed only for the macOS app' + @printf '%s\n' 'Ready for: make demo' + setup: PYTHON_BIN="$(PYTHON)" CORTEX_VENV="$(abspath $(VENV))" ./scripts/bootstrap_dev.sh +demo: + @if ! test -x "$(VENV_PYTHON)" \ + || ! "$(VENV_PYTHON)" -c 'import sys, fastapi, uvicorn, cortex_client; raise SystemExit(sys.version_info < (3, 12))' >/dev/null 2>&1; then \ + printf '%s\n' 'Cortex dependencies are missing or incomplete; running make setup first...'; \ + $(MAKE) setup PYTHON="$(PYTHON)" VENV="$(VENV)"; \ + fi + "$(VENV_PYTHON)" scripts/examples_smoke.py --quickstart + run: @test -x "$(VENV_PYTHON)" || { echo "Missing $(VENV_PYTHON). Run 'make setup' first." >&2; exit 1; } CORTEX_PYTHON="$(abspath $(VENV_PYTHON))" ./scripts/dev_backend.sh +run-standalone: + @test -x "$(VENV_PYTHON)" || { echo "Missing $(VENV_PYTHON). Run 'make setup' first." >&2; exit 1; } + cd backend && \ + CORTEX_API_KEY="$${CORTEX_API_KEY:-dev-local-key}" \ + CORTEX_ALLOW_INSECURE_DEV_TOKEN="$${CORTEX_ALLOW_INSECURE_DEV_TOKEN:-1}" \ + CORTEX_VAULT_PATH="$${CORTEX_VAULT_PATH:-./data/Cortex.vault}" \ + CORTEX_DB_PATH="$${CORTEX_DB_PATH:-./data/Cortex.vault/index.sqlite}" \ + "$(abspath $(VENV_PYTHON))" -m app.standalone_server \ + --host "$${CORTEX_HOST:-127.0.0.1}" \ + --port "$${CORTEX_PORT:-8766}" + test: @test -x "$(VENV_PYTHON)" || { echo "Missing $(VENV_PYTHON). Run 'make setup' first." >&2; exit 1; } "$(VENV_PYTHON)" -m pytest backend/tests -q docs-check: @test -x "$(VENV_PYTHON)" || { echo "Missing $(VENV_PYTHON). Run 'make setup' first." >&2; exit 1; } + "$(VENV_PYTHON)" scripts/check_markdown_links.py "$(VENV_PYTHON)" scripts/check_docs_current.py "$(VENV_PYTHON)" scripts/check_distribution_site.py "$(VENV_PYTHON)" scripts/validate_update_manifest.py --allow-remote-artifacts site/downloads/latest.json @@ -40,11 +76,35 @@ examples-check: @test -x "$(VENV_PYTHON)" || { echo "Missing $(VENV_PYTHON). Run 'make setup' first." >&2; exit 1; } "$(VENV_PYTHON)" scripts/examples_smoke.py +runtime-check: + @test -x "$(VENV_PYTHON)" || { echo "Missing $(VENV_PYTHON). Run 'make setup' first." >&2; exit 1; } + "$(VENV_PYTHON)" scripts/examples_smoke.py --quickstart --runtime standalone + check: docs-check "$(VENV_PYTHON)" scripts/retrieval_eval.py "$(VENV_PYTHON)" scripts/adaptation_eval.py "$(VENV_PYTHON)" -m pytest sdk/python/tests -q "$(VENV_PYTHON)" scripts/examples_smoke.py + "$(VENV_PYTHON)" scripts/examples_smoke.py --quickstart --runtime standalone + +lock-python: + @"$(PYTHON)" -c 'import sys; raise SystemExit(sys.version_info[:2] != (3, 12))' \ + || { printf '%s\n' 'make lock-python requires Python 3.12 (override PYTHON=/path/to/python3.12).' >&2; exit 1; } + @LOCK_TMP="$$(mktemp -d "$${TMPDIR:-/tmp}/cortex-lock.XXXXXX")"; \ + trap 'rm -r -- "$$LOCK_TMP"' EXIT; \ + "$(PYTHON)" -m venv "$$LOCK_TMP/venv"; \ + "$$LOCK_TMP/venv/bin/python" -m pip install --disable-pip-version-check --quiet \ + 'pip==25.3' 'pip-tools==7.5.2'; \ + CUSTOM_COMPILE_COMMAND='make lock-python' "$$LOCK_TMP/venv/bin/pip-compile" \ + --quiet --generate-hashes --resolver=backtracking --strip-extras \ + --output-file=requirements-dev.lock requirements-dev.in; \ + CUSTOM_COMPILE_COMMAND='make lock-python' "$$LOCK_TMP/venv/bin/pip-compile" \ + --quiet --generate-hashes --resolver=backtracking --strip-extras \ + --output-file=backend/requirements.lock backend/requirements.txt; \ + CUSTOM_COMPILE_COMMAND='make lock-python' "$$LOCK_TMP/venv/bin/pip-compile" \ + --quiet --generate-hashes --resolver=backtracking --strip-extras \ + --output-file=backend/runtime-requirements.lock backend/runtime-requirements.txt; \ + printf '%s\n' 'Updated contributor, hosted, and bundled-runtime Python locks' clean: find backend scripts sdk -type d -name __pycache__ -prune -exec rm -r {} + diff --git a/README.md b/README.md index 3b35b981..5dfad45e 100644 --- a/README.md +++ b/README.md @@ -1,43 +1,156 @@

-Cortex +Cortex # Cortex -### Your personal operating model for AI: a local, cited model of how you work. +### The local memory layer for the AI tools you already use. -Every AI tool starts from a foundation model of the world and knows nothing about you. Cortex builds a -**model of you** on your own Mac — your voice, preferences, decisions, and the people and projects -around them — and serves it to Claude, ChatGPT, Cursor, and other MCP clients. Your memory is plain -Markdown plus a rebuildable index that you own, and every answer it gives is **cited or withheld**. +**Review what it learns · Retrieve with citations · Keep a vault you own** -
+[![CI](https://github.com/trace-cortex/cortex-app/actions/workflows/ci.yml/badge.svg)](https://github.com/trace-cortex/cortex-app/actions/workflows/ci.yml) +[![Release](https://img.shields.io/github/v/release/doppl-tech/releases?sort=semver&display_name=tag&label=macOS)](https://github.com/doppl-tech/releases/releases/latest) +[![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) +[![Python 3.12](https://img.shields.io/badge/python-3.12-3776AB.svg)](.python-version) - -[University of Waterloo](https://uwaterloo.ca) -[Faculty of Engineering](https://uwaterloo.ca/engineering) -[Academic Research](https://uwaterloo.ca/research) -[Sponsored by Composio](https://composio.dev) -[MIT License](LICENSE) +

+ Download for macOS + · One-command demo + · Docs + · Architecture + · Roadmap +

-
+
-[![Download for macOS](https://img.shields.io/github/v/release/doppl-tech/releases?sort=semver&display_name=tag&label=Download%20for%20macOS&color=E4B429&labelColor=000000&logo=apple&logoColor=FDD54F&style=for-the-badge)](https://github.com/doppl-tech/releases/releases/latest) +Cortex: the local memory layer for your AI tools -
+Cortex gives Claude, Cursor, ChatGPT, and other AI tools one user-owned memory. +Bring in notes and chat history, approve what is worth remembering, then +retrieve only the context a task needs over MCP or HTTP. The local engine +returns source citations—or explicitly abstains when the evidence is not there. -![macOS 13+](https://img.shields.io/badge/macOS-13%2B%20(Apple%20Silicon)-000000?logo=apple&logoColor=white) -![Notarized](https://img.shields.io/badge/Apple-Notarized-555555?logo=apple&logoColor=white) -![SwiftUI](https://img.shields.io/badge/App-SwiftUI-F05138?logo=swift&logoColor=white) -![Python engine](https://img.shields.io/badge/Engine-Python%203.12-3776AB?logo=python&logoColor=white) -![SQLite + sqlite-vec](https://img.shields.io/badge/Storage-SQLite%20%2B%20sqlite--vec-003B57?logo=sqlite&logoColor=white) -![MCP](https://img.shields.io/badge/Protocol-MCP-6E56CF?logo=modelcontextprotocol&logoColor=white) -![Works with Claude](https://img.shields.io/badge/Works%20with-Claude-D97757?logo=claude&logoColor=white) -![On-device retrieval](https://img.shields.io/badge/Retrieval-on--device-2E7D32?logo=ghostery&logoColor=white) +> If local, portable AI memory is a problem you want solved, **star Cortex to +> follow the beta**. If something breaks, tell us—the project is built in the +> open and the rough edges are documented. -

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

+## Why Cortex exists + +| Stop repeating yourself | Keep memory trustworthy | Switch tools freely | Inspect everything | +|---|---|---|---| +| Reuse approved project decisions, preferences, and context across sessions. | New memories wait in Review; edit, approve, or archive them. | Serve the same cited memory to MCP clients instead of rebuilding a profile in every app. | The vault stays readable as Markdown/JSON, with SQLite as a rebuildable index. | + +## One-command local demo + +The demo uses three synthetic Project Atlas records. The source workflow needs +Git, Make, and Python 3.12, but no API key, Docker, Redis, model provider, or +personal Cortex vault. + +```bash +git clone https://github.com/trace-cortex/cortex-app.git +cd cortex-app +make demo +``` + +

+ Terminal recording of make demo starting Cortex, loading synthetic captures, and returning a cited answer +

+ +`make demo` checks prerequisites, creates `.venv` when needed, runs an isolated +loopback server on a free port, prints a cited answer, then removes the +temporary vault. If Python 3.12 is not discoverable, `make doctor` prints the +exact fix for an existing interpreter. A fresh clone downloads the pinned +development dependencies and may take a few minutes; subsequent runs complete +in seconds. + +
+See the exact expected output + +```text +Cortex is working. + [ok] isolated loopback server started + [ok] 3 synthetic Project Atlas captures loaded + [ok] Ask returned cited evidence + +Question: When does Project Atlas ship? +Answer: Cortex returned a cited answer for this question: … +Citation: https://example.invalid/cortex-demo/project-atlas +``` + +
+ +### Pick your path + +| I want to… | Fastest path | +|---|---| +| Use Cortex | **[Download the signed and notarized macOS beta](https://github.com/doppl-tech/releases/releases/latest)** | +| Evaluate the engine safely | Run `make demo` with synthetic data | +| Build an agent integration | Use the [Python SDK](sdk/python/README.md), [TypeScript SDK](sdk/typescript/README.md), or [MCP guide](docs/MCP_INTEGRATIONS.md) | +| Contribute | Run `make doctor`, then open the [contributor guide](CONTRIBUTING.md) | +| Understand the internals | Read [Architecture](docs/ARCHITECTURE.md), [CMP](docs/CMP_PROTOCOL.md), and the [vault format](docs/LOCAL_VAULT_FORMAT.md) | + +> [!NOTE] +> The desktop beta currently requires Apple Silicon, macOS 13+, and a one-time +> account sign-in. Backend development and the demo run on macOS or Linux. +> Read the [limitations](#current-limitations-plainly) before installing. + +The source of truth is this `trace-cortex` repository. Signed installers are +published by the project maintainer in `doppl-tech/releases`; the committed +[release manifest](site/downloads/latest.json) records the build, source +commit, artifacts, and checksums. + +## The product loop + +| ① Connect | ② Review | ③ Ask | ④ Control | +|---|---|---|---| +| Bring in notes, chat exports, files, or a live source. | Approve what is useful; edit or archive the noise. | Get cited evidence in Cortex or retrieve it from an MCP client. | See and revoke every tool, scope, read, save, and export permission. | ---- +Cortex Review showing synthetic, cited sample memories ready for approval + +

Published macOS beta · Review screen · synthetic sample notes, not personal data

+ +## A different default for AI memory + +| Design choice | Cortex default | Why it matters | +|---|---|---| +| Storage | Local readable vault; rebuildable index | Your memory survives an app or model change | +| Admission | Explicit review for connected sources | Noisy inputs do not silently become personal facts | +| Retrieval | Cited evidence or abstention | Agents can preserve provenance instead of inventing confidence | +| Delivery | Bounded, model-calibrated context over MCP and HTTP | Tools receive task-relevant context, not a vault dump | +| Providers | On-device embeddings; deterministic Ask | The core loop does not need a model API key | + +## Reproducible proof, not a leaderboard claim + +The checked-in offline gates use synthetic fixtures with known answers and no +model API calls. The recorded source-checkout run passed 163 labeled +**hash/FTS retrieval** queries, 30 context-packing checks, and every no-leak and +budget check. It does not measure the bundled Model2Vec semantic path. + +| Gate | Recorded result | +|---|---:| +| Deterministic hash/FTS retrieval top-1 / recall@3 | 1.000 / 1.000 | +| Packed-context citation coverage | 1.000 | +| Session delta token savings | 28.5% | +| Token-estimator MAPE vs simulated references | 1.58% | + +These are deterministic regression results—not proof of real-world answer +quality or latency. Read the [method, machine-readable snapshot, commands, and +limitations](docs/BENCHMARKS.md), then reproduce them locally. CI also runs +2,000+ backend tests plus SDK, plugin, security, packaging, and documentation +gates. + +## What you can build + +| Starting point | Outcome | +|---|---| +| [`minimal_search.py`](examples/minimal_search.py) | Add cited local-memory search to a script in a few lines | +| [`memory_workflow.py`](examples/memory_workflow.py) | Search and Ask a factual query, then assemble bounded context for a work-shaped task | +| [`multi_agent_context.py`](examples/multi_agent_context.py) | Give planner, researcher, and writer agents different views of the same approved memory | +| [`tool_catalog.py`](examples/tool_catalog.py) | Expose Cortex through an OpenAI-compatible tool catalog without calling a model | +| [Connector guide](docs/ADDING_A_CONNECTOR.md) | Send records through the generic source contract or ship a first-party connector | + +All examples are provider-neutral and run against the same local API. Start +with the [example guide and synthetic fixture](examples/README.md). ## What Cortex does today @@ -81,16 +194,6 @@ bundled or called to write prose. Retrieval embeddings run entirely on-device. client IDs ship empty. Use file import or a pasted token instead. - **PDF text extraction is inactive** in the shipped app — `pypdf` is not bundled. -## The loop - -
- -| ① Connect | ② Review | ③ Ask | ④ Control | -|:--:|:--:|:--:|:--:| -| Bring in notes, an AI-chat export, or connect a live source | Approve what's useful, archive the noise — memory stays trustworthy | Ask with cited answers, or let a connected AI tool retrieve what you approved | Keep reads, saves, exports, and every connection visible and revocable | - -
- ## How it works ```mermaid @@ -196,16 +299,27 @@ Approved memory goes in; a calibrated model of how you operate comes out. Cortex checks the HTTPS release feed for new builds and links you to the current DMG. Installing an update still uses the normal macOS app-replacement flow. -## Five-minute developer quickstart +## Run a persistent development server + +Use `make demo` first if you only want to evaluate the engine. It creates the +development environment, so you do not need to run setup again. If you skipped +the demo, run `make setup` once. -Requires Python 3.12. This starts a loopback-only development server and uses -synthetic data, so it does not touch your packaged Cortex vault. +For API and SDK development, start the FastAPI development runtime locally: ```bash -make setup CORTEX_AUTO_APPROVE_CAPTURES=1 make run ``` +This is local development—not Cortex Cloud. It writes disposable development +state under `backend/data/Cortex.vault/` and does not read the installed app's +vault under `~/Library/Application Support/Cortex/`. Auto-approval is enabled +here so the synthetic examples are immediately searchable; normal product +sources remain review-first. + +If the installed app or another process already owns port 8766, `make run` +prints a free-port command and the matching `CORTEX_BASE_URL` export. + In a second terminal: ```bash @@ -222,12 +336,15 @@ curl -sS -G http://127.0.0.1:8766/v1/ask \ The second response either includes the cited quickstart memory or explicitly abstains. Next, try the runnable [Python examples](examples/README.md), the [Python SDK](sdk/python/README.md), or the -[TypeScript SDK](sdk/typescript/README.md). `dev-local-key` is accepted only by -the explicit local development configuration. +[TypeScript SDK](sdk/typescript/README.md). Interactive API documentation is at +`http://127.0.0.1:8766/docs`. `dev-local-key` is accepted only by the explicit +local development configuration. ## Build from source -Requires **Python 3.12** and Xcode command line tools. CI and the packaged runtime both use Python 3.12. +Backend/API development requires **Git, Make, and Python 3.12**. Xcode command +line tools are required only to build the macOS app. CI and the packaged +runtime both use Python 3.12. ```bash # Reproducible contributor environment @@ -238,22 +355,25 @@ make test # macOS app (SwiftUI) — writes macos/build/Cortex.app ./macos/build.sh -# Run the hosted-plane API (FastAPI + uvicorn) at 127.0.0.1:8766 +# Run the local FastAPI development API at 127.0.0.1:8766 make run # Or run the stdlib engine the packaged app actually ships -cd backend && python3 -m app.standalone_server --host 127.0.0.1 --port 8766 +make run-standalone -# Deterministic, offline quality gates (no dependencies needed) -python3 scripts/retrieval_eval.py -python3 scripts/adaptation_eval.py +# Deterministic, offline quality gates (use the pinned contributor environment) +.venv/bin/python scripts/retrieval_eval.py +.venv/bin/python scripts/adaptation_eval.py ``` By default `./macos/build.sh` produces an ad-hoc-signed app with **no** bundled interpreter or embedding model, and prints an embeddings-fallback warning — good enough to launch and inspect. A full build needs `CORTEX_BUNDLE_PYTHON=1` and a python.org framework install at -`/Library/Frameworks/Python.framework/Versions/3.12`; release packaging lives in -`macos/package_release.sh`. +`/Library/Frameworks/Python.framework/Versions/3.12`, or set +`CORTEX_PYTHON_FRAMEWORK_SOURCE` to another complete Python 3.12 framework +(including Homebrew's framework). Release packaging lives in +`macos/package_release.sh`; CI boots and exercises the bundled interpreter, +native vector runtime, offline embedding model, capture path, and cited Ask. CI runs the full gate set for pull requests and pushes to `main` — see [`.github/workflows/ci.yml`](.github/workflows/ci.yml) for the authoritative command list. [`SETUP.md`](SETUP.md) covers the app-side development loop. @@ -269,18 +389,21 @@ for the authoritative command list. [`SETUP.md`](SETUP.md) covers the app-side d | `packages/`, `sdk/`, `extension/` | Plugins, client SDKs, and the browser extension | | `docs/` | Architecture, protocol, and release documentation | | `deploy/`, `site/` | Hosted-plane ops and the static distribution site | -| `capture.py`, `ingest.py`, `mcp_server.py`, `redis_store.py`, `github_store.py`, `ui.py` | **Legacy prototype** at the repo root, kept for reference. Not used by the app; the root `requirements.txt` (Redis, Voyage AI, rumps) belongs to it, not to `backend/`. | +| `legacy/` | Archived Redis/Voyage/Streamlit prototype; not used by or installed with the current product | + +Use the [contributor code map](docs/CODE_MAP.md) to find the first implementation +file and focused test for a change. ## Documentation | Area | Doc | |---|---| | Documentation index and status | [`docs/README.md`](docs/README.md) | -| System architecture | [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) | +| System architecture and contributor code map | [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) · [`docs/CODE_MAP.md`](docs/CODE_MAP.md) | | Contextual Memory Protocol | [`docs/CMP_PROTOCOL.md`](docs/CMP_PROTOCOL.md) · [`docs/PORTABLE_MEMORY_PROTOCOL_V2.md`](docs/PORTABLE_MEMORY_PROTOCOL_V2.md) | | The vault format you own | [`docs/LOCAL_VAULT_FORMAT.md`](docs/LOCAL_VAULT_FORMAT.md) | | Connecting AI tools | [`docs/EXTERNAL_INTEGRATIONS.md`](docs/EXTERNAL_INTEGRATIONS.md) · [`docs/MCP_INTEGRATIONS.md`](docs/MCP_INTEGRATIONS.md) | -| Importing your sources | [`docs/SOURCE_IMPORTS.md`](docs/SOURCE_IMPORTS.md) | +| Importing sources and adding connectors | [`docs/SOURCE_IMPORTS.md`](docs/SOURCE_IMPORTS.md) · [`docs/ADDING_A_CONNECTOR.md`](docs/ADDING_A_CONNECTOR.md) | | Trust & privacy controls | [`docs/TRUST_CONTROLS.md`](docs/TRUST_CONTROLS.md) | | Reproducible benchmark results | [`docs/BENCHMARKS.md`](docs/BENCHMARKS.md) | | Open-source readiness review | [`docs/OPEN_SOURCE_READINESS.md`](docs/OPEN_SOURCE_READINESS.md) | @@ -291,14 +414,25 @@ for the authoritative command list. [`SETUP.md`](SETUP.md) covers the app-side d | Release & distribution | [`docs/DISTRIBUTION.md`](docs/DISTRIBUTION.md) · [`docs/APPLE_RELEASE.md`](docs/APPLE_RELEASE.md) | | Experimental pairwise twin evaluation | [Overview](https://github.com/trace-cortex/cortex-app/blob/feat/pairwise-twin-eval/docs/PAIRWISE_TWIN_EVALUATION.md) · [Integration guide](https://github.com/trace-cortex/cortex-app/blob/feat/pairwise-twin-eval/docs/PAIRWISE_TWIN_INTEGRATION_GUIDE.md) | -## Contributing and support +## Build with us + +Cortex is an early open-source beta, and small, evidence-backed contributions +are welcome. You do not need a personal vault to help: the demo, examples, and +evaluation fixtures all use synthetic data. -Start with [`CONTRIBUTING.md`](CONTRIBUTING.md), then use the issue forms for a -reproducible bug or a scoped feature proposal. Security reports belong in the -private channel described by [`SECURITY.md`](SECURITY.md), not a public issue. -Project direction and release history live in [`ROADMAP.md`](ROADMAP.md) and -[`CHANGELOG.md`](CHANGELOG.md); support boundaries are in -[`SUPPORT.md`](SUPPORT.md). +| Bring… | Start here | +|---|---| +| A reproducible bug | [Open a bug report](https://github.com/trace-cortex/cortex-app/issues/new?template=bug_report.yml) | +| Installation friction | [Ask for setup help](https://github.com/trace-cortex/cortex-app/issues/new?template=installation_help.yml) | +| A focused product idea | [Propose a feature](https://github.com/trace-cortex/cortex-app/issues/new?template=feature_request.yml) | +| An integration you built | [Share it with the community](https://github.com/trace-cortex/cortex-app/issues/new?template=showcase.yml) | +| A code or docs contribution | Read [`CONTRIBUTING.md`](CONTRIBUTING.md) and run `make check` | + +Please keep real memories, exports, tokens, and vaults out of issues. Report +vulnerabilities privately through [`SECURITY.md`](SECURITY.md). Project +direction, support boundaries, and releases live in +[`ROADMAP.md`](ROADMAP.md), [`SUPPORT.md`](SUPPORT.md), and +[`CHANGELOG.md`](CHANGELOG.md). ## Privacy @@ -341,7 +475,11 @@ Cortex is released under the **[MIT License](LICENSE)**, free to use, modify, an

-**Cortex is your personal operating model for AI:** the cited, local, portable model of how you work. +**One memory you can inspect. Cited context your tools can use.** + +
-The more you bring in, the more your tools act the way you would. +[⭐ Star Cortex](https://github.com/trace-cortex/cortex-app) +· [Download the beta](https://github.com/doppl-tech/releases/releases/latest) +· [Build an integration](examples/README.md)
diff --git a/SETUP.md b/SETUP.md index 10500938..d5d58436 100644 --- a/SETUP.md +++ b/SETUP.md @@ -4,20 +4,53 @@ Cortex is a local-first macOS beta. You do not need Redis, Docker, or an external vector database. The current direct-download build requires account sign-in; retrieval and the user-owned vault remain local. +## Choose a setup path + +### Evaluate the engine + +This is the safest first source run: it uses synthetic data and deletes its +temporary vault. + +```bash +make doctor +make demo +``` + +### Use the desktop app + +Download the current notarized DMG from the +[release page](https://github.com/doppl-tech/releases/releases/latest). Building +from source is a contributor workflow, not the shortest installation path. + +### Develop the backend or SDKs + +```bash +make setup +make run +``` + +Open `http://127.0.0.1:8766/docs` for the interactive API. + ## Requirements -- Apple Silicon Mac running macOS 13 or later -- Xcode command line tools -- Python 3.12 (the repository includes `.python-version` for version managers) +- Backend/demo: Python 3.12 on macOS or Linux +- TypeScript SDK: Node.js 18+; Obsidian/OpenClaw plugins: Node.js 22 +- Desktop app: Apple Silicon Mac running macOS 13+ and Xcode command line tools -## Build And Open +## Build the macOS UI from source ```bash ./macos/build.sh open macos/build/Cortex.app ``` -The release package starts its bundled local service on `127.0.0.1:8766` and stores user-owned memory files in: +The default source build is an ad-hoc-signed UI development bundle without the +release Python runtime or embedding model. It is useful for SwiftUI work but is +not a full replacement for the downloadable app. See +[docs/APPLE_RELEASE.md](docs/APPLE_RELEASE.md) for a release-like bundle. + +The release package starts its bundled local service on `127.0.0.1:8766` and +stores user-owned memory files in: ```text ~/Library/Application Support/Cortex/Cortex.vault/ @@ -56,18 +89,10 @@ Open Connections & Privacy to manage: - source/audit trail - advanced diagnostics and setup reset -## Local Backend Development - -For backend development without opening the app: - -```bash -make setup -make run -``` - `make setup` refuses to continue with the system Python when it is not 3.12, creates `.venv`, and installs the exact backend/test versions in -`requirements-dev.lock`. To use an explicit interpreter: +`requirements-dev.lock`, verifying every downloaded artifact hash. To use an +explicit interpreter: ```bash make setup PYTHON=/opt/homebrew/bin/python3.12 diff --git a/SUPPORT.md b/SUPPORT.md index e810f99b..9e61e2b4 100644 --- a/SUPPORT.md +++ b/SUPPORT.md @@ -2,8 +2,10 @@ ## Choose the Right Channel -- **Reproducible bug or installation problem:** open a GitHub issue using the - bug-report template. +- **Installation problem:** open a GitHub issue using the installation-help + template. +- **Reproducible product bug:** open a GitHub issue using the bug-report + template. - **Feature or design proposal:** open a feature request after checking [ROADMAP.md](ROADMAP.md). - **Security or private-data concern:** follow [SECURITY.md](SECURITY.md); do @@ -14,12 +16,17 @@ 1. Confirm you are testing the latest release or current `main`. 2. Check [docs/TROUBLESHOOTING.md](docs/TROUBLESHOOTING.md). -3. Run the relevant health check: +3. For source/setup problems, run the read-only prerequisite check and isolated + demo: ```bash - make check + make doctor + make demo ``` + `make doctor` changes nothing. `make demo` uses only disposable synthetic + data and removes its temporary vault. + 4. For a running app, create a sanitized support bundle from Connections & Privacy or: diff --git a/backend/README.md b/backend/README.md index 99bb6e0e..b77c8449 100644 --- a/backend/README.md +++ b/backend/README.md @@ -1,6 +1,25 @@ # Cortex Backend -FastAPI backend for the Cortex MVP. +The maintained Python core and FastAPI development/hosted runtime. New +contributors should use the repository-root commands; they pin Python and +dependencies consistently with CI. + +## Start here + +From the repository root: + +```bash +make demo # isolated synthetic data; starts and stops automatically +make run # persistent FastAPI server at http://127.0.0.1:8766 +make test # complete backend suite (roughly ten minutes on a laptop) +``` + +Interactive API documentation is available at +`http://127.0.0.1:8766/docs` while `make run` is active. The stdlib server +shipped inside the macOS app can be exercised with `make run-standalone`. + +Do not create a second virtual environment under `backend/`; `make setup` +creates the canonical root `.venv`. It provides: @@ -28,18 +47,6 @@ It provides: - diagnostics, reliability reports, support bundles, backups, repair, and search maintenance - MCP-style JSON-RPC tools -## Local Run - -```bash -cd backend -python3 -m venv .venv -source .venv/bin/activate -pip install -r requirements.txt -uvicorn app.main:app --reload --host 127.0.0.1 --port 8766 -``` - -The macOS app defaults to `http://127.0.0.1:8766`. - ## Environment ```bash diff --git a/backend/app/config.py b/backend/app/config.py index 8d375531..bdac63b1 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -239,7 +239,16 @@ def load_settings() -> Settings: or bool(oidc_apple_client_id) or bool(accounts_db_env) ) - public_base_url = os.environ.get("CORTEX_PUBLIC_BASE_URL", "http://127.0.0.1:8766") + configured_public_base = os.environ.get("CORTEX_PUBLIC_BASE_URL", "").strip() + if configured_public_base: + public_base_url = configured_public_base + else: + # Development servers can move off 8766 when the desktop app already owns + # that port. Keep discovery and generated callback URLs on the same origin. + configured_port = int(os.environ.get("CORTEX_PORT", "8766") or "8766") + if configured_port < 1 or configured_port > 65535: + raise ValueError("CORTEX_PORT must be between 1 and 65535") + public_base_url = f"http://127.0.0.1:{configured_port}" api_key = os.environ.get("CORTEX_API_KEY", "").strip() if api_key == INSECURE_DEV_API_KEY and not _truthy_env("CORTEX_ALLOW_INSECURE_DEV_TOKEN"): raise RuntimeError( diff --git a/backend/app/connectors/agent_sessions.py b/backend/app/connectors/agent_sessions.py index 09d9fb11..8e2ca4aa 100644 --- a/backend/app/connectors/agent_sessions.py +++ b/backend/app/connectors/agent_sessions.py @@ -254,7 +254,7 @@ def _claude_records(root: Path, path: Path, *, session_cap: int) -> list[AgentSe cleaned = _clean_user_text(text) if not cleaned: continue - fingerprint = sha1(cleaned.encode("utf-8")).hexdigest() + fingerprint = sha1(cleaned.encode("utf-8"), usedforsecurity=False).hexdigest() if fingerprint in seen_texts: continue seen_texts.add(fingerprint) @@ -316,7 +316,7 @@ def _codex_records(root: Path, path: Path, *, session_cap: int) -> list[AgentSes cleaned = _clean_user_text(str(payload.get("message") or "")) if not cleaned: continue - fingerprint = sha1(cleaned.encode("utf-8")).hexdigest() + fingerprint = sha1(cleaned.encode("utf-8"), usedforsecurity=False).hexdigest() if fingerprint in seen_texts: continue seen_texts.add(fingerprint) @@ -370,7 +370,7 @@ def _cursor_records(root: Path, path: Path, *, mtime: float, session_cap: int) - cleaned = _clean_user_text(str(prompt.get("text") or "")) if not cleaned: continue - fingerprint = sha1(cleaned.encode("utf-8")).hexdigest() + fingerprint = sha1(cleaned.encode("utf-8"), usedforsecurity=False).hexdigest() if fingerprint in seen_texts: continue seen_texts.add(fingerprint) @@ -465,7 +465,7 @@ def _stable_external_id(agent: str, session_id: str, anchor: str) -> str: raw = f"{agent}:{session_id}:{anchor}" if len(raw) <= 240: return raw - digest = sha1(raw.encode("utf-8")).hexdigest()[:16] + digest = sha1(raw.encode("utf-8"), usedforsecurity=False).hexdigest()[:16] return f"{raw[:220]}#{digest}" diff --git a/backend/app/embeddings.py b/backend/app/embeddings.py index 67e7d214..aa3d7ad7 100644 --- a/backend/app/embeddings.py +++ b/backend/app/embeddings.py @@ -29,9 +29,11 @@ # hash when it does not — so no new asset is bundled and hash-only machines/CI stay byte-identical. DEFAULT_MODEL2VEC_MODEL = "minishlab/potion-base-8M" MODEL2VEC_DIMENSIONS = 256 +EMBEDDING_TEXT_RECIPE_VERSION = "source-text-v1" _MODEL2VEC_MODEL: Any = None _MODEL2VEC_MODEL_KEY: str | None = None +_MODEL2VEC_DIMENSIONS_BY_KEY: dict[str, int] = {} _MODEL2VEC_LOCK = threading.Lock() # Process-level failure latch: when model2vec is CONFIGURED but a live embed actually @@ -179,6 +181,9 @@ def configured_embedding_dimensions(default: int = VECTOR_DIMENSIONS) -> int: # model2vec emits a fixed native dimension (256 for potion-base-8M); # honour that as the default so status/index_compatible are truthful. if configured_embedding_provider() == "model2vec": + key = resolve_model2vec_path() or configured_embedding_model() + if key in _MODEL2VEC_DIMENSIONS_BY_KEY: + return _MODEL2VEC_DIMENSIONS_BY_KEY[key] return MODEL2VEC_DIMENSIONS return default try: @@ -187,6 +192,57 @@ def configured_embedding_dimensions(default: int = VECTOR_DIMENSIONS) -> int: return default +def embedding_index_fingerprint(dimensions: int | None = None) -> str: + """Identity of vectors that may safely coexist in one sqlite-vec table. + + Equal dimensions do not imply equal vector spaces. Include the effective + provider, configured model/revision, local asset identity, native dimension, + and text recipe so a model swap triggers a rebuild instead of silently + mixing incompatible vectors. + """ + provider = effective_embedding_provider() + model = configured_embedding_model() if provider != "hash" else VECTOR_MODEL + resolved_dimensions = int( + dimensions if dimensions is not None else configured_embedding_dimensions() + ) + payload = { + "provider": provider, + "model": model, + "revision": os.environ.get("CORTEX_EMBEDDING_REVISION", "").strip(), + "dimensions": resolved_dimensions, + "text_recipe": EMBEDDING_TEXT_RECIPE_VERSION, + "local_asset": _local_model_asset_identity() if provider == "model2vec" else "", + } + canonical = json.dumps(payload, sort_keys=True, separators=(",", ":")) + return hashlib.sha256(canonical.encode("utf-8")).hexdigest() + + +def _local_model_asset_identity() -> str: + """Cheap replacement detector for a bundled/local Model2Vec directory. + + Hash the small model config and include size/mtime metadata for large model + files. Remote/cache users can pin `CORTEX_EMBEDDING_REVISION` explicitly. + """ + raw_path = resolve_model2vec_path() + if not raw_path: + return "" + path = Path(raw_path).expanduser() + parts = [str(path.resolve(strict=False))] + config = path / "config.json" + try: + parts.append(hashlib.sha256(config.read_bytes()).hexdigest()) + except OSError: + parts.append("missing-config") + for name in ("model.safetensors", "tokenizer.json"): + candidate = path / name + try: + stat = candidate.stat() + parts.append(f"{name}:{stat.st_size}:{stat.st_mtime_ns}") + except OSError: + parts.append(f"{name}:missing") + return hashlib.sha256("|".join(parts).encode("utf-8")).hexdigest() + + def embedding_status(schema_dimensions: int | None = None) -> dict[str, Any]: """Report the active embedding provider and how it lines up with the vector index. @@ -392,6 +448,8 @@ def _model2vec_embedding(text: str, dimensions: int) -> EmbeddingResult: vector = [float(value) for value in (raw.tolist() if hasattr(raw, "tolist") else raw)] if not vector: raise ValueError("model2vec produced an empty embedding") + key = resolve_model2vec_path() or configured_embedding_model() + _MODEL2VEC_DIMENSIONS_BY_KEY[key] = len(vector) # Report the model's NATIVE dimension truthfully (potion-base-8M = 256), not the requested # default — the storage layer sizes/rebuilds the vector index from embedding_status(). return EmbeddingResult( diff --git a/backend/app/main.py b/backend/app/main.py index 337f25ae..f60e0edd 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -72,7 +72,7 @@ from .oidc_registry import OidcError, OidcProviderRegistry from .ratelimit import TokenBucketRateLimiter from .sharding import StoreRegistry -from .storage import BACKEND_VERSION, ExportSizeLimitError +from .storage import BACKEND_VERSION, ExportSizeLimitError, UnknownAgentSessionError from .webauth import ( FAVICON_SVG, PUBLIC_PAGE_CSP, @@ -116,7 +116,7 @@ def _stable_operation_id(route: APIRoute) -> str: app = FastAPI( title=f"{APP_BRAND} API", - version="0.1.0", + version=BACKEND_VERSION, generate_unique_id_function=_stable_operation_id, ) _bearer_scheme = HTTPBearer( @@ -1029,17 +1029,9 @@ def privacy_page() -> Response: @app.get("/health") def health() -> dict[str, Any]: payload = store.health_payload(mode="fastapi", auth=bool(settings.api_key)) - if settings.shard_mode == "local": - payload["hosted_readiness"] = _hosted_readiness_contract() - else: - # Liveness must be constant-time. The full tenant-wide credential audit - # belongs to /ready, where its result is bounded by a short cache. - payload["hosted_readiness"] = { - "status": "deferred", - "hosted_mode": True, - "shard_mode": settings.shard_mode, - "detail": "Full hosted release checks are available from /ready.", - } + # Public liveness must be constant-time and must not expose storage paths, + # tenant counts, or configuration details. Authenticated diagnostics carry + # the operator-facing detail. return payload @@ -1059,15 +1051,15 @@ def ready() -> dict[str, Any]: if hosted_readiness["status"] != "ok": raise HTTPException( status_code=503, - detail={ - "status": "needs_configuration", - "hosted_readiness": hosted_readiness, - }, + detail={"status": "not_ready", "check": "hosted_configuration"}, ) diagnostics = store.diagnostics(settings.default_user_id) if diagnostics["status"] != "ok": - raise HTTPException(status_code=503, detail=diagnostics) - return {"status": "ok", "diagnostics": diagnostics, "hosted_readiness": hosted_readiness} + raise HTTPException( + status_code=503, + detail={"status": "not_ready", "check": "storage"}, + ) + return {"status": "ok", "backend_version": BACKEND_VERSION} @app.get("/capture", response_class=HTMLResponse) @@ -2519,8 +2511,16 @@ def get_context( surface: str = Query(default="agent", max_length=40), token_budget: int = Query(default=2000, ge=1, le=100000), intent: str | None = Query(default=None, max_length=16), - sector: str | None = Query(default=None, max_length=120), - project: str | None = Query(default=None, max_length=160), + sector: str | None = Query( + default=None, + max_length=120, + description="Hard memory-sector filter; use for corpus isolation.", + ), + project: str | None = Query( + default=None, + max_length=160, + description="Entity-ranking hint only; not an isolation boundary. Use sector for isolation.", + ), as_of: str | None = Query(default=None, max_length=40), format: str = Query(default="json", pattern="^(json|markdown|smp)$"), model: str | None = Query(default=None, max_length=80), @@ -2532,26 +2532,29 @@ def get_context( # internal render format falls back to json in that path. model=None + text = byte-identical. response_format = "smp" if format == "smp" else "text" internal_format = "json" if format == "smp" else format - pack = store.assemble_context( - user_id, - task, - surface=surface, - token_budget=token_budget, - sector=sector, - project=project, - as_of=as_of, - intent=intent, - # Reaching this endpoint already required the read scope, and the identity layer is a read - # of distilled context — so it is always included here (parity with the local server's GET). - include_identity=True, - format=internal_format, - # Transport parity with POST /v1/context and the local server: a session_id turns on the - # per-session working-set delta channel (pay once per fact), and pin content-addresses the - # pack for later verification. Absent both, behavior is byte-identical to before. - pin=pin, - session_id=str(session_id or "") or None, - **_assemble_context_ext_kwargs(response_format, model), - ) + try: + pack = store.assemble_context( + user_id, + task, + surface=surface, + token_budget=token_budget, + sector=sector, + project=project, + as_of=as_of, + intent=intent, + # Reaching this endpoint already required the read scope, and the identity layer is a read + # of distilled context — so it is always included here (parity with the local server's GET). + include_identity=True, + format=internal_format, + # Transport parity with POST /v1/context and the local server: a session_id turns on the + # per-session working-set delta channel (pay once per fact), and pin content-addresses the + # pack for later verification. Absent both, behavior is byte-identical to before. + pin=pin, + session_id=str(session_id or "") or None, + **_assemble_context_ext_kwargs(response_format, model), + ) + except UnknownAgentSessionError as exc: + raise HTTPException(status_code=422, detail=str(exc)) from exc return _context_response(pack, internal_format) @@ -2560,23 +2563,26 @@ def post_context(body: ContextRequest, user_id: str = Depends(auth)) -> Any: format = body.format response_format = "smp" if format == "smp" else "text" internal_format = "json" if format == "smp" else format - pack = store.assemble_context( - user_id, - body.task, - surface=body.surface, - token_budget=body.token_budget, - sector=body.sector, - project=body.project, - as_of=body.as_of, - intent=body.intent, - # Identity/persona is distilled memory and follows the same read-scope policy as GET, - # standalone, MCP, and both SDKs (which use this POST transport). - include_identity=True, - format=internal_format, - pin=body.pin, - session_id=body.session_id, - **_assemble_context_ext_kwargs(response_format, body.model), - ) + try: + pack = store.assemble_context( + user_id, + body.task, + surface=body.surface, + token_budget=body.token_budget, + sector=body.sector, + project=body.project, + as_of=body.as_of, + intent=body.intent, + # Identity/persona is distilled memory and follows the same read-scope policy as GET, + # standalone, MCP, and both SDKs (which use this POST transport). + include_identity=True, + format=internal_format, + pin=body.pin, + session_id=body.session_id, + **_assemble_context_ext_kwargs(response_format, body.model), + ) + except UnknownAgentSessionError as exc: + raise HTTPException(status_code=422, detail=str(exc)) from exc return _context_response(pack, internal_format) diff --git a/backend/app/models.py b/backend/app/models.py index 4897cf9b..d1809ff7 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -1110,8 +1110,19 @@ class ContextRequest(BaseModel): surface: str = Field(default="agent", max_length=40) token_budget: int = Field(default=2000, ge=1, le=100_000) intent: str | None = Field(default=None, max_length=16) - sector: str | None = Field(default=None, max_length=120) - project: str | None = Field(default=None, max_length=160) + sector: str | None = Field( + default=None, + max_length=120, + description="Hard memory-sector filter. Use this field for corpus isolation.", + ) + project: str | None = Field( + default=None, + max_length=160, + description=( + "Entity-ranking hint used to enrich project context. This is not an access-control " + "or corpus-isolation boundary; use sector for isolation." + ), + ) as_of: str | None = Field(default=None, max_length=40) format: Literal["json", "markdown", "smp"] = "json" model: str | None = Field(default=None, max_length=80) diff --git a/backend/app/obsidian_writeback.py b/backend/app/obsidian_writeback.py index 3fc1ff18..b0078b5c 100644 --- a/backend/app/obsidian_writeback.py +++ b/backend/app/obsidian_writeback.py @@ -71,7 +71,9 @@ def safe_page_stem(name: str, fallback: str = "page") -> str: def person_page_filename(entity_id: str, name: str) -> str: """``--.md`` — slug for human browsing, hash so the page survives label edits/collisions (same scheme as the native vault's entity MOC pages).""" - short = hashlib.sha1(str(entity_id or "").encode("utf-8")).hexdigest()[:12] + short = hashlib.sha1( + str(entity_id or "").encode("utf-8"), usedforsecurity=False + ).hexdigest()[:12] return f"{safe_page_stem(name, 'person')}--{short}.md" diff --git a/backend/app/sharding.py b/backend/app/sharding.py index 8e53c334..5cdc7fc2 100644 --- a/backend/app/sharding.py +++ b/backend/app/sharding.py @@ -784,12 +784,11 @@ def assignment_for(self, user_id: str) -> ShardAssignment: def health_payload(self, *, mode: str, auth: bool) -> dict[str, Any]: payload = self.default_store.health_payload(mode=mode, auth=auth) payload["sharding"] = { - **self.router.payload(), + "mode": self.router.mode, + "shard_count": self.router.shard_count if self.router.mode == "bucket" else 1, "active_store_count": len(self._stores), - "default": self.assignment_for(self.default_user_id).as_dict(), + "default_shard_id": self.assignment_for(self.default_user_id).shard_id, } - if self.router.mode != "local": - payload["sharding"]["control_plane"] = self.control_plane_status() return payload def runtime_storage_status(self) -> dict[str, Any]: diff --git a/backend/app/standalone_server.py b/backend/app/standalone_server.py index bbd49b0c..7c600c4a 100644 --- a/backend/app/standalone_server.py +++ b/backend/app/standalone_server.py @@ -17,7 +17,7 @@ from typing import Any from urllib.parse import parse_qs, unquote, urlparse -from .config import APP_BRAND, load_settings +from .config import APP_BRAND, INSECURE_DEV_API_KEY, load_settings from .context_file import ( DEFAULT_STYLE as CONTEXT_FILE_DEFAULT_STYLE, SUPPORTED_STYLES as CONTEXT_FILE_SUPPORTED_STYLES, @@ -42,7 +42,7 @@ tools_for_scopes, ) from .sharding import StoreRegistry -from .storage import BACKEND_VERSION, ExportSizeLimitError +from .storage import BACKEND_VERSION, ExportSizeLimitError, UnknownAgentSessionError settings = load_settings() @@ -638,7 +638,8 @@ def _capture_confirmation(saved: dict) -> tuple[str, str]: class CortexRequestHandler(BaseHTTPRequestHandler): - server_version = f"{APP_BRAND}Standalone/0.1" + server_version = f"{APP_BRAND}Standalone" + sys_version = "" # Socket read timeout (seconds). Without it a client that declares a Content-Length but sends # fewer bytes would block the handler thread forever in rfile.read() while holding one of the # (default 8) concurrency-gate slots — 8 such stalls wedge the whole server. 30s sits safely @@ -752,28 +753,24 @@ def _dispatch(self, method: str, path: str, params: dict[str, list[str]]) -> Non self._send_text(ROOT_HTML, media_type="text/html") return if method == "GET" and path == "/health": - payload = store.health_payload(mode="standalone", auth=bool(settings.api_key)) - payload["hosted_readiness"] = _hosted_readiness_contract() - self._send_json(payload) + self._send_json(store.health_payload(mode="standalone", auth=bool(settings.api_key))) return if method == "GET" and path == "/ready": hosted_readiness = _hosted_readiness_contract() if hosted_readiness["status"] != "ok": self._send_json( - { - "detail": { - "status": "needs_configuration", - "hosted_readiness": hosted_readiness, - } - }, + {"detail": {"status": "not_ready", "check": "hosted_configuration"}}, status=HTTPStatus.SERVICE_UNAVAILABLE, ) return diagnostics = store.diagnostics(settings.default_user_id) if diagnostics["status"] != "ok": - self._send_json({"detail": diagnostics}, status=HTTPStatus.SERVICE_UNAVAILABLE) + self._send_json( + {"detail": {"status": "not_ready", "check": "storage"}}, + status=HTTPStatus.SERVICE_UNAVAILABLE, + ) else: - self._send_json({"status": "ok", "diagnostics": diagnostics, "hosted_readiness": hosted_readiness}) + self._send_json({"status": "ok", "backend_version": BACKEND_VERSION}) return if method == "GET" and path == "/.well-known/cortex.json": self._send_json({ @@ -2699,23 +2696,30 @@ def _dispatch(self, method: str, path: str, params: dict[str, list[str]]) -> Non # byte-identical to the prior behavior. response_format = "smp" if output_format == "smp" else "text" internal_format = "json" if output_format == "smp" else output_format - pack = store.assemble_context( - user_id, - task, - surface=surface, - token_budget=token_budget, - sector=sector, - project=project, - as_of=as_of, - intent=intent, - # Reaching /v1/context already required read scope; the identity layer is a read - # of distilled context, so it is always included. - include_identity=True, - format=internal_format, - pin=pin, - session_id=pin_session_id, - **_assemble_context_ext_kwargs(response_format, model), - ) + try: + pack = store.assemble_context( + user_id, + task, + surface=surface, + token_budget=token_budget, + sector=sector, + project=project, + as_of=as_of, + intent=intent, + # Reaching /v1/context already required read scope; the identity layer is a read + # of distilled context, so it is always included. + include_identity=True, + format=internal_format, + pin=pin, + session_id=pin_session_id, + **_assemble_context_ext_kwargs(response_format, model), + ) + except UnknownAgentSessionError as exc: + self._send_json( + {"detail": str(exc)}, + status=HTTPStatus.UNPROCESSABLE_ENTITY, + ) + return if internal_format == "markdown": self._send_text(pack, media_type="text/markdown") else: @@ -3600,21 +3604,33 @@ def _send_bytes(self, data: bytes, status: HTTPStatus = HTTPStatus.OK, media_typ def serve(host: str = "127.0.0.1", port: int | None = None) -> None: - if not _standalone_host_is_loopback(host) and os.environ.get( - "CORTEX_ALLOW_NONLOCAL_STANDALONE", "" - ).strip().lower() not in {"1", "true", "yes", "on"}: - raise ValueError( - "The dependency-light standalone server is local-only. Bind to a " - "loopback address or explicitly set CORTEX_ALLOW_NONLOCAL_STANDALONE=1 " - "after reviewing its local trust model." - ) + loopback = _standalone_host_is_loopback(host) + if not loopback: + if settings.api_key == INSECURE_DEV_API_KEY: + raise ValueError( + "The sample token 'dev-local-key' cannot be used on a non-loopback bind. " + "Set a long random CORTEX_API_KEY." + ) + if os.environ.get( + "CORTEX_ALLOW_NONLOCAL_STANDALONE", "" + ).strip().lower() not in {"1", "true", "yes", "on"}: + raise ValueError( + "The dependency-light standalone server is local-only. Bind to a " + "loopback address or explicitly set CORTEX_ALLOW_NONLOCAL_STANDALONE=1 " + "after reviewing its local trust model." + ) resolved_port = port or int(os.environ.get("CORTEX_PORT", "8766")) server = ThreadingHTTPServer((host, resolved_port), CortexRequestHandler) worker_thread = _start_standalone_worker() if worker_thread is not None: print(f"{APP_BRAND} standalone worker running for queued memory jobs", flush=True) print(f"{APP_BRAND} standalone backend running on http://{host}:{resolved_port}", flush=True) - server.serve_forever() + try: + server.serve_forever() + except KeyboardInterrupt: + print(f"\n{APP_BRAND} standalone backend stopped", flush=True) + finally: + server.server_close() def _standalone_host_is_loopback(host: str) -> bool: diff --git a/backend/app/storage.py b/backend/app/storage.py index 59911540..4fac996a 100644 --- a/backend/app/storage.py +++ b/backend/app/storage.py @@ -27,7 +27,7 @@ ) from .connectors._redaction import redact_error_message from .database import connect, sqlite_vec_status -from .embeddings import VECTOR_DIMENSIONS, configured_embedding_model, embed_text, embed_text_result, embedding_hash, embedding_json, embedding_source_text, embedding_status, warmup_embedding_provider +from .embeddings import VECTOR_DIMENSIONS, embed_text, embed_text_result, embedding_hash, embedding_index_fingerprint, embedding_json, embedding_source_text, embedding_status, warmup_embedding_provider from .http_security import open_same_origin from .query_plan import ( build_query_plan, @@ -78,6 +78,10 @@ class ExportSizeLimitError(ValueError): """The corpus is too large for an in-memory synchronous export.""" +class UnknownAgentSessionError(ValueError): + """A requested context-pack pin refers to no agent session for this user.""" + + BACKEND_FEATURES = ( "local-vault", "capture-surfaces", @@ -620,11 +624,13 @@ def _configured_rerank_weights() -> dict[str, float]: "did", "do", "does", + "for", "had", "has", "have", "how", "i", + "is", "me", "my", "or", @@ -635,6 +641,7 @@ def _configured_rerank_weights() -> dict[str, float]: "to", "usually", "we", + "who", "why", "you", "your", @@ -727,10 +734,66 @@ def _configured_rerank_weights() -> dict[str, float]: "told", "was", "were", + "who", "with", "you", "your", } + +# Small, reviewable concept groups close common product-language paraphrases +# when the dependency-free hash/FTS fallback is active. This is not a general +# thesaurus: each group is backed by adversarial retrieval fixtures and expands +# only the public query, never stored source text. +LEXICAL_CONCEPT_VARIANTS: dict[str, tuple[str, ...]] = { + "owner": ( + "accountable", + "accountability", + "dri", + "owner", + "ownership", + "responsible", + "responsibility", + ), + "release": ( + "launch", + "release", + "rollout", + "ship", + "shipping", + ), +} +_EXPLICIT_NON_ANSWER_RE = re.compile( + r"(?i)\b(?:" + r"does\s+not\s+name\s+(?:an?\s+)?owner|" + r"doesn't\s+name\s+(?:an?\s+)?owner|" + r"must\s+not\s+be\s+used\s+to\s+answer|" + r"not\s+(?:a\s+)?source\s+of\s+truth|" + r"template\s+(?:is\s+)?unassigned" + r")\b" +) + + +def _normalize_lexical_term(term: str) -> str: + clean = str(term or "").strip("_").lower() + if not clean: + return "" + if ( + clean == "dri" + or clean.startswith("accountab") + or clean.startswith("responsib") + or clean.startswith("owner") + ): + return "owner" + if ( + clean.startswith("launch") + or clean.startswith("release") + or clean.startswith("rollout") + or clean.startswith("ship") + ): + return "release" + if len(clean) > 3 and clean.endswith("s") and not clean.endswith(("ss", "ies")): + clean = clean[:-1] + return clean TASK_QUERY_STOPWORDS = { "action", "actions", @@ -3194,10 +3257,10 @@ def __init__(self, db_path, vault_path: str | Path | None = None): resolved_vault_path = Path(vault_path).expanduser() if vault_path else self.db_path.parent self.vault = CortexVault(resolved_vault_path, self.db_path) self.vault.ensure() - # Cache of the vector-index dimension we've reconciled to (see _ensure_vector_index). None - # until reconciled; lets us detect an embedding-model/dimension change and rebuild the + # Cache of the vector-index identity we've reconciled to (see _ensure_vector_index). None + # until reconciled; lets us detect an embedding-model/revision/dimension change and rebuild the # (rebuildable) vec table + re-embed rather than silently mismatching. - self._vector_index_ensured: int | None = None + self._vector_index_ensured: tuple[int, str] | None = None # Current-head Proof-of-Belief substrate. The cache key is a trigger-maintained per-user # memory-event revision, so direct SQL edits/deletes/inserts invalidate it too. self._belief_integrity_cache: dict[str, dict[str, Any]] = {} @@ -3211,14 +3274,14 @@ def __init__(self, db_path, vault_path: str | Path | None = None): self._prefetch_lock = _threading.Lock() self._prefetch_inflight_users: set[str] = set() self._prefetch_slots = _threading.BoundedSemaphore(self.SESSION_PREFETCH_MAX_INFLIGHT) - # Reconcile the vector index to the active model's dimension once, up front, on a dedicated - # connection (safe: not nested in any caller transaction). - self._ensure_vector_index() # Front-load the embedding model so the health surface is truthful before the first query: # if a model2vec store's model can't load, this trips the failure latch now (so /ready and - # search diagnostics report degraded and the vector-write gate drops to FTS-only) instead of - # leaving a lying "model2vec" state until the first real query silently falls back to hash. + # search diagnostics report degraded and the vector-write gate drops to FTS-only). The + # probe also records a custom model's actual native dimension before index reconciliation. warmup_embedding_provider() + # Reconcile the vector index to the warmed provider/model fingerprint once, up front, on a + # dedicated connection (safe: not nested in any caller transaction). + self._ensure_vector_index() # Store-owned lightweight migration (same duplicate-column-tolerant pattern as # database.MIGRATIONS): the memories dedup/occurrence semantics live here, so the # occurrences column is ensured here too. Legacy databases upgrade in place; every @@ -14225,9 +14288,7 @@ def _item_matched_query_terms(self, item: dict[str, Any], terms: list[str]) -> s clean = token.strip("_") if not clean: continue - if len(clean) > 3 and clean.endswith("s") and not clean.endswith(("ss", "ies")): - clean = clean[:-1] - item_terms.add(clean) + item_terms.add(_normalize_lexical_term(clean)) return {term for term in terms if any(candidate.startswith(term) for candidate in item_terms)} def _citation_metadata(self, item: dict[str, Any], provenance: dict[str, Any]) -> dict[str, Any]: @@ -15015,7 +15076,9 @@ def _fnv(text: str) -> int: continue seen_edges.add(key) canvas_edges.append({ - "id": hashlib.sha1(("|".join(key)).encode("utf-8")).hexdigest()[:16], + "id": hashlib.sha1( + ("|".join(key)).encode("utf-8"), usedforsecurity=False + ).hexdigest()[:16], "fromNode": self.vault.entity_moc_short_id(key[0]), "toNode": self.vault.entity_moc_short_id(key[1]), }) @@ -18615,6 +18678,11 @@ def assemble_context( self.require_agent_access(user_id, "read") task = str(task or "").strip()[:500] surface = str(surface or "agent").strip().lower()[:40] or "agent" + linked_session = str(session_id or "").strip() + if pin and linked_session: + # Validate an explicit pin linkage before cache reads, retrieval, reuse + # recording, or working-set mutation. A failed pin must be atomic. + self._require_agent_session(user_id, linked_session) try: token_budget = int(token_budget) except (TypeError, ValueError): @@ -18932,6 +19000,15 @@ def _pack_item(item: dict[str, Any]) -> dict[str, Any]: "citations": citations, "receipt": {"tool": "get_context", "audited": True, "event_kind": "context_pack"}, } + if project: + # `project` predates hard sector scoping and is only an entity-ranking + # hint. Make that visible in-band so clients cannot mistake its legacy + # location under `filters` for an authorization boundary. + result["filter_semantics"] = { + "sector": "hard_scope", + "project": "ranking_hint_not_isolation", + "as_of": "historical_cutoff", + } # Session working-set delta (build-plan #13): when the call carries a session_id, compute # the delta of THIS pack's cited ids versus what the session already knows and advance the # working-set. The delta rides the SMP envelope's `working_memory` field (or the warnings[] @@ -18939,7 +19016,6 @@ def _pack_item(item: dict[str, Any]) -> dict[str, Any]: # after `result` is built and never enters the content-addressed pack bytes. working_memory_delta: dict[str, Any] | None = None prefetch_centroid: dict[str, Any] = {} - linked_session = str(session_id or "").strip() if linked_session: cited_seq = [ {"ref": str(item.get("memory_id") or ""), "relevance": item.get("relevance")} @@ -18958,7 +19034,7 @@ def _pack_item(item: dict[str, Any]) -> dict[str, Any]: if pin: # Pin the underlying pack first so the SMP/markdown response references a real, # content-addressed artifact (the pin block is envelope metadata, excluded from the sha). - result["pin"] = self.pin_context_pack(user_id, result, session_id=session_id) + result["pin"] = self.pin_context_pack(user_id, result, session_id=linked_session or None) # Speculative trajectory + 1-hop-graph prefetch (build-plan #13): warm the hot-cache for the # likely next request on a background thread under a HARD deadline. Never blocks the reply. if linked_session and record_reuse: @@ -20470,8 +20546,6 @@ def health_payload(self, *, mode: str, auth: bool) -> dict[str, Any]: "health_contract": HEALTH_CONTRACT, "features": list(BACKEND_FEATURES), "mode": mode, - "db_path": str(self.db_path), - "vault_path": str(self.vault.root), "auth": auth, } @@ -25371,7 +25445,9 @@ def _require_agent_session(self, user_id: str, session_id: str) -> dict[str, Any (user_id, str(session_id or "").strip()), ).fetchone() if row is None: - raise ValueError("Unknown agent session; call start_agent_session first (ids look like asess_...).") + raise UnknownAgentSessionError( + "Unknown agent session; call start_agent_session first (ids look like asess_...)." + ) return self._agent_session_from_row(row) def _next_checkpoint_index(self, user_id: str, session_id: str) -> int: @@ -30070,7 +30146,7 @@ def _active_vector_dimensions(self, conn) -> int | None: # No meta recorded yet: init_db creates memory_vec at VECTOR_DIMENSIONS, so that is its size. return VECTOR_DIMENSIONS - def _record_vec_index_meta(self, conn, dimensions: int) -> None: + def _record_vec_index_meta(self, conn, dimensions: int, fingerprint: str) -> None: conn.execute( "CREATE TABLE IF NOT EXISTS vec_index_meta (id INTEGER PRIMARY KEY CHECK (id = 1), " "dimensions INTEGER NOT NULL, model TEXT NOT NULL, updated_at TEXT NOT NULL)" @@ -30079,7 +30155,7 @@ def _record_vec_index_meta(self, conn, dimensions: int) -> None: "INSERT INTO vec_index_meta(id, dimensions, model, updated_at) VALUES (1, ?, ?, ?) " "ON CONFLICT(id) DO UPDATE SET dimensions = excluded.dimensions, " "model = excluded.model, updated_at = excluded.updated_at", - (int(dimensions), configured_embedding_model(), now_iso()), + (int(dimensions), fingerprint, now_iso()), ) def _ensure_vector_index(self) -> None: @@ -30090,7 +30166,9 @@ def _ensure_vector_index(self) -> None: cache (the vault is the source of truth), so a dimension change drops + recreates it and re-embeds every memory in the background. No-op when sqlite-vec is unavailable.""" desired = int(embedding_status()["dimensions"]) - if self._vector_index_ensured == desired: + fingerprint = embedding_index_fingerprint(desired) + desired_identity = (desired, fingerprint) + if self._vector_index_ensured == desired_identity: return try: with connect(self.db_path) as conn: @@ -30100,7 +30178,9 @@ def _ensure_vector_index(self) -> None: "CREATE TABLE IF NOT EXISTS vec_index_meta (id INTEGER PRIMARY KEY CHECK (id = 1), " "dimensions INTEGER NOT NULL, model TEXT NOT NULL, updated_at TEXT NOT NULL)" ) - meta_row = conn.execute("SELECT dimensions FROM vec_index_meta WHERE id = 1").fetchone() + meta_row = conn.execute( + "SELECT dimensions, model FROM vec_index_meta WHERE id = 1" + ).fetchone() table_exists = conn.execute( "SELECT 1 FROM sqlite_master WHERE name = 'memory_vec'" ).fetchone() is not None @@ -30109,21 +30189,20 @@ def _ensure_vector_index(self) -> None: else: # init_db creates memory_vec at VECTOR_DIMENSIONS; absent meta = still that size. current = VECTOR_DIMENSIONS if table_exists else None - if table_exists and current == desired: - if not (meta_row and meta_row[0]): - self._record_vec_index_meta(conn, desired) - conn.commit() - self._vector_index_ensured = desired + stored_fingerprint = str(meta_row[1] or "") if meta_row else "" + if table_exists and current == desired and stored_fingerprint == fingerprint: + self._vector_index_ensured = desired_identity return - # Rebuild the vector table at the new dimension; drop the now-incompatible vectors. + # Rebuild when either dimensions OR the vector-space fingerprint changed. Equal + # dimensions are not compatible across providers/models/revisions. conn.execute("DROP TABLE IF EXISTS memory_vec") conn.execute(f"CREATE VIRTUAL TABLE memory_vec USING vec0(embedding float[{int(desired)}])") conn.execute("DELETE FROM memory_vec_map") - self._record_vec_index_meta(conn, desired) + self._record_vec_index_meta(conn, desired, fingerprint) conn.commit() # Mark ensured BEFORE enqueuing: the enqueue path calls _vector_ready (pure read), # which now matches the new dims, so nothing recurses back here. - self._vector_index_ensured = desired + self._vector_index_ensured = desired_identity self._enqueue_reembed_all(conn) conn.commit() except sqlite3.Error: @@ -30358,7 +30437,11 @@ def _lexical_fallback_search( terms = self._lexical_fallback_terms(query) if not terms: return [] - match_query = " OR ".join(f"{term}*" for term in terms) + match_query = " OR ".join( + f"{variant}*" + for term in terms + for variant in LEXICAL_CONCEPT_VARIANTS.get(term, (term,)) + ) filters, params = self._memory_filters( user_id, user_settings, @@ -30406,7 +30489,8 @@ def _lexical_fallback_search( + self._source_quality_boost(row, source_policies) + self._recency_boost(row, now=now) + self._importance_boost(row) - + self._confidence_boost(row), + + self._confidence_boost(row) + + self._explicit_non_answer_penalty(row, terms), } ) return [item["row"] for item in sorted(ranked, key=lambda item: item["score"], reverse=True)] @@ -30443,6 +30527,7 @@ def _fuse_search_rows( # never called and plan_entities stays empty, so _entity_overlap_boost returns 0.0 and every # fused score is bit-for-bit unchanged (retrieval_eval stays byte-identical). plan_entities = {e.lower() for e in build_query_plan(query).entities} if entity_boost_enabled() else set() + query_terms = self._lexical_fallback_terms(query, limit=12) for entry in ranked.values(): entry["score"] += self._layer_boost(entry["row"], layer_boosts) entry["score"] += self._temporal_boost(entry["row"], temporal_prefixes) @@ -30451,6 +30536,7 @@ def _fuse_search_rows( entry["score"] += self._importance_boost(entry["row"]) entry["score"] += self._confidence_boost(entry["row"]) entry["score"] += self._entity_overlap_boost(entry["row"], plan_entities) + entry["score"] += self._explicit_non_answer_penalty(entry["row"], query_terms) return [item["row"] for item in sorted(ranked.values(), key=lambda item: item["score"], reverse=True)[:limit]] def _rank_rows_with_layer_boosts( @@ -31193,6 +31279,22 @@ def _row_value(self, row: Any, key: str, default: Any = None) -> Any: return row.get(key, default) return default + def _explicit_non_answer_penalty(self, row: Any, query_terms: list[str]) -> float: + """Demote boilerplate that explicitly disclaims being an answer. + + The check is intentionally narrow and applies only to ownership queries. + A legitimate record saying an owner is unassigned remains retrievable; + a template saying it must not answer the question loses to source + evidence that names the DRI. + """ + if "owner" not in query_terms: + return 0.0 + text = " ".join( + str(self._row_value(row, field) or "") + for field in ("content", "summary") + ) + return -0.05 if _EXPLICIT_NON_ANSWER_RE.search(text) else 0.0 + def _lexical_fallback_terms(self, query: str, *, limit: int = 8) -> list[str]: terms: list[str] = [] seen: set[str] = set() @@ -31200,9 +31302,7 @@ def _lexical_fallback_terms(self, query: str, *, limit: int = 8) -> list[str]: clean = token.strip("_") if not clean or clean in QUERY_LEXICAL_FALLBACK_STOPWORDS: continue - normalized_token = clean - if len(clean) > 3 and clean.endswith("s") and not clean.endswith(("ss", "ies")): - normalized_token = clean[:-1] + normalized_token = _normalize_lexical_term(clean) if len(normalized_token) < 3 or normalized_token in seen: continue seen.add(normalized_token) @@ -31226,9 +31326,7 @@ def _lexical_matched_terms(self, row: Any, terms: list[str]) -> set[str]: clean = token.strip("_") if not clean: continue - if len(clean) > 3 and clean.endswith("s") and not clean.endswith(("ss", "ies")): - clean = clean[:-1] - row_terms.add(clean) + row_terms.add(_normalize_lexical_term(clean)) return {term for term in terms if any(candidate.startswith(term) for candidate in row_terms)} def _relevance_terms(self, obj: Any, terms: list[str]) -> list[str]: @@ -31251,9 +31349,7 @@ def _relevance_terms(self, obj: Any, terms: list[str]) -> list[str]: clean = token.strip("_") if not clean: continue - if len(clean) > 3 and clean.endswith("s") and not clean.endswith(("ss", "ies")): - clean = clean[:-1] - row_terms.add(clean) + row_terms.add(_normalize_lexical_term(clean)) return sorted(term for term in terms if any(candidate.startswith(term) for candidate in row_terms)) def _annotate_search_relevance( @@ -33116,10 +33212,13 @@ def _fts_query(self, query: str) -> str: clean = token.strip("_") if not clean or clean in QUERY_FTS_STOPWORDS: continue - normalized_token = clean - if len(clean) > 3 and clean.endswith("s") and not clean.endswith(("ss", "ies")): - normalized_token = clean[:-1] + normalized_token = _normalize_lexical_term(clean) if normalized_token not in seen: seen.add(normalized_token) - normalized.append(normalized_token + "*") - return " ".join(normalized) + variants = LEXICAL_CONCEPT_VARIANTS.get(normalized_token) + normalized.append( + "(" + " OR ".join(f"{variant}*" for variant in variants) + ")" + if variants + else normalized_token + "*" + ) + return " AND ".join(normalized) diff --git a/backend/app/vault.py b/backend/app/vault.py index 27b32b48..b26efafc 100644 --- a/backend/app/vault.py +++ b/backend/app/vault.py @@ -732,7 +732,9 @@ def memory_note_short_id(self, memory_id: str) -> str: summary/content) so it is invariant across edits — that is what makes glob-by-suffix reliable. Sweeps that mutate/delete also confirm the parsed frontmatter id, so even an (astronomically unlikely) collision can at worst rewrite the same logical note.""" - return hashlib.sha1(str(memory_id or "").encode("utf-8")).hexdigest()[:12] + return hashlib.sha1( + str(memory_id or "").encode("utf-8"), usedforsecurity=False + ).hexdigest()[:12] def memory_note_stem(self, record: dict[str, Any]) -> str: """`--` — the SINGLE source of both the on-disk note filename and the @@ -877,7 +879,9 @@ def entity_moc_short_id(self, entity_id: str) -> str: disambiguator and by the stale-page sweep. 16 hex = 64 bits: birthday-collision-safe well past any realistic entity count (an 8-hex/32-bit id collides around tens of thousands of entities and would clobber another entity's page).""" - return hashlib.sha1(str(entity_id or "").encode("utf-8")).hexdigest()[:16] + return hashlib.sha1( + str(entity_id or "").encode("utf-8"), usedforsecurity=False + ).hexdigest()[:16] def entity_moc_stem(self, entity_id: str, label: str | None = None) -> str: """The MOC note filename stem — the SINGLE source of the entity->entity wikilink target and diff --git a/backend/requirements.lock b/backend/requirements.lock new file mode 100644 index 00000000..4f3c6d5f --- /dev/null +++ b/backend/requirements.lock @@ -0,0 +1,531 @@ +# +# This file is autogenerated by pip-compile with Python 3.12 +# by the following command: +# +# make lock-python +# +annotated-doc==0.0.5 \ + --hash=sha256:117bac03a25ede5df5440e855b32d556049ca169ead221505badf432fed4b101 \ + --hash=sha256:c7e58ce09192557605d8bbd92836d7e1d520ac9580096042c0bfd197efacf1bb + # via fastapi +annotated-types==0.8.0 \ + --hash=sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7 \ + --hash=sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0 + # via pydantic +anthropic==0.120.2 \ + --hash=sha256:0f0bc2b381dc0eb41c8d886b815d79c2041cd2374f83aed36f574b6dc9c579c1 \ + --hash=sha256:9722efc10c27a30a69f5338ddacdb35bc6a64297a4e4ba729bf83af873d5fb3a + # via -r backend/requirements.txt +anyio==4.14.2 \ + --hash=sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494 \ + --hash=sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f + # via + # anthropic + # httpx + # starlette +argon2-cffi==25.1.0 \ + --hash=sha256:694ae5cc8a42f4c4e2bf2ca0e64e51e23a040c6a517a85074683d3959e1346c1 \ + --hash=sha256:fdc8b074db390fccb6eb4a3604ae7231f219aa669a2652e0f20e16ba513d5741 + # via -r backend/requirements.txt +argon2-cffi-bindings==25.1.0 \ + --hash=sha256:1db89609c06afa1a214a69a462ea741cf735b29a57530478c06eb81dd403de99 \ + --hash=sha256:1e021e87faa76ae0d413b619fe2b65ab9a037f24c60a1e6cc43457ae20de6dc6 \ + --hash=sha256:21378b40e1b8d1655dd5310c84a40fc19a9aa5e6366e835ceb8576bf0fea716d \ + --hash=sha256:2630b6240b495dfab90aebe159ff784d08ea999aa4b0d17efa734055a07d2f44 \ + --hash=sha256:3c6702abc36bf3ccba3f802b799505def420a1b7039862014a65db3205967f5a \ + --hash=sha256:3d3f05610594151994ca9ccb3c771115bdb4daef161976a266f0dd8aa9996b8f \ + --hash=sha256:473bcb5f82924b1becbb637b63303ec8d10e84c8d241119419897a26116515d2 \ + --hash=sha256:5acb4e41090d53f17ca1110c3427f0a130f944b896fc8c83973219c97f57b690 \ + --hash=sha256:5d588dec224e2a83edbdc785a5e6f3c6cd736f46bfd4b441bbb5aa1f5085e584 \ + --hash=sha256:6dca33a9859abf613e22733131fc9194091c1fa7cb3e131c143056b4856aa47e \ + --hash=sha256:7aef0c91e2c0fbca6fc68e7555aa60ef7008a739cbe045541e438373bc54d2b0 \ + --hash=sha256:84a461d4d84ae1295871329b346a97f68eade8c53b6ed9a7ca2d7467f3c8ff6f \ + --hash=sha256:87c33a52407e4c41f3b70a9c2d3f6056d88b10dad7695be708c5021673f55623 \ + --hash=sha256:8b8efee945193e667a396cbc7b4fb7d357297d6234d30a489905d96caabde56b \ + --hash=sha256:a1c70058c6ab1e352304ac7e3b52554daadacd8d453c1752e547c76e9c99ac44 \ + --hash=sha256:a98cd7d17e9f7ce244c0803cad3c23a7d379c301ba618a5fa76a67d116618b98 \ + --hash=sha256:aecba1723ae35330a008418a91ea6cfcedf6d31e5fbaa056a166462ff066d500 \ + --hash=sha256:b0fdbcf513833809c882823f98dc2f931cf659d9a1429616ac3adebb49f5db94 \ + --hash=sha256:b55aec3565b65f56455eebc9b9f34130440404f27fe21c3b375bf1ea4d8fbae6 \ + --hash=sha256:b957f3e6ea4d55d820e40ff76f450952807013d361a65d7f28acc0acbf29229d \ + --hash=sha256:ba92837e4a9aa6a508c8d2d7883ed5a8f6c308c89a4790e1e447a220deb79a85 \ + --hash=sha256:c4f9665de60b1b0e99bcd6be4f17d90339698ce954cfd8d9cf4f91c995165a92 \ + --hash=sha256:c87b72589133f0346a1cb8d5ecca4b933e3c9b64656c9d175270a000e73b288d \ + --hash=sha256:d3e924cfc503018a714f94a49a149fdc0b644eaead5d1f089330399134fa028a \ + --hash=sha256:da0c79c23a63723aa5d782250fbf51b768abca630285262fb5144ba5ae01e520 \ + --hash=sha256:e2fd3bfbff3c5d74fef31a722f729bf93500910db650c925c2d6ef879a7e51cb + # via argon2-cffi +certifi==2026.7.22 \ + --hash=sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775 \ + --hash=sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55 + # via + # httpcore + # httpx +cffi==2.1.0 \ + --hash=sha256:02cb7ff33ded4f1532476731f89ede53e2e488a8e6205515a82144246ffa7dcc \ + --hash=sha256:03e9810d18c646077e501f661b682fbf5dee4676048527ca3cffe66faa9960dd \ + --hash=sha256:0520e1f4c35f44e209cbbb421b67eec42e6a157f59444dfb6058874ff3610e5d \ + --hash=sha256:0582a58f3051372229ca8e7f5f589f9e5632678208d8636fea3676711fdf7fe5 \ + --hash=sha256:0611e7ebf90573a535ebdc33ae9da222d037853983e13359f580fab781ca017f \ + --hash=sha256:0a42c688d19fca6e095a53c6a6e2295a5b050a8b289f109adab02a9e61a25de6 \ + --hash=sha256:0a96b74cda968eebbad56d973efe5098974f0a9fb323865bf99ea1fd24e3e64c \ + --hash=sha256:10537b1df4967ca26d21e5072d7d54188354483b91dc75058968d3f0cf13fbda \ + --hash=sha256:11b3fb55f4f8ad92274ed26705f65d8f91457de71f5380061eb6d125a768fecd \ + --hash=sha256:15faec4adfff450819f3aee0e2e02c812de6edb88203aa58807955db2003472a \ + --hash=sha256:164bff1657b2a74f0b6d54e11c9b375bc97b931f2ca9c43fcf875838da1570dd \ + --hash=sha256:1854b724d00f6654c742097d5387569021be12d3a0f770eae1df8f8acfcc6acd \ + --hash=sha256:19c54ac121cad98450b4896fa9a43ee0180d57bc4bc911a33db6cab1efab6cd3 \ + --hash=sha256:1b96bfe2c4bd825681b7d311ad6d9b7280a091f43e8f63da5729638083cd3bfb \ + --hash=sha256:1e9f50d192a3e525b15a75ab5114e442d83d657b7ec29182a991bc9a88fd3a66 \ + --hash=sha256:1ff3456eab0d889592d1936d6125bbfbc7ae4d3354a700f8bd80450a66445d4d \ + --hash=sha256:2282cd5e38aa8accd03e99d1256af8411c84cdbee6a89d841b563fdbd1f3e50f \ + --hash=sha256:276f20fffd7b396e12516ba8edf9509210ac248cbbc5acbc39cd512f9f59ebe6 \ + --hash=sha256:2b71d409cccee78310ab5dec549aed052aaea483346e282c7b02362596e01bb0 \ + --hash=sha256:2e9dabb9abcb7ad15938c7196ad5c1718a4e6d33cc79b4c0209bdb64c4a54a5c \ + --hash=sha256:30b65779d598c370374fefabf138d456fd6f3216bfa7bedfab1ba82025b0cd93 \ + --hash=sha256:33eb1ad83ebe8f313e0df035c406227d55a79456704a863fad9842136af5ad7d \ + --hash=sha256:35aaea0c7ee0e58a5cd8c2fd1a48fdf7ece0d2699b7ecdda08194e9ce5dd9b3d \ + --hash=sha256:3681e031db29958a7502f5c0c9d6bbc4c36cb20f7b104086fa642d1799631ff8 \ + --hash=sha256:379de10ce1ba048b1448599d1b37b24caee16309d1ac98d3982fc997f768700b \ + --hash=sha256:37f525a7e7e50c017fdebe58b787be310ad59357ae43a053943a6e1a6c526001 \ + --hash=sha256:3b926723c13eba9f81d2ef3820d63aeceec3b2d4639906047bf675cb8a7a500d \ + --hash=sha256:3d7f118b5adbfdfead90c25822690b02bc8074fba949bb7858bec4ebd55adb43 \ + --hash=sha256:46b1c8db8f6122420f32d02fffb924c2fe9bc772d228c7c711748fff56aabb2b \ + --hash=sha256:47ff3a8bfd8cb9da1af7524b965127095055654c177fcfc7578debcb015eecd0 \ + --hash=sha256:4d433a51f1870e43a13b6732f92aaf540ff77c2015097c78556f75a2d6c030e0 \ + --hash=sha256:4f26194e3d95e06501b942642855aed4f953d55e95d7d01b7c4483db3ecff458 \ + --hash=sha256:510aeeeac94811b138077451da1fb18b308a5feab47dd2b603af55804155e1c8 \ + --hash=sha256:5972433ad71a9e46516584ef60a0fda12d9dc459938d1539c3ddecf9bdc1368d \ + --hash=sha256:5ecbd0499275d57506d397eebe1981cee87b47fcd9ef5c22cab7ed7644a39a94 \ + --hash=sha256:6274dcb2d15cef48daa73ed1be5a40d501d74dccd0cd6db364776d12cb6ba022 \ + --hash=sha256:63960549e4f8dc41e31accb97b975abaecfc44c03e396c093a6436763c2ea7db \ + --hash=sha256:64c753a0f87a256020004f37a1c8c02c480e725f910f0b2a0f3f07debd1b2479 \ + --hash=sha256:6af371f3767faeffc6ac1ef57cdfd25844403e9d3f476c5537caee499de96376 \ + --hash=sha256:6ca4919c6e4f89aa99c42510b42cf54596892c00b3f9077f6bdd1505e24b9c8d \ + --hash=sha256:6d194185eabd279f1c05ebe3504265ddfc5ad2b58d0714f7db9f01da592e9eb6 \ + --hash=sha256:702c436735fbe99d59ada02a1f65cfc0d31c0ee8b7290912f8fbc5cd1e4b16c3 \ + --hash=sha256:716ff8ec22f20b4d988b12884086bcef0fc99737043e503f7a3935a6be99b1ea \ + --hash=sha256:762f99479dcb369f60ab9017ad4ab97a36a1dd7c1ee5a3b15db0f4b8659120cd \ + --hash=sha256:7762faa47e8ff7eb80bd261d9a7d8eea2d8baa69de5e95b70c1f338bbe712f02 \ + --hash=sha256:78474632761faa0fb96f30b1c928c84ebcf68713cbb80d15bab09dfe61640fde \ + --hash=sha256:799416bae98336e400981ff6e532d67d5c709cfb30afb79865a1315f94b0e224 \ + --hash=sha256:7d034dcffa09e9a46c93fa3a3be402096cb5354ac6e41ab8e5cc9cd8b642ad76 \ + --hash=sha256:7d28dff1db6764108bc30788d85d61c876beff416d9a49cb9dd7c5a9f34f5804 \ + --hash=sha256:7d3538f9c0e50670f4deb93dbb696576e60590369cae2faf7de681e597a8a1f1 \ + --hash=sha256:7d5980a3433d4b71a5e120f9dd551403d7824e31e2e67124fe2769c404c06913 \ + --hash=sha256:7ea6b3e2c4250ff1de21c630fe72d0f63eb95c2c32ffbf64a358cf4a8836d714 \ + --hash=sha256:86cf8755a791f72c85dc287128cc62d4f24d392e3f1e15837245623f4a33cccc \ + --hash=sha256:88023dfe18799507b73f1dbb0d14326a17465de1bc9c9c7655c22845e9ddc3a2 \ + --hash=sha256:89095c1968b4ba8285840e131bf2891b09ae137fe2146905acae0354fbce1b5e \ + --hash=sha256:8d35c139744adb3e727cd51b1a18324bbe44b8bd41bf8322bca4d41289f48eda \ + --hash=sha256:8e74a6135550c4748af665b1b1118b6aab33b1fc6a16f9aff630af107c3b4512 \ + --hash=sha256:8f9ec95b8a043d3dfbc74d9abc6f7baf524dd27a8dc160b0a32ff9cdab650c28 \ + --hash=sha256:90bec57cf82089383bd06a605b3eb8daebf7e5a668520beaf6e327a83a947699 \ + --hash=sha256:95f2954c2c9473d892eca6e0409f3568b37ab62a8eedb122461f73cc273476e3 \ + --hash=sha256:961be50688f7fba2fa65f63712d3b9b341a22311f5253460ce933f52f0de1c8c \ + --hash=sha256:98fff996e983a36d3aa2eca83af40c5821202e7e6f32d13ae94e3d2286f10cfe \ + --hash=sha256:9b8f0f26ca4e7513c534d351eca551947d053fac438f2a04ac96d882909b0d3a \ + --hash=sha256:9d72af0cf10a76a600a9690078fe31c63b9588c8e86bf9fd353f713c84b5db0f \ + --hash=sha256:9d8272c0e483b024e1b9ad029821470ed8ec65631dbd90217469da0e7cd89f1c \ + --hash=sha256:a016194dbe13d14ee9556e734b772d8d67b947092b268d757fd4290e3ba2dfc2 \ + --hash=sha256:a5781494d4d400a3f47f8f1da94b324f6e6b440a53387774002890a2a2f4b50f \ + --hash=sha256:a95b05f9baf29b91171b3a8bd2020b028835243e7b0ff6bb23e2a3c228518b1b \ + --hash=sha256:aa7a1b53a2a4452ada2d1b5dade9960b2522f1e61293a811a077439e39029565 \ + --hash=sha256:ac0f1a2d0cfa7eea3f2aaf006ab6e70e8feeb16b75d65b7e5939982ca2f11056 \ + --hash=sha256:af5e2915d41fe6c961694d7bfdc8562942638200f3ce2765dfb8b745cf997629 \ + --hash=sha256:b6422532152adf4e59b110cb2808cee7a033800952f5c036b4af047ee43199e7 \ + --hash=sha256:b65f590ef2a44640f9a05dbb548a429b4ade77913ce683ac8b1480777658a6c0 \ + --hash=sha256:ba00f661f8ba35d075c937174e27c2c421cec3942fd2e0ea3e66996757c0fdd9 \ + --hash=sha256:bccbbb5ee76a61f9d99b5bf3846a51d7fca4b6a732fe46f89295610edaf41853 \ + --hash=sha256:bf01d8c84cbea96b944c73b22182e6c7c432b3475632b8111dbfdc95ddad6e13 \ + --hash=sha256:bf5c6cf48238b0eb4c086978c492ad1cbc22373fc5b2d7353b3a598ce6db887a \ + --hash=sha256:c16914df9fb7f500e440e6875fa23ff5e0b31db01fa9c06af98d59a91f0dc2e4 \ + --hash=sha256:c351efb95e832a853a29361675f33a7ce53de1a109cd73fd47af0712213aa4ce \ + --hash=sha256:c4165821e131d6d4ca444347c2b694e2311bcfa3fe5a861cc72968f28867beac \ + --hash=sha256:c5f5df567f6eb216de69be06ce55c8b714090fae02b18a3b40da8163b8c5fa9c \ + --hash=sha256:c941bb58d5a6e1c3892d86e42927ed6c180302f07e6d395d08c416e594b98b46 \ + --hash=sha256:c97f080ea627e2863524c5af3836e2270b5f5dfff1f104392b959f8df0c5d384 \ + --hash=sha256:cb96698e3c7413d906ce83f8ffd245ec1bd94707541f299d0ce4d6b0193e982b \ + --hash=sha256:cbb7640ce37159548d2147b5b8c241f962143d4c71231431820783f4dc78f210 \ + --hash=sha256:cdf2448aab5f661c9315308ec8b93f4e8a1a67a3c733f8631067a2b67d5913dc \ + --hash=sha256:d2117334c3af3bdcb9a88522b844a2bdb5efdc4f71c6c822df55486ae1c3347a \ + --hash=sha256:d53d10f7da99ae46f7373b9150393e9c5eab9b224909982b43832668de4779f5 \ + --hash=sha256:d9fafc5aa2e2a39aaf7f8cc0c1f044a9b07fca12e558dca53a3cc5c654ad67a7 \ + --hash=sha256:db3eb7d46527159a878ec3460e9d40615bc25ba337d477db681aea6e4f05c5d2 \ + --hash=sha256:dbf7c7a88e2bac086f06d14577332760bdeecc42bdec8ac4077f6260557d9326 \ + --hash=sha256:df2b82571a1b30f58a87bf4e5a9e78d2b1eff6c6ce8fd3aa3757221f93f0863f \ + --hash=sha256:df92f2aba50eb4d96718b68ef76f2e57a57b54f2fa62333496d16c6d585a85ca \ + --hash=sha256:eb4e8997a49aa2c08a3e43c9045d224448b8941d88e7ac163c7d383e560cbf98 \ + --hash=sha256:efc1cdd798b1aaf39b4610bba7aad28c9bea9b910f25c784ccf9ec1fa719d1f9 \ + --hash=sha256:f146d154428a2523f9cc7936c02353c2459b8f6cf07d3cd1ee1c0a611109c5d5 \ + --hash=sha256:f5bce581e6b8c235e566a14768a943b172ada3ed73537bb0c0be1edee312d4e7 \ + --hash=sha256:f9912624a0c0b834b7520d7769b3644453aabc0a7e1c839da7359f050750e9bc \ + --hash=sha256:fb62edb5bb52cca65fab91a63afa7561607120d26090a7e8fda6fb9f064726da \ + --hash=sha256:ff067a8d8d880e7809e4ac88eb009bb848870115317b306666502ccad30b147f + # via + # argon2-cffi-bindings + # cryptography +click==8.4.2 \ + --hash=sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6 \ + --hash=sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76 + # via uvicorn +cryptography==49.0.0 \ + --hash=sha256:026ac7423e6fa66872d3bf889be5974507da3944f866f704fa200eadacd00001 \ + --hash=sha256:07cab27cc7b7e0fd28e5e26bb9eeedde5c135c868b46de4a27845abe94af6122 \ + --hash=sha256:084ef1af862eb07ec46d25f68689f2102a9fc0e05ce7b80f14f5fe51e4eef0f6 \ + --hash=sha256:0b82e28ee398a386f0807bba7884d30f25218855690f45115831bcce5d90822c \ + --hash=sha256:0e959b578856a3924bc0cbb710fc12c387b9412a951389f3ca61704a9e25f325 \ + --hash=sha256:0f21641cf4b30fca7aee061ced0ec7ad7b073518088b7c9969a297c0ae796c69 \ + --hash=sha256:196ecd6a36e4e9aa10270393bb98d8df88fccee0bf1e5128b91ae4eb4375896d \ + --hash=sha256:2400ef9c9e2299a25614eb1dea3db54a69b1349efd043bfac9c67630d136df36 \ + --hash=sha256:28d8b15e6275f12c8a207dc309dfa957903c927d08d0cc937ee3f63f200693cc \ + --hash=sha256:2afe9051da7ae7bd5905da5a949280c7d2bb75682e188f650a9d0f2756b834c6 \ + --hash=sha256:2eda353d8a27bcbcaa4cbed18994a74ab4d19a2ca897db188ea269ab9b71419b \ + --hash=sha256:32703d93296f5c1f4b53349ad3a250c2cae0fdecd3a3dd5d47e616d8d616af27 \ + --hash=sha256:33cd0565932807baddb67b96dbee92f2c374b5c89dee09fd74079aeb8c8dba61 \ + --hash=sha256:35b151772baff2c74cba7fa290ceaff4c3b11c0c881eb93eb5dbc05a7cfbba18 \ + --hash=sha256:36d1709f992593689b45bda411498d62c6e365f2ca00b84657d4dadd24de16db \ + --hash=sha256:42b0684e0e40cf26122427802486f6d93aea593612603a94fbf260c7eb1e9c1b \ + --hash=sha256:4ae387c9cb68ea569ca17e490d66d8142b81c3cc814bf179974b7d146e490bbb \ + --hash=sha256:53ecee2e23f7169b6117e99fc8a944e5e50f79e69758a83b52a00cb98ab2b2d2 \ + --hash=sha256:66ec79c3904820572d7e987abdf304281f141d37ad9a489b8e97066e7b9b6459 \ + --hash=sha256:67e1d20ad9ef3a563c59ef22e7a8a0b8210bd26604369ea4a30a7c66aefe504e \ + --hash=sha256:6f2debedf9ca60cf1d5bd466475638af5130f89965605cd818484d19987d3a21 \ + --hash=sha256:6fc361c34fb6aac015ce19435876635e5c6d21db31998b0920f675f131e043b8 \ + --hash=sha256:73a205dce83953d131a4aa1e0fd917a2fd1c5b1eef251e9d7152efefcbf5caf7 \ + --hash=sha256:7abcee80084cda3f7691f3eb1ce480d8df49cec637b429aa35986c1de71738aa \ + --hash=sha256:8c25ceb16df5b9435f3f6a9829204985b0e0cbee3b48aacd432c7d2c850b44d9 \ + --hash=sha256:966fe0e9c67490071f14c0d2b1cb2dfb3023c5ce39457343931415f08382f2db \ + --hash=sha256:9e82dcc8e56052715fb18b2429e3bca4823b1629136a2084fc45a9a5cecb9b64 \ + --hash=sha256:b20133d204d2bb56ba047642199603876c872026ca53e79c35b83772ab2cc505 \ + --hash=sha256:b39efa323140595abd3ecca8529d321ae50f55f3aa3ba9cc81ea56a6011953d5 \ + --hash=sha256:b47db11c2c3525083296069b98ac5221907455e989ae0c2e3008bde851921615 \ + --hash=sha256:b87e65d263b3e5d3bb92a57e2a6638e2f31110fa7aa890c7b2dbba42248d0a3f \ + --hash=sha256:b970c6da94d5bb18629db453d14f2a1300f6bf59b61e9b82377931ef95504866 \ + --hash=sha256:be9fcb48a55f023493482827d4f459bd263cc20efde64f204b97c123201850c6 \ + --hash=sha256:c2bc30226390d60ea19d9f82b19db005fe0452154a23c1c410c12ea801e43561 \ + --hash=sha256:c83782480a4a9da4d0feb51950131ba32e12e70813848b3343f6e18c28a66838 \ + --hash=sha256:cbc77da8c523d5abd028635ba850a6966fcee2c82e2bf65a41d1d8afe0f98be9 \ + --hash=sha256:ccac2bfebc306b862133e3bb71f3f6ee8bb525240089b2d952e4144b3a6d5da7 \ + --hash=sha256:d0527ce944105f257f605a827d6ebead966c752038b6e8656abb9c5edee6fc68 \ + --hash=sha256:d8ecde755e2e91bf773fc94e8c9d730cd7f2007004cb492263a794ec3899a1c8 \ + --hash=sha256:e3fb64c420688e5319ae25113a354015abbd8dffbfbc41781a1ea66fc7622ac3 \ + --hash=sha256:e5dfc1e64de5677cec922ffa8da89c546d0415bf6efdf081842e5d44c84e1f0e \ + --hash=sha256:ec5e529fb80935c94fe7b729f9972b50e351a0e6b50aa294fd5cabb109fcc29a \ + --hash=sha256:f37d847238971164fdbc68ade6f6574aecc9c0af714190e2083429ff68f4ce9d \ + --hash=sha256:f78ff2c9ed8dc2d036b0f4d640e22522213d047c1b14e61205a7e55c80a494d4 \ + --hash=sha256:f89660a348f4f78a92366240a61404e337586ef7f5909a2fef59ca88ef505493 \ + --hash=sha256:fc1e275c2f1d97b1a6450b8b0ea3ebfa6e087a611c2b26cb2404d48588abab7b + # via -r backend/requirements.txt +distro==1.9.0 \ + --hash=sha256:2fa77c6fd8940f116ee1d6b94a2f90b13b5ea8d019b98bc8bafdcabcdd9bdbed \ + --hash=sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2 + # via anthropic +docstring-parser==0.18.0 \ + --hash=sha256:292510982205c12b1248696f44959db3cdd1740237a968ea1e2e7a900eeb2015 \ + --hash=sha256:b3fcbed555c47d8479be0796ef7e19c2670d428d72e96da63f3a40122860374b + # via anthropic +fastapi==0.141.1 \ + --hash=sha256:bfb91aa2d334c61cb35ba9a116fc123b3d3df31640b801cf57a7a78ec3f603b3 \ + --hash=sha256:e8822fc40db1e1858054d7a949a888695bc9bdce70139178e33bd2871a453ca1 + # via -r backend/requirements.txt +h11==0.16.0 \ + --hash=sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1 \ + --hash=sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86 + # via + # httpcore + # uvicorn +httpcore==1.0.9 \ + --hash=sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55 \ + --hash=sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8 + # via httpx +httpx==0.28.1 \ + --hash=sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc \ + --hash=sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad + # via anthropic +idna==3.18 \ + --hash=sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2 \ + --hash=sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848 + # via + # anyio + # httpx +jiter==0.16.0 \ + --hash=sha256:0542a7189c26920778658fc8fcf2af8bae05bae9924577f71804acef37996536 \ + --hash=sha256:0758ab7747a984797cf048e8eedea1d8ef39d7994b25611daf5b48fc903e8873 \ + --hash=sha256:0df91907609837f33341b8e6fe73b95991fdaa57caf1a0fbd343dffe826f386f \ + --hash=sha256:0e2e9efbe042210df657bade597f66d6d75723e3d8f45a12ea6d8167ff8bbce3 \ + --hash=sha256:0fa25b09b13075c46f5bc174f2690525a925a4fc2f7c82969a2bbabff22386ce \ + --hash=sha256:10438939205546132189c8e74a2d536a707841f3a25cd7c74ee91fe503407a26 \ + --hash=sha256:12288303c9844e61e1651d02a9a6f6633e47d39f897d6991d1427161ce6b746e \ + --hash=sha256:17ca7fae79f6d99cd9a042b75f917eaada7b895cfc7dd2ee3a16089dcaec7a85 \ + --hash=sha256:1878349266f8ee36ecb1375cc5ba2f115f35fd9f0a1a4119e725e379126647f7 \ + --hash=sha256:1a0f05e229edb29e68cdd0ccb83cea13b64263416120cf943767a6fd72e6787f \ + --hash=sha256:2577196f4474ef3fc4779a088a23b0897bbf86f9ea3679c372d45b8383b43207 \ + --hash=sha256:26798522707abb47d767db536e4148ceac1b14446bf028ee85e579a2e043cfe5 \ + --hash=sha256:26d122613ada2b708eb714695446f40fce5bdf2edb4b02116dec62faa62dfab3 \ + --hash=sha256:2a0d46ef67cc58d906a6132dd3040ca70ae4f0b0d7c9c052fe432c658a69b3f6 \ + --hash=sha256:2a8e9e39cf083016137aa5cadafe3188adc2ba6ba1fbf1e5d18889ad3e9ad056 \ + --hash=sha256:2c842cbf374a8daf50b2c04212995bee34ca2ac2cdc29a901b4cdb072c9c4131 \ + --hash=sha256:2ed5738ae4af18271a51a528b8811b0cbfa4a1858de9d83359e4169855d6a331 \ + --hash=sha256:3126a5dbad56401989ac769aca0cb56005bfb3e2366eea0ca99d1a91c3c1ee03 \ + --hash=sha256:31aaee5b80f672c1dc21272bcfb9cbdcfc1ea04ff50f00ed5af500b80c44fa93 \ + --hash=sha256:3a23f0e4f957e1be65752d2dfac9a5a06b1917af8dc85deb639c3b9d02e31290 \ + --hash=sha256:3b21b412b899fd8bd51a3046934b59a3bb068b79f70a5c6010053ac77cc53f0c \ + --hash=sha256:3c1fd2dbe1b0af19e987f03fe66c5f5bd105a2229c1aff4ab14890b24f41d21a \ + --hash=sha256:3f4d9e473a5ce7d27fef8b848df4dc16e283893d3f53b4a585e72c9595f3c284 \ + --hash=sha256:3fb7bc819187b56dc48aa5c833aaf92257da8e07efdb9306156667bd2eeb491c \ + --hash=sha256:41977aa5654023948c2dae2a81cbf9c43343954bef1cd59a154dd15a4d84c195 \ + --hash=sha256:44d019fa8cdaf89bf29c71b39e3712143fdd0ac76725c6ef954f9957a5ea8730 \ + --hash=sha256:46add52f4ad47a08bfb1219f3e673da972191489a33016edefdb5ea55bfa8c48 \ + --hash=sha256:49060fd70737fad59d33ba9dcc0d83247dc9e77187de26053a19c16c9f32bd69 \ + --hash=sha256:491e7d072a253b156fff46b78bceac4652a697aa8d7082c9c18c03d7b7917d24 \ + --hash=sha256:4a1d68ff7ca1d3b5dee20a97a3decda7d5f15003823bf6d140c81f8561d3bc5c \ + --hash=sha256:51d7b836acb0108d7c77df1742332cac2a1fa04a74d6dacec46e7091f0e91274 \ + --hash=sha256:560b2cf3fb03240cd34f27409a238547488708f05b7c3924f571a60422251ec7 \ + --hash=sha256:561926e0573ffe4a32498420a76d64b16c513e1ab413b9d28158a8764ac701e5 \ + --hash=sha256:57b37fc887a32d44798e4d8ebfa7c9683ff3da1d5bf38f08d1bb3573ccb39106 \ + --hash=sha256:5a71d0d2014c3275043e1170bf3d4e771493cb0dcf07be54c567155f4d8ee64b \ + --hash=sha256:5ab4f50ff971b611d656554ea10b75f80097392c827bc32923c6eeb6386c8b00 \ + --hash=sha256:5af7780e4a26bd7d0d989592bf9ef12ebf806b74ab709223ecca37c749872ea9 \ + --hash=sha256:5cf109d010b4b05a105afb3d43be36a21322d345ad3111e13d15f680afef0e5b \ + --hash=sha256:5ed466aee31294d7cdcd4d37dfe5c42c97bc29d9a5f00eacf24504358309cb9b \ + --hash=sha256:616e89e008a93c01104161c75b4988e58716b01d62307ebfe161e52a56d2a818 \ + --hash=sha256:62c1b7fe1f77925acf5af68b6140b8810fa87dfd4dc0a9c8568ec2fa2a10429c \ + --hash=sha256:63efadc657488f45db1c676d81e704cac2abf3fdb892def1faea61db053127e2 \ + --hash=sha256:63ffdbdae7d4499f4cda14eadc12ddcabef0fc0c081191bdc2247489cb698077 \ + --hash=sha256:64c0203212098470032aabcde9356fc168f377aade3e43def61dfe17e92f2037 \ + --hash=sha256:64d613743df53199b1aa256a7d328340da6d7078aac7705a7db9d7a791e9cfd2 \ + --hash=sha256:659039cc50b5addcc35fcc87ae2c1833b7c0a8e5326ef631a75e4478447bcf84 \ + --hash=sha256:6722bcef4ffc86c835574b1b2fac6b33b9fb4a889c781e67950e891591f3c55a \ + --hash=sha256:67c3bc1760f8c99d805dcab4e644027142a53b1d5d861f18780ebdbd5d40b72a \ + --hash=sha256:67fddeda1688f0cce2d2ae83ccf8a80f79936f2d2997d6cc2261f82fdb54a4d3 \ + --hash=sha256:6edb63a46e65a82c26800a868e49b2cac30dd5a4218b88d74bc2c848c8ad60bb \ + --hash=sha256:70a490b55634dc0d2606ce8a8e01b1d62459011beb368d15d76e1eaf62460e3d \ + --hash=sha256:710cc51d4ebdcd3c1f70b232c1db1ea1344a075770422bbd4bede5708335acbe \ + --hash=sha256:741eed508c233a76313a1c7b001f8f21b82f14327e9196ae8bd29a2cc164ae84 \ + --hash=sha256:7b24c3492c5f4f84a37946ad9cf504910cf6a782d6a4e0689b6673c5894b4a1c \ + --hash=sha256:7c9610fd25ebccb43fca584136f5c2fbb26802447eccd430dfdbab95a0fd5126 \ + --hash=sha256:850ccb1d7eedb4200f4014b1c0e8a577de114fc3cd88faad646dcc9bc4bb12ad \ + --hash=sha256:8597d23c87f59294f83bcb6229b9ed1fccee13dbba967b46930d2f1759466fee \ + --hash=sha256:8d031aabecc4f1b6276adfb42e3aabb77c89d468bf616600e8d3a11328929053 \ + --hash=sha256:8d30a4a1c87713060c8d1cc59a7b6c8fb6b8ef0a6900368014c76c87922a2929 \ + --hash=sha256:8fb8de1e23a0cb2a7f53c335049c7b72b6db41aa6227cdcc0972a1de5cb39450 \ + --hash=sha256:8fc4d94713c4697347e38faf7d6ef91547c142219bdcfc7220c4870879974244 \ + --hash=sha256:96e38eea538c8ddf853a35727c7be0741c76c13f04148ac5c116222f50ece3b3 \ + --hash=sha256:9acf1b2faec82d998811ecce7ae84d9005e53410773e9d37d61cdc424ba4581b \ + --hash=sha256:9c8a956fd72c2cf1e730d01ea080341f13aa0a97a4a33b51abebe725b7ae9ca9 \ + --hash=sha256:9ec553a99b0987efd7a3645a1a825cf29c224e494db267a83369fcc8da9aeda5 \ + --hash=sha256:a111256a7193bea0759267b10385e5870949c239ed7b6ddbaaf57573edb38734 \ + --hash=sha256:a32d2027a9fa67f109ff245a3252ece3ccc32cc56703e1deab6cc846a59e0585 \ + --hash=sha256:a5c54ef4ff776d9675837ef535b3308d6e31c208d43ebc44a0f7ab8a208c68f7 \ + --hash=sha256:adbb8edeadd431bc4477879d5d371ece7cb1334486584e0f252656dd7ffada29 \ + --hash=sha256:adff21bc78edfe086c15eb495b900306076de378dc2337c132401fc39bd79c91 \ + --hash=sha256:b1e7923093a376d93c6eb507c77045ae258d689ba577392846a1b3f10d0b09a9 \ + --hash=sha256:b2c61484666ad42726029af0c00ef4541f0f3b5cdc550221f56c2343208018ee \ + --hash=sha256:b42e9ff5376819c053da25809a8d4b6fa6e473b4856ebe42e298ac958be3d7f9 \ + --hash=sha256:b72d0b2990ca754a9102779ac98d8597b7cb31678958562214a007f909eab78e \ + --hash=sha256:b7dc85ea77d4abbae8bad0d3538678aedee75bceec4e2f6c8dfb1c74772e5aa5 \ + --hash=sha256:b8a3f9a6008048fe9def7bf465180564a6e458047d2ce499149cfbe73c3ae9db \ + --hash=sha256:baad945ed47f163ad833314f8e3288c396118934f94e7bbb9e243ce4b341a4fd \ + --hash=sha256:bae96332410f866e5900d809298b1ed82735932986c672495f9701daacd80620 \ + --hash=sha256:bc837c1b9631be10abfe0191537fe8009838204cec7e44827401ace390ddb567 \ + --hash=sha256:bf3ea07d9bc8e7d03a9fbc051295462e6dbc295b894fd72457c3136e3e43d898 \ + --hash=sha256:c22a488f7b9218e245a0025a9ba6b100e2e54700831cf4cf16833a27fba3ad01 \ + --hash=sha256:c4b4717bdb35ae456f831a6b08d01880fff399887a6bbc526a583a406e484eea \ + --hash=sha256:c5fc4f8def331036a7b8e981b4347ebe409981edbc8308a5ea842b8c3614fa6c \ + --hash=sha256:c682bea068a90b764577bdb78a60a4c1d1606daf9cd4c893832a37c7cc9d9026 \ + --hash=sha256:c90c0f63df322be920eda6ce622e3083d8906ba267f8220fe7873213b8b4430e \ + --hash=sha256:c9c53be232c2e206ef9cdbad81a48bfa74c3d3f08bcf8124630a8a748aad993e \ + --hash=sha256:cbd18dd5e2df96b580487b5745adf57ef64ad89ba2d9662fc3c19386acce7db8 \ + --hash=sha256:cf0d73f50e7b6935677854f6e8e31d499ca7064dd24734f703e060f5b237d883 \ + --hash=sha256:d284fb8d94d5855d60c44fefcab4bf966f1da6fada73992b01f6f0c9bc0c6702 \ + --hash=sha256:d28bb3c26762358dadf3e5bf0bccd29ae987d65e6988d2e6f49829c76b003c09 \ + --hash=sha256:d5bf78d0e05e45cfdd66558893938d59afe3d1b1a824a202039b20e607d25a72 \ + --hash=sha256:d5f91b1c27fc22a57993d5a5cb8a627cb8ed4b10502716fac1ffbfe1d19d84e8 \ + --hash=sha256:d8f80521644426d451e70f00c7974240cab8f6ee088aedaa9af2697153ab7805 \ + --hash=sha256:da3d7ec75dc83bb18bca888b5edfae0656a26849056c59e05a7728badd17e7af \ + --hash=sha256:dab907db06fc593645e73109acf4581ba5b548897d28b9348dc41ddc8343b2d3 \ + --hash=sha256:de5ba8763e56b793561f43bed197c9ea55776daa5e9a6b91eed68a909bc9cdbf \ + --hash=sha256:e03a5f21a5ce96a9441b8cb32719a8b88ed5388f53e0f339c5bcf54f1317f9d0 \ + --hash=sha256:e34e97bda77eb63242a410243c071e28ac7e0d8c0948c5ee658498690a4b2f2f \ + --hash=sha256:e431cfc9caf44c1d5459ff77d4e64cbf85fddb6a35dad836a15c6a9ec23087c1 \ + --hash=sha256:eab2cd170150e70153de16896a1774e3a1dca80154c56b54d7a812c479a7165e \ + --hash=sha256:ee6162b77d49a9939229df666dfa8af3e656b6701b54c4c84966d740e189264e \ + --hash=sha256:f17d61a28b4b3e0e3e2ba98490c70501403b4d196f78732439160e7fd3678127 \ + --hash=sha256:f3bd327cdfa118bc1ce69c214c2678571d5bd39b8ccd0ebf43a54db00541ba9a \ + --hash=sha256:f4444a83f946605990c98f625cdd3d2725bfb818158760c5748c653170a20e0e \ + --hash=sha256:fb08c276dd02dac3a284acdd02cacc630d2e3cd6572a4b85519f35cbd133c3de + # via anthropic +pycparser==3.0 \ + --hash=sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29 \ + --hash=sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992 + # via cffi +pydantic==2.13.4 \ + --hash=sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba \ + --hash=sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6 + # via + # -r backend/requirements.txt + # anthropic + # fastapi +pydantic-core==2.46.4 \ + --hash=sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0 \ + --hash=sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262 \ + --hash=sha256:041bde0a48fd37cf71cab1c9d56d3e8625a3793fef1f7dd232b3ff37e978ecda \ + --hash=sha256:0c563b08bca408dc7f65f700633d8442fffb2421fc47b8101377e9fd65051ff0 \ + --hash=sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e \ + --hash=sha256:0ce40cd7b21210e99342afafbd4d0f76d784eb5b1d60f3bdc566be4983c6c73b \ + --hash=sha256:0e96592440881c74a213e5ad528e2b24d3d4f940de2766bed9010ab1d9e51594 \ + --hash=sha256:10e17cbb10a330363733efc4d7c4d0dd827ac0909b8f6a6542298fed1ea62f29 \ + --hash=sha256:133878133d271ade3d41d1bfb2a45ec38dbdbda40bc065921c6b04e4630127e2 \ + --hash=sha256:14d4edf427bdcf950a8a02d7cb44a08614388dd6e1bdcbf4f67504fa7887da9c \ + --hash=sha256:14f4c5d6db102bd796a627bbb3a17b4cf4574b9ae861d8b7c9a9661c6dd3362d \ + --hash=sha256:17299feefe090f2caa5b8e37222bb5f663e4935a8bfa6931d4102e5df1a9f398 \ + --hash=sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d \ + --hash=sha256:18e5ceec2ab67e6d5f1a9085e5a24c9c4e2ac4545730bfe668680bca05e555f3 \ + --hash=sha256:19e51f073cd3df251856a8a4189fbdf1de4012c3ebacfb1884f94f1eb406079f \ + --hash=sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb \ + --hash=sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7 \ + --hash=sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5 \ + --hash=sha256:228ee9bae8bef5b1e97ec58302f80357c37199e0d0a99174e138d28e6957b9d9 \ + --hash=sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462 \ + --hash=sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4 \ + --hash=sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b \ + --hash=sha256:2f84c03c8607173d16b5a854ec68a2f9079ae03237a54fb506d13af47e1d018d \ + --hash=sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df \ + --hash=sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2 \ + --hash=sha256:3447661d99f75a3683a4cf5c87da72f2161964611864dbbeac7fbb118bb4bfc0 \ + --hash=sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519 \ + --hash=sha256:395aebd9183f9d112f569aeb5b2214d1a10a33bec8456447f7fbdfa51d38d4cd \ + --hash=sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7 \ + --hash=sha256:3be77f45df024d789a672ae34f8b06fb346c4f9f46ea714956660ea4862e89ac \ + --hash=sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6 \ + --hash=sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565 \ + --hash=sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898 \ + --hash=sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb \ + --hash=sha256:432c179df7874eeb73307aad2df0755e1ae0efa61ff0ea89b93e194411ae3928 \ + --hash=sha256:4a05d69cba51d852c5c3e92758653245a50c0b646ced0cf05bd793ed592839d6 \ + --hash=sha256:4c63ebc82684aa89d9a3bcbd13d515b3be44250dc68dd3bd81526c1cb31286c3 \ + --hash=sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a \ + --hash=sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596 \ + --hash=sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987 \ + --hash=sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e \ + --hash=sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d \ + --hash=sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712 \ + --hash=sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008 \ + --hash=sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd \ + --hash=sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1 \ + --hash=sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be \ + --hash=sha256:66ce7632c22d837c95301830e111ad0128a32b8207533b60896a96c4915192ea \ + --hash=sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292 \ + --hash=sha256:6f2eeda33a839975441c86a4119e1383c50b47faf0cbb5176985565c6bb02c33 \ + --hash=sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3 \ + --hash=sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4 \ + --hash=sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b \ + --hash=sha256:7bfb192b3f4b9e8a89b6277b6ce787564f62cfd272055f6e685726b111dc7826 \ + --hash=sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac \ + --hash=sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7 \ + --hash=sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d \ + --hash=sha256:8358a950c8909158e3df31538a7e4edc2d7265a7c54b47f0864d9e5bae9dcebf \ + --hash=sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4 \ + --hash=sha256:86e1a4418c6cd97d60c95c71164158eaf7324fae7b0923264016baa993eba6fc \ + --hash=sha256:8b9bab013d1c7a79d3501ff86d0bc9c31bf587db4551677b96bec07df78c6b15 \ + --hash=sha256:8c5dac79fa1614d1e06ca695109c6105923bd9c7d1d6c918d4e637b7e6b32fd3 \ + --hash=sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b \ + --hash=sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914 \ + --hash=sha256:9037063db01f09b09e237c282b6792bd4da634b5402c4e7f0c61effed7701a04 \ + --hash=sha256:905a0ed8ea6f2d61c1738835f99b699348d7857379083e5fc497fa0c967a407c \ + --hash=sha256:90884113d8b48f760e9587002789ddd741e76ab9f89518cd1e43b1f1a52ec44b \ + --hash=sha256:91a06d2e259ecfbd8c901d70c3c507900458498142b3026a296b7de4d1322cc9 \ + --hash=sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce \ + --hash=sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4 \ + --hash=sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a \ + --hash=sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f \ + --hash=sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424 \ + --hash=sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894 \ + --hash=sha256:9bc519fbf2b7578398853d815009ae5e4d4603d12f4e3f91da8c06852d3da3e9 \ + --hash=sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76 \ + --hash=sha256:9f444c499b3eefd3a92e348059471ea0c3a6e303d9c1cec09fa748fd9f895201 \ + --hash=sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb \ + --hash=sha256:a0f62d0a58f4e7da165457e995725421e0064f2255d8eccebc49f41bbc23b109 \ + --hash=sha256:a396dcc17e5a0b164dbe026896245a4fa9ff402edca1dff0be3d53a517f74de4 \ + --hash=sha256:aaa2a54443eff1950ba5ddc6b6ccda0d9c84a364276a62f969bdf2a390650848 \ + --hash=sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526 \ + --hash=sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0 \ + --hash=sha256:b078afbc25f3a1436c7a1d2cd3e322497ee99615ba97c563566fdf46aff1ee01 \ + --hash=sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458 \ + --hash=sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e \ + --hash=sha256:bb63e0198ca18aad131c089b9204c23079c3afa95487e561f4c522d519e55aba \ + --hash=sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a \ + --hash=sha256:c1747f85cee84c26985853c6f3d9bd3e75da5212912443fa111c113b9c246f39 \ + --hash=sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c \ + --hash=sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000 \ + --hash=sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b \ + --hash=sha256:c7a7bd4e39e8e4c12c39cd480356842b6a8a06e41b23a55a5e3e191718838ddf \ + --hash=sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4 \ + --hash=sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd \ + --hash=sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28 \ + --hash=sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9 \ + --hash=sha256:d396ec2b979760aaf3218e76c24e65bd0aca24983298653b3a9d7a45f9e47b30 \ + --hash=sha256:d51026d73fcfd93610abc7b27789c26b313920fcfb20e27462d74a7f8b06e983 \ + --hash=sha256:d80ee3d731373b24cebbc10d689ca4ee1875caf0d5703a245db18efd4dd37fc1 \ + --hash=sha256:d995260fdf4e1db774581b4900e0f832abe3c7c84996726bbc161b19c8f29e76 \ + --hash=sha256:da4b951fe36dc7c3a1ccb4e3cd1747c3542b8c9ceede8fc86cae054e764485f5 \ + --hash=sha256:daa27d92c36f24388fe3ad306b174781c747627f134452e4f128ea00ce1fe8c4 \ + --hash=sha256:db06ffe51636ffe9ca531fe9023dd64bdd794be8754cb5df57c5498ae5b518a7 \ + --hash=sha256:e0d65b8c354be7fb5f720c3caa8bc940bc2d20ce749c8e06135f07f8ed95dd7c \ + --hash=sha256:e68b7a074f65a2fd746c52a7ce6142ab7006074ac269ace0c25cd8ba171f8066 \ + --hash=sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3 \ + --hash=sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02 \ + --hash=sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89 \ + --hash=sha256:ea793e075b70290d89d8142074262885d3f7da19634845135751bd6344f73b50 \ + --hash=sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76 \ + --hash=sha256:f13a646d65d09fbf1bc6b3a9635d30095c8e7e5cc419ff35ecc563c5fd04cd49 \ + --hash=sha256:f47286a97f0bc9b8859519809077b91b2cefe4ae47fcbf5e466a009c1c5d742b \ + --hash=sha256:f747929cf940cddb5b3668a390056ddd5ba2e5010615ea2dcf4f9c4f3ab8791d \ + --hash=sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7 \ + --hash=sha256:f9fa868638bf362d3d138ea55829cefb3d5f4b0d7f142234382a15e2485dbec4 \ + --hash=sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c \ + --hash=sha256:fc010ab034c8c7452522748bf937df58020d256ccae0874463d1f4d01758af8e \ + --hash=sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff \ + --hash=sha256:fd8b3d9fd264be37976686c7f65cd52a83f5e84f4bfd2adf9c1d469676bbb6ae + # via pydantic +python-dotenv==1.2.2 \ + --hash=sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a \ + --hash=sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3 + # via -r backend/requirements.txt +sniffio==1.3.1 \ + --hash=sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2 \ + --hash=sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc + # via anthropic +sqlite-vec==0.1.9 \ + --hash=sha256:1515727990b49e79bcaf75fdee2ffc7d461f8b66905013231251f1c8938e7786 \ + --hash=sha256:1b62a7f0a060d9475575d4e599bbf94a13d85af896bc1ce86ee80d1b5b48e5fb \ + --hash=sha256:1d52e30513bae4cc9778ddbf6145610434081be4c3afe57cd877893bad9f6b6c \ + --hash=sha256:4a28dc12fa4b53d7b1dced22da2488fade444e96b5d16fd2d698cd670675cf32 \ + --hash=sha256:4e921e592f24a5f9a18f590b6ddd530eb637e2d474e3b1972f9bbeb773aa3cb9 + # via -r backend/requirements.txt +starlette==1.3.1 \ + --hash=sha256:05d0213193f2fbaae60e2ecb593b4add4262ad4e46536b54abe36f11a71724e0 \ + --hash=sha256:c7372aae11c3c3f26a42df7bd626cec2f47d03483d261d369516a615a53714c6 + # via fastapi +typing-extensions==4.16.0 \ + --hash=sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8 \ + --hash=sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5 + # via + # anthropic + # anyio + # fastapi + # pydantic + # pydantic-core + # starlette + # typing-inspection +typing-inspection==0.4.2 \ + --hash=sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7 \ + --hash=sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464 + # via + # fastapi + # pydantic +uvicorn==0.52.0 \ + --hash=sha256:3d887809810b89ed33501bcf0a9aba469b06ecd608158efce04bd6b48d8c9b08 \ + --hash=sha256:ca8876ad6c1983f394157c168b39d52f6dd56dabf5602fa0982751cffc2293ae + # via -r backend/requirements.txt diff --git a/backend/runtime-requirements.lock b/backend/runtime-requirements.lock new file mode 100644 index 00000000..ec37e6cd --- /dev/null +++ b/backend/runtime-requirements.lock @@ -0,0 +1,387 @@ +# +# This file is autogenerated by pip-compile with Python 3.12 +# by the following command: +# +# make lock-python +# +anyio==4.14.2 \ + --hash=sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494 \ + --hash=sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f + # via httpx +certifi==2026.7.22 \ + --hash=sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775 \ + --hash=sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55 + # via + # httpcore + # httpx +click==8.4.2 \ + --hash=sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6 \ + --hash=sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76 + # via huggingface-hub +filelock==3.32.2 \ + --hash=sha256:87dd94cf281e586d135fa51132b8e3d9a598b316e90377a288663c9321036c82 \ + --hash=sha256:c33351e1f49cae33414acbc6d56784e6ecee82514ec90795da1161fc4836b5b8 + # via huggingface-hub +fsspec==2026.7.0 \ + --hash=sha256:b57ddbafedfaef7018c1ecab32aa200a9d7ca26b77965f64e48b70061249d279 \ + --hash=sha256:c803c40f4cf860b49dea58ee3e1c33cb9c790520e233537e1340049f89b82a88 + # via huggingface-hub +h11==0.16.0 \ + --hash=sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1 \ + --hash=sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86 + # via httpcore +hf-xet==1.5.2 \ + --hash=sha256:045f84440c55cdeb659cf1a1dd48c77bcd0d2e93632e2fea8f2c3bdee79f38ed \ + --hash=sha256:1da28519496eb7c8094c11e4d25509b4a468457a0302d58136099db2fd9a671d \ + --hash=sha256:4a5ecb9cda8512ba2aa8ee5d37c87a1422992165892d653098c7b90247481c3b \ + --hash=sha256:580e59e29bf37aece1f2b68537de1e3fb04f43a23d910dcf6f128280b5bfbba4 \ + --hash=sha256:6395cfe3c9cbead4f16b31808b0e67eac428b66c656f856e99636adaddea878f \ + --hash=sha256:73044bd31bae33c984af832d19c752a0dffb67518fee9ddbd91d616e1101cf47 \ + --hash=sha256:7db73c810500c54c6760be8c39d4b2e476974de85424c50063efc22fdda13025 \ + --hash=sha256:8764488197c1d7b1378c8438c18d2eea902e150dbca0b0f0d2d32603fb9b5576 \ + --hash=sha256:8d7446f72abbf7e01ca5ff131786bc2e74a56393462c17a6bf1e303fbab81db4 \ + --hash=sha256:bee28c619622d36968056532fd49cf2b35ca75099b1d616c31a618a893491380 \ + --hash=sha256:cde8cd167126bb6109b2ceb19b844433a4988643e8f3e01dd9dd0e4a34535097 \ + --hash=sha256:d6f9c58549407b84b9a5383afd68db0acc42345326a3159990b36a5ca8a20e4e \ + --hash=sha256:db78c39c83d6279daddc98e2238f373ab8980685556d42472b4ec51abcf03e8c \ + --hash=sha256:e396ab0faf6298199ad7a95305c3ca8498cb825978a6485be6d00587ee4ec577 \ + --hash=sha256:ecf63d1cb69a9a7319910f8f83fcf9b46e7a32dfcf4b8f8eeddb55f647306e65 \ + --hash=sha256:f922b8f5fb84f1dd3d7ab7a1316354a1bca9b1c73ecfc19c76e51a2a49d29799 \ + --hash=sha256:fd3add255549e8ef58fa35b2e42dc016961c050600444e7d77d030ba6b57120e + # via huggingface-hub +httpcore==1.0.9 \ + --hash=sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55 \ + --hash=sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8 + # via httpx +httpx==0.28.1 \ + --hash=sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc \ + --hash=sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad + # via huggingface-hub +huggingface-hub==1.26.0 \ + --hash=sha256:c8cd4e2df1ba9402f77fce9b509ec1d52debb502551789473f34016acc14e361 \ + --hash=sha256:e8cca670caa5d8dfa7e45bf45e86b466698198cd8150c021bcdb4a86b9252364 + # via tokenizers +idna==3.18 \ + --hash=sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2 \ + --hash=sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848 + # via + # anyio + # httpx +jinja2==3.1.6 \ + --hash=sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d \ + --hash=sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67 + # via model2vec +joblib==1.5.3 \ + --hash=sha256:5fc3c5039fc5ca8c0276333a188bbd59d6b7ab37fe6632daa76bc7f9ec18e713 \ + --hash=sha256:8561a3269e6801106863fd0d6d84bb737be9e7631e33aaed3fb9ce5953688da3 + # via model2vec +markupsafe==3.0.3 \ + --hash=sha256:0303439a41979d9e74d18ff5e2dd8c43ed6c6001fd40e5bf2e43f7bd9bbc523f \ + --hash=sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a \ + --hash=sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf \ + --hash=sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19 \ + --hash=sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf \ + --hash=sha256:0f4b68347f8c5eab4a13419215bdfd7f8c9b19f2b25520968adfad23eb0ce60c \ + --hash=sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175 \ + --hash=sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219 \ + --hash=sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb \ + --hash=sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6 \ + --hash=sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab \ + --hash=sha256:15d939a21d546304880945ca1ecb8a039db6b4dc49b2c5a400387cdae6a62e26 \ + --hash=sha256:177b5253b2834fe3678cb4a5f0059808258584c559193998be2601324fdeafb1 \ + --hash=sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce \ + --hash=sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218 \ + --hash=sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634 \ + --hash=sha256:1ba88449deb3de88bd40044603fafffb7bc2b055d626a330323a9ed736661695 \ + --hash=sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad \ + --hash=sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73 \ + --hash=sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c \ + --hash=sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe \ + --hash=sha256:2a15a08b17dd94c53a1da0438822d70ebcd13f8c3a95abe3a9ef9f11a94830aa \ + --hash=sha256:2f981d352f04553a7171b8e44369f2af4055f888dfb147d55e42d29e29e74559 \ + --hash=sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa \ + --hash=sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37 \ + --hash=sha256:3537e01efc9d4dccdf77221fb1cb3b8e1a38d5428920e0657ce299b20324d758 \ + --hash=sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f \ + --hash=sha256:38664109c14ffc9e7437e86b4dceb442b0096dfe3541d7864d9cbe1da4cf36c8 \ + --hash=sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d \ + --hash=sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c \ + --hash=sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97 \ + --hash=sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a \ + --hash=sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19 \ + --hash=sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9 \ + --hash=sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9 \ + --hash=sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc \ + --hash=sha256:591ae9f2a647529ca990bc681daebdd52c8791ff06c2bfa05b65163e28102ef2 \ + --hash=sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4 \ + --hash=sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354 \ + --hash=sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50 \ + --hash=sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698 \ + --hash=sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9 \ + --hash=sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b \ + --hash=sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc \ + --hash=sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115 \ + --hash=sha256:7c3fb7d25180895632e5d3148dbdc29ea38ccb7fd210aa27acbd1201a1902c6e \ + --hash=sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485 \ + --hash=sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f \ + --hash=sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12 \ + --hash=sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025 \ + --hash=sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009 \ + --hash=sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d \ + --hash=sha256:949b8d66bc381ee8b007cd945914c721d9aba8e27f71959d750a46f7c282b20b \ + --hash=sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a \ + --hash=sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5 \ + --hash=sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f \ + --hash=sha256:a320721ab5a1aba0a233739394eb907f8c8da5c98c9181d1161e77a0c8e36f2d \ + --hash=sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1 \ + --hash=sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287 \ + --hash=sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6 \ + --hash=sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f \ + --hash=sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581 \ + --hash=sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed \ + --hash=sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b \ + --hash=sha256:c0c0b3ade1c0b13b936d7970b1d37a57acde9199dc2aecc4c336773e1d86049c \ + --hash=sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026 \ + --hash=sha256:c4ffb7ebf07cfe8931028e3e4c85f0357459a3f9f9490886198848f4fa002ec8 \ + --hash=sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676 \ + --hash=sha256:d2ee202e79d8ed691ceebae8e0486bd9a2cd4794cec4824e1c99b6f5009502f6 \ + --hash=sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e \ + --hash=sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d \ + --hash=sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d \ + --hash=sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01 \ + --hash=sha256:df2449253ef108a379b8b5d6b43f4b1a8e81a061d6537becd5582fba5f9196d7 \ + --hash=sha256:e1c1493fb6e50ab01d20a22826e57520f1284df32f2d8601fdd90b6304601419 \ + --hash=sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795 \ + --hash=sha256:e2103a929dfa2fcaf9bb4e7c091983a49c9ac3b19c9061b6d5427dd7d14d81a1 \ + --hash=sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5 \ + --hash=sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d \ + --hash=sha256:e8fc20152abba6b83724d7ff268c249fa196d8259ff481f3b1476383f8f24e42 \ + --hash=sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe \ + --hash=sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda \ + --hash=sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e \ + --hash=sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737 \ + --hash=sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523 \ + --hash=sha256:f42d0984e947b8adf7dd6dde396e720934d12c506ce84eea8476409563607591 \ + --hash=sha256:f71a396b3bf33ecaa1626c255855702aca4d3d9fea5e051b41ac59a9c1c41edc \ + --hash=sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a \ + --hash=sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50 + # via jinja2 +model2vec==0.8.2 \ + --hash=sha256:4e88d1a5eb2136475ebd90505689046f61caf25c69371c331f21c6499637c110 \ + --hash=sha256:f0ecfe994316e401dca583fbf6dd22079d308c05717dd36d40bff60f265431cf + # via -r backend/runtime-requirements.txt +numpy==2.5.1 \ + --hash=sha256:08d60c810432eb83360958dea0999ac4cfb94531ea8efcbf0b7f277c2068aeb2 \ + --hash=sha256:09e9bfd8d2cf479c7d174804fb3811c53a8e9f20a37444008606b57d6b7a826d \ + --hash=sha256:0bfebd8695f9863592fe744be833a258120b14a9f39da255e8aa8fade2c0ddd1 \ + --hash=sha256:17a25e09640602e10bc8de0e6fa2b3fd68eedd84ba6d7842dc8f32f9ab87bd0b \ + --hash=sha256:1c6759f538fb912fc46de0a6b1758ccf7b57bc7c7ebebc23974fdac3de8db0cd \ + --hash=sha256:224ca51130ef7da85bea2191625181cb4f337f9cb64b471f10c1a12aa8b60077 \ + --hash=sha256:24d0eb82c0541d3415a33425db64ae439dffccd7b4dbcb30e7c35120205c506a \ + --hash=sha256:2ae0ca40bcb22d6ba59c1dfd5446f49940b0f2d821fde133f10dda11f816b84e \ + --hash=sha256:2c889b56fe48b1018f764b0eec8df59ab654e9148aa91faa12596043500de277 \ + --hash=sha256:30b44a6b53a7ae63c54c089a8726e5563ed302716c5b7ccc85afade40b0e7ff6 \ + --hash=sha256:32985c896d897419ef8da6917872d80b78ad0ea26d85b23245c7366ffde76d75 \ + --hash=sha256:3935f3b419b244a02732676fa5317a9193cc596a4c0646db07e5b421229ac9f7 \ + --hash=sha256:4939237038ada79308dda3204ac6462df056b5672b2e25db1149cf873668b3e1 \ + --hash=sha256:4b4ff1608417eb7a59da7b967bbb798cacfe071d2caf526a24281cd562072ed9 \ + --hash=sha256:54ad769f17bc2d833b620851989f62054fb9ab93c969d9e1dc3c8e3d56beea21 \ + --hash=sha256:59fda5e192b570217ec2580c96f00e9a7e12ef6866a900eb089b62c1a32545ca \ + --hash=sha256:5a4c988b38d261deeeaad9954e3deb091ad905c94e8bb6708654ef1d97f286b0 \ + --hash=sha256:5a6db61f9aaa57e369905c67d852045d3c4f7126405b29d09b19dec118e9c9cb \ + --hash=sha256:6165343f81b56ef8f514f396989e529b61d9dc709b99421b07e9f3e698e2287d \ + --hash=sha256:61ac47e772e6b8ea489e1d2f441a34c5c3ac17327e7ce294cbdf535795ad4e75 \ + --hash=sha256:6c3fe51bc6a16453d452997053454f309e8e0ed7b42d6b361ce4ac8c32913d74 \ + --hash=sha256:6eab239876581b2b3c5a242281b6007bbdbcd1c7085d7709bb57c5929b11e6bf \ + --hash=sha256:78798bd5b9ad744056af8efa90e3b9ddaa53272a0848a483084a1cc0a13b2dc0 \ + --hash=sha256:7c786fe9a5bbe360022e584c5a34cf6b54265c71bd7ec8ac3d8fec38968071f8 \ + --hash=sha256:83ce9c80d5b521b0d77ddcbe5447c218d247929b6cc056ca5351342accfff0af \ + --hash=sha256:9726558e8db4a5bf7929a70ae50f63abda4daf0efe810e3bfbab95976f75fc1a \ + --hash=sha256:99d5095fa265a0c4152e7bb12759e14381ef5496152f1ce58f44bdf55c44beb4 \ + --hash=sha256:a33276be12fa045805f477f22482088b66bb758ffbe89a9d21457de863a32e22 \ + --hash=sha256:a48a113e6afea91f5608793bafa7ef2ad481fefbda87ec5069f483de61cb9fa3 \ + --hash=sha256:ab451b59c5643c570974c43aef780703ef1d3b4965d2be07afd530615a9358d1 \ + --hash=sha256:ab84dc6b074fa881cae55bea94cc4f68e285181ba7f32497bf7dee6b1496165b \ + --hash=sha256:ab87a91b3cc3382b8956095bd8f95e00cf679bb81554339be1a2ba404a1473c1 \ + --hash=sha256:c12afb53450fa976d4c681c50a7423729a4c51c0465ed9f32b8a9cabbc472373 \ + --hash=sha256:caf3e317d33d60c37986b452613f4ab51246d0691350c03d0cb4a898627f4a95 \ + --hash=sha256:dc932a65ded7ce9013d120845a2514dcccb1a67bfc8deb8d37633762951904a6 \ + --hash=sha256:e68d8dd1e7eba712948f2053a29ec86917bc70ba1358df869d9f06649ef9cf09 \ + --hash=sha256:e824c2acf8862052246be5a44c15da1777940c60d010dd2aab897824d9c430f9 \ + --hash=sha256:e8c11c405efc5ff6816d5983c96cdfa215bab3428961243af3ff59b228490438 \ + --hash=sha256:efd736408cc97c79b9e6917338dfc8f06013b2274f992e96b1d9a81a71e2a2c2 \ + --hash=sha256:f089d7b00756190aacf1f5d34bdf38c3c430ac82b4f868f8cede73380460fce7 \ + --hash=sha256:f2479a47f8d5932d1718168a681ad6e536a9df484c83cfcf9de365e164537ace \ + --hash=sha256:f7119ebff1a9829e9f431a4f9d28e703023bb6b9fe7c8f724467dbfc27c94ab3 \ + --hash=sha256:f7d60026c0bdb1380e83bfa7a0419c4577ee4b9a08880afcb6dadeb74c649fa2 \ + --hash=sha256:f7feb014281029e628ba2d5a007407443b06e418b6fe451d1e2adcbc8eba0107 + # via model2vec +packaging==26.2 \ + --hash=sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e \ + --hash=sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661 + # via huggingface-hub +pysqlite3==0.6.0 \ + --hash=sha256:193488f62ebafcca1b72beb3daba98e4bc3be62ca6e3adee5bff603eadb535f7 \ + --hash=sha256:1cc753922baa703496b6edbf92687389490b0e7ad61b7ed4925e6cf2a7def0a1 \ + --hash=sha256:268fa5873143cccd79f32624c9a25714e4595715795ae09c8366c75cae8f5aed \ + --hash=sha256:2886b6ccf9c3c7ce678ea795a2aa9ddb4563c50c81bce399560fdb2c5662e55d \ + --hash=sha256:30bc9471bca6cfb35865e1a7ec6383b7fd37271edb7748941329709ef680c478 \ + --hash=sha256:4a2dd23831c3fef68db0761c685b9ca5286dc8d58b090bc5c055f389d1b319a5 \ + --hash=sha256:4f03a69a9c64d0476bbf9c619e1024770368b08e6b572c3f5f0fa641f1b67f27 \ + --hash=sha256:503e570d0c771ea2a79397523443687e5ca149e93530487dd61b9763b393be58 \ + --hash=sha256:51f24094c55039bd10c0a2d83e3c502039bf4bfa2b199ecf326815c52ec30bfb \ + --hash=sha256:5ae52d27b48a634f1015051d791d23a5bd3f0a992099a632bf39bca21a156420 \ + --hash=sha256:60cc74e051af0bef460950ea975d2d6c80a2145203f23653673a19cc367a6bb7 \ + --hash=sha256:64d48638e2c787f1e7ca0100294740f4db7f6bce1516ba8076e082d6443925d4 \ + --hash=sha256:65a9af11004d350538b6bd852b0ec22cdd62928be9ab522d85c0840d5bf92a72 \ + --hash=sha256:81226436fae52d3bec4ce578104c5bcf4c22d3fcf29d4a590fcbde56b90472ef \ + --hash=sha256:86d1a3ab3351984ddfa1a32ce51b378450dc9c53147a0e663febe077436db79a \ + --hash=sha256:87368097abc224c25719e186a9173f80b1253cf0d84989791804e9c12440fcd6 \ + --hash=sha256:97550d7f7b4714a174920799841fa61ddeb340bc662e421e2ebc12d8474fc89c \ + --hash=sha256:a7855d3016392fa81571404fc1647a1d07d1128ff862652e1f89e59edcc3405e \ + --hash=sha256:adc5faf38dcc1c47818e993c6998ee044643b6863ae89db693b36173d26b2400 \ + --hash=sha256:b4b1f27e8cfe15a4b13448a3ff48daa0a3cfd0bdb202aeb2378d768c14407b28 \ + --hash=sha256:b6f768d6e00d4574f5cc1bff174a4703382afe221b04b009bcb8b41164af08a0 \ + --hash=sha256:c02894e33691c742c62e4219a03b9683e1fdd4bad7d799c3395df8aefcd5ed17 \ + --hash=sha256:c4f85a31e80520351e7bc8e452e048bbc4470e17ca1882105cfe90833b476361 \ + --hash=sha256:c86cd86b2833b43fb4c01b8c90e71f70e95105995f4b1e27b8d9d5c906512992 \ + --hash=sha256:cc9787dc409f800e310c8a457b7c98d1c4d4a76e15821b7fb460c0e63554ddd8 \ + --hash=sha256:e3e0ff8dfd84926a06cac62905b7659be7152ced4f6c1ac8ea381a1281555cce \ + --hash=sha256:e52ec831759304540b53b550a230c698c3c4b9349732e7a2365bda5faa14854f \ + --hash=sha256:ec9ec8cb87132f912dead528c8d8e7b71348a5b83b808515114f8b95bd8f742a \ + --hash=sha256:ecf5112b62a4e6c04438957e343fe9672707bd3191f789ecae6c95b226aa6bb6 \ + --hash=sha256:ed0da0211335335900e699f93c7fe7292ab3437b015ee1310ee5199fd9d05c29 \ + --hash=sha256:f88d0f8f45bf3644ddd3e7c7de4b6b8d5b10a1bfe23db55b5a1ed95cbd4a0cb9 + # via -r backend/runtime-requirements.txt +pyyaml==6.0.3 \ + --hash=sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c \ + --hash=sha256:0150219816b6a1fa26fb4699fb7daa9caf09eb1999f3b70fb6e786805e80375a \ + --hash=sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3 \ + --hash=sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956 \ + --hash=sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6 \ + --hash=sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c \ + --hash=sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65 \ + --hash=sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a \ + --hash=sha256:1ebe39cb5fc479422b83de611d14e2c0d3bb2a18bbcb01f229ab3cfbd8fee7a0 \ + --hash=sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b \ + --hash=sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1 \ + --hash=sha256:22ba7cfcad58ef3ecddc7ed1db3409af68d023b7f940da23c6c2a1890976eda6 \ + --hash=sha256:27c0abcb4a5dac13684a37f76e701e054692a9b2d3064b70f5e4eb54810553d7 \ + --hash=sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e \ + --hash=sha256:2e71d11abed7344e42a8849600193d15b6def118602c4c176f748e4583246007 \ + --hash=sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310 \ + --hash=sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4 \ + --hash=sha256:3c5677e12444c15717b902a5798264fa7909e41153cdf9ef7ad571b704a63dd9 \ + --hash=sha256:3ff07ec89bae51176c0549bc4c63aa6202991da2d9a6129d7aef7f1407d3f295 \ + --hash=sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea \ + --hash=sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0 \ + --hash=sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e \ + --hash=sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac \ + --hash=sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9 \ + --hash=sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7 \ + --hash=sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35 \ + --hash=sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb \ + --hash=sha256:5cf4e27da7e3fbed4d6c3d8e797387aaad68102272f8f9752883bc32d61cb87b \ + --hash=sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69 \ + --hash=sha256:5ed875a24292240029e4483f9d4a4b8a1ae08843b9c54f43fcc11e404532a8a5 \ + --hash=sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b \ + --hash=sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c \ + --hash=sha256:6344df0d5755a2c9a276d4473ae6b90647e216ab4757f8426893b5dd2ac3f369 \ + --hash=sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd \ + --hash=sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824 \ + --hash=sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198 \ + --hash=sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065 \ + --hash=sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c \ + --hash=sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c \ + --hash=sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764 \ + --hash=sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196 \ + --hash=sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b \ + --hash=sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00 \ + --hash=sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac \ + --hash=sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8 \ + --hash=sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e \ + --hash=sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28 \ + --hash=sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3 \ + --hash=sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5 \ + --hash=sha256:9c57bb8c96f6d1808c030b1687b9b5fb476abaa47f0db9c0101f5e9f394e97f4 \ + --hash=sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b \ + --hash=sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf \ + --hash=sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5 \ + --hash=sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702 \ + --hash=sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8 \ + --hash=sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788 \ + --hash=sha256:b865addae83924361678b652338317d1bd7e79b1f4596f96b96c77a5a34b34da \ + --hash=sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d \ + --hash=sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc \ + --hash=sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c \ + --hash=sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba \ + --hash=sha256:c2514fceb77bc5e7a2f7adfaa1feb2fb311607c9cb518dbc378688ec73d8292f \ + --hash=sha256:c3355370a2c156cffb25e876646f149d5d68f5e0a3ce86a5084dd0b64a994917 \ + --hash=sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5 \ + --hash=sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26 \ + --hash=sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f \ + --hash=sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b \ + --hash=sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be \ + --hash=sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c \ + --hash=sha256:efd7b85f94a6f21e4932043973a7ba2613b059c4a000551892ac9f1d11f5baf3 \ + --hash=sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6 \ + --hash=sha256:fa160448684b4e94d80416c0fa4aac48967a969efe22931448d853ada8baf926 \ + --hash=sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0 + # via huggingface-hub +safetensors==0.8.0 \ + --hash=sha256:040070828e36dc8e122178bbbd5830ff9e97920affb84cbe0f46442497bed358 \ + --hash=sha256:096ec1a98435df7beb08853bb5aa9081a84f23d0adc67ed1a0a10550f608373f \ + --hash=sha256:2ddf52eac562eda224f99acfa7889d02968c1fd59a5b011ae7d8137c37e9c02d \ + --hash=sha256:3ae091f16662658bdc019a4ff6cb4c085bb7d725eb5978b183ffd265863b6d2d \ + --hash=sha256:4124502b78f03534117c848f87a39b8f31e577b15eff423bf8bfb95f2a8c30d0 \ + --hash=sha256:4a95ae2b05d7726d751da4ebf626a2ca782b706e101bd894c95bc2450b1cffcc \ + --hash=sha256:7a46e5ff292c356d6991e60942ba7f79817682d3a2cef0702136448cb9c4d235 \ + --hash=sha256:7bc0a787ba8a35be368ee3574edfa2b1ad389eebd0a72e482ae275490e3f6c98 \ + --hash=sha256:87eec7ffed2b809f05a398a8becb7d013f19f7837cd15d9748580d6cf30dbaf4 \ + --hash=sha256:8e080062fcde23be189565e1c3305d16751a218ecf9412c8601e64204eb6f846 \ + --hash=sha256:8e9f537aa183a38ace122d27303dcd986b26bd2a7591f9181d7f0c396f4677ca \ + --hash=sha256:c554f85858e05226d3c2828e32395e677434685d6d94594a41643361c5e837f0 \ + --hash=sha256:c80201d22cbf405b80647a60ada77bba06c8fba2da2743ba1e89cdcc39a81f25 \ + --hash=sha256:f7838e5135a406ad3e02efdcb8cf2e5397d368b0154537c4fec682dbc544d452 \ + --hash=sha256:fabaf3e0f18a6618d9b36560682562157f77c2b71fcffc7b432be2baed9d753d \ + --hash=sha256:fcdd41ec4628fee5799f807c73c353629130fbd942aa23d83c623dd6c9d52d78 \ + --hash=sha256:fd6f3f93c9a0a7cc2788ee63fb763353d4bd2e89b0751bc78fcf7dda00bea774 + # via model2vec +sqlite-vec==0.1.9 \ + --hash=sha256:1515727990b49e79bcaf75fdee2ffc7d461f8b66905013231251f1c8938e7786 \ + --hash=sha256:1b62a7f0a060d9475575d4e599bbf94a13d85af896bc1ce86ee80d1b5b48e5fb \ + --hash=sha256:1d52e30513bae4cc9778ddbf6145610434081be4c3afe57cd877893bad9f6b6c \ + --hash=sha256:4a28dc12fa4b53d7b1dced22da2488fade444e96b5d16fd2d698cd670675cf32 \ + --hash=sha256:4e921e592f24a5f9a18f590b6ddd530eb637e2d474e3b1972f9bbeb773aa3cb9 + # via -r backend/runtime-requirements.txt +tokenizers==0.23.1 \ + --hash=sha256:120468fb4c24faf0543c835a4fabafa4deb3f20a035c9b6e83d0b553a97615d4 \ + --hash=sha256:1974288a609c343774f1b897c8b482c791ab17b75ab5c8c2b1737565c1d82288 \ + --hash=sha256:1bf13402aff9bc533c89cb849ec3b412dc3fbeacc9744840e423d7bf3f7dc0e3 \ + --hash=sha256:1feeeadf865a7915adc25445dea30e9933e593c31bb96c277cee36de227c8bfa \ + --hash=sha256:5075b405006415ea148a992d093699c66eb01952bf59f4d5727089a98bda45a4 \ + --hash=sha256:53b09e85775d5187941e7bab30e941b4134ab4a7dd8c68e783d231fb7ca27c51 \ + --hash=sha256:56f3a77de629917652f876294dc9fe6bad4a0c43bc229dc72e59bb23a0f4729a \ + --hash=sha256:93120a930b919416da7cd10a2f606ac9919cc69cacae7980fa2140e277660948 \ + --hash=sha256:9d10a6d957ef01896dc274e890eee27d41bd0e74ef31e60616f0fc311345184e \ + --hash=sha256:a26197957d8e4425dfba746315f3c425ea00cfa8367c5fbc4ec73447893dcea9 \ + --hash=sha256:ae848657742035523fdf261773630cb819a26995fcd3d9ecae0c1daf6e5a4959 \ + --hash=sha256:e03d6ffcbe0d56ee9c1ccd070e70a13fa750727c0277e138152acbc0252c2224 \ + --hash=sha256:e0948bbb1ac1d7cdfc9fb6d62c596e3b7550036ad60ecd654a66ad273326324e \ + --hash=sha256:e3d8f40ea6268047de7046906326abed5134f27d4e8447b23763afe5808c8a96 \ + --hash=sha256:e7bfaf995c1bdbbd21d13539decb6650967013759318627d85daeb7881af16b7 \ + --hash=sha256:ea5a0ce170074329faaa8ea3f6400ecde604b6678192688533af80980daae71a \ + --hash=sha256:f836ca703b89ae07919a309f9651f7a88fd5a33d5f718ba5ad0870ec0256bad6 + # via model2vec +tqdm==4.70.0 \ + --hash=sha256:55b0b0dbd97462d06ebee91e4dac24ed4d4702be82b24f07e6c1d27e08cea220 \ + --hash=sha256:7f585706bfddbdebf89daac705b2dfcc16890130727d3197ca62c732b4310953 + # via + # huggingface-hub + # model2vec +typing-extensions==4.16.0 \ + --hash=sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8 \ + --hash=sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5 + # via + # anyio + # huggingface-hub diff --git a/backend/tests/test_config.py b/backend/tests/test_config.py index 5763dfa2..3070ea71 100644 --- a/backend/tests/test_config.py +++ b/backend/tests/test_config.py @@ -25,6 +25,24 @@ def test_missing_global_token_does_not_create_sample_admin_token(self) -> None: self.assertEqual(settings.api_key, "") + def test_public_base_url_follows_an_overridden_development_port(self) -> None: + with patch.dict(os.environ, {"CORTEX_PORT": "57490"}, clear=True): + settings = load_settings() + self.assertEqual(settings.public_base_url, "http://127.0.0.1:57490") + self.assertEqual(settings.public_app_url, "http://127.0.0.1:57490") + + def test_explicit_public_base_url_overrides_the_development_port(self) -> None: + with patch.dict( + os.environ, + { + "CORTEX_PORT": "57490", + "CORTEX_PUBLIC_BASE_URL": "https://api.example.invalid", + }, + clear=True, + ): + settings = load_settings() + self.assertEqual(settings.public_base_url, "https://api.example.invalid") + if __name__ == "__main__": unittest.main() diff --git a/backend/tests/test_context_engine.py b/backend/tests/test_context_engine.py index ba196ad0..67b9f8c8 100644 --- a/backend/tests/test_context_engine.py +++ b/backend/tests/test_context_engine.py @@ -150,6 +150,19 @@ def test_entity_layer_carries_graph_connections(self) -> None: labels = {connection.get("label") for connection in entity.get("connections") or []} self.assertIn("Atlas", labels) + def test_project_is_explicitly_labeled_as_a_ranking_hint(self) -> None: + self._seed_corpus() + pack = self.store.assemble_context( + self.user_id, + "summarize Atlas", + project="Project Atlas", + ) + self.assertEqual(pack["filters"]["project"], "Project Atlas") + self.assertEqual( + pack["filter_semantics"]["project"], + "ranking_hint_not_isolation", + ) + def test_markdown_render_contains_included_ids(self) -> None: self._seed_corpus() pack = self.store.assemble_context(self.user_id, "what did we decide about Atlas?") diff --git a/backend/tests/test_fastapi_contract.py b/backend/tests/test_fastapi_contract.py index dc4510d2..5a5d90ee 100644 --- a/backend/tests/test_fastapi_contract.py +++ b/backend/tests/test_fastapi_contract.py @@ -228,22 +228,17 @@ def test_privacy_lifecycle_report_exposes_delete_and_backup_contract(self) -> No self.assertIn("trust_score", payload["ai_access"]) self.assertIn("events", payload["audit"]) - def test_health_exposes_sharding_contract(self) -> None: + def test_health_exposes_only_safe_sharding_status(self) -> None: response = self.client.get("/health") self.assertEqual(response.status_code, 200) payload = response.json() sharding = payload["sharding"] self.assertEqual(sharding["mode"], "local") - self.assertEqual(sharding["default"]["shard_id"], "local") - self.assertIn("db_path", sharding["default"]) - hosted_readiness = payload["hosted_readiness"] - self.assertEqual(hosted_readiness["status"], "ok") - self.assertFalse(hosted_readiness["hosted_mode"]) - self.assertEqual(hosted_readiness["shard_mode"], "local") - self.assertFalse(hosted_readiness["require_scoped_api_tokens"]) - self.assertEqual(hosted_readiness["global_token_user_switching"], "allowed_local_compatibility") - self.assertEqual(hosted_readiness["checks"][0]["name"], "scoped_api_tokens_required") + self.assertEqual(sharding["default_shard_id"], "local") + self.assertNotIn("db_path", json.dumps(payload)) + self.assertNotIn("vault_path", json.dumps(payload)) + self.assertNotIn("hosted_readiness", payload) def test_hosted_health_defers_tenant_wide_readiness_scan(self) -> None: original_settings = main_module.settings @@ -258,7 +253,7 @@ def test_hosted_health_defers_tenant_wide_readiness_scan(self) -> None: main_module.settings = original_settings self.assertEqual(response.status_code, 200) - self.assertEqual(response.json()["hosted_readiness"]["status"], "deferred") + self.assertNotIn("hosted_readiness", response.json()) def test_ready_requires_scoped_api_tokens_for_hosted_shard_modes(self) -> None: original_settings = main_module.settings @@ -268,15 +263,10 @@ def test_ready_requires_scoped_api_tokens_for_hosted_shard_modes(self) -> None: self.assertEqual(response.status_code, 503) detail = response.json()["detail"] - self.assertEqual(detail["status"], "needs_configuration") - hosted_readiness = detail["hosted_readiness"] - self.assertEqual(hosted_readiness["status"], "blocked") - self.assertTrue(hosted_readiness["hosted_mode"]) - self.assertEqual(hosted_readiness["shard_mode"], "bucket") - self.assertFalse(hosted_readiness["require_scoped_api_tokens"]) - self.assertEqual(hosted_readiness["global_token_user_switching"], "blocked") - self.assertEqual(hosted_readiness["checks"][0]["status"], "blocked") - self.assertIn("CORTEX_REQUIRE_SCOPED_API_TOKENS=1", hosted_readiness["checks"][0]["detail"]) + self.assertEqual( + detail, + {"status": "not_ready", "check": "hosted_configuration"}, + ) finally: main_module.settings = original_settings @@ -512,9 +502,14 @@ def test_hosted_ready_requires_runtime_scoped_token_control_plane(self) -> None: response = self.client.get("/ready") self.assertEqual(response.status_code, 503) + self.assertEqual( + response.json()["detail"], + {"status": "not_ready", "check": "hosted_configuration"}, + ) + hosted_readiness = main_module._hosted_readiness_contract() blocked = { check["name"] - for check in response.json()["detail"]["hosted_readiness"]["checks"] + for check in hosted_readiness["checks"] if check["status"] == "blocked" } self.assertEqual(blocked, {"runtime_hosted_storage", "background_worker_queue", "control_plane_scoped_tokens"}) @@ -524,9 +519,10 @@ def test_hosted_ready_requires_runtime_scoped_token_control_plane(self) -> None: main_module.store.ensure_mcp_token(split_user, "cxm_hosted_ready_split_mcp_token_123456789", label="Hosted MCP", scopes=["read"]) split_ready = self.client.get("/ready") self.assertEqual(split_ready.status_code, 503) + hosted_readiness = main_module._hosted_readiness_contract() split_blocked = { check["name"] - for check in split_ready.json()["detail"]["hosted_readiness"]["checks"] + for check in hosted_readiness["checks"] if check["status"] == "blocked" } self.assertEqual(split_blocked, {"runtime_hosted_storage", "background_worker_queue", "control_plane_scoped_tokens"}) @@ -535,7 +531,7 @@ def test_hosted_ready_requires_runtime_scoped_token_control_plane(self) -> None: ready = self.client.get("/ready") self.assertEqual(ready.status_code, 503) - hosted_readiness = ready.json()["detail"]["hosted_readiness"] + hosted_readiness = main_module._hosted_readiness_contract() runtime_blocked = { check["name"] for check in hosted_readiness["checks"] @@ -1111,6 +1107,23 @@ def test_context_engine_endpoint_and_mcp_tool(self) -> None: bad_format = self.client.post("/v1/context", json={"task": "x", "format": "yaml"}, headers=headers) self.assertEqual(bad_format.status_code, 422) + for method in ("get", "post"): + with self.subTest(method=method): + if method == "get": + invalid_session = self.client.get( + "/v1/context", + params={"task": "Atlas database decision", "pin": True, "session_id": "asess_missing"}, + headers=headers, + ) + else: + invalid_session = self.client.post( + "/v1/context", + json={"task": "Atlas database decision", "pin": True, "session_id": "asess_missing"}, + headers=headers, + ) + self.assertEqual(invalid_session.status_code, 422) + self.assertIn("Unknown agent session", invalid_session.json()["detail"]) + # A READ-scoped API token can use the engine AND receives the identity layer — the # distilled picture of the user is a read, which is the whole point of the product. read_token = "cxa-context-read-token" diff --git a/backend/tests/test_phase2_context_packs.py b/backend/tests/test_phase2_context_packs.py index 2a541fc0..765bb603 100644 --- a/backend/tests/test_phase2_context_packs.py +++ b/backend/tests/test_phase2_context_packs.py @@ -141,8 +141,17 @@ def test_session_linked_pins_are_listable_by_session(self) -> None: self.assertEqual(replay["session_id"], session["id"]) def test_pin_with_unknown_session_fails_loudly(self) -> None: - with self.assertRaises(ValueError): + with connect(self.db_path) as conn: + events_before = int(conn.execute("SELECT COUNT(*) FROM memory_events").fetchone()[0]) + working_before = int(conn.execute("SELECT COUNT(*) FROM session_working_set").fetchone()[0]) + with self.assertRaisesRegex(ValueError, "Unknown agent session"): self._pin(session_id="asess_missing") + with connect(self.db_path) as conn: + events_after = int(conn.execute("SELECT COUNT(*) FROM memory_events").fetchone()[0]) + working_after = int(conn.execute("SELECT COUNT(*) FROM session_working_set").fetchone()[0]) + self.assertEqual(working_after, working_before) + self.assertEqual(events_after, events_before) + self.assertEqual(self._call("list_context_packs", {}), []) # -- substrate invariants --------------------------------------------------------------------- diff --git a/backend/tests/test_retrieval_quality.py b/backend/tests/test_retrieval_quality.py index 530fed1c..a7507767 100644 --- a/backend/tests/test_retrieval_quality.py +++ b/backend/tests/test_retrieval_quality.py @@ -586,6 +586,77 @@ def test_lexical_fallback_does_not_pad_existing_strict_results(self) -> None: self.assertEqual([item["id"] for item in results], ["strict_taipei_positioning"]) + def test_owner_and_release_paraphrases_beat_explicit_non_answer_bait(self) -> None: + query = "Who is accountable for the Project Aster launch?" + self.store._vector_ready = lambda conn: False + self.assertEqual( + self.store._lexical_fallback_terms(query), + ["owner", "project", "aster", "release"], + ) + + for order_name, canonical_first in ( + ("canonical-first", True), + ("bait-first", False), + ): + with self.subTest(order=order_name): + user_id = f"adversarial-paraphrase-{order_name}" + self.store.update_settings( + user_id, + { + "review_new_captures": False, + "allow_pending_in_context": True, + }, + ) + canonical_id = f"aster_canonical_{order_name}" + bait_id = f"aster_bait_{order_name}" + canonical = { + "id": canonical_id, + "kind": "claim", + "layer": "semantic", + "content": "Project Aster's rollout DRI is Mina Chen.", + "summary": "Mina Chen is the DRI for the Project Aster rollout.", + "confidence": "confirmed", + "importance": 3, + "topics": ["project-aster", "rollout", "dri"], + "entity_ids": [], + } + bait = { + "id": bait_id, + "kind": "claim", + "layer": "semantic", + "content": ( + "Project Aster launch accountability template. This " + "unassigned template does not name an owner and must not " + "be used to answer who is accountable." + ), + "summary": "An unassigned Project Aster accountability template.", + "confidence": "confirmed", + "importance": 3, + "topics": ["project-aster", "launch", "accountability"], + "entity_ids": [], + } + records = [canonical, bait] if canonical_first else [bait, canonical] + self.store.save_capture( + user_id=user_id, + content="\n".join(str(record["content"]) for record in records), + source="adversarial-retrieval-test", + source_url="https://example.invalid/project-aster", + title="Project Aster ownership fixture", + extracted={ + "_timestamp": "2026-07-30T00:00:00Z", + "summary": "Project Aster ownership fixture.", + "records": records, + "tasks": [], + "entities": [], + }, + ) + + results = self.store.search(user_id, query, limit=5) + + self.assertTrue(results) + self.assertEqual(results[0]["id"], canonical_id) + self.assertIn(canonical_id, [item["id"] for item in results]) + def test_lexical_fallback_applies_recency_and_importance_boosts(self) -> None: self.store.update_settings(self.user_id, {"review_new_captures": False, "allow_pending_in_context": True}) self.store._vector_ready = lambda conn: False diff --git a/backend/tests/test_standalone_server.py b/backend/tests/test_standalone_server.py index c7c501e3..176c03dd 100644 --- a/backend/tests/test_standalone_server.py +++ b/backend/tests/test_standalone_server.py @@ -5,6 +5,7 @@ import tempfile import threading import unittest +from dataclasses import replace from unittest import mock from pathlib import Path from urllib import error, request @@ -250,6 +251,10 @@ def context_pack(self, user_id: str, *, query: str = "", limit: int = 12, sector def assemble_context(self, user_id: str, task: str = "", **kwargs) -> dict | str: call = {"user_id": user_id, "task": task, **kwargs} self.assemble_context_calls.append(call) + if kwargs.get("pin") and kwargs.get("session_id") == "asess_missing": + raise standalone_server.UnknownAgentSessionError( + "Unknown agent session; call start_agent_session first (ids look like asess_...)." + ) if kwargs.get("format") == "markdown": return "# Cortex Context Pack\n\nassembled" return { @@ -649,7 +654,9 @@ def health_payload(self, *, mode: str, auth: bool) -> dict: "auth": auth, "sharding": { "mode": "local", - "default": {"shard_id": "local", "db_path": "/tmp/index.sqlite", "vault_path": "/tmp/Cortex.vault"}, + "shard_count": 1, + "active_store_count": 1, + "default_shard_id": "local", }, } @@ -2912,16 +2919,37 @@ def test_standalone_worker_enabled_only_for_local_inline_mode(self): self.assertTrue(standalone_server._standalone_worker_enabled()) def test_standalone_server_refuses_non_loopback_bind_by_default(self): - for host in ("0.0.0.0", "::", "192.0.2.10", "public.example"): + # Wildcard and public hosts are literal rejection cases, never bind targets. + wildcard_host = "0.0.0.0" # nosec B104 + for host in (wildcard_host, "::", "192.0.2.10", "public.example"): with self.subTest(host=host), mock.patch.dict(os.environ, {}, clear=True): with self.assertRaisesRegex(ValueError, "local-only"): standalone_server.serve(host, 9876) + def test_standalone_server_refuses_sample_token_on_non_loopback_bind(self): + original_settings = standalone_server.settings + standalone_server.settings = replace( + original_settings, + api_key=standalone_server.INSECURE_DEV_API_KEY, + ) + try: + with mock.patch.dict( + os.environ, + {"CORTEX_ALLOW_NONLOCAL_STANDALONE": "1"}, + clear=True, + ): + with self.assertRaisesRegex(ValueError, "sample token"): + standalone_server.serve("0.0.0.0", 9876) # nosec B104 + finally: + standalone_server.settings = original_settings + def test_standalone_loopback_detection_handles_ipv4_ipv6_and_mapped_addresses(self): for host in ("localhost", "127.0.0.1", "127.9.8.7", "::1", "::ffff:127.0.0.1"): with self.subTest(host=host): self.assertTrue(standalone_server._standalone_host_is_loopback(host)) - self.assertFalse(standalone_server._standalone_host_is_loopback("0.0.0.0")) + # This literal validates that wildcard binding is not classified as loopback. + wildcard_host = "0.0.0.0" # nosec B104 + self.assertFalse(standalone_server._standalone_host_is_loopback(wildcard_host)) def test_standalone_worker_tick_drains_memory_jobs_without_source_scheduling(self): result = standalone_server._run_standalone_worker_tick(limit=7) @@ -3128,27 +3156,23 @@ def test_cors_echoes_configured_local_origin(self) -> None: self.assertEqual(headers.get("Access-Control-Allow-Origin"), "http://localhost:8766") self.assertEqual(headers.get("Vary"), "Origin") - def test_health_and_ready_expose_hosted_readiness_contract(self) -> None: + def test_health_and_ready_expose_only_public_probe_status(self) -> None: with self.get("/health") as response: health = json.loads(response.read().decode("utf-8")) self.assertEqual(response.status, 200) self.assertEqual(health["mode"], "standalone") - hosted_readiness = health["hosted_readiness"] - self.assertEqual(hosted_readiness["status"], "ok") - self.assertFalse(hosted_readiness["hosted_mode"]) - self.assertEqual(hosted_readiness["shard_mode"], "local") - self.assertFalse(hosted_readiness["require_scoped_api_tokens"]) - self.assertEqual(hosted_readiness["global_token_user_switching"], "allowed_local_compatibility") - self.assertEqual(hosted_readiness["checks"][0]["name"], "scoped_api_tokens_required") + self.assertNotIn("db_path", json.dumps(health)) + self.assertNotIn("vault_path", json.dumps(health)) + self.assertNotIn("hosted_readiness", health) with self.get("/ready") as response: ready = json.loads(response.read().decode("utf-8")) self.assertEqual(response.status, 200) self.assertEqual(ready["status"], "ok") - self.assertEqual(ready["hosted_readiness"]["status"], "ok") - self.assertEqual(ready["diagnostics"]["status"], "ok") + self.assertEqual(ready["backend_version"], standalone_server.BACKEND_VERSION) + self.assertNotIn("diagnostics", ready) def test_ready_requires_scoped_api_tokens_for_hosted_shard_modes(self) -> None: standalone_server.settings = Settings( @@ -3164,22 +3188,16 @@ def test_ready_requires_scoped_api_tokens_for_hosted_shard_modes(self) -> None: health = json.loads(response.read().decode("utf-8")) self.assertEqual(response.status, 200) - hosted_readiness = health["hosted_readiness"] - self.assertEqual(hosted_readiness["status"], "blocked") - self.assertTrue(hosted_readiness["hosted_mode"]) - self.assertEqual(hosted_readiness["shard_mode"], "bucket") - self.assertFalse(hosted_readiness["require_scoped_api_tokens"]) - self.assertEqual(hosted_readiness["global_token_user_switching"], "blocked") + self.assertNotIn("hosted_readiness", health) with self.assertRaises(error.HTTPError) as context: self.get("/ready") self.assertEqual(context.exception.code, 503) detail = json.loads(context.exception.read().decode("utf-8"))["detail"] - self.assertEqual(detail["status"], "needs_configuration") - hosted_readiness = detail["hosted_readiness"] - self.assertEqual(hosted_readiness["status"], "blocked") - self.assertEqual(hosted_readiness["checks"][0]["status"], "blocked") - self.assertIn("CORTEX_REQUIRE_SCOPED_API_TOKENS=1", hosted_readiness["checks"][0]["detail"]) + self.assertEqual( + detail, + {"status": "not_ready", "check": "hosted_configuration"}, + ) def test_delete_capture_forwards_to_store(self) -> None: with self.delete("/v1/captures/capture-1") as response: @@ -4953,6 +4971,24 @@ def test_context_engine_routes_on_shipping_server(self) -> None: self.assertEqual(response.status, 200) self.assertIs(self.fake_store.assemble_context_calls[-1]["pin"], False) + for request_factory in ( + lambda: request.Request( + self.base_url + "/v1/context?task=x&pin=true&session_id=asess_missing", + headers={"Authorization": "Bearer test-token"}, + ), + lambda: request.Request( + self.base_url + "/v1/context", + data=json.dumps({"task": "x", "pin": True, "session_id": "asess_missing"}).encode("utf-8"), + headers={"Authorization": "Bearer test-token", "Content-Type": "application/json"}, + method="POST", + ), + ): + request_value = request_factory() + with self.subTest(method=request_value.get_method()), self.assertRaises(error.HTTPError) as context: + request.urlopen(request_value, timeout=5) + self.assertEqual(context.exception.code, 422) + self.assertIn("Unknown agent session", context.exception.read().decode("utf-8")) + def test_context_pack_verify_route_on_shipping_server(self) -> None: # Phase 2b: POST /v1/context/packs/{sha}/verify reaches store.verify_context_pack, # is maintenance-scoped for API tokens, and maps ValueError -> 404. diff --git a/backend/tests/test_vector_dim_migration.py b/backend/tests/test_vector_dim_migration.py index a710202b..345ab620 100644 --- a/backend/tests/test_vector_dim_migration.py +++ b/backend/tests/test_vector_dim_migration.py @@ -1,5 +1,6 @@ from __future__ import annotations +import json import tempfile import unittest from pathlib import Path @@ -37,6 +38,7 @@ def setUp(self) -> None: self.user_id = "vec-user" E._MODEL2VEC_MODEL = None E._MODEL2VEC_MODEL_KEY = None + E._MODEL2VEC_DIMENSIONS_BY_KEY.clear() E._MODEL2VEC_LOAD_FAILED = False E._MODEL2VEC_FAILURE_LOGGED = False # Create the user's settings once (persist in the DB; later stores reread them). @@ -47,6 +49,7 @@ def setUp(self) -> None: def tearDown(self) -> None: E._MODEL2VEC_MODEL = None E._MODEL2VEC_MODEL_KEY = None + E._MODEL2VEC_DIMENSIONS_BY_KEY.clear() E._MODEL2VEC_LOAD_FAILED = False E._MODEL2VEC_FAILURE_LOGGED = False self._tmp.cleanup() @@ -159,6 +162,58 @@ def test_switching_model_rebuilds_index_and_reembeds(self) -> None: hits = store.search(self.user_id, "which datastore did we pick", limit=5) self.assertTrue(any(h.get("id") == "mem_db" for h in hits), [h.get("id") for h in hits]) + def test_same_dimension_model_swap_rebuilds_incompatible_vectors(self) -> None: + base_env = { + "CORTEX_EMBEDDING_PROVIDER": "openai", + "CORTEX_EMBEDDING_DIMENSIONS": str(E.VECTOR_DIMENSIONS), + } + with mock.patch.dict( + "os.environ", + {**base_env, "CORTEX_EMBEDDING_MODEL": "same-dim-model-a"}, + clear=False, + ): + first = self._store() + self._seed( + first, + "mem_same_dimension_swap", + "Project Ember uses SQLite for its local datastore.", + ["project-ember", "database"], + ) + with connect(self.db_path) as conn: + first._write_memory_vector( + conn, + memory_id="mem_same_dimension_swap", + user_id=self.user_id, + embedding_model="same-dim-model-a", + text_hash="fixture", + vector=json.dumps([0.0] * E.VECTOR_DIMENSIONS), + timestamp=now_iso(), + ) + first_fingerprint = conn.execute( + "SELECT model FROM vec_index_meta WHERE id = 1" + ).fetchone()[0] + self.assertEqual(conn.execute("SELECT count(*) FROM memory_vec").fetchone()[0], 1) + + with mock.patch.dict( + "os.environ", + {**base_env, "CORTEX_EMBEDDING_MODEL": "same-dim-model-b"}, + clear=False, + ): + second = self._store() + with connect(self.db_path) as conn: + second_fingerprint = conn.execute( + "SELECT model FROM vec_index_meta WHERE id = 1" + ).fetchone()[0] + self.assertNotEqual(first_fingerprint, second_fingerprint) + self.assertEqual(conn.execute("SELECT count(*) FROM memory_vec").fetchone()[0], 0) + self.assertEqual(conn.execute("SELECT count(*) FROM memory_vec_map").fetchone()[0], 0) + self.assertGreater( + conn.execute( + "SELECT count(*) FROM memory_jobs WHERE job_type = 'embed_memory'" + ).fetchone()[0], + 0, + ) + if __name__ == "__main__": unittest.main() diff --git a/deploy/bootstrap.sh b/deploy/bootstrap.sh index 26e37850..6c05568c 100755 --- a/deploy/bootstrap.sh +++ b/deploy/bootstrap.sh @@ -89,7 +89,10 @@ fi # --- 4. Python env + release switch ------------------------------------------- python3 -m venv /srv/cortex/venv 2>/dev/null || true /srv/cortex/venv/bin/pip install --quiet --upgrade pip -/srv/cortex/venv/bin/pip install --quiet -r "$RELEASE_DIR/backend/requirements.txt" -r "$RELEASE_DIR/backend/runtime-requirements.txt" uvicorn +/srv/cortex/venv/bin/pip install --quiet --require-hashes \ + -r "$RELEASE_DIR/backend/requirements.lock" +/srv/cortex/venv/bin/pip install --quiet --require-hashes \ + -r "$RELEASE_DIR/backend/runtime-requirements.lock" ln -sfn "$RELEASE_DIR" /srv/cortex/current chown -R cortex:cortex /srv/cortex/releases diff --git a/deploy/macmini/setup.sh b/deploy/macmini/setup.sh index 9d973a75..dd401249 100755 --- a/deploy/macmini/setup.sh +++ b/deploy/macmini/setup.sh @@ -147,7 +147,11 @@ chmod +x "$REPO_DIR/deploy/macmini/run-api.sh" "$REPO_DIR/deploy/macmini/run-wor # --- Python deps --- echo "==> Installing backend dependencies into $VENV_DIR" -"$PYTHON" -m pip install --quiet -r "$REPO_DIR/backend/requirements.txt" -r "$REPO_DIR/backend/runtime-requirements.txt" uvicorn || { +"$PYTHON" -m pip install --quiet --require-hashes \ + -r "$REPO_DIR/backend/requirements.lock" || { + echo "!! hosted dependency install failed; check $PYTHON"; exit 1; } +"$PYTHON" -m pip install --quiet --require-hashes \ + -r "$REPO_DIR/backend/runtime-requirements.lock" || { echo "!! pip install failed; check $PYTHON"; exit 1; } # --- launchd plists --- diff --git a/deploy/systemd/cortex-api.service b/deploy/systemd/cortex-api.service index 6dcf12ef..bbbbed6d 100644 --- a/deploy/systemd/cortex-api.service +++ b/deploy/systemd/cortex-api.service @@ -15,12 +15,23 @@ ExecStart=/srv/cortex/venv/bin/uvicorn backend.app.main:app --host 127.0.0.1 --p Restart=always RestartSec=3 # Sandboxing +UMask=0077 NoNewPrivileges=yes ProtectSystem=strict ProtectHome=yes PrivateTmp=yes +PrivateDevices=yes +ProtectClock=yes +ProtectControlGroups=yes +ProtectKernelModules=yes +ProtectKernelTunables=yes +LockPersonality=yes +RestrictRealtime=yes +RestrictSUIDSGID=yes +RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 ReadWritePaths=/var/lib/cortex MemoryMax=5G +TasksMax=256 [Install] WantedBy=multi-user.target diff --git a/deploy/systemd/cortex-backup.service b/deploy/systemd/cortex-backup.service index 697532ac..a1781dd6 100644 --- a/deploy/systemd/cortex-backup.service +++ b/deploy/systemd/cortex-backup.service @@ -4,5 +4,7 @@ Description=Cortex nightly backup (WAL-safe SQLite snapshots + vaults) [Service] Type=oneshot User=root +UMask=0077 +NoNewPrivileges=yes EnvironmentFile=/etc/cortex/cortex.env ExecStart=/usr/local/bin/cortex-backup diff --git a/deploy/systemd/cortex-worker.service b/deploy/systemd/cortex-worker.service index 85422baf..82b06f4b 100644 --- a/deploy/systemd/cortex-worker.service +++ b/deploy/systemd/cortex-worker.service @@ -11,12 +11,23 @@ WorkingDirectory=/srv/cortex/current ExecStart=/srv/cortex/venv/bin/python scripts/run_memory_worker.py --iterations 0 --interval-seconds 30 Restart=always RestartSec=5 +UMask=0077 NoNewPrivileges=yes ProtectSystem=strict ProtectHome=yes PrivateTmp=yes +PrivateDevices=yes +ProtectClock=yes +ProtectControlGroups=yes +ProtectKernelModules=yes +ProtectKernelTunables=yes +LockPersonality=yes +RestrictRealtime=yes +RestrictSUIDSGID=yes +RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 ReadWritePaths=/var/lib/cortex MemoryMax=2G +TasksMax=256 [Install] WantedBy=multi-user.target diff --git a/deploy/update.sh b/deploy/update.sh index f82974b8..e028c643 100755 --- a/deploy/update.sh +++ b/deploy/update.sh @@ -13,7 +13,10 @@ echo "==> Pre-deploy safety snapshot" /usr/local/bin/cortex-backup || echo "!! backup failed (continuing; investigate after)" echo "==> Installing requirements" -/srv/cortex/venv/bin/pip install --quiet -r "$NEW_RELEASE/backend/requirements.txt" -r "$NEW_RELEASE/backend/runtime-requirements.txt" uvicorn +/srv/cortex/venv/bin/pip install --quiet --require-hashes \ + -r "$NEW_RELEASE/backend/requirements.lock" +/srv/cortex/venv/bin/pip install --quiet --require-hashes \ + -r "$NEW_RELEASE/backend/runtime-requirements.lock" echo "==> Switching current -> $NEW_RELEASE" ln -sfn "$NEW_RELEASE" /srv/cortex/current diff --git a/docs/ADDING_A_CONNECTOR.md b/docs/ADDING_A_CONNECTOR.md new file mode 100644 index 00000000..87d99fbe --- /dev/null +++ b/docs/ADDING_A_CONNECTOR.md @@ -0,0 +1,226 @@ +# Adding a Cortex Connector + +This guide separates two different jobs: + +1. **Send records from an existing integration.** Use the generic source-account + API. You do not need to modify Cortex. +2. **Ship a first-party connector.** Add a source fetcher, credentials/setup + metadata, scheduled sync, API parity, UI wiring, and contract tests. + +Start with the generic API unless the integration must be configured and +scheduled by Cortex itself. + +## Send One Cited Record Without Changing Cortex + +Start the development server: + +```bash +CORTEX_AUTO_APPROVE_CAPTURES=0 make run +``` + +In another terminal, run this complete review-first workflow: + +```bash +export CORTEX_BASE_URL="${CORTEX_BASE_URL:-http://127.0.0.1:8766}" +export CORTEX_API_KEY=dev-local-key + +ACCOUNT_JSON="$( + curl --fail --silent --show-error \ + -H "Authorization: Bearer $CORTEX_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{ + "source": "custom-notes", + "account_label": "Connector tutorial", + "account_identifier": "tutorial@example.invalid", + "connection_type": "api", + "status": "connected", + "auth_state": "healthy", + "policy": {"review_required": true, "allow_ai_context": true} + }' \ + "$CORTEX_BASE_URL/v1/source-accounts" +)" +ACCOUNT_ID="$( + printf '%s' "$ACCOUNT_JSON" | + .venv/bin/python -c 'import json,sys; print(json.load(sys.stdin)["id"])' +)" + +SYNC_JSON="$( + curl --fail --silent --show-error \ + -H "Authorization: Bearer $CORTEX_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{ + "records": [{ + "external_id": "tutorial-record-1", + "title": "Project Firefly launch", + "content": "Project Firefly launches on 15 August. Mina Chen is the DRI.", + "source_url": "https://example.invalid/tutorial/project-firefly", + "captured_at": "2026-07-30T12:00:00Z", + "metadata": {"kind": "project-note"} + }], + "processing": "sync", + "cursor_value": "tutorial-record-1" + }' \ + "$CORTEX_BASE_URL/v1/source-accounts/$ACCOUNT_ID/sync" +)" +CAPTURE_ID="$( + printf '%s' "$SYNC_JSON" | + .venv/bin/python -c 'import json,sys; print(json.load(sys.stdin)["capture_ids"][0])' +)" + +# Review-first invariant: the pending record must not be retrievable. +curl --fail --silent --show-error --get \ + -H "Authorization: Bearer $CORTEX_API_KEY" \ + --data-urlencode "query=Who is the DRI for Project Firefly?" \ + --data-urlencode "limit=5" \ + "$CORTEX_BASE_URL/v1/search" | + .venv/bin/python -c ' +import json, sys +base = "https://example.invalid/tutorial/project-firefly" +results = json.load(sys.stdin).get("results", []) +assert all(not str(item.get("source_url", "")).startswith(base) for item in results) +print("pending capture is correctly excluded from retrieval") +' + +curl --fail --silent --show-error -X POST \ + -H "Authorization: Bearer $CORTEX_API_KEY" \ + "$CORTEX_BASE_URL/v1/captures/$CAPTURE_ID/approve" >/dev/null + +curl --fail --silent --show-error --get \ + -H "Authorization: Bearer $CORTEX_API_KEY" \ + --data-urlencode "query=Who is the DRI for Project Firefly?" \ + --data-urlencode "limit=5" \ + "$CORTEX_BASE_URL/v1/ask" | + .venv/bin/python -m json.tool +``` + +The cited answer should include the original `source_url` as its base. Cortex +may append a generated `#line=...&excerpt=...` locator so clients can open the +specific cited passage. Keep `external_id` stable: re-sending the same source +record updates its capture instead of duplicating it. + +## The Normalized Record Contract + +A connector converts vendor data into at most 500 records per sync request: + +| Field | Required | Rule | +|---|---:|---| +| `content` | yes | Plain, useful source text; maximum 200,000 characters | +| `external_id` | strongly recommended | Stable and unique within the source account | +| `title` | no | Human-readable source title | +| `source_url` | no | Original deep link; Cortex creates a stable locator if absent | +| `captured_at` | no | Source timestamp in ISO 8601 form | +| `metadata` | no | Non-secret filtering and provenance fields | + +Never put access tokens, session cookies, authorization headers, or refresh +tokens in record content, metadata, errors, logs, or fixtures. + +## First-Party Connector Architecture + +Use [`backend/app/connectors/raindrop.py`](../backend/app/connectors/raindrop.py) +as the smallest token-based reference and +[`backend/app/connectors/google_drive.py`](../backend/app/connectors/google_drive.py) +as an OAuth/pagination reference. + +```mermaid +flowchart LR + Setup["Connections UI or API setup"] --> Credentials["Local credential store"] + Scheduler["Sync scheduler"] --> Fetcher["Connector fetcher"] + Credentials --> Fetcher + Cursor["Durable sync cursor"] --> Fetcher + Fetcher --> Normalize["Normalized source records"] + Normalize --> Sync["CortexStore.sync_source_account_records"] + Sync --> Review["Review policy"] + Review --> Vault["Cited vault + rebuildable index"] +``` + +The fetcher is separate from persistence. It accepts credentials and cursor +state, performs bounded read-only requests, and returns normalized records plus +the next cursor. `CortexStore` owns deduplication, review policy, extraction, +provenance, and vault writes. + +## Implementation Checklist + +### 1. Fetch and normalize + +- Add `backend/app/connectors/.py`. +- Return immutable record and sync-result dataclasses with a + `to_source_account_record()` adapter. +- Bound page size, pages per sync, total records, response size, and request + timeouts. +- Preserve a stable vendor record ID, original deep link, and source timestamp. +- Make pagination resumable. Do not advance the durable high-water mark until a + bounded scan completes. +- Use `backend/app/http_security.py` for outbound requests and enforce the + official HTTPS origin. Redirects must not escape the allowed origin. +- Redact credentials and authorization headers from every error with + `backend/app/connectors/_redaction.py`. + +### 2. Register the connector + +- Export the fetcher and record types from + `backend/app/connectors/__init__.py`. +- Add setup/auth/readiness metadata to `CONNECTOR_SETUP_BLUEPRINTS` in + `backend/app/storage.py`. +- Add or update the public connector catalog entry used by + `source_connector_catalog()`. +- Add a `CortexStore` sync method that reads credentials, calls the fetcher, + passes records to `sync_source_account_records()`, and persists cursor/error + state. +- Register scheduled dispatch in the source-sync scheduler. A manual sync and a + scheduled sync must call the same storage method. + +### 3. Preserve both HTTP runtimes + +- Add request/response fields to `backend/app/models.py` when the generic + contract is insufficient. +- Add FastAPI routes in `backend/app/main.py`. +- Add matching behavior in `backend/app/standalone_server.py`. The standalone + server is the runtime shipped in the macOS app; FastAPI-only support is + incomplete. +- Add an MCP tool in `backend/app/mcp_tools.py` only if an AI client needs the + operation. Give it the narrowest capability (`read`, `write`, or + `maintenance`). + +### 4. Add the product setup path + +- Add the connector to the macOS Connections & Privacy catalog and setup flow. +- Store secrets only through the existing credential/Keychain boundary. +- Show required scopes before authorization, support disconnect/resume, and + surface a redacted `last_error`. +- Keep file/export import as the fallback path when live authorization is not + release-ready. + +### 5. Prove the contract + +At minimum, tests must cover: + +- empty/malformed/oversized vendor responses; +- pagination, cursor replay, and an interrupted scan; +- stable IDs, unchanged-record deduplication, and changed-record replacement; +- duplicate text with different external IDs; +- citation/deep-link preservation; +- credential and error redaction; +- origin/redirect enforcement; +- disconnect, resume, and scheduled sync; +- FastAPI and standalone-server parity; +- connector catalog and macOS UI visibility. + +Use deterministic fixtures and injected request functions. Tests must not call +the real vendor service. + +```bash +.venv/bin/python -m pytest backend/tests/test__connector.py -q +.venv/bin/python scripts/check_connector_baseline.py +.venv/bin/python scripts/examples_smoke.py --quickstart --runtime standalone +make check +``` + +## Definition of Done + +A connector is ready only when a new user can discover it, understand its +scopes, connect or disconnect it, complete an incremental sync, review the +result, and retrieve a source-bearing citation. The same workflow must pass in +the packaged standalone runtime and the FastAPI development runtime. + +If any of those pieces is intentionally absent, label the connector as +experimental or export-only in the catalog instead of presenting it as ready. diff --git a/docs/API_LIFECYCLE.md b/docs/API_LIFECYCLE.md index e5feeeec..7ce50744 100644 --- a/docs/API_LIFECYCLE.md +++ b/docs/API_LIFECYCLE.md @@ -27,6 +27,12 @@ Cortex is currently beta. Additive response fields, new endpoints, new optional request fields, and new enum values may ship in a minor release. Consumers must ignore response fields they do not recognize. +The API/SDK compatibility version is currently `0.1.0`; it is independent from +the desktop application release version (`0.2.0`, build 51 at the time of this +review). OpenAPI, MCP server metadata, and backend diagnostics all read the +shared `BACKEND_VERSION` constant. Do not infer API compatibility from a DMG or +Sparkle build number. + A change is breaking when an existing valid request stops working, a field is removed or changes meaning/type, an authentication scope becomes insufficient, or an SDK method changes incompatibly. Breaking changes require one of: diff --git a/docs/APPLE_RELEASE.md b/docs/APPLE_RELEASE.md index 8ccef592..eb4e094f 100644 --- a/docs/APPLE_RELEASE.md +++ b/docs/APPLE_RELEASE.md @@ -305,12 +305,14 @@ or Obsidian/local notes and complete one sync, approve one memory, ask Cortex an verify a cited memory appears, create one backup, export one support bundle, quit + relaunch, and verify the memory folder remains intact. -## Current public release blockers (DMG path) - -- Developer ID signing -- notarization -- hosted HTTPS download domain -- public privacy policy URL and support email -- clean rollback archive for the previous DMG -- final app icon and final landing/privacy copy review -- test on a clean Mac that has never run the dev build +## Current public release status (DMG path) + +The canonical public manifest, `site/downloads/latest.json`, records Cortex +0.2.0 build 51 as Developer ID signed, notarized, and hosted on GitHub Releases. +That statement applies only to the exact artifacts and SHA-256 values in the +manifest. + +Cutting a later release still requires the signing identity, notary profile, +clean-profile install test, rollback archive, checksum verification, and +publication order described above. If any of those checks is unavailable, keep +the candidate internal and do not replace the public manifest. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 7d286320..d7130e73 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -46,16 +46,16 @@ hosted connectors are restricted to their official service origins. The original prototype has these pieces: -- `capture.py`: macOS menu bar capture using Python -- `ingest.py`: Claude-based extraction into records, tasks, and entities -- `github_store.py`: markdown persistence in GitHub -- `redis_store.py`: Voyage embeddings plus Redis vector search -- `mcp_server.py`: legacy prototype local stdio MCP server; packaged builds use the app-bundled `scripts/cortex_mcp_stdio.py` proxy instead -- `ui.py`: Streamlit memory chat +- `legacy/capture.py`: macOS menu bar capture using Python +- `legacy/ingest.py`: Claude-based extraction into records, tasks, and entities +- `legacy/github_store.py`: markdown persistence in GitHub +- `legacy/redis_store.py`: Voyage embeddings plus Redis vector search +- `legacy/mcp_server.py`: legacy prototype local stdio MCP server; packaged builds use the app-bundled `scripts/cortex_mcp_stdio.py` proxy instead +- `legacy/ui.py`: Streamlit memory chat -These root-level modules are retained for reference and are not used by the -packaged app. New product work belongs in `backend/app/`, `macos/`, the SDKs, or -the integration packages. +These modules are isolated under `legacy/`, retained for reference, and not +used by the packaged app. New product work belongs in `backend/app/`, `macos/`, +the SDKs, or the integration packages. ## Current Product @@ -72,7 +72,9 @@ macOS app Obsidian/local notes sync | v -FastAPI backend +Shared API surface + FastAPI: development and optional hosted plane + standalone_server.py: packaged local subset /v1/captures /v1/captures/queue /v1/captures/{id}/status @@ -266,6 +268,7 @@ Each memory also has a retrieval layer: - decision - preference - negative +- procedural ### Entity diff --git a/docs/BENCHMARKS.md b/docs/BENCHMARKS.md index 1c78babe..638f6f47 100644 --- a/docs/BENCHMARKS.md +++ b/docs/BENCHMARKS.md @@ -6,11 +6,16 @@ context packing, adaptation, token calibration, routing, and end-to-end agent tasks. They run on synthetic fixtures with known answers so regressions fail in CI without sending personal data to an external model. -## Latest deterministic regression snapshot +## Recorded deterministic hash/FTS regression snapshot Run on 2026-07-30 with Python 3.12 on Apple Silicon. All commands used the repository's checked-in fixtures and made no model API calls. +This checkout did not contain the packaged Model2Vec dependency and weights. +The retrieval row below therefore measures the deterministic hash/FTS +regression path, not semantic retrieval, hybrid lift, or the shipped model +asset. The distinction is part of the result, not an implementation detail. + Machine-readable provenance and limitations are committed in [`benchmarks/2026-07-30-local.json`](benchmarks/2026-07-30-local.json). This was one run from a dirty review tree based on commit `8eedd5c`; timing and controlled @@ -19,7 +24,8 @@ a latency benchmark or a release artifact. | Gate | Cases / checks | Result | Selected metrics | |---|---:|---|---| -| Retrieval | 163 labeled queries | Pass | top-1 accuracy 1.000; recall@3 1.000; precision@3 0.947 | +| Deterministic hash/FTS retrieval | 163 labeled queries | Pass | top-1 accuracy 1.000; recall@3 1.000; precision@3 0.947 | +| Model2Vec semantic/rerank lift | Not executed in this snapshot | Not measured | Release/model-provisioned environments must run the non-skipping command below | | Relevance monotonicity | 160 reordered cases / 41 pairs | Pass | pairwise concordance 1.000; reordered top-1 1.000 | | Context packing | 10 tasks / 30 checks | Pass | citation coverage 1.000; MRR 1.000; nDCG@k 0.888; no-leak 1.000; budget adherence 1.000 | | Session replay | 2 sessions / 9 turns | Pass | recall@3 1.000; prefetch hit rate 0.750; delta token savings 28.5%; no resend/leak 1.000 | @@ -36,20 +42,33 @@ make setup .venv/bin/python scripts/token_calibration_eval.py ``` +To verify the real semantic path, provision the local Model2Vec package and +model asset, then require a non-skipping run: + +```bash +CORTEX_EMBEDDING_PROVIDER=model2vec \ + .venv/bin/python scripts/rerank_eval.py --forbid-skip +``` + +Without the package/model, `rerank_eval.py` reports `status: skipped`; +`--forbid-skip` converts that missing evidence into a failing gate. Release +validation also uses `scripts/check_vector_runtime.py --require-model2vec`. + `make check` runs the fast pre-PR subset. The full CI workflow adds routing, adapter, delivery, agent-task, connector, docs, security, and packaging gates. ## What these numbers mean -- **Top-1 accuracy / recall** measure whether the known relevant fixture is - returned at the expected rank. +- **Top-1 accuracy / recall** measure whether the known relevant synthetic + fixture is returned at the expected rank on the named retrieval path. - **Citation coverage** requires packed evidence to retain provenance. - **No-leak** fails when a deliberately disallowed cross-project memory appears. - **Budget adherence** checks the serialized context against its token budget. - **Session token savings** compares delta delivery with naively resending a full context pack every turn. - **Calibration MAPE** measures the error between a profile's estimate and the - harness token count. + harness's simulated reference count. It is not validation against a live + vendor tokenizer. ## Limitations @@ -59,6 +78,12 @@ known Cortex behaviors. The token-calibration harness uses simulated reference counts rather than live vendor tokenizers. Runtime varies by hardware and warm cache state, so this page does not claim a production latency percentile. +The committed snapshot is also a one-run record from a dirty review tree. It +does not include a complete dirty diff, raw per-case artifacts, repetitions, +or controlled cache timings; it cannot reconstruct the exact executable state +as a scientific artifact. Re-run the current checkout before relying on the +numbers for engineering decisions. + Before making broad quality claims, Cortex still needs: 1. a versioned, consented corpus of real user tasks; diff --git a/docs/CODE_MAP.md b/docs/CODE_MAP.md new file mode 100644 index 00000000..71487cbb --- /dev/null +++ b/docs/CODE_MAP.md @@ -0,0 +1,68 @@ +# Cortex Contributor Code Map + +Use this page to find the first file to open. Cortex has two HTTP runtimes around +one storage/retrieval core, so runtime-facing changes usually need parity tests. + +| I want to change… | Start here | Verify with | +|---|---|---| +| FastAPI development/hosted route | [`backend/app/main.py`](../backend/app/main.py), [`backend/app/models.py`](../backend/app/models.py) | [`backend/tests/test_fastapi_contract.py`](../backend/tests/test_fastapi_contract.py) | +| Packaged local HTTP behavior | [`backend/app/standalone_server.py`](../backend/app/standalone_server.py) | [`backend/tests/test_standalone_server.py`](../backend/tests/test_standalone_server.py) | +| Capture extraction | [`backend/app/extractor.py`](../backend/app/extractor.py) and capture methods in [`storage.py`](../backend/app/storage.py) | [`test_extractor_quality.py`](../backend/tests/test_extractor_quality.py), [`test_storage_lifecycle.py`](../backend/tests/test_storage_lifecycle.py) | +| Search/ranking | Retrieval section near `_search_rows` in [`backend/app/storage.py`](../backend/app/storage.py) | [`test_retrieval_quality.py`](../backend/tests/test_retrieval_quality.py), [`scripts/retrieval_eval.py`](../scripts/retrieval_eval.py) | +| Context packs / CMP | Context-pack methods in [`backend/app/storage.py`](../backend/app/storage.py), [`backend/app/models.py`](../backend/app/models.py) | [`scripts/context_pack_eval.py`](../scripts/context_pack_eval.py) | +| Embeddings/vector index | [`backend/app/embeddings.py`](../backend/app/embeddings.py), vector-index section in [`storage.py`](../backend/app/storage.py) | [`test_embeddings.py`](../backend/tests/test_embeddings.py), [`test_vector_dim_migration.py`](../backend/tests/test_vector_dim_migration.py) | +| A source connector | [`backend/app/connectors/`](../backend/app/connectors), connector setup/scheduler methods in [`storage.py`](../backend/app/storage.py) | [`ADDING_A_CONNECTOR.md`](ADDING_A_CONNECTOR.md) | +| MCP tool behavior | [`backend/app/mcp_tools.py`](../backend/app/mcp_tools.py) | [`backend/tests/`](../backend/tests) (`test_mcp_*.py`) | +| Vault, backup, and repair | [`backend/app/vault.py`](../backend/app/vault.py), lifecycle/maintenance methods in [`storage.py`](../backend/app/storage.py) | [`backend/tests/`](../backend/tests) (`test_vault*.py`), [`test_native_vault_source_of_truth.py`](../backend/tests/test_native_vault_source_of_truth.py), [`test_job_reliability.py`](../backend/tests/test_job_reliability.py) | +| Authentication and capabilities | [`backend/app/authn.py`](../backend/app/authn.py), [`backend/app/accounts.py`](../backend/app/accounts.py), route/tool policies in [`main.py`](../backend/app/main.py) and [`mcp_tools.py`](../backend/app/mcp_tools.py) | [`test_authn_sessions.py`](../backend/tests/test_authn_sessions.py), [`test_auth_http.py`](../backend/tests/test_auth_http.py), capability contract tests | +| Python SDK | [`sdk/python/cortex_client/client.py`](../sdk/python/cortex_client/client.py) | [`sdk/python/tests/`](../sdk/python/tests) | +| TypeScript SDK | [`sdk/typescript/src/index.ts`](../sdk/typescript/src/index.ts) | `npm test` in [`sdk/typescript/`](../sdk/typescript) | +| macOS UI / lifecycle | [`macos/Sources/`](../macos/Sources) | `swift test` in [`macos/`](../macos) | +| Public examples | [`examples/`](../examples) | `make examples-check` | +| Release metadata/site | [`site/`](../site), [`macos/package_release.sh`](../macos/package_release.sh) | `make docs-check` | + +## Runtime Parity + +`backend/app/main.py` is the FastAPI development and optional hosted surface. +`backend/app/standalone_server.py` is the dependency-light server bundled in +the desktop app. Both call the same `CortexStore`, but they have separate HTTP +adapters. + +When adding or changing an endpoint: + +1. update the shared model/store behavior; +2. update both HTTP adapters when the endpoint belongs in local mode; +3. add one FastAPI contract test and one standalone-server contract test; +4. run `make runtime-check`. + +Hosted-only filesystem operations must stay hosted-safe: a path sent to FastAPI +refers to the server filesystem, never the user's Mac. + +## About `storage.py` + +`backend/app/storage.py` is a large compatibility boundary that currently owns +schema migrations and several legacy domain services. Avoid adding an unrelated +new subsystem to it. + +- Put pure parsing, vendor HTTP, scoring, or transformation logic in a focused + module. +- Keep `CortexStore` as the transaction/orchestration boundary. +- Reuse public store methods instead of duplicating persistence rules in routes. +- Add a narrow private helper when behavior must remain transaction-local. +- Preserve vault-first durability: SQLite is a rebuildable index, not the sole + source of truth. + +This is an architectural direction, not a claim that the existing module has +already been split. + +## Fast Validation Loop + +```bash +make doctor +make check +make runtime-check +``` + +Run `make test` before a broad backend pull request. SDK-only changes can use +their focused test suites first, but `make check` remains the required local +pre-PR gate. diff --git a/docs/MCP_INTEGRATIONS.md b/docs/MCP_INTEGRATIONS.md index fff7c621..25c4ef4f 100644 --- a/docs/MCP_INTEGRATIONS.md +++ b/docs/MCP_INTEGRATIONS.md @@ -1,5 +1,21 @@ # Cortex AI Integrations +## Fastest path + +For the installed macOS app: + +1. Open **Connections & Privacy → AI tools**. +2. Choose Claude Desktop, Cursor, Windsurf, Cline, or Roo Code. +3. Select **Install**. Cortex backs up and merges the client's existing config. +4. Restart the AI client. +5. Ask it to call `ask_memory` or `search_memory`. + +Use **Repair** in the same screen if the app moved or the token changed. Do not +copy the app's admin token into a client; the installer creates a scoped `cxm_` +MCP token. Client-specific paths and the manual fallback are documented below. +For connection failures, jump to +[Troubleshooting](TROUBLESHOOTING.md#an-mcp-client-cannot-connect). + ## Local Transport Cortex exposes two MCP-compatible paths: diff --git a/docs/OPEN_SOURCE_READINESS.md b/docs/OPEN_SOURCE_READINESS.md index b69c1be7..08307575 100644 --- a/docs/OPEN_SOURCE_READINESS.md +++ b/docs/OPEN_SOURCE_READINESS.md @@ -4,10 +4,8 @@ Last reviewed: 2026-07-30 Review posture: independent, reject by default -Review baseline: source commit `8eedd5c` on the local -`docs/repo-refresh` branch. The corrections described below are the review -delta from that baseline; this document must be updated with a new baseline -before it is reused as evidence for a later release. +Review scope: the checked-out source tree through the date above. This document +must be reviewed again before it is reused as evidence for a later release. This is an evidence-based engineering review of the checked-out source tree, not a security certification. It used local source and local test tooling only; diff --git a/docs/README.md b/docs/README.md index 5e2d52b4..375ceeff 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,10 +1,9 @@ # Cortex Documentation This index separates the current product contract from operator runbooks, -forward-looking designs, and experimental feature branches. It was last audited -against source commit `8eedd5c` plus the local `docs/repo-refresh` review delta -on 2026-07-30. It does not claim that unmerged branch content is already in -`main`. +forward-looking designs, and experimental feature branches. It was last +audited against the checked-out source on 2026-07-30. It does not claim that +unmerged branch content is already in `main`. ## Status Rules @@ -26,12 +25,12 @@ When documents disagree, use this order of authority: | Area | Documentation | |---|---| -| Architecture | [ARCHITECTURE.md](ARCHITECTURE.md) | +| Architecture and code map | [ARCHITECTURE.md](ARCHITECTURE.md) · [CODE_MAP.md](CODE_MAP.md) | | API lifecycle and naming | [API_LIFECYCLE.md](API_LIFECYCLE.md) · [NAMING_AND_OWNERSHIP.md](NAMING_AND_OWNERSHIP.md) | | Product loop and onboarding | [SIMPLE_PRODUCT_LOOP.md](SIMPLE_PRODUCT_LOOP.md) · [FIRST_RUN_ONBOARDING.md](FIRST_RUN_ONBOARDING.md) | | Local vault | [LOCAL_VAULT_FORMAT.md](LOCAL_VAULT_FORMAT.md) | | Context protocol | [CMP_PROTOCOL.md](CMP_PROTOCOL.md) · [PORTABLE_MEMORY_PROTOCOL_V2.md](PORTABLE_MEMORY_PROTOCOL_V2.md) | -| Source ingestion | [SOURCE_IMPORTS.md](SOURCE_IMPORTS.md) · [CAPTURE_SURFACES.md](CAPTURE_SURFACES.md) | +| Source ingestion and extension | [SOURCE_IMPORTS.md](SOURCE_IMPORTS.md) · [ADDING_A_CONNECTOR.md](ADDING_A_CONNECTOR.md) · [CAPTURE_SURFACES.md](CAPTURE_SURFACES.md) | | MCP and external tools | [MCP_INTEGRATIONS.md](MCP_INTEGRATIONS.md) · [EXTERNAL_INTEGRATIONS.md](EXTERNAL_INTEGRATIONS.md) | | Connector readiness | [CONNECTOR_COVERAGE_READINESS.md](CONNECTOR_COVERAGE_READINESS.md) | | Trust and privacy | [TRUST_CONTROLS.md](TRUST_CONTROLS.md) · [SECURITY_REVIEW.md](SECURITY_REVIEW.md) | diff --git a/docs/SOURCE_IMPORTS.md b/docs/SOURCE_IMPORTS.md index 9bd7f383..368c19c8 100644 --- a/docs/SOURCE_IMPORTS.md +++ b/docs/SOURCE_IMPORTS.md @@ -1,5 +1,10 @@ # Source Ingestion +> **Choose your path:** To send records from an existing integration or build a +> new first-party source, use the complete +> [connector guide](ADDING_A_CONNECTOR.md). This page is the product support +> matrix and source-account contract reference. + > **Current connector note (audited 2026-07-30):** In addition to the export > parsers below, the backend now has read-only live connectors for GitHub, > Gmail, Google Drive, Outlook, Slack, Readwise, Calendar, Raindrop, Zotero, @@ -7,11 +12,12 @@ > Google, Microsoft, and Notion managed OAuth flows exist in code, but the > direct build currently ships their client IDs empty; those connectors > therefore use a user-supplied token/key unless a release configures OAuth. -> Where older rollout notes below say those connectors still need to be built, -> this note and the executable `CONNECTOR_SETUP_BLUEPRINTS` contract in -> `backend/app/storage.py` are authoritative. -Cortex source ingestion turns connected services, local app connectors, MCP bridges, and support fallback imports into normalized memory candidates. The first-100-user product should lead with connected paths that can register a source account, stream records through `/v1/source-accounts/{account_id}/sync`, preserve citations, and route new records through Review. +Cortex source ingestion turns connected services, local app connectors, MCP +bridges, and support fallback imports into normalized memory candidates. The +current product leads with connected paths that register a source account, +stream records through `/v1/source-accounts/{account_id}/sync`, preserve +citations, and route new records through Review. Local export/file import still exists as backend infrastructure for tests, migrations, unsupported services, and support recovery. It should not be the normal first-run product path. @@ -71,7 +77,10 @@ GET /v1/sources/readiness A connector sends records with `content`, optional `title`, original `source_url`, `external_id`, `captured_at`, and metadata. If `source_url` is absent, Cortex generates a stable `source-account://{source}/{account_id}/{external_id}` locator. `processing: "sync"` extracts immediately; `processing: "async"` stores raw captures and queues extraction. -The response reports saved, queued, skipped, failed, generated capture IDs, per-record statuses, and the updated cursor. This is the contract that should power local app integrations, MCP bridges, and future Gmail, Notion, Slack, Drive, Calendar, GitHub, Mail, Messages, browser, and AI-tool connectors. +The response reports saved, queued, skipped, failed, generated capture IDs, +per-record statuses, and the updated cursor. The shipped read-only connectors, +local app integrations, and MCP bridges use this contract; new connectors +should extend it instead of inventing a second ingestion path. For account-backed sync, `source_account_id + external_id` is the durable record identity. Re-syncing the same external record with unchanged content is skipped; re-syncing it with changed content replaces the capture's derived memories, tasks, graph edges, and queued extraction work under the same capture ID. Two different external records are allowed to produce separate captures even if their current text is identical, because service records often share boilerplate, signatures, or short repeated status text. @@ -151,7 +160,12 @@ GET /v1/sources/readiness The catalog lists common services such as ChatGPT, Claude, Gmail, Apple Mail, Outlook, email files, docs, PDFs, cloud-doc exports, Notion, Google Drive, Google Docs, Google Keep, Microsoft 365, Slack, Google Chat, Teams, Discord, Telegram, Messages, iMessage exports, WhatsApp, Calendar, Contacts, GitHub, Linear, Jira, Zoom, Browser Bookmarks, browser history exports, Readwise, Raindrop, Zotero, LinkedIn, Twitter/X, Apple Notes, and Obsidian. Each entry includes current connector readiness, future live-sync status, auth type, scopes, supported formats, `export_status`, and the canonical `source_ids` that captures and memories will use. -Branded connectors can map to canonical memory sources. For example, Gmail, Apple Mail, and Outlook mail records can map to `email`, Google Drive and Google Docs records can map to `cloud-docs` or `docs`, PDFs map to `docs`, iMessage records map to `messages`, GitHub records map to `github` or `work-tools`, and Readwise/Raindrop/Zotero records map to their branded sources or `knowledge-base`. Connections & Privacy should show working account/local-note connections first and keep export/file import under Advanced/Fallback when a source cannot connect directly yet. +Branded connectors can map to canonical memory sources. For example, Gmail, +Apple Mail, and Outlook mail records can map to `email`; Google Drive and +Google Docs records can map to `cloud-docs` or `docs`; and GitHub records can +map to `github` or `work-tools`. Connections & Privacy shows working +account/local-note connections first and keeps export/file import under +Advanced/Fallback when a source cannot connect directly yet. See `docs/CONNECTOR_COVERAGE_READINESS.md` for the first-100-user beta coverage map and the later live OAuth readiness gates. @@ -220,12 +234,12 @@ When aliases match a Slack handle/name or email sender, that text can seed user- ## Product Flow -Connections & Privacy should present only useful connection paths in the default flow. For the first-100-user checkpoint, those are: - -- MCP AI tools for connected assistant access and memory writes. -- Obsidian vault sync as the first native local notes connector. - -Future source rows should follow the same pattern: connect or authorize the source, register a source account, sync records, then let Review decide what becomes trusted memory. Manual sync controls, export/file import, and copy-oriented fallback flows should stay inside Advanced/Fallback, not Home, Review, or Ask. +Connections & Privacy presents connection paths whose catalog readiness is +implemented. Each follows the same product loop: connect or authorize the +source, register a source account, sync records, then let Review decide what +becomes trusted memory. MCP AI-tool setup and Obsidian local-folder sync remain +the provider-neutral paths. Export/file import and copy-oriented fallback flows +stay inside Advanced/Fallback rather than Home, Review, or Ask. ## Privacy Boundaries @@ -247,5 +261,6 @@ Future source rows should follow the same pattern: connect or authorize the sour Twitter/X, Zoom, and browser history. - Advanced/Fallback import remains available for services that cannot connect directly yet. - PDF extraction depends on optional `pypdf`; otherwise the macOS fallback can extract PDFs selected through the app. -- Very large exports are capped by record count and per-record character limits, then chunking/reranking should be improved in the next ingestion pass. +- Very large exports are capped by record count and per-record character limits; + chunking and ranking quality at those limits remain an open evaluation area. - The importer normalizes data into candidate captures; extraction quality still depends on the local heuristic extractor or the configured LLM extractor. diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index 7a656214..3f2e2076 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -3,16 +3,20 @@ ## `make setup` says Python 3.12 is missing The macOS system `python3` may be 3.9 and is not supported by this repository. -Install Python 3.12 and point setup at it: +On a Homebrew-based Mac: ```bash -make setup PYTHON=/absolute/path/to/python3.12 +brew install python@3.12 +make doctor +make demo ``` -Verify without creating a virtual environment: +On another platform, install Python 3.12 with your system or Python version +manager, then point Cortex at it: ```bash -PYTHON_BIN=/absolute/path/to/python3.12 ./scripts/bootstrap_dev.sh --check +make doctor PYTHON=/absolute/path/to/python3.12 +make demo PYTHON=/absolute/path/to/python3.12 ``` ## `make run` cannot import FastAPI or uvicorn @@ -26,10 +30,12 @@ The packaged app may already be running. Either quit Cortex or use a different development port: ```bash -CORTEX_PORT=8877 make run +make run ``` -Keep clients pointed at the same base URL. +On a collision, `make run` prints an actually available port and the exact +`CORTEX_BASE_URL` export for clients. Re-run the suggested command instead of +relying on a fixed fallback port, which may also be occupied. ## The app opens but the full local engine is unavailable diff --git a/docs/benchmarks/2026-07-30-local.json b/docs/benchmarks/2026-07-30-local.json index da7c248c..7109e49e 100644 --- a/docs/benchmarks/2026-07-30-local.json +++ b/docs/benchmarks/2026-07-30-local.json @@ -12,7 +12,9 @@ "os": "Darwin 25.5.0 arm64", "hardware": "Apple M1 Pro, 10 cores, 16 GB RAM", "network_used": false, - "model_api_calls": false + "model_api_calls": false, + "retrieval_provider": "hash", + "semantic_model_asset_present": false }, "method": { "repetitions": 1, @@ -30,6 +32,7 @@ "results": { "retrieval": { "status": "ok", + "path": "deterministic_hash_fts", "cases": 163, "top1_accuracy": 1.0, "recall_at_3": 1.0, @@ -42,6 +45,10 @@ "pairwise_concordance": 1.0, "reordered_top1": 1.0 }, + "semantic_rerank": { + "status": "not_measured", + "reason": "model2vec package/model asset was not provisioned for this snapshot" + }, "context_packing": { "status": "ok", "tasks": 10, @@ -79,5 +86,8 @@ "Only one repetition was run, so variance and confidence intervals are unavailable.", "Wall-clock timing and controlled cold/warm cache state were not captured.", "The fixtures are synthetic and do not establish real-user answer quality." + ,"The retrieval result measures the deterministic hash/FTS path, not Model2Vec semantic or hybrid retrieval." + ,"The token-calibration references are simulated rather than live vendor tokenizer counts." + ,"Raw per-case artifacts and the dirty working-tree diff were not retained." ] } diff --git a/examples/README.md b/examples/README.md index b8a998f9..74dc9be4 100644 --- a/examples/README.md +++ b/examples/README.md @@ -4,11 +4,22 @@ These examples use the dependency-free Python SDK against a running Cortex server. They are intentionally provider-neutral: Cortex supplies cited memory; your application decides which model or agent framework consumes it. +If you have not run Cortex yet, start with the repository-level disposable +demo: + +```bash +make demo +``` + +That proves capture → retrieval → cited Ask without touching personal data. +Return here when you want a persistent server and code you can modify. + ## Run Locally Terminal 1: ```bash +# Skip this line if `make demo` or `make setup` already created `.venv`. make setup CORTEX_AUTO_APPROVE_CAPTURES=1 make run ``` @@ -36,20 +47,35 @@ a packaged or personal vault. The fixture includes a release decision, a storage decision, and an explicit writing preference, all about or usable with the Project Atlas example task. -## Examples +## Choose an Example + +| Goal | Run | What you get | +|---|---|---| +| Add cited memory search | `.venv/bin/python examples/minimal_search.py` | A concise result and source; add `--json` for retrieval diagnostics | +| Build a complete memory step | `.venv/bin/python examples/memory_workflow.py` | A factual Search/Ask followed by context for a work-shaped task | +| Give roles different context | `.venv/bin/python examples/multi_agent_context.py` | Separate planner, researcher, and writer views of approved memory | +| Discover callable tools | `.venv/bin/python examples/tool_catalog.py` | An OpenAI-compatible tool catalog without calling a model | -| Example | What it demonstrates | -|---|---| -| [`minimal_search.py`](minimal_search.py) | Smallest search request with cited results | -| [`memory_workflow.py`](memory_workflow.py) | Search, cite-or-abstain Ask, and a task-specific context pack | -| [`multi_agent_context.py`](multi_agent_context.py) | Give planner/researcher/writer roles separate bounded context | -| [`tool_catalog.py`](tool_catalog.py) | Fetch the OpenAI-compatible tool catalog without calling a model | +Read the corresponding source: +[`minimal_search.py`](minimal_search.py) · +[`memory_workflow.py`](memory_workflow.py) · +[`multi_agent_context.py`](multi_agent_context.py) · +[`tool_catalog.py`](tool_catalog.py). Every script accepts `CORTEX_BASE_URL` and `CORTEX_API_KEY`. The default base URL is `http://127.0.0.1:8766`; the query/task examples consistently default to Project Atlas so the checked-in fixture produces deterministic, cited output without extra arguments. +`memory_workflow.py` deliberately separates a factual `--query` (for Search and +Ask) from a work-shaped `--task` (for Context). For example: + +```bash +.venv/bin/python examples/memory_workflow.py \ + --query "Which database did we choose for Project Atlas?" \ + --task "Draft the Project Atlas architecture section." +``` + ## Integration Pattern ```text @@ -70,6 +96,18 @@ For model-specific function-calling examples, see the [Python SDK guide](../sdk/python/README.md) and [TypeScript SDK guide](../sdk/typescript/README.md). +## Why Search, Ask, and Context Can Differ + +| Surface | Optimizes for | Expected empty/abstain behavior | +|---|---|---| +| Search | Ranked evidence matching one query | Can return no hits when lexical/semantic evidence is weak | +| Ask | A defensible answer with citations | Abstains when retrieved evidence cannot support an answer | +| Context | A bounded, layer-balanced pack for doing a task | May include useful decisions, preferences, or procedures that do not directly answer the query | + +These are separate contracts, not three aliases for the same retrieval call. A +task context can correctly contain cited background while Ask correctly +abstains from making a specific unsupported claim. + ## Best Practices - Mint the narrowest token scopes the integration needs. diff --git a/examples/memory_workflow.py b/examples/memory_workflow.py index 1def2d59..924e4ed1 100644 --- a/examples/memory_workflow.py +++ b/examples/memory_workflow.py @@ -3,9 +3,9 @@ from __future__ import annotations +import argparse import json import os -import sys from typing import Any from cortex_client import CortexClient @@ -16,17 +16,34 @@ def show(label: str, value: Any) -> None: print(json.dumps(value, indent=2, sort_keys=True)) +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Run factual retrieval and task-context assembly against Cortex." + ) + parser.add_argument( + "--query", + default="When does Project Atlas ship?", + help="Factual question used for Search and cited Ask.", + ) + parser.add_argument( + "--task", + default="Draft a concise Project Atlas release update.", + help="Work-shaped instruction used to assemble a bounded Context pack.", + ) + return parser.parse_args() + + def main() -> None: - task = " ".join(sys.argv[1:]).strip() or "When does Project Atlas ship?" + args = parse_args() client = CortexClient( base_url=os.environ.get("CORTEX_BASE_URL", "http://127.0.0.1:8766"), token=os.environ.get("CORTEX_API_KEY", ""), ) - show("Search", client.search(task, top_k=5)) - show("Cited answer or abstention", client.ask(task, top_k=8)) + show("Search", client.search(args.query, top_k=5)) + show("Cited answer or abstention", client.ask(args.query, top_k=8)) show( "Task context", - client.context(task, intent="draft", token_budget=1200, surface="example"), + client.context(args.task, intent="draft", token_budget=1200, surface="example"), ) diff --git a/examples/minimal_search.py b/examples/minimal_search.py index 4d9d3a3f..7776e3ee 100644 --- a/examples/minimal_search.py +++ b/examples/minimal_search.py @@ -1,23 +1,73 @@ #!/usr/bin/env python3 -"""Search local Cortex memory and print the cited JSON result.""" +"""Search local Cortex memory and print a short cited result.""" from __future__ import annotations +import argparse import json import os -import sys from cortex_client import CortexClient -def main() -> None: - query = " ".join(sys.argv[1:]).strip() or "When does Project Atlas ship?" +def _source_label(item: dict) -> str: + return str( + item.get("source_url") + or item.get("source") + or item.get("memory_id") + or item.get("id") + or "source unavailable" + ).strip() + + +def main() -> int: + parser = argparse.ArgumentParser( + description="Search Cortex and print concise, source-preserving results." + ) + parser.add_argument( + "query", + nargs="*", + help="Question or search terms. Defaults to the Project Atlas demo query.", + ) + parser.add_argument( + "--json", + action="store_true", + help="Print the full retrieval payload and diagnostics as JSON.", + ) + args = parser.parse_args() + query = " ".join(args.query).strip() or "When does Project Atlas ship?" client = CortexClient( base_url=os.environ.get("CORTEX_BASE_URL", "http://127.0.0.1:8766"), token=os.environ.get("CORTEX_API_KEY", ""), ) - print(json.dumps(client.search(query, top_k=5), indent=2, sort_keys=True)) + payload = client.search(query, top_k=5) + if args.json: + print(json.dumps(payload, indent=2, sort_keys=True)) + return 0 + + results = payload.get("results", []) if isinstance(payload, dict) else [] + print(f"Query: {query}") + if not results: + print("No cited memory matched. Approve relevant items or try a more specific query.") + return 0 + + print(f"Found {len(results)} cited result{'s' if len(results) != 1 else ''}:") + for index, item in enumerate(results, start=1): + content = " ".join(str(item.get("content") or item.get("summary") or "").split()) + if len(content) > 220: + content = f"{content[:217].rstrip()}…" + print(f"\n[{index}] {content or '(no excerpt)'}") + print(f" Source: {_source_label(item)}") + + retrieval = payload.get("retrieval") if isinstance(payload, dict) else None + if isinstance(retrieval, dict) and retrieval.get("degraded"): + print( + "\nNote: this development server is using its documented keyword-search " + "fallback. The packaged app bundles on-device semantic embeddings." + ) + print("Run again with --json to inspect retrieval diagnostics.") + return 0 if __name__ == "__main__": - main() + raise SystemExit(main()) diff --git a/legacy/README.md b/legacy/README.md new file mode 100644 index 00000000..acabb9c0 --- /dev/null +++ b/legacy/README.md @@ -0,0 +1,17 @@ +# Archived Prototype + +This directory preserves the original Cortex proof of concept for historical +reference. It used Redis, Voyage embeddings, GitHub-backed storage, Streamlit, +and a Python menu-bar capture app. + +It is **not** the current product, an installation path, or a supported +extension API. Do not install `legacy/requirements.txt` to work on Cortex. + +Current work belongs in: + +- `backend/app/` for storage, retrieval, connectors, and HTTP/MCP behavior; +- `macos/` for the desktop app; +- `sdk/`, `packages/`, or `extension/` for integrations; and +- `scripts/` for maintained developer and release tooling. + +See the root `README.md` and `CONTRIBUTING.md` for supported setup commands. diff --git a/capture.py b/legacy/capture.py similarity index 99% rename from capture.py rename to legacy/capture.py index 0455fa36..e4e8817e 100644 --- a/capture.py +++ b/legacy/capture.py @@ -1,5 +1,5 @@ """ -cortex/capture.py +legacy/capture.py ----------------- Global hotkey capture tool. Lives in your Mac menu bar. diff --git a/github_store.py b/legacy/github_store.py similarity index 99% rename from github_store.py rename to legacy/github_store.py index a891ae96..a04b4d22 100644 --- a/github_store.py +++ b/legacy/github_store.py @@ -1,5 +1,5 @@ """ -cortex/github_store.py +legacy/github_store.py ---------------------- Push extracted context to GitHub repo via the REST API. diff --git a/ingest.py b/legacy/ingest.py similarity index 99% rename from ingest.py rename to legacy/ingest.py index 744dbf47..522aa071 100644 --- a/ingest.py +++ b/legacy/ingest.py @@ -1,5 +1,5 @@ """ -cortex/ingest.py +legacy/ingest.py ---------------- Core extraction pipeline. Takes raw text from any source, calls Claude to extract structured context, returns a dict. diff --git a/instrumentation.py b/legacy/instrumentation.py similarity index 98% rename from instrumentation.py rename to legacy/instrumentation.py index c97fe50a..2c5429b9 100644 --- a/instrumentation.py +++ b/legacy/instrumentation.py @@ -1,5 +1,5 @@ """ -cortex/instrumentation.py +legacy/instrumentation.py -------------------------- Centralized Arize AX tracing setup. diff --git a/mcp_server.py b/legacy/mcp_server.py similarity index 99% rename from mcp_server.py rename to legacy/mcp_server.py index 1062549a..4fccef07 100644 --- a/mcp_server.py +++ b/legacy/mcp_server.py @@ -1,5 +1,5 @@ """ -cortex/mcp_server.py +legacy/mcp_server.py -------------------- Cortex MCP server — exposes your second brain to Claude and any MCP-compatible AI. diff --git a/redis_store.py b/legacy/redis_store.py similarity index 99% rename from redis_store.py rename to legacy/redis_store.py index c13627ae..98d76982 100644 --- a/redis_store.py +++ b/legacy/redis_store.py @@ -1,5 +1,5 @@ """ -cortex/redis_store.py +legacy/redis_store.py --------------------- Embed context items and store in Redis Stack for semantic + metadata search. diff --git a/requirements.txt b/legacy/requirements.txt similarity index 78% rename from requirements.txt rename to legacy/requirements.txt index add22c92..88fd975e 100644 --- a/requirements.txt +++ b/legacy/requirements.txt @@ -1,3 +1,4 @@ +# Archived prototype dependencies; not used by the current product. anthropic>=0.40.0 mcp>=1.0.0 redis[hiredis]>=5.0.0 diff --git a/ui.py b/legacy/ui.py similarity index 99% rename from ui.py rename to legacy/ui.py index a8b501ac..01ebf1a4 100644 --- a/ui.py +++ b/legacy/ui.py @@ -1,5 +1,5 @@ """ -cortex/ui.py +legacy/ui.py ------------ Streamlit chat UI for Cortex — your universal second brain. diff --git a/macos/Sources/CortexApp.swift b/macos/Sources/CortexApp.swift index 17c63f0e..15647945 100644 --- a/macos/Sources/CortexApp.swift +++ b/macos/Sources/CortexApp.swift @@ -5,7 +5,7 @@ import Foundation import SwiftUI import Carbon import Security -import UserNotifications +@preconcurrency import UserNotifications import Darwin import UniformTypeIdentifiers diff --git a/macos/Sources/QuickCapture.swift b/macos/Sources/QuickCapture.swift index 79c4b22e..5f713306 100644 --- a/macos/Sources/QuickCapture.swift +++ b/macos/Sources/QuickCapture.swift @@ -3,7 +3,7 @@ import ApplicationServices import Carbon import CoreGraphics import SwiftUI -import Vision +@preconcurrency import Vision // MARK: - KeyCombo diff --git a/macos/build.sh b/macos/build.sh index 542ee6fd..e048859f 100755 --- a/macos/build.sh +++ b/macos/build.sh @@ -183,7 +183,13 @@ if [[ "$BUNDLE_PYTHON" != "0" && "$BUNDLE_PYTHON" != "false" && "$BUNDLE_PYTHON" # not reject on these symbols) so outbound HTTPS connectors remain available. DYNLOAD="$PY_STDLIB/lib-dynload" strip_ext() { # $1 = glob under lib-dynload - for f in "$DYNLOAD"/$1; do [[ -e "$f" ]] && rm -f "$f" && echo " pruned $(basename "$f")"; done + for f in "$DYNLOAD"/$1; do + if [[ -e "$f" ]]; then + rm -f "$f" + echo " pruned $(basename "$f")" + fi + done + return 0 } echo "Pruning non-public-API C extensions (_tkinter always; _ssl in app-store mode)..." strip_ext "_tkinter*.so" @@ -264,8 +270,9 @@ if [[ "$BUNDLE_PYTHON" != "0" && "$BUNDLE_PYTHON" != "false" && "$BUNDLE_PYTHON" "$PYTHON_FRAMEWORK_SOURCE/bin/python3.12" -m pip install \ --disable-pip-version-check \ --only-binary=:all: \ + --require-hashes \ --target "$PY_RUNTIME_DEPS" \ - -r "$ROOT/../backend/runtime-requirements.txt" + -r "$ROOT/../backend/runtime-requirements.lock" find "$PY_RUNTIME_DEPS" -type d -name "__pycache__" -prune -exec rm -rf {} + find "$PY_RUNTIME_DEPS" -type f -name "*.pyc" -delete # Notarization hygiene: joblib ships intentionally-truncated .gz test pickles that the diff --git a/requirements-dev.in b/requirements-dev.in new file mode 100644 index 00000000..b768eac8 --- /dev/null +++ b/requirements-dev.in @@ -0,0 +1,6 @@ +# Direct dependencies for the Python 3.12 contributor and CI environment. +# +# Regenerate requirements-dev.lock with: +# make lock-python +-r backend/requirements.txt +pytest>=7.0 diff --git a/requirements-dev.lock b/requirements-dev.lock index 0e9f0ea4..683c5c84 100644 --- a/requirements-dev.lock +++ b/requirements-dev.lock @@ -1,37 +1,551 @@ -# Exact Python 3.12 development/test environment. -# Refresh intentionally after reviewing upstream release notes: -# python -m pip install -r backend/requirements.txt pytest -# python -m pip freeze -annotated-doc==0.0.5 -annotated-types==0.8.0 -anthropic==0.120.2 -anyio==4.14.2 -argon2-cffi==25.1.0 -argon2-cffi-bindings==25.1.0 -certifi==2026.7.22 -cffi==2.1.0 -click==8.4.2 -cryptography==49.0.0 -distro==1.9.0 -docstring_parser==0.18.0 -fastapi==0.141.1 -h11==0.16.0 -httpcore==1.0.9 -httpx==0.28.1 -idna==3.18 -iniconfig==2.3.0 -jiter==0.16.0 -packaging==26.2 -pluggy==1.6.0 -pycparser==3.0 -pydantic==2.13.4 -pydantic_core==2.46.4 -Pygments==2.20.0 -pytest==9.1.1 -python-dotenv==1.2.2 -sniffio==1.3.1 -sqlite-vec==0.1.9 -starlette==1.3.1 -typing-inspection==0.4.2 -typing_extensions==4.16.0 -uvicorn==0.52.0 +# +# This file is autogenerated by pip-compile with Python 3.12 +# by the following command: +# +# make lock-python +# +annotated-doc==0.0.5 \ + --hash=sha256:117bac03a25ede5df5440e855b32d556049ca169ead221505badf432fed4b101 \ + --hash=sha256:c7e58ce09192557605d8bbd92836d7e1d520ac9580096042c0bfd197efacf1bb + # via fastapi +annotated-types==0.8.0 \ + --hash=sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7 \ + --hash=sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0 + # via pydantic +anthropic==0.120.2 \ + --hash=sha256:0f0bc2b381dc0eb41c8d886b815d79c2041cd2374f83aed36f574b6dc9c579c1 \ + --hash=sha256:9722efc10c27a30a69f5338ddacdb35bc6a64297a4e4ba729bf83af873d5fb3a + # via -r backend/requirements.txt +anyio==4.14.2 \ + --hash=sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494 \ + --hash=sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f + # via + # anthropic + # httpx + # starlette +argon2-cffi==25.1.0 \ + --hash=sha256:694ae5cc8a42f4c4e2bf2ca0e64e51e23a040c6a517a85074683d3959e1346c1 \ + --hash=sha256:fdc8b074db390fccb6eb4a3604ae7231f219aa669a2652e0f20e16ba513d5741 + # via -r backend/requirements.txt +argon2-cffi-bindings==25.1.0 \ + --hash=sha256:1db89609c06afa1a214a69a462ea741cf735b29a57530478c06eb81dd403de99 \ + --hash=sha256:1e021e87faa76ae0d413b619fe2b65ab9a037f24c60a1e6cc43457ae20de6dc6 \ + --hash=sha256:21378b40e1b8d1655dd5310c84a40fc19a9aa5e6366e835ceb8576bf0fea716d \ + --hash=sha256:2630b6240b495dfab90aebe159ff784d08ea999aa4b0d17efa734055a07d2f44 \ + --hash=sha256:3c6702abc36bf3ccba3f802b799505def420a1b7039862014a65db3205967f5a \ + --hash=sha256:3d3f05610594151994ca9ccb3c771115bdb4daef161976a266f0dd8aa9996b8f \ + --hash=sha256:473bcb5f82924b1becbb637b63303ec8d10e84c8d241119419897a26116515d2 \ + --hash=sha256:5acb4e41090d53f17ca1110c3427f0a130f944b896fc8c83973219c97f57b690 \ + --hash=sha256:5d588dec224e2a83edbdc785a5e6f3c6cd736f46bfd4b441bbb5aa1f5085e584 \ + --hash=sha256:6dca33a9859abf613e22733131fc9194091c1fa7cb3e131c143056b4856aa47e \ + --hash=sha256:7aef0c91e2c0fbca6fc68e7555aa60ef7008a739cbe045541e438373bc54d2b0 \ + --hash=sha256:84a461d4d84ae1295871329b346a97f68eade8c53b6ed9a7ca2d7467f3c8ff6f \ + --hash=sha256:87c33a52407e4c41f3b70a9c2d3f6056d88b10dad7695be708c5021673f55623 \ + --hash=sha256:8b8efee945193e667a396cbc7b4fb7d357297d6234d30a489905d96caabde56b \ + --hash=sha256:a1c70058c6ab1e352304ac7e3b52554daadacd8d453c1752e547c76e9c99ac44 \ + --hash=sha256:a98cd7d17e9f7ce244c0803cad3c23a7d379c301ba618a5fa76a67d116618b98 \ + --hash=sha256:aecba1723ae35330a008418a91ea6cfcedf6d31e5fbaa056a166462ff066d500 \ + --hash=sha256:b0fdbcf513833809c882823f98dc2f931cf659d9a1429616ac3adebb49f5db94 \ + --hash=sha256:b55aec3565b65f56455eebc9b9f34130440404f27fe21c3b375bf1ea4d8fbae6 \ + --hash=sha256:b957f3e6ea4d55d820e40ff76f450952807013d361a65d7f28acc0acbf29229d \ + --hash=sha256:ba92837e4a9aa6a508c8d2d7883ed5a8f6c308c89a4790e1e447a220deb79a85 \ + --hash=sha256:c4f9665de60b1b0e99bcd6be4f17d90339698ce954cfd8d9cf4f91c995165a92 \ + --hash=sha256:c87b72589133f0346a1cb8d5ecca4b933e3c9b64656c9d175270a000e73b288d \ + --hash=sha256:d3e924cfc503018a714f94a49a149fdc0b644eaead5d1f089330399134fa028a \ + --hash=sha256:da0c79c23a63723aa5d782250fbf51b768abca630285262fb5144ba5ae01e520 \ + --hash=sha256:e2fd3bfbff3c5d74fef31a722f729bf93500910db650c925c2d6ef879a7e51cb + # via argon2-cffi +certifi==2026.7.22 \ + --hash=sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775 \ + --hash=sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55 + # via + # httpcore + # httpx +cffi==2.1.0 \ + --hash=sha256:02cb7ff33ded4f1532476731f89ede53e2e488a8e6205515a82144246ffa7dcc \ + --hash=sha256:03e9810d18c646077e501f661b682fbf5dee4676048527ca3cffe66faa9960dd \ + --hash=sha256:0520e1f4c35f44e209cbbb421b67eec42e6a157f59444dfb6058874ff3610e5d \ + --hash=sha256:0582a58f3051372229ca8e7f5f589f9e5632678208d8636fea3676711fdf7fe5 \ + --hash=sha256:0611e7ebf90573a535ebdc33ae9da222d037853983e13359f580fab781ca017f \ + --hash=sha256:0a42c688d19fca6e095a53c6a6e2295a5b050a8b289f109adab02a9e61a25de6 \ + --hash=sha256:0a96b74cda968eebbad56d973efe5098974f0a9fb323865bf99ea1fd24e3e64c \ + --hash=sha256:10537b1df4967ca26d21e5072d7d54188354483b91dc75058968d3f0cf13fbda \ + --hash=sha256:11b3fb55f4f8ad92274ed26705f65d8f91457de71f5380061eb6d125a768fecd \ + --hash=sha256:15faec4adfff450819f3aee0e2e02c812de6edb88203aa58807955db2003472a \ + --hash=sha256:164bff1657b2a74f0b6d54e11c9b375bc97b931f2ca9c43fcf875838da1570dd \ + --hash=sha256:1854b724d00f6654c742097d5387569021be12d3a0f770eae1df8f8acfcc6acd \ + --hash=sha256:19c54ac121cad98450b4896fa9a43ee0180d57bc4bc911a33db6cab1efab6cd3 \ + --hash=sha256:1b96bfe2c4bd825681b7d311ad6d9b7280a091f43e8f63da5729638083cd3bfb \ + --hash=sha256:1e9f50d192a3e525b15a75ab5114e442d83d657b7ec29182a991bc9a88fd3a66 \ + --hash=sha256:1ff3456eab0d889592d1936d6125bbfbc7ae4d3354a700f8bd80450a66445d4d \ + --hash=sha256:2282cd5e38aa8accd03e99d1256af8411c84cdbee6a89d841b563fdbd1f3e50f \ + --hash=sha256:276f20fffd7b396e12516ba8edf9509210ac248cbbc5acbc39cd512f9f59ebe6 \ + --hash=sha256:2b71d409cccee78310ab5dec549aed052aaea483346e282c7b02362596e01bb0 \ + --hash=sha256:2e9dabb9abcb7ad15938c7196ad5c1718a4e6d33cc79b4c0209bdb64c4a54a5c \ + --hash=sha256:30b65779d598c370374fefabf138d456fd6f3216bfa7bedfab1ba82025b0cd93 \ + --hash=sha256:33eb1ad83ebe8f313e0df035c406227d55a79456704a863fad9842136af5ad7d \ + --hash=sha256:35aaea0c7ee0e58a5cd8c2fd1a48fdf7ece0d2699b7ecdda08194e9ce5dd9b3d \ + --hash=sha256:3681e031db29958a7502f5c0c9d6bbc4c36cb20f7b104086fa642d1799631ff8 \ + --hash=sha256:379de10ce1ba048b1448599d1b37b24caee16309d1ac98d3982fc997f768700b \ + --hash=sha256:37f525a7e7e50c017fdebe58b787be310ad59357ae43a053943a6e1a6c526001 \ + --hash=sha256:3b926723c13eba9f81d2ef3820d63aeceec3b2d4639906047bf675cb8a7a500d \ + --hash=sha256:3d7f118b5adbfdfead90c25822690b02bc8074fba949bb7858bec4ebd55adb43 \ + --hash=sha256:46b1c8db8f6122420f32d02fffb924c2fe9bc772d228c7c711748fff56aabb2b \ + --hash=sha256:47ff3a8bfd8cb9da1af7524b965127095055654c177fcfc7578debcb015eecd0 \ + --hash=sha256:4d433a51f1870e43a13b6732f92aaf540ff77c2015097c78556f75a2d6c030e0 \ + --hash=sha256:4f26194e3d95e06501b942642855aed4f953d55e95d7d01b7c4483db3ecff458 \ + --hash=sha256:510aeeeac94811b138077451da1fb18b308a5feab47dd2b603af55804155e1c8 \ + --hash=sha256:5972433ad71a9e46516584ef60a0fda12d9dc459938d1539c3ddecf9bdc1368d \ + --hash=sha256:5ecbd0499275d57506d397eebe1981cee87b47fcd9ef5c22cab7ed7644a39a94 \ + --hash=sha256:6274dcb2d15cef48daa73ed1be5a40d501d74dccd0cd6db364776d12cb6ba022 \ + --hash=sha256:63960549e4f8dc41e31accb97b975abaecfc44c03e396c093a6436763c2ea7db \ + --hash=sha256:64c753a0f87a256020004f37a1c8c02c480e725f910f0b2a0f3f07debd1b2479 \ + --hash=sha256:6af371f3767faeffc6ac1ef57cdfd25844403e9d3f476c5537caee499de96376 \ + --hash=sha256:6ca4919c6e4f89aa99c42510b42cf54596892c00b3f9077f6bdd1505e24b9c8d \ + --hash=sha256:6d194185eabd279f1c05ebe3504265ddfc5ad2b58d0714f7db9f01da592e9eb6 \ + --hash=sha256:702c436735fbe99d59ada02a1f65cfc0d31c0ee8b7290912f8fbc5cd1e4b16c3 \ + --hash=sha256:716ff8ec22f20b4d988b12884086bcef0fc99737043e503f7a3935a6be99b1ea \ + --hash=sha256:762f99479dcb369f60ab9017ad4ab97a36a1dd7c1ee5a3b15db0f4b8659120cd \ + --hash=sha256:7762faa47e8ff7eb80bd261d9a7d8eea2d8baa69de5e95b70c1f338bbe712f02 \ + --hash=sha256:78474632761faa0fb96f30b1c928c84ebcf68713cbb80d15bab09dfe61640fde \ + --hash=sha256:799416bae98336e400981ff6e532d67d5c709cfb30afb79865a1315f94b0e224 \ + --hash=sha256:7d034dcffa09e9a46c93fa3a3be402096cb5354ac6e41ab8e5cc9cd8b642ad76 \ + --hash=sha256:7d28dff1db6764108bc30788d85d61c876beff416d9a49cb9dd7c5a9f34f5804 \ + --hash=sha256:7d3538f9c0e50670f4deb93dbb696576e60590369cae2faf7de681e597a8a1f1 \ + --hash=sha256:7d5980a3433d4b71a5e120f9dd551403d7824e31e2e67124fe2769c404c06913 \ + --hash=sha256:7ea6b3e2c4250ff1de21c630fe72d0f63eb95c2c32ffbf64a358cf4a8836d714 \ + --hash=sha256:86cf8755a791f72c85dc287128cc62d4f24d392e3f1e15837245623f4a33cccc \ + --hash=sha256:88023dfe18799507b73f1dbb0d14326a17465de1bc9c9c7655c22845e9ddc3a2 \ + --hash=sha256:89095c1968b4ba8285840e131bf2891b09ae137fe2146905acae0354fbce1b5e \ + --hash=sha256:8d35c139744adb3e727cd51b1a18324bbe44b8bd41bf8322bca4d41289f48eda \ + --hash=sha256:8e74a6135550c4748af665b1b1118b6aab33b1fc6a16f9aff630af107c3b4512 \ + --hash=sha256:8f9ec95b8a043d3dfbc74d9abc6f7baf524dd27a8dc160b0a32ff9cdab650c28 \ + --hash=sha256:90bec57cf82089383bd06a605b3eb8daebf7e5a668520beaf6e327a83a947699 \ + --hash=sha256:95f2954c2c9473d892eca6e0409f3568b37ab62a8eedb122461f73cc273476e3 \ + --hash=sha256:961be50688f7fba2fa65f63712d3b9b341a22311f5253460ce933f52f0de1c8c \ + --hash=sha256:98fff996e983a36d3aa2eca83af40c5821202e7e6f32d13ae94e3d2286f10cfe \ + --hash=sha256:9b8f0f26ca4e7513c534d351eca551947d053fac438f2a04ac96d882909b0d3a \ + --hash=sha256:9d72af0cf10a76a600a9690078fe31c63b9588c8e86bf9fd353f713c84b5db0f \ + --hash=sha256:9d8272c0e483b024e1b9ad029821470ed8ec65631dbd90217469da0e7cd89f1c \ + --hash=sha256:a016194dbe13d14ee9556e734b772d8d67b947092b268d757fd4290e3ba2dfc2 \ + --hash=sha256:a5781494d4d400a3f47f8f1da94b324f6e6b440a53387774002890a2a2f4b50f \ + --hash=sha256:a95b05f9baf29b91171b3a8bd2020b028835243e7b0ff6bb23e2a3c228518b1b \ + --hash=sha256:aa7a1b53a2a4452ada2d1b5dade9960b2522f1e61293a811a077439e39029565 \ + --hash=sha256:ac0f1a2d0cfa7eea3f2aaf006ab6e70e8feeb16b75d65b7e5939982ca2f11056 \ + --hash=sha256:af5e2915d41fe6c961694d7bfdc8562942638200f3ce2765dfb8b745cf997629 \ + --hash=sha256:b6422532152adf4e59b110cb2808cee7a033800952f5c036b4af047ee43199e7 \ + --hash=sha256:b65f590ef2a44640f9a05dbb548a429b4ade77913ce683ac8b1480777658a6c0 \ + --hash=sha256:ba00f661f8ba35d075c937174e27c2c421cec3942fd2e0ea3e66996757c0fdd9 \ + --hash=sha256:bccbbb5ee76a61f9d99b5bf3846a51d7fca4b6a732fe46f89295610edaf41853 \ + --hash=sha256:bf01d8c84cbea96b944c73b22182e6c7c432b3475632b8111dbfdc95ddad6e13 \ + --hash=sha256:bf5c6cf48238b0eb4c086978c492ad1cbc22373fc5b2d7353b3a598ce6db887a \ + --hash=sha256:c16914df9fb7f500e440e6875fa23ff5e0b31db01fa9c06af98d59a91f0dc2e4 \ + --hash=sha256:c351efb95e832a853a29361675f33a7ce53de1a109cd73fd47af0712213aa4ce \ + --hash=sha256:c4165821e131d6d4ca444347c2b694e2311bcfa3fe5a861cc72968f28867beac \ + --hash=sha256:c5f5df567f6eb216de69be06ce55c8b714090fae02b18a3b40da8163b8c5fa9c \ + --hash=sha256:c941bb58d5a6e1c3892d86e42927ed6c180302f07e6d395d08c416e594b98b46 \ + --hash=sha256:c97f080ea627e2863524c5af3836e2270b5f5dfff1f104392b959f8df0c5d384 \ + --hash=sha256:cb96698e3c7413d906ce83f8ffd245ec1bd94707541f299d0ce4d6b0193e982b \ + --hash=sha256:cbb7640ce37159548d2147b5b8c241f962143d4c71231431820783f4dc78f210 \ + --hash=sha256:cdf2448aab5f661c9315308ec8b93f4e8a1a67a3c733f8631067a2b67d5913dc \ + --hash=sha256:d2117334c3af3bdcb9a88522b844a2bdb5efdc4f71c6c822df55486ae1c3347a \ + --hash=sha256:d53d10f7da99ae46f7373b9150393e9c5eab9b224909982b43832668de4779f5 \ + --hash=sha256:d9fafc5aa2e2a39aaf7f8cc0c1f044a9b07fca12e558dca53a3cc5c654ad67a7 \ + --hash=sha256:db3eb7d46527159a878ec3460e9d40615bc25ba337d477db681aea6e4f05c5d2 \ + --hash=sha256:dbf7c7a88e2bac086f06d14577332760bdeecc42bdec8ac4077f6260557d9326 \ + --hash=sha256:df2b82571a1b30f58a87bf4e5a9e78d2b1eff6c6ce8fd3aa3757221f93f0863f \ + --hash=sha256:df92f2aba50eb4d96718b68ef76f2e57a57b54f2fa62333496d16c6d585a85ca \ + --hash=sha256:eb4e8997a49aa2c08a3e43c9045d224448b8941d88e7ac163c7d383e560cbf98 \ + --hash=sha256:efc1cdd798b1aaf39b4610bba7aad28c9bea9b910f25c784ccf9ec1fa719d1f9 \ + --hash=sha256:f146d154428a2523f9cc7936c02353c2459b8f6cf07d3cd1ee1c0a611109c5d5 \ + --hash=sha256:f5bce581e6b8c235e566a14768a943b172ada3ed73537bb0c0be1edee312d4e7 \ + --hash=sha256:f9912624a0c0b834b7520d7769b3644453aabc0a7e1c839da7359f050750e9bc \ + --hash=sha256:fb62edb5bb52cca65fab91a63afa7561607120d26090a7e8fda6fb9f064726da \ + --hash=sha256:ff067a8d8d880e7809e4ac88eb009bb848870115317b306666502ccad30b147f + # via + # argon2-cffi-bindings + # cryptography +click==8.4.2 \ + --hash=sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6 \ + --hash=sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76 + # via uvicorn +cryptography==49.0.0 \ + --hash=sha256:026ac7423e6fa66872d3bf889be5974507da3944f866f704fa200eadacd00001 \ + --hash=sha256:07cab27cc7b7e0fd28e5e26bb9eeedde5c135c868b46de4a27845abe94af6122 \ + --hash=sha256:084ef1af862eb07ec46d25f68689f2102a9fc0e05ce7b80f14f5fe51e4eef0f6 \ + --hash=sha256:0b82e28ee398a386f0807bba7884d30f25218855690f45115831bcce5d90822c \ + --hash=sha256:0e959b578856a3924bc0cbb710fc12c387b9412a951389f3ca61704a9e25f325 \ + --hash=sha256:0f21641cf4b30fca7aee061ced0ec7ad7b073518088b7c9969a297c0ae796c69 \ + --hash=sha256:196ecd6a36e4e9aa10270393bb98d8df88fccee0bf1e5128b91ae4eb4375896d \ + --hash=sha256:2400ef9c9e2299a25614eb1dea3db54a69b1349efd043bfac9c67630d136df36 \ + --hash=sha256:28d8b15e6275f12c8a207dc309dfa957903c927d08d0cc937ee3f63f200693cc \ + --hash=sha256:2afe9051da7ae7bd5905da5a949280c7d2bb75682e188f650a9d0f2756b834c6 \ + --hash=sha256:2eda353d8a27bcbcaa4cbed18994a74ab4d19a2ca897db188ea269ab9b71419b \ + --hash=sha256:32703d93296f5c1f4b53349ad3a250c2cae0fdecd3a3dd5d47e616d8d616af27 \ + --hash=sha256:33cd0565932807baddb67b96dbee92f2c374b5c89dee09fd74079aeb8c8dba61 \ + --hash=sha256:35b151772baff2c74cba7fa290ceaff4c3b11c0c881eb93eb5dbc05a7cfbba18 \ + --hash=sha256:36d1709f992593689b45bda411498d62c6e365f2ca00b84657d4dadd24de16db \ + --hash=sha256:42b0684e0e40cf26122427802486f6d93aea593612603a94fbf260c7eb1e9c1b \ + --hash=sha256:4ae387c9cb68ea569ca17e490d66d8142b81c3cc814bf179974b7d146e490bbb \ + --hash=sha256:53ecee2e23f7169b6117e99fc8a944e5e50f79e69758a83b52a00cb98ab2b2d2 \ + --hash=sha256:66ec79c3904820572d7e987abdf304281f141d37ad9a489b8e97066e7b9b6459 \ + --hash=sha256:67e1d20ad9ef3a563c59ef22e7a8a0b8210bd26604369ea4a30a7c66aefe504e \ + --hash=sha256:6f2debedf9ca60cf1d5bd466475638af5130f89965605cd818484d19987d3a21 \ + --hash=sha256:6fc361c34fb6aac015ce19435876635e5c6d21db31998b0920f675f131e043b8 \ + --hash=sha256:73a205dce83953d131a4aa1e0fd917a2fd1c5b1eef251e9d7152efefcbf5caf7 \ + --hash=sha256:7abcee80084cda3f7691f3eb1ce480d8df49cec637b429aa35986c1de71738aa \ + --hash=sha256:8c25ceb16df5b9435f3f6a9829204985b0e0cbee3b48aacd432c7d2c850b44d9 \ + --hash=sha256:966fe0e9c67490071f14c0d2b1cb2dfb3023c5ce39457343931415f08382f2db \ + --hash=sha256:9e82dcc8e56052715fb18b2429e3bca4823b1629136a2084fc45a9a5cecb9b64 \ + --hash=sha256:b20133d204d2bb56ba047642199603876c872026ca53e79c35b83772ab2cc505 \ + --hash=sha256:b39efa323140595abd3ecca8529d321ae50f55f3aa3ba9cc81ea56a6011953d5 \ + --hash=sha256:b47db11c2c3525083296069b98ac5221907455e989ae0c2e3008bde851921615 \ + --hash=sha256:b87e65d263b3e5d3bb92a57e2a6638e2f31110fa7aa890c7b2dbba42248d0a3f \ + --hash=sha256:b970c6da94d5bb18629db453d14f2a1300f6bf59b61e9b82377931ef95504866 \ + --hash=sha256:be9fcb48a55f023493482827d4f459bd263cc20efde64f204b97c123201850c6 \ + --hash=sha256:c2bc30226390d60ea19d9f82b19db005fe0452154a23c1c410c12ea801e43561 \ + --hash=sha256:c83782480a4a9da4d0feb51950131ba32e12e70813848b3343f6e18c28a66838 \ + --hash=sha256:cbc77da8c523d5abd028635ba850a6966fcee2c82e2bf65a41d1d8afe0f98be9 \ + --hash=sha256:ccac2bfebc306b862133e3bb71f3f6ee8bb525240089b2d952e4144b3a6d5da7 \ + --hash=sha256:d0527ce944105f257f605a827d6ebead966c752038b6e8656abb9c5edee6fc68 \ + --hash=sha256:d8ecde755e2e91bf773fc94e8c9d730cd7f2007004cb492263a794ec3899a1c8 \ + --hash=sha256:e3fb64c420688e5319ae25113a354015abbd8dffbfbc41781a1ea66fc7622ac3 \ + --hash=sha256:e5dfc1e64de5677cec922ffa8da89c546d0415bf6efdf081842e5d44c84e1f0e \ + --hash=sha256:ec5e529fb80935c94fe7b729f9972b50e351a0e6b50aa294fd5cabb109fcc29a \ + --hash=sha256:f37d847238971164fdbc68ade6f6574aecc9c0af714190e2083429ff68f4ce9d \ + --hash=sha256:f78ff2c9ed8dc2d036b0f4d640e22522213d047c1b14e61205a7e55c80a494d4 \ + --hash=sha256:f89660a348f4f78a92366240a61404e337586ef7f5909a2fef59ca88ef505493 \ + --hash=sha256:fc1e275c2f1d97b1a6450b8b0ea3ebfa6e087a611c2b26cb2404d48588abab7b + # via -r backend/requirements.txt +distro==1.9.0 \ + --hash=sha256:2fa77c6fd8940f116ee1d6b94a2f90b13b5ea8d019b98bc8bafdcabcdd9bdbed \ + --hash=sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2 + # via anthropic +docstring-parser==0.18.0 \ + --hash=sha256:292510982205c12b1248696f44959db3cdd1740237a968ea1e2e7a900eeb2015 \ + --hash=sha256:b3fcbed555c47d8479be0796ef7e19c2670d428d72e96da63f3a40122860374b + # via anthropic +fastapi==0.141.1 \ + --hash=sha256:bfb91aa2d334c61cb35ba9a116fc123b3d3df31640b801cf57a7a78ec3f603b3 \ + --hash=sha256:e8822fc40db1e1858054d7a949a888695bc9bdce70139178e33bd2871a453ca1 + # via -r backend/requirements.txt +h11==0.16.0 \ + --hash=sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1 \ + --hash=sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86 + # via + # httpcore + # uvicorn +httpcore==1.0.9 \ + --hash=sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55 \ + --hash=sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8 + # via httpx +httpx==0.28.1 \ + --hash=sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc \ + --hash=sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad + # via anthropic +idna==3.18 \ + --hash=sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2 \ + --hash=sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848 + # via + # anyio + # httpx +iniconfig==2.3.0 \ + --hash=sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730 \ + --hash=sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12 + # via pytest +jiter==0.16.0 \ + --hash=sha256:0542a7189c26920778658fc8fcf2af8bae05bae9924577f71804acef37996536 \ + --hash=sha256:0758ab7747a984797cf048e8eedea1d8ef39d7994b25611daf5b48fc903e8873 \ + --hash=sha256:0df91907609837f33341b8e6fe73b95991fdaa57caf1a0fbd343dffe826f386f \ + --hash=sha256:0e2e9efbe042210df657bade597f66d6d75723e3d8f45a12ea6d8167ff8bbce3 \ + --hash=sha256:0fa25b09b13075c46f5bc174f2690525a925a4fc2f7c82969a2bbabff22386ce \ + --hash=sha256:10438939205546132189c8e74a2d536a707841f3a25cd7c74ee91fe503407a26 \ + --hash=sha256:12288303c9844e61e1651d02a9a6f6633e47d39f897d6991d1427161ce6b746e \ + --hash=sha256:17ca7fae79f6d99cd9a042b75f917eaada7b895cfc7dd2ee3a16089dcaec7a85 \ + --hash=sha256:1878349266f8ee36ecb1375cc5ba2f115f35fd9f0a1a4119e725e379126647f7 \ + --hash=sha256:1a0f05e229edb29e68cdd0ccb83cea13b64263416120cf943767a6fd72e6787f \ + --hash=sha256:2577196f4474ef3fc4779a088a23b0897bbf86f9ea3679c372d45b8383b43207 \ + --hash=sha256:26798522707abb47d767db536e4148ceac1b14446bf028ee85e579a2e043cfe5 \ + --hash=sha256:26d122613ada2b708eb714695446f40fce5bdf2edb4b02116dec62faa62dfab3 \ + --hash=sha256:2a0d46ef67cc58d906a6132dd3040ca70ae4f0b0d7c9c052fe432c658a69b3f6 \ + --hash=sha256:2a8e9e39cf083016137aa5cadafe3188adc2ba6ba1fbf1e5d18889ad3e9ad056 \ + --hash=sha256:2c842cbf374a8daf50b2c04212995bee34ca2ac2cdc29a901b4cdb072c9c4131 \ + --hash=sha256:2ed5738ae4af18271a51a528b8811b0cbfa4a1858de9d83359e4169855d6a331 \ + --hash=sha256:3126a5dbad56401989ac769aca0cb56005bfb3e2366eea0ca99d1a91c3c1ee03 \ + --hash=sha256:31aaee5b80f672c1dc21272bcfb9cbdcfc1ea04ff50f00ed5af500b80c44fa93 \ + --hash=sha256:3a23f0e4f957e1be65752d2dfac9a5a06b1917af8dc85deb639c3b9d02e31290 \ + --hash=sha256:3b21b412b899fd8bd51a3046934b59a3bb068b79f70a5c6010053ac77cc53f0c \ + --hash=sha256:3c1fd2dbe1b0af19e987f03fe66c5f5bd105a2229c1aff4ab14890b24f41d21a \ + --hash=sha256:3f4d9e473a5ce7d27fef8b848df4dc16e283893d3f53b4a585e72c9595f3c284 \ + --hash=sha256:3fb7bc819187b56dc48aa5c833aaf92257da8e07efdb9306156667bd2eeb491c \ + --hash=sha256:41977aa5654023948c2dae2a81cbf9c43343954bef1cd59a154dd15a4d84c195 \ + --hash=sha256:44d019fa8cdaf89bf29c71b39e3712143fdd0ac76725c6ef954f9957a5ea8730 \ + --hash=sha256:46add52f4ad47a08bfb1219f3e673da972191489a33016edefdb5ea55bfa8c48 \ + --hash=sha256:49060fd70737fad59d33ba9dcc0d83247dc9e77187de26053a19c16c9f32bd69 \ + --hash=sha256:491e7d072a253b156fff46b78bceac4652a697aa8d7082c9c18c03d7b7917d24 \ + --hash=sha256:4a1d68ff7ca1d3b5dee20a97a3decda7d5f15003823bf6d140c81f8561d3bc5c \ + --hash=sha256:51d7b836acb0108d7c77df1742332cac2a1fa04a74d6dacec46e7091f0e91274 \ + --hash=sha256:560b2cf3fb03240cd34f27409a238547488708f05b7c3924f571a60422251ec7 \ + --hash=sha256:561926e0573ffe4a32498420a76d64b16c513e1ab413b9d28158a8764ac701e5 \ + --hash=sha256:57b37fc887a32d44798e4d8ebfa7c9683ff3da1d5bf38f08d1bb3573ccb39106 \ + --hash=sha256:5a71d0d2014c3275043e1170bf3d4e771493cb0dcf07be54c567155f4d8ee64b \ + --hash=sha256:5ab4f50ff971b611d656554ea10b75f80097392c827bc32923c6eeb6386c8b00 \ + --hash=sha256:5af7780e4a26bd7d0d989592bf9ef12ebf806b74ab709223ecca37c749872ea9 \ + --hash=sha256:5cf109d010b4b05a105afb3d43be36a21322d345ad3111e13d15f680afef0e5b \ + --hash=sha256:5ed466aee31294d7cdcd4d37dfe5c42c97bc29d9a5f00eacf24504358309cb9b \ + --hash=sha256:616e89e008a93c01104161c75b4988e58716b01d62307ebfe161e52a56d2a818 \ + --hash=sha256:62c1b7fe1f77925acf5af68b6140b8810fa87dfd4dc0a9c8568ec2fa2a10429c \ + --hash=sha256:63efadc657488f45db1c676d81e704cac2abf3fdb892def1faea61db053127e2 \ + --hash=sha256:63ffdbdae7d4499f4cda14eadc12ddcabef0fc0c081191bdc2247489cb698077 \ + --hash=sha256:64c0203212098470032aabcde9356fc168f377aade3e43def61dfe17e92f2037 \ + --hash=sha256:64d613743df53199b1aa256a7d328340da6d7078aac7705a7db9d7a791e9cfd2 \ + --hash=sha256:659039cc50b5addcc35fcc87ae2c1833b7c0a8e5326ef631a75e4478447bcf84 \ + --hash=sha256:6722bcef4ffc86c835574b1b2fac6b33b9fb4a889c781e67950e891591f3c55a \ + --hash=sha256:67c3bc1760f8c99d805dcab4e644027142a53b1d5d861f18780ebdbd5d40b72a \ + --hash=sha256:67fddeda1688f0cce2d2ae83ccf8a80f79936f2d2997d6cc2261f82fdb54a4d3 \ + --hash=sha256:6edb63a46e65a82c26800a868e49b2cac30dd5a4218b88d74bc2c848c8ad60bb \ + --hash=sha256:70a490b55634dc0d2606ce8a8e01b1d62459011beb368d15d76e1eaf62460e3d \ + --hash=sha256:710cc51d4ebdcd3c1f70b232c1db1ea1344a075770422bbd4bede5708335acbe \ + --hash=sha256:741eed508c233a76313a1c7b001f8f21b82f14327e9196ae8bd29a2cc164ae84 \ + --hash=sha256:7b24c3492c5f4f84a37946ad9cf504910cf6a782d6a4e0689b6673c5894b4a1c \ + --hash=sha256:7c9610fd25ebccb43fca584136f5c2fbb26802447eccd430dfdbab95a0fd5126 \ + --hash=sha256:850ccb1d7eedb4200f4014b1c0e8a577de114fc3cd88faad646dcc9bc4bb12ad \ + --hash=sha256:8597d23c87f59294f83bcb6229b9ed1fccee13dbba967b46930d2f1759466fee \ + --hash=sha256:8d031aabecc4f1b6276adfb42e3aabb77c89d468bf616600e8d3a11328929053 \ + --hash=sha256:8d30a4a1c87713060c8d1cc59a7b6c8fb6b8ef0a6900368014c76c87922a2929 \ + --hash=sha256:8fb8de1e23a0cb2a7f53c335049c7b72b6db41aa6227cdcc0972a1de5cb39450 \ + --hash=sha256:8fc4d94713c4697347e38faf7d6ef91547c142219bdcfc7220c4870879974244 \ + --hash=sha256:96e38eea538c8ddf853a35727c7be0741c76c13f04148ac5c116222f50ece3b3 \ + --hash=sha256:9acf1b2faec82d998811ecce7ae84d9005e53410773e9d37d61cdc424ba4581b \ + --hash=sha256:9c8a956fd72c2cf1e730d01ea080341f13aa0a97a4a33b51abebe725b7ae9ca9 \ + --hash=sha256:9ec553a99b0987efd7a3645a1a825cf29c224e494db267a83369fcc8da9aeda5 \ + --hash=sha256:a111256a7193bea0759267b10385e5870949c239ed7b6ddbaaf57573edb38734 \ + --hash=sha256:a32d2027a9fa67f109ff245a3252ece3ccc32cc56703e1deab6cc846a59e0585 \ + --hash=sha256:a5c54ef4ff776d9675837ef535b3308d6e31c208d43ebc44a0f7ab8a208c68f7 \ + --hash=sha256:adbb8edeadd431bc4477879d5d371ece7cb1334486584e0f252656dd7ffada29 \ + --hash=sha256:adff21bc78edfe086c15eb495b900306076de378dc2337c132401fc39bd79c91 \ + --hash=sha256:b1e7923093a376d93c6eb507c77045ae258d689ba577392846a1b3f10d0b09a9 \ + --hash=sha256:b2c61484666ad42726029af0c00ef4541f0f3b5cdc550221f56c2343208018ee \ + --hash=sha256:b42e9ff5376819c053da25809a8d4b6fa6e473b4856ebe42e298ac958be3d7f9 \ + --hash=sha256:b72d0b2990ca754a9102779ac98d8597b7cb31678958562214a007f909eab78e \ + --hash=sha256:b7dc85ea77d4abbae8bad0d3538678aedee75bceec4e2f6c8dfb1c74772e5aa5 \ + --hash=sha256:b8a3f9a6008048fe9def7bf465180564a6e458047d2ce499149cfbe73c3ae9db \ + --hash=sha256:baad945ed47f163ad833314f8e3288c396118934f94e7bbb9e243ce4b341a4fd \ + --hash=sha256:bae96332410f866e5900d809298b1ed82735932986c672495f9701daacd80620 \ + --hash=sha256:bc837c1b9631be10abfe0191537fe8009838204cec7e44827401ace390ddb567 \ + --hash=sha256:bf3ea07d9bc8e7d03a9fbc051295462e6dbc295b894fd72457c3136e3e43d898 \ + --hash=sha256:c22a488f7b9218e245a0025a9ba6b100e2e54700831cf4cf16833a27fba3ad01 \ + --hash=sha256:c4b4717bdb35ae456f831a6b08d01880fff399887a6bbc526a583a406e484eea \ + --hash=sha256:c5fc4f8def331036a7b8e981b4347ebe409981edbc8308a5ea842b8c3614fa6c \ + --hash=sha256:c682bea068a90b764577bdb78a60a4c1d1606daf9cd4c893832a37c7cc9d9026 \ + --hash=sha256:c90c0f63df322be920eda6ce622e3083d8906ba267f8220fe7873213b8b4430e \ + --hash=sha256:c9c53be232c2e206ef9cdbad81a48bfa74c3d3f08bcf8124630a8a748aad993e \ + --hash=sha256:cbd18dd5e2df96b580487b5745adf57ef64ad89ba2d9662fc3c19386acce7db8 \ + --hash=sha256:cf0d73f50e7b6935677854f6e8e31d499ca7064dd24734f703e060f5b237d883 \ + --hash=sha256:d284fb8d94d5855d60c44fefcab4bf966f1da6fada73992b01f6f0c9bc0c6702 \ + --hash=sha256:d28bb3c26762358dadf3e5bf0bccd29ae987d65e6988d2e6f49829c76b003c09 \ + --hash=sha256:d5bf78d0e05e45cfdd66558893938d59afe3d1b1a824a202039b20e607d25a72 \ + --hash=sha256:d5f91b1c27fc22a57993d5a5cb8a627cb8ed4b10502716fac1ffbfe1d19d84e8 \ + --hash=sha256:d8f80521644426d451e70f00c7974240cab8f6ee088aedaa9af2697153ab7805 \ + --hash=sha256:da3d7ec75dc83bb18bca888b5edfae0656a26849056c59e05a7728badd17e7af \ + --hash=sha256:dab907db06fc593645e73109acf4581ba5b548897d28b9348dc41ddc8343b2d3 \ + --hash=sha256:de5ba8763e56b793561f43bed197c9ea55776daa5e9a6b91eed68a909bc9cdbf \ + --hash=sha256:e03a5f21a5ce96a9441b8cb32719a8b88ed5388f53e0f339c5bcf54f1317f9d0 \ + --hash=sha256:e34e97bda77eb63242a410243c071e28ac7e0d8c0948c5ee658498690a4b2f2f \ + --hash=sha256:e431cfc9caf44c1d5459ff77d4e64cbf85fddb6a35dad836a15c6a9ec23087c1 \ + --hash=sha256:eab2cd170150e70153de16896a1774e3a1dca80154c56b54d7a812c479a7165e \ + --hash=sha256:ee6162b77d49a9939229df666dfa8af3e656b6701b54c4c84966d740e189264e \ + --hash=sha256:f17d61a28b4b3e0e3e2ba98490c70501403b4d196f78732439160e7fd3678127 \ + --hash=sha256:f3bd327cdfa118bc1ce69c214c2678571d5bd39b8ccd0ebf43a54db00541ba9a \ + --hash=sha256:f4444a83f946605990c98f625cdd3d2725bfb818158760c5748c653170a20e0e \ + --hash=sha256:fb08c276dd02dac3a284acdd02cacc630d2e3cd6572a4b85519f35cbd133c3de + # via anthropic +packaging==26.2 \ + --hash=sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e \ + --hash=sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661 + # via pytest +pluggy==1.6.0 \ + --hash=sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3 \ + --hash=sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746 + # via pytest +pycparser==3.0 \ + --hash=sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29 \ + --hash=sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992 + # via cffi +pydantic==2.13.4 \ + --hash=sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba \ + --hash=sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6 + # via + # -r backend/requirements.txt + # anthropic + # fastapi +pydantic-core==2.46.4 \ + --hash=sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0 \ + --hash=sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262 \ + --hash=sha256:041bde0a48fd37cf71cab1c9d56d3e8625a3793fef1f7dd232b3ff37e978ecda \ + --hash=sha256:0c563b08bca408dc7f65f700633d8442fffb2421fc47b8101377e9fd65051ff0 \ + --hash=sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e \ + --hash=sha256:0ce40cd7b21210e99342afafbd4d0f76d784eb5b1d60f3bdc566be4983c6c73b \ + --hash=sha256:0e96592440881c74a213e5ad528e2b24d3d4f940de2766bed9010ab1d9e51594 \ + --hash=sha256:10e17cbb10a330363733efc4d7c4d0dd827ac0909b8f6a6542298fed1ea62f29 \ + --hash=sha256:133878133d271ade3d41d1bfb2a45ec38dbdbda40bc065921c6b04e4630127e2 \ + --hash=sha256:14d4edf427bdcf950a8a02d7cb44a08614388dd6e1bdcbf4f67504fa7887da9c \ + --hash=sha256:14f4c5d6db102bd796a627bbb3a17b4cf4574b9ae861d8b7c9a9661c6dd3362d \ + --hash=sha256:17299feefe090f2caa5b8e37222bb5f663e4935a8bfa6931d4102e5df1a9f398 \ + --hash=sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d \ + --hash=sha256:18e5ceec2ab67e6d5f1a9085e5a24c9c4e2ac4545730bfe668680bca05e555f3 \ + --hash=sha256:19e51f073cd3df251856a8a4189fbdf1de4012c3ebacfb1884f94f1eb406079f \ + --hash=sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb \ + --hash=sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7 \ + --hash=sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5 \ + --hash=sha256:228ee9bae8bef5b1e97ec58302f80357c37199e0d0a99174e138d28e6957b9d9 \ + --hash=sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462 \ + --hash=sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4 \ + --hash=sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b \ + --hash=sha256:2f84c03c8607173d16b5a854ec68a2f9079ae03237a54fb506d13af47e1d018d \ + --hash=sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df \ + --hash=sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2 \ + --hash=sha256:3447661d99f75a3683a4cf5c87da72f2161964611864dbbeac7fbb118bb4bfc0 \ + --hash=sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519 \ + --hash=sha256:395aebd9183f9d112f569aeb5b2214d1a10a33bec8456447f7fbdfa51d38d4cd \ + --hash=sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7 \ + --hash=sha256:3be77f45df024d789a672ae34f8b06fb346c4f9f46ea714956660ea4862e89ac \ + --hash=sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6 \ + --hash=sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565 \ + --hash=sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898 \ + --hash=sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb \ + --hash=sha256:432c179df7874eeb73307aad2df0755e1ae0efa61ff0ea89b93e194411ae3928 \ + --hash=sha256:4a05d69cba51d852c5c3e92758653245a50c0b646ced0cf05bd793ed592839d6 \ + --hash=sha256:4c63ebc82684aa89d9a3bcbd13d515b3be44250dc68dd3bd81526c1cb31286c3 \ + --hash=sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a \ + --hash=sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596 \ + --hash=sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987 \ + --hash=sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e \ + --hash=sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d \ + --hash=sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712 \ + --hash=sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008 \ + --hash=sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd \ + --hash=sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1 \ + --hash=sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be \ + --hash=sha256:66ce7632c22d837c95301830e111ad0128a32b8207533b60896a96c4915192ea \ + --hash=sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292 \ + --hash=sha256:6f2eeda33a839975441c86a4119e1383c50b47faf0cbb5176985565c6bb02c33 \ + --hash=sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3 \ + --hash=sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4 \ + --hash=sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b \ + --hash=sha256:7bfb192b3f4b9e8a89b6277b6ce787564f62cfd272055f6e685726b111dc7826 \ + --hash=sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac \ + --hash=sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7 \ + --hash=sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d \ + --hash=sha256:8358a950c8909158e3df31538a7e4edc2d7265a7c54b47f0864d9e5bae9dcebf \ + --hash=sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4 \ + --hash=sha256:86e1a4418c6cd97d60c95c71164158eaf7324fae7b0923264016baa993eba6fc \ + --hash=sha256:8b9bab013d1c7a79d3501ff86d0bc9c31bf587db4551677b96bec07df78c6b15 \ + --hash=sha256:8c5dac79fa1614d1e06ca695109c6105923bd9c7d1d6c918d4e637b7e6b32fd3 \ + --hash=sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b \ + --hash=sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914 \ + --hash=sha256:9037063db01f09b09e237c282b6792bd4da634b5402c4e7f0c61effed7701a04 \ + --hash=sha256:905a0ed8ea6f2d61c1738835f99b699348d7857379083e5fc497fa0c967a407c \ + --hash=sha256:90884113d8b48f760e9587002789ddd741e76ab9f89518cd1e43b1f1a52ec44b \ + --hash=sha256:91a06d2e259ecfbd8c901d70c3c507900458498142b3026a296b7de4d1322cc9 \ + --hash=sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce \ + --hash=sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4 \ + --hash=sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a \ + --hash=sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f \ + --hash=sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424 \ + --hash=sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894 \ + --hash=sha256:9bc519fbf2b7578398853d815009ae5e4d4603d12f4e3f91da8c06852d3da3e9 \ + --hash=sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76 \ + --hash=sha256:9f444c499b3eefd3a92e348059471ea0c3a6e303d9c1cec09fa748fd9f895201 \ + --hash=sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb \ + --hash=sha256:a0f62d0a58f4e7da165457e995725421e0064f2255d8eccebc49f41bbc23b109 \ + --hash=sha256:a396dcc17e5a0b164dbe026896245a4fa9ff402edca1dff0be3d53a517f74de4 \ + --hash=sha256:aaa2a54443eff1950ba5ddc6b6ccda0d9c84a364276a62f969bdf2a390650848 \ + --hash=sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526 \ + --hash=sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0 \ + --hash=sha256:b078afbc25f3a1436c7a1d2cd3e322497ee99615ba97c563566fdf46aff1ee01 \ + --hash=sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458 \ + --hash=sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e \ + --hash=sha256:bb63e0198ca18aad131c089b9204c23079c3afa95487e561f4c522d519e55aba \ + --hash=sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a \ + --hash=sha256:c1747f85cee84c26985853c6f3d9bd3e75da5212912443fa111c113b9c246f39 \ + --hash=sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c \ + --hash=sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000 \ + --hash=sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b \ + --hash=sha256:c7a7bd4e39e8e4c12c39cd480356842b6a8a06e41b23a55a5e3e191718838ddf \ + --hash=sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4 \ + --hash=sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd \ + --hash=sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28 \ + --hash=sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9 \ + --hash=sha256:d396ec2b979760aaf3218e76c24e65bd0aca24983298653b3a9d7a45f9e47b30 \ + --hash=sha256:d51026d73fcfd93610abc7b27789c26b313920fcfb20e27462d74a7f8b06e983 \ + --hash=sha256:d80ee3d731373b24cebbc10d689ca4ee1875caf0d5703a245db18efd4dd37fc1 \ + --hash=sha256:d995260fdf4e1db774581b4900e0f832abe3c7c84996726bbc161b19c8f29e76 \ + --hash=sha256:da4b951fe36dc7c3a1ccb4e3cd1747c3542b8c9ceede8fc86cae054e764485f5 \ + --hash=sha256:daa27d92c36f24388fe3ad306b174781c747627f134452e4f128ea00ce1fe8c4 \ + --hash=sha256:db06ffe51636ffe9ca531fe9023dd64bdd794be8754cb5df57c5498ae5b518a7 \ + --hash=sha256:e0d65b8c354be7fb5f720c3caa8bc940bc2d20ce749c8e06135f07f8ed95dd7c \ + --hash=sha256:e68b7a074f65a2fd746c52a7ce6142ab7006074ac269ace0c25cd8ba171f8066 \ + --hash=sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3 \ + --hash=sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02 \ + --hash=sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89 \ + --hash=sha256:ea793e075b70290d89d8142074262885d3f7da19634845135751bd6344f73b50 \ + --hash=sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76 \ + --hash=sha256:f13a646d65d09fbf1bc6b3a9635d30095c8e7e5cc419ff35ecc563c5fd04cd49 \ + --hash=sha256:f47286a97f0bc9b8859519809077b91b2cefe4ae47fcbf5e466a009c1c5d742b \ + --hash=sha256:f747929cf940cddb5b3668a390056ddd5ba2e5010615ea2dcf4f9c4f3ab8791d \ + --hash=sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7 \ + --hash=sha256:f9fa868638bf362d3d138ea55829cefb3d5f4b0d7f142234382a15e2485dbec4 \ + --hash=sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c \ + --hash=sha256:fc010ab034c8c7452522748bf937df58020d256ccae0874463d1f4d01758af8e \ + --hash=sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff \ + --hash=sha256:fd8b3d9fd264be37976686c7f65cd52a83f5e84f4bfd2adf9c1d469676bbb6ae + # via pydantic +pygments==2.20.0 \ + --hash=sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f \ + --hash=sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176 + # via pytest +pytest==9.1.1 \ + --hash=sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313 \ + --hash=sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c + # via -r requirements-dev.in +python-dotenv==1.2.2 \ + --hash=sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a \ + --hash=sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3 + # via -r backend/requirements.txt +sniffio==1.3.1 \ + --hash=sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2 \ + --hash=sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc + # via anthropic +sqlite-vec==0.1.9 \ + --hash=sha256:1515727990b49e79bcaf75fdee2ffc7d461f8b66905013231251f1c8938e7786 \ + --hash=sha256:1b62a7f0a060d9475575d4e599bbf94a13d85af896bc1ce86ee80d1b5b48e5fb \ + --hash=sha256:1d52e30513bae4cc9778ddbf6145610434081be4c3afe57cd877893bad9f6b6c \ + --hash=sha256:4a28dc12fa4b53d7b1dced22da2488fade444e96b5d16fd2d698cd670675cf32 \ + --hash=sha256:4e921e592f24a5f9a18f590b6ddd530eb637e2d474e3b1972f9bbeb773aa3cb9 + # via -r backend/requirements.txt +starlette==1.3.1 \ + --hash=sha256:05d0213193f2fbaae60e2ecb593b4add4262ad4e46536b54abe36f11a71724e0 \ + --hash=sha256:c7372aae11c3c3f26a42df7bd626cec2f47d03483d261d369516a615a53714c6 + # via fastapi +typing-extensions==4.16.0 \ + --hash=sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8 \ + --hash=sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5 + # via + # anthropic + # anyio + # fastapi + # pydantic + # pydantic-core + # starlette + # typing-inspection +typing-inspection==0.4.2 \ + --hash=sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7 \ + --hash=sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464 + # via + # fastapi + # pydantic +uvicorn==0.52.0 \ + --hash=sha256:3d887809810b89ed33501bcf0a9aba469b06ecd608158efce04bd6b48d8c9b08 \ + --hash=sha256:ca8876ad6c1983f394157c168b39d52f6dd56dabf5602fa0982751cffc2293ae + # via -r backend/requirements.txt diff --git a/scripts/bootstrap_dev.sh b/scripts/bootstrap_dev.sh index 102253f6..01d3f41a 100755 --- a/scripts/bootstrap_dev.sh +++ b/scripts/bootstrap_dev.sh @@ -18,10 +18,12 @@ if ! command -v "$PYTHON_BIN" >/dev/null 2>&1; then Cortex development requires Python 3.12, but '$PYTHON_BIN' was not found. Install Python 3.12, then rerun: - make setup + make doctor + make demo Or point Cortex at an existing interpreter: - make setup PYTHON=/absolute/path/to/python3.12 + make doctor PYTHON=/absolute/path/to/python3.12 + make demo PYTHON=/absolute/path/to/python3.12 EOF exit 1 fi @@ -32,7 +34,8 @@ if [[ "$VERSION" != "3.12" ]]; then Cortex development requires Python 3.12; '$PYTHON_BIN' reports Python $VERSION. Use: - make setup PYTHON=/absolute/path/to/python3.12 + make doctor PYTHON=/absolute/path/to/python3.12 + make demo PYTHON=/absolute/path/to/python3.12 EOF exit 1 fi @@ -43,8 +46,12 @@ if [[ "$CHECK_ONLY" == "1" ]]; then fi "$PYTHON_BIN" -m venv "$VENV_PATH" -"$VENV_PATH/bin/python" -m pip install -r "$ROOT/requirements-dev.lock" -"$VENV_PATH/bin/python" -m pip install -e "$ROOT/sdk/python" +echo "Installing the pinned Cortex development environment..." +"$VENV_PATH/bin/python" -m pip install --disable-pip-version-check --quiet \ + --require-hashes \ + -r "$ROOT/requirements-dev.lock" +"$VENV_PATH/bin/python" -m pip install --disable-pip-version-check --quiet \ + -e "$ROOT/sdk/python" cat < int: + with socket.socket() as sock: + sock.bind(("127.0.0.1", 0)) + return int(sock.getsockname()[1]) + + +def _json_request( + base_url: str, + path: str, + *, + token: str, + method: str = "GET", + body: dict | None = None, + query: dict[str, str] | None = None, +) -> dict: + url = f"{base_url}{path}" + if query: + url = f"{url}?{parse.urlencode(query)}" + payload = json.dumps(body).encode("utf-8") if body is not None else None + headers = {"Authorization": f"Bearer {token}"} + if payload is not None: + headers["Content-Type"] = "application/json" + with request.urlopen( + request.Request(url, data=payload, headers=headers, method=method), + timeout=10, + ) as response: + return json.loads(response.read().decode("utf-8")) + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--app", type=Path, default=Path("macos/build/Cortex.app")) + args = parser.parse_args() + + app = args.app.expanduser().resolve() + resources = app / "Contents" / "Resources" + python = ( + app + / "Contents" + / "Frameworks" + / "Python.framework" + / "Versions" + / "3.12" + / "bin" + / "python3" + ) + required = [ + python, + resources / "backend" / "app" / "standalone_server.pyc", + resources / "python", + resources / "model2vec", + ] + missing = [str(path) for path in required if not path.exists()] + if missing: + raise SystemExit(f"bundled runtime is incomplete: {missing}") + + token = "cortex-bundled-runtime-smoke-token" + port = _free_port() + base_url = f"http://127.0.0.1:{port}" + with tempfile.TemporaryDirectory(prefix="cortex-bundled-runtime-") as tmp: + root = Path(tmp) + env = os.environ.copy() + env.update( + { + "CORTEX_API_KEY": token, + "CORTEX_AUTO_APPROVE_CAPTURES": "1", + "CORTEX_DB_PATH": str(root / "Cortex.vault" / "index.sqlite"), + "CORTEX_VAULT_PATH": str(root / "Cortex.vault"), + "CORTEX_EMBEDDING_PROVIDER": "model2vec", + "CORTEX_EMBEDDING_STRICT": "1", + "CORTEX_MODEL2VEC_PATH": str(resources / "model2vec"), + "CORTEX_PUBLIC_BASE_URL": base_url, + "HF_HUB_OFFLINE": "1", + "PYTHONNOUSERSITE": "1", + "PYTHONDONTWRITEBYTECODE": "1", + "PYTHONPATH": os.pathsep.join( + [str(resources / "backend"), str(resources / "python")] + ), + } + ) + process = subprocess.Popen( + [ + str(python), + "-S", + "-m", + "app.standalone_server", + "--host", + "127.0.0.1", + "--port", + str(port), + ], + cwd=resources, + env=env, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + ) + try: + deadline = time.monotonic() + 30 + while True: + if process.poll() is not None: + output = process.stdout.read() if process.stdout else "" + raise RuntimeError( + f"bundled runtime exited with {process.returncode}: {output[-4000:]}" + ) + try: + health = _json_request(base_url, "/health", token=token) + break + except (error.URLError, TimeoutError): + if time.monotonic() >= deadline: + raise RuntimeError("bundled runtime did not become healthy within 30s") + time.sleep(0.2) + + encoded_health = json.dumps(health) + assert health["status"] == "ok", health + assert "db_path" not in encoded_health and "vault_path" not in encoded_health + ready = _json_request(base_url, "/ready", token=token) + assert ready["status"] == "ok", ready + + _json_request( + base_url, + "/v1/captures", + token=token, + method="POST", + body={ + "content": "Bundled runtime smoke launches Friday after verification.", + "source": "bundled-runtime-smoke", + "source_url": "cortex-smoke://bundled-runtime", + }, + ) + answer = _json_request( + base_url, + "/v1/ask", + token=token, + query={"query": "When does the bundled runtime smoke launch?"}, + ) + assert answer.get("status") == "cited", answer + assert answer.get("citations"), answer + finally: + process.terminate() + try: + process.wait(timeout=5) + except subprocess.TimeoutExpired: + process.kill() + process.wait(timeout=5) + + print("bundled runtime smoke: health, readiness, capture, and cited Ask passed") + + +if __name__ == "__main__": + main() diff --git a/scripts/check_distribution_site.py b/scripts/check_distribution_site.py index 2ed2c572..16ce77e6 100644 --- a/scripts/check_distribution_site.py +++ b/scripts/check_distribution_site.py @@ -150,6 +150,69 @@ def validate_manifest(site_dir: Path) -> list[str]: return errors +def validate_release_surfaces(site_dir: Path) -> list[str]: + """Keep static fallbacks and legacy distribution metadata on the canonical feed.""" + errors: list[str] = [] + manifest_path = site_dir / "downloads" / "latest.json" + app_js_path = site_dir / "app.js" + distribution_path = site_dir / "downloads" / "distribution.json" + if not manifest_path.exists(): + return errors + try: + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + except json.JSONDecodeError: + return errors # validate_manifest reports the parse error. + + canonical = { + artifact.get("kind"): artifact + for artifact in manifest.get("artifacts", []) + if artifact.get("kind") in {"dmg", "zip"} + } + if app_js_path.exists(): + app_js = app_js_path.read_text(encoding="utf-8") + required_js_tokens = [ + f'version: "{manifest.get("version")}"', + f'build: "{manifest.get("build")}"', + f'channel: "{manifest.get("channel")}"', + *[ + str(artifact.get(field) or "") + for artifact in canonical.values() + for field in ("filename", "url") + ], + ] + for token in required_js_tokens: + if token and token not in app_js: + errors.append(f"app.js: release fallback is not aligned with latest.json ({token!r})") + + if distribution_path.exists(): + try: + distribution = json.loads(distribution_path.read_text(encoding="utf-8")) + except json.JSONDecodeError as exc: + errors.append(f"downloads/distribution.json is invalid JSON: {exc}") + else: + for field in ("version", "build", "channel"): + if distribution.get(field) != manifest.get(field): + errors.append( + f"downloads/distribution.json: {field} does not match latest.json" + ) + distribution_artifacts = { + artifact.get("kind"): artifact + for artifact in distribution.get("artifacts", []) + if artifact.get("kind") in {"dmg", "zip"} + } + for kind, artifact in canonical.items(): + candidate = distribution_artifacts.get(kind) + if candidate is None: + errors.append(f"downloads/distribution.json: missing {kind} artifact") + continue + for field in ("filename", "url", "sha256", "size_bytes"): + if candidate.get(field) != artifact.get(field): + errors.append( + f"downloads/distribution.json: {kind} {field} does not match latest.json" + ) + return errors + + def self_test() -> list[str]: """Exercise both manifest branches in a throwaway site dir. @@ -245,11 +308,12 @@ def main() -> None: else: errors.extend(validate_html(site_dir)) errors.extend(validate_manifest(site_dir)) + errors.extend(validate_release_surfaces(site_dir)) if errors: print(json.dumps({"status": "error", "site_dir": str(site_dir), "errors": errors}, indent=2)) raise SystemExit(1) - print(json.dumps({"status": "ok", "site_dir": str(site_dir), "checks": ["html-links", "release-manifest", "artifact-hashes"]}, indent=2)) + print(json.dumps({"status": "ok", "site_dir": str(site_dir), "checks": ["html-links", "release-manifest", "release-surface-parity", "artifact-hashes"]}, indent=2)) if __name__ == "__main__": diff --git a/scripts/check_markdown_links.py b/scripts/check_markdown_links.py new file mode 100644 index 00000000..a2374223 --- /dev/null +++ b/scripts/check_markdown_links.py @@ -0,0 +1,75 @@ +#!/usr/bin/env python3 +"""Fail when a repository Markdown file points at a missing local path.""" + +from __future__ import annotations + +import re +from pathlib import Path +from urllib.parse import unquote, urlsplit + + +ROOT = Path(__file__).resolve().parents[1] +EXCLUDED_PARTS = { + ".git", + ".venv", + "build", + "dist", + "dist-test", + "node_modules", + "release-artifacts", +} +INLINE_LINK = re.compile( + r"!?\[[^\]]*]\(\s*(?P<[^>]+>|[^)\s]+)(?:\s+['\"][^)]*)?\)" +) +REFERENCE_LINK = re.compile(r"^\s*\[[^\]]+]:\s*(?P<[^>]+>|\S+)", re.MULTILINE) + + +def _markdown_files() -> list[Path]: + return sorted( + path + for path in ROOT.rglob("*.md") + if not any(part in EXCLUDED_PARTS for part in path.relative_to(ROOT).parts) + ) + + +def _local_target(raw: str) -> str | None: + target = raw.strip().strip("<>") + if not target or target.startswith("#"): + return None + parsed = urlsplit(target) + if parsed.scheme or parsed.netloc: + return None + return unquote(parsed.path) + + +def main() -> int: + errors: list[str] = [] + checked = 0 + for document in _markdown_files(): + text = document.read_text(encoding="utf-8") + matches = [*INLINE_LINK.finditer(text), *REFERENCE_LINK.finditer(text)] + for match in matches: + raw = match.group("target") + target = _local_target(raw) + if target is None: + continue + checked += 1 + destination = ( + ROOT / target.lstrip("/") + if target.startswith("/") + else document.parent / target + ) + if not destination.exists(): + line = text.count("\n", 0, match.start()) + 1 + errors.append( + f"{document.relative_to(ROOT)}:{line}: missing local link target {raw!r}" + ) + if errors: + print("\n".join(errors)) + return 1 + print(f"markdown links: {checked} local targets passed") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/context_pack_eval.py b/scripts/context_pack_eval.py index 08ac2a66..359abf3d 100644 --- a/scripts/context_pack_eval.py +++ b/scripts/context_pack_eval.py @@ -1352,6 +1352,11 @@ def main() -> None: parser.add_argument("--vault-path", type=Path, help="Optional vault path. Defaults beside the SQLite database.") parser.add_argument("--user-id", default=USER_ID) parser.add_argument("--report-only", action="store_true", help="Print metrics without failing on regressions.") + parser.add_argument( + "--json", + action="store_true", + help="Print the complete per-case result instead of the concise default summary.", + ) args = parser.parse_args() if args.db_path: @@ -1364,9 +1369,31 @@ def main() -> None: tmp = Path(tempfile.mkdtemp(prefix="context-pack-eval-")) result = run_context_pack_eval(tmp / "context-pack-eval.sqlite", tmp / "Cortex.vault", args.user_id) - print(json.dumps(result, indent=2, sort_keys=True)) - failures = check_context_pack_thresholds(result) + if args.json or failures: + print(json.dumps(result, indent=2, sort_keys=True)) + else: + metrics = result["metrics"] + replay_metrics = result["session_replay"]["metrics"] + print( + json.dumps( + { + "status": "ok", + "checks": result["counts"]["total_checks"], + "profiles": result.get("profile_names", []), + "ndcg@k": metrics.get("ndcg@k"), + "mrr": metrics.get("mrr"), + "citation_coverage": metrics.get("citation_coverage"), + "no_leak": metrics.get("no_leak"), + "budget_adherence": metrics.get("budget_adherence"), + "session_turns": replay_metrics.get("turn_count"), + "session_delta_token_ratio": replay_metrics.get("token_savings_ratio"), + "note": "Use --json for per-case and session-replay diagnostics.", + }, + indent=2, + sort_keys=True, + ) + ) if failures and not args.report_only: print("\nCONTEXT PACK GATE FAILED:", file=sys.stderr) for failure in failures: diff --git a/scripts/dev_backend.sh b/scripts/dev_backend.sh index 48bf3372..ec4ac492 100755 --- a/scripts/dev_backend.sh +++ b/scripts/dev_backend.sh @@ -26,4 +26,43 @@ if [[ "$VERSION" != "3.12" ]]; then exit 1 fi +if FREE_PORT="$("$PYTHON_BIN" - "$CORTEX_PORT" <<'PY' +import socket +import sys + +try: + requested = int(sys.argv[1]) + if not 1 <= requested <= 65535: + raise ValueError +except ValueError: + print(f"CORTEX_PORT must be an integer from 1 to 65535; received {sys.argv[1]!r}.", file=sys.stderr) + raise SystemExit(2) + +with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as probe: + try: + probe.bind(("127.0.0.1", requested)) + except OSError: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as fallback: + fallback.bind(("127.0.0.1", 0)) + print(fallback.getsockname()[1]) + raise SystemExit(1) +PY +)"; then + : +else + STATUS=$? + if [[ "$STATUS" -eq 1 ]]; then + cat >&2 < dict[str, object]: return sections -def main() -> int: +def _print_quickstart_result(base_url: str, token: str) -> None: + query = "When does Project Atlas ship?" + answer = _get_json( + f"{base_url}/v1/ask?{urlencode({'query': query, 'limit': 5})}", + token, + ) + if answer.get("status") != "cited" or not answer.get("citations"): + raise AssertionError(f"quickstart Ask did not return a cited answer: {answer}") + citation = answer["citations"][0] + print() + print("Cortex is working.") + print(" [ok] isolated loopback server started") + print(" [ok] 3 synthetic Project Atlas captures loaded") + print(" [ok] Ask returned cited evidence") + print() + print(f"Question: {query}") + print(f"Answer: {answer.get('answer', '').strip()}") + print( + "Citation: " + f"{citation.get('source_url') or citation.get('source') or citation.get('memory_id')}" + ) + print() + print("The temporary vault has been removed; no personal Cortex data was read.") + print( + "Next: CORTEX_AUTO_APPROVE_CAPTURES=1 make run" + " # persistent dev server + http://127.0.0.1:8766/docs" + ) + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser( + description="Exercise public Cortex examples against an isolated local server." + ) + parser.add_argument( + "--quickstart", + action="store_true", + help="Run only the synthetic seed + cited-answer path and print human-readable output.", + ) + parser.add_argument( + "--runtime", + choices=("fastapi", "standalone"), + default="fastapi", + help="Server implementation to test. 'standalone' is the runtime shipped in the macOS app.", + ) + args = parser.parse_args(argv) port = _available_loopback_port() base_url = f"http://127.0.0.1:{port}" with tempfile.TemporaryDirectory(prefix="cortex-examples-") as temp: @@ -92,8 +138,18 @@ def main() -> int: ), } ) - server = subprocess.Popen( - [ + if args.runtime == "standalone": + server_command = [ + sys.executable, + "-m", + "backend.app.standalone_server", + "--host", + "127.0.0.1", + "--port", + str(port), + ] + else: + server_command = [ sys.executable, "-m", "uvicorn", @@ -104,7 +160,9 @@ def main() -> int: str(port), "--log-level", "warning", - ], + ] + server = subprocess.Popen( + server_command, cwd=ROOT, env=env, stdout=subprocess.DEVNULL, @@ -140,6 +198,9 @@ def main() -> int: seed = _run_example("examples/seed_demo.py", env) if "done: 3 deterministic synthetic captures are ready" not in seed: raise AssertionError("seed_demo.py did not report all fixture captures") + if args.quickstart: + _print_quickstart_result(base_url, "dev-local-key") + return 0 first_stats = _get_json(f"{base_url}/v1/stats", "dev-local-key") second_seed = _run_example("examples/seed_demo.py", env) if "done: 3 deterministic synthetic captures are ready" not in second_seed: @@ -152,7 +213,7 @@ def main() -> int: f"{first_stats} -> {second_stats}" ) - search = json.loads(_run_example("examples/minimal_search.py", env)) + search = json.loads(_run_example("examples/minimal_search.py", env, "--json")) results = search.get("results", []) if not results or not _contains_demo_source(search): raise AssertionError("minimal_search.py did not return the cited demo memory") @@ -198,7 +259,7 @@ def main() -> int: server.kill() server.wait(timeout=5) - print("examples smoke: all public Python examples passed") + print(f"examples smoke: all public Python examples passed ({args.runtime} runtime)") return 0 diff --git a/scripts/retrieval_eval.py b/scripts/retrieval_eval.py index 336a983f..3e39f743 100644 --- a/scripts/retrieval_eval.py +++ b/scripts/retrieval_eval.py @@ -5674,6 +5674,11 @@ def main() -> None: action="store_true", help="Print metrics without failing on threshold regressions (local inspection).", ) + parser.add_argument( + "--json", + action="store_true", + help="Print the complete per-case result instead of the concise default summary.", + ) args = parser.parse_args() if args.db_path: @@ -5683,10 +5688,29 @@ def main() -> None: root = Path(tmp) result = run_retrieval_eval(root / "retrieval-eval.sqlite", root / "Cortex.vault", args.user_id) - print(json.dumps(result, indent=2, sort_keys=True)) - failures = check_retrieval_metric_thresholds(result) failures.extend(check_relevance_monotonicity(result)) + if args.json or failures: + print(json.dumps(result, indent=2, sort_keys=True)) + else: + overall = result["metrics"]["overall"] + monotonicity = result["relevance_monotonicity"] + print( + json.dumps( + { + "status": result.get("status"), + "retrieval_mode": "deterministic hash/FTS regression path", + "cases": overall.get("case_count"), + "top1_accuracy": overall.get("top1_accuracy"), + "recall@3": overall.get("recall@3"), + "precision@3": overall.get("precision@3"), + "pairwise_concordance": monotonicity.get("pairwise_concordance"), + "note": "Use --json for per-case diagnostics.", + }, + indent=2, + sort_keys=True, + ) + ) if failures and not args.report_only: print("\nRETRIEVAL QUALITY GATE FAILED:", file=sys.stderr) for failure in failures: diff --git a/sdk/README.md b/sdk/README.md index bf1b865f..9224213b 100644 --- a/sdk/README.md +++ b/sdk/README.md @@ -10,6 +10,11 @@ catalog. | [Python](./python/) | `doppl-cortex-client` | stdlib `urllib` | none (Python 3.9+) | | [TypeScript](./typescript/) | `@doppl-tech/cortex-client` | `fetch` | none (Node 18+ / browser) | +> **Distribution status:** both clients are tested source packages in this +> repository. Registry names are reserved/planned; use the editable/source +> instructions in each SDK README until a published release is explicitly +> documented. + Both mirror the same method surface: | Method (py / ts) | Endpoint | Purpose | @@ -18,7 +23,7 @@ Both mirror the same method surface: | `openai_tools` / `openaiTools` | `GET /v1/tools/schema?format=openai` | OpenAI function-calling `tools` array. | | `anthropic_tools` / `anthropicTools` | `GET /v1/tools/schema?format=anthropic` | Anthropic `tools` array. | | `call_tool` / `callTool` | `POST /v1/tools/call` | Invoke any tool by name; returns its `result`. | -| `context` | `POST /v1/context` | Token-budgeted, cited working-context pack. | +| `context` | `POST /v1/context` | Token-budgeted, cited working-context pack; `sector` isolates while `project` is a ranking hint. | | `search` | `GET /v1/search` | Search memory (`top_k` → server `limit`). | | `ask` | `GET /v1/ask` | Cited answer or explicit abstention. | diff --git a/sdk/python/README.md b/sdk/python/README.md index f512d1c0..c3a12d22 100644 --- a/sdk/python/README.md +++ b/sdk/python/README.md @@ -20,22 +20,33 @@ tool definition. This client wraps that HTTP surface. Requires Python 3.9+. +The SDK is currently supported from this repository; the PyPI name is reserved +but not yet the installation path: + ```bash -pip install doppl-cortex-client # once published (see sdk/PUBLISHING.md) -# or, for local development against this repo: -cd sdk/python && pip install -e . +# From the Cortex repository root: +python -m pip install -e sdk/python ``` -The importable module is `cortex_client` regardless of the PyPI package name (`doppl-cortex-client`) -— see the Quickstart below. Or just copy the `cortex_client/` folder into your project — it has no -dependencies. +`make setup` already performs this editable install for contributors. Once a +registry release is documented, the command will be +`pip install doppl-cortex-client`; do not assume that command works before then. +The importable module is `cortex_client` regardless of the distribution name. +You can also vendor the `cortex_client/` folder because it has no dependencies. ## Quickstart +First start Cortex (`make run` from the repository root) or open the installed +macOS app. The development server uses `dev-local-key`; an installed app exposes +a scoped `cxa_` REST token under Connections & Privacy. + ```python from cortex_client import CortexClient, CortexError -client = CortexClient(base_url="http://127.0.0.1:8766", token="cxa_your_token") +client = CortexClient( + base_url="http://127.0.0.1:8766", + token="dev-local-key", # use a scoped cxa_ token with the installed app +) # Cited answer to a specific question (never an uncited guess). print(client.ask("What database do we use?")) @@ -46,6 +57,15 @@ print(client.search("release checklist", top_k=5)) # Token-budgeted working-context pack — call this before doing a task. print(client.context("draft the release notes", intent="draft", token_budget=1500)) +# Portable SMP projection with multi-turn delta context and a project hint. +print(client.context( + "plan the Project Atlas launch", + format="smp", + model="claude", + session_id="atlas-planning", + project="Atlas", +)) + # Any tool in the catalog, by name. print(client.call_tool("get_person_map", {})) @@ -55,6 +75,12 @@ except CortexError as exc: print(exc.status, exc.detail) # e.g. 403 "Cortex API token requires destructive scope" ``` +`sector` is the hard corpus filter to use when memories must be isolated. +`project` only helps Cortex rank and enrich entity context; it is **not** an +authorization or isolation boundary. If `pin=True` is combined with +`session_id`, that ID must come from the `start_agent_session` MCP tool. Pinning +without a session ID is valid. + ## Methods | Method | Endpoint | Purpose | @@ -63,7 +89,7 @@ except CortexError as exc: | `openai_tools()` | `GET /v1/tools/schema?format=openai` | Convenience → OpenAI function-calling `tools` array. | | `anthropic_tools()` | `GET /v1/tools/schema?format=anthropic` | Convenience → Anthropic `tools` array. | | `call_tool(name, arguments=None)` | `POST /v1/tools/call` | Invoke any tool; returns its `result`. | -| `context(task, intent=None, token_budget=2000, surface="agent")` | `POST /v1/context` | Cited working-context pack. | +| `context(task, ..., format=None, model=None, session_id=None, pin=None, sector=None, project=None, as_of=None)` | `POST /v1/context` | Cited pack, SMP/Markdown projection, sector filter, project hint, and session deltas. | | `search(query, top_k=8)` | `GET /v1/search` | Search memory. | | `ask(query, top_k=8)` | `GET /v1/ask` | Cited answer / explicit abstention. | @@ -154,5 +180,5 @@ print(resp.content) The test suite stubs the SDK's one `urllib` call — it never touches the network: ```bash -python3 -m pytest sdk/python/tests/test_client.py +.venv/bin/python -m pytest sdk/python/tests/test_client.py ``` diff --git a/sdk/python/cortex_client/client.py b/sdk/python/cortex_client/client.py index ff184d86..4f8320bd 100644 --- a/sdk/python/cortex_client/client.py +++ b/sdk/python/cortex_client/client.py @@ -273,6 +273,14 @@ def context( intent: Optional[str] = None, token_budget: int = 2000, surface: str = "agent", + *, + format: Optional[str] = None, + model: Optional[str] = None, + session_id: Optional[str] = None, + pin: Optional[bool] = None, + sector: Optional[str] = None, + project: Optional[str] = None, + as_of: Optional[str] = None, ) -> Any: """Build a token-budgeted, cited working-context pack for a task. @@ -284,20 +292,37 @@ def context( ``recall``). token_budget: Approximate token budget for the assembled pack. surface: Which tool/agent you are (e.g. ``cursor``, ``claude``, ``agent``). + format: Optional response projection: ``json``, ``markdown``, or ``smp``. + model: Optional target-model profile used for pack adaptation. + session_id: Optional multi-turn session identifier for delta packs. When + combined with ``pin=True``, this must be an ``asess_...`` id returned + by the ``start_agent_session`` MCP tool. + pin: Whether to persist an immutable, content-addressed copy of the pack. + sector: Optional hard memory-sector filter; use this for corpus isolation. + project: Optional entity-ranking hint. This is not an access-control or + isolation boundary; use ``sector`` for isolation. + as_of: Optional ISO 8601 historical cutoff. Returns: The assembled context pack (JSON object). """ - return self._request( - "POST", - "/v1/context", - body={ - "task": task, - "intent": intent, - "token_budget": token_budget, - "surface": surface, - }, - ) + body: dict[str, Any] = { + "task": task, + "intent": intent, + "token_budget": token_budget, + "surface": surface, + } + optional_fields = { + "format": format, + "model": model, + "session_id": session_id, + "pin": pin, + "sector": sector, + "project": project, + "as_of": as_of, + } + body.update({key: value for key, value in optional_fields.items() if value is not None}) + return self._request("POST", "/v1/context", body=body) def search(self, query: str, top_k: int = 8) -> Any: """Search Cortex memory and return results with retrieval diagnostics. diff --git a/sdk/python/tests/test_client.py b/sdk/python/tests/test_client.py index b352c0fa..052b99fa 100644 --- a/sdk/python/tests/test_client.py +++ b/sdk/python/tests/test_client.py @@ -180,6 +180,35 @@ def test_context_defaults(recorder): assert body["surface"] == "agent" +def test_context_forwards_cmp_session_and_scope_options(recorder): + client = CortexClient(token="t") + client.context( + "plan the Project Atlas launch", + intent="plan", + format="smp", + model="claude", + session_id="session-atlas", + pin=True, + sector="Project Atlas", + project="Atlas", + as_of="2026-07-30T00:00:00Z", + ) + body = json.loads(recorder.request.data.decode("utf-8")) + assert body == { + "task": "plan the Project Atlas launch", + "intent": "plan", + "token_budget": 2000, + "surface": "agent", + "format": "smp", + "model": "claude", + "session_id": "session-atlas", + "pin": True, + "sector": "Project Atlas", + "project": "Atlas", + "as_of": "2026-07-30T00:00:00Z", + } + + # -- call_tool ------------------------------------------------------------------------- def test_call_tool_body_and_unwrap(monkeypatch): diff --git a/sdk/typescript/README.md b/sdk/typescript/README.md index 9aa31240..75612daa 100644 --- a/sdk/typescript/README.md +++ b/sdk/typescript/README.md @@ -21,29 +21,34 @@ tool definition. This client wraps that HTTP surface. Requires Node 18+ (for global `fetch` / `AbortController`). -```bash -npm install @doppl-tech/cortex-client # once published (see sdk/PUBLISHING.md) -``` - -### Build from source +The npm scope is planned but not yet the supported installation path. Build and +test the source package from this repository: ```bash cd sdk/typescript -npm install # dev-only: typescript, @types/node +npm ci # dev-only: typescript, @types/node npm run build # emits dist/index.js + dist/index.d.ts via tsc npm test # compiles test/ + src/ and runs against a stubbed fetch (node:test) ``` -Or drop `src/index.ts` straight into your project. +Until a registry release is documented, import `dist/index.js` from the checkout +or vendor `src/index.ts`. Do not assume +`npm install @doppl-tech/cortex-client` works before then. ## Quickstart +First start Cortex (`make run` from the repository root) or open the installed +macOS app. The development server uses `dev-local-key`; an installed app exposes +a scoped `cxa_` REST token under Connections & Privacy. + ```ts -import { CortexClient, CortexError } from "@doppl-tech/cortex-client"; +// Registry release: import from "@doppl-tech/cortex-client". +// Source checkout: import from "./sdk/typescript/dist/index.js". +import { CortexClient, CortexError } from "./sdk/typescript/dist/index.js"; const cortex = new CortexClient({ baseUrl: "http://127.0.0.1:8766", - token: "cxa_your_token", + token: "dev-local-key", // use a scoped cxa_ token with the installed app }); // Cited answer to a specific question (never an uncited guess). @@ -55,6 +60,14 @@ console.log(await cortex.search("release checklist", 5)); // Token-budgeted working-context pack — call this before doing a task. console.log(await cortex.context("draft the release notes", { intent: "draft", tokenBudget: 1500 })); +// Portable SMP projection with multi-turn delta context and a project hint. +console.log(await cortex.context("plan the Project Atlas launch", { + format: "smp", + model: "claude", + sessionId: "atlas-planning", + project: "Atlas", +})); + // Any tool in the catalog, by name. console.log(await cortex.callTool("get_person_map", {})); @@ -67,6 +80,12 @@ try { } ``` +`sector` is the hard corpus filter to use when memories must be isolated. +`project` only helps Cortex rank and enrich entity context; it is **not** an +authorization or isolation boundary. If `pin: true` is combined with +`sessionId`, that ID must come from the `start_agent_session` MCP tool. Pinning +without a session ID is valid. + ## Methods | Method | Endpoint | Purpose | @@ -75,7 +94,7 @@ try { | `openaiTools()` | `GET /v1/tools/schema?format=openai` | Convenience → `OpenAITool[]`. | | `anthropicTools()` | `GET /v1/tools/schema?format=anthropic` | Convenience → `AnthropicTool[]`. | | `callTool(name, args = {})` | `POST /v1/tools/call` | Invoke any tool; returns its `result`. | -| `context(task, options?)` | `POST /v1/context` | Cited working-context pack. | +| `context(task, options?)` | `POST /v1/context` | Cited pack, SMP/Markdown projection, sector filter, project hint, and session deltas. | | `search(query, topK = 8)` | `GET /v1/search` | Search memory. | | `ask(query, topK = 8)` | `GET /v1/ask` | Cited answer / explicit abstention. | diff --git a/sdk/typescript/src/index.ts b/sdk/typescript/src/index.ts index 8bf25501..d2049e9c 100644 --- a/sdk/typescript/src/index.ts +++ b/sdk/typescript/src/index.ts @@ -49,6 +49,26 @@ export interface ContextOptions { tokenBudget?: number; /** Which tool/agent you are (e.g. "cursor", "claude", "agent"). Default "agent". */ surface?: string; + /** Response projection. The SMP form is designed for portable agent memory. */ + format?: "json" | "markdown" | "smp"; + /** Optional target-model profile used for pack adaptation. */ + model?: string; + /** + * Multi-turn session id used for delta context packs. With `pin: true`, this + * must be an `asess_...` id returned by the `start_agent_session` MCP tool. + */ + sessionId?: string; + /** Persist an immutable, content-addressed copy of the assembled pack. */ + pin?: boolean; + /** Optional hard memory-sector filter; use this for corpus isolation. */ + sector?: string; + /** + * Optional entity-ranking hint. This is not an access-control or isolation + * boundary; use `sector` for isolation. + */ + project?: string; + /** Optional ISO 8601 historical cutoff. */ + asOf?: string; } /** Constructor options for {@link CortexClient}. */ @@ -306,13 +326,21 @@ export class CortexClient { * Call this first before doing work for the user. */ async context(task: string, options: ContextOptions = {}): Promise { + const body: Record = { + task, + intent: options.intent ?? null, + token_budget: options.tokenBudget ?? 2000, + surface: options.surface ?? "agent", + }; + if (options.format !== undefined) body.format = options.format; + if (options.model !== undefined) body.model = options.model; + if (options.sessionId !== undefined) body.session_id = options.sessionId; + if (options.pin !== undefined) body.pin = options.pin; + if (options.sector !== undefined) body.sector = options.sector; + if (options.project !== undefined) body.project = options.project; + if (options.asOf !== undefined) body.as_of = options.asOf; return this.request("POST", "/v1/context", { - body: { - task, - intent: options.intent ?? null, - token_budget: options.tokenBudget ?? 2000, - surface: options.surface ?? "agent", - }, + body, }); } diff --git a/sdk/typescript/test/client.test.ts b/sdk/typescript/test/client.test.ts index 66a89cb1..3babb555 100644 --- a/sdk/typescript/test/client.test.ts +++ b/sdk/typescript/test/client.test.ts @@ -168,6 +168,36 @@ test("context forwards intent/tokenBudget/surface overrides", async () => { }); }); +test("context forwards CMP session, model, projection, and scope options", async () => { + const { fetchStub, calls } = makeFetchStub([{ status: 200, body: {} }]); + const client = new CortexClient({ token: "t", fetch: fetchStub }); + + await client.context("plan the Project Atlas launch", { + intent: "plan", + format: "smp", + model: "claude", + sessionId: "session-atlas", + pin: true, + sector: "Project Atlas", + project: "Atlas", + asOf: "2026-07-30T00:00:00Z", + }); + + assert.deepEqual(JSON.parse(calls[0].body ?? "{}"), { + task: "plan the Project Atlas launch", + intent: "plan", + token_budget: 2000, + surface: "agent", + format: "smp", + model: "claude", + session_id: "session-atlas", + pin: true, + sector: "Project Atlas", + project: "Atlas", + as_of: "2026-07-30T00:00:00Z", + }); +}); + test("search issues GET /v1/search with query + limit params", async () => { const { fetchStub, calls } = makeFetchStub([{ status: 200, body: { results: [] } }]); const client = new CortexClient({ token: "t", fetch: fetchStub }); diff --git a/site/app.js b/site/app.js index 0ed93df5..ae3a8cb4 100644 --- a/site/app.js +++ b/site/app.js @@ -1,18 +1,18 @@ (function () { const fallbackRelease = { version: "0.2.0", - build: "22", + build: "51", channel: "local-beta", artifacts: [ { kind: "dmg", - filename: "Cortex-0.2.0-22.dmg", - url: "downloads/Cortex-0.2.0-22.dmg" + filename: "Cortex-0.2.0-51.dmg", + url: "https://github.com/doppl-tech/releases/releases/download/v0.2.0-51/Cortex-0.2.0-51.dmg" }, { kind: "zip", - filename: "Cortex-0.2.0-22.app.zip", - url: "downloads/Cortex-0.2.0-22.app.zip" + filename: "Cortex-0.2.0-51.app.zip", + url: "https://github.com/doppl-tech/releases/releases/download/v0.2.0-51/Cortex-0.2.0-51.app.zip" } ] }; diff --git a/site/downloads/distribution.json b/site/downloads/distribution.json index 55954cf9..66f6817e 100644 --- a/site/downloads/distribution.json +++ b/site/downloads/distribution.json @@ -1,18 +1,18 @@ { "artifacts": [ { - "filename": "Cortex-0.2.0-22.dmg", + "filename": "Cortex-0.2.0-51.dmg", "kind": "dmg", - "sha256": "eb421cf0fd767b3aaf8a1d5ef05235186b67e056d30f299c777ae89d9cad34b9", - "size_bytes": 87312603, - "url": "downloads/Cortex-0.2.0-22.dmg" + "sha256": "b416c8af096d9bc2776adc306a9d60f843054bc35e503d993eda9e199436ec64", + "size_bytes": 74548179, + "url": "https://github.com/doppl-tech/releases/releases/download/v0.2.0-51/Cortex-0.2.0-51.dmg" }, { - "filename": "Cortex-0.2.0-22.app.zip", + "filename": "Cortex-0.2.0-51.app.zip", "kind": "zip", - "sha256": "eb8797c807fa2b50fc9a5972660ffd639348c59077a84c192a342f6266c4f2bd", - "size_bytes": 68365377, - "url": "downloads/Cortex-0.2.0-22.app.zip" + "sha256": "3a3ee9fc26fca2c3944c9bd4dff49fddb5f8dfc377b5efa14ce8966f5b34e273", + "size_bytes": 71687986, + "url": "https://github.com/doppl-tech/releases/releases/download/v0.2.0-51/Cortex-0.2.0-51.app.zip" }, { "filename": "cortex-memory-0.1.0.zip", @@ -22,8 +22,8 @@ "url": "downloads/cortex-memory-0.1.0.zip" } ], - "build": "22", + "build": "51", "channel": "local-beta", - "prepared_at": "2026-07-09T19:23:49+00:00", + "prepared_at": "2026-07-24T03:54:50Z", "version": "0.2.0" } From 1cb883a5388711d577b5799c85dc1cb4880e4802 Mon Sep 17 00:00:00 2001 From: VamikaSinghal Date: Sat, 1 Aug 2026 21:01:21 -0700 Subject: [PATCH 5/6] Make release CI platform-safe and resilient --- .github/workflows/ci.yml | 11 +- scripts/bundled_runtime_smoke.py | 188 ++++++++++++++++++++----------- 2 files changed, 132 insertions(+), 67 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d27961c2..77894ca7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -145,8 +145,11 @@ jobs: - name: Audit hosted runtime dependencies run: pip-audit --strict -r backend/requirements.lock - - name: Audit top-level dependencies for known vulnerabilities - run: pip-audit --strict -r legacy/requirements.txt + - name: Audit archived prototype dependencies for known vulnerabilities + # The archived manifest includes macOS-only rumps/pyobjc dependencies that + # cannot be resolved on this Linux runner. Audit its direct declarations + # without attempting to install the retired prototype dependency graph. + run: pip-audit --strict --no-deps -r legacy/requirements.txt - name: Audit packaged backend runtime dependencies run: pip-audit --strict -r backend/runtime-requirements.lock @@ -280,5 +283,7 @@ jobs: python scripts/check_vector_runtime.py \ --app macos/build/Cortex.app \ --require-model2vec - python scripts/bundled_runtime_smoke.py --app macos/build/Cortex.app + python scripts/bundled_runtime_smoke.py \ + --app macos/build/Cortex.app \ + --startup-timeout-seconds 120 codesign --verify --deep --strict --verbose=2 macos/build/Cortex.app diff --git a/scripts/bundled_runtime_smoke.py b/scripts/bundled_runtime_smoke.py index fd9186a2..e5f88d38 100644 --- a/scripts/bundled_runtime_smoke.py +++ b/scripts/bundled_runtime_smoke.py @@ -28,6 +28,7 @@ def _json_request( method: str = "GET", body: dict | None = None, query: dict[str, str] | None = None, + timeout_seconds: float = 10, ) -> dict: url = f"{base_url}{path}" if query: @@ -38,15 +39,63 @@ def _json_request( headers["Content-Type"] = "application/json" with request.urlopen( request.Request(url, data=payload, headers=headers, method=method), - timeout=10, + timeout=timeout_seconds, ) as response: return json.loads(response.read().decode("utf-8")) +def _server_log_tail(path: Path, *, limit: int = 4000) -> str: + try: + output = path.read_text(encoding="utf-8", errors="replace") + except OSError as exc: + return f"" + return output[-limit:] or "" + + +def _wait_for_json_endpoint( + process: subprocess.Popen, + base_url: str, + path: str, + *, + token: str, + deadline: float, + server_log_path: Path, +) -> dict: + while True: + if process.poll() is not None: + raise RuntimeError( + f"bundled runtime exited with {process.returncode}; server output:\n" + f"{_server_log_tail(server_log_path)}" + ) + remaining = deadline - time.monotonic() + if remaining <= 0: + raise RuntimeError( + f"bundled runtime endpoint {path} was not ready before the deadline; " + f"server output:\n{_server_log_tail(server_log_path)}" + ) + try: + return _json_request( + base_url, + path, + token=token, + timeout_seconds=min(2, max(0.1, remaining)), + ) + except (error.URLError, TimeoutError, json.JSONDecodeError): + time.sleep(0.2) + + def main() -> None: parser = argparse.ArgumentParser() parser.add_argument("--app", type=Path, default=Path("macos/build/Cortex.app")) + parser.add_argument( + "--startup-timeout-seconds", + type=float, + default=90, + help="Maximum time to wait for a cold bundled runtime to become healthy.", + ) args = parser.parse_args() + if args.startup_timeout_seconds <= 0: + parser.error("--startup-timeout-seconds must be greater than zero") app = args.app.expanduser().resolve() resources = app / "Contents" / "Resources" @@ -75,6 +124,7 @@ def main() -> None: base_url = f"http://127.0.0.1:{port}" with tempfile.TemporaryDirectory(prefix="cortex-bundled-runtime-") as tmp: root = Path(tmp) + server_log_path = root / "server.log" env = os.environ.copy() env.update( { @@ -94,71 +144,81 @@ def main() -> None: ), } ) - process = subprocess.Popen( - [ - str(python), - "-S", - "-m", - "app.standalone_server", - "--host", - "127.0.0.1", - "--port", - str(port), - ], - cwd=resources, - env=env, - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, - text=True, - ) - try: - deadline = time.monotonic() + 30 - while True: - if process.poll() is not None: - output = process.stdout.read() if process.stdout else "" - raise RuntimeError( - f"bundled runtime exited with {process.returncode}: {output[-4000:]}" - ) - try: - health = _json_request(base_url, "/health", token=token) - break - except (error.URLError, TimeoutError): - if time.monotonic() >= deadline: - raise RuntimeError("bundled runtime did not become healthy within 30s") - time.sleep(0.2) - - encoded_health = json.dumps(health) - assert health["status"] == "ok", health - assert "db_path" not in encoded_health and "vault_path" not in encoded_health - ready = _json_request(base_url, "/ready", token=token) - assert ready["status"] == "ok", ready - - _json_request( - base_url, - "/v1/captures", - token=token, - method="POST", - body={ - "content": "Bundled runtime smoke launches Friday after verification.", - "source": "bundled-runtime-smoke", - "source_url": "cortex-smoke://bundled-runtime", - }, + with server_log_path.open("w", encoding="utf-8") as server_log: + process = subprocess.Popen( + [ + str(python), + "-S", + "-m", + "app.standalone_server", + "--host", + "127.0.0.1", + "--port", + str(port), + ], + cwd=resources, + env=env, + stdout=server_log, + stderr=subprocess.STDOUT, + text=True, ) - answer = _json_request( - base_url, - "/v1/ask", - token=token, - query={"query": "When does the bundled runtime smoke launch?"}, - ) - assert answer.get("status") == "cited", answer - assert answer.get("citations"), answer - finally: - process.terminate() try: - process.wait(timeout=5) - except subprocess.TimeoutExpired: - process.kill() - process.wait(timeout=5) + deadline = time.monotonic() + args.startup_timeout_seconds + health = _wait_for_json_endpoint( + process, + base_url, + "/health", + token=token, + deadline=deadline, + server_log_path=server_log_path, + ) + + encoded_health = json.dumps(health) + assert health["status"] == "ok", health + assert "db_path" not in encoded_health and "vault_path" not in encoded_health + ready = _wait_for_json_endpoint( + process, + base_url, + "/ready", + token=token, + deadline=deadline, + server_log_path=server_log_path, + ) + assert ready["status"] == "ok", ready + + _json_request( + base_url, + "/v1/captures", + token=token, + method="POST", + body={ + "content": "Bundled runtime smoke launches Friday after verification.", + "source": "bundled-runtime-smoke", + "source_url": "cortex-smoke://bundled-runtime", + }, + ) + answer = _json_request( + base_url, + "/v1/ask", + token=token, + query={"query": "When does the bundled runtime smoke launch?"}, + ) + assert answer.get("status") == "cited", answer + assert answer.get("citations"), answer + except Exception as exc: + if "server output:" in str(exc): + raise + raise RuntimeError( + f"bundled runtime smoke failed: {exc}; server output:\n" + f"{_server_log_tail(server_log_path)}" + ) from exc + finally: + process.terminate() + try: + process.wait(timeout=5) + except subprocess.TimeoutExpired: + process.kill() + process.wait(timeout=5) print("bundled runtime smoke: health, readiness, capture, and cited Ask passed") From 6fd6d9b25e011e7d4c9044ea940bd39e7b6893a5 Mon Sep 17 00:00:00 2001 From: VamikaSinghal Date: Sat, 1 Aug 2026 21:07:17 -0700 Subject: [PATCH 6/6] Mark archived macOS dependency correctly --- .github/workflows/ci.yml | 7 +++---- legacy/requirements.txt | 2 +- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 77894ca7..4ab00bac 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -146,10 +146,9 @@ jobs: run: pip-audit --strict -r backend/requirements.lock - name: Audit archived prototype dependencies for known vulnerabilities - # The archived manifest includes macOS-only rumps/pyobjc dependencies that - # cannot be resolved on this Linux runner. Audit its direct declarations - # without attempting to install the retired prototype dependency graph. - run: pip-audit --strict --no-deps -r legacy/requirements.txt + # rumps is correctly Darwin-marked in the manifest, so this Linux job + # resolves and audits the applicable archived dependency graph only. + run: pip-audit --strict -r legacy/requirements.txt - name: Audit packaged backend runtime dependencies run: pip-audit --strict -r backend/runtime-requirements.lock diff --git a/legacy/requirements.txt b/legacy/requirements.txt index 88fd975e..b00df40d 100644 --- a/legacy/requirements.txt +++ b/legacy/requirements.txt @@ -5,7 +5,7 @@ redis[hiredis]>=5.0.0 voyageai>=0.2.0 requests>=2.31.0 python-dotenv>=1.0.0 -rumps>=0.4.0 +rumps>=0.4.0; sys_platform == "darwin" pynput>=1.7.0 pyperclip>=1.8.0 uvicorn>=0.20.0