LocalLens

1. Domain types

The vocabulary every other module imports.

src/domain.ts is the only file in the project with no internal imports. It's the shared vocabulary the rest of the app speaks.

What lives here

src/domain.ts
export type Brain = {
  id: string;
  name: string;
  folderPath: string;
  workspace: string;
  status: "indexing" | "indexed" | "error";
  fileCount: number;
  chunkCount: number;
  createdAt: string;
  updatedAt: string;
  lastIndexedAt?: string;
  lastError?: string;
};

export type LocalDocument = {
  relativePath: string;
  content: string;
  checksum: string;
  bytes: number;
};

export type BrowserDocumentInput = {
  relativePath: string;
  content: string;
  bytes: number;
};

export type TextChunk = {
  id: string;
  brainId: string;
  relativePath: string;
  chunkIndex: number;
  content: string;
  checksum: string;
};

export type ChatMessage = {
  role: "user" | "assistant" | "system";
  content: string;
};

export type Citation = {
  id: string;
  relativePath: string;
  chunkIndex: number;
  score?: number;
};

export type SearchHit = Citation & { content: string };
export type ChatAnswer = { answer: string; citations: Citation[] };

export type CreateBrainFromFilesInput = {
  name: string;
  folderName: string;
  documents: BrowserDocumentInput[];
};

export type CreateBrainFromFolderInput = {
  name: string;
  folderPath: string;
};

export class AppError extends Error {
  constructor(
    message: string,
    readonly status = 400,
  ) {
    super(message);
    this.name = "AppError";
  }
}

Why this file exists

Two reasons:

  1. It pins the shape of data moving between modules. Brain, TextChunk, SearchHit, and ChatAnswer get touched by every other file. Putting them in one place stops three modules from each having slightly different ideas of what a TextChunk looks like.
  2. It keeps AppError reusable. Any module can throw an AppError with an HTTP-style status, and the server maps it straight to a response. Without this, you end up with bespoke error types in every layer.

The seam between local and browser

LocalDocument and BrowserDocumentInput are deliberately separate.

  • LocalDocument carries a checksum because the local-folder walker can compute one from disk.
  • BrowserDocumentInput doesn't — the file picker hands us content and bytes; the checksum gets computed downstream by the file adapter.

Keeping these two types apart means the workflow doesn't have to special-case "this brain came from disk" vs "this brain came from a browser upload" until the very last step.

Citation has a score, SearchHit has content

SearchHit extends Citation. The split matters:

  • A SearchHit is what ragSearch returns. The chunk content is included because the prompt builder needs it.
  • A Citation is what we return to the caller. The chunk content is intentionally dropped — the caller doesn't need to render the source verbatim, just link to it.

A tiny pattern, but it stops chunk text from leaking into HTTP responses.

What you can run

After writing only domain.ts:

bun run typecheck

…will pass. Nothing else compiles yet because nothing else exists, but the domain file alone has no errors.

Next up: the chunker and grounded prompt, which imports these types and starts using them.

On this page