Architecture

How Mind Archive is put together, and why.

Every choice here was recorded as a numbered decision when it was made; the decision log has them in full.

Shape of the system

                    Browser
                       |
        React + TypeScript + Vite  (apps/web)
                       |
                    HTTP / JSON
                       |
          Python + FastAPI  (apps/api)
                       |
      +----------------+----------------+
      |        |        |       |       |
  Importers  Archive  Search  Events  Storage
    (now)     (now)    (now)    (now)  adapters
      |        |        |       |       |
      +----------------+----------------+
                       |
            Local filesystem + SQLite

Two applications, one repository. They talk over plain HTTP with JSON. There is no shared runtime, no build-time coupling, and no reason for either to know how the other is implemented.

Storage adapters are the one piece still to come: local storage is currently direct filesystem access, and the interface waits for a real second implementation rather than being guessed at now. The roadmap says when.

Backend — apps/api

apps/api/src/mind_archive/
├── main.py            FastAPI application, CORS, router wiring
├── config.py          Typed settings from environment variables
├── events.py          Small in-process event bus
├── paths.py           Safe resolution of filesystem paths
├── models.py          Conversation and Message, the archive's own model
├── importers/
│   ├── __init__.py    The registry: which importer can read this file
│   ├── base.py        The Importer protocol every provider satisfies
│   ├── reading.py     Reading untrusted JSON, shared by every importer
│   ├── zip_safety.py  Reading archives someone else produced
│   ├── chatgpt.py     The ChatGPT adapter
│   └── claude.py      The Claude adapter
├── archive/
│   ├── writer.py      Conversations to Markdown and JSON on disk
│   └── reader.py      ... and back off disk again
├── index/
│   ├── schema.py      The SQLite schema. Derived, droppable, rebuildable
│   ├── indexer.py     Building the index by reading the archive
│   └── search.py      FTS5 queries, and rewriting what people type
└── routes/
    ├── health.py      GET /api/health
    ├── config.py      GET /api/config
    ├── import_.py     GET /api/importers, POST /api/import, the inbox
    ├── conversations.py  GET /api/conversations, tags, POST /api/index/rebuild
    └── export.py      GET /api/export — the whole archive as a zip

Configuration is typed and read from the environment through pydantic-settings. Nothing reads os.environ directly, and no secret is ever returned by an API endpoint.

The event bus is a synchronous, in-process publish/subscribe helper. Event names are noun.verbarchive.imported, conversation.created, document.indexed, sync.completed. It exists now so that later milestones have a real extension point rather than an aspiration. It is deliberately not a message broker; see decision D-009.

Path safety is centralised in paths.py. Every path that originates outside the application — a configured archive location, a filename inside an imported export — is resolved and checked to be inside the archive root before use. This matters most in Milestone 2, when untrusted archives start arriving.

Storage will sit behind a StorageProvider interface with LocalStorageProvider as the only implementation for the foreseeable future. Synchronisation, when it arrives, is a separate SyncProvider concern — storing a file and replicating it elsewhere are different problems and should not share an interface.

Frontend — apps/web

apps/web/src/
├── main.tsx           Entry point
├── App.tsx            The single workspace
├── api.ts             Typed calls to the backend
├── theme.ts           Light/dark handling
├── styles.css         CSS custom properties for both themes
└── components/
    ├── Header.tsx
    ├── ThemeToggle.tsx
    ├── ImportPanel.tsx
    ├── ArchivePanel.tsx      Browse and search
    ├── ConversationView.tsx  Read one
    ├── Snippet.tsx           A search match, marked
    └── StatusPanel.tsx

One page, one workspace, a minimal header. No router yet, because there is one view. No state management library, because component state is sufficient. No UI framework — theming is CSS custom properties on :root, which is both smaller and less likely to produce a generic, AI-generated look (decision D-008).

Light is the default theme. With no stored preference the system setting is honoured, falling back to light. The choice persists in localStorage, wrapped in try/catch because storage can be unavailable.

Data

Two kinds of data, deliberately separated.

Your content — conversations, documents, attachments — is written to the filesystem as Markdown, JSON and plain text under the archive directory. It is readable, greppable and portable with ordinary tools.

Application metadata — indexes, search tables, import records — lives in SQLite.

The rule that keeps these honest: deleting the SQLite database must never destroy your content. It must be rebuildable by re-reading the files on disk. This is what prevents the archive from becoming another proprietary silo.

That rule is enforced by a test — test_deleting_the_database_loses_nothing deletes the database, rebuilds it, and checks the results are identical. If it ever fails, something has started living only in SQLite, and that is a bug.

The index is rebuilt automatically at startup when the archive has content but the index does not, which is what makes copying the archive folder to another machine enough. POST /api/index/rebuild does the same on demand.

data/
├── archive/              Your conversations and documents
│   └── <source>/<conversation>/
│       ├── conversation.md
│       └── metadata.json
└── mind_archive.db       Index only. Rebuildable. Disposable.

Providers are adapters

Every AI provider is an importer adapter behind one interface:

class Importer(Protocol):
    name: str                      # "chatgpt"
    display_name: str              # "ChatGPT"
    supported_formats: list[str]   # [".zip", ".json"]

    def detect(self, path: Path) -> bool: ...
    def validate(self, path: Path) -> ValidationResult: ...
    def parse(self, path: Path) -> ImportResult: ...

parse returns Conversation objects and writes nothing. Storing them is the archive’s job, which keeps parsing testable without touching the filesystem.

Core code never imports a provider-specific module — it asks the registry in importers/__init__.py which adapter can read a file. Adding a provider means writing an adapter and adding it to one list.

Detection inspects the file’s shape, not its name (D-027). ChatGPT and Claude both ship a file called conversations.json, so matching on the filename had the ChatGPT importer confidently claiming Claude exports. That defect had existed since Milestone 2, and only a second real provider could expose it — which is precisely why the interface was generalised against a genuine second case rather than a guessed one.

Exports are sharded, and the export says so (D-042). A current ChatGPT export contains no conversations.json at all — it holds conversations-000.json, -001, -002 and declares the mapping in its own export_manifest.json:

"logical_files": {
  "conversations.json": {
    "files": ["conversations-000.json", "conversations-001.json"],
    "shard_count": 2, "sharded": true
  }
}

reading.py resolves that to a member list and concatenates the shards, so every importer downstream still sees one list. The manifest is preferred over guessing filenames, for the same reason detection reads shape over name: the export is telling us the answer. A filename fallback covers an export that declares nothing.

Claude’s download manifest is recognised, not imported. Claude now emails a small JSON of single-use links rather than the data. It carries no conversations, so it is detected only to produce a refusal that names the file to download. Mind Archive never fetches those URLs — it makes no network requests at all.

The two formats share almost nothing:

  ChatGPT Claude
Messages a mapping tree plus current_node a flat chat_messages list
Title / id title / conversation_id name / uuid
Timestamps Unix epoch floats ISO 8601 strings
Speaker author.role, "user" sender, "human"

What they do share — reading a JSON member out of a zip, coercing untrusted values, refusing to raise on a bad field — moved into importers/reading.py once two real callers wanted it, rather than when one might.

Untrusted input

An export is a file from outside the application, so importers treat it as hostile. zip_safety.py refuses path traversal, zip bombs and oversized archives; every JSON field is checked before use; conversation titles become folder names only through paths.py. One unreadable conversation is skipped and reported rather than failing the whole import. See SECURITY.md.

SQLite FTS5, with the porter tokenizer so “bake” finds “baking”. No Elasticsearch: a personal archive is thousands of conversations, and adding a search server would be infrastructure without a requirement.

MATCH takes a query language, not a string, so what someone types is rewritten before it gets there. C++, a lone ", or NEAR( are all syntax errors in FTS5 and none of them should be an error in a search box. The input is split into words, each is quoted, and they are joined with AND; the last word gets a prefix wildcard so results narrow as you type. A query with nothing searchable in it means “no filter”, not “no results”.

This is about correctness rather than security — the query was always a bound parameter.

Taking it all with you

GET /api/export zips the archive folder and hands it over. What comes out is not a bundle in some format of ours — it is exactly the folder from disk: same Markdown, same JSON, same layout, plus a README.txt explaining how to read it without Mind Archive.

This is what makes “your data is yours” a property rather than a claim.

Storage adapters are still direct filesystem access. A StorageProvider interface with a single implementation would be a guess about the second, so it waits for Milestone 6, where a real cloud adapter can shape it (D-028).

Cloud

Not implemented, and off by default when it is. MIND_ARCHIVE_CLOUD_ENABLED defaults to false, the API reports cloud status so the interface can state plainly where your data lives, and no code path uploads anything without explicit configuration. See decision D-011.

Running it

Docker Compose is the primary supported path, because it gives every contributor an identical, current runtime (decision D-006).

docker compose up --build
    web  → localhost:5173  (Vite dev server)
    api  → localhost:8000  (Uvicorn)
    data → ./data          (bind mount, git-ignored)

Native development is supported and documented in DEVELOPMENT.md, and requires Python 3.11+ and Node 20+.

What is deliberately absent

No microservices. No Kubernetes. No Redis, Kafka or Celery. No Elasticsearch — SQLite full-text search is the plan, and is sufficient for a personal archive. No authentication, because the application is single-user and local. No GraphQL. No ORM yet.

Each of these can be added when a real requirement appears. Adding them before that would make the project harder to understand for no benefit.