memory isolation fix and session grouping in client
This commit is contained in:
@@ -13,19 +13,21 @@ 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.
|
||||
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 | 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) |
|
||||
| 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 |
|
||||
|
||||
Sessions with no project assigned behave as they always have —
|
||||
only their own past episodes are searched.
|
||||
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
|
||||
|
||||
@@ -48,7 +50,7 @@ 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
|
||||
### Step 2 — Qdrant episode filter construction
|
||||
|
||||
In `services/qdrant.js`, `searchEpisodes` builds the filter:
|
||||
|
||||
@@ -68,14 +70,45 @@ if (projectSessionIds) {
|
||||
`WHERE sessionId IN (...)`. When `projectSessionIds` is set, the single-session
|
||||
filter is not used.
|
||||
|
||||
### Step 3 — Episode payloads
|
||||
### 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. This
|
||||
is what the Qdrant filter matches against.
|
||||
payload. `sessionId` here is the **internal integer ID** from SQLite.
|
||||
|
||||
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.
|
||||
### 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
|
||||
|
||||
@@ -88,6 +121,12 @@ project's session list.
|
||||
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
|
||||
@@ -95,12 +134,27 @@ which is passed to `useChat`. After `onDone` fires, `useChat` calls
|
||||
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
|
||||
## Verified Behaviours (tested April 2026)
|
||||
|
||||
Episodes are stored with this payload:
|
||||
- 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.
|
||||
This is important when building filters — always use internal IDs.
|
||||
`projectId` is the SQLite `projects.id` integer.
|
||||
Always use internal IDs when building Qdrant filters.
|
||||
Reference in New Issue
Block a user