Files
nexusAI/docs/reference/Memory-isolation.md
2026-04-19 02:09:12 -07:00

6.0 KiB

Memory Isolation

NexusAI implements project-scoped memory — sessions belonging to the same project share semantic context within that project's boundary. All projects are isolated by default.

Concepts

Session — a single conversation thread. Identified by external_id.

Project — a named grouping of sessions. isolated is always 1 — the toggle has been removed from the UI and isolated: 1 is hardcoded on project creation.

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

Semantic Search Scope

Session state Episode search scope Entity search scope
No project All non-project episodes (shared pool) No entity context
Assigned to a project All episodes across all sessions in that project Entities tagged with that project
Removed from a project Back to shared non-project pool Back to no entity context

Non-project sessions share a common memory pool — they can draw on each other's episodes via semantic search, but cannot access episodes from any project session. Project sessions are fully isolated from all non-project sessions and from other projects.

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, projectSessionIds is populated with the internal integer IDs of all sessions in that project — creating a shared memory pool across all conversations in the project.

Step 2 — Qdrant episode 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 — Entity search scoping

Entity search is also project-scoped. searchEntities in services/qdrant.js accepts a projectId parameter and filters accordingly:

if (projectId) {
  body.filter = {
    must: [{ key: 'projectId', match: { value: projectId } }]
  };
}
// No filter for non-project sessions — entity context not provided

Non-project sessions receive no entity context. Project sessions only see entities extracted from conversations within that project.

Step 4 — Episode payloads

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

Step 5 — Entity payloads

Every entity upserted into Qdrant carries { name, type, notes, projectId } in its payload. projectId is the integer project ID.

Entities are extracted and stored with projectId by extraction.js, which receives it from createEpisode in episodic/index.js, which receives it from the memory service episode route, which receives it from orchestration's createEpisode call in chat/index.js. The full chain:

chat/index.js → memory.createEpisode(session.id, ..., session.project_id)
  → POST /episodes { projectId }
  → episodic.createEpisode(..., projectId)
  → extractAndStoreEntities(userMessage, aiResponse, projectId)
  → semantic.upsertEntity(id, vector, { name, type, notes, projectId })

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.

Entity tags are immutable. Entities extracted from a session's episodes are tagged with the projectId at extraction time. If a session is later moved to a different project, its previously extracted entities retain the original projectId. New entities extracted after the move will use the new projectId. Re-tagging existing entities requires a Qdrant payload update.

New sessions created from ProjectView are assigned after the first message. handleNewProjectChat in App.jsx calls sendMessage with the project ID, which is passed to useChat. After onDone fires, useChat calls updateSession to write the project assignment to the backend. 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.

Verified Behaviours (tested April 2026)

  • Project sessions cannot read episodes from non-project sessions ✓
  • Non-project sessions cannot read episodes from project sessions ✓
  • Non-project sessions can read each other's episodes ✓
  • Adding a session to a project — its history joins the project pool immediately ✓
  • Removing a session from a project — exits the project pool immediately ✓
  • Entity contamination across projects eliminated by projectId filter ✓

Qdrant Payload Structures

Episodes:

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

Entities:

{ "name": "NexusAI", "type": "project", "notes": "...", "projectId": 3 }

sessionId is the SQLite sessions.id integer, not the external_id UUID. projectId is the SQLite projects.id integer. Always use internal IDs when building Qdrant filters.