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
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:
- Validate. Empty name? Throw. No documents? Throw.
- Save with status
indexing. Lets the UI show progress, and leaves a clear record if the run crashes. - Chunk → save chunks → ingest. Three steps, in order.
- Save again with status
indexed. Finalize. - On error, save with status
errorand 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
SearchHitgets mapped down to aCitation(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.