LocalLens

6. The application workflow

LocalLensApp is where the modules from the previous five steps come together.

src/locallens.ts exports LocalLensApp, the single entry point both the CLI and the server use. It owns the workflow — the order things happen in — and nothing else.

The constructor

src/locallens.ts
export class LocalLensApp {
  constructor(
    private readonly store = new LocalLensStore(),
    private readonly qvac = new QvacGateway(),
  ) {}
  // …
}

Two parameters with sensible defaults. Tests pass in their own store pointed at a temp directory. The CLI and server take the defaults.

Creating a brain from a folder

async createBrainFromFolder(input: CreateBrainFromFolderInput): Promise<Brain> {
  const folderPath = path.resolve(input.folderPath);
  const documents = await discoverTextDocuments(folderPath);

  return this.createBrainFromLocalDocuments(input.name.trim(), folderPath, documents);
}

The public method is short. The real work lives in the private helper:

private async createBrainFromLocalDocuments(
  name: string,
  folderPath: string,
  documents: LocalDocument[],
): Promise<Brain> {
  if (!name) throw new AppError("Brain name is required.");
  if (documents.length === 0) {
    throw new AppError("Choose a folder with supported text files.");
  }

  const now = new Date().toISOString();
  const brain: Brain = {
    id: randomUUID(),
    name,
    folderPath,
    workspace: `locallens-${slugify(name)}-${randomUUID().slice(0, 8)}`,
    status: "indexing",
    fileCount: documents.length,
    chunkCount: 0,
    createdAt: now,
    updatedAt: now,
  };

  await this.store.saveBrain(brain);

  try {
    const chunks = await chunkDocuments(documents, { brainId: brain.id });
    await this.store.saveChunks(brain.id, chunks);
    await this.qvac.ingestChunks(brain.workspace, chunks);

    const indexed = {
      ...brain,
      status: "indexed" as const,
      chunkCount: chunks.length,
      updatedAt: new Date().toISOString(),
      lastIndexedAt: new Date().toISOString(),
    };
    await this.store.saveBrain(indexed);
    return indexed;
  } catch (error) {
    const failed = {
      ...brain,
      status: "error" as const,
      updatedAt: new Date().toISOString(),
      lastError: toErrorMessage(error),
    };
    await this.store.saveBrain(failed);
    throw error;
  }
}

The shape:

  1. Validate. Empty name? Throw. No documents? Throw.
  2. Save with status indexing. Lets the UI show progress, and leaves a clear record if the run crashes.
  3. Chunk → save chunks → ingest. Three steps, in order.
  4. Save again with status indexed. Finalize.
  5. On error, save with status error and re-throw. The store keeps the failure visible to the UI.

This is the only place in the app where multi-step state transitions happen. That's deliberate — keeping the lifecycle in one method means there's exactly one place to read to understand it.

Asking a question

async askBrain(id: string, question: string): Promise<ChatAnswer> {
  const brain = await this.getBrain(id);
  const normalizedQuestion = question.trim();

  if (!normalizedQuestion) throw new AppError("Question is required.");
  if (brain.status !== "indexed") {
    throw new AppError("This brain is not ready. Remove it and create it again.", 409);
  }

  const hits = await this.qvac.search(brain.workspace, normalizedQuestion, 5);
  const history = buildGroundedHistory(normalizedQuestion, hits);
  let answer = "";

  for await (const token of this.qvac.answer(history)) answer += token;

  return {
    answer: answer.trim() || "This brain does not contain enough information to answer.",
    citations: hits.map(({ id: hitId, relativePath, chunkIndex, score }) => ({
      id: hitId,
      relativePath,
      chunkIndex,
      score,
    })),
  };
}

Three things to notice:

  • Status check. Asking a question of a brain that's still indexing or errored returns a 409. The UI uses this to keep the chat input disabled.
  • Citations are derived from hits. Each SearchHit gets mapped down to a Citation (no chunk content) before returning. That's the privacy boundary keeping chunk text from leaving the server.
  • Empty-answer fallback. If the model produces nothing, the caller gets a plain message instead of an empty string.

Browser path

The browser variant is a thin wrapper around the same private helper:

async createBrainFromDocuments(input: CreateBrainFromFilesInput): Promise<Brain> {
  const name = input.name.trim();
  const folderName = sanitizeFolderName(input.folderName);
  const documents = browserDocumentsFromInput(input.documents);

  return this.createBrainFromLocalDocuments(name, `browser://${folderName}`, documents);
}

It uses browser://<folderName> as the brain's folderPath, so you can tell where it came from without ever holding a real disk path the browser never gave us.

Deleting a brain

async deleteBrain(id: string): Promise<void> {
  const brain = await this.getBrain(id);
  await this.qvac.closeWorkspace(brain.workspace, true);
  await this.store.deleteBrain(id);
}

Two-phase, in order: close the QVAC workspace (deleting embeddings on disk), then drop the JSON entry. If QVAC throws, the JSON entry stays put — which is the right behaviour, because it leaves a visible record that something went wrong instead of a silent half-cleanup.

Shutdown

async close(): Promise<void> {
  await this.qvac.close();
}

That's all the workflow needs to expose. The store has nothing to close — a JSON file is fine when you just stop writing to it.

Where to add features

If your feature involves a new step in the brain lifecycle, add it here. If it's a new piece of data, add it to domain.ts and thread it through the relevant gateways. The workflow class is the only place where multiple modules are orchestrated.

Next: the CLI, the smallest possible consumer of this class.

On this page