160 lines
6.0 KiB
Markdown
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. |