Build a Python engine, CLI, and authenticated local API named openmind that creates local AI memory over user-approved folders and exposes stable product-level capabilities to client applications.
OpenMind Core must:
- Index local files from explicitly added folders.
- Extract text from supported file types.
- Normalize and chunk extracted text.
- Embed chunks with the configured local embedding provider.
- Store vectors and chunk metadata in LanceDB.
- Store sources, file records, and indexing state in SQLite.
- Search indexed chunks and return file path, score, and snippet.
- Ask questions by retrieving chunks and returning a source-grounded answer.
- Store all app data under
~/.openmindunlessOPENMIND_HOMEis set. - Use LM Studio as the first user-facing model server for chat, embeddings, and image descriptions.
- Provide first-run setup and background indexing progress.
- Expose a versioned API on loopback for local client applications.
- Require bearer authentication for private API operations.
openmind-core/
├── openmind/
│ ├── cli/
│ │ └── main.py
│ ├── api/
│ │ ├── app.py
│ │ ├── auth.py
│ │ ├── deps.py
│ │ ├── files.py
│ │ ├── schemas.py
│ │ └── routes/
│ │ ├── system.py
│ │ ├── models.py
│ │ ├── sources.py
│ │ ├── indexing.py
│ │ ├── memory.py
│ │ └── actions.py
│ ├── core/
│ │ ├── config.py
│ │ ├── engine.py
│ │ └── models.py
│ ├── sources/
│ │ ├── manager.py
│ │ └── scanner.py
│ ├── extractors/
│ │ ├── base.py
│ │ ├── text.py
│ │ ├── pdf.py
│ │ ├── ocr.py
│ │ ├── image.py
│ │ ├── docx.py
│ │ ├── code.py
│ │ ├── tabular.py
│ │ └── html.py
│ ├── ingestion/
│ │ ├── normalizer.py
│ │ └── chunker.py
│ ├── embeddings/
│ │ └── provider.py
│ ├── providers/
│ │ └── lmstudio/
│ │ ├── client.py
│ │ ├── llm.py
│ │ ├── embeddings.py
│ │ ├── images.py
│ │ ├── models.py
│ │ └── errors.py
│ ├── storage/
│ │ ├── sqlite_store.py
│ │ └── lance_store.py
│ ├── retrieval/
│ │ ├── search.py
│ │ └── context.py
│ └── llm/
│ └── answer.py
├── tests/
├── pyproject.toml
├── API.md
├── README.md
└── TECHNICAL_SPEC.md
Runtime:
typerrichquestionaryfastapiuvicornlancedbsentence-transformerspydanticpypdfpypdfium2pillowrapidocr-onnxruntimepython-docxbeautifulsoup4pandas
Development:
httpx2pytest
Dependency management:
uvpyproject.tomluv.lock
OpenMind supports two install paths:
conda activate openmind
uv pip install -e ".[dev]"or:
uv sync --all-extrasLater, not included yet:
fastapiuvicornwatchdogllama-cpp-pythonollama
Default:
~/.openmind/
├── config.toml
├── openmind.sqlite
├── lancedb/
└── logs/
Override for tests or local experiments:
OPENMIND_HOME=/tmp/openmind-dev openmind initCREATE TABLE sources (
id TEXT PRIMARY KEY,
path TEXT NOT NULL UNIQUE,
recursive INTEGER NOT NULL DEFAULT 1,
enabled INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL
);CREATE TABLE files (
id TEXT PRIMARY KEY,
source_id TEXT NOT NULL,
path TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
extension TEXT NOT NULL,
size INTEGER NOT NULL,
modified_at REAL NOT NULL,
content_hash TEXT NOT NULL,
status TEXT NOT NULL,
indexed_at TEXT,
error TEXT,
FOREIGN KEY(source_id) REFERENCES sources(id)
);CREATE TABLE index_runs (
id TEXT PRIMARY KEY,
started_at TEXT NOT NULL,
finished_at TEXT,
files_seen INTEGER NOT NULL DEFAULT 0,
files_indexed INTEGER NOT NULL DEFAULT 0,
files_skipped INTEGER NOT NULL DEFAULT 0,
errors INTEGER NOT NULL DEFAULT 0
);CREATE TABLE index_jobs (
id TEXT PRIMARY KEY,
status TEXT NOT NULL,
total_files INTEGER DEFAULT 0,
processed_files INTEGER DEFAULT 0,
indexed_files INTEGER DEFAULT 0,
skipped_files INTEGER DEFAULT 0,
already_indexed_files INTEGER DEFAULT 0,
failed_files INTEGER DEFAULT 0,
total_chunks INTEGER DEFAULT 0,
current_file TEXT,
error TEXT,
started_at TEXT,
completed_at TEXT,
updated_at TEXT
);Table: chunks
Columns:
id: stringsource_id: stringfile_id: stringpath: stringfile_name: stringextension: stringtitle: stringtext: stringvector: list[float]chunk_index: intcontent_hash: stringmodified_at: floatindexed_at: stringmetadata: string containing JSON
openmind setup
openmind init
openmind source add <path>
openmind source list
openmind source remove <id>
openmind index
openmind index start
openmind index status
openmind index pause
openmind index resume
openmind index stop
openmind models list
openmind models load
openmind models update
openmind provider status
openmind search "<query>" --limit 5
openmind ask "<question>" --limit 5
openmind status
openmind flush
openmind flush --yes --include-sources
openmind uninstall
openmind uninstall --yes --packageopenmind source remove <id> removes OpenMind's access to a source and unindexes its memory. It never modifies or deletes the original folder.
- Refuse removal while a background indexing job is unfinished.
- Mark the source disabled so concurrent foreground indexing cannot commit new records.
- Delete every LanceDB chunk with the source ID.
- Delete the source's SQLite file records and source record in one SQLite transaction.
- Report the file-record and chunk counts removed.
File indexing verifies that its source is still enabled when committing its SQLite record. If a source was disabled concurrently, any chunks produced by that indexing operation are deleted. During initialization, OpenMind also removes orphan file records and chunks left by source removals performed by older versions.
openmind setup must:
- Initialize
~/.openmindif needed. - Check LM Studio at
http://localhost:1234. - Display the OpenMind ASCII banner.
- Present an arrow-key provider selector with LM Studio as the only current option.
- Fetch
GET /api/v1/models. - Split models by
type:llmfor chat andembeddingfor embeddings. - Use arrow-key selectors for chat, embedding, and image-description models.
- Require one embedding model.
- Load selected models with
POST /api/v1/models/load. - Save config to
~/.openmind/config.toml. - Use a checkbox selector for folders, with a custom-folder option.
- Start background indexing.
- Tell the user to run
openmind index status.
[provider]
name = "lmstudio"
base_url = "http://localhost:1234"
api_token_env = "LM_API_TOKEN"
[models]
chat_model = "selected-chat-model-key"
embedding_model = "selected-embedding-model-key"
[indexing]
auto_start_after_setup = true
background = true
[extraction.ocr]
enabled = true
backend = "rapidocr"
min_text_chars_per_page = 80openmind uninstall removes OpenMind-owned local data from the configured app home.
It deletes:
config.tomlopenmind.sqlitelancedb/logs/- any other files under
~/.openmindorOPENMIND_HOME
It must not delete:
- user source folders
- LM Studio
- downloaded provider models
- the current Python environment unless
--packageis explicitly passed
Behavior:
- Show the resolved app home and files that will be removed.
- Refuse obviously unsafe app home paths such as
/, the user's home directory, or the current working directory. - Require confirmation unless
--yesis passed. - Support
--dry-run. - Request an active indexing job to stop before deletion.
- Delete the app home directory.
- If
--packageis passed, runpython -m pip uninstall -y openmind-corein the current Python environment. - If
--packageis not passed, tell the user how to remove the installed Python package separately.
openmind flush resets indexed memory and indexing state without uninstalling OpenMind.
It deletes:
- SQLite
filesrecords - SQLite
index_jobsrecords - SQLite
index_runsrecords - LanceDB vectors and chunks
- log files
It keeps by default:
config.toml- saved source folder records
- user source folders and files
- provider apps and downloaded models
- the installed Python package
If --include-sources is passed, it also deletes saved source folder records from SQLite. It still must not delete the actual folders or files that were indexed.
Behavior:
- Show the resolved app home and what will be removed.
- Show current counts for sources, file records, indexed files, index jobs, and index runs.
- Require confirmation unless
--yesis passed. - Support
--dry-run. - Request an active indexing job to stop and wait briefly before deleting state.
- Abort if the active indexing job does not stop.
- Clear SQLite index state.
- Reset the LanceDB directory.
- Clear log files.
- Tell the user to run
openmind index startto rebuild memory.
class Extractor:
def supports(self, path: str) -> bool: ...
def extract(self, path: str) -> ExtractedDocument: ...
class EmbeddingProvider:
@property
def dimension(self) -> int: ...
def embed(self, texts: list[str]) -> list[list[float]]: ...
class AnswerProvider:
def answer(self, question: str, context: list[SearchResult]) -> str: ...PDF extraction is two-stage:
- Extract embedded text with
pypdf. - Measure whether the text is usable.
- If text is empty, too sparse, or mostly unusual characters, try local OCR.
- Render PDF pages with
pypdfium2and OCR them with RapidOCR. - Continue the normal pipeline: normalize, chunk, embed, and store in LanceDB.
Default config:
[extraction.ocr]
enabled = true
backend = "rapidocr"
min_text_chars_per_page = 80OCR metadata is stored on chunks:
{
"file_type": "pdf",
"extraction_method": "ocr",
"ocr_engine": "rapidocr-onnxruntime+pypdfium2",
"ocr_used": true,
"page_count": 12
}If OCR dependencies are missing or OCR fails, extraction records ocr_error metadata. The indexer marks an otherwise empty PDF as skipped with that error and continues indexing other files. Optional ocrmypdf backend support remains available for users who install OCRmyPDF, Tesseract, and Ghostscript separately.
Standalone image files are converted to searchable text, then processed by the normal ingestion pipeline.
Image extraction flow:
- Keep the original image on disk.
- Read image metadata such as width, height, mode, format, file size, EXIF, and safe image info fields.
- Send the image plus an indexing prompt to the configured local model server endpoint.
- Generate a concise search-oriented image description.
- Run local OCR when available.
- Combine description, OCR text, and searchable metadata text.
- Normalize, chunk, embed, and store the resulting text in LanceDB.
OpenMind must not store raw image bytes in LanceDB.
Default config:
[extraction.images]
enabled = true
model = "ggml-org/SmolVLM-500M-Instruct-GGUF"
ocr_enabled = true
max_new_tokens = 220Image chunk metadata includes:
{
"file_type": "image",
"raw_image_stored": false,
"image_description_model": "ggml-org/SmolVLM-500M-Instruct-GGUF",
"image_ocr_used": true,
"image_width": 1200,
"image_height": 800,
"image_format": "JPEG",
"image_exif": {
"Make": "Example Camera"
}
}LM Studio is the current model server implementation for image descriptions. The first recommended vision model is ggml-org/SmolVLM-500M-Instruct-GGUF. Future providers should keep the same extractor interface and storage contract.
Image metadata must be JSON serializable before storage. Binary metadata fields such as ICC profiles are summarized, not stored as raw bytes.
LMStudioClient:
class LMStudioClient:
def health_check(self) -> bool: ...
def list_models(self) -> list[LMStudioModel]: ...
def load_model(self, model_key: str, context_length: int | None = None) -> dict: ...
def load_model_if_needed(
self,
model_key: str,
context_length: int | None = None,
) -> dict: ...
def chat(self, model: str, messages: list[dict]) -> LMStudioChatResult: ...
def respond_with_reasoning(
self,
model: str,
messages: list[dict],
effort: str = "medium",
) -> LMStudioChatResult: ...
def embed(self, model: str, texts: list[str]) -> list[list[float]]: ...Native REST API:
GET /api/v1/modelsPOST /api/v1/models/loadPOST /api/v1/models/unloadPOST /api/v1/chat
User-facing model loading must call GET /api/v1/models first and skip POST /api/v1/models/load when the selected model already has loaded instances.
OpenAI-compatible API:
POST /v1/chat/completionsPOST /v1/responsesPOST /v1/embeddings
Multimodal image descriptions also use POST /v1/chat/completions, with image bytes sent only to the local model server request as a data URL. Those bytes are not persisted by OpenMind.
Ask consumes message and reasoning events from the native chat endpoint. Reasoning is disabled by default and enabled only when --reasoning or API reasoning = true is explicitly requested. OpenMind maps the boolean to a reasoning setting supported by the selected model.
openmind models update re-runs provider and model selection after setup:
- Initialize OpenMind if needed.
- Ask for provider selection. LM Studio is the only current provider.
- Fetch
GET /api/v1/models. - Split models into chat, embedding, and vision/image-capable lists.
- Let the user choose a chat model, or search-only mode.
- Require one embedding model.
- Let the user choose an image description model or disable image indexing.
- Unless
--no-loadis passed, compute the previous OpenMind model keys that are absent from the new selection. - Resolve those models' loaded instance IDs with
GET /api/v1/modelsand unload each instance withPOST /api/v1/models/unload. - Save the selected keys to
~/.openmind/config.toml. - Load the selected models unless
--no-loadis passed.
Only models from OpenMind's previous configuration are eligible for automatic unloading. Unchanged selections and unrelated models loaded directly in LM Studio are not unloaded. When --no-load is used, OpenMind updates configuration without changing model-server memory.
openmind ask streams by default:
- normal Ask uses native
POST /api/v1/chatwithstream = true - interactive follow-ups use the previous native
response_id --reasoning/--no-reasoningcontrols model reasoning and displays native reasoning events when enabled--no-streamuses the previous full-response behavior- sources are appended after streaming finishes
Interactive ask:
openmind askwith no question starts an interactive chat session.- Session history is held in memory only.
- Every chat turn retrieves context using the current user question before calling the model.
- The LLM receives recent user/assistant messages plus fresh local file context for the current turn.
/clearresets session history./exitand/quitclose the session.
- Load enabled sources from SQLite.
- Discovery phase: scan each source recursively and count supported files.
- Ignore unsupported files and noisy folders.
- Indexing phase: compare path, size, and modified time against SQLite records.
- Skip unchanged files that were already indexed, and count them as already indexed.
- If metadata changed, compute content hash.
- If content hash is unchanged, update file metadata and keep existing chunks.
- If content hash changed or the file is new, extract text with the matching extractor.
- Normalize text.
- Split text into chunks.
- Embed chunks with the selected LM Studio embedding model.
- Delete old vectors for the file from LanceDB.
- Store new chunks in LanceDB.
- Upsert file status in SQLite.
- Update
index_jobsprogress after each file, including the already-indexed count.
OpenMind should tell the user when unchanged files are already indexed and accessible. This is separate from generic skipped files, because skipped files may also include files where no text could be extracted.
Discovery should be metadata-first. It should not compute content hashes for every discovered file before indexing starts, because that makes large folders appear stuck and wastes work for unchanged files.
Default scanning is document-first plus supported images. OpenMind should index human-facing files such as .txt, .md, .pdf, .docx, .csv, .html, .png, .jpg, .jpeg, .webp, .bmp, .tif, and .tiff. It should not index source code, JSON config/package files, generated build artifacts, app asset catalogs such as Assets.xcassets, dependency folders, or other low-level project internals unless a future opt-in code indexing mode is added.
- Embed the query.
- Search LanceDB
chunks. - Return top results with score, file path, title, chunk text snippet, and metadata.
Search requires an embedding provider. Normal setup uses LM Studio embeddings.
- Run the search flow for the question.
- Build a compact context from retrieved chunks.
- If an LM Studio chat model is configured, generate an answer grounded only in context.
- If no answer provider is configured, return retrieved snippets without embedding source paths in the answer.
- Return the answer as GitHub-flavored Markdown.
- Keep generated answer text separate from structured retrieval sources.
- Append deduplicated
file://source links only in the CLI presentation layer.
Interactive CLI and API chat use LM Studio's native stateful POST /api/v1/chat endpoint. The first turn stores the provider conversation and captures its response_id; follow-ups run a fresh vector search, then send the current question, that turn's retrieved evidence, and previous_response_id. One-shot CLI Ask remains stateless.
The synchronous API identifies Ask output with format = "markdown", returns an opaque OpenMind session_id, and accepts that ID on follow-ups. The streaming API emits the session ID and Markdown format in its initial meta event. reasoning defaults to false and controls whether model reasoning is generated and returned. Sources remain available only through the structured sources field or SSE event, not appended to generated API text. Search output is unchanged.
openmind index start creates an index_jobs row and starts:
openmind index worker --job-id <id>as a background subprocess.
The worker accepts --job-id and writes stdout/stderr to:
~/.openmind/logs/index-<job-id>.log
If a job remains pending for more than 30 seconds, a later openmind index start marks it failed and creates a new job.
Pause behavior:
openmind index pausesetspause_requested.- The worker finishes the current file, then changes the state to
paused. - The worker remains paused until
openmind index resumechanges the state back torunning. openmind index stopcan stop a paused or running worker.
openmind index status reads SQLite and displays a live Rich table until the user exits with Ctrl-C.
openmind index status --once prints one snapshot and exits.
The status table reports:
- state
- total files
- processed files
- indexed files
- skipped files
- failed files
- chunks created
- progress percentage
- current file
Progress formula:
processed_files / total_files * 100
No Celery, Redis, external queue, or daemon manager is included.
Search and ask must catch provider errors and print concise CLI messages instead of Python tracebacks.
For embedding requests, OpenMind uses LM Studio's OpenAI-compatible POST /v1/embeddings endpoint and normalizes newlines to spaces before sending input text.
OpenMind writes structured JSONL logs to:
~/.openmind/logs/openmind.log
Index worker stdout/stderr is written to:
~/.openmind/logs/index-<job-id>.log
CLI:
openmind dev logs
openmind dev logs --no-follow --lines 40
openmind dev logs --log all
openmind dev logs --lm-studio--lm-studio runs lms log stream, matching LM Studio's own development guidance for inspecting model input.
openmind serve starts a single-process FastAPI application at 127.0.0.1:8765. The CLI does not expose a host option; remote network binding is outside the current security model.
The public liveness route is:
GET /health
All product routes are versioned under /api/v1 and require:
Authorization: Bearer <token>The API token is generated with Python's secrets module, stored at ~/.openmind/api_token, restricted to mode 0600 on POSIX platforms, and compared with secrets.compare_digest. The server reads the current token for authenticated requests so rotation takes effect without a restart. Token values must not be written to OpenMind or Uvicorn access logs.
Protected capabilities:
- system and indexing status
- provider status and model discovery
- validated model selection and loading
- source listing, addition, and removal
- background indexing start, pause, resume, status, and stop
- search with structured source records
- stateful synchronous and server-sent-event Ask, including structured source events
- opt-in model reasoning for API clients
- indexed file and chunk details
- opening an indexed file in its default operating-system application
The open-file action accepts only a generated file ID. Before launching an OS application, OpenMind must verify that the file record is indexed, the file still exists, and its fully resolved path remains beneath an enabled source directory.
API schemas reject unknown request fields. Queries, questions, paths, model-key lists, result limits, and file IDs are bounded and validated. Browser CORS is disabled by default. --allow-origin accepts exact HTTP or HTTPS origins and refuses wildcard, credential-bearing, path-bearing, query-bearing, or fragment-bearing values.
The API must not expose:
- API token values in responses other than the explicit CLI token command
- arbitrary local paths for open or read actions
- raw files or raw image bytes
- embedding vectors
- raw SQLite or LanceDB operations
- manual chunk insertion
- manual embedding or extractor operations
- shell command execution
FastAPI lifespan initializes one shared OpenMindEngine before requests are accepted. Route handlers call engine capabilities rather than reaching around the engine into database implementation details, except read-only indexed document lookup needed to expose sanitized chunk text.
The client contract and examples are documented in API.md.
The API keeps one shared engine instance, but the CLI can update config.toml from another process. Configuration saves use an atomic same-directory file replacement. Before each authenticated API request, the engine fingerprints the complete config snapshot and reloads only when it changed. Reloading rebuilds the chat, embedding, image-description, extractor, and provider clients together so status and inference use one consistent configuration.
The first acceptable build must prove:
openmind initcreates app data directories and SQLite tables.openmind source add <path>records a user-approved source.openmind source listshows recorded sources.- Removing a source deletes only its SQLite records and LanceDB chunks while preserving user files.
- Source removal cannot race an unfinished background indexing job.
- Initialization cleans indexed data orphaned by legacy source removal behavior.
- Scanner finds supported files and ignores noisy folders.
- Extractors turn supported test files into text.
- Image extractor stores generated descriptions/OCR text, not raw image bytes.
- Chunker creates overlapping chunks with stable source metadata.
- SQLite file records can be inserted and updated.
- Search service can return ranked results with a fake embedding provider and fake vector store.
- Ask mode returns source-grounded context if no LLM provider is configured.
- LM Studio client can list and load models with mocked API responses.
- Config can save and load selected provider/model settings.
- SQLite can create and update indexing job status.
- LM Studio ask returns a clear message when the server is unreachable.
- Public health works without a token while all
/api/v1routes reject missing or invalid tokens. - The OpenAPI schema declares bearer security for private routes.
- API token files use private permissions and can be rotated.
- The server CLI always binds Uvicorn to
127.0.0.1. - Wildcard CORS and malformed request bodies are rejected.
- Search, Ask, streaming Ask, source management, model selection, and indexing controls work through the API.
- Document lookup omits vectors.
- Open-file actions reject files outside enabled source folders.