Architecture
ThreadShelf is intentionally small: one Node.js server, reusable core modules, local embedding, local vector storage, and a React browser UI.
Conversation generation is an optional layer on top of the archive. The llama.cpp path is local; the explicitly selected OpenRouter path is external.
High-Level Flow
Export folder
-> src/ingest.ts
-> src/parser.ts
-> src/chunking.ts
-> src/embedding.ts
-> src/store.ts
-> LanceDB
-> src/server.ts API
-> public/ UI
-> mcp/server.ts
Indexed thread -> generation provider registry -> llama.cpp loopback OR OpenRouter
Created chat -> internal __threads namespace -> generation provider -> persisted exchange
Connected Obsidian vault -> safe live Markdown search/read -> UI / API / MCP / native chat tools
Native chat tool call -> write policy + vault boundary -> create/edit OR browser-confirmed trash move
Modules
src/obsidian/ and src/generation/obsidian-agent.ts
- Keep the user’s vault as ordinary Markdown files, separate from chat collections.
- Store only the vault path and write policy in the local Obsidian configuration.
- Search live note text/paths, with bounded scans and no embeddings or reimport step.
- Validate relative Markdown paths, reject hidden paths and links, create exclusively, and check revisions before atomic edits. A directory lock excludes other ThreadShelf writers, including a separate MCP process.
- Run bounded native tool rounds through the same generation providers; preserve opaque provider reasoning state in memory within those rounds, never in the archive.
- Keep deletion capabilities in server memory and emit them only to the browser. Approval checks the explicit checkbox, configuration and note revisions before moving files to vault trash. MCP has no deletion or approval capability.
- Apply the saved write policy on each operation. The UI policy-only PATCH preserves the current vault even when another browser tab has an older configuration cached.
src/server.ts
- Express app.
- Serves the web UI.
- Exposes collection, ingest, search, thread, upload, stats, and health routes.
- Mounts HTTP routes from
src/routes/. - Mounts the OpenAI/Anthropic-compatible local model API at
/v1(src/routes/local-api.ts) ahead of the app-wide 512 KB JSON parser, since inference bodies carry whole conversations. - Starts the optional network listener (
src/local-api-network.ts): a second port bound to all interfaces that serves the same/v1router and nothing else, so sharing models never exposes the archive or/api.
src/routes/
- Own HTTP-specific validation, upload handling, and NDJSON progress streams.
- Delegate reusable behavior to
src/services/andsrc/store.ts. - Wrap every long download in
abortOnDisconnect(stream-abort.ts), which listens for bothreq’sabortedandres’sclose— a browser cancelling afetchonly fires the latter, and missing it leaves the server downloading gigabytes after the user pressed Cancel.
src/services/
- Shared business logic used by HTTP routes and MCP.
- Keeps collection, search, and thread loading behavior consistent across surfaces.
src/ingest.ts
- Finds likely export files recursively.
- Skips known metadata/account files.
- Parses files, chunks turns, embeds chunks, and writes rows to storage.
- Reimport merges local continuations into matching conversations; missing keys retain their locally continued branch. Rewritten positional keys fork safely.
- Files that parse to zero conversations are skipped (reported in
skippedFiles) and never remove archived rows, including in watch mode. clearFirststages the complete folder before publication. Invalid or empty files, embedding failures, or cancellation before the archive commit preserve the previous collection and reportreplacementSkipped; staging requires memory proportional to the folder. Progress events carry per-batchembeddingDone/embeddingTotalandprogressPercent.
Archive/index recovery (src/store.ts):
- Replacements use atomic LanceDB merges, rather than separate delete/add calls.
- Archive turns and their
indexPendingjob commit together. The index is a recoverable projection:localrefreshes locally authored chunks,allrebuilds a file,deleteremoves a tombstone, andresetrebuilds a collection. - Embedding runs outside the global
__threadslock. Writers take a snapshot, embed, then re-read the scope under the lock and commit only if it is unchanged (up to 3 attempts), so imports never block unrelated chat saves. - A disconnect after archive publication does not interrupt the index commit. An index failure retains the durable job with
indexAttempts,indexRetryAtandindexError; workers in the HTTP server, MCP server and CLIs retry with 15 s–1 h backoff and pause after 8 failures until the thread is edited. Undecodable rows are markedinvalid, keep their raw data, and are excluded from search without hiding healthy conversations. Full replacements stay out of search until the matching vectors are ready, avoiding stale turn offsets. - The LanceDB connection uses
readConsistencyInterval: 0, so cached table handles observe commits from other processes. - Deletion and recovery acquire the collection lock before the thread-table lock. Deletion rejects an active chat generation with 409. Tombstones contain no turn text and are removed after the vectors are deleted; retries cannot recreate a deleted chat from an old in-memory snapshot.
test/archive-recovery.test.jsinjects write failures, races deletion with an in-flight index, and kills a child process between archive/index commits.
src/parser.ts
- Detects provider format.
- Converts provider-specific JSON into normalized turns, including Google AI Studio text chats and Imagen prompt histories.
- Supported providers: Google AI Studio, OpenRouter, LM Studio, Grok/xAI, ChatGPT/OpenAI, and Claude/Anthropic.
src/chunking.ts
- Splits long turn text into searchable chunks.
- Preserves
sourceFile,role, andturnIndexmetadata.
src/embedding.ts
- Loads the local multilingual Xenova embedding model.
- Produces vectors for chunks and search queries.
src/store.ts
- Manages LanceDB tables as collections.
- Writes chunk rows.
- Runs vector search.
- Applies search filters to the whole collection, not to a candidate window: readiness, role, origin and date are LanceDB prefilters, and the model filter adds a
LIKEprefilter before the exact portable-label check. If that check (or dropping unsearchable chunks) leaves fewer thannresults, the vector window doubles up toMAX_VECTOR_SEARCH_WINDOWcandidates. Keyword search streams every match in Arrow batches and keeps a running topn. - Lists files and collection stats.
- Persists normalized conversation turns in the internal
__threadstable at ingest time, so the thread view and the conversation listing keep working after a source file is moved, rewritten, or deleted. Table names starting with__are internal and never listed as collections.
src/validation.ts
- Normalizes and validates API inputs.
- Keeps route behavior predictable and testable.
src/generation/
- Defines the plugin contract shared by generation providers.
- Discovers current GGUF files under default and user-configured roots.
- Manages one loopback-only
llama-serverprocess for the selected model. - Wraps the OpenAI-compatible llama.cpp and OpenRouter chat APIs.
- Maps public model ids to GGUF files for the
/v1local model API and loads them under the same model lease as UI chats (local-api.ts). - Stops the previous
llama-serverand waits for it to exit before spawning another model, and optionally unloads an idle model afteridleUnloadMinutes(llama-process.ts). - Converts provider SSE into redacted NDJSON progress and token events for the UI.
- Reports whether a GGUF is discovered, loading, or active in the managed runtime.
- Maps validated CPU/GPU/hybrid/multi-GPU profiles to upstream llama.cpp flags.
- Provides a loopback-only, directory-only filesystem browser for model roots.
- Keeps OpenRouter API keys in environment/session memory, never persisted config.
- Contains the explicit-consent llama.cpp release installer primitives, including the
nightly-tag.txtresolution that finds the release actually carrying binaries (llama-install.ts). - Owns created-chat lifecycle and per-thread generation leases in
src/generation/threads.ts. - Browses the public Hugging Face GGUF catalog read-only, without a token, and detects gated repositories up front (
model-catalog.ts). - Plans and fetches catalog models into
downloadDirectory, verifying each file against its LFSoidbefore moving it into place (model-download.ts). - Shares one resumable, hash-verifying downloader between runtime archives and models; a cancelled transfer keeps its
.partfile, any other failure deletes it (downloader.ts). - Detects accelerators and RAM and turns a byte size into a single fits/tight/too-large verdict used by every surface (
hardware.ts). - Builds the one-screen setup plan, fingerprints exactly what it would fetch, and runs it (
quick-setup.ts). The plan is always rebuilt server-side and matched against the approved fingerprint, so a client can never hand the server a URL to fetch and execute.
mcp/server.ts
- Exposes indexed local data to MCP clients over stdio.
- Reuses the same service layer as the web app.
client/src/
- React frontend built with Vite into
public/. - Handles collection selection, ingest, upload, search, role filters, file tree, thread reader, and the persistent New chat workspace.
Data Model
Parser output is normalized into turns shaped like:
{
user: '...',
thinking: '...',
ai: '...',
model: 'optional-model-name'
}
Not every turn has every role. A user-only or assistant-only message is valid.
Chunk rows stored in LanceDB include:
- Vector embedding.
- Text.
- Role:
user,thinking, orai. - Source file.
- Turn index.
- Collection name.
- Model metadata when available.
Collections
Each collection maps to a LanceDB table. A shared internal __threads table additionally stores the normalized turns of every ingested conversation, keyed by (collection, sourceFile, conversationKey); it is replaced per file on re-ingest and cleared alongside its collection. Collections are used to isolate archives:
- One provider per collection.
- One project per collection.
- One time range per collection.
- Throwaway collections for real-data testing.
The special all selector is not a table. It searches across available collections.
Chats created inside ThreadShelf share the same normalized turn representation and use the dedicated threadshelf_conversations collection plus __threads. Their rows and turns set createdInThreadShelf=true and retain creation time, generation backend, and model metadata. They never pretend to be source export files. Imported threads keep thread-level archive provenance while appended ThreadShelf turns carry per-turn provenance. After a complete response, the API updates normalized storage and idempotently replaces only ThreadShelf-authored search chunks. Generation is serialized per thread.
Uploads
Browser folder uploads are stored under .uploads/ by default. The path can be changed with:
UPLOADS_DIR=path/to/uploads npm start
On Windows PowerShell:
$env:UPLOADS_DIR='D:\threadshelf-uploads'
npm start
Upload paths are normalized and checked to reduce path traversal risk. Incoming multipart files are staged on disk rather than buffered fully in memory. Deleting a collection removes its LanceDB table and copies stored below .uploads/<collection>; it never deletes files from the original folder chosen by the user.
Embeddings
The app uses:
Xenova/paraphrase-multilingual-MiniLM-L12-v2
This is a local multilingual model. The first run may be slower because model files need to download/cache.
API Surface
Core routes:
GET /api/healthGET /api/collectionsPOST /api/collectionsDELETE /api/collections/:namePOST /api/collections/:name/clearGET /api/collections/:name/statsGET /api/filesGET /api/ingest-previewPOST /api/ingest-progressPOST /api/ingest-uploadGET /api/searchGET /api/threadGET /api/generation/config(loopback only)PUT /api/generation/config(loopback only)GET /api/generation/models(loopback only)GET /api/generation/runtime(redacted runtime status)GET /api/generation/runtime/logs/POST /api/generation/runtime/eject(loopback only)GET /api/generation/directories(loopback only)/api/generation/promptsCRUD and active selection (loopback only)/api/generation/threadslist/create/get/rename/delete (loopback only)POST /api/generation/chat(loopback only)POST /api/generation/chat/stream(loopback-only NDJSON)GET /api/generation/hardware(loopback only)GET /api/generation/catalog/search/GET /api/generation/catalog/model(loopback only)POST /api/generation/catalog/download(loopback-only NDJSON, cancellable)GET /api/generation/setup/plan(loopback only)POST /api/generation/setup/run(loopback-only NDJSON; needsconfirmplus the approvedfingerprint)
Local model API (loopback only unless network access is on; optional API key; see LOCAL_API.md):
GET /v1/models,GET /v1/models/{id}POST /v1/chat/completions,POST /v1/completions,POST /v1/responses(OpenAI)POST /v1/messages,POST /v1/messages/count_tokens(Anthropic)POST /v1/models/unload(ThreadShelf: free the loaded model’s memory)GET /api/generation/local-api(settings UI, loopback only): model list and network listener status, without needing the API key
Testing Layers
Unit/regression:
npm test
API/LanceDB/MCP integration:
npm run test:e2e
Browser workflows:
npm run test:playwright
The full npm run check gate also verifies repository privacy hygiene, documentation links, lint, TypeScript, and the production client build.
The optional synthetic search-quality benchmark is documented in SEARCH_BENCHMARK.md. Run it with npm run bench:search -- --check; it uses a temporary database and is not part of the standard check gate.
Extension Points
Add a generation provider:
- Implement
GenerationProviderundersrc/generation/providers/. - Register it in
src/generation/registry.ts. - Keep model discovery dynamic and declare whether requests are local/external.
- Add provider contract tests and an API E2E stub; never call a live paid API in tests.
Add a provider:
- Add format detection and parser logic in
src/parser.ts. - Add a synthetic fixture under
test/fixtures/. - Add parser tests.
- Add an ingest or E2E case if the structure is unusual.
Improve search:
- Add store-level behavior in
src/store.ts. - Keep API response shape backward-compatible when possible.
- Add tests for ranking, role filters, and cross-collection behavior.
Improve UI:
- Update the React components and styles under
client/src/. - Add or update Playwright tests.
- Verify empty states, role filters, collection switching, and modal behavior.