Files
nexusAI/docs/services/memory-service.md
2026-04-17 03:46:17 -07:00

5.9 KiB

Memory Service

Package: @nexusai/memory-service
Location: packages/memory-service
Deployed on: Mini PC 1 (192.168.0.81)
Port: 3002

Purpose

Responsible for all reading and writing of long-term memory. Acts as the sole interface to both SQLite and Qdrant — no other service accesses these stores directly. On episode creation, automatically calls the embedding service to generate and store a vector in Qdrant.

Dependencies

  • express — HTTP API
  • better-sqlite3 — SQLite driver
  • @qdrant/js-client-rest — Qdrant vector store client
  • dotenv — environment variable loading
  • @nexusai/shared — shared utilities and constants

Environment Variables

Variable Required Default Description
PORT No 3002 Port to listen on
SQLITE_PATH Yes Path to SQLite database file
QDRANT_URL No http://localhost:6333 Qdrant instance URL
EMBEDDING_SERVICE_URL No http://localhost:3003 Embedding service URL

Internal Structure

src/
├── db/
│   ├── index.js       # SQLite connection + initialization + migrations
│   ├── schema.js      # Table definitions, indexes, FTS5, triggers
│   └── projects.js    # Project CRUD functions
├── episodic/
│   └── index.js       # Session + episode CRUD, FTS search, embedding write path
├── semantic/
│   └── index.js       # Qdrant collection management, upsert, search, delete
├── entities/
│   └── index.js       # Entity + relationship CRUD
└── index.js           # Express app + all route definitions

SQLite Schema

Six core tables:

  • sessions — top-level conversation containers. Fields: external_id, name, project_id, metadata
  • episodes — individual exchanges (user message + AI response) tied to a session
  • entities — named things the system learns about (people, places, concepts)
  • relationships — directional labeled links between entities
  • summaries — condensed episode groups for efficient context retrieval
  • projects — named groupings of sessions with name, description, colour, icon, isolated

Migrations

Schema changes that cannot use CREATE TABLE IF NOT EXISTS are applied as idempotent migrations in db/index.js at startup:

try { db.exec(`ALTER TABLE sessions ADD COLUMN name TEXT`); } catch {}
try { db.exec(`ALTER TABLE sessions ADD COLUMN project_id INTEGER REFERENCES projects(id)`); } catch {}
try { db.exec(`CREATE INDEX IF NOT EXISTS idx_sessions_project ON sessions(project_id)`); } catch {}
try { db.exec(`ALTER TABLE projects ADD COLUMN isolated INTEGER NOT NULL DEFAULT 0`); } catch {}

New migrations are always appended here — never modify the schema file for existing tables since ALTER TABLE cannot use IF NOT EXISTS.

An episodes_fts virtual table enables keyword search across all episodes. Three triggers (episodes_fts_insert, episodes_fts_update, episodes_fts_delete) keep the FTS index automatically in sync with the episodes table.

SQLite Configuration

  • journal_mode = WAL — non-blocking reads during writes
  • foreign_keys = ON — enforces referential integrity and cascade deletes
  • PRAGMAs set via db.pragma(), not db.exec()

Dynamic Session Updates

updateSession builds its SET clause dynamically from only the fields passed — prevents partial updates from overwriting fields that weren't touched:

function updateSession(id, { name, projectId } = {}) {
  const updates = [];
  const values = [];
  if (name !== undefined)      { updates.push('name = ?');       values.push(name ?? null); }
  if (projectId !== undefined) { updates.push('project_id = ?'); values.push(projectId ?? null); }
  // ...
}

Qdrant / Semantic Layer

Three Qdrant collections are initialized on service startup:

Collection Purpose
episodes Embeddings for individual conversation exchanges
entities Embeddings for named entities
summaries Embeddings for condensed episode summaries

All collections use 768-dimension vectors with Cosine similarity, matching nomic-embed-text via Ollama. Vector size and distance metric are defined in @nexusai/shared — not hardcoded here.

Each collection exposes three operations in src/semantic/index.js: upsert, search (with optional Qdrant filter), and delete. The wait: true flag is used on all writes.

Embedding Write Path

When a new episode is created:

  1. Episode saved to SQLite synchronously — response returned immediately
  2. User message + AI response combined: User: ...\nAssistant: ...
  3. Text sent to embedding service (POST /embed)
  4. Vector upserted into episodes Qdrant collection with payload { sessionId, createdAt }

This step is fire-and-forget — if embedding fails, the episode is still saved and searchable via FTS. The error is logged but not surfaced.

The Qdrant payload stores sessionId (the internal integer ID). This is used for per-session and per-project filtering during semantic search. See memory-isolation.md for how project-level filtering works.

Entity Layer

Entities and relationships use upsert semantics with composite unique constraints to prevent duplicates:

  • UNIQUE(name, type) on entities
  • UNIQUE(from_id, to_id, label) on relationships
  • ON DELETE CASCADE on relationship foreign keys

Project Delete Behaviour

Deleting a project runs as a transaction — it first nulls out project_id on all assigned sessions, then deletes the project. This avoids a foreign key constraint failure since sessions.project_id has no ON DELETE rule:

const doDelete = db.transaction(() => {
  db.prepare(`UPDATE sessions SET project_id = NULL WHERE project_id = ?`).run(id);
  db.prepare(`DELETE FROM projects WHERE id = ?`).run(id);
});

For all HTTP endpoints, see api-routes.md.