Inkforge is a pnpm + Turborepo monorepo with three packages:
packages/
core/ @inkforge/core — generation engine (no UI dependency)
cli/ @inkforge/cli — Commander.js terminal interface
apps/
web/ @inkforge/web — Next.js 16 + Tailwind v4 web UI
Inkforge implements an adapted version of the STORM pipeline where the outline is an explicit first-class artifact rather than an implicit prompt side-effect.
Input (notes / topic / code)
↓
ingest.ts → NormalisedInput
headings, code blocks, tags extracted
↓
outline.ts → Outline artifact ← STAGE 1
h2/h3 tree + keyPoints[]
+ wordBudget per section
↓
draft.ts → Section drafts ← STAGE 2
one LLM call per section
context-chained sequentially
↓
polish.ts → Humanisation pass
voice, concreteness, transitions
↓
emit.ts → .md frontmatter + dual write
content/articles/<category>/
+ Anvilry mirror (optional)
Stage 1 — Outline generation:
The LLM receives the normalised input and generation parameters and returns structured JSON: section titles, key points per section, real-world examples to include, and a word budget per section. The outline is validated against OutlineSchema (Zod) before proceeding.
Stage 2 — Section drafting: Each section is drafted independently. Each call receives the full outline + section spec + summaries of all preceding sections (context chaining). This prevents repetition without sending the growing full draft on every call.
Polish pass: A single LLM call targets human-readability: personal voice injection ("I've seen..."), concrete specifics over vague generalities, smooth transitions between sections, varied sentence length.
For notes-dump inputs, Inkforge uses a BM25 in-memory index over the local content/ directory:
- Hierarchical Markdown chunker (
src/rag/chunker.ts): h1-h6 hard splits, ~2000 char soft splits, heading context string preserved per chunk (e.g.# Guide > ## Installation > ### macOS) - NoteIndex (
src/rag/indexer.ts): BM25 with k1=1.5, b=0.75 (TREC defaults), no external database needed at personal KB scale - Enricher (
src/rag/enricher.ts): Top-5 BM25 hits injected into the outline stage prompt as related context
packages/core/src/llm/index.ts is the single source of truth for all LLM interactions:
| Export | Use |
|---|---|
generateText() |
Blocking accumulation — for pipeline intermediate stages |
streamText() |
Streaming ReadableStream — for CLI progress display |
isConfigured() |
Boolean env check — no network call |
makeClient() |
Constructs provider client (Bedrock or Anthropic) |
bedrockCreds() |
Decodes base64 credentials, exported for reuse |
Fallback chain (Bedrock): Sonnet 4.6 → Haiku 4.5
Fallback eligibility: isFallbackEligible() returns true for 429, 404, 5xx, connection errors, and 403 messages containing "not authorized to perform" (per-model IAM deny). Plain 403 bad-credentials is NOT eligible — no point retrying other models.
Browser Next.js API Route
| |
| POST /api/generate |
| ReadableStream ─────────────|
| | generate() with onProgress
| data: {"type":"progress"} | ←── pipeline emits events
| data: {"type":"progress"} |
| data: {"type":"complete"} |
| |
The web UI parses SSE events (data: {json}\n\n) from a ReadableStream. Each pipeline stage fires a progress event; completion fires complete with the article metadata.
All output paths in emit.ts resolve relative to process.cwd(), the directory the host process was started from. For the CLI run from the repo root, INKFORGE_CONTENT_DIR=content/articles lands in <repo>/content/articles. The Next.js server runs with apps/web as its cwd, so relative values land under apps/web/ while the web read paths (apps/web/src/lib/env.ts) resolve against the repo root; use absolute paths to make both agree.
Matches the Anvilry portfolio dark theme — all CSS custom properties defined in apps/web/src/app/globals.css and mapped to Tailwind v4 via @theme inline.
content/
articles/<category>/<slug>.md ← GITIGNORED (generated output, written by emit)
inputs/<category>/<slug>.md ← GITIGNORED (raw input saved per run)
published/ ← COMMITTED (tracking records)
<platform>/<slug>.md
linkedin/<slug>/post.md (slides/ and *.pdf are gitignored)
assets/<slug>/ ← COMMITTED (shared media, e.g. GIF plans)
Generated articles are never committed — they're output, not source. Published tracking records are committed — they're the source of truth for what's live where.
Always use Playwright headless Chromium. Never use macOS qlmanage — it renders to a square canvas and adds whitespace below content.
async with async_playwright() as p:
browser = await p.chromium.launch()
page = await browser.new_page(viewport={"width": W, "height": H})
await page.set_content(f"<html><body style='margin:0'>{svg}</body></html>")
await page.screenshot(path=out, clip={"x":0,"y":0,"width":W,"height":H})