Files
nexusAI/docs/reference/Memory-isolation.md
2026-04-18 23:37:32 -07:00

106 lines
3.7 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 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.