# 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: ```js 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: ```js 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: ```json { "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.