128 lines
4.7 KiB
Markdown
128 lines
4.7 KiB
Markdown
# Memory Isolation
|
|
|
|
NexusAI implements project-scoped memory — sessions belonging to the same
|
|
project can share semantic context, and isolated projects can be restricted
|
|
from drawing on memory outside the project. This document describes how the
|
|
system works end-to-end.
|
|
|
|
## Concepts
|
|
|
|
**Session** — a single conversation thread. Identified by `external_id`.
|
|
|
|
**Project** — a named grouping of sessions. Has an `isolated` flag (0 or 1).
|
|
|
|
**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 non-isolated project | All episodes across all sessions in the project |
|
|
| Assigned to an isolated project | All episodes within the project only |
|
|
| Removed from a project | Own session's episodes only (from that point) |
|
|
|
|
Sessions with no project assigned behave the same 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 (isolated or not), `projectSessionIds`
|
|
is populated with the internal integer IDs of all sessions in that project.
|
|
|
|
For **non-isolated projects**, this expands the search to all project sessions.
|
|
For **isolated projects**, the same set is used but the intent is restriction
|
|
— since `projectSessionIds` only contains project sessions, no external
|
|
episodes can appear.
|
|
|
|
Both cases use the same code path — the `isolated` flag does not change the
|
|
query logic, only the conceptual meaning.
|
|
|
|
### 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.**
|
|
The `useChat` hook writes the `project_id` assignment via `updateSession` after
|
|
`onDone` fires. 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.
|
|
|
|
## Isolated vs Non-Isolated
|
|
|
|
The `isolated` flag is stored on the project but does not currently change the
|
|
query logic — both isolated and non-isolated projects result in a
|
|
`projectSessionIds` filter. The distinction is semantic and enforced by
|
|
the project's membership:
|
|
|
|
- **Non-isolated** — intentionally draws from all sessions in the project,
|
|
creating a shared memory pool for related conversations
|
|
- **Isolated** — by design contains only sessions explicitly added to it,
|
|
so the same filter naturally restricts context to project-only episodes
|
|
|
|
If cross-project contamination became a concern (e.g. a session accidentally
|
|
added to the wrong project), removing it from the project immediately restores
|
|
isolation.
|
|
|
|
## 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. |