Skip to content

Architecture

loci is a local-first context persistence system. Every component runs on your machine. Nothing is transmitted to external servers.


Component map

┌─────────────────────────────────────────────────────────────┐
│                    Your machine                              │
│                                                              │
│  ┌──────────────────┐     ┌─────────────────────────────┐   │
│  │  Browser          │────▶│  Tauri desktop app           │   │
│  │  Extension        │     │  (Scholar / Wizard UI)       │   │
│  │  (Chrome MV3)     │     └──────────┬──────────────────┘   │
│  │                   │                │                       │
│  │  · Reads DOM of   │     ┌──────────▼──────────────────┐   │
│  │    Claude.ai      │     │  ~/.loci/                    │   │
│  │    ChatGPT        │     │                              │   │
│  │  · MutationObs.   │     │  config.json                 │   │
│  │  · Local index    │     │  index/search.json           │   │
│  └──────────────────┘     │  index/metadata.db           │   │
│                            │  rooms/{dev,design,...}      │   │
│  ┌──────────────────┐     │  loci/*.locus                 │   │
│  │  MCP server       │◀────┤                              │   │
│  │  (Rust, :3721)    │     └──────────────────────────────┘   │
│  │                   │                                        │
│  │  Exposes rooms    │                                        │
│  │  as MCP tools     │                                        │
│  │  to IDE / agent   │                                        │
│  └──────────────────┘                                        │
└─────────────────────────────────────────────────────────────┘

  ▲                    ▲
  │                    │
Claude Code          Cursor / Zed / any MCP client

Data flow diagram

::: mermaid graph LR A[Claude.ai / ChatGPT] -->|MutationObserver| B[Content Script] B -->|chrome.runtime.sendMessage| C[Service Worker] C -->|IndexedDB write| D[(IndexedDB
Raw Content)] C -->|MiniSearch.add| E[MiniSearch Index] E -->|toJSON| F[(chrome.storage.local
Serialized Index)] F -->|loadJSON on query| E C -->|search results| G[⌘K Overlay] C -->|search results| H[Side Panel] D -->|on sync| I[~/.loci/
SQLite] I -->|room context| J[MCP Server] J -->|tools| K[Claude Code / Cursor] :::


Data flow

Collection (browser extension)

  1. Extension content script attaches MutationObserver to conversation container
  2. On each new assistant message: extract turn, append to local conversation record
  3. Conversation record written to IndexedDB in the extension's origin
  4. MiniSearch index updated incrementally, serialised to chrome.storage.local
  1. User opens ⌘K overlay (extension) or Tauri app search
  2. Query passed to MiniSearch loadJSON() instance
  3. Results ranked by relevance score + recency weight
  4. Results display: conversation title, platform, date, matched excerpt

Room assignment (Wizard+)

  1. User drags conversation to a room, or assigns via right-click
  2. Room assignment stored in metadata.db
  3. Room context file (rooms/{name}/context.md) auto-updated with conversation summary
  4. MCP server serves updated room context on next tool call

Data at rest

loci stores data in three distinct locations, each with a specific purpose.

LocationWhatFormatSize (typical)Encryption
IndexedDB (extension)Raw conversation contentStructured objects50-200 MBNone (origin-isolated)
chrome.storage.localSerialized MiniSearch indexJSON blob5-15 MBNone (extension-private)
~/.loci/Metadata, rooms, loci, configSQLite + Markdown + JSON10-50 MBOS-level only

Why three locations:

  • IndexedDB holds the full conversation text. It is the source of truth for content. Origin isolation means no other extension or website can access it.
  • chrome.storage.local holds the search index. The unlimitedStorage permission lifts the 10 MB cap. This is the only way to persist a MiniSearch instance across service worker restarts.
  • ~/.loci/ holds everything the desktop app and MCP server need. SQLite provides relational queries (room membership, tags, date ranges). Markdown files provide human-readable context.

Encryption status: loci does not currently encrypt data at rest beyond what your OS provides (FileVault, BitLocker, LUKS). This is intentional: encryption adds key management complexity and breaks plain-text searchability. A future config.json flag ("encryption": true) will enable optional encryption for the ~/.loci/ directory using OS keychain-stored keys.

WARNING

If you work with sensitive material and your disk is unencrypted, enable OS-level full-disk encryption before using loci.


MV3 service worker lifecycle

Chrome's Manifest V3 forces extensions to use service workers instead of persistent background pages. Service workers terminate after 30 seconds of inactivity. This breaks any in-memory state, including search indexes.

loci solves this with aggressive serialization:

javascript
// service-worker.js: simplified

let searchIndex = null;

// Lazy-load the index only when needed
async function getSearchIndex() {
  if (searchIndex) return searchIndex;

  // Service worker restarted: reload from storage
  const { indexData } = await chrome.storage.local.get('indexData');
  if (indexData) {
    searchIndex = MiniSearch.loadJSON(indexData, {
      fields: ['title', 'content'],
      storeFields: ['title', 'url', 'platform', 'date']
    });
  } else {
    // First run: create empty index
    searchIndex = new MiniSearch({
      fields: ['title', 'content'],
      storeFields: ['title', 'url', 'platform', 'date']
    });
  }
  return searchIndex;
}

// On every mutation: update index AND persist immediately
async function handleNewTurn(conversation) {
  const index = await getSearchIndex();

  // Remove old version if exists, add new version
  if (index.has(conversation.id)) {
    index.discard(conversation.id);
  }
  index.add(conversation);

  // Serialize immediately: service worker may die any moment
  await chrome.storage.local.set({
    indexData: JSON.stringify(index)
  });
}

// Search always works, even after restart
async function search(query) {
  const index = await getSearchIndex();
  return index.search(query, {
    prefix: true,
    fuzzy: 0.2,
    boost: { title: 2 }
  });
}

Key decisions:

  • loadJSON() on every search is fast enough (< 50ms for 10k documents)
  • toJSON() on every update is acceptable because updates are infrequent (once per assistant message)
  • The index is never held solely in memory: always backed by storage

TIP

If you're debugging the extension and the search seems stale, the service worker likely restarted and is loading from storage. Check chrome://extensions → Inspect views → Service worker to verify it's running.


Why MiniSearch over alternatives

LibraryFuzzyPrefixSize (gzip)SerializationMV3-safeVerdict
MiniSearch 7.xYesYes6 KBtoJSON() / loadJSON()YesWinner
Fuse.jsYesNo5 KBManualPartialNo prefix = bad UX
Lunr.jsNoYes8 KBManual (complex)NoNo fuzzy, complex serialization
Manual inverted indexNoYes0 KBCustomYesNo fuzzy, maintenance burden
FlexSearchYesYes22 KBPartialNoLarger, serialization issues

MiniSearch wins because:

  1. Prefix search: typing "auth" matches "authentication" immediately. Essential for ⌘K search-as-you-type.
  2. Fuzzy matching: typing "authentcation" (typo) still matches "authentication".
  3. Native serialization: toJSON() produces a string, loadJSON() reconstitutes the index. No custom code.
  4. Size: 6 KB gzipped. Smaller than Fuse.js's fuzzy implementation despite doing more.
  5. MV3-compatible: service worker can die and restart; we just reload from storage.

What we lose:

  • No stemming out of the box (configurable, but adds size)
  • No field-level weighting in serialized form (we reapply on load)
  • No incremental index updates to storage (we serialize the whole thing)

These tradeoffs are acceptable for conversation search where documents are small and updates are infrequent.


Tech stack decisions

Search: MiniSearch 7.x

See comparison table above.

Storage: IndexedDB + chrome.storage.local

  • IndexedDB: raw conversation content (~50 MB for a heavy user). Origin-isolated, survives extension restarts.
  • chrome.storage.local with unlimitedStorage permission: serialised search index (~10 MB for heavy user).
  • ~/.loci/index/metadata.db: SQLite via Tauri app for richer queries (filter by platform, date, room, tag).

Desktop: Tauri v2

  • Rust backend: file system access, OS keychain (API keys), MCP server, native messaging with extension
  • WebView frontend: same design system as landing page (data-theme switching)
  • Universal binary on Mac (arm64 + x86_64), NSIS installer on Windows

MCP server: Rust (Wizard tier)

  • Runs on localhost:3721
  • Exposes each configured room as a named MCP tool
  • Room context (rooms/{name}/context.md) served as tool description + content
  • Clients: Claude Code, Cursor, Zed, any MCP-compatible IDE

Security model

ConcernDecision
Extension reads page DOMIsolated content script world. Not subject to page CSP. Shadow DOM for all injected UI.
IndexedDB access by other extensionsOrigin-isolated to chrome-extension://{extension-id}/. Inaccessible to other extensions.
API keys storageOS keychain via keyring Rust crate. Never written to config.json.
Data at restPlaintext in ~/.loci/. User's OS-level encryption (FileVault / BitLocker) applies. Future: optional encryption flag in config.
Network callsNone from core index path. LLM provider calls are opt-in (Wizard tier). Extension makes zero network calls.
Chrome Web StoreStandard MV3 permissions. Privacy policy required pre-submission: "no data leaves device."

Full ~/.loci/ directory tree

~/.loci/
├── config.json                    # Master config: version, tier, LLM settings, MCP port
├── index/
│   ├── search.json                # Serialized MiniSearch index (synced from extension)
│   └── metadata.db                # SQLite: conversations, rooms, tags, timestamps
├── rooms/
│   ├── great-hall/
│   │   ├── context.md             # Room context: auto-generated from conversation summaries
│   │   └── .room.json             # Room metadata: name, color, icon, tools, created_at
│   ├── dev/
│   │   ├── context.md
│   │   ├── .room.json
│   │   └── CLAUDE.md              # Optional: soul file for this room (MCP-served)
│   ├── design/
│   │   ├── context.md
│   │   └── .room.json
│   ├── research/
│   │   ├── context.md
│   │   └── .room.json
│   ├── hatchery/
│   │   ├── context.md
│   │   └── .room.json
│   └── garden/
│       ├── context.md
│       └── .room.json
├── loci/
│   ├── kafka-vs-sqs.locus         # Crystallized insight: architecture decision
│   ├── privacy-receipt.locus      # Crystallized insight: UX pattern
│   └── trust-protocol.locus       # Crystallized insight: research finding
├── logs/
│   ├── session.log                # Current session log (rotated daily)
│   └── archive/
│       └── 2026-05-03.log         # Archived session logs
├── backups/
│   └── metadata-2026-05-03.db     # Auto-backup of SQLite (weekly)
└── .loci-lock                     # Lock file: prevents multiple MCP server instances

File descriptions

FilePurpose
config.jsonMaster configuration. Stores version, tier (Scholar/Wizard/LLMAGE), LLM provider settings, MCP server port, sync preferences.
index/search.jsonMiniSearch index serialized to JSON. Synced from extension via native messaging. Enables desktop app search without extension running.
index/metadata.dbSQLite database. Schema: conversations, rooms, tags, conversation_tags, loci. Enables complex queries the MiniSearch index cannot handle.
rooms/{name}/context.mdAuto-generated room context. Contains: recent conversation summaries, linked loci, active threads. Served by MCP as tool description.
rooms/{name}/.room.jsonRoom metadata. See .room.json schema.
rooms/{name}/CLAUDE.mdOptional soul file. If present, MCP server includes this in room context. Use for persistent instructions, crystals, garden definitions.
loci/{slug}.locusCrystallized insight. Markdown with YAML frontmatter. See .locus file format.
logs/session.logDebug log. Rotates daily. Contains: MCP requests, sync events, errors.
.loci-lockPID lock file. Prevents multiple MCP server instances binding to the same port.

File structure reference

~/.loci/
├── config.json              # Master config (version, tier, llm, mcp)
├── index/
│   ├── search.json          # Serialised MiniSearch index
│   └── metadata.db          # SQLite: id, platform, url, title, room_id, tags, timestamps
├── rooms/
│   ├── dev/
│   │   ├── context.md       # Room context: served by MCP server
│   │   └── .room.json       # Room metadata (name, color, tools, created_at)
│   ├── design/
│   ├── research/
│   ├── hatchery/
│   └── garden/
├── loci/
│   └── {slug}.locus         # Crystallised insight nodes (Markdown + frontmatter)
└── logs/
    └── session.log

.locus file format

markdown
---
id: "locus-20260503-privacy-receipt"
created: "2026-05-03T14:22:00Z"
room: "design"
tags: ["ux", "privacy", "patterns"]
source_conversation: "https://claude.ai/chat/abc123"
---

# The Privacy Receipt

A privacy action should feel like a receipt, not a dashboard.
One number. One moment. Swipe to dismiss.

The metaphor: you did something, here's the record. Not: here's everything running.

Built by Hux × Vesper · Apache 2.0