The hackable AI note taker. Local SQLite. Markdown is export.
- Runtime: Electron + electron-vite
- Frontend: React + TanStack Query + Zustand
- Editor: CodeMirror 6
- Database: SQLite (better-sqlite3)
- Monorepo: pnpm + turborepo
apps/
desktop/ # Electron app (main, preload, renderer)
packages/
ai-core/ # Provider-agnostic AI: streaming, LLM providers, context builder
core/ # Domain logic + markdown parsing
command-registry/ # Command palette registry
plugin-api/ # Plugin system interfaces
storage-core/ # Storage interfaces (pure TS)
storage-sqlite/ # SQLite adapter (peerDep for better-sqlite3)
licensing/ # License validation
product-config/ # Product configuration
sync-core/ # Sync contracts + notebook tree validation
# Live sync is desktop SyncService, not this package
pnpm install # Install dependencies
pnpm dev # Run desktop in dev mode
pnpm test # Run tests (excludes storage-sqlite)
pnpm build # Build for production
pnpm typecheck # Validate TypeScript
pnpm lint # Run ESLint
pnpm format # Format with Prettier- Markdown is sacred: Never auto-modify user's markdown text
- AST is ephemeral: Parse for features, never persist as authority
- Core is pure: No Electron/React deps in packages/core
- AuthGate first: Account is required to open the workspace. Sync is optional E2E after that.
- Offline after login: The workspace works without internet. Sync is a feature, not a requirement.
- Test first: Core domain changes require tests
Native modules like better-sqlite3 require special handling in Electron + pnpm workspaces:
- Native deps only in
apps/desktop: Never add native dependencies directly to workspace packages - Workspace packages = pure TypeScript: Use
peerDependenciesfor native modules - electron-builder owns the rebuild: Let
postinstall: electron-builder install-app-depshandle native module compilation - No manual rebuilds: Never add
@electron/rebuildscripts or workarounds
Pattern for workspace packages with native deps:
// packages/storage-sqlite/package.json
{
"peerDependencies": {
"better-sqlite3": "^11.0.0"
},
"devDependencies": {
"better-sqlite3": "^11.7.0"
}
}apps/desktop pins @types/react to match its React version. Marketing and docs live in dripnex/marketing and dripnex/docs-site, not this repo.
If you see 'X' cannot be used as a JSX component errors: Check that each app's package.json pins @types/react to match its React version.
pnpm testruns all tests except storage-sqlite (safe to run always)- storage-sqlite tests run only in CI with clean Node.js environment
- Why:
better-sqlite3binary compiled for Electron ≠ Node.js binary
To test storage-sqlite locally (breaks Electron app until pnpm dev):
cd packages/storage-sqlite && pnpm rebuild better-sqlite3 && pnpm testpnpm dev— Run desktop in development modepnpm test— Test before committingpnpm typecheck— Validate TypeScriptpnpm build && pnpm --filter @dripnex/desktop dist:mac— Build for production
We use a simplified Git Flow with automated releases:
main ← Production releases (semantic-release runs here)
└── develop ← Integration branch (NEVER push directly)
└── feature/* ← Feature development
└── fix/* ← Bug fixes
| Branch | Purpose | Merges to |
|---|---|---|
main |
Production releases | - |
develop |
Integration, next release | main (via PR) |
feature/* |
New features | develop |
fix/* |
Bug fixes | develop |
- NEVER commit directly to
developormain— always create a feature/fix branch first - All work goes through PRs — even small fixes, even single-line changes
- PR flow:
feature/*orfix/*→develop(via PR) →main(via PR) - Branch naming:
feature/short-descriptionfor new features,fix/short-descriptionfor bug fixes
Claude Code MUST follow this workflow:
git checkout develop && git pull origin developgit checkout -b fix/description-here(orfeature/)- Make changes, commit on the branch
git push -u origin fix/description-heregh pr create --base develop --head fix/description-here- After merge:
git checkout develop && git pull && git branch -d fix/description-here
NEVER do: git commit on develop, git push origin develop. The only PR onto main is a promotion: chore(release): promote X.Y.Z, merge commit, never squash.
See docs/RELEASE.md. Merge the promotion PR. Release + Build run on their own. No PAT. No Run workflow.
# Soft rollback — stop distribution immediately
gh release edit v0.10.0 --draft
# Hard rollback — delete entirely
gh release delete v0.10.0 --yes
git push --delete origin v0.10.0Starting new work:
git checkout develop
git pull origin develop
git checkout -b feature/my-featureKeep branch in sync (do this daily or before pushing):
git fetch origin develop
git rebase origin/developCreating PR:
git push -u origin feature/my-feature
gh pr create --base develop --head feature/my-featureAfter PR merged:
git checkout develop
git pull origin develop
git branch -d feature/my-featureLong-lived branches cause painful merge conflicts. Follow these rules:
- Always branch from develop:
git checkout develop && git pull && git checkout -b fix/my-fix - Never push to develop directly: All changes via PR from feature/fix branches
- Rebase daily:
git fetch origin develop && git rebase origin/developbefore starting work each day - Small PRs: Prefer 3 small PRs over 1 large one. Split by layer (types → logic → UI)
- Max branch lifetime: 2-3 days. If work takes longer, split into incremental PRs
- Don't touch unrelated files: Avoid changes to
package.json, lockfiles, orapps/webunless that's the PR's purpose — these are high-conflict files - Rebase before pushing: Always rebase against latest develop before
git pushto catch conflicts early - Clean up after merge: Delete feature branches locally and remotely after PR is merged
Use conventional commits:
feat:— New featurefix:— Bug fixrefactor:— Code refactoringdocs:— Documentationtest:— Testschore:— Maintenance
- All tests pass (
pnpm test) - Build succeeds (
pnpm build) - PR targets
develop(notmain) - Descriptive title with conventional commit prefix
- Summary of changes in description
Source of Truth: packages/product-config/src/facade.ts
All pricing, plans, and guarantees live in ONE place. Marketing pages consume it.
Golden Rule: If the business model changes, one PR must touch:
packages/product-config/src/facade.ts— Update SoT- Marketing pages that consume facade — Auto-updated via import
terms.astro+privacy.astro— Align vocabulary manually
Pages consuming facade:
pricing.astro✅faq.astro✅Hero.astro✅Audience.astro✅
Legal pages checklist (before merge):
- Model matches facade? (free vs subscription)
- "Free tier" and "Pro" used consistently?
- Trial days =
config.trialDays? - Refund days = 14?
The app uses a centralized NavigationState for all navigation concerns.
Source of Truth: hooks/useNavigation.tsx
type NavigationState =
| { kind: 'global'; filter: 'all' | 'pinned' | 'trash' }
| { kind: 'notebook'; id: string }
| { kind: 'tag'; name: string } // Future
| { kind: 'search'; query: string }; // FutureKey Principles:
- One state rules all navigation
- All filtering derived from
NavigationState - Sidebar emits navigation actions, never filters data
- UI = pure function of state
How it works:
NavigationProviderwraps the appuseNavigation()provides state + actionsfilteredNotesis derived automatically- Sidebar calls
goToNotebook(),goToAllNotes(), etc. - No props drilling for navigation
Adding new views (Tags, Smart Folders):
// 1. Add to NavigationState type
| { kind: 'tag'; name: string }
// 2. Add action to useNavigation
const goToTag = useCallback((name: string) => {
setNavigation({ kind: 'tag', name });
}, []);
// 3. Add filter case in filteredNotes useMemo
case 'tag':
notes = notes.filter(n => n.tags?.includes(navigation.name));
break;PR Checklist (navigation changes):
- State change via actions only (
goToX()) - Filtering in
useNavigation.tsxonly - Sidebar uses
useNavigation()hook - No implicit flags (
!== null)
The AI system lives in packages/ai-core with a provider-agnostic, streaming-first design.
Renderer (AiPanel) → IPC → Main (ipc-ai.ts) → AIService → ProviderRegistry → Provider → SSE stream
← batched LLMEvents (text/error/done) ←
Key packages and files:
packages/ai-core/— LLMProvider interface, ProviderRegistry, AnthropicProvider, ContextBuilder, AIServiceapps/desktop/src/main/ai/ipc-ai.ts— IPC bridge, 50ms batched event streamingapps/desktop/src/preload/index.ts—window.dripnex.aiAPI (chat, onEvent, cancel)apps/desktop/src/renderer/components/ai/AiPanel.tsx— Chat UI with streaming
Adding a new LLM provider:
- Create
packages/ai-core/src/providers/my-provider.tsimplementingLLMProvider - Register in
ProviderRegistryatapps/desktop/src/main/ai/ipc-ai.ts - Add option to
apps/desktop/src/renderer/pages/settings/sections/AiSection.tsx
Key types:
LLMEvent— Protocol:text(delta),error(with code),done,tool_call,tool_resultChatOptions— Provider, model, messages, tools, maxTokensLLMProvider—chat(options): AsyncGenerator<LLMEvent>+models()+validateKey()
Rules:
- No SDK dependencies in ai-core: Providers use native
fetch+ SSE parsing - Streaming only: No request/response pattern — everything streams via
LLMEvent - Single panel instance: Both Cmd+K and Sparkles button toggle the same AiPanel in App.tsx via CustomEvent (
dripnex:ai:toggle-panel) - Settings store is source of truth: API key, model, and provider come from Zustand settings store (
selectAiselector), not plugin config
- Architecture decisions:
plan.md - Package docs:
packages/*/README.md - Technical docs:
apps/docs-site/ - Live docs: https://dripnex.app/docs
- GitHub: https://github.com/dripnex/app