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.
|
||||
@@ -92,14 +92,14 @@ src/
|
||||
│ └── useContextMenu.js # Right-click context menu position and visibility
|
||||
├── components/
|
||||
│ ├── App.jsx # Root component — layout, shared state, view routing
|
||||
│ ├── Sidebar.jsx # Left sidebar — projects, recent chats, navigation
|
||||
│ ├── Sidebar.jsx # Left sidebar — projects, grouped recent chats, navigation
|
||||
│ ├── HomeView.jsx # Landing screen — greeting, centred input, quick actions
|
||||
│ ├── ChatWindow.jsx # Centre panel — message thread and input bar
|
||||
│ ├── ChatWindow.jsx # Centre panel — message thread, back button, model pill
|
||||
│ ├── MessageBubble.jsx # Individual message bubble — renders markdown via react-markdown
|
||||
│ ├── InfoPanel.jsx # Right panel — model selector and session metadata (slide-in)
|
||||
│ ├── SessionModal.jsx # Modal for session rename, project assignment, delete
|
||||
│ ├── ProjectModal.jsx # Modal for project create, edit, delete
|
||||
│ ├── AllChatsView.jsx # Full paginated session list with multi-select bulk delete
|
||||
│ ├── AllChatsView.jsx # Paginated session list with project indicator column
|
||||
│ ├── AllProjectsView.jsx # Project tile grid with create/edit/delete; tile click navigates to ProjectView
|
||||
│ ├── ProjectView.jsx # Individual project — conversations, new chat input, memory
|
||||
│ │ # placeholder, user notes, ⋮ edit/delete menu
|
||||
@@ -129,8 +129,13 @@ panel are persistent across all views.
|
||||
│ All Projects → │ memory → MemoryView │
|
||||
│ │ │
|
||||
│ RECENT CHATS ▾ │ │
|
||||
│ Session 1 │ │
|
||||
│ Session 2 │ │
|
||||
│ ● Project A │ │
|
||||
│ Session 1 │ │
|
||||
│ Session 2 │ │
|
||||
│ ● Project B │ │
|
||||
│ Session 3 │ │
|
||||
│ Other │ │
|
||||
│ Session 4 │ │
|
||||
│ All Chats → │ │
|
||||
│ │ │
|
||||
│ ⚙ Settings │ │
|
||||
@@ -167,13 +172,17 @@ leaving `'home'` expands it.
|
||||
|
||||
## Home View
|
||||
|
||||
`HomeView` is the landing screen shown on initial load and when there are no
|
||||
active sessions. It displays:
|
||||
`HomeView` is the landing screen shown on initial load. It displays:
|
||||
- Time-based greeting ("Morning / Afternoon / Evening, Tim")
|
||||
- Currently loaded model name (from `modelProps.modelAlias`, stripped of `.gguf`)
|
||||
- Centred textarea input — sending creates a new session and navigates to chat
|
||||
- Quick action pills that populate the input without auto-sending
|
||||
|
||||
Sending from HomeView uses `handleHomeSend` in `App.jsx`, which calls
|
||||
`createSession()` (returns the new session object), then immediately calls
|
||||
`sendMessage` with the session passed directly as a parameter — avoiding the
|
||||
React state settling race condition that would cause the message to be dropped.
|
||||
|
||||
## CSS Architecture
|
||||
|
||||
Styles follow a hybrid approach — CSS utility classes for static reusable
|
||||
@@ -225,6 +234,11 @@ are written into the active assistant bubble token by token via
|
||||
`updateLastMessage`. The blinking cursor in `MessageBubble` is shown while
|
||||
`message.streaming === true`.
|
||||
|
||||
`useChat.sendMessage` accepts an optional `session` parameter (4th arg) that
|
||||
overrides the closed-over `activeSession`. This is used by `handleHomeSend`
|
||||
and `handleNewProjectChat` in `App.jsx` to pass the newly created session
|
||||
object directly, avoiding React state settling races.
|
||||
|
||||
`useChat` accepts an optional `projectId` parameter in `sendMessage`. After
|
||||
the first message completes in a new session, if `projectId` is set,
|
||||
`updateSession` is called to write the project assignment to the backend.
|
||||
@@ -236,6 +250,9 @@ the `uuid` package. New sessions are created locally and auto-registered in
|
||||
the memory service on the first message. The session list refreshes after
|
||||
each completed response to surface newly created sessions.
|
||||
|
||||
`useSession.createSession` returns the new session object — callers can pass
|
||||
it directly to `sendMessage` rather than waiting for React state to update.
|
||||
|
||||
`useSession.selectSession` skips the history fetch for new (`isNew: true`)
|
||||
sessions — fetching history for an unsaved session would 404 since it doesn't
|
||||
exist in the backend yet.
|
||||
@@ -267,6 +284,16 @@ mode, and delete confirmation in `confirm-delete` mode.
|
||||
- Dynamic `updateSession` SQL builds `SET` clause from only the fields passed — prevents accidental overwrites
|
||||
- `AllChatsView` pagination uses `CLIENT_DEFAULTS.PAGE_SIZE` (not `API_DEFAULTS.PAGE_SIZE` which doesn't exist)
|
||||
|
||||
## Sidebar — Session Grouping
|
||||
|
||||
Recent sessions in the sidebar are grouped by project under a colour dot +
|
||||
project name label. Unassigned sessions appear under "Other" if any project
|
||||
groups are present. The grouping is computed client-side from the `sessions`
|
||||
array and `projects` list already available in `App.jsx` — no extra API call.
|
||||
|
||||
`AllChatsView` receives `projects` as a prop from `App.jsx` and displays a
|
||||
project indicator column (colour dot + truncated name) in each session row.
|
||||
|
||||
## Project Management
|
||||
|
||||
All projects are isolated by default (`isolated: 1` hardcoded on create).
|
||||
|
||||
@@ -180,7 +180,8 @@ After extraction, each entity is:
|
||||
the entity is new (`COALESCE(entities.notes, excluded.notes)` prevents
|
||||
overwriting existing notes with speculative updates)
|
||||
2. Embedded via the embedding service and upserted into the `entities`
|
||||
Qdrant collection with `{ name, type, notes }` as payload
|
||||
Qdrant collection with `{ name, type, notes, projectId }` as payload —
|
||||
`projectId` scopes entities to their project for isolated retrieval
|
||||
|
||||
The Qdrant payload stores enough information to reconstruct entity context
|
||||
at retrieval time without a SQLite roundtrip.
|
||||
|
||||
Reference in New Issue
Block a user