Personal operating system for SIO TOU LAI (Nelson, @lst97) β a desktop-styled portfolio site with a source-grounded chat assistant and a Payload CMS admin for editorial content.
Live site: https://www.lst97.dev Β· Repository: https://github.com/lst97-oss/last-lst97-dev-web Β· License: MIT
One Bun application with three runtime surfaces:
| Surface | Path | Purpose |
|---|---|---|
| Public site | src/routes/_site.*.tsx, src/components/site/ |
Portfolio, blog, changelog, chat, contact β presented as a desktop OS shell |
| Payload admin | src/routes/_payload*.tsx |
CMS for posts, projects, changelogs, media at /_payload/admin |
| Server domain | src/server/** |
Chat agent, RAG retrieval, WakaTime, contact/email, SEO, moderation, security |
Content lives in two Postgres databases. DATABASE_URL is Payload's own database (editorial content,
users, rate limits, contact approvals). KNOWLEDGE_DATABASE_URL is a dedicated pgvector database holding
the retrieval index and the WakaTime heartbeat warehouse. They are deliberately separate.
- Runtime / package manager: Bun 1.4.3 (
bun.lock,bun test,bunx --bun). NoenginesorpackageManagerfield exists. - Framework: TanStack Start 1.168 with RSC enabled, React 19, TanStack Router (file routes), Vite 8, Nitro 3.
- CMS: Payload CMS v4 canary (
4.0.0-canary.37) on@payloadcms/db-postgres, with@payloadcms/[email protected]. - UI: Tailwind CSS v4, 61 shadcn-style wrappers over Base UI primitives, lucide-react icons.
- AI: OpenRouter (decision model, planner, responder), SiliconFlow (reranking + query embeddings), local
llama-server(document embeddings). - Quality: Bun test (168 test files), Biome 2.5.15,
bun check.
bun install
cp .env.example .env # fill in PAYLOAD_SECRET, DATABASE_URL, and the provider keys you need
bun run build # regenerates payload-types.ts + payload-import-map.ts, then vite build
bun run dev # http://localhost:3000The admin at /_payload/admin needs a committed migration applied first (bun run db:migrate) and at
least one users row. Both payload-types.ts and src/payload-import-map.ts are gitignored, so a fresh
clone cannot boot the admin until bun run build (or the two generate:* scripts) has run.
Everything in the RAG stack is opt-in. With KNOWLEDGE_RAG_ENABLED=false (the default) you need no
model file, no llama-server, and no SiliconFlow key β the site and ordinary chat work as-is.
| Command | What it does |
|---|---|
bun run dev |
Vite dev server on port 3000 |
bun run dev:rag |
Dev server plus a local llama-server embedding sidecar |
bun run build |
generate:payload-admin-css β generate:types β generate:importmap β deploy:migrate (no-op unless DEPLOY_MIGRATE=true) β vite build |
bun run test |
Full bun test suite |
bun run typecheck |
bun check β the built-in TypeScript checker that covers tests/** |
bun run db:migrate |
Apply committed Payload migrations |
bun run worker |
Payload jobs worker for the knowledge queue |
bun run check:server / lint:server / format:server |
Biome over src/server only |
RAG and content-corpora maintenance: knowledge:migrate, knowledge:sync, knowledge:github:sync,
knowledge:github:reindex, knowledge:github:catalog, knowledge:interview:index, wakatime:import,
embedding:serve, embedding:test.
Build output is not dist/. Fresh Vite output goes to .vercel/output/ under the default
NITRO_PRESET=vercel, or .output/ when the Dockerfile sets NITRO_PRESET=node-server. To run the
production server locally, build with NITRO_PRESET=node-server bun run build, then bun run start
(which runs .output/server/index.mjs, the same file the Docker image runs).
Production runs on Bun. Vercel's function runtime is Bun because the build command is
bun run build β Nitro detects a Bun build process and emits a bun1.x function; the build log line
[nitro:vercel] Using \bun1.x` runtime.is the proof. The container is Bun independently, viaoven/bun:1.4.3-slimandbun .output/server/index.mjs. Don't infer this from the rest of the tooling: the Payload CLI runs under Node on Vercel while the server still runs under Bun. The two platforms also migrate at different moments β Vercel sets DEPLOY_MIGRATE=true` so migrations run inside the build,
while the container migrates on start so the image can be built against a dummy database URL.
The chat is an agentic loop over five read-only tools, defined as a single tuple in
src/server/chat/types.ts:
search_knowledgeβ RAG over the indexed corpora (Payload posts/projects, the operator profile, the public WakaTime share, GitHub repository and contribution reports, and the interview Q&A corpus)list_owned_projectsβ structured catalogue fromknowledge_projects, with filter-aware totalscoding_statsβ public WakaTime share aggregatescoding_historyβ the imported heartbeat warehouse (per-project totals, daily series, streaks)site_contentβ currently published Payload posts, projects, changelogs, and topics
The decision model selects which sources a turn needs; an OpenRouter planner prepares tool arguments for only the
approved tools; a separate OpenRouter responder writes the answer in Nelson's first person. Retrieval
searches 10 vector candidates, reranks to 10, gates the top 3 through a decision-model relevance check, and injects
surviving evidence wrapped as untrusted data.
Full architecture, tool contracts, and change checklist: src/server/chat/AGENTS.md.
| Document | Contents |
|---|---|
AGENTS.md |
RAG setup, retrieval pipeline, WakaTime tools, owned-project catalogue |
docs/wakatime-history.md |
Heartbeat warehouse schema, rollups, privacy |
docs/security/vercel-ingress.md |
Vercel ingress and client-IP trust |
CONTEXT.md |
Ubiquitous language for content domains |
src/server/knowledge/AGENTS.md |
RAG module invariants and conventions |
src/server/chat/AGENTS.md |
Chat architecture and model responsibilities |
docs/superpowers/ |
Dated design specs and plans (historical record, not a task list) |
Checkbox state in docs/superpowers/plans/** is not a completion signal β several shipped plans
still have every box unticked. Treat the code and the per-directory AGENTS.md files as authoritative.
Vercel is the production target (nitro preset vercel, runtime bun1.x). A Docker image is also
provided: four stages on oven/bun:1.4.3-slim, non-root bun user, port 3000, a healthcheck against
/api/site/health, and an entrypoint that runs payload migrate before serving unless SKIP_MIGRATIONS=true.
The Docker image cannot serve chat or contact on its own: rate limits need a visitor IP it does not have. Put it behind
a proxy that overwrites a single-IP header, and set TRUSTED_CLIENT_IP_HEADER to that header's name. See
docs/security/vercel-ingress.md.
PUBLIC_SITE_URL must be the real public origin in production β canonical links, og:url,
sitemap.xml, robots.txt and security.txt are all baked from it at build time via the
__LAST_OS_SITE_URL__ define.
bun run build calls deploy:migrate between code generation and vite build. That script applies
pending Payload migrations and fails the build if they error, so new code is never deployed against
a schema it does not match. Each platform opts in differently, because the two have different lifetimes:
- Vercel sets
DEPLOY_MIGRATE=truein Production. Its build has a realDATABASE_URL, and a migration belongs before the new code starts serving. - Docker leaves it unset, so the image builds without a live database, and
docker-entrypoint.shmigrates on container start instead. An image can sit built for a long time before it boots, so migrating only at build time would leave it stale.
To apply migrations by hand β a production hotfix, or a Vercel deploy where you do not want the build
to touch the database β run bun run db:migrate with DATABASE_URL pointing at the target.
db:migrate:check is the read-only counterpart: bun run dev runs it first and refuses to start when a
committed migration is unregistered, so a schema change cannot be developed against unnoticed.
Hosted Postgres and
sslmode. The hosted production endpoint (Railway) requires TLS and presents a self-signed certificate, so itsDATABASE_URLneeds?sslmode=no-verifyappended. Without it the connection fails withno pg_hba.conf entry ... no encryption, and withsslmode=requireit fails asself-signed certificate. Re-add?sslmode=no-verifywhenever you editDATABASE_URLβ a value that loses the parameter fails the same way.
bun run build starts with payload generate:types, which loads payload.config.ts, which calls
getServerEnv() at module scope. That validates the whole server schema and requires DATABASE_URL
and PAYLOAD_SECRET. A Preview environment has neither, so every PR preview fails at
Environment validation failed: DATABASE_URL: Invalid input: expected string, received undefined.
The failure is unrelated to the PR being tested β it is a property of the build. Only main deploys,
via the Production deploy hook scoped to ref: main. To preview a branch, run the app locally.
Preview builds have not been disabled. The GitHub App builds a Preview for every non-main push
and each one fails this way. Two things that look like fixes and are not:
vercel.jsonhas nogitDeploymentEnabledkey. Adding one fails the schema check outright and takes Production down with it βInvalid vercel.json - should NOT have additional property.gitProviderOptions.createDeployments: disabledvia the project API returns HTTP 200 but does not stop the GitHub App; agit-devPreview is still created afterwards.
Disabling previews needs either the Vercel dashboard's branch-ignore setting or unlinking the Git
integration and deploying main through the CLI or the deploy hook. That is a project-settings
decision, not a code one, so nothing in the repo attempts it.
Do not "fix" this by making DATABASE_URL or PAYLOAD_SECRET optional. Both are load-bearing at
runtime, and relaxing them trades a build-time failure for a deploy-time one.
Store PAYLOAD_SECRET as an encrypted Secret, not a plaintext Config value. Config values are
readable by anyone with project access. Note that Vercel will not return the value of a Config var β
the API yields a ciphertext blob and vercel env pull writes [SENSITIVE] placeholders β so a secret
mistakenly stored as Config cannot be recovered once written; rotate it instead.
Run bun run typecheck and bun test before opening a pull request. See AGENTS.md for the
architecture map, code conventions, and the verification traps that produce confidently wrong results in
this repo.
MIT Β© 2026 SIO TOU LAI β see LICENSE.