Skip to main content
Glama
README.md
# Vesperiki

[![CI](https://github.com/kevin-ho/vesperiki/actions/workflows/ci.yml/badge.svg)](https://github.com/kevin-ho/vesperiki/actions/workflows/ci.yml)

**The wiki that AI maintains. The wiki that humans enjoy reading.**

You figure something out once. Vesperiki keeps it figured out — written,
cross-linked, and kept current by your agent, readable by you anywhere,
even offline.

> **/vesperiki** Where are the best photograph spots in Venice?
>
> **/vesperiki** Map my home network infrastructure

It builds on [Karpathy's LLM-wiki pattern](https://gist.github.com/karpathy/442a6bf555914893e9891c11519de94f):
don't re-derive knowledge from raw sources on every query — compile it
once into persistent, interlinked pages and keep it current. Projects
like [obsidian-wiki](https://github.com/Ar9av/obsidian-wiki) apply that
pattern to markdown (a compromise format) files that both humans and agents read and edit.

**But do humans really want to maintain a wiki?**

Vesperiki has a simpler philosophy: **agents write, humans read.** 

- **Agents write and query it** through typed tools over SQLite —
  structured, transactional, full-text search. No markdown parsing, no
  token tax. They author asynchronously: while you're away, the wiki
  keeps growing.
- **Humans read it** as a clean, offline-first PWA — real pages, search,
  works with no server and no wifi signal.

And because agents are the authors, maintenance is built in: readers
flag mistakes straight back to the agent as a work queue, and pages
carry confidence scores and staleness dates so neglected content
surfaces itself.

Self-hosted, one Python process, one SQLite file. Harness-agnostic: any
MCP-capable agent plugs in — Claude Desktop, OpenAI Codex, and friends —
plus a read-only REST API for everything else.

## Why

- **Your wiki, your file.** Everything lives in one SQLite database you can back up, copy, and inspect with standard tools. No SaaS, no accounts, no lock-in.
- **Nothing is ever lost.** Every edit records who made it and why. Roll back to any revision; deleting a page hides it rather than destroying it.
- **Readers keep it honest.** A one-tap flag on any page hands typos and mistakes back to the agent as a work queue; pages carry confidence scores and staleness dates, so neglected content surfaces itself instead of rotting quietly.
- **Easy in, easy out.** Import an existing markdown folder (frontmatter and wikilinks included) with one command; export the whole wiki to Obsidian-format markdown anytime.
- **Small and auditable.** One Python process, one schema, 545 tests, and a WCAG 2.2 AA reader UI.

Full tool and API details are in [MCP integration](#mcp-integration) below.

## Quick start

### Backend

```bash
python3 -m venv .venv && . .venv/bin/activate
pip install -e .
python -m vesperiki.api              # :7420, serves API + built SPA if dist/ exists
```

With `uv`: `uv venv && uv pip install -e . && python -m vesperiki.api`.

### Frontend

```bash
cd frontend
pnpm install
pnpm dev                             # vite on :5173, proxies /api and /healthz to :7420
```

Browse to <http://localhost:5173>. For production: `pnpm build` emits
`frontend/dist/` with the PWA shell, sqlite-wasm worker files, and the
`sw-assets.js` precache manifest. FastAPI serves the built SPA itself,
so production is just `python -m vesperiki.api` on <http://localhost:7420>.

If the API is hosted at a different origin from the built SPA, set
`VITE_API_BASE` at build time so the SPA prepends it to every call.

## Architecture

A single Python process serves the REST API and the built SPA. The MCP
server is a separate stdio process talking to the same SQLite file. The
browser optionally talks to the API in dev and to an offline
sqlite-wasm database once installed.

```
+----------------+   stdio   +---------------+        +---------------+
| MCP-capable    |---------->| vesperiki/mcp |        | Browser (PWA) |
| authoring      |           | 5 tools       |        | React 19 SPA  |
| agent          |           | full|author|ro|        | + sqlite-wasm |
+----------------+           +-------+-------+        | + service W.  |
                                    |                 +-------+-------+
                                    v                         | HTTP
                            +-------+-------+                 v
                            | SQLite (FTS5) |<------- vesperiki/api
                            | pages, revs,  |        | FastAPI + SPA
                            | links, tags,  |        | :7420 (dist/)
                            | media, sync   |        +---------------+
                            +---------------+
```

| Component       | Path                          | Role                                       |
| --------------- | ----------------------------- | ------------------------------------------ |
| Backend         | `vesperiki/service.py`        | Domain logic, validation, dedup, link graph|
| Database        | `vesperiki/db.py`             | Schema, FTS5, `change_seq`, FK, WAL        |
| REST API        | `vesperiki/api.py`            | FastAPI transport + static SPA serving     |
| MCP server      | `vesperiki/mcp.py`            | stdio JSON-RPC, 5 tools, modes             |
| Export CLI      | `vesperiki/export.py`         | Dump DB to Obsidian-format markdown        |
| Migration CLI   | `vesperiki/migrate.py`        | Import a markdown directory into the DB    |
| Static serving  | `vesperiki/static_serving.py` | SPA fallback + PWA / sqlite-wasm routes    |
| Reader SPA      | `frontend/`                   | React 19 + Vite 6 + TanStack Router/Query  |
| Service worker  | `frontend/public/sw.js`       | Net-first API, cache-first nav, SWR assets |
| Offline DB      | `@sqlite.org/sqlite-wasm` 3.53| OPFS VFS, full SQL reader offline          |

## Tech stack

| Layer                | Technology                                                 |
| -------------------- | ---------------------------------------------------------- |
| Runtime              | Python 3.11+                                               |
| HTTP framework       | FastAPI + uvicorn                                          |
| Storage              | SQLite (WAL, FK, FTS5, `change_seq` cursor)                |
| MCP transport        | MCP Python SDK (`mcp>=1.10,<2`), stdio JSON-RPC            |
| Frontend framework   | React 19 + TypeScript                                      |
| Build tool           | Vite 6                                                     |
| Routing / data       | TanStack Router, TanStack Query                            |
| Components / styles  | React Aria Components, Tailwind v4                         |
| Markdown             | react-markdown + remark-gfm                               |
| Offline DB           | @sqlite.org/sqlite-wasm 3.53.0 (OPFS via `opfs-wl`)        |
| Tests                | pytest (backend), vitest + Testing Library + jsdom (FE)    |
| Frontend package mgr | pnpm                                                       |

## Configuration

| Variable                  | Default              | Purpose                                              |
| ------------------------- | -------------------- | ---------------------------------------------------- |
| `VESPERIKI_DB_PATH`       | `./vesperiki.db`     | SQLite database file path.                           |
| `VESPERIKI_WRITER`        | `default`            | Writer identity recorded on every revision.          |
| `VESPERIKI_CLIENT`        | `unknown`            | Client identity recorded on every revision.          |
| `VESPERIKI_MODE`          | `full`               | MCP tool exposure: `full`, `author`, or `readonly`.  |
| `VESPERIKI_HOST`          | `127.0.0.1`          | uvicorn bind address.                                |
| `VESPERIKI_PORT`          | `7420`               | uvicorn bind port.                                   |
| `VESPERIKI_MIGRATE_SOURCE`| (unset)              | Default `--source` for `python -m vesperiki.migrate`.|
| `VITE_API_BASE`           | (unset, same origin) | Build-time API base URL for the SPA.                 |

Mode tool surface:

| Mode       | Tools                                                        |
| ---------- | ------------------------------------------------------------ |
| `full`     | `wiki_read`, `wiki_search`, `wiki_meta`, `wiki_write`, `wiki_admin` |
| `author`   | `wiki_read`, `wiki_search`, `wiki_meta`, `wiki_write`         |
| `readonly` | `wiki_read`, `wiki_search`, `wiki_meta`                       |

## MCP integration

Wire the MCP server into your MCP-capable agent (Claude Desktop, generic
JSON clients):

```json
{
  "mcpServers": {
    "vesperiki": {
      "command": "python",
      "args": ["-m", "vesperiki.mcp"],
      "cwd": "/path/to/vesperiki",
      "env": {
        "VESPERIKI_DB_PATH": "/path/to/vesperiki/vesperiki.db",
        "VESPERIKI_WRITER": "agent-name",
        "VESPERIKI_CLIENT": "agent-runtime",
        "VESPERIKI_MODE": "full"
      }
    }
  }
}
```

| Tool         | Purpose                                                                                 |
| ------------ | --------------------------------------------------------------------------------------- |
| `wiki_read`  | Read a page by slug; `expand_links`, `include_revisions`, or `section` (return a single section by slugified heading or `intro` instead of the full body). |
| `wiki_search`| BM25 search with snippets; tag/type filters and `include_body`.                        |
| `wiki_meta`  | `exists`, `list`, `tags`, `recent`, `stale`, `stale_ranked`, `links`, `backlinks`, `corrections`. `list` and `stale_ranked` accept `include_tags` (AND) / `exclude_tags` (NOT-ANY) tag-combination filters plus `limit` and `type`; `stale` takes `days`; `recent` and `list` take `limit`; `list` takes `status` and `cursor`. |
| `wiki_write` | `create`, `update`, `delete`, `revive`, `update_section`. `create` is an upsert (existing slug updates instead of erroring) and runs duplicate detection, overridden by `force=true`. With `source_markdown=true` it parses a leading YAML frontmatter block (title/tags/type) and stores only the clean body; the duplicate error message includes the force hint. `revive` flips a soft-deleted page back to active (no-op if already active). `update_section` (section id = slugified heading or `intro`) replaces one section's body — the heading is preserved automatically, do NOT include it. `update` with no changed fields is a silent no-op (no seq bump, no revision row, no `updated_at` refresh). |
| `wiki_admin` | `link` / `unlink` explicit edges, `restore` revisions.                                  |

Inputs are JSON-schema validated; bad input returns a structured error
with the offending field and valid values.

## Deployment

`python -m vesperiki.api` serves both the API and the built SPA from one
process.

```ini
[Unit]
Description=Vesperiki wiki
After=network.target

[Service]
WorkingDirectory=/opt/vesperiki
ExecStart=/opt/vesperiki/.venv/bin/python -m vesperiki.api
Environment=VESPERIKI_HOST=127.0.0.1
Environment=VESPERIKI_PORT=7421
Environment=VESPERIKI_DB_PATH=/opt/vesperiki/vesperiki.db
Restart=on-failure

[Install]
WantedBy=multi-user.target
```

Expose on your tailnet with Tailscale Serve (TLS + tailnet-only auth):

```bash
tailscale serve --bg --https=7420 --set-path=/ http://127.0.0.1:7421
```

Browse to `https://<host>.<tailnet>.ts.net:7420` from any tailnet device.
No separate static host is required. API-only mode kicks in if
`frontend/dist/` is absent (CI wheel, test environment).

## Development

```bash
pytest                                # backend, 320 tests
cd frontend && pnpm test              # frontend, 138 tests (vitest + jsdom)
cd frontend && pnpm typecheck        # tsc --noEmit, strict tsconfig
cd frontend && pnpm build             # emits dist/ with PWA + sqlite-wasm
```

CSS is split by audience: `frontend/src/styles/layout.css` carries the
structural styles, Tailwind v4 wiring, markdown typography, and focus
rings. `frontend/src/styles/theme.css` carries the themable custom
properties (colors, drop-shadow, font stacks) plus the dark-mode
override. The light/dark theme toggle persists in `localStorage`.

## Contributing

Issues and pull requests are welcome. Backend public functions live in
`vesperiki/service.py` and return plain dicts; tests live in `tests/`
under pytest. Frontend code lives next to the code it exercises
(`src/lib/*.test.ts`, `src/components/*.test.tsx`) under vitest, jsdom,
and Testing Library. Run both suites before opening a PR: `pytest` at
the repo root and `pnpm test` in `frontend/`.

## License

MIT — see [LICENSE](LICENSE).