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

160 lines
6.0 KiB
Markdown

# 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:
```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 episode 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 — Entity search scoping
Entity search is also project-scoped. `searchEntities` in `services/qdrant.js`
accepts a `projectId` parameter and filters accordingly:
```js
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:**
```json
{ "sessionId": 42, "createdAt": 1776080188 }
```
**Entities:**
```json
{ "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.