LLM Wiki

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, contradictions
  • QueryResponseSchema — answer, pagesUsed, suggestedNewPage, confidence, caveats
  • LintResponseSchema — 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 lookups
  • pages_fts — FTS5 virtual table on title + content + tags for top-K relevance ranking during ingest/query/lint
  • page_sources — many-to-many join. Powers the "Sources" section on each wiki page + the "contributed to N pages" view on each source detail page
  • chats — chat thread metadata (file is still source of truth)
  • usage — per-call token + cost rows for the Settings → Costs tab
  • response_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

  1. Write the extractor in packages/ingestion/src/<format>.ts. It receives a Buffer and returns { kind: "text" | "vision", title, content, metadata? }.
  2. Register the format in packages/ingestion/src/detect.ts so file-extension detection routes to it.
  3. Add a branch in runExtractor() in apps/web/src/app/api/ingest/route.ts.
  4. Append the file extension to the ACCEPTED_EXTENSIONS constant 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 baseURL in packages/llm/src/client.ts createClient(). The openai SDK works against any OpenAI-compatible endpoint.
  • Ollama / local model — first-class supported via the provider field on each model slot in WikiSettings.defaultModels. createClient(apiKey, "ollama") routes to http://localhost:11434/v1 (or the OLLAMA_BASE_URL env 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 modelOverride param. 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 conventions
  • query.ts — citation rules + "save as wiki page" suggestion criteria
  • chat.ts — conversational tone, citation rules, preserve thread continuity
  • lint.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 via removeBrokenLink() in editor.ts
  • rebuild-index — local; calls rebuildIndexFromPages() in index-builder.ts
  • fix-all-broken-links — local; iterates removeBrokenLink over an array
  • create-stub-page — LLM; gathers backlinks for context, calls createStubPage() in lint-fixes.ts. Falls back to rebuild-index when the slug already has a page.
  • apply-suggested-fix — LLM; calls applyLintSuggestedFix(). The client picks the target page by scanning the suggested-fix text for kebab-case slugs in affectedPages (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.ts buildGraph(wikiPath, db). Reuses the existing uniqueLinkedSlugs() parser; drops broken links (lint's job to surface) and self-links.
  • Page apps/web/src/app/graph/page.tsx server component. Reads ?node=<slug> from searchParamsso deep links work without a client-side flicker.
  • Client component apps/web/src/components/graph/vault-graph.tsx. Dynamic import with ssr: false so the ~600KB three.js bundle doesn't land in any other route's payload. Theme reactivity via MutationObserver on <html> watching the theme class flip.
  • URL state via window.history.replaceState (not useRouter().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/) — what llm-wiki start actually runs. next buildtraces every module the server needs and copies them into a self-contained tree alongside server.js. A postbuild script (scripts/copy-standalone-assets.mjs) does the things Next 14 leaves for you: copies .next/static + public into the standalone tree, and resolves + deep-copies every serverComponentsExternalPackages entry 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.mjs assembles a clean package: rewrites package.json with the public name (@syasas/llm-wiki), strips workspace deps + build-time deps, and externalizes the native packages (better-sqlite3, keytar, plus heavy pure-JS like jsdom) so npm 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.md at the repo root for the do/don't list.
  • Read docs/01-vision.md through docs/11-attribution-license.md for the design contract — V1 scope is deliberately small.
  • See docs/dev-log.md for execution history and open questions (V2 ideas, deferred polish, etc.).
  • See docs/dev-setup.md for the run/stop/recover recipe and "why is port 3000 stuck" troubleshooting.

v1.2.3 · MIT · github.com/ddsyasas/llm-wiki