Developers
Reading, extending, and contributing.
Everything you need to understand the code. For the user-facing guide see Help; for the product story see About. The single source of truth for design decisions lives in the /docs folder on GitHub.
Stack
What's under the hood
- Language
- TypeScript strict
- Framework
- Next.js 14 (App Router)
- UI
- React, Tailwind, shadcn-style primitives
- Storage
- Plain markdown + SQLite (better-sqlite3)
- Search
- FTS5 (built into SQLite)
- LLM SDK
- openai npm package against OpenRouter base URL
- Schema validation
- zod
- Frontmatter
- gray-matter
- Watch
- chokidar
- Tests
- vitest
- Package manager
- pnpm workspaces
- Node
- ≥ 18.17
Two hard rules from the design contract: TypeScript everywhere (no Python sidecars) and cross-platform from day one (Mac, Windows, Linux). No Electron / Tauri / React Native in V1 — the app is a Next.js server you run locally.
Layout
The monorepo
llm-wiki/ ├── apps/web/ # Next.js app (UI + API routes) │ ├── src/app/ # routes │ ├── src/components/ # shared React components │ └── src/lib/server-wiki.ts # per-request DB + settings context ├── packages/core/ # wiki I/O, schemas, prompts, operations │ ├── src/wiki.ts # file I/O for pages, index, log │ ├── src/db.ts # SQLite open + schema migrations │ ├── src/ingest.ts # the ingest operation │ ├── src/query.ts # the query operation │ ├── src/lint.ts # the lint operation │ ├── src/chat.ts # chat threads (send, create, promote) │ ├── src/editor.ts # manual page edits + lint quick-fixes │ ├── src/index-builder.ts # index.md render + rebuild │ ├── src/lint-fixes.ts # LLM-powered lint fixes │ ├── src/graph.ts # /graph builder — nodes/links from pages │ ├── src/secrets.ts # OpenRouter key (keychain w/ file fallback) │ ├── src/schema.ts # zod schemas for LLM JSON contracts │ └── src/prompts/ # system prompts per operation ├── packages/llm/ # LLM client + retries + JSON repair │ ├── src/client.ts # OpenRouter via openai SDK + defensive parse │ └── src/models.ts # model presets + pricing table ├── packages/ingestion/ # source-format extractors │ ├── src/pdf.ts # vision-model pipeline │ ├── src/docx.ts # mammoth │ ├── src/html.ts # Readability + jsdom + turndown │ └── … # one extractor per format └── docs/ # spec — read 01-vision.md first
The three operations
Ingest, Query, Lint
Karpathy's pattern centers three operations. Each is a single function in packages/core/ that takes the wiki path, a DB connection, an LLM client, and a model slug.
ingest
packages/core/src/ingest.ts
→ ingestSource() / ingestPastedText() / ingestVisionSource()
Reads schema + index + top-K relevant pages, calls the LLM with a strict JSON schema (zod-validated), writes new pages, updates existing ones (with backup), refreshes index, appends log.
query
packages/core/src/query.ts
→ answerQuery()
Reads schema + index + top-K pages, calls the LLM with the question, returns answer + cited slugs + an optional new-page suggestion the user can promote.
lint
packages/core/src/lint.ts
→ lintWiki()
Two passes: local scan (broken links + orphans, no LLM) and LLM pass (contradictions, gaps, stale, missing-page). Returns issues grouped + suggested-fix strings + overall health rating.
LLM contracts
JSON shapes the LLM must return
Every LLM call is non-streaming + returns JSON validated by zod before use. Schemas live at packages/core/src/schema.ts:
IngestResponseSchema— newPages, pageUpdates, indexEntries, logEntry, contradictionsQueryResponseSchema— answer, pagesUsed, suggestedNewPage, confidence, caveatsLintResponseSchema— issues (severity + type + description + affectedPages + suggestedFix), suggestedQuestions, overallHealth
The LLM client (packages/llm/src/client.ts) handles:
- Defensive JSON parsing — strips markdown code fences (Anthropic models love wrapping their JSON in
```json) and slices to first-brace through last-brace. - One repair retry on InvalidJsonError, then surface to UI.
- Retry with backoff on 5xx / network / 429 (Retry-After honored).
- AbortSignal propagation for user cancellation mid-flight.
Storage
Files of truth, SQLite for metadata
The wiki folder is the source of truth. SQLite (.llm-wiki/meta.sqlite) is a derived cache — regenerable from the markdown on disk via syncWikiToDb() on startup and live file-watch.
SQLite tables:
sources— every raw input (filename, format, size, ingested_at, url, title)pages— wiki pages cached for fast UI lookupspages_fts— FTS5 virtual table on title + content + tags for top-K relevance ranking during ingest/query/lintpage_sources— many-to-many join. Powers the "Sources" section on each wiki page + the "contributed to N pages" view on each source detail pagechats— chat thread metadata (file is still source of truth)usage— per-call token + cost rows for the Settings → Costs tabresponse_cache— hash-keyed LLM response cache (placeholder; unused in V1)
Wiki files can be edited externally (Obsidian, vim, git pull). chokidar watches the folder and re-syncs SQLite rows on changes.
Extending
Adding a new source format
- Write the extractor in
packages/ingestion/src/<format>.ts. It receives aBufferand returns{ kind: "text" | "vision", title, content, metadata? }. - Register the format in
packages/ingestion/src/detect.tsso file-extension detection routes to it. - Add a branch in
runExtractor()inapps/web/src/app/api/ingest/route.ts. - Append the file extension to the
ACCEPTED_EXTENSIONSconstant in the Sources page so the file picker accepts it.
Vision-capable formats (PDF, images) go through ingestVisionSource() which sends the bytes as base64 in an image_url message part. The text path uses ingestSource().
LLM
Swapping providers
OpenRouter is the default because one key gives access to most frontier models. To use a different provider:
- Direct Anthropic / OpenAI / etc. — change
baseURLinpackages/llm/src/client.tscreateClient(). TheopenaiSDK works against any OpenAI-compatible endpoint. - Ollama / local model — first-class supported via the
providerfield on each model slot inWikiSettings.defaultModels.createClient(apiKey, "ollama")routes tohttp://localhost:11434/v1(or theOLLAMA_BASE_URLenv var if set). User-facing setup instructions and per-model hardware requirements live at the in-app Local models setup guide. Beware: many local models struggle with strict JSON output; defensive parsing helps but won't save badly misformed responses. - Per-operation override — every operation accepts a
modelOverrideparam. The UI exposes this via per-slot dropdowns at Settings → Models.
Prompts
Where the LLM's instructions live
One file per operation in packages/core/src/prompts/:
ingest.ts— strict JSON shape, per-field rules, wikilink conventionsquery.ts— citation rules + "save as wiki page" suggestion criteriachat.ts— conversational tone, citation rules, preserve thread continuitylint.ts— what to flag, what to ignore, how to phrase suggested fixes
Each prompt embeds a literal JSON_SHAPE block showing the expected output object, with field-by-field rules. That was added after small models repeatedly drifted on field types (e.g. putting the user's topic into a category enum).
Lint fixes
How quick-fixes are dispatched
All lint fixes go through a single endpoint: POST /api/lint/fix with a type discriminator:
remove-broken-link— local; strips[[slug]]from a host page viaremoveBrokenLink()ineditor.tsrebuild-index— local; callsrebuildIndexFromPages()inindex-builder.tsfix-all-broken-links— local; iteratesremoveBrokenLinkover an arraycreate-stub-page— LLM; gathers backlinks for context, callscreateStubPage()inlint-fixes.ts. Falls back torebuild-indexwhen the slug already has a page.apply-suggested-fix— LLM; callsapplyLintSuggestedFix(). The client picks the target page by scanning the suggested-fix text for kebab-case slugs inaffectedPages(not alwaysaffectedPages[0]). No-op detection: if the LLM returns unchanged content, skip the write and tell the UI.
Visualization
3D graph view (/graph)
Renders the wiki as a 3D force-directed graph. Each page is a node; each [[wikilink]] is an edge. Built on react-force-graph-3d (Three.js + d3-force-3d under the hood — same engine as Obsidian's 3D Graph plugin).
- Builder —
packages/core/src/graph.tsbuildGraph(wikiPath, db). Reuses the existinguniqueLinkedSlugs()parser; drops broken links (lint's job to surface) and self-links. - Page —
apps/web/src/app/graph/page.tsxserver component. Reads?node=<slug>fromsearchParamsso deep links work without a client-side flicker. - Client component —
apps/web/src/components/graph/vault-graph.tsx. Dynamic import withssr: falseso the ~600KB three.js bundle doesn't land in any other route's payload. Theme reactivity viaMutationObserveron<html>watching the theme class flip. - URL state via
window.history.replaceState(notuseRouter().replace()) so selection clicks don't trigger Next router re-renders.
Design + decisions in docs/12-graph-view.md.
Testing
Where the test suite lives
packages/core/— ~120+ vitest tests covering wiki I/O, DB CRUD, sync, ingest, query, lint, chat, editor, index-builder, links, config, secrets.packages/llm/— 17 tests on the LLM client (happy path, error mapping, retry behavior, defensive JSON parsing).packages/ingestion/— extractor smoke tests per format.
Run from the repo root:
pnpm -r --filter @llm-wiki/core test --run pnpm -r exec tsc --noEmit # monorepo typecheck
Shipping it
Build + publish pipeline
Two artifacts come out of the build, with very different shapes:
- Standalone server bundle (
.next/standalone/) — whatllm-wiki startactually runs.next buildtraces every module the server needs and copies them into a self-contained tree alongsideserver.js. A postbuild script (scripts/copy-standalone-assets.mjs) does the things Next 14 leaves for you: copies.next/static+publicinto the standalone tree, and resolves + deep-copies everyserverComponentsExternalPackagesentry from the right workspace root (Next's tracer skips externals in pnpm + transpilePackages setups). - Publishable tarball (
dist-publish/) — what gets uploaded to GitHub Releases / npm.scripts/build-publish-tarball.mjsassembles a clean package: rewritespackage.jsonwith the public name (@syasas/llm-wiki), strips workspace deps + build-time deps, and externalizes the native packages (better-sqlite3,keytar, plus heavy pure-JS likejsdom) sonpm installfetches per-platform binaries at install time. Flattens the standalone.pnpm/store so Node's regular resolver can find everything without pnpm's symlink graph.
Two pnpm scripts in apps/web:
pnpm build:publish # build + assemble dist-publish/ pnpm pack:publish # build:publish + npm pack (smoke test)
To actually publish: cd apps/web/dist-publish && npm publish --access public — this is intentionally a manual step (irreversible upload).
Contributing
Open questions + pointers
The project is MIT-licensed and welcomes PRs. Before opening one:
- Read
CLAUDE.mdat the repo root for the do/don't list. - Read
docs/01-vision.mdthroughdocs/11-attribution-license.mdfor the design contract — V1 scope is deliberately small. - See
docs/dev-log.mdfor execution history and open questions (V2 ideas, deferred polish, etc.). - See
docs/dev-setup.mdfor the run/stop/recover recipe and "why is port 3000 stuck" troubleshooting.
v1.2.3 · MIT · github.com/ddsyasas/llm-wiki