metuge.dev

Projects

Life OS

One place for tasks, journaling, scheduling and the numbers behind them, instead of four apps that ignore each other.

2025–Independent developer, ongoing

The problem

Tasks live in one app, notes in another, the calendar somewhere else. Nothing talks to anything, so you end up being the integration.

What I did

One NestJS backend, but every domain keeps a clear internal boundary, so a change to journaling cannot quietly break scheduling. The AI assistant is not bolted on either — it calls the same command and query bus a normal request would. It is the architecture I would want to still be able to work on in two years.

Built with

  • Next.js
  • NestJS
  • Expo
  • PostgreSQL
  • Prisma
  • Anthropic
  • Gemini
  • Groq
  • Cloudinary
  • Cloudflare R2

No side door for the assistant

Most "AI features" are a chat window bolted onto an app, talking to the model through its own bespoke path. LifeOS's assistant doesn't get one. Every tool it calls — creating a task, rescheduling a commitment, deleting a note — dispatches into the exact same command bus and query bus a REST request would use. It inherits the same validation, the same domain rules, the same events. That single decision is most of the architecture below.

A modular monolith, not a service mesh

Ten domain modules — auth, assistant, brain, scheduler, project, analytics, finance, export, settings, health — live in one NestJS process, one deploy, one Postgres database. Splitting this into microservices would have bought nothing: a single user's data, running on one VPS, has no independent scaling axis to justify the network hop.

What each module does get is the same strict internal layering every time — domain, application, infrastructure, presentation. A controller never touches Prisma directly; it dispatches a command or query and returns whatever comes back. The repository itself is an interface, bound to its Prisma implementation by a token, so a domain module can be unit-tested against a fake without touching a database.

Every route sits behind a global JWT guard unless explicitly marked public. Access and refresh tokens are separate, the refresh token is persisted and rotated, and Google OAuth has two independent verification paths — a server-side code exchange for the web client, and a standalone endpoint for mobile that checks the ID token's issuer, audience, and email_verified flag before trusting it.

The model is a swappable part, not a foundation

Every AI feature — chat, tool-calling, note enrichment — goes through one interface:

interface LlmPort {
  modelTokenLimit: number;
  supportsStreamingToolCalls: boolean;
  complete(messages: ChatMessage[], options?: CompletionOptions): Promise<CompletionResult>;
  stream(messages: ChatMessage[], options?: CompletionOptions): AsyncIterable<StreamChunk>;
}

Four adapters implement it — Anthropic, Gemini, Groq, and vLLM for a local OpenAI-compatible endpoint — chosen at boot by a single environment variable. Nothing in the assistant module imports a provider SDK directly.

The interface has to paper over real seams between providers, not just request shapes. Gemini's thinking models reject a tool round-trip that comes back without the thoughtSignature attached to the original call, so tool calls carry an optional signature field every other adapter just ignores. supportsStreamingToolCalls is a capability flag rather than an assumption, because not every provider can stream tokens and a tool call in the same breath. Embeddings sit behind their own port, since none of the four chat providers currently expose them.

An assistant that can act, gated where it should be

The assistant has 38 tools across five domains — tasks, projects, notes, commitments, and long-term memory — and every one dispatches into the same command and query bus described above. It has no repository access of its own; asking it to delete a task takes the identical path a DELETE request would.

What it doesn't do is act unsupervised on anything destructive:

const deleteTaskTool: ToolDefinition = {
  name: 'delete_task',
  requiresConfirmation: true,
  async describeAction(ctx, args) {
    const task = await ctx.queryBus.execute(new GetTaskByIdQuery(ctx.userId, args.taskId));
    return task ? `Permanently delete the task "${task.title}"?` : 'Permanently delete this task?';
  },
  async execute(ctx, args) {
    await ctx.commandBus.execute(new DeleteTaskCommand(ctx.userId, args.taskId));
    return { summary: 'Task deleted.' };
  },
};

Five of the 38 tools declare requiresConfirmation; anything without a declaration at all — an MCP tool, say — is gated by default rather than assumed safe. A flagged call is held in memory, single-use, for ten minutes until the user approves it, rather than written to the database — a server restart invalidates it instead of resurrecting a stale delete request.

Memory that degrades instead of failing

Notes are embedded on write: a listener on note-created and note-updated events sends the title and body through the embedding port and stores the resulting vector next to the note. Search embeds the query the same way and ranks by cosine distance — but if that call fails, most commonly because no embedding key is configured, the handler logs a warning and falls back to keyword search over the same table instead of erroring. The feature loses precision, not availability.

Analytics that never imports scheduler or brain

The analytics module has zero direct dependency on scheduler or brain — it imports only their event types and reacts to them through @nestjs/event-emitter. One listener handles nine distinct event names from three other modules to keep daily summaries and streaks current. Delete the analytics module entirely and scheduler and brain wouldn't notice — they'd keep emitting into a room nobody's listening in.

Two clients, one contract, different constraints

Web (Next.js) and mobile (Expo) each talk to the backend through per-module API clients that attach the JWT and unwrap the response envelope the backend wraps every reply in. The web chat panel renders a flagged tool call as an inline approve/deny control, so a destructive action surfaces its confirmation in the conversation itself rather than a separate modal.

Mobile carries the harder problem: a hands-free voice mode with real voice-activity detection — a smoothed noise baseline decides when the user has stopped talking — plus barge-in detection while the assistant is speaking. The audio APIs available on device have no acoustic echo cancellation, so barge-in is deliberately best-effort, with a manual tap-to-interrupt as the fallback rather than a promise the mic will always win the race.

What it adds up to

24,232 lines of backend TypeScript, 15,719 on the web client, 11,507 on mobile. Ten domain modules. 38 assistant tools, five of them gated behind confirmation. Four interchangeable LLM providers behind one environment variable. Eleven cross-module event listeners holding the whole thing together without anyone importing anyone else.

Two honest seams

The confirmation gate belongs to the assistant's chat loop specifically — the note-processing pipeline in brain (attachment extraction, enrichment) calls the model through a separate mechanism with no shared tool registry. And the voice-activity detection lives in the dedicated hands-free voice-mode screen, not in the regular chat tab's push-to-talk recorder. Neither is a bug; they're two systems doing two different jobs, worth naming rather than papering over as one.

Nothing here is a portfolio scaffold. LifeOS is the thing I actually run my week through, on a single Docker container on a personal VPS — and that constraint shaped the architecture more than any framework did. A daily-driver app punishes shortcuts a demo would never surface, which is the real argument for routing the assistant through the same bus as the API instead of giving it one, and for making search degrade to keyword matching instead of throwing when a key is missing.