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

4.7 KiB

Memory Isolation

NexusAI implements project-scoped memory — sessions belonging to the same project can share semantic context, and isolated projects can be restricted from drawing on memory outside the project. This document describes how the system works end-to-end.

Concepts

Session — a single conversation thread. Identified by external_id.

Project — a named grouping of sessions. Has an isolated flag (0 or 1).

Semantic search — at inference time, the user's message is embedded and compared against past episodes in Qdrant to surface relevant context. The scope of this search is controlled by the project context.

Semantic Search Scope

Session state Semantic search scope
No project Own session's episodes only
Assigned to a non-isolated project All episodes across all sessions in the project
Assigned to an isolated project All episodes within the project only
Removed from a project Own session's episodes only (from that point)

Sessions with no project assigned behave the same as they always have — only their own past episodes are searched.

How It Works

Step 1 — Project context resolution (orchestration)

In chat/index.js, immediately after session resolution:

let projectSessionIds = null;
if (session.project_id) {
  const project = await memory.getProject(session.project_id);
  if (project) {
    const projectSessions = await memory.getProjectSessions(session.project_id);
    projectSessionIds = projectSessions.map(s => s.id);
  }
}

If the session belongs to any project (isolated or not), projectSessionIds is populated with the internal integer IDs of all sessions in that project.

For non-isolated projects, this expands the search to all project sessions.
For isolated projects, the same set is used but the intent is restriction — since projectSessionIds only contains project sessions, no external episodes can appear.

Both cases use the same code path — the isolated flag does not change the query logic, only the conceptual meaning.

Step 2 — Qdrant filter construction

In services/qdrant.js, searchEpisodes builds the filter:

if (projectSessionIds) {
  body.filter = {
    should: projectSessionIds.map(id => ({
      key: 'sessionId', match: { value: id }
    }))
  };
} else if (sessionId) {
  body.filter = { must: [{ key: 'sessionId', match: { value: sessionId } }] };
}

should is Qdrant's "match any of" operator — equivalent to SQL WHERE sessionId IN (...). When projectSessionIds is set, the single-session filter is not used.

Step 3 — Episode payloads

Every episode upserted into Qdrant carries { sessionId, createdAt } in its payload. sessionId here is the internal integer ID from SQLite. This is what the Qdrant filter matches against.

This means the filter works correctly regardless of when episodes were created or when a session was added to a project — the payload is immutable.

Important Behaviours

Pre-existing episodes are included immediately. When a session is added to a project and a new message is sent, Qdrant can match all of that session's existing episodes since the filter only requires the sessionId to be in the project's session list.

Removing a session from a project takes effect immediately. On the next message, getProjectSessions will not include that session's ID, so its episodes disappear from the semantic search scope.

New sessions created from ProjectView are assigned after the first message. The useChat hook writes the project_id assignment via updateSession after onDone fires. There is a brief window during the first message where the session has no project assigned. The project is correctly applied from the second message onward.

Isolated vs Non-Isolated

The isolated flag is stored on the project but does not currently change the query logic — both isolated and non-isolated projects result in a projectSessionIds filter. The distinction is semantic and enforced by the project's membership:

  • Non-isolated — intentionally draws from all sessions in the project, creating a shared memory pool for related conversations
  • Isolated — by design contains only sessions explicitly added to it, so the same filter naturally restricts context to project-only episodes

If cross-project contamination became a concern (e.g. a session accidentally added to the wrong project), removing it from the project immediately restores isolation.

Qdrant Payload Structure

Episodes are stored with this payload:

{ "sessionId": 42, "createdAt": 1776080188 }

sessionId is the SQLite sessions.id integer, not the external_id UUID. This is important when building filters — always use internal IDs.