Local-first project & task planner
  • TypeScript 99.7%
  • CSS 0.3%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
2026-08-30 13:51:48 +02:00
packages Reduce MCP memory catalog overhead 2026-08-30 13:49:22 +02:00
public Initial commit: Planner app 2026-05-27 22:49:04 +02:00
scripts Clean obsolete scripts and test coverage 2026-08-26 12:38:19 +02:00
tests Reduce MCP memory catalog overhead 2026-08-30 13:49:22 +02:00
.gitattributes chore: enforce LF checkouts via .gitattributes 2026-08-01 16:31:21 +02:00
.gitignore Fix audited quick wins 2026-08-15 11:47:18 +02:00
.oxfmtrc.json Fix audited quick wins 2026-08-15 11:47:18 +02:00
.oxlintrc.json Move server, shared, and web app under packages/ 2026-08-13 09:39:36 +02:00
bun.lock Update dependencies 2026-08-24 13:34:19 +02:00
eslint.config.js Move server, shared, and web app under packages/ 2026-08-13 09:39:36 +02:00
knip.json Clean up dead and duplicated code 2026-08-23 16:55:05 +02:00
knip.production.json Remediate repository audit findings 2026-08-27 23:08:27 +02:00
package.json Remediate repository audit findings 2026-08-27 23:08:27 +02:00
playwright.config.ts Harden dependency and distribution gates 2026-08-08 21:06:27 +02:00
react-router.config.ts Move server, shared, and web app under packages/ 2026-08-13 09:39:36 +02:00
README.md feat(agent): expose received shares through MCP 2026-08-28 19:44:48 +02:00
tsconfig.json Move server, shared, and web app under packages/ 2026-08-13 09:39:36 +02:00
vite.config.ts Protect private dev-server paths 2026-08-14 23:39:33 +02:00
vitest.config.ts Give the browser suite room under contention 2026-08-26 00:43:18 +02:00

Planner

Self-hosted project and task planner with private encrypted agent memory, a Markdown notepad, dark mode, nested project folders with tags, and PDF export. Optional Ollama-powered AI helpers.

Run

bun run dev starts the Vite dev server. It is for development only: it serves the project directory as static files and applies only an HMR-safe subset of the security headers, while the binary applies the full Content-Security-Policy. The supported way to actually run Planner is the single binary below.

bun install --frozen-lockfile
export PLANNER_POSTGRES_MODE=socket
export PLANNER_POSTGRES_SOCKET=/var/run/postgresql
export PLANNER_POSTGRES_DATABASE=planner
export PLANNER_POSTGRES_USER=planner_runtime
export PLANNER_CREDENTIAL_GENERATION_FILE=/run/credentials/planner-credential-generation
bun run dev

Open http://127.0.0.1:2137. The dev server binds loopback only. Reaching it from another device — a phone on the same LAN at http://<host-lan-ip>:2137 — is an explicit opt-in, because exposing a server that also serves the project directory should be a decision:

$env:PLANNER_DEV_HOST='0.0.0.0'   # bind all IPv4 interfaces
bun run dev

Planner has one runtime storage backend: PostgreSQL. The development server uses the same PostgreSQL repository as the standalone binary. Its default master key file is ./.planner-master-key.json; never expose or commit that file.

Single binary

Development and builds require Bun 1.4.0 plus Node.js 22.22 or newer in the Node 22 line, or Node.js 24 or newer. Node 23 is not supported. The release compiler requires the exact Bun version pinned by packageManager because that runtime is embedded in every executable; engines.bun is the broader range for ordinary repository tooling. Check both with bun --version and node --version. The resulting binary runs on its own.

Build a standalone executable for the current OS and CPU:

bun run build:binary

The host build writes dist/planner.exe on Windows and dist/planner elsewhere. To cross-build, pass one target:

bun run build:binary -- windows-x64
bun run build:binary -- linux-x64
bun run build:binary -- linux-arm64
bun run build:binary -- macos-x64
bun run build:binary -- macos-arm64

Run it with:

dist\planner.exe --host 127.0.0.1 --port 2137

On Linux or macOS:

./dist/planner --host 127.0.0.1 --port 2137

The binary serves the app, API, and public share pages from one process. Keep the loopback listener for local use. To bind 0.0.0.0 or another non-loopback address, put Planner behind an HTTPS reverse proxy and configure the public HTTPS origin/secure cookies; the built-in listener is plain HTTP and must not be exposed directly to an untrusted network. The master key defaults to .planner-master-key.json beside the executable and can be changed with PLANNER_MASTER_KEY_FILE or --master-key-file. Back up the PostgreSQL database and master key together. Losing either makes the encrypted records unusable.

The runtime requires an explicit PostgreSQL connection profile. Unix socket mode uses PLANNER_POSTGRES_SOCKET; TCP mode requires PLANNER_POSTGRES_HOST, a secure PLANNER_POSTGRES_PASSWORD_FILE, and PLANNER_POSTGRES_TLS_CA_FILE. TCP always verifies the server certificate and hostname. Raw password variables and DATABASE_URL are rejected.

Variable Purpose
PLANNER_POSTGRES_MODE Required: socket or tcp
PLANNER_POSTGRES_DATABASE Required database name
PLANNER_POSTGRES_USER Required least-privilege runtime role
PLANNER_POSTGRES_SOCKET Absolute socket directory in socket mode
PLANNER_POSTGRES_HOST Database hostname in tcp mode
PLANNER_POSTGRES_PORT Database port; default 5432
PLANNER_POSTGRES_PASSWORD_FILE Owner-protected password file in tcp mode
PLANNER_POSTGRES_TLS_CA_FILE Trusted CA file in tcp mode
PLANNER_POSTGRES_TLS_CERT_FILE Optional client certificate
PLANNER_POSTGRES_TLS_KEY_FILE Optional client key; required with cert
PLANNER_CREDENTIAL_GENERATION_FILE Required owner-protected credential-generation secret

PostgreSQL provisioning

Provisioning is separate from the least-privilege runtime service. Planner requires PostgreSQL 17 or 18 with these server settings; startup rejects a database that does not match them:

data_checksums = on
fsync = on
full_page_writes = on
synchronous_commit = on
log_statement = none
log_parameter_max_length = 0
log_parameter_max_length_on_error = 0

WAL archiving is not part of this baseline. A previous unbounded archive path consumed about 40 GB, so do not enable it as part of Planner provisioning. Any later PITR project must first provide bounded retention, tested cleanup, capacity alarms, and enough repository capacity, then validate an isolated restore independently of this runbook.

Create an empty database, a provisioning login that owns it, and a distinct runtime login with no role-creation, database-creation, replication, bypass-RLS, ownership, or privileged role membership. Configure the provisioning login with PLANNER_POSTGRES_PROVISIONING_USER; TCP deployments also use PLANNER_POSTGRES_PROVISIONING_PASSWORD_FILE and, when client certificates are required, PLANNER_POSTGRES_PROVISIONING_TLS_CERT_FILE plus PLANNER_POSTGRES_PROVISIONING_TLS_KEY_FILE. The common mode, database, socket or host, port, and CA variables are shared with the runtime profile.

Before bootstrapping, create both secret files as owner-protected regular files outside any data, export, backup, or temporary tree. PLANNER_MASTER_KEY_FILE must be absolute and contain a keyring such as {"active":1,"keys":{"1":"<base64-encoded 32-byte key>"}}. PLANNER_CREDENTIAL_GENERATION_FILE contains exactly one canonical base64-encoded 32-byte secret, optionally followed by one newline. Back up both files with the database.

Install pgvector 0.8.6 on the PostgreSQL host, then run this database-local prerequisite as postgres before applying Planner's schema. postgres retains ownership of both the extension and its dedicated schema. Replace planor_provisioning with the quoted provisioning role used by the deployment.

CREATE SCHEMA planor_vector AUTHORIZATION postgres;
REVOKE ALL PRIVILEGES ON SCHEMA planor_vector FROM PUBLIC;
CREATE EXTENSION vector WITH SCHEMA planor_vector VERSION '0.8.6';
REVOKE ALL PRIVILEGES ON ALL FUNCTIONS IN SCHEMA planor_vector FROM PUBLIC;

DO $body$
DECLARE item record;
BEGIN
  FOR item IN
    SELECT namespace.nspname, type.typname
    FROM pg_catalog.pg_type AS type
    JOIN pg_catalog.pg_namespace AS namespace ON namespace.oid = type.typnamespace
    WHERE namespace.nspname = 'planor_vector' AND type.typelem = 0
  LOOP
    EXECUTE format(
      'REVOKE ALL PRIVILEGES ON TYPE %I.%I FROM PUBLIC',
      item.nspname,
      item.typname
    );
  END LOOP;
END
$body$;

GRANT USAGE ON SCHEMA planor_vector TO planor_provisioning;
GRANT USAGE ON TYPE planor_vector.vector TO planor_provisioning;
GRANT EXECUTE ON FUNCTION
  planor_vector.vector_dims(planor_vector.vector),
  planor_vector.vector_norm(planor_vector.vector),
  planor_vector.cosine_distance(planor_vector.vector, planor_vector.vector)
TO planor_provisioning;

PostgreSQL has no REVOKE ... ON ALL TYPES IN SCHEMA form. Array types also cannot be revoked directly, so the loop deliberately covers only base types with typelem = 0. Do not grant the provisioning role schema CREATE, extension ownership, or broader function or type privileges.

Apply the schema and initialize its encrypted bootstrap with the provisioning profile. Each command requires a fresh UUID run identifier:

./dist/planner storage postgres schema apply --run-id=<uuid>
./dist/planner storage postgres bootstrap --run-id=<uuid>

Then grant the runtime role only the privileges enforced by Planner's startup preflight. Replace planner and planner_runtime with the quoted database and role names used by the deployment. Run the database and public object statements as the provisioning owner. Run the planor_vector revoke loop and grants as postgres, because the extension remains DBA-owned:

REVOKE ALL PRIVILEGES ON DATABASE planner FROM PUBLIC;
REVOKE ALL PRIVILEGES ON DATABASE planner FROM planner_runtime;
GRANT CONNECT ON DATABASE planner TO planner_runtime;
REVOKE ALL PRIVILEGES ON SCHEMA public FROM PUBLIC;
REVOKE ALL PRIVILEGES ON SCHEMA public FROM planner_runtime;
GRANT USAGE ON SCHEMA public TO planner_runtime;
REVOKE ALL PRIVILEGES ON ALL TABLES IN SCHEMA public FROM planner_runtime;
REVOKE ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA public FROM planner_runtime;
REVOKE ALL PRIVILEGES ON SCHEMA planor_vector FROM planner_runtime;
REVOKE ALL PRIVILEGES ON ALL FUNCTIONS IN SCHEMA planor_vector FROM planner_runtime;

DO $body$
DECLARE item record;
BEGIN
  FOR item IN
    SELECT namespace.nspname, type.typname
    FROM pg_catalog.pg_type AS type
    JOIN pg_catalog.pg_namespace AS namespace ON namespace.oid = type.typnamespace
    WHERE namespace.nspname = 'planor_vector' AND type.typelem = 0
  LOOP
    EXECUTE format(
      'REVOKE ALL PRIVILEGES ON TYPE %I.%I FROM planner_runtime',
      item.nspname,
      item.typname
    );
  END LOOP;
END
$body$;

GRANT USAGE ON SCHEMA planor_vector TO planner_runtime;
GRANT USAGE ON TYPE planor_vector.vector TO planner_runtime;
GRANT EXECUTE ON FUNCTION
  planor_vector.vector_dims(planor_vector.vector),
  planor_vector.vector_norm(planor_vector.vector),
  planor_vector.cosine_distance(planor_vector.vector, planor_vector.vector)
TO planner_runtime;

GRANT SELECT ON TABLE
  public.schema_migrations,
  public.crypto_bootstrap,
  public.storage_datasets,
  public.storage_control
TO planner_runtime;

GRANT DELETE ON TABLE
  public.account_generations,
  public.account_heads
TO planner_runtime;
GRANT SELECT (dataset_id, owner_id) ON TABLE public.account_generations TO planner_runtime;
GRANT SELECT (dataset_id, owner_id) ON TABLE public.account_heads TO planner_runtime;

GRANT SELECT, INSERT, UPDATE, DELETE ON TABLE
  public.auth_users,
  public.collection_heads,
  public.opaque_records,
  public.derived_records,
  public.attachment_uploads,
  public.attachment_chunks,
  public.attachment_heads,
  public.auth_sessions,
  public.auth_api_tokens,
  public.auth_agent_installs,
  public.auth_shares,
  public.archive_imports,
  public.archive_chunks,
  public.archive_export_leases,
  public.storage_changes,
  public.embedding_profiles,
  public.embedding_documents
TO planner_runtime;

GRANT SELECT, INSERT, DELETE ON TABLE public.embedding_chunks TO planner_runtime;

GRANT USAGE, SELECT ON SEQUENCE public.storage_changes_change_seq_seq TO planner_runtime;

Start Planner only after provisioning and grants are complete. The runtime preflight fails closed on missing or excess privileges, unsafe role attributes, schema drift, or an unauthenticated bootstrap.

Legacy filesystem trees are not a runtime option. The explicit planner storage postgres migrate and source-recover operator workflows can read an offline source tree for migration or rollback recovery. Account archives remain the supported user-facing transfer format.

Smoke-test the built binary against a fresh disposable PostgreSQL database on a host with pgvector 0.8.6 installed. The smoke refuses to run unless the database name starts with planner_smoke_, its public schema is empty, and the destructive-test opt-in is explicit. It connects as postgres to install and lock down the extension, checks its ownership and PUBLIC grants, applies and verifies the schema, bootstraps temporary encryption credentials, grants the runtime role its exact exported privilege allowlist, then verifies an authenticated write survives a binary process restart. It also writes a two-chunk note projection, exercises exact cosine search and fail-closed RLS, then proves a compiled-binary note update invalidates the stale projection.

export PLANNER_BINARY_SMOKE_DISPOSABLE=1
export PLANNER_POSTGRES_MODE=socket
export PLANNER_POSTGRES_SOCKET=/var/run/postgresql
export PLANNER_POSTGRES_DATABASE=planner_smoke_$(date +%s)
export PLANNER_POSTGRES_USER=planner_smoke_runtime
export PLANNER_POSTGRES_PROVISIONING_USER=planner_smoke_provisioning
bun run smoke:binary

The database and both login roles must already exist. The provisioning role must own the empty database; the runtime role must remain a non-owner without BYPASSRLS. Socket mode expects local peer access as postgres. TCP mode also requires PLANNER_BINARY_SMOKE_DBA_PASSWORD_FILE, held to the same secure-file rules as the other PostgreSQL password files. Use a new database for every run and dispose of it afterward.

Agent access (optional)

Planner can issue short-lived, single-use install URLs for CLI and MCP clients. Agent access reuses the signed-in user's scoped API tokens and is disabled by default. Build the downloadable CLI sidecar — this also compiles with Bun, one binary per platform — and enable it with:

bun run build:agent-cli
export PLANNER_AGENT_ENABLED=true
export PLANNER_PUBLIC_URL=https://planner.example.com
export PLANNER_AGENT_CLI_DIR=/opt/planner/agent-cli

Deploy the complete dist/agent-cli directory beside the server binary as agent-cli, or point PLANNER_AGENT_CLI_DIR at it. In Settings > API, select the scopes and lifetime, then create an install URL. Viewing the URL is safe; only planner agent install <url> consumes it. Install URLs last 15 minutes and the resulting credential lasts 90 days by default.

Agent clients can use PLANNER_URL and PLANNER_TOKEN_FILE. MCP clients use Streamable HTTP at /mcp with Authorization: Bearer <token>.

The ordinary note, project, task, and sub-task agent/MCP tools include resources shared directly with the authenticated account. List and get results carry a stable ref plus access metadata; pass that ref back to get or mutation tools so Planor can route the call through owner or share authorization. Shared resources remain subject to their view or edit grant, cannot be deleted by the grantee, and disappear immediately when the owner revokes the grant. Public share links remain separate anonymous capabilities. workspace_get, workspace_discover, note folders, project folders, and note search stay owner-only.

Logging

Planner keeps runtime diagnostics local. The standalone server writes readable lines to stdout/stderr for terminals and journalctl, plus structured JSONL files under logs beside the executable. Browser crash, sync, and AI error reports are posted only to the same-origin Planner server. Logging never sends telemetry to an external service; the configured AI provider and SMTP server remain the only outbound integrations.

Production defaults are info level, 14 UTC days of files, a rolling 50 MB per day, a 1 second slow-request threshold, and a 30 second threshold for AI calls. Direct bun run dev defaults to debug and writes JSONL under ./logs. Set PLANNER_LOG_DIR to override that location.

Variable Purpose
PLANNER_LOG_LEVEL debug, info, warn, error, or fatal
PLANNER_LOG_DIR JSONL directory; defaults to logs beside the standalone executable
PLANNER_LOG_RETENTION_DAYS UTC dates to retain; default 14
PLANNER_LOG_MAX_DAILY_MB Rolling per-day file limit; default 50
PLANNER_SLOW_REQUEST_MS Slow ordinary API threshold; default 1000
PLANNER_AI_SLOW_REQUEST_MS Slow AI API threshold; default 30000
PLANNER_TRUSTED_PROXIES Comma-separated proxy IP/CIDR list allowed to supply X-Forwarded-For; default empty

Invalid logging values stop startup rather than silently changing behavior. If the file destination becomes unavailable at runtime, Planner continues on the console and retries the file transport. Log files contain operational metadata, internal IDs, and sanitized stacks, but not workspace content, request bodies, credentials, prompts, model responses, email addresses, or raw client IPs.

For a normal systemd service, both console streams are available together:

journalctl -f -u planner.service

Set PLANNER_TRUSTED_PROXIES only for proxy addresses you control. With an empty value, forwarded client-address headers are ignored. A production unit should use Restart=on-failure so a deliberately fatal process error restarts after Planner flushes its final log record.

Memory search keeps its encrypted lexical index and can optionally add semantic ranking through user-configured embedding and tokenizer request URLs. The feature is off for each account until that user saves a complete profile in Settings. Owned-note semantic search is a separate opt-in using the same profile; enabling memory embeddings does not upload notes. When enabled, note indexing sends the title, content, and tags to the configured provider. Memory and notes remain separate corpora with different derivation schemas.

The Memory and Notepad search fields use these hybrid retrieval paths directly. Without semantic ranking, each field uses deterministic lexical ranking.

Agent clients can search active owned notes through GET /api/agent/v1/notes/search, planner agent note search --query TEXT, or the MCP note_search tool. These surfaces require notes:read, exclude trashed and shared-in notes, and never mix memory observations into note results. Search uses deterministic lexical title/content/tag ranking when note embeddings are disabled, incomplete, or unavailable.

The profile and optional bearer token are encrypted with the user's workspace and redacted from API responses. Planor sends the selected model ID to both provider routes, so one profile works with a single-model server or a model-aware router. No server-wide embedding host, model, credential, or ranking policy is built into the application.

Embedding vectors are plaintext-derived numeric values inside PostgreSQL, not application-encrypted payloads. Encrypt and tightly control PGDATA and tablespaces, WAL and any archive if later used, physical and logical disaster recovery backups, snapshots and replicas, temporary spill, swap, and core dumps. Portable account archives and active inverse exports exclude vectors, which can be rebuilt from decrypted source data. Deletion removes logical rows but cannot promise immediate physical erasure from MVCC storage, WAL, replicas, snapshots, or retained backups.

Stack and storage

React 19 · TypeScript · Vite · Zustand · Marked. PostgreSQL is authoritative for accounts, credentials, shares, workspaces, notes, memory, collaboration checkpoints, attachments, archive staging, and change notifications. Sensitive payloads are encrypted and authenticated before they enter PostgreSQL; lookup columns use scoped blind indexes rather than plaintext logical identifiers.

The remaining files are operational boundaries, not alternate data stores: the master key, PostgreSQL password and TLS material, logs, build/static assets, and user-selected import/export archives. Protect these with a dedicated service account and restrictive file permissions or ACLs. Theme, sidebar width, folder expansion, and the archived section are account preferences; saved changes notify other signed-in tabs.

Development checks

The individual quality gates are composable:

bun run typecheck
bun run lint
bun run lint:react
bun run format:check
bun run knip
bun run test
bun run test:ui

bun run check runs type generation/typechecking, Oxlint, React ESLint, formatting, Knip, serial Bun server tests, Vitest component tests, and the web build/bundle budget. bun run check:full additionally audits dependencies, runs cross-browser E2E, builds both server and agent distributions, and runs their smoke suites. Use bun run format to apply the Oxfmt baseline.

Exact dependency overrides are release invariants, not ordinary update pins: @codemirror/view keeps CodeMirror class identity single-versioned; fast-uri, hono, and nanoid force patched transitives retained by Bun's resolver; vite keeps React Router and direct build tooling on the audited release. bun outdated does not report all override drift, so review this block with bun why <package>, bun audit, and notice regeneration during upgrades.

AI (optional)

Open Settings & AI and point Planner at an Ollama-compatible server (default http://localhost:11434, model gemma3:4b). Planner proxies /api/tags and /api/chat through its authenticated backend. Saved API keys are encrypted at rest, added to upstream requests only by the server, and never returned to the browser.

PLANNER_AI_ALLOWED_HOSTS controls outbound AI hosts. It defaults to *, which permits public hosts plus loopback Ollama on port 11434. Entries without a port allow only the URL scheme's default port; private/LAN hosts and custom ports must be listed explicitly as comma-separated host[:port] entries, for example:

$env:PLANNER_AI_ALLOWED_HOSTS='pool.kleb.sh,ollama.lan:11434'

PLANNER_AI_RATE_LIMIT caps AI requests per account per five minutes and defaults to 30, which suits interactive use. Raise it only for controlled batch workloads, then restore the default afterwards.

Without an AI server the rest of Planner continues to work; AI actions show a connection error.

When an AI API key is configured, Planner requires an HTTPS endpoint unless every resolved address is loopback. Allowlisting a LAN hostname permits routing to it but does not waive this transport rule; terminate TLS at the AI service or an authenticated reverse proxy.

Email recovery (optional)

SMTP enables verified recovery emails, password reset, and opt-in share notifications. Configure it through environment variables so credentials do not appear in command-line arguments:

$env:PLANNER_SMTP_URL='smtps://user:password@smtp.example.com:465'
$env:PLANNER_MAIL_FROM='Planner <planner@example.com>'
$env:PLANNER_PUBLIC_URL='https://planner.example.com'

If PLANNER_SMTP_URL is unset, Planner starts normally with email features disabled. New and existing accounts can optionally add a recovery email; it must be verified before password recovery or share notifications can use it.

For HTTPS deployments, set PLANNER_PUBLIC_URL to the external HTTPS origin or set PLANNER_SECURE_COOKIES=true. Planner also detects direct TLS and HTTPS browser origins automatically. Set PLANNER_SECURE_COOKIES=false only for an intentionally plain-HTTP deployment such as local loopback use.

Feedback (optional)

Set PLANNER_FEEDBACK_EMAIL to put a feedback form in Settings > General. Submissions are rendered into one email to that address and are never stored, so the instance needs no administrator account and no inbox screen:

$env:PLANNER_FEEDBACK_EMAIL='you@example.com'

It requires PLANNER_SMTP_URL; without both, the form does not appear. Each submission carries a summary, a message, and up to three image or text attachments (2 MB each, 5 MB total), plus the build id and the sender's username and language. When the sender has a verified recovery email, the message's Reply-To is set to it. Accounts are limited to five submissions per hour.

Features

  • Dashboard, project view, calendar, statistics
  • Private encrypted memory with search, project context, sessions, timelines, and reversible trash
  • Notepad with Markdown preview and Export PDF
  • Nested project folders, star-to-pin, drag-and-drop projects between folders
  • Per-project tags with filter + sort on the dashboard
  • Dark mode (persisted)
  • Resizable sidebar (persisted)