3.7 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 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 project | All episodes across all sessions in that project |
| Removed from a project | Own session's episodes only (from that point) |
Sessions with no project assigned behave 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, 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 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.
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.
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.