Skip to content

Latest commit

 

History

37 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Zettel — Non-Linear Note-taking

Description

Zettel is a local-first, privacy-preserving note-taking app built around a simple idea: capture first, think later. Hit a global hotkey, dump a thought, and get on with your day — nothing is analyzed yet. When you're ready, you review the capture in your Inbox, give it an optional category, and send it on. Only then does a local extraction pipeline read it with a local small language model, pull out entities, relationships and action items, weave them into a personal knowledge graph, and — once a cluster of related ideas gets dense enough to mean something — synthesize a Master Note out of it.

Everything runs on your machine. No capture, embedding, or extracted entity ever leaves it.

⌘⇧K → SQLite (Inbox) → garden → poller → Extractor → Graph Updater → Task Manager → Synthesizer
                                              │              │              │
                                        Kùzu + LanceDB    tasks table   Master Notes

Table of Contents

Getting Started / Installation

Requirements: Ollama running locally, Python 3.13, Node 22+, pnpm, and Rust (for the Tauri shell).

git clone https://github.com/cubeerea/zettel-ai.git
cd zettel-ai

make setup     # creates the Python venv, installs backend + desktop deps
make models    # ollama pull qwen2.5-coder:7b, nomic-embed-text
make app       # launches the desktop app, which starts the backend itself

make app is the one command you need day to day — the desktop shell spawns and supervises the Python backend automatically. See Development below if you want to run pieces separately (e.g. the backend alone, for debugging).

Usage

Press ⌘⇧K anywhere to capture. The overlay commits your text and disappears — that part is instant, no model call is on that path. The capture then waits in the Inbox until you garden it; extraction only starts once you send it on, and never makes you wait when it does.

The app is organized into a few views, reachable from the sidebar:

  • Inbox — every capture waiting to be gardened. Edit the text, give it an optional category, and send it to curation; nothing here has reached the engine yet.
  • Stream — every raw capture, newest first, gardened or not.
  • Search — hybrid semantic + keyword search, with a category filter to browse straight to everything you've tagged a given way.
  • Action Board — extracted tasks in swimlanes by inferred project. Click a card to advance its status.
  • Graph — a force-directed concept map, colorable by entity type or by category. Click a node to see it beside the raw notes that produced it, or jump here directly from a note's entity chips.
  • Master Notes — synthesis on the left, its exact source captures on the right. Edit the left pane and save to trigger re-curation. You can also manually attach any existing capture to a Master Note's source list — a deliberate override of the automatic clustering that never rewrites the prose.
  • Duplicates — captures the engine flagged as likely duplicates, for you to confirm or dismiss.

Tech Stack

Layer Technology Role
Desktop shell Tauri v2 (Rust) Global hotkey, capture overlay, supervises the backend process
UI React 19 + TypeScript + Tailwind v4 Inbox, Stream, Action Board, Graph Canvas, Master Notes
Data fetching TanStack React Query Polling, caching, optimistic updates
API + engine FastAPI (Python) Async extraction pipeline over gardened, unprocessed notes
Relational store SQLite Immutable raw captures, tasks, master notes, provenance
Graph store Kùzu Entity nodes, RelatesTo edges
Vector store LanceDB Semantic search over captures (nomic-embed-text, 768-dim)
Models Ollama qwen2.5-coder:7b for extraction and synthesis
Graph rendering react-force-graph-2d Force-directed concept map

Architecture

The gardening gate

A capture is not eligible for the ingest poller the instant it lands — it sits in the Inbox until you review it. Gardening a note (optionally giving it a category) is what sends it on; the category rides along as a hint into the extractor prompt, so the engine groups text into the right entities and Master Notes instead of guessing from an unedited brain dump. It's also indexed for search. Once a note has been gardened it stays gardened: editing or reprocessing it later never sends it back to the Inbox.

The five pipeline stages

  1. Extractor — grammar-constrained JSON out of the SLM (Ollama's format takes a real JSON Schema), plus an embedding and the gardening category as a hint. Falls back through a JSON-repair ladder rather than dropping a note.
  2. Graph Updater — MERGEs entities and relationships into Kùzu, upserts the embedding into LanceDB, and records entity_mentions — the row every provenance query depends on.
  3. Task Manager — writes genuine action items to tasks, inferring the project.
  4. Synthesizer — communities over the entity graph (Louvain); those that clear the density gate become Master Notes. Manual and cadence-triggered synthesis passes are serialized against each other so they can't race.
  5. Re-Curation — when you edit a Master Note, re-parses your text and folds your corrections back into the graph.

The pipeline itself is a small set of plain async Python functions chained together — no external orchestration framework, just the node sequence above run by an async poller.

Design Decisions Worth Knowing

Kùzu allows one read-write handle per process. The API server and the ingest poller therefore share a single process, and uvicorn must run with workers=1 and without --reload (which forks). This is enforced by convention, not by the library — see the comment at the top of backend/zettel/main.py.

Capture does one INSERT and returns. No model call is on that path. That is the entire reason the hotkey feels instant.

Gardening is mandatory, not a nicety. The poller will not claim a note until it has been gardened, full stop — there is no setting to skip the Inbox. This mirrors how people actually brain-dump (get it down fast, make sense of it later) and gives the engine a human-confirmed category instead of only ever guessing from raw, unedited text.

Community detection runs on explicit relations unioned with co-mention. Extractors overwhelmingly emit hub-and-spoke relations, and a star is a tree — its density is exactly 2/n, which can never clear a supra-tree gate. Detection on explicit edges alone would produce communities that never qualify, and no Master Note would ever be written. Co-mention (the standard bipartite projection of note→entity) restores the density that actually reflects "these ideas belong together." The rendered graph still shows only explicit relations.

A Master Note you have edited is never regenerated. is_user_edited permanently protects your prose; synthesis refreshes only its provenance links.

Master Note identity survives re-clustering. Louvain re-partitions the whole graph each pass, so notes are matched by entity-set Jaccard and containment. Containment matters: when a community splits, Jaccard collapses and the fragment would otherwise be minted as a near-duplicate over the same sources.

A weak extraction never wedges the queue. failed is reserved for infrastructure faults; a note the model merely handled badly is still marked completed and still gets its embedding.

Duplicate detection is a two-stage funnel. A cheap cosine-similarity recall pass over embeddings is deliberately over-inclusive (tuned for recall, not precision); a second pass asks the SLM to actually adjudicate each candidate before anything is flagged for review or merged.

Data & Configuration

Everything lives in ~/.zettel (override with ZETTEL_DATA_DIR):

~/.zettel/
├── zettel.db     # raw_notes, tasks, master_notes, provenance
├── graph/        # Kùzu
└── vectors/      # LanceDB

Configuration is environment-driven — see backend/zettel/config.py for every knob (models, ports, synthesis cadence, density thresholds, dedup thresholds).

Development

make test       # backend test suite, no model required
make smoke      # live extraction over the PRD sample inputs
make backend    # run the backend alone with readable tracebacks
make e2e        # push the samples through a running backend and print the results

In dev the Rust shell starts the backend straight from backend/.venv. In a packaged build it runs a PyInstaller sidecar:

make bundle     # builds the sidecar, then the .app

The child process is killed on exit — an orphaned backend keeps the Kùzu write lock, and the next launch would fail to open its own database. If you ever see the backend fail to start while the app still runs fine, it's almost always a leftover make backend process holding that lock; kill it and relaunch.

License

Apache License 2.0

About

Non-Linear Note-taking

Resources

Stars

3 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages