Pile is a desktop journaling app that stores everything as markdown files on your local filesystem, then adds AI-powered reflection and vector search on top. No server. No sync account. Your words stay on your machine.
Why I starred it
Most journaling apps treat local storage as an afterthought — export it as a ZIP if you want to leave. Pile inverts that. The file system is the source of truth. Entries are .md files organized by YYYY/Mon/YYMMDD-HHMMSS.md, and every operation — search, AI reflection, tagging — is built on top of that file tree. That approach is rare in consumer apps, and it means you can open your journal in any text editor and it just works.
The AI angle is also honest. It's not trying to be a therapy bot. The default system prompt in src/renderer/context/AIContext.js reads: "You can never directly address you or directly respond to you. Try not to repeat what the user said, instead try to seed new ideas, encourage or debate." That's a deliberate design choice — the AI replies float in a thread but exist separately from your actual writing, more like marginalia than conversation.
How it works
The core is src/main/utils/pileIndex.js — a singleton PileIndex class that maintains a Map of relative file paths to frontmatter metadata, persisted as index.json in each Pile directory. On load it either reads that JSON or walks the directory and regenerates from frontmatter via gray-matter. Sort is done by createdAt on every write, which is a bit blunt (a full re-sort on every save), but fine at journaling scale.
Two search indexes sit on top of the main index:
Text search (pileSearchIndex.js) uses lunr — inverted index over title, content, attachments, tags. The index is rebuilt in-memory on each mutation. Thread replies are concatenated to parent content before indexing, so searching "anxiety" finds both the original entry and any AI or personal replies in that thread.
Vector search (pileEmbeddings.js) is more interesting. It generates embeddings per thread (parent + replies concatenated), stores them in an embeddings.json file alongside the index, then does cosine similarity at query time entirely in process:
// src/main/utils/pileEmbeddings.js
function cosineSimilarity(embedding, queryEmbedding) {
let dotProduct = 0, normA = 0, normB = 0;
for (let i = 0; i < embedding.length; i++) {
dotProduct += embedding[i] * queryEmbedding[i];
normA += embedding[i] ** 2;
normB += queryEmbedding[i] ** 2;
}
return dotProduct / (Math.sqrt(normA) * Math.sqrt(normB));
}
No external vector DB, no server round-trip. Embeddings come from either OpenAI (text-embedding-3-small) or Ollama (mxbai-embed-large by default), configurable per-pile. The main search flow in AIContext.js calls vectorSearch to surface semantically similar past entries before sending the AI reflection prompt — so the AI can reference what you wrote six months ago about the same topic.
The AI layer supports both OpenAI and Ollama through a unified generateCompletion interface. OpenAI goes through the official SDK with streaming; Ollama uses raw fetch against http://localhost:11434/api/chat with a manual ReadableStream decoder. Both stream tokens back to the renderer via callbacks:
// src/renderer/context/AIContext.js
for await (const part of stream) {
callback(part.choices[0].delta.content);
}
The post format itself is defined in src/renderer/utils/fileOperations.js — a flat object with title, content, attachments, replies (array of relative paths), isReply, and isAI flags. Replies are linked by path, not ID — clean and filesystem-native, but means renaming files would break threads.
The editor is Tiptap (ProseMirror under the hood) with a custom EnterSubmitExtension that maps Enter to submit. That's intentional — each entry is meant to be a quick note, not a long-form document.
Using it
Download from the releases page, open the app, create a new Pile (which maps to a folder on disk). If you want AI features:
Settings → API Key → paste OpenAI key
Or for fully local operation:
Settings → AI Provider → Ollama
ollama pull mxbai-embed-large
ollama pull llama3
Then write an entry, hit Enter, and click "Reflect" to get an AI response threaded below your post. The global chat panel (top-right icon) does vector-powered Q&A across your full journal history.
Your entries land at paths like:
~/Documents/MyPile/2024/Sep/240915-143022.md
Rough edges
The only test file is src/__tests__/App.test.tsx — a bare Electron smoke test. Nothing covering the index, embeddings, or IPC handlers. That's a real gap if you want to contribute.
The comment at the top of pileEmbeddings.js is honest about a performance issue: "Todo: Cache the norms alongside embeddings at some point to avoid recomputing them for every query." Right now, every vector search recomputes norms for every stored embedding. Fine with 100 entries, noticeable with thousands.
The openai SDK is initialized with dangerouslyAllowBrowser: true — necessary because the renderer process does the API call directly rather than routing through the main process. That's an architectural shortcut that leaks the API key into the renderer context.
Last commit activity was December 2024, so the project is alive but slow-moving. The README lists React 19 and Electron 33 as recent upgrades, which at least means dependencies aren't stale.
Bottom line
If you journal in plain text and want AI reflection without sending your writing to someone's cloud, Pile is the most coherent implementation of that idea I've seen. The Ollama support makes it genuinely air-gapped. The file format is too simple to lock you in.
