diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..769c307 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,123 @@ +# St-Pierre — Flashcard MCP Server + +MCP server that stores and retrieves flashcards using SQLite for persistence, +Qdrant for vector search, and OpenAI-compatible embeddings (LiteLLM proxy). + +## Tech Stack + +- Go (1.26), flat package `main` +- MCP server: `github.com/mark3labs/mcp-go` +- SQLite: `modernc.org/sqlite` (pure Go, no CGo) +- Migrations: `github.com/pressly/goose/v3` (embedded via `//go:embed migrations/*.sql`) +- Vector DB: Qdrant via `github.com/qdrant/go-client` +- Embeddings: OpenAI-compatible API (LiteLLM at `https://llm.aviron.ai`) +- Logging: `go.uber.org/zap` +- Deployed on **enlil** (NixOS server) at `https://st-pierre.home.all-dressed-programming.com/mcp` + +## Repository Structure + +``` +*.go Flat package main (no cmd/ layout) +main.go Entry point, MCP server wiring, auth middleware +api.go Tool definitions (store_flashcard, search_flashcard) + vote +sqlite.go SQLite store — CRUD, search, vote, migration runner +fts5.go FTS5 full-text search queries +qdrant.go Qdrant gRPC client — upsert, search, collection management +embed.go OpenAI-compatible embeddings client +search.go Search engine that combines SQLite + Qdrant results +types.go Go types (Flashcard, searchRequest, etc.) +migrations/ Goose SQL migrations (embedded) +public/ Frontend (served via embed.FS or disk in dev mode) +public_fs.go Production: embed public/ (no build tag) +public_fs_dev.go Dev mode: serve public/ from disk (build tag: dev) +flake.nix Nix build + NixOS module +``` + +## Build & Run + +```bash +# Build +nix build + +# Dev (hot-reload frontend) +go build -tags dev -o st-pierre . +./st-pierre + +# Run directly +export OPENAI_API_KEY=sk-... MCP_TOKEN=my-token +./st-pierre +``` + +## Conventions + +### Goose Migrations + +**NEVER modify an applied migration file.** Goose v3 tracks checksums in +`goose_db_version`. Modifying an applied migration causes "checksum mismatch". +Always add a new migration with a higher version number. If you must recover +a corrupted DB: `DELETE FROM goose_db_version WHERE version_id = N;` or +`DROP TABLE goose_db_version;` to reset entirely. + +### SQLite + +- `modernc.org/sqlite` — pure Go, no CGo required +- Migrations embedded via `//go:embed migrations/*.sql` +- `INTEGER PRIMARY KEY` for auto-increment IDs +- `SetMaxOpenConns(1)` mandatory (single-writer SQLite) +- Append-only design — never UPDATE rows +- Default timestamps: `datetime('now')` +- Nullable columns use `sql.NullString` +- JSON arrays stored as text columns + +### Nix + +After any `go.mod` or `go.sum` change, update `vendorHash` in `flake.nix` +with the hash from a failed build attempt. Never commit go.mod/go.sum changes +without updating `vendorHash`. + +`lib.mkIf` is incompatible with the `//` merge operator — it can silently +drop attributes. Use `lib.optionalAttrs` instead when merging with `//`. + +## Architecture Notes + +### MCP path + +The MCP server is served at `/mcp` via `http.ServeMux`, not at root. CORS +wraps the entire mux. The `result/` symlink created by nix builds is in +`.gitignore`. + +### Embedding URL normalization + +`EMBEDDING_BASE_URL` strips any trailing `/v1` and always appends +`/v1/embeddings`. A bare host like `https://llm.aviron.ai` correctly becomes +`https://llm.aviron.ai/v1/embeddings`. The default is `https://api.openai.com`. + +### Dev build tag + +Two files control how `public/` is served: + +- `public_fs.go` (`//go:build !dev`) — embeds `public/` via `embed.FS` +- `public_fs_dev.go` (`//go:build dev`) — serves from disk via `http.Dir("public")` + +Build with `go build -tags dev` for frontend hot-reload during development. + +## Troubleshooting + +### SQLite readonly on NixOS + +`SQLITE_READONLY_CANTINIT (1032)` means the database file is owned by root +due to `createHome` + `StateDirectory` interaction. Fix: add `chown` in the +systemd unit `preStart`. Also verify the directory is writable with a +`SELECT 1` ping in `newSqliteStore`. + +### Embedding API 404 + +Empty 404 from embedding API means the URL path is wrong. Verify +`EMBEDDING_BASE_URL` is set correctly — the server normalizes the base URL +and appends `/v1/embeddings`. For LiteLLM proxy, leave it bare: +`EMBEDDING_BASE_URL=https://llm.aviron.ai`. + +### MCP endpoint + +The full endpoint is `https://st-pierre.home.all-dressed-programming.com/mcp`. +Auth via `Authorization: Bearer ` header. Token set via `MCP_TOKEN` env var. diff --git a/public/app.js b/public/app.js index df3e26b..1f3a84f 100644 --- a/public/app.js +++ b/public/app.js @@ -5,9 +5,8 @@ const API = "/api/v1/flashcards"; const Card = { view: function (v) { const c = v.attrs.card; - const maxNote = (c.note || "").length > 120 - ? c.note.slice(0, 120) + "…" - : c.note; + const maxNote = + (c.note || "").length > 120 ? c.note.slice(0, 120) + "…" : c.note; return m( ".cyber-card.cyber-card--interactive.cyber-neon-border.cyber-mb-md", { @@ -18,12 +17,17 @@ const Card = { c.tags?.length ? m( ".cyber-card__header", - c.tags.map((t) => m("span.cyber-badge.cyber-badge--pulse.cyber-mr-xs", t)), + c.tags.map((t) => + m("span.cyber-badge.cyber-badge--pulse.cyber-mr-xs", t), + ), ) : null, m(".cyber-card__body", [ m(".cyber-text-muted.cyber-font-mono", `#${c.id} v${c.version}`), - m("p.cyber-text-primary.cyber-mt-sm", maxNote || m("em.cyber-text-muted", "no content")), + m( + "p.cyber-text-primary.cyber-mt-sm", + maxNote || m("em.cyber-text-muted", "no content"), + ), ]), ], ); @@ -36,19 +40,24 @@ const Pager = { view: function (v) { const { page, total } = v.attrs; if (total <= 1) return null; - return m(".cyber-flex.cyber-justify-center.cyber-items-center.cyber-gap-sm.cyber-my-md", [ - m( - "button.cyber-btn.cyber-btn--sm" + (page <= 1 ? ".cyber-btn--ghost" : ""), - { disabled: page <= 1, onclick: () => v.attrs.onpage(page - 1) }, - "\u2190", - ), - m("span.cyber-font-mono.cyber-text-muted", `${page} / ${total}`), - m( - "button.cyber-btn.cyber-btn--sm" + (page >= total ? ".cyber-btn--ghost" : ""), - { disabled: page >= total, onclick: () => v.attrs.onpage(page + 1) }, - "\u2192", - ), - ]); + return m( + ".cyber-flex.cyber-justify-center.cyber-items-center.cyber-gap-sm.cyber-my-md", + [ + m( + "button.cyber-btn.cyber-btn--sm" + + (page <= 1 ? ".cyber-btn--ghost" : ""), + { disabled: page <= 1, onclick: () => v.attrs.onpage(page - 1) }, + "\u2190", + ), + m("span.cyber-font-mono.cyber-text-muted", `${page} / ${total}`), + m( + "button.cyber-btn.cyber-btn--sm" + + (page >= total ? ".cyber-btn--ghost" : ""), + { disabled: page >= total, onclick: () => v.attrs.onpage(page + 1) }, + "\u2192", + ), + ], + ); }, }; @@ -77,13 +86,20 @@ const List = { m(Pager, { page, total: totalPages, - onpage: (n) => { v.state.page = n; }, + onpage: (n) => { + v.state.page = n; + }, }), - m(".cyber-scanlines", slice.map((c) => m(Card, { card: c }))), + m( + ".cyber-scanlines", + slice.map((c) => m(Card, { card: c })), + ), m(Pager, { page, total: totalPages, - onpage: (n) => { v.state.page = n; }, + onpage: (n) => { + v.state.page = n; + }, }), ]); }, @@ -107,12 +123,20 @@ const Detail = { const e = v.state.entry; if (!e) { return m(".cyber-p-lg", { style: "max-width:720px;margin:0 auto" }, [ - m("button.cyber-btn.cyber-btn--ghost", { onclick: () => m.route.set("/") }, "\u2B05 Back"), + m( + "button.cyber-btn.cyber-btn--ghost", + { onclick: () => m.route.set("/") }, + "\u2B05 Back", + ), m("p.cyber-text-magenta", "Flashcard not found."), ]); } return m(".cyber-p-lg", { style: "max-width:720px;margin:0 auto" }, [ - m("button.cyber-btn.cyber-btn--ghost.cyber-mb-md", { onclick: () => m.route.set("/") }, "\u2B05 Back"), + m( + "button.cyber-btn.cyber-btn--ghost.cyber-mb-md", + { onclick: () => m.route.set("/") }, + "\u2B05 Back", + ), m(".cyber-card.cyber-card--holo", [ m(".cyber-card__header", [ m("span.cyber-font-mono.cyber-text-cyan", `#${e.id} v${e.version}`), @@ -120,29 +144,50 @@ const Detail = { m(".cyber-card__body", [ m(".cyber-field", [m(".cyber-field__label", "Note"), m("p", e.note)]), e.host - ? m(".cyber-field.cyber-mt-sm", [m(".cyber-field__label", "Host"), m("p", e.host)]) + ? m(".cyber-field.cyber-mt-sm", [ + m(".cyber-field__label", "Host"), + m("p", e.host), + ]) : null, e.user - ? m(".cyber-field.cyber-mt-sm", [m(".cyber-field__label", "User"), m("p", e.user)]) + ? m(".cyber-field.cyber-mt-sm", [ + m(".cyber-field__label", "User"), + m("p", e.user), + ]) : null, e.context - ? m(".cyber-field.cyber-mt-sm", [m(".cyber-field__label", "Context"), m("p", e.context)]) + ? m(".cyber-field.cyber-mt-sm", [ + m(".cyber-field__label", "Context"), + m("p", e.context), + ]) : null, e.repos?.length ? m(".cyber-field.cyber-mt-sm", [ m(".cyber-field__label", "Repos"), - m("div", e.repos.map((r) => m("span.cyber-badge.cyber-mr-xs", r))), + m( + "div", + e.repos.map((r) => m("span.cyber-badge.cyber-mr-xs", r)), + ), ]) : null, e.tags?.length ? m(".cyber-field.cyber-mt-sm", [ m(".cyber-field__label", "Tags"), - m("div", e.tags.map((t) => m("span.cyber-badge.cyber-mr-xs", t))), + m( + "div", + e.tags.map((t) => m("span.cyber-badge.cyber-mr-xs", t)), + ), ]) : null, - m(".cyber-field.cyber-mt-sm", [m(".cyber-field__label", "Created"), m("p", e.created)]), + m(".cyber-field.cyber-mt-sm", [ + m(".cyber-field__label", "Created"), + m("p", e.created), + ]), e.delete_timestamp - ? m(".cyber-field.cyber-mt-sm", [m(".cyber-field__label", "Deleted"), m("p", e.delete_timestamp)]) + ? m(".cyber-field.cyber-mt-sm", [ + m(".cyber-field__label", "Deleted"), + m("p", e.delete_timestamp), + ]) : null, ]), ]),