This commit is contained in:
yakamoz221 2026-08-04 15:27:34 +02:00 committed by GitHub
commit 1cd17dd105
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 2797 additions and 0 deletions

7
app.py
View file

@ -666,6 +666,13 @@ app.include_router(setup_session_routes(
from routes.admin_wipe.admin_wipe_routes import setup_admin_wipe_routes
app.include_router(setup_admin_wipe_routes(session_manager))
# Memory Graph View (beta) — MUST be included before memory_router below.
# memory_routes.py's GET/PUT/DELETE /api/memory/{memory_id} wildcard would
# otherwise swallow GET /api/memory/graph, since Starlette matches routes in
# registration order across the whole app, not by specificity.
from routes.memory.memory_graph_routes import setup_memory_graph_routes
app.include_router(setup_memory_graph_routes(memory_manager, memory_vector=memory_vector))
# Memory
from routes.memory.memory_routes import setup_memory_routes
memory_router = setup_memory_routes(memory_manager, session_manager, memory_vector=memory_vector)

54
docs/handoff.md Normal file
View file

@ -0,0 +1,54 @@
# Memory Graph View — Handoff
Read this first if picking up this work in a new session. It answers: what's done, what's the very next thing to do, what could bite you, and why certain calls were made.
For the full checklist see `docs/todos.md`. For the session-by-session history see `docs/progress.md`. For the accepted design see `docs/memory-graph-design.md`. For the repo research behind it see `docs/memory-graph-analysis.md`.
## Where things stand right now
- **Branch**: `feature/memory-graph-view`.
- **Milestone 1 (backend API)**: done, tested, **committed**. One additional bug found and fixed during M2 verification (see below) — `memory_graph_routes.py`'s owner resolution was inconsistent with the rest of the memory routes, which made the graph always empty and links always 404 in single-user/no-auth mode. Committed separately (`fix(memory-graph): align owner resolution with rest of memory routes`).
- **Milestone 2 (frontend)**: done, **committed**, **visually verified** end-to-end against a real seeded dataset (render, node click + neighborhood highlight, detail panel, inline edit, search, category filters, similarity slider, link-mode create, theme switch — see `docs/progress.md` Session 4 for the full pass). Two frontend bugs found and fixed in the same commit: the detail panel's `hidden` class was never removed on node selection (panel was permanently invisible), and `_applySearch()` left stale `mg-dimmed`/`mg-highlighted` classes from a prior node selection that masked search-match nodes at `opacity:0.08`.
- **Milestone 3 (polish)**: done, committed, visually verified (legend collapse, isolate-component, `f`/arrow-key shortcuts all tested live against real seeded data — see `docs/progress.md` Session 5).
## Exact next task
There is no open milestone left on the original plan. The branch is fully committed but **not pushed** — the last push attempt (`git push -u origin feature/memory-graph-view`) failed:
```
remote: Permission to odysseus-dev/odysseus.git denied to yakamoz221.
fatal: unable to access '...': The requested URL returned error: 403
```
The `origin` remote (`https://github.com/odysseus-dev/odysseus.git`) doesn't grant this GitHub account write access. Before this branch can be shared, the user needs to either: grant `yakamoz221` push access to that repo, point `origin` at a fork the account does own, or push some other way (e.g. a different configured remote/credential). This is a permissions/credentials matter outside what a coding session can resolve on its own.
Two loose ends beyond that, neither blocking:
1. **Known limitation, not fixed**: graph node colors are only recomputed on next render, not live-reactive to a theme flip while the modal is already open (confirmed still true — switching theme via the Theme modal while Memory Graph was open did not immediately recolor node fills, though all non-canvas chrome — detail panel, chips, buttons, the legend header — is CSS-variable-driven and updates live). Left as-is by deliberate choice at the end of Milestone 3, not an oversight — revisit only if a user actually notices/minds.
2. If compact/mobile icon-rail parity is wanted for the Memory Graph nav item (see Implementation notes below), that's a small standalone follow-up, not scoped into any milestone so far.
For a repeatable verification loop in a future session (isolated data dir, seeding via the real API, symmetric Chroma cleanup, PID-verified process kill), the exact steps used are preserved in `docs/progress.md` Sessions 4 and 5 — worth reusing as-is rather than re-deriving.
## Resolved decisions (kept for context, not open anymore)
- **"Feature flag enabled (beta)"**: asked the user directly during Session 5 whether this meant a real togglable on/off switch or the shipped privilege-gate (`can_manage_memory`, same as the "Brain" button) plus a static "beta" label was enough. User confirmed the latter is sufficient — no separate settings switch was built, and none is needed.
- **The `src/agent_loop.py` one-line fix** (a genuinely pre-existing bug — `NameError: name 'Any' is not defined` from a missing `typing` import, confirmed via `git stash` on a clean `dev` checkout, blocking the whole app from starting): kept and committed separately (`fix(agent_loop): add missing typing.Any import`), not folded into any Memory Graph commit. Low-risk enough (adds a missing stdlib import; nothing downstream of that line worked before this fix either) that it didn't need further debate.
## Risks / things to watch
1. **The branch is unpushed** — see "Exact next task" above. Anyone continuing this work locally has everything; anyone expecting to see it on GitHub does not yet.
2. **Do not seed demo/test memory data into the real ChromaDB without cleaning it up.** Sessions 4 and 5 both did this correctly (seed via the real `POST /api/memory/add`, delete via the real `DELETE /api/memory/{id}`, then directly query the shared Chroma collection's `/get` endpoint by id to *confirm* zero vectors remain rather than assuming). Any future session doing this must follow the same symmetric pattern.
3. **The dev venv (`.venv-test/`) is a throwaway, git-ignored (via `.git/info/exclude`, not `.gitignore`) local artifact**, created because this sandbox had no Python environment with the project's dependencies installed. It is NOT part of the repo and should not be referenced by anything committed. A future session may need to recreate it (`python -m venv .venv-test && .venv-test/Scripts/python.exe -m pip install -r requirements.txt`) if it's not still present.
4. **Pre-existing test-collection errors exist in this sandbox and are environment-driven**, not caused by this feature: an `mcp` package version mismatch (`AttributeError: 'Server' object has no attribute 'list_tools'`) affecting 4 test files, plus one unrelated `UnicodeDecodeError` in a document-diff test — confirmed via `pytest --collect-only` at the end of Session 5 to be the exact same 5 errors, nothing new. (Session 2 additionally diffed a full *execution* run against a clean `dev` baseline and found 199 identical pre-existing failures/errors; that full-run comparison wasn't re-done in Sessions 4/5, only the lighter collection-only check — worth a full re-diff if a future session wants that stronger guarantee.)
5. **Chroma collections are global, not owner-scoped.** This was already true of the existing memory system before this feature (`docs/memory-graph-analysis.md` §10) and the graph feature doesn't change it, but it's exactly why point 2 above matters — any manual testing against a real/shared Chroma instance needs symmetric cleanup.
## Implementation notes worth knowing before touching this code
- **Route-ordering is load-bearing.** `routes/memory/memory_graph_routes.py` must be `include_router()`'d in `app.py` *before* `routes/memory/memory_routes.py`'s router. The latter's `GET/PUT/DELETE /api/memory/{memory_id}` is a single-segment wildcard; Starlette matches routes in registration order across the whole app (not by specificity), so `GET /api/memory/graph` would otherwise resolve to the wildcard with `memory_id="graph"` and 404. This is regression-tested in `tests/test_memory_graph_route_ordering.py` using a real `TestClient` — the only place in this feature's tests that a full ASGI app was actually needed, because the repo's usual "look up the route by exact path string, call the endpoint function directly" test convention can't catch this class of bug (it never exercises real Starlette path matching).
- **The runtime memory store is a JSON file, not the SQL `memories` table.** `src/memory.py::MemoryManager` persists to `data/memory.json`; there's a `memories` SQLAlchemy table in `core/database.py` too, but it's unused by any live code path (see `docs/memory-graph-analysis.md` §8's "Important discrepancy"). The Memory Graph feature correctly targets the JSON store + the `odysseus_memories` Chroma collection, matching what every other memory route already does. Don't be tempted to "fix" this by wiring the graph to the SQL table — that's explicitly out of scope (see the analysis doc).
- **Manual links are stored as a new optional `links: []` field directly on memory JSON entries.** No migration was needed because it's a JSON file, not a SQL schema — old entries without the field are handled via `entry.get("links", [])`/`mem.get("links") or []` throughout, so nothing breaks for pre-existing memories.
- **The frontend has zero bundler.** Cytoscape.js is vendored as a single UMD file (`static/lib/cytoscape.min.js`, fetched via `npm pack cytoscape@3` in a scratch dir — not a hand-typed CDN URL) and lazy-loaded via a dynamically-created `<script>` tag the first time the Graph modal opens, mirroring the exact pattern `documentLibrary.js` already uses for `xlsx.full.min.js`/`mammoth.browser.min.js` (`ensureXLSX`/`ensureMammoth``ensureCytoscape` in `memoryGraph.js`).
- **The modal itself is built entirely in JS, not in `index.html`.** `memoryGraph.js`'s `_getModal()` follows `calendar.js`'s `_getModal()` template exactly: lazily `document.createElement`'d on first open, appended to `document.body` once, memoized in a module-level variable, registered with `modalManager.js`'s `Modals.register(...)` (gets minimize/dock/restore/z-order for free — same mechanism gallery/notes/cookbook/calendar all use). This was a deliberate choice over adding static modal markup to `index.html` (which is how the older "Brain"/"Calendar" full-page structure looks from the HTML side, but their *actual* open/close JS logic turned out to live in `app.js`/`calendar.js`, not be purely declarative — worth rereading `docs/memory-graph-analysis.md` if this surprises you). Following the newer self-registering pattern meant **zero edits** to `app.js`'s older hardcoded Escape-key modal-id arrays (`modalItemMap`, `_modalSidebarMap`, `dynamicModals`) — the new modal manages its own Escape handling directly, exactly like `calendar.js` does.
- **Category colors are resolved from existing theme CSS custom properties** (`--fg`, `--hl-keyword`, `--warn`, `--color-accent`, `--color-brand-blue`, `--accent-warm`, `--green`) via `getComputedStyle(document.documentElement)` at render time — not hardcoded hex values — so dark/light theme support came largely for free. **Known limitation**: colors are only (re)computed when the graph is (re)rendered (on open, or after a mutation reload), not on a live theme-toggle while the modal is already open. This was considered during Milestone 3 and deliberately left unbuilt (see "Exact next task" above) rather than missed.
- **Filtering is entirely client-side; only the initial load hits the backend.** The graph is fetched once per open (or after a mutation) at a generous floor (`min_similarity=0.5`, `max_edges_per_node=8`) and cached in memory; the category chips, the similarity slider, and the search box all operate on that cached copy via Cytoscape's own `style('display', ...)`/class toggling. This was a deliberate deviation from a literal reading of the design doc's per-request query params (`docs/memory-graph-design.md` §3) — it makes filter interactions instant instead of a round-trip per click, at the cost of the UI slider not being able to go below 0.5 (the server floor). Worth flagging as a design refinement, not an oversight.
- **Nav item scope was deliberately kept to the sidebar Tools list only.** There's also a separate, fixed-size compact "icon rail" (mobile/collapsed-sidebar duplicate) in `index.html` with a hardcoded `_railToolMap` in `app.js` — extending that to add a Memory Graph icon was skipped as out of scope for this milestone (higher risk of visually cramming a fixed-width icon strip, not explicitly requested). If compact/mobile nav parity is wanted, that's a small, well-scoped follow-up.

View file

@ -0,0 +1,180 @@
# Memory Graph View — Repository Analysis
Status: research only at the time this document was originally written. No application code, dependencies, or database state was modified to produce it. **Since then, implementation has begun** — see the "Post-implementation addendum" at the end of this document, plus `docs/progress.md`, `docs/todos.md`, and `docs/handoff.md` for current status.
Scope: this document inventories the parts of the Odysseus codebase relevant to building an interactive, Obsidian-like Memory Graph View, and identifies the safest points to extend the system. A companion document, `docs/memory-graph-design.md`, proposes the actual design based on these findings.
## 1. Frontend architecture
- No bundler, no build step. `static/js/package.json` sets `{ "type": "module" }` so the browser loads native ES modules directly. There is no webpack/vite/esbuild config anywhere in the repo. The root `package.json` has no `scripts` block; its only `devDependency` is `@antithesishq/bombadil`, a browser-fuzzing spec library unrelated to bundling.
- Entry point: `static/index.html` loads `static/app.js` via `<script type="module">`. `app.js` statically imports ~30 feature modules, each a plain ES module with a default export object (e.g. `import memoryModule from './js/memory.js?v=20260722memoryloading1'`). Cache-busting is done manually via query-string suffixes on the import path.
- Cross-module reachability: some modules are also hung off `window` (`window.themeModule`, `window.sessionModule`, `window.uiModule`, `window.adminModule`, `window.cookbookModule`) so unrelated modules can call into them without a formal event bus.
- `static/js/` has ~100 flat files plus subfolders for cohesive subsystems: `editor/` (canvas-based image editor), `compare/`, `research/`, `calendar/`, `emailLibrary/`, `markdown/`, `model/`, `color/`, `util/`. `static/js/MODULE_SUMMARY.md` is the authoritative, actively maintained architecture index and should be updated alongside any new module.
- Backend communication is plain `fetch()` returning JSON, plus one Server-Sent Events (SSE) stream for chat (`chat.js` posts to `/api/chat_stream`, reads via `res.body.getReader()`, parses `data: {...}` lines by `type`). No WebSockets exist anywhere in the codebase.
- `app.js` patches `window.fetch` globally so any `401` response redirects to `/login` — any new module's fetches automatically inherit this behavior.
## 2. Routing
- There is no client-side SPA router (no history-based route table, no hash router library). Odysseus is a single persistent DOM (`index.html`) where "navigation" means opening/closing modals and full-screen panels, not swapping views by URL.
- A lightweight deep-link opener exists in `app.js`: a `_routeOpen` map keyed by `window.location.pathname` (entries for `/notes`, `/calendar`, `/cookbook`, `/email`, `/memory`, `/gallery`, `/tasks`, `/library`). The server presumably serves `index.html` for these paths (catch-all), and this map simulates the click that would normally open the corresponding modal. A new `/memory-graph` entry point would follow this exact pattern, or the graph could live as a new tab inside the existing memory modal instead of a new top-level route.
- Everything else (Memory, Calendar, Gallery, Documents, Tasks, Compare, Cookbook, Settings) is a `.modal` element in `index.html` toggled by `modalManager.js`. There is no hash-based view dispatch beyond an ad hoc entity-hash regex in `init.js` used only for composer-restore behavior.
## 3. UI component library / design system
- No component framework (no React/Vue/etc.), no CSS framework (no Tailwind/Bootstrap). `static/style.css` is a single hand-written stylesheet (tens of thousands of lines) using CSS custom properties for theming (`:root { --bg; --fg; --red; ... }`), a `:root.light` override, density variants (`.density-compact`, `.density-spacious`), and a UI-scale zoom mechanism.
- The "component" pattern is plain JS factory functions building DOM via `document.createElement` and manual event wiring — no virtual DOM, no templating engine. `static/js/memory.js` builds each memory-list row this way.
- Modals share a common infrastructure:
- `static/js/modalManager.js` — central open/minimize/close/dock manager. Public API: `Modals.register(id, { railBtnId, restoreFn, closeFn })`, `Modals.toggle(id)`. Owns a draggable "minimized dock" tray with FLIP animations and magnetic close-on-drag-to-trash behavior. Each modal type has a label+icon entry in `_LABELS`, including an existing `'memory-modal': { label: 'Brain', icon: ... }` entry. **A new Memory Graph View, if its own modal/window, should register here** to get consistent minimize/dock/restore/z-order behavior for free.
- `static/js/modalSnap.js` — edge-docking/snap-to-zone logic, imported by `modalManager.js`.
- `static/js/tileManager.js` — desktop window tiling/snap-to-edge, used by `memory.js` for `snapModalToZone`.
- `static/js/windowDrag.js` — generic `makeWindowDraggable(modal, opts)` helper, used to make the memory modal's header draggable.
- `static/js/toolWindowZOrder.js` — monotonically increasing z-index (`nextToolWindowZ()`) so the most-recently-focused tool window stacks on top.
- `static/modal-control-variants.html`, `static/wave-variants.html`, `static/whirlpool-variants.html` are standalone design-exploration/prototyping pages, not wired into the live app bundle.
- Third-party libraries that are needed client-side are vendored directly into `static/lib/` (e.g. `docx.umd.min.js`, `highlight.min.js`, `xlsx.full.min.js`) rather than installed via npm — this is the established pattern for adding any graph-rendering library, since there is no bundler to run `npm install` through.
## 4. Existing memory UI
- `static/js/memory.js` (~1550 lines) implements the entire "Brain" modal. No graph, timeline, or relationship view exists today.
- Modal structure (`index.html`): `#memory-modal` with tabs — Browse, Skills, Add, Settings.
- Browse tab is a flat list view (`#memory-list`, `.memory-item` rows), not a card grid. Each row shows text, category badge, pinned badge, source (`auto`/`manual`), use-count, relative timestamp, and a kebab menu (Pin/Select/Edit/Delete).
- Supported interactions: free-text client-side search, category filter chips, sort dropdown (newest/oldest/A-Z/most-used) with pinned items floated to top, bulk multi-select with bulk-delete, inline double-click-to-edit, an AI-driven "Tidy" (dedupe/audit) action, import from file with LLM-extracted suggestion review, and JSON export.
- Categories are a fixed client-side set: `MEMORY_CATEGORIES = ['fact','identity','preference','contact','project','goal','task']`.
- The backend already exposes `GET /api/memory/timeline`, sorted by timestamp with resolved session names, but **no frontend module calls it today** — it is the closest existing "structured" memory endpoint and a plausible seed for a graph-view layout, but it is currently dead code from the UI's perspective.
## 5. Existing graph or visualization components
- None. An explicit search across `static/`, `src/`, `routes/`, and the whole repo for `d3.`, `cytoscape`, `vis-network`, `vis.js`, `sigma.js`, `force-graph`, `d3-force`, `three.js`, and `networkx` returned zero matches.
- The only `<canvas>` usage in the codebase is the image editor (`static/js/editor/*`, `static/js/galleryEditor.js`) for pixel/layer compositing — unrelated to node-link rendering.
- Conclusion: a Memory Graph View is a greenfield UI addition. There is no library, canvas renderer, or "relations" concept in the data model to build on top of.
## 6. Backend architecture
- Framework: FastAPI (`app.py`), served by Uvicorn. Two entrypoints: `app.py` (standard dev/server) and `launcher.py` (Windows portable/frozen build with a tray icon, same Uvicorn server underneath).
- Route registration is manual, not auto-discovered: every `routes/*.py` module exposes a `setup_*_routes(...)` factory that builds and returns an `APIRouter` with dependencies passed as plain constructor args (not FastAPI `Depends()`). `app.py` explicitly imports and `include_router()`s over 40 of these factories.
- Startup uses the modern FastAPI `lifespan` context manager. `src/app_initializer.py::initialize_managers()` is a pure component factory invoked once at startup: it builds `MemoryManager`, `SkillsManager`, `SessionManager`, `UploadHandler`, `PersonalDocsManager`, `APIKeyManager`, `PresetManager`, `MemoryVectorStore` (Chroma-backed, degrades gracefully if unhealthy), wraps memory in a `MemoryProviderRegistry`, and builds `ChatProcessor`/`ChatHandler`/`ModelDiscovery`.
- Route handlers are `async def`, but database access uses classic synchronous SQLAlchemy (`SessionLocal()`), occasionally wrapped in `asyncio.to_thread`. The process is single-instance by convention (log rotation is explicitly not multi-process safe).
- Dependency injection is a manual "component bag" pattern, not `Depends()`: singletons are built once in `app.py`/`app_initializer.py` and closed over by each router factory. `Request.state` carries auth-derived values (`current_user`, `api_token`, etc.) set by middleware and read directly inside handlers.
## 7. API routes
- Auth is enforced by `AuthMiddleware` (inside `app.py`, conditional on `AUTH_ENABLED=true`) plus per-route calls into `src/auth_helpers.py`: `get_current_user(request)` (soft), `require_user(request)` (401 if unauthenticated), `require_privilege(request, "can_manage_memory")` (401/403 by privilege flag), `effective_user(request)` (resolves the real owner behind a Bearer API token).
- Ownership is enforced manually per route via helpers like `_assert_session_owner`/`_verify_memory_owner` in `routes/memory/memory_routes.py`, all raising 404 (not 403) on cross-owner access to avoid confirming another user's resource exists.
- Request/response validation is inconsistent by design across routes: some use Pydantic models from `src/request_models.py` (`MemoryAddRequest`, etc., with lenient field validators that clamp/default rather than hard-reject), others use raw `Form(...)` parameters. Pick per-route based on whether the caller posts JSON or a browser form.
- Error handling is two-layered: routes raise `HTTPException` directly and inline; a small set of domain exceptions (`SessionNotFoundError`, `InvalidFileUploadError`, `LLMServiceError`, `WebSearchError`) are registered globally in `core/exceptions.py` / `app.py` with fixed JSON shapes and status codes.
- Rate limiting (`src/rate_limiter.py`) is a simple in-memory sliding-window limiter, wired only into `routes/auth_routes.py` (login/setup) — there is no global rate-limit middleware.
- Pagination has no single shared convention. History uses offset/limit with a "default to most recent page" fallback; documents use `Query(0, ge=0)`/`Query(20, ge=1, le=50)`; **memory routes have no pagination at all**`GET /api/memory` and `/timeline` return the full owner-scoped list every time. A graph endpoint returning potentially hundreds of nodes/edges will need new pagination/limiting that doesn't exist in the memory API today.
## 8. Database schema
- Engine: SQLite by default (`DATABASE_URL=sqlite:///{DATA_DIR}/app.db`), synchronous SQLAlchemy declarative ORM. A custom `EncryptedText` `TypeDecorator` transparently Fernet-encrypts sensitive columns at the ORM layer.
- Migrations: no Alembic. Hand-rolled, idempotent `_migrate_add_*` functions run in a fixed sequence from `init_db()`, each checking `PRAGMA table_info(table)` before `ALTER TABLE ... ADD COLUMN`, safe to re-run every startup. `init_db()` also calls `Base.metadata.create_all(bind=engine)` for any brand-new tables.
- Key tables (all in `core/database.py`): `sessions`, `chat_messages`, `memories` (see discrepancy below), `documents`/`document_versions`, `gallery_albums`/`gallery_images`, `notes`, `calendars`/`calendar_events`/`caldav_deleted_events`, `email_accounts`, `scheduled_tasks`/`task_runs`, plus supporting tables (`model_endpoints`, `api_tokens`, `mcp_servers`, `comparisons`, `signatures`, `webhooks`, `user_tools`, `crew_members`, `editor_drafts`, `integrations`).
- Users/auth are **not** in the SQL database — `core/auth.py::AuthManager` reads/writes `data/auth.json`. Ownership on DB rows is a plain `owner` string column (username), not a foreign key to a `users` table; `NULL` conventionally means "legacy/shared, visible to everyone."
- **Important discrepancy**: a `memories` SQL table exists with proper schema and a `session_id` FK, but the runtime memory store actually used everywhere (routes, chat context injection, agent tool) is `src/memory.py::MemoryManager`, which persists to a flat JSON file `data/memory.json`, not this table. The SQL `memories` table appears to be unused/legacy. Any Memory Graph View must treat `memory.json` (+ its Chroma vector index) as the real source of truth, not the SQL table.
## 9. Memory system
- Canonical storage: `src/memory.py::MemoryManager` — a JSON file (`data/memory.json`) holding flat entries: `id`, `text`, `category` (default `"fact"`), `source`, `owner`, `timestamp`, optional `session_id`, `pinned`, `uses`. **There is no relationship/edge field** — no `related_ids`, `links`, or similar. Any graph view must derive edges rather than read stored ones.
- `services/memory/memory.py` and `services/memory/memory_vector.py` are thin backward-compatibility shims re-exporting the canonical `src/memory.py` / `src/memory_vector.py` implementations.
- Backend routes (`routes/memory/memory_routes.py`, prefix `/api/memory`; `routes/memory_routes.py` is a compat shim re-exporting the same router object): `POST /add`, `GET ""` (list), `POST /search`, `GET /timeline`, `GET /by-session/{session_id}`, `POST /extract` (LLM suggestion extraction from a chat session), `POST /audit` (dedupe), `POST /import` (file-based extraction), `POST /{id}/pin`, `GET /{id}`, `PUT /{id}`, `DELETE /{id}`.
- Memory-to-session relationship is informational provenance only (`session_id` records which chat session a memory was extracted from) and is not a structural graph edge.
- Two independent paths connect memory to the agent/chat pipeline:
1. **Automatic read path**`src/chat_processor.py::ChatProcessor.build_context_preface(...)` runs on every chat/agent turn when `use_memory=True` (default): loads the owner's memories, selects relevant pinned + hybrid-retrieved (BM25 + vector) memories up to a context limit, injects them as untrusted-context messages before the LLM ever sees the turn, and bumps each injected memory's `uses` counter.
2. **Explicit write path** — the model can emit a `manage_memory` tool call (`src/ai_interaction.py::do_manage_memory`), supporting `list|add|edit|delete|search`, mutating `MemoryManager` + `MemoryVectorStore` directly and firing a `memory_added` event.
- `src/memory_provider.py` defines a `MemoryProvider` ABC / `MemoryProviderRegistry` intended to let alternate memory backends plug in behind the same interface; only `NativeMemoryProvider` is currently registered. This is the natural extension seam if a graph-capable provider is ever needed, though the design in this analysis targets the native provider directly since that's what all current UI and agent paths use.
## 10. ChromaDB integration / vector search
- `src/chroma_client.py` uses `chromadb.HttpClient(host, port)` — a real HTTP client against a **separate `chromadb` container**, not an embedded/local persistent client. Config: `CHROMADB_HOST` (default `localhost`), `CHROMADB_PORT` (default `8100`); docker-compose sets these to `chromadb`/`8000` for in-network container-to-container access. A TCP probe fails fast before constructing the client; `client.heartbeat()` verifies liveness before the singleton is cached.
- Collection names: `odysseus_memories` (`src/memory_vector.py::MemoryVectorStore.COLLECTION_NAME`) and `odysseus_rag` (`src/rag_vector.py`). Both are created lazily through `build_embedding_lanes()``chroma_client.get_or_create_collection(...)` (`src/embedding_lanes.py`), which supports multiple simultaneous embedding backends ("lanes": local FastEmbed ONNX, or a configured custom embedding endpoint), each with its own Chroma sub-collection, searched in parallel and de-duplicated.
- `MemoryVectorStore` exposes `add(memory_id, text)`, `remove(memory_id)`, `search(query, k)`, and a `healthy` flag. This is the natural, already-available source of pairwise similarity scores for auto-generating graph edges between memories, since the memory schema itself has no explicit relation data.
- Memories and personal-doc RAG chunks live in separate Chroma collections and are not cross-linked or queried together today.
## 11. Document storage
- Two distinct subsystems:
- "Living documents" (AI-editable canvas documents): `routes/document_routes.py`, backed entirely by the relational DB (`documents`/`document_versions` tables) — content lives in `Text` columns, not on disk. `Document.owner` is stamped independently of `session_id` so a document survives its parent session's deletion.
- "Personal Docs" (RAG document library): `src/personal_docs.py::PersonalDocsManager` walks a directory on disk, extracts text (PDF via `pypdf`, Office via `markitdown`), chunks it, and indexes into the `odysseus_rag` Chroma collection. Files stay on disk; there is no separate SQL metadata table — the Chroma collection's metadata is the catalog.
- Not directly part of the Memory Graph View's data model, but a candidate future edge type ("memory ⟷ document it was extracted from") if the design is extended later.
## 12. Conversation storage
- Sessions and messages are relational DB rows (`sessions`, `chat_messages`), managed exclusively through `core/session_manager.py::SessionManager`.
- `SessionManager` keeps an in-memory cache but only loads the 100 most-recently-accessed, non-archived, non-empty sessions' metadata at boot (not messages) to bound memory; message history is hydrated on demand.
- `routes/history/history_routes.py` supports offset/limit paging directly against `chat_messages`, independent of the in-memory cache, and lazily "hydrates" a session's in-RAM history from the DB if it's found to be behind.
- `sessions.owner` (nullable username string) is the ownership authority that memory, documents, and other per-session resources ultimately trace back to via their own `owner` column or `session_id` FK.
## 13. Agent architecture
- `src/agent_loop.py::stream_agent_loop(...)` is the central async-generator agent loop: streams SSE events (`delta`, `tool_start`, `tool_output`, `agent_step`, `metrics`, `[DONE]`), assembles a dynamic system prompt, and handles plan-mode, tool policies, and per-model quirks.
- `src/tool_execution.py` dispatches model-emitted tool calls either to MCP servers (`src/mcp_manager.py`) or native Python implementations under `src/agent_tools/`. Sensitive filesystem tools are admin-only and path-confined via deny/allow lists.
- `src/agent_runs.py` lets an SSE stream survive a browser disconnect by draining the generator server-side into a replay buffer per session id (does not survive a server restart) — this is the closest existing pattern to a "live push channel," and is the template to follow if the graph view needs live updates (see §14).
- Memory interacts with the agent loop via the two paths described in §9 (automatic context injection, explicit `manage_memory` tool), not via any structural graph traversal today.
## 14. Real-time/event infrastructure
- `src/event_bus.py::fire_event(event_name, owner)` is a **task-automation trigger bus**, not a pub/sub-to-frontend mechanism. It matches `ScheduledTask` rows with `trigger_type == "event"` against the fired event name and runs the matching automation once a threshold count is hit. It has no subscriber API that a browser tab could listen to.
- Publishers of `memory_added`: `routes/memory/memory_routes.py` (on add), `services/memory/memory_extractor.py`, `src/ai_interaction.py` (memory captured during chat). The only built-in consumer is the "Memory Tidy" automation (fires a `consolidate_memory` task every 5 adds). There is currently no `memory_updated` or `memory_deleted` event fired anywhere.
- Genuine per-session pub/sub does exist, but scoped to a single request's own output stream: `routes/chat_routes.py` + `src/agent_runs.py` implement `AgentRun.subscribers` as a `set` of `asyncio.Queue` (one per connected client), fed into a `StreamingResponse(media_type="text/event-stream")`. Similar SSE streaming exists in `routes/shell_routes.py`, `routes/model_routes.py`, `routes/research/research_routes.py`.
- Implication: there is no ready-made mechanism today to push "a memory changed elsewhere" to an open Memory Graph View. The design doc proposes either polling or a new lightweight per-owner SSE channel modeled directly on the `agent_runs.py` queue-per-subscriber pattern.
## 15. Authentication
- `core/auth.py::AuthManager` is JSON-file-backed (`data/auth.json` for users, `data/sessions.json` for session tokens), not a DB table.
- Two callable-facing auth mechanisms, both resolved in `AuthMiddleware` (in `app.py`, gated by `AUTH_ENABLED=true`):
- Cookie session auth: bcrypt-password-gated, 7-day TTL, sets `request.state.current_user` to the resolved username.
- Bearer API-token auth (`Authorization: Bearer ody_...`): matched against bcrypt-hashed `ApiToken` rows with scopes (`routes/api_token_routes.py` already defines `memory:read`/`memory:write` scopes); sets `request.state.current_user = "api"` plus `api_token_owner`/`api_token_scopes`.
- An internal-tool loopback path lets the agent's own HTTP tool calls reach admin-gated routes via `X-Odysseus-Internal-Token` / `X-Odysseus-Owner`.
- Multi-user support is real: `AuthManager.users` is a dict keyed by lowercase username, with admin-gated create/delete/rename, and reserved usernames that can never be created (`internal-tool`, `api`, `demo`, `system`).
- Privilege model: `DEFAULT_PRIVILEGES` includes `can_manage_memory` among others (`can_use_agent`, `can_use_documents`, `can_use_bash`, etc.). Admins get all privileges unconditionally. **Read-only memory routes (`GET /api/memory`, `/timeline`) do not require `can_manage_memory`** — only mutation (add/import) does. Per `THREAT_MODEL.md`, memory management is explicitly listed as available to both admins and non-admins, unlike shell/email/MCP/tokens/settings which are admin-only.
- Ownership pattern to reuse for a graph endpoint: resolve `owner = get_current_user(request)` (or `effective_user(request)` for Bearer-token callers), filter the memory load by that owner, and re-verify ownership on any caller-supplied id via the existing `_verify_memory_owner` helper rather than trusting the id in isolation.
## 16. Docker configuration
- Two-stage `Dockerfile` on `python:3.14-slim`, final image installs `build-essential, cmake, curl, git, nodejs, npm, chromium, tmux, openssh-client, gosu, libgl1, ...` plus the static Docker CLI (no daemon; host Docker socket is bind-mounted separately if enabled). Exposes port 7000; default CMD is `uvicorn app:app --host 0.0.0.0 --port 7000`, wrapped by `docker/entrypoint.sh`.
- `docker-compose.yml` composes four services:
- `odysseus` — the app container; volumes for `data`, `logs`, `.ssh`, `.cache/huggingface`, `.local`; huge environment block (LLM endpoints, embeddings, auth flags, upload limits, PUID/PGID); `depends_on: searxng (healthy), chromadb (started)`.
- `chromadb`**separate container**, image `chromadb/chroma:latest`, bound `127.0.0.1:8100:8000` on the host, named volume `chromadb-data`, `ANONYMIZED_TELEMETRY=FALSE`. No healthcheck (app relies on its own TCP probe + `heartbeat()` instead).
- `searxng` — pinned version, custom entrypoint templating `settings.yml`, healthcheck via Python urlopen, minimal Linux capabilities (`cap_drop: ALL` + a small `cap_add` set).
- `ntfy` — push-notification relay, bound `127.0.0.1:8091:80`.
- `docker/entrypoint.sh` implements the standard PUID/PGID drop-privilege pattern (create/reuse group+user matching host `PUID`/`PGID`, chown a bounded set of data directories, then `exec gosu $ODY_USER "$@"` so signals reach uvicorn directly).
- GPU overlays (`docker/gpu.nvidia.yml`, `docker/gpu.amd.yml`) and an opt-in `docker/host-docker.yml` (mounts the Docker socket) are pure compose overlays, not relevant to the Memory Graph View itself but relevant if it ever needs a background job (e.g. embedding recompute) that benefits from GPU access.
- Nothing about the graph feature requires new Docker services: no new database, no new container. It rides on the existing `odysseus` app container and the existing `chromadb` container.
## 17. Testing framework
- Backend: pytest, configured in `pyproject.toml` (`testpaths=["tests"]`, `asyncio_mode="auto"`, a fixed set of `area_*` taxonomy markers registered in `tests/_taxonomy.py`).
- `tests/conftest.py` is intentionally thin (repo convention: "prefer explicit local setup over hidden global fixtures", per `tests/README.md`). It forces `DATABASE_URL=sqlite:///:memory:` before any `core.database` import, pre-imports modules so later per-file mocking doesn't poison the real ORM for other tests, and stubs a fixed list of optional third-party deps if not installed. There is no shared `TestClient`/seeded-DB fixture.
- Route-testing convention (seen in `tests/test_memory_owner_isolation.py`, `tests/test_memory_routes_session_owner.py`): tests do not spin up a FastAPI `TestClient`/ASGI app. Instead they call the `setup_*_routes(...)` factory directly with real or `MagicMock()` dependencies, look up the target endpoint function off `router.routes` by path+method, and call it directly with a hand-built `Request` stand-in (`SimpleNamespace(state=SimpleNamespace(current_user=...))`). Auth is bypassed by monkeypatching `get_current_user`/`require_user`/`require_privilege` directly on the route module. Ownership tests assert `HTTPException(404)` on cross-owner access and that returned payloads only contain the caller's own data.
- `tests/helpers/` provides `sqlite_db.make_temp_sqlite` and `db_stubs.make_core_db_stub` for tests needing a real file-backed SQLite DB. `tests/run_focus.py` supports marker-based selective runs (e.g. `-m area_routes`).
- Frontend: no Jest/Vitest/Mocha/Playwright/Cypress. Pure-logic JS files are unit-tested via Node's built-in `node:test` runner, invoked as a subprocess from a thin pytest wrapper (e.g. `tests/test_streaming_segmenter_js.py` shells out to `node --test tests/streaming/*.test.mjs`), or via a `vm.createContext()` sandbox that string-shims `import`/`export` out of a production file before running it headlessly (`tests/markdown_codefence_placeholder_regression.mjs`). `tests/bombadil-spec.ts` is a separate fuzzing/property spec (Antithesis-style) for full-app exploration, not part of the normal pytest/CI gate. CI (`.github/workflows/ci.yml`) runs `node --check` (syntax only) over all frontend JS, plus `pytest -q` for everything else.
- Implication for the Memory Graph View: any pure-JS graph-layout/edge-derivation logic should get a `.test.mjs` file run via `node --test`, wrapped by a thin pytest shim, following the existing pattern. DOM/rendering behavior remains manually verified against the running app — there is no automated DOM test harness in this repo today.
## 18. Security posture (relevant to a new data-exposing endpoint)
- `THREAT_MODEL.md` frames Odysseus as a privileged local-access console for trusted users on a private network, not a public multi-tenant SaaS — but multi-user ownership isolation is still a real, tested boundary (see `tests/test_memory_owner_isolation.py`).
- `core/middleware.py`'s `SecurityHeadersMiddleware` already sets `X-Content-Type-Options`, `Referrer-Policy`, `Permissions-Policy`, HSTS (when HTTPS), and a nonce-based CSP (`script-src 'self' 'nonce-{nonce}' https://cdn.jsdelivr.net`) on all normal routes. A new graph view page doesn't need a special CSP branch unless it renders inline HTML/iframe content the way the visual-report/tool-render pages do.
- CORS is configured via `CORSMiddleware` with `allow_origins` from `ALLOWED_ORIGINS` (default `http://localhost,http://127.0.0.1`) and `allow_credentials=True`.
- Known gaps documented in `THREAT_MODEL.md` (no shell/filesystem sandbox for agent tools, an SSRF gap in chat `base_url`, coarse API token scopes) are not directly implicated by a read-only graph endpoint, but the "coarse token scopes" gap matters if the graph is ever exposed over a Bearer API token — it should reuse the existing `memory:read` scope rather than inventing a new one.
## 19. Safest extension points, summarized
Ranked from safest/most isolated to most invasive:
1. **New read-only backend route** `GET /api/memory/graph` inside the existing `routes/memory/memory_routes.py` module (or a new `routes/memory/memory_graph_routes.py` included the same way), reusing `_owner(request)` scoping and the existing `MemoryManager`/`MemoryVectorStore` singletons already wired up in `app_initializer.py`. No new tables, no new services, no new containers.
2. **New frontend module** `static/js/memoryGraph.js`, registered with `Modals.register(...)` the same way `memory-modal` already is, either as a new tab inside the existing Brain modal or a sibling modal reachable from it. No changes to `app.js`'s routing map are strictly required if it's a tab; one new `_routeOpen` entry if it's a standalone deep-linkable view.
3. **A vendored graph-rendering library** dropped into `static/lib/`, following the existing vendoring convention (no npm/bundler involvement).
4. **Optional: a new SSE channel** for live updates, modeled directly on `src/agent_runs.py`'s per-subscriber `asyncio.Queue` pattern, with new `memory_updated`/`memory_deleted` `fire_event()` calls added at the existing pin/update/delete call sites. This is additive and does not touch the existing task-automation consumer of `memory_added`.
5. **Not recommended as a first step**: touching the dormant SQL `memories` table, since it is not the runtime source of truth today and reconciling it would be a separate, larger migration unrelated to shipping a graph view.
No area inspected requires a new database engine, a schema migration to an existing hot-path table, or a new Docker service.
## 20. Post-implementation addendum
Everything above was written before any code existed. Implementation has since started (Milestones 12 of `docs/memory-graph-design.md`); this section records what that process confirmed, corrected, or added to the picture above. Full detail lives in `docs/progress.md` / `docs/handoff.md` — this is a short pointer, not a duplicate.
- **§3/§19 confirmed in practice**: the "tab inside the Brain modal" recommendation was *not* what got built — the user's Milestone 2 instructions explicitly asked for a dedicated top-level nav item and a dedicated page, which is what exists now (a new `#tool-memory-graph-btn` in the sidebar Tools section, opening its own `memory-graph-modal`). Worth knowing if you re-read the original §19 recommendation and wonder why it doesn't match the code.
- **§3 correction**: the "Brain"/"Calendar" modals looked, from `index.html`'s static markup, like they might be simple declarative panels. In practice their real open/close/register logic lives in `app.js` (Brain) or the module itself (`calendar.js`'s `_getModal()`), and `calendar.js`'s pattern — lazily build the modal element in JS, append to `document.body` once, register with `modalManager.js`'s `Modals.register(...)` — turned out to be the cleanest, most self-contained template to copy for a brand-new modal, requiring zero edits to `app.js`'s older hardcoded Escape-key modal-id arrays. `memoryGraph.js` follows this template exactly.
- **§17 (testing framework) confirmed useful**: the "call the endpoint function directly" convention was followed for all new route tests, plus one deliberate exception — a real `fastapi.testclient.TestClient` test for the route-ordering fix specifically, because that convention *cannot* catch an ordering bug (it never exercises actual Starlette path matching). See `tests/test_memory_graph_route_ordering.py`.
- **New finding, not in the original analysis**: `src/agent_loop.py` has a pre-existing bug (missing `from typing import Any`) that hard-crashes `app.py` at import time in this sandbox's Python 3.11/3.14 environment. Confirmed via `git stash` to reproduce identically on a clean `dev` checkout — unrelated to this feature, but blocking enough that it had to be patched locally just to launch the app for a demo. See `docs/handoff.md` → Risks for what to do about it.
- **New finding, not in the original analysis**: the local ChromaDB instance's collections (`odysseus_memories`, `odysseus_rag`) are shared/global regardless of which `ODYSSEUS_DATA_DIR` the app process points at — only the JSON file store and the SQL `owner` column are per-deployment/per-owner. This matters for anyone manually testing against a real Chroma instance: seeded test data must be cleaned up via the real `DELETE` endpoints (which correctly call `memory_vector.remove()`), not just by discarding a scratch data directory.

197
docs/memory-graph-design.md Normal file
View file

@ -0,0 +1,197 @@
# Memory Graph View — Design
Status: **approved and partially implemented.** This proposal builds on the findings in `docs/memory-graph-analysis.md`. The user approved this design and requested implementation; Milestone 1 (backend) is complete and committed, Milestone 2 (frontend) is written but uncommitted and not yet visually verified. See `docs/progress.md` for the session log, `docs/todos.md` for the live checklist, and `docs/handoff.md` for the exact next task and a "Resolved open questions" note below. The rest of this document is kept as originally written (the plan), with corrections/decisions layered in inline where implementation diverged from the original proposal.
## Goals / non-goals
**Goals**: an interactive, pannable/zoomable node-link visualization of a user's own memories (Obsidian-graph-like), where nodes are memories and edges represent derived relationships (semantic similarity, shared session, shared category), reachable from the existing "Brain" memory modal, scoped strictly to the owning user.
**Non-goals for v1**: cross-user graphs, graphs spanning memories + documents + sessions in one view (a plausible v2 extension, not required now), real-time multi-tab collaborative editing of the graph, migrating the dormant SQL `memories` table into active use.
## 1. Architecture
The feature is additive and follows the existing "component bag" / manual-wiring conventions already used throughout the codebase — no new architectural pattern is introduced.
```
Browser (static/js/memoryGraph.js, new tab in #memory-modal)
│ fetch('/api/memory/graph?...')
FastAPI route: routes/memory/memory_graph_routes.py
setup_memory_graph_routes(memory_manager, memory_vector, session_manager)
Service: src/memory_graph.py (new, pure-ish module)
build_graph(owner, filters) -> {nodes, edges, meta}
│ │
▼ ▼
MemoryManager.load(owner=...) MemoryVectorStore.search(...) per-node kNN via Chroma
(data/memory.json, existing) (odysseus_memories collection, existing)
```
- **New backend module**: `routes/memory/memory_graph_routes.py`, mounted in `app.py` next to the existing `setup_memory_routes(...)` call, receiving the same already-constructed `memory_manager` and `memory_vector` singletons from `app_initializer.initialize_managers()`. No new singleton, no new startup step.
- **New service module**: `src/memory_graph.py` holds the graph-building logic (node assembly + edge derivation), kept separate from the route file so the edge-derivation logic is unit-testable without FastAPI in the loop (see §10).
- **New frontend module**: `static/js/memoryGraph.js`, imported from `static/app.js` alongside the existing `memory.js` import, following the same default-export-object + optional `window.*` exposure convention.
- **New tab**, not a new modal: added to the existing `.memory-tabs` strip in `index.html` (`Browse | Skills | Add | Settings | Graph`) and registered as a fifth tab panel inside `#memory-modal`. This reuses the modal's existing `Modals.register('memory-modal', ...)` entry, its drag/dock/minimize/z-order behavior, and its existing auth/ownership context — no new modal-manager registration needed. A standalone modal was considered and rejected for v1 (more registration/dock plumbing for no functional benefit; a tab is also the closer analogue to Obsidian's own "Graph view" living alongside the file browser).
- **Graph-rendering library**: recommend vendoring **Cytoscape.js** (single UMD file) into `static/lib/cytoscape.min.js`, following the existing vendoring convention used for `docx.umd.min.js`, `xlsx.full.min.js`, etc. Cytoscape is purpose-built for node-link graphs, ships a canvas renderer (needed for performance past a few hundred nodes — see §5), and bundles pan/zoom/drag interaction out of the box, so no additional libraries (separate zoom/drag/force packages) need to be vendored. D3 (force + zoom + drag composed manually) was considered as an alternative; Cytoscape was chosen because it is a single dependency rather than several composed low-level pieces, and its declarative style/selector API maps naturally onto category-based node styling. **This library choice is a recommendation, not a decision already made — flagging it explicitly for approval before implementation** (see Open Questions).
## 2. Database changes
**None required for v1.** Edges are computed on request from existing data (`data/memory.json` + the `odysseus_memories` Chroma collection); nothing new is persisted. This deliberately avoids two riskier paths identified in the analysis: touching the dormant SQL `memories` table (unused by the runtime today, so "fixing" it is an unrelated, larger migration) and adding a hand-rolled SQLite migration for a brand-new table.
If a later phase adds **explicit user-drawn links** (an Obsidian-style manual connection between two memories, as opposed to a derived one), the proposed change is still additive and still avoids SQL:
- Add an optional `links: []` array field to entries in `data/memory.json`, defaulting to `[]` when absent (`entry.get("links", [])`) so every existing entry remains valid without a migration pass.
- `MemoryManager` already owns read/write of this file; the change is a new optional key, not a schema/format break. Old app versions reading a file with this key simply ignore it (JSON is forward-tolerant), and rollback requires no data cleanup (see §9).
No changes to `core/database.py`, no new Alembic-equivalent `_migrate_add_*` function, no new SQLAlchemy model.
## 3. API design
New endpoints, added to the existing `/api/memory` prefix, following the file's existing conventions (owner-scoped via `get_current_user`/`effective_user`, 404-not-403 on cross-owner id access, `HTTPException` for errors):
### `GET /api/memory/graph`
Returns the full owner-scoped graph (nodes + derived edges), subject to server-side limits (see §5).
Query parameters (all optional):
| Param | Type | Default | Meaning |
|---|---|---|---|
| `category` | string, repeatable | none | Filter nodes to one or more of the existing `MEMORY_CATEGORIES` |
| `min_similarity` | float 01 | 0.75 | Minimum cosine similarity to draw a semantic edge |
| `max_edges_per_node` | int | 5 | Cap on nearest-neighbor edges per node (bounds edge count) |
| `include_session_edges` | bool | true | Whether to draw "extracted from the same session" edges |
| `include_category_edges` | bool | false | Whether to draw "same category" edges (off by default — high fan-out) |
| `limit` | int | 1000 | Max nodes returned (see §5 for behavior beyond this) |
| `since` | unix ts | none | Only include memories created/updated after this time (for incremental/lazy loading) |
Response shape:
```json
{
"nodes": [
{"id": "mem_123", "text": "...", "category": "preference", "pinned": false,
"uses": 3, "timestamp": 1769..., "session_id": "sess_abc"}
],
"edges": [
{"source": "mem_123", "target": "mem_456", "type": "similarity", "weight": 0.86},
{"source": "mem_123", "target": "mem_789", "type": "session", "weight": 1.0}
],
"meta": {"node_count": 214, "truncated": false, "generated_at": 1769...}
}
```
Auth/ownership: identical pattern to `GET /api/memory``require_user(request)` (no `can_manage_memory` needed, matching that read-only memory listing today requires no special privilege), owner resolved via `effective_user(request)` so Bearer-token callers are scoped correctly, and the response is built exclusively from `memory_manager.load(owner=user)` — no id-based lookup path exists on this endpoint, so there's no cross-owner leakage vector to guard beyond the load-time filter.
### `GET /api/memory/graph/{id}/neighbors`
Lazy-expansion endpoint for a single node's immediate neighbors, for progressive loading on large graphs (see §5). Reuses the existing `_verify_memory_owner(id, owner)` 404-on-mismatch helper before returning anything.
### `POST /api/memory/{id}/links` / `DELETE /api/memory/{id}/links/{target_id}` (phase 2, optional)
Manual link create/remove, gated by `require_privilege(request, "can_manage_memory")` exactly like other memory mutations (`PUT`/`DELETE /api/memory/{id}`). Not required for v1; documented here so the API surface is planned coherently rather than bolted on later.
### API token scope
The graph read endpoint is exposed under the **existing** `memory:read` scope already defined in `routes/api_token_routes.py` — no new scope is introduced, consistent with the analysis's note about avoiding further scope proliferation.
## 4. UI design
**Entry point**: new "Graph" tab in the existing `.memory-tabs` strip (`index.html`), alongside Browse/Skills/Add/Settings. Selecting it lazily creates the Cytoscape instance on first open (not on modal load) to avoid any cost for users who never click it.
**Canvas**: fills the tab panel body, resizing with the modal (a `ResizeObserver` on the panel calls `cy.resize()` + `cy.fit()`, since `windowDrag.js`/`tileManager.js` already support the user resizing/tiling the modal itself).
**Node styling** (mirrors the existing Browse-tab visual language rather than inventing a new one):
- Fill color by category, reusing the same category color mapping already defined for the Browse tab's category chips/badges in `style.css`.
- Size scaled by `uses` (more-referenced memories render larger, echoing their real weight in chat context injection).
- Pinned memories get a distinct border/ring, matching the pinned badge already used in the list view.
- Node label: truncated memory text (first ~40 chars), full text on hover tooltip.
**Edge styling**:
- Semantic-similarity edges: solid line, opacity/thickness scaled by `weight` (cosine similarity).
- Same-session edges: dashed line, fixed low opacity, to visually separate "structurally related" from "semantically related" without cluttering the primary read.
- A small legend (reusing the existing modal's compact panel styling) explains the two edge types and lets the user toggle each on/off — mapped directly to the `include_session_edges`/`include_category_edges` query params.
**Interactions**:
- Pan/zoom: native Cytoscape gestures.
- Click node: opens a side panel (or reuses the existing inline-edit affordance from the Browse tab) showing full text, category, pin state, use count, and Pin/Edit/Delete actions — deliberately reusing `memory.js`'s existing edit/pin/delete logic rather than re-implementing it, so behavior (and any future changes to it) stays in one place.
- Double-click node: same inline-edit flow already used in the Browse tab.
- Drag node: repositions it client-side only (not persisted), matching how Obsidian's own graph behaves — dragged position is not sent back to the server.
- Search box (reusing the existing `#memory-search` pattern): highlights/centers matching nodes instead of filtering a list.
- Category filter chips (reusing the existing Browse-tab chip component): toggle node visibility by category.
- Similarity threshold slider: re-queries `min_similarity` and redraws edges (debounced, see §5).
- Click-to-isolate: clicking a node dims everything outside its connected component, a common Obsidian-graph affordance, implemented purely client-side against the already-loaded graph (no extra request).
**Empty/loading/error states**: follow existing modal conventions — spinner (`spinner.js`) while the initial `GET /api/memory/graph` is in flight, an empty-state message reusing the Browse tab's "no memories yet" copy/style when the user has zero memories, and a plain inline error message (no full-modal takeover) on request failure, consistent with how other tabs degrade.
**Accessibility note**: a pure canvas graph is not screen-reader navigable; the existing Browse tab remains the accessible list view of the same data, so the Graph tab is explicitly an additional visualization, not a replacement — nothing about memory access requires the graph to work.
## 5. Performance considerations
- **Server-side edge computation must avoid O(n²) pairwise similarity.** Use Chroma's own ANN query per node (`MemoryVectorStore.search(text, k=max_edges_per_node)`) rather than brute-force pairwise cosine over all memories. This is a set of N approximate-nearest-neighbor queries (already fast, since Chroma is built for this), not N² raw comparisons.
- **Cap response size.** Default `limit=1000` nodes; if an owner has more memories than that, the endpoint returns the most-recent/most-used `limit` nodes plus `meta.truncated=true`, and the frontend shows a "showing N of M — refine filters to see more" notice rather than silently dropping data. The `/graph/{id}/neighbors` endpoint exists precisely so a user can drill into the truncated remainder on demand instead of the server ever needing to return everything at once.
- **Canvas over SVG.** Cytoscape's canvas renderer is required, not optional, once node count exceeds roughly 200300 — an SVG-per-node approach (as some hand-rolled D3 examples use) degrades badly at that scale by DOM node count alone. This is why Cytoscape (canvas-first) was preferred over a naive D3+SVG approach in §1.
- **Debounce filter changes.** The similarity-threshold slider and category toggles should debounce their re-fetch (e.g. 250ms) rather than firing a request per slider tick.
- **Server-side short-lived cache.** Cache the computed `{nodes, edges}` graph per owner (in-process, e.g. a small dict keyed by `owner` with a TTL of a minute or two, or invalidated eagerly on `memory_added`/`memory_updated`/`memory_deleted` if the optional SSE work in §1 of the analysis is picked up later). This avoids recomputing kNN edges on every tab-open within a short window, at negligible memory cost given the existing single-process assumption already baked into the app.
- **Target scale.** This is a personal/local-assistant memory store, not a general knowledge base — the design explicitly targets smooth interaction for roughly 2002,000 memories per user, with graceful truncation (not failure) beyond that, rather than engineering for unbounded scale from day one.
## 6. Scalability
- **Multi-user isolation carries no new risk**: the graph endpoint reads through the same `memory_manager.load(owner=user)` owner filter every other memory route already uses, so per-user data volume and per-user isolation both scale exactly as well (or as poorly) as the existing memory list/search endpoints do today.
- **No new infrastructure dependency.** No new database engine, no new container (confirmed in the analysis: ChromaDB is already a separate container the app already depends on). Scaling the graph feature scales exactly with scaling the app's existing single-process/single-SQLite/single-Chroma-container deployment model — this proposal does not change that model or its known limits.
- **Phase-2 scaling lever (only if needed)**: if a user's memory count grows large enough that on-request kNN computation becomes noticeably slow even with caching, the next lever is an incrementally-maintained nearest-neighbor cache (refreshed only for the new/changed memory on each `memory_added`/`memory_updated`, not recomputed from scratch), rather than anything to do with the graph *rendering* — the render side is already the cheap part once node count is capped per §5.
## 7. Security
- **No new privilege boundary.** `GET /api/memory/graph` requires only `require_user`, matching the existing `GET /api/memory` and `/timeline` endpoints — consistent with `THREAT_MODEL.md` explicitly listing memory management as available to non-admins. Mutating endpoints (optional manual links, phase 2) require `can_manage_memory`, matching existing memory-mutation routes.
- **No new cross-owner leakage vector.** The list endpoint has no id-based lookup path (built entirely from an owner-filtered load), and the neighbors/link endpoints reuse the existing `_verify_memory_owner` 404-on-mismatch helper rather than introducing a new ownership check pattern.
- **XSS hygiene in rendering.** Memory text is user-authored/LLM-extracted content rendered into node labels/tooltips — it must go through the same escaping helper already used elsewhere in the frontend (`uiModule.esc()` per the existing markdown-rendering convention) before being placed in any DOM tooltip; Cytoscape's own canvas node labels are not DOM-injected and are safe by construction, but any HTML side-panel showing full memory text on click must still escape it.
- **API token scope reuse, not expansion.** Exposing the read endpoint under the existing `memory:read` scope avoids adding to the token-scope surface, per the analysis's note on the "coarse scopes" gap already tracked in `THREAT_MODEL.md`.
- **Light rate limiting.** Even in a trusted-local-network threat model, add a light per-owner limit (reusing `src/rate_limiter.py`, the same mechanism already used for login) on `GET /api/memory/graph` so a misbehaving client/tab can't hammer Chroma with repeated kNN queries — cheap insurance, not a response to a specific known threat.
- **No prompt-injection surface introduced.** This is a display endpoint; it does not feed memory text back into an LLM prompt, so the existing `untrusted_context_message()` wrapping requirement (which already governs memory text reaching the agent loop) is unaffected and does not need to be duplicated here.
## 8. Migration strategy
No data migration is required for v1 (§2). The rollout is staged purely at the feature-flag / code level:
- **Phase 1 — backend only.** Ship `routes/memory/memory_graph_routes.py` + `src/memory_graph.py`, mounted in `app.py`, with no frontend entry point yet. Fully testable via the existing route-factory test convention (§10) and manual `curl`, with zero user-visible surface — the lowest-risk possible increment.
- **Phase 2 — frontend behind an opt-in setting.** Ship the "Graph" tab gated by a Settings-tab toggle (e.g. "Enable Memory Graph (beta)"), defaulting **off**, so existing users see no change until they opt in. This mirrors how a genuinely new, unproven interaction pattern should be introduced into an app whose UI conventions (flat list, modal-based navigation) this feature is a first departure from.
- **Phase 3 — default-on.** After a soak period with no material bug reports, flip the default to on and drop the "(beta)" label. The toggle itself can remain as a permanent "hide Graph tab" preference for users who simply don't want it, or be removed — a call to make at that time, not now.
- **Phase 4 — optional extensions**, proposed separately and not part of this design's approval scope: manual linking (§2/§3 phase-2 bits), live SSE-based updates (per the analysis's §14 finding that no such channel exists today), and cross-linking memories to the documents/sessions they were extracted from.
Each phase is independently shippable, independently revertable, and does not block on the next phase being designed yet.
## 9. Rollback strategy
- **Backend rollback**: the new route module is additive and isolated — removing its `include_router(...)` call (or the whole file) from `app.py` has zero blast radius on any other route, since it shares only already-existing, already-stable singletons (`memory_manager`, `memory_vector`) and defines no new schema. Rollback is a plain code revert.
- **Frontend rollback**: with the Phase-2 opt-in flag, disabling the flag hides the tab immediately with no deploy needed. A full revert (removing the module, the tab markup, and the flag) is likewise a plain code revert — no other module should come to depend on `memoryGraph.js` internals, which should be verified (grep for imports of it) before merging any phase past Phase 1.
- **No data to roll back.** Because v1 stores nothing new (§2), there is no "undo a migration" step, no backfill to reverse, and no risk of orphaned rows or half-migrated JSON — rollback is strictly a code-level operation at every phase of v1.
- **If the optional `links` JSON field (§2, phase-2 extension) ships later**: rolling back the code that reads/writes it leaves harmless, inert data behind (`links: []` or a populated array), which older `MemoryManager` code already tolerates by construction (unknown/extra JSON keys are never rejected) — no cleanup pass is required unless the user explicitly wants the field purged, which would be a separate, deliberate data-cleanup action, not an automatic part of rollback.
## 10. Testing strategy
- **Backend route tests** (`tests/test_memory_graph_routes.py`, new): follow the repo's established convention exactly — build the router via `setup_memory_graph_routes(...)` directly (no `TestClient`/ASGI app), look up the endpoint function off `router.routes` by path, call it directly with a hand-built `Request` stand-in, and bypass auth via `monkeypatch.setattr(memory_graph_routes, "get_current_user", ...)` / `require_user`. Required cases:
- Owner isolation: a graph built for owner A contains none of owner B's memories (mirrors `tests/test_memory_owner_isolation.py`).
- `min_similarity`/`max_edges_per_node`/`category` filters change the returned edge/node sets as expected.
- `limit`/`meta.truncated` behavior when an owner has more memories than the limit.
- `/graph/{id}/neighbors` 404s on a foreign-owned id (reusing the existing `_verify_memory_owner` test pattern).
- **Edge-derivation unit tests** (`tests/test_memory_graph_edges.py`, new): the similarity/session/category edge-building logic in `src/memory_graph.py` should be a pure function taking pre-fetched memory + neighbor data and returning edge lists, so it's table-driven-testable without Chroma or FastAPI in the loop — pass in synthetic embeddings/neighbor lists and assert threshold and top-k truncation behavior deterministically.
- **Frontend pure-logic tests**: any pure-JS piece of `memoryGraph.js` that isn't DOM-dependent (mapping an API response into Cytoscape `elements`, category→color lookup, client-side connected-component isolation on click) gets a `.test.mjs` file run via `node --test`, wrapped by a thin pytest shim — following the exact precedent of `tests/test_streaming_segmenter_js.py` / `tests/streaming/invariant.test.mjs`.
- **Manual/DOM verification**: since the repo has no DOM/browser test harness (§17 of the analysis), pan/zoom/drag/click interactions and visual correctness (category colors, edge styling, legend, resize behavior) are verified manually against the running dev server before merge, and this should be stated explicitly in the PR description rather than implied — consistent with how the repo already documents this limitation for other rendering-heavy modules.
- **Performance smoke test**: seed a temporary `MemoryManager` with a few hundred and a few thousand synthetic entries and assert `GET /api/memory/graph` completes within a defined latency budget with a warm cache — a regression guard against an accidental reintroduction of O(n²) pairwise comparison.
- **Marker tagging**: new tests get the existing `area_routes`/`area_services` markers per `tests/_taxonomy.py` so they're picked up by the same `-m area_routes` selective-run convention the rest of the suite already uses.
## Open questions — resolved
The four questions originally posed here have since been answered by explicit user instruction (see `docs/progress.md`, Session 1 → Session 2 transition). Recorded here for anyone re-reading this doc without that context:
1. **Graph library**: **Cytoscape.js**, as recommended. Vendored at `static/lib/cytoscape.min.js` (3.34.0, MIT), fetched via `npm pack cytoscape@3` rather than a hand-typed CDN URL. D3 and a from-scratch renderer were not pursued.
2. **Tab-in-modal vs. standalone modal**: **standalone.** The user explicitly asked for "a new Memory Graph navigation item" and "a dedicated Graph page" — this superseded the original tab-in-Brain-modal recommendation. What's built: a new `#tool-memory-graph-btn` in the sidebar Tools section, opening its own `memory-graph-modal` (built and registered exactly like `calendar.js`'s modal — see `docs/handoff.md` for why that template was chosen over the Brain modal's own pattern).
3. **Manual linking**: **in scope**, from Milestone 1 onward, not deferred to a later phase. `POST /api/memory/{id}/links` / `DELETE /api/memory/{id}/links/{target_id}` shipped in the same backend milestone as the read endpoint, and the frontend's "Link mode" toolbar toggle + per-node "Start link" action + per-relationship remove (×) in the detail panel shipped in Milestone 2.
4. **Opt-in beta flag vs. default-on**: **neither, precisely.** The user's instruction was "Feature flag enabled (beta)," which was implemented as: the nav item is visible by default (gated only by the existing `can_manage_memory` privilege, same as every other memory entry point) and carries a static "beta" label in the modal header — there is no separate togglable setting. This is flagged as an explicit open question needing confirmation in `docs/handoff.md`, since "feature flag" could reasonably have meant an actual on/off switch (the original Phase 2 proposal below, under "Migration strategy") rather than just a visible label.
## Deviations from the original plan worth knowing
Beyond the four resolved questions above, implementation diverged from the letter of this document in two ways, both deliberate and both recorded in more detail in `docs/handoff.md`:
- **§3 API design** describes per-request query params (`category`, `min_similarity`, `max_edges_per_node`, etc.) driving what the server returns. What's actually built: the frontend fetches once per open (or after a mutation) at a generous floor (`min_similarity=0.5`, `max_edges_per_node=8`) and does all category/similarity/search filtering **client-side** against that cached graph via Cytoscape's own display/class toggling. The query params themselves still exist and work server-side (see `routes/memory/memory_graph_routes.py`); the frontend just doesn't re-hit them on every filter interaction, trading a slightly-higher initial payload for instant filter feedback.
- **§4 UI design**'s edge-type distinction (similarity solid/opacity-by-weight, session dashed, manual distinct-color-solid) was carried through as specified. The legend, search-highlight, and category-filter-chip pieces were all built in Milestone 2 rather than deferred to "Milestone 3 polish" as this document's phasing loosely implied — the user's Milestone 2 instructions listed them as hard requirements for that milestone, not nice-to-haves.
No further action will be taken beyond what's recorded in `docs/todos.md` until the user resumes work.

146
docs/progress.md Normal file
View file

@ -0,0 +1,146 @@
# Memory Graph View — Progress Log
Session-by-session record. Newest entry on top. See `docs/todos.md` for the live checklist and `docs/handoff.md` for the exact next action.
---
## Session 5 — Milestone 3 (polish), committed and visually verified
**Open question resolved first**: asked the user whether "feature flag enabled (beta)" needed a real togglable switch or whether the shipped privilege-gate + static "beta" label was sufficient — confirmed the latter. No code change from this; just closes the open item from Sessions 3/4.
### What was done
1. **Collapsible legend**: restructured the legend markup into a clickable header (`Legend ▾`) plus a body of the existing three legend rows; a `.collapsed` class (toggled on click, wired once in `_wireToolbar()`) hides the body and rotates the caret. `.memory-graph-legend`'s `pointer-events: none` had to be dropped (it predated the legend having anything interactive in it) — the legend DOM node isn't recreated by `_renderGraph()`, so collapse state naturally persists for the life of the modal without extra JS state.
2. **Isolate-component affordance**: added a pure `_componentNodeIds(graph, rootId)` (undirected BFS, no DOM/Cytoscape dependency — deliberately factored out this way so it's unit-testable) and wired it into `_applyFilters()` alongside the existing category filter (`categoryOk && isolateOk`). A new "Isolate"/"Show all" toggle button in the detail panel (`_toggleIsolate`) sets/clears `_isolateRootId`; a banner (`#memory-graph-isolate-banner`, reusing the existing `.memory-graph-demo-banner` CSS class since the two can never be visible at once — isolate is unreachable in demo mode, same as every other detail-panel action) reports "Isolated — showing N connected memories". Clears automatically on background click (`_clearSelection`) and on every graph reload (`_renderGraph`), so it never survives a mutation or a stale state across reopens.
3. **Keyboard shortcuts**: renamed the modal's `_escHandler` to `_keyHandler` (it now does more than Escape) and extended it: `f` (no modifiers) focuses the search box; `ArrowRight`/`ArrowDown` and `ArrowLeft`/`ArrowUp` cycle the selection through `_visibleNodeIds()` (nodes whose Cytoscape `display` style isn't `none` — so navigation automatically respects whatever category/similarity/isolate filters are active) and re-center the camera on the new selection. Both guarded: skipped entirely while typing in any input/textarea (so they don't hijack normal typing in the search box or the edit textarea), and skipped entirely while the modal is minimized (a latent gap the old Escape-only handler already had — Escape would still fire over a hidden modal — fixed as a byproduct of touching this function anyway, not a new regression).
4. **`MODULE_SUMMARY.md`**: added the `memoryGraph.js` row to §6 (Knowledge, Memory, and RAG), describing the module and its M1/M2/M3 feature set for future readers who haven't touched this feature before.
5. **Automated frontend tests**: `tests/memoryGraph/graphHarness.mjs` loads `static/js/memoryGraph.js` under Node via the exact `vm.createContext()` + import-string-shim pattern already established by `tests/markdown_codefence_placeholder_regression.mjs` (that script itself turned out to be an orphan — not wired into pytest or CI anywhere, only referenced as a documented pattern in `docs/memory-graph-analysis.md` — a pre-existing gap, not touched). `tests/memoryGraph/pureLogic.test.mjs` (10 `node:test` cases) covers: `_nodeSize` clamping, `_categoryColor` resolving every known category to a distinct color plus its fallback, `_buildQuery`'s exact query string, `_toElements` label truncation and — importantly — dropping edges whose endpoints aren't in the given node set (the same referential-integrity class of bug that would otherwise crash Cytoscape's element construction), `DEMO_GRAPH`'s own referential integrity and category validity, and `_componentNodeIds` (connected component, an isolated node with no edges, and a nonexistent root id). Wrapped by `tests/test_memory_graph_pure_logic_js.py`, mirroring `tests/test_streaming_segmenter_js.py`'s `node --test` subprocess + skip-if-no-node pattern — this is what actually gets picked up by `pytest -q`, unlike the orphaned codefence script.
6. **Live browser verification**: launched a second isolated instance (port 7011, separate scratch data dir), seeded 5 memories (2 near-duplicate identity nodes for a similarity edge, a manually-linked Lighthouse pair, one fully isolated Python-preference node), and verified: legend collapse/expand, `f` focusing the search box, arrow-key navigation selecting nodes and centering the camera, and the isolate toggle both ways (isolating down to the 2-node Lighthouse component with the correct banner count, then "Show all" correctly restoring the rest of the graph). Cleaned up identically to Session 4: all 5 memories deleted via the real API, confirmed zero orphaned vectors in the shared `odysseus_memories_fastembed` Chroma collection, server killed by verified `Get-NetTCPConnection` PID.
7. **Regression pass**: `node --check` clean on all touched JS. The 27 M1 backend tests + the new 10-case JS suite (as one pytest test) all pass — 28 total. `pytest --collect-only` across the whole suite shows exactly the same 5 pre-existing collection errors as documented before this session (the `mcp` package version mismatch across 4 files, plus one unrelated `UnicodeDecodeError` in a document-diff test) — zero new failures introduced.
8. Committed as 3 commits: the M3 feature code (legend + isolate + keyboard shortcuts + `MODULE_SUMMARY.md`), the new pure-logic test suite, and this docs update.
### Files changed this session
| File | Change |
|---|---|
| `static/js/memoryGraph.js` | collapsible legend, isolate-component affordance (`_componentNodeIds`, `_toggleIsolate`, `_renderIsolateBanner`), keyboard shortcuts (`_keyHandler` rename + `f`/arrow handling, `_visibleNodeIds`/`_navigateNodes`) |
| `static/style.css` | legend header/collapse rules, dropped `pointer-events: none` from `.memory-graph-legend` |
| `static/js/MODULE_SUMMARY.md` | new `memoryGraph.js` row |
| `tests/memoryGraph/graphHarness.mjs`, `tests/memoryGraph/pureLogic.test.mjs`, `tests/test_memory_graph_pure_logic_js.py` | new — pure-logic test suite |
| `docs/handoff.md`, `docs/progress.md`, `docs/todos.md` | updated to reflect M3 done/verified/committed |
### Push attempt
After M3 was committed, `git push -u origin feature/memory-graph-view` was attempted and failed:
```
remote: Permission to odysseus-dev/odysseus.git denied to yakamoz221.
fatal: unable to access '...': The requested URL returned error: 403
```
The `origin` remote is `https://github.com/odysseus-dev/odysseus.git`; the authenticated GitHub account (`yakamoz221`) doesn't have write access to it. Not something a coding session can fix on its own — needs the user to sort out repo permissions, point at a fork, or push via different credentials. The branch is fully committed locally (11 commits total on top of `dev`) and otherwise ready; see `docs/handoff.md` for the exact next step.
---
## Session 4 — Milestone 2 verification, two bugs found and fixed, M2 committed
**Goal**: pick up exactly where Session 3 stopped — get a real, visual confirmation the Memory Graph tab works, then commit M2.
### What was done
1. Launched an isolated instance (`ODYSSEUS_DATA_DIR`/`DATABASE_URL` in the session scratchpad, `AUTH_ENABLED=false`, `LOCALHOST_BYPASS=true`, `CHROMADB_PORT=8100`, `APP_PORT=7010`). ChromaDB was still reachable at `localhost:8100` as documented. FastEmbed model download completed this time (was cached from the aborted Session 3 attempt).
2. Seeded the exact 7-memory set from `docs/handoff.md`'s Session 3 plan via `POST /api/memory/add`, pinned one, and attempted the manual link between the two Lighthouse entries via `POST /api/memory/{id}/links`.
3. **Found bug 1 (backend, Milestone 1 code)**: the link call 404'd and `GET /api/memory/graph` returned zero nodes despite 7 memories existing. Root cause: `memory_graph_routes.py` used `require_user(request)` (returns `""` in single-user/no-auth/localhost-bypass mode) for owner resolution, while every route in `memory_routes.py` uses a local `_owner(request)` = `get_current_user(request)` (returns `None` in that same mode). `MemoryManager.load(owner=...)` and `_verify_memory_owner()` both special-case `None` as "no filter / bypass ownership check" — but `""` is not `None`, so `load(owner="")` filtered strictly against entries that never got `owner=""` stamped (since `add_entry` only sets the `owner` key when it's truthy). Fixed by switching the graph and links routes to the same `_owner()`/`get_current_user()` pattern already used everywhere else in the memory routes (added a local `_owner()` helper to `memory_graph_routes.py`, matching `memory_routes.py`'s). Verified via curl: graph then returned all 7 nodes plus the expected similarity edge (0.875, between the near-duplicate identity pair) and the manual link edge. Committed separately from the M2 frontend work, since it's a fix to already-committed M1 code, not new M2 code.
4. Restarted the server (data survives — only the process was killed) and re-verified the fix, then moved to browser testing.
5. Opened `/` in Chrome, clicked "Memory Graph" in the sidebar Tools list. Modal rendered correctly: 7 nodes, similarity edge, manual link edge, category chips, search box, min-match slider, Link mode button, legend.
6. **Found bug 2 (frontend)**: clicking a node correctly highlighted its neighborhood but no detail panel appeared. Traced to `static/js/memoryGraph.js`: the panel div (`#memory-graph-detail`) is created with a hardcoded `hidden` class in the initial modal template, and `_renderDetailPanel()` only ever set `panel.innerHTML` — nothing removed the `hidden` class on selection. Fixed with a one-line `panel.classList.remove('hidden')` at the top of `_renderDetailPanel()`. Re-verified: panel now shows category tag, text, uses/pinned meta, timestamp, and Unpin/Edit/Start-link/Delete buttons on node click.
7. **Found bug 3 (frontend)**: typing a search term didn't visually surface matches — matching nodes stayed at `opacity:0.08` because a prior node-selection's `.mg-dimmed` class (added by `_highlightNeighborhood`) was never cleared by `_applySearch()`, which only ever added/removed its own `.mg-search-match` class. Fixed by having `_applySearch()` clear `_selectedId` (resetting the detail panel) and strip `mg-highlighted`/`mg-dimmed` at the top of the function, so a fresh search cleanly supersedes any leftover selection state. Re-verified: searching "lighthouse" now shows all nodes at full opacity with the two matching nodes ringed.
8. Verified category chip filtering (isolating "identity" correctly showed only the 2 identity nodes), the min-match similarity slider (raising it above 0.875 correctly hid the identity-pair similarity edge), link-mode's two-click create flow end-to-end through the actual UI (new manual edge appeared after the automatic post-mutation reload, proving the bug-1 fix works from the UI path too, not just curl), the inline Edit textarea (Save/Cancel), and a theme switch (light theme via the Theme modal — all non-canvas chrome recolored live via CSS variables as expected; canvas node fill recompute-on-render-only limitation, already documented, was reconfirmed and left as a Milestone 3 item rather than fixed now).
9. **Cleanup**: deleted all 7 seeded memories via the real `DELETE /api/memory/{id}` endpoint. Queried the shared Chroma `odysseus_memories_fastembed` collection directly (`POST .../collections/{id}/get` with the 7 ids) and confirmed zero vectors remained — symmetric cleanup verified, not just assumed. Killed the server by resolving the actual listening PID via `Get-NetTCPConnection -LocalPort 7010 -State Listen` (not a guessed PID or shell job number — confirmed this matters: the venv's `python.exe` re-execs into a different on-disk interpreter path, so `Get-CimInstance ... -Filter "Name='python.exe'"` alone returns multiple candidates and only the actual socket owner via `Get-NetTCPConnection` disambiguates which one to kill).
10. Committed as 4 commits: the pre-existing `agent_loop.py` import fix (standalone, per Session 3's flag not to fold it silently into a feature commit), the M1 owner-scoping fix (standalone), the M2 frontend (module + vendored Cytoscape + HTML/app.js/init.js wiring + CSS, one commit), and this docs update.
### Files changed this session
| File | Change |
|---|---|
| `routes/memory/memory_graph_routes.py` | owner-resolution bug fix (bug 1) |
| `static/js/memoryGraph.js` | detail-panel visibility fix (bug 2) + search-highlight-clearing fix (bug 3) |
| `docs/handoff.md`, `docs/progress.md`, `docs/todos.md`, `docs/memory-graph-analysis.md`, `docs/memory-graph-design.md` | updated to reflect M2 done/verified/committed |
### Test / verification status
- `node --check` passes on `static/js/memoryGraph.js` after both fixes.
- Full manual browser verification pass completed (see step 8 above) — this is the first real visual confirmation the feature has had; Sessions 13 never got past syntax-checking the frontend.
- Backend test suite not re-run this session (only one backend file changed, a two-line owner-variable substitution with no new logic branches; the existing 27 M1 tests don't exercise the no-auth/localhost-bypass code path that the bug lived in, which is exactly how it shipped unnoticed — worth a Milestone 3 follow-up test case for that specific mode).
---
## Session 3 — Milestone 2 (Frontend), stopped mid-verification
**Status when stopped**: all M2 frontend code written and syntax-checked, but **not yet committed** and **not yet visually verified in a browser**. The user interrupted mid-launch to request a documentation-only checkpoint. No further development happened after that instruction — this entry and the other four requested doc files are the only changes made after the stop request.
### What was completed this session
1. Vendored Cytoscape.js 3.34.0 (MIT license) into `static/lib/cytoscape.min.js` via `npm pack cytoscape@3` in a scratch directory, then copied the UMD `dist/cytoscape.min.js` build — not fetched from a hand-typed/guessed URL, and not installed into the project's own `node_modules` (the app has no bundler; this follows the existing vendoring convention used for `xlsx.full.min.js`, `docx.umd.min.js`, etc.).
2. Wrote `static/js/memoryGraph.js` (new file, ~/600 lines) — see full breakdown in "Files changed" below.
3. Added a "Memory Graph" entry to the sidebar Tools section in `static/index.html`, placed directly after the existing "Brain" entry, using the same `.list-item` markup pattern as every other tool.
4. Wired it up in `static/app.js`:
- New static import of `memoryGraph.js` next to the existing `memory.js` import.
- New click handler block for `#tool-memory-graph-btn`, copied from the `#tool-calendar-btn` block's exact structure (`Modals.toggle(...)` check, falling back to the module's own `openMemoryGraph()`/`closeMemoryGraph()`).
- New entry in the `UI_VIS_MAP` object (Customize UI panel) so the nav item can be hidden/shown like any other tool.
- New `/memory-graph` entry in the `_routeOpen` deep-link map, mirroring the existing `/memory` entry.
5. Added one line to `static/js/init.js`: `hideOn('#tool-memory-graph-btn', privs.can_manage_memory)`, directly under the existing identical line for `#tool-memory-btn`, so the privilege gating stays consistent between the two entry points.
6. Appended a new `/* Memory Graph View (beta) */` CSS block to the end of `static/style.css` (~234 lines) — did not edit any existing rule.
7. Investigated and fixed a **pre-existing, unrelated** bug blocking `app.py` from importing at all: `src/agent_loop.py` used `dict[str, Any]` in a function annotation without ever importing `Any` from `typing`. Confirmed via `git stash` that this reproduces identically on a clean `dev` checkout — not something introduced by this feature. Patched with a one-line import fix so the app could actually be launched for the live demo. **This fix is currently uncommitted and its disposition needs the user's explicit decision** (see `docs/handoff.md` → Risks).
8. Started a local server to seed demo data and take a screenshot, using an isolated `ODYSSEUS_DATA_DIR` / `DATABASE_URL` (a scratch directory, not the repo's real `data/`) with `AUTH_ENABLED=false` and `LOCALHOST_BYPASS=true` for frictionless local testing, pointed at the machine's already-running local ChromaDB (`localhost:8100`) since a completely separate Chroma instance wasn't available. The process was still downloading/loading the FastEmbed embedding model (first-run cache warm-up) when the user's stop instruction arrived. **Killed immediately** (`taskkill` on the confirmed PID, verified via `Get-CimInstance Win32_Process` that it was the right process before killing). Nothing was ever seeded — no demo memory entries were created, no port was ever bound, and the isolated data directory (outside the repo, in the session scratchpad) can simply be discarded.
### Files changed this session (all uncommitted)
| File | Change |
|---|---|
| `static/lib/cytoscape.min.js` | new, vendored, 435 KB |
| `static/js/memoryGraph.js` | new, ~600 lines |
| `static/index.html` | +17 lines (nav item) |
| `static/app.js` | +16 lines (import, click handler, UI_VIS_MAP entry, route entry) |
| `static/js/init.js` | +1 line (privilege gate) |
| `static/style.css` | +234 lines (appended block only) |
| `src/agent_loop.py` | +1/-1 line (missing `Any` import — pre-existing bug, see Risks) |
### Test / verification status
- `node --check` passes on `static/js/memoryGraph.js`, `static/app.js`, `static/js/init.js` (matches the CI `node-syntax` job's check).
- **No live browser verification performed.** No screenshot exists. This is the single most important open item — see `docs/handoff.md`.
- No new automated tests written for the frontend module this session (not requested as part of M2's explicit requirement list, but flagged as a Milestone 3 item in `docs/todos.md`).
- Backend (Milestone 1) test status is unchanged from Session 2 (see below) — nothing in Session 3 touched backend code.
---
## Session 2 — Milestone 1 (Backend), completed and committed
### What was completed
- `src/memory_graph.py`: pure `build_graph()` / `build_similarity_edges()` / `build_session_edges()` / `build_manual_edges()` functions. Similarity edges use one nearest-neighbor query per node against `MemoryVectorStore` (never O(n²) pairwise). Manual edges read an additive `links` field on memory entries.
- `routes/memory/memory_graph_routes.py`: `GET /api/memory/graph` (owner-scoped, filterable by category/min_similarity/max_edges_per_node, with a node `limit` + `truncated` flag), `GET /api/memory/graph/{id}/neighbors` (lazy single-node expansion), `POST /api/memory/{id}/links` and `DELETE /api/memory/{id}/links/{target_id}` (manual relationship editing, gated by the existing `can_manage_memory` privilege).
- `app.py`: mounted the new router **before** `memory_routes.py`'s router, with an explanatory comment — `memory_routes.py`'s `GET/PUT/DELETE /api/memory/{memory_id}` is a single-segment wildcard that would otherwise swallow `GET /api/memory/graph` (Starlette matches routes in registration order, not by specificity, across the whole app).
- Three new test files: `tests/test_memory_graph_edges.py` (14 pure-logic unit tests), `tests/test_memory_graph_routes.py` (12 route/owner-isolation tests, following the repo's "call the endpoint function directly" convention), `tests/test_memory_graph_route_ordering.py` (2 tests using a real `TestClient` — the one place a full ASGI app was needed, specifically to prove the route-ordering fix actually works against real Starlette request matching, which the repo's usual direct-call test style can't verify).
### Test status
- All 27 new tests pass.
- Ran the full existing suite (`pytest -q --continue-on-collection-errors`) both on this branch and on a `git stash`-clean `dev` checkout in the same throwaway venv. Exact same 199 pre-existing failures/errors on both sides — confirmed via `comm -23`/`comm -13` diff that the sets are byte-for-byte identical. Zero regressions, zero newly-fixed tests (none expected).
- All pre-existing failures are sandbox/environment gaps (missing Node.js test runner artifacts for a couple of cases, missing `rg` binary, an `mcp` package version newer than the repo pins, Windows-specific path-confinement test assumptions) — none touch memory, graph, or app.py wiring code.
### Commits made
```
5d91d1a docs: add Memory Graph View repository analysis and design
f488a8d feat(memory): add pure edge-derivation logic for Memory Graph View
366bdbf feat(memory): add Memory Graph API — GET /api/memory/graph + manual links
```
---
## Session 1 — Analysis and design (Phase 0)
- Produced `docs/memory-graph-analysis.md` (19-section repo inventory) and `docs/memory-graph-design.md` (architecture, API, UI, performance, security, migration/rollback, testing strategy) purely from research — no code touched, no dependencies installed, no database changed, nothing committed at the time (the docs commit itself happened at the start of Session 2, once implementation was approved).
- User approved the design and requested implementation with: Cytoscape.js, a dedicated Memory Graph module, automatic semantic relationships, manual relationship editing, a beta feature flag, no breaking changes, small commits, Docker compatibility, passing tests, and incremental delivery with a build/test/preview/approval checkpoint after each milestone.

49
docs/todos.md Normal file
View file

@ -0,0 +1,49 @@
# Memory Graph View — TODO
Live checklist. Update as work proceeds. See `docs/handoff.md` for the exact next action and `docs/progress.md` for the session-by-session log.
## Milestone 1 — Backend API (DONE, committed)
- [x] `src/memory_graph.py` — pure edge-derivation logic (similarity / session / manual)
- [x] `routes/memory/memory_graph_routes.py``GET /api/memory/graph`, `GET /api/memory/graph/{id}/neighbors`, `POST /api/memory/{id}/links`, `DELETE /api/memory/{id}/links/{target_id}`
- [x] Mounted in `app.py` before `memory_router` (route-ordering fix, regression-tested)
- [x] 27 tests (`test_memory_graph_edges.py`, `test_memory_graph_routes.py`, `test_memory_graph_route_ordering.py`) — all passing
- [x] Full-suite diff vs clean `dev` baseline — zero new failures
- [x] 3 commits + 1 docs commit made
## Milestone 2 — Frontend (DONE, committed, visually verified)
- [x] Vendored `static/lib/cytoscape.min.js` (3.34.0, MIT, via `npm pack` — not hand-fetched from a guessed URL)
- [x] `static/js/memoryGraph.js` — full module: modal shell, lazy Cytoscape load, fetch + client-side filter/search, node/edge styling incl. dark/light theme via CSS custom properties, node selection + neighborhood highlighting, link-mode manual relationship editing, detail panel (pin/edit/delete/link management), demo-data fallback
- [x] `static/index.html` — new "Memory Graph" nav item in the sidebar Tools section
- [x] `static/app.js` — module import, click-handler wiring (mirrors `tool-calendar-btn` pattern), Customize-UI visibility map entry, `/memory-graph` deep-link route entry
- [x] `static/js/init.js` — privilege gating (`hideOn('#tool-memory-graph-btn', privs.can_manage_memory)`, mirrors the Brain button)
- [x] `static/style.css` — new `.memory-graph-*` block appended at end of file
- [x] `node --check` passes on all three touched/added JS files
- [x] Live browser verification against a real seeded dataset: render, node click + neighborhood highlight, detail panel, inline edit, search, category filters, similarity slider, link-mode create, theme switch. Found and fixed two bugs in the process (detail panel permanently `hidden`; search leaving stale selection-dim opacity over matches) — see `docs/progress.md` Session 4.
- [x] Found and fixed a real Milestone-1 backend bug while seeding real data: `memory_graph_routes.py`'s graph/links endpoints used `require_user()` (returns `""`) instead of the rest of the memory routes' `get_current_user()`-based `_owner()` (returns `None`), which meant the graph was always empty and links always 404'd in single-user/no-auth mode. Fixed and committed separately from the M2 frontend commit.
- [x] Seeded memories cleaned up: all 7 deleted via the real `DELETE /api/memory/{id}` endpoint, confirmed zero orphaned vectors left in the shared Chroma `odysseus_memories_fastembed` collection, demo server process killed (confirmed real PID via `Get-NetTCPConnection`, not the bash job id).
- [x] Commit M2 work — 4 commits: pre-existing `agent_loop.py` fix (standalone), the M1 owner-scoping fix (standalone), the M2 frontend (module + vendored lib + wiring + CSS), this docs update.
- [x] Explicit on/off feature-flag setting — resolved in Milestone 3 (user confirmed the privilege-gate + static "beta" label is sufficient; no separate toggle needed).
- [x] Automated frontend tests for `memoryGraph.js` pure logic — see Milestone 3.
## Milestone 3 — Polish (DONE, committed)
- [x] "Feature flag enabled (beta)" question resolved: user confirmed the current privilege-gate + static "beta" label is sufficient; no separate togglable switch needed.
- [x] Collapsible legend — click "Legend" header to expand/collapse, caret rotates, state persists for the life of the modal.
- [x] "Isolate connected component" affordance — new "Isolate"/"Show all" button in the detail panel; runs an undirected BFS (`_componentNodeIds`) over the currently loaded graph and hides everything outside the selected node's component, with a banner showing the count. Clears on background click or graph reload.
- [x] Keyboard shortcuts: `f` focuses the search box; Arrow keys (Right/Down = next, Left/Up = previous) cycle selection through the currently *visible* nodes (respects active category/similarity/isolate filters) and re-center the camera. Guarded against firing while typing in any input/textarea or while the modal is minimized.
- [x] `MODULE_SUMMARY.md` updated with a `memoryGraph.js` row (§6, Knowledge/Memory/RAG).
- [x] Automated frontend tests: `tests/memoryGraph/pureLogic.test.mjs` (10 tests via `node --test`, wrapped by `tests/test_memory_graph_pure_logic_js.py` per the repo's `node:test` + pytest-shim convention) covering category-color resolution, API-response-to-Cytoscape-elements mapping (including dropping edges with dangling endpoints), the fetch query string, demo-graph referential integrity, and the isolate-component BFS.
- [x] Final regression pass: `node --check` clean on all touched JS; the 27 existing Milestone 1 backend tests + the new JS suite all pass; `pytest --collect-only` across the full suite shows the same 5 pre-existing collection errors as before this session (mcp package version mismatch + one unrelated `UnicodeDecodeError`), zero new failures.
- [x] `src/agent_loop.py` fix: kept, committed separately (see Session 4) — decided low-risk enough to ship without further debate.
## Distribution
- [x] All work committed locally — 11 commits ahead of `dev` on `feature/memory-graph-view`.
- [ ] **BLOCKED**: `git push -u origin feature/memory-graph-view` fails with 403 (`Permission to odysseus-dev/odysseus.git denied to yakamoz221`). Needs the user to grant push access, point `origin` at a fork, or push via different credentials — see `docs/handoff.md`.
## Known pre-existing issues (not introduced by this feature, out of scope to fix here)
- `mcp_servers/rag_server.py` (and a few tests importing it) hit `AttributeError: 'Server' object has no attribute 'list_tools'` — an `mcp` package version mismatch in this sandbox's venv vs whatever version the repo's real environment pins. Not touched, not in scope.
- Several JS-logic tests (`node --test`-backed) and shell/path-confinement tests fail in this sandbox because Node.js version/`rg` binary/Windows path handling differ from the repo's real CI environment. Confirmed identical failure set on baseline `dev` — not caused by this feature.

View file

@ -0,0 +1,150 @@
# routes/memory/memory_graph_routes.py
"""Memory Graph View endpoints: read-only graph derivation plus manual
relationship (link) editing between a user's own memories.
Kept as a separate router (not folded into memory_routes.py's wildcard-heavy
router) but MUST be included in app.py before that router see the comment
at the include_router call site. `GET /api/memory/graph` would otherwise be
swallowed by memory_routes.py's `GET /api/memory/{memory_id}` wildcard if
that router's routes were checked first.
"""
from typing import Dict, List, Optional
import logging
from fastapi import APIRouter, HTTPException, Query, Request
from services.memory import MemoryManager
from src.auth_helpers import get_current_user, require_privilege
from src.memory_graph import (
DEFAULT_MAX_EDGES_PER_NODE,
DEFAULT_MIN_SIMILARITY,
build_graph,
)
logger = logging.getLogger(__name__)
def setup_memory_graph_routes(memory_manager: MemoryManager, memory_vector=None):
"""Set up Memory Graph View routes."""
router = APIRouter(prefix="/api/memory", tags=["memory-graph"])
def _owner(request: Request) -> Optional[str]:
return get_current_user(request)
def _verify_memory_owner(memory: dict, user: Optional[str]):
"""Raise 404 if user doesn't own this memory. Mirrors
memory_routes.py's _verify_memory_owner: strict ownership so a
legacy/null-owner memory never leaks across accounts."""
if user is None:
return # Auth disabled
if memory.get("owner") != user:
raise HTTPException(404, "Memory not found")
@router.get("/graph")
def get_memory_graph(
request: Request,
category: Optional[List[str]] = Query(None),
min_similarity: float = Query(DEFAULT_MIN_SIMILARITY, ge=0.0, le=1.0),
max_edges_per_node: int = Query(DEFAULT_MAX_EDGES_PER_NODE, ge=1, le=50),
include_session_edges: bool = Query(True),
include_manual_edges: bool = Query(True),
limit: int = Query(1000, ge=1, le=5000),
):
"""Return the caller's own memories as a derived node/edge graph."""
user = _owner(request)
memories = memory_manager.load(owner=user)
return build_graph(
memories,
memory_vector,
categories=category,
min_similarity=min_similarity,
max_edges_per_node=max_edges_per_node,
include_session_edges=include_session_edges,
include_manual_edges=include_manual_edges,
limit=limit,
)
@router.get("/graph/{memory_id}/neighbors")
def get_memory_graph_neighbors(
request: Request,
memory_id: str,
min_similarity: float = Query(DEFAULT_MIN_SIMILARITY, ge=0.0, le=1.0),
max_edges_per_node: int = Query(DEFAULT_MAX_EDGES_PER_NODE, ge=1, le=50),
):
"""Lazy drill-down: one node plus its immediate derived neighbors.
For graphs too large to render whole (see build_graph's `limit`/
`truncated`), the frontend can expand a single node on demand instead
of the server ever needing to compute/return the entire graph.
"""
user = _owner(request)
memories = memory_manager.load(owner=user)
target = next((m for m in memories if m.get("id") == memory_id), None)
if target is None:
raise HTTPException(404, "Memory not found")
_verify_memory_owner(target, user)
full = build_graph(
memories,
memory_vector,
min_similarity=min_similarity,
max_edges_per_node=max_edges_per_node,
limit=len(memories) or 1,
)
neighbor_ids = {
(e["target"] if e["source"] == memory_id else e["source"])
for e in full["edges"]
if memory_id in (e["source"], e["target"])
}
neighbor_ids.add(memory_id)
nodes = [n for n in full["nodes"] if n["id"] in neighbor_ids]
edges = [e for e in full["edges"] if e["source"] in neighbor_ids and e["target"] in neighbor_ids]
return {"nodes": nodes, "edges": edges, "meta": {"node_count": len(nodes), "edge_count": len(edges)}}
@router.post("/{memory_id}/links")
def add_memory_link(request: Request, memory_id: str, target_id: str = Query(...)):
"""Create an explicit manual relationship between two of the caller's
own memories (the Memory Graph View's "draw a link" affordance)."""
require_privilege(request, "can_manage_memory")
user = _owner(request)
if target_id == memory_id:
raise HTTPException(400, "A memory cannot link to itself")
all_mem = memory_manager.load_all()
source = next((m for m in all_mem if m.get("id") == memory_id), None)
if source is None:
raise HTTPException(404, "Memory not found")
_verify_memory_owner(source, user)
target = next((m for m in all_mem if m.get("id") == target_id), None)
if target is None:
raise HTTPException(404, "Target memory not found")
_verify_memory_owner(target, user)
links = list(source.get("links") or [])
if target_id not in links:
links.append(target_id)
source["links"] = links
memory_manager.save(all_mem)
return {"ok": True, "links": links}
@router.delete("/{memory_id}/links/{target_id}")
def remove_memory_link(request: Request, memory_id: str, target_id: str):
"""Remove a manual relationship. Idempotent — removing a link that
doesn't exist is not an error, matching how memory delete/pin already
treat repeat calls as harmless in this codebase."""
require_privilege(request, "can_manage_memory")
user = _owner(request)
all_mem = memory_manager.load_all()
source = next((m for m in all_mem if m.get("id") == memory_id), None)
if source is None:
raise HTTPException(404, "Memory not found")
_verify_memory_owner(source, user)
links = list(source.get("links") or [])
if target_id in links:
links = [l for l in links if l != target_id]
source["links"] = links
memory_manager.save(all_mem)
return {"ok": True, "links": links}
return router

183
src/memory_graph.py Normal file
View file

@ -0,0 +1,183 @@
"""memory_graph.py
Derives a node/edge graph over a user's own memory entries for the Memory
Graph View. Nothing here is persisted beyond the optional manual `links`
field already carried on a memory entry (see routes/memory/memory_graph_routes.py)
edges are computed fresh from MemoryManager entries and MemoryVectorStore
similarity search on every call.
"""
from typing import Any, Dict, List, Optional
DEFAULT_MIN_SIMILARITY = 0.75
DEFAULT_MAX_EDGES_PER_NODE = 5
DEFAULT_LIMIT = 1000
def _node_from_entry(entry: Dict) -> Dict:
return {
"id": entry.get("id"),
"text": entry.get("text", ""),
"category": entry.get("category", "fact"),
"pinned": bool(entry.get("pinned", False)),
"uses": int(entry.get("uses", 0) or 0),
"timestamp": entry.get("timestamp"),
"session_id": entry.get("session_id"),
}
def _sorted_pair(a: str, b: str) -> tuple:
return (a, b) if a <= b else (b, a)
def build_similarity_edges(
memories: List[Dict],
memory_vector,
*,
min_similarity: float = DEFAULT_MIN_SIMILARITY,
max_edges_per_node: int = DEFAULT_MAX_EDGES_PER_NODE,
) -> List[Dict]:
"""Derive semantic-similarity edges via one nearest-neighbor query per node.
Deliberately per-node top-k (via the vector store's own ANN search),
never O(n^2) pairwise comparison, so this stays cheap as memory count
grows see docs/memory-graph-design.md, "Performance considerations".
"""
if not memory_vector or not getattr(memory_vector, "healthy", False):
return []
ids_in_scope = {m["id"] for m in memories if m.get("id")}
if len(ids_in_scope) < 2:
return []
edges = []
seen_pairs = set()
for mem in memories:
mid = mem.get("id")
text = (mem.get("text") or "").strip()
if not mid or not text:
continue
try:
results = memory_vector.search(text, k=max_edges_per_node + 1)
except Exception:
continue
for row in results:
other_id = row.get("memory_id")
score = row.get("score", 0.0)
if not other_id or other_id == mid or other_id not in ids_in_scope:
continue
if score < min_similarity:
continue
pair = _sorted_pair(mid, other_id)
if pair in seen_pairs:
continue
seen_pairs.add(pair)
edges.append({
"source": pair[0],
"target": pair[1],
"type": "similarity",
"weight": round(float(score), 4),
})
return edges
def build_session_edges(memories: List[Dict]) -> List[Dict]:
"""Derive edges between memories extracted from the same chat session."""
by_session: Dict[str, List[str]] = {}
for mem in memories:
sid = mem.get("session_id")
mid = mem.get("id")
if sid and mid:
by_session.setdefault(sid, []).append(mid)
edges = []
seen_pairs = set()
for ids in by_session.values():
if len(ids) < 2:
continue
for i in range(len(ids)):
for j in range(i + 1, len(ids)):
pair = _sorted_pair(ids[i], ids[j])
if pair in seen_pairs:
continue
seen_pairs.add(pair)
edges.append({"source": pair[0], "target": pair[1], "type": "session", "weight": 1.0})
return edges
def build_manual_edges(memories: List[Dict]) -> List[Dict]:
"""Derive edges from user-authored explicit links (entry['links'])."""
ids_in_scope = {m["id"] for m in memories if m.get("id")}
edges = []
seen_pairs = set()
for mem in memories:
mid = mem.get("id")
links = mem.get("links") or []
if not mid or not isinstance(links, list):
continue
for target_id in links:
if not target_id or target_id == mid or target_id not in ids_in_scope:
continue
pair = _sorted_pair(mid, target_id)
if pair in seen_pairs:
continue
seen_pairs.add(pair)
edges.append({"source": pair[0], "target": pair[1], "type": "manual", "weight": 1.0})
return edges
def build_graph(
memories: List[Dict],
memory_vector=None,
*,
categories: Optional[List[str]] = None,
min_similarity: float = DEFAULT_MIN_SIMILARITY,
max_edges_per_node: int = DEFAULT_MAX_EDGES_PER_NODE,
include_session_edges: bool = True,
include_manual_edges: bool = True,
limit: int = DEFAULT_LIMIT,
) -> Dict[str, Any]:
"""Build a `{nodes, edges, meta}` graph for one owner's already-filtered
(owner-scoped) memory list. Caller is responsible for owner scoping
this function has no concept of ownership, only the entries it's given.
"""
filtered = memories
if categories:
cat_set = set(categories)
filtered = [m for m in filtered if m.get("category", "fact") in cat_set]
total = len(filtered)
truncated = total > limit
if truncated:
# Keep the most-used / most-recent memories rather than an arbitrary
# slice, so truncation drops the least-referenced tail first.
filtered = sorted(
filtered,
key=lambda m: (int(m.get("uses", 0) or 0), m.get("timestamp", 0) or 0),
reverse=True,
)[:limit]
nodes = [_node_from_entry(m) for m in filtered]
edges: List[Dict] = []
if memory_vector is not None:
edges.extend(build_similarity_edges(
filtered, memory_vector,
min_similarity=min_similarity,
max_edges_per_node=max_edges_per_node,
))
if include_session_edges:
edges.extend(build_session_edges(filtered))
if include_manual_edges:
edges.extend(build_manual_edges(filtered))
return {
"nodes": nodes,
"edges": edges,
"meta": {
"node_count": len(nodes),
"edge_count": len(edges),
"total_memories": total,
"truncated": truncated,
},
}

View file

@ -19,6 +19,7 @@ import markdownModule from './js/markdown.js';
import chatRenderer from './js/chatRenderer.js?v=20260722emailfastindex1';
import sessionModule from './js/sessions.js?v=20260722ctxheader4';
import memoryModule from './js/memory.js?v=20260722memoryloading1';
import memoryGraphModule from './js/memoryGraph.js?v=20260729memorygraph1';
import voiceRecorderModule from './js/voiceRecorder.js';
import censorModule from './js/censor.js';
import galleryModule from './js/gallery.js';
@ -1062,6 +1063,19 @@ function initializeEventListeners() {
});
}
// Memory Graph tool button
const toolMemoryGraphBtn = el('tool-memory-graph-btn');
if (toolMemoryGraphBtn) {
toolMemoryGraphBtn.addEventListener('click', async () => {
if (!memoryGraphModule) return;
const Modals = await import('./js/modalManager.js');
if (!Modals.toggle('memory-graph-modal')) {
if (memoryGraphModule.isMemoryGraphOpen()) memoryGraphModule.closeMemoryGraph();
else memoryGraphModule.openMemoryGraph();
}
});
}
// Calendar tool button
const toolCalendarBtn = el('tool-calendar-btn');
if (toolCalendarBtn) {
@ -1212,6 +1226,7 @@ function initializeEventListeners() {
setTimeout(_goFullscreen, 200);
},
'/memory': () => document.getElementById('tool-memory-btn')?.click(),
'/memory-graph': () => document.getElementById('tool-memory-graph-btn')?.click(),
'/gallery': () => document.getElementById('tool-gallery-btn')?.click(),
'/tasks': () => document.getElementById('tool-tasks-btn')?.click(),
'/library': () => sessionModule && sessionModule.openLibrary && sessionModule.openLibrary(),
@ -2727,6 +2742,7 @@ function initializeEventListeners() {
'tool-gallery': '#tool-gallery-btn',
'tool-library': '#tool-library-btn',
'tool-memory': '#tool-memory-btn',
'tool-memory-graph': '#tool-memory-graph-btn',
'tool-notes': '#tool-notes-btn',
'tool-tasks': '#tool-tasks-btn',
'tool-theme': '#tool-theme-btn',

View file

@ -860,6 +860,23 @@
</svg>
<span class="grow">Brain</span>
</div>
<div class="list-item" id="tool-memory-graph-btn">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
style="flex-shrink:0;opacity:0.5;">
<circle cx="6" cy="6" r="2.4"/>
<circle cx="18" cy="6" r="2.4"/>
<circle cx="12" cy="13" r="2.4"/>
<circle cx="6" cy="19" r="2.4"/>
<circle cx="18" cy="19" r="2.4"/>
<line x1="7.7" y1="7.3" x2="10.6" y2="11.5"/>
<line x1="16.3" y1="7.3" x2="13.4" y2="11.5"/>
<line x1="10.9" y1="14.8" x2="7.7" y2="17.7"/>
<line x1="13.1" y1="14.8" x2="16.3" y2="17.7"/>
<line x1="8.2" y1="6.2" x2="15.8" y2="6.2"/>
</svg>
<span class="grow">Memory Graph</span>
</div>
<div class="list-item" id="tool-calendar-btn">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round"

View file

@ -106,6 +106,7 @@ The largest and most central subsystem. Chat submission → backend SSE → prog
| Module | Responsibility |
|---|---|
| **`memory.js`** | AI memory CRUD, search/filter UI, memory extraction, count badge. |
| **`memoryGraph.js`** | Memory Graph View (beta): Cytoscape.js-based interactive graph over a user's memories and their derived (similarity/session) and manual relationships. Lazily vendors `static/lib/cytoscape.min.js`, follows `calendar.js`'s self-registering modal pattern. Node click + neighborhood highlight, arrow-key node navigation, `f`-to-focus search, category/similarity filtering, collapsible legend, connected-component isolation, link-mode manual relationship editing, detail panel (pin/edit/delete/link management), demo-data fallback. Backed by `GET /api/memory/graph` + `POST`/`DELETE /api/memory/{id}/links` (`routes/memory/memory_graph_routes.py`). |
| **`rag.js`** | Personal document RAG: load documents, add directories/files, show included paths. |
| **`group.js`** | Group-chat UI and model orchestration. |

View file

@ -79,6 +79,7 @@ document.addEventListener('DOMContentLoaded', markComposerUserEdited, { once: tr
hideOn('#tool-research-btn, #research-toggle-btn', privs.can_use_research);
// Memory & skills (rail/tool button only — UI/API entry).
hideOn('#tool-memory-btn', privs.can_manage_memory);
hideOn('#tool-memory-graph-btn', privs.can_manage_memory);
// Agent mode toggle — force chat mode by hiding the Agent toggle button.
if (privs.can_use_agent === false) {
const _agent = document.getElementById('mode-agent-btn');

877
static/js/memoryGraph.js Normal file
View file

@ -0,0 +1,877 @@
// Memory Graph View (beta)
// Interactive Cytoscape.js visualization of a user's own memories and their
// derived (semantic similarity, same-session) and manual relationships.
// Backed by GET /api/memory/graph and POST/DELETE /api/memory/{id}/links
// (routes/memory/memory_graph_routes.py).
import uiModule from './ui.js';
import spinnerModule from './spinner.js';
import * as Modals from './modalManager.js';
import { makeWindowDraggable } from './windowDrag.js';
const API_BASE = window.location.origin;
const escapeHtml = uiModule.esc;
// Category → CSS custom-property name. Resolved to a concrete color at
// render time via getComputedStyle so it tracks the active dark/light theme.
const CATEGORY_VAR = {
fact: '--fg',
identity: '--hl-keyword',
preference: '--warn',
contact: '--color-accent',
project: '--color-brand-blue',
goal: '--accent-warm',
task: '--green',
};
const CATEGORY_FALLBACK_VAR = '--color-muted-alt';
const KNOWN_CATEGORIES = Object.keys(CATEGORY_VAR);
function _cssVar(name, fallback) {
const v = getComputedStyle(document.documentElement).getPropertyValue(name).trim();
return v || fallback;
}
function _categoryColor(category) {
const varName = CATEGORY_VAR[category] || CATEGORY_FALLBACK_VAR;
return _cssVar(varName, '#888');
}
// ---- placeholder data, shown only when the caller has zero real memories ----
const DEMO_GRAPH = (() => {
const now = Math.floor(Date.now() / 1000);
const nodes = [
{ id: 'demo-1', text: 'Works as a product designer at a small startup', category: 'identity', uses: 4, pinned: true, timestamp: now - 86400 * 30 },
{ id: 'demo-2', text: 'Prefers dark roast coffee, no sugar', category: 'preference', uses: 2, pinned: false, timestamp: now - 86400 * 20 },
{ id: 'demo-3', text: 'Working on a side project called "Lighthouse"', category: 'project', uses: 6, pinned: true, timestamp: now - 86400 * 14, session_id: 'demo-session' },
{ id: 'demo-4', text: 'Wants to launch Lighthouse beta by end of quarter', category: 'goal', uses: 3, pinned: false, timestamp: now - 86400 * 10, session_id: 'demo-session' },
{ id: 'demo-5', text: "Partner's birthday is on the 12th", category: 'fact', uses: 1, pinned: false, timestamp: now - 86400 * 6 },
{ id: 'demo-6', text: 'Best reached by email rather than phone', category: 'contact', uses: 1, pinned: false, timestamp: now - 86400 * 2 },
];
const edges = [
{ source: 'demo-3', target: 'demo-4', type: 'session', weight: 1 },
{ source: 'demo-1', target: 'demo-3', type: 'manual', weight: 1 },
{ source: 'demo-1', target: 'demo-2', type: 'similarity', weight: 0.81 },
];
return { nodes, edges, meta: { node_count: nodes.length, edge_count: edges.length, total_memories: nodes.length, truncated: false } };
})();
// ---- module state ----
let _modal = null;
let _cy = null;
let _open = false;
let _isDemo = false;
let _graph = { nodes: [], edges: [] };
let _activeCategory = null; // null = show all categories
let _searchTerm = '';
let _minSimilarity = 0.75;
let _linkMode = false;
let _linkSourceId = null;
let _selectedId = null;
let _isolateRootId = null; // set = only this node's connected component is shown
let _keyHandler = null;
let _resizeWired = false;
// ---- lazy-load Cytoscape, mirroring documentLibrary.js's ensureXLSX/ensureMammoth ----
let _cytoscapeReady = null;
function ensureCytoscape() {
if (_cytoscapeReady) return _cytoscapeReady;
if (window.cytoscape) return (_cytoscapeReady = Promise.resolve());
_cytoscapeReady = new Promise((resolve, reject) => {
const s = document.createElement('script');
s.src = '/static/lib/cytoscape.min.js';
s.onload = resolve;
s.onerror = () => reject(new Error('Failed to load Cytoscape library'));
document.head.appendChild(s);
});
return _cytoscapeReady;
}
// ---- modal shell ----
function _getModal() {
if (_modal) return _modal;
_modal = document.createElement('div');
_modal.id = 'memory-graph-modal';
_modal.className = 'modal hidden';
_modal.style.display = 'none';
_modal.innerHTML = `
<div class="modal-content memory-graph-modal-content">
<div class="modal-header">
<h4>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:6px">
<circle cx="6" cy="6" r="2.4"/><circle cx="18" cy="6" r="2.4"/><circle cx="12" cy="13" r="2.4"/><circle cx="6" cy="19" r="2.4"/><circle cx="18" cy="19" r="2.4"/>
<line x1="7.7" y1="7.3" x2="10.6" y2="11.5"/><line x1="16.3" y1="7.3" x2="13.4" y2="11.5"/><line x1="10.9" y1="14.8" x2="7.7" y2="17.7"/><line x1="13.1" y1="14.8" x2="16.3" y2="17.7"/>
</svg>Memory Graph <span style="font-size:10px;opacity:0.5;font-weight:400;">beta</span>
</h4>
<button class="close-btn" id="memory-graph-close"></button>
</div>
<div class="memory-graph-modal-body">
<div class="memory-graph-toolbar">
<input type="text" class="memory-graph-search" id="memory-graph-search" placeholder="Search memories…" />
<div class="memory-graph-cat-chips" id="memory-graph-cat-chips"></div>
<div class="memory-graph-slider-row">
<span>min match</span>
<input type="range" id="memory-graph-similarity" min="0.5" max="0.95" step="0.05" value="${_minSimilarity}" />
</div>
<button type="button" class="memory-graph-link-mode-btn" id="memory-graph-link-mode-btn" title="Click two memories to draw a relationship between them">Link mode</button>
</div>
<div class="memory-graph-main">
<div class="memory-graph-canvas-wrap" style="position:relative;flex:1;min-width:0;">
<div class="memory-graph-canvas" id="memory-graph-canvas"></div>
<div class="memory-graph-demo-banner hidden" id="memory-graph-demo-banner">Showing demo data add memories to see your real graph</div>
<div class="memory-graph-demo-banner hidden" id="memory-graph-isolate-banner"></div>
<div class="memory-graph-legend" id="memory-graph-legend">
<div class="memory-graph-legend-header" id="memory-graph-legend-toggle">
<span>Legend</span><span class="memory-graph-legend-caret"></span>
</div>
<div class="memory-graph-legend-body">
<div class="memory-graph-legend-row"><span class="memory-graph-legend-line"></span><span>similarity</span></div>
<div class="memory-graph-legend-row"><span class="memory-graph-legend-line dashed"></span><span>same session</span></div>
<div class="memory-graph-legend-row"><span class="memory-graph-legend-line" style="border-top-color:var(--red);"></span><span>manual link</span></div>
</div>
</div>
</div>
<div class="memory-graph-detail-panel hidden" id="memory-graph-detail">
<div class="memory-graph-detail-empty">Select a memory to see details.</div>
</div>
</div>
</div>
</div>`;
document.body.appendChild(_modal);
_modal.querySelector('#memory-graph-close').addEventListener('click', closeMemoryGraph);
_modal.addEventListener('click', (e) => { if (e.target === _modal) closeMemoryGraph(); });
const content = _modal.querySelector('.modal-content');
const header = _modal.querySelector('.modal-header');
if (content && header) makeWindowDraggable(_modal, { content, header });
if (content) {
const obs = new ResizeObserver(() => { if (_cy) _cy.resize(); });
obs.observe(content);
}
_wireToolbar();
return _modal;
}
function _wireToolbar() {
const search = document.getElementById('memory-graph-search');
if (search) {
let debounceTimer;
search.addEventListener('input', () => {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => { _searchTerm = search.value; _applySearch(); }, 220);
});
}
const slider = document.getElementById('memory-graph-similarity');
if (slider) {
slider.addEventListener('input', () => {
_minSimilarity = parseFloat(slider.value) || 0.75;
_applyFilters();
});
}
const linkBtn = document.getElementById('memory-graph-link-mode-btn');
if (linkBtn) linkBtn.addEventListener('click', () => _setLinkMode(!_linkMode));
const legendToggle = document.getElementById('memory-graph-legend-toggle');
if (legendToggle) {
legendToggle.addEventListener('click', () => {
document.getElementById('memory-graph-legend')?.classList.toggle('collapsed');
});
}
}
function _wireResize() {
if (_resizeWired) return;
_resizeWired = true;
window.addEventListener('resize', () => { if (_cy && _open) _cy.resize(); });
}
// ---- data loading ----
function _buildQuery() {
const params = new URLSearchParams();
// Over-fetch at a floor below the lowest UI slider position (0.5) and a
// generous per-node edge cap; category/similarity filtering beyond that is
// done client-side against this cached graph so chip/slider changes are
// instant and don't re-hit the backend (see docs/memory-graph-design.md,
// "Performance considerations").
params.set('min_similarity', '0.5');
params.set('max_edges_per_node', '8');
return params.toString();
}
async function _fetchGraph() {
const res = await fetch(`${API_BASE}/api/memory/graph?${_buildQuery()}`, { credentials: 'same-origin' });
if (!res.ok) throw new Error(`Memory graph request failed (${res.status})`);
return res.json();
}
async function _loadGraph() {
_renderLoadingState();
try {
const data = await _fetchGraph();
if (!data.nodes || !data.nodes.length) {
_graph = DEMO_GRAPH;
_isDemo = true;
} else {
_graph = data;
_isDemo = false;
}
} catch (err) {
console.error('[memoryGraph] load failed', err);
_graph = DEMO_GRAPH;
_isDemo = true;
uiModule.showToast?.('Could not load your memory graph — showing demo data', 3000);
}
try {
await ensureCytoscape();
} catch (err) {
console.error('[memoryGraph] cytoscape load failed', err);
uiModule.showToast?.('Could not load the graph renderer', 3000);
return;
}
_renderCategoryChips();
_renderGraph();
}
function _renderLoadingState() {
const canvas = document.getElementById('memory-graph-canvas');
if (!canvas) return;
canvas.innerHTML = '';
const wrap = document.createElement('div');
wrap.className = 'memory-graph-loading';
wrap.appendChild(spinnerModule.createLoadingRow('Loading memory graph…', 18));
canvas.appendChild(wrap);
}
// ---- cytoscape element/style construction ----
function _nodeSize(n) {
return Math.max(20, Math.min(60, 20 + (n.uses || 0) * 4));
}
function _toElements(graph) {
const nodeById = new Map(graph.nodes.map(n => [n.id, n]));
const nodes = graph.nodes.map(n => ({
data: {
id: n.id,
label: (n.text || '').length > 42 ? `${n.text.slice(0, 42)}` : (n.text || ''),
category: n.category || 'fact',
color: _categoryColor(n.category || 'fact'),
size: _nodeSize(n),
pinned: !!n.pinned,
},
}));
const edges = [];
graph.edges.forEach((e, i) => {
if (!nodeById.has(e.source) || !nodeById.has(e.target)) return;
edges.push({
data: {
id: `e${i}-${e.source}-${e.target}`,
source: e.source,
target: e.target,
type: e.type,
weight: e.weight || 1,
},
});
});
return { nodes, edges };
}
function _cyStyle() {
const fg = _cssVar('--fg', '#9cdef2');
const bg = _cssVar('--bg', '#282c34');
const border = _cssVar('--border', '#355a66');
const accent = _cssVar('--accent', _cssVar('--red', '#e06c75'));
return [
{ selector: 'node', style: {
'background-color': 'data(color)',
'label': 'data(label)',
'width': 'data(size)',
'height': 'data(size)',
'font-size': 9,
'color': fg,
'text-valign': 'bottom',
'text-margin-y': 4,
'text-wrap': 'wrap',
'text-max-width': '90px',
'border-width': 1,
'border-color': border,
'text-outline-width': 2,
'text-outline-color': bg,
} },
{ selector: 'node[?pinned]', style: {
'border-width': 3,
'border-color': accent,
} },
{ selector: 'edge[type = "similarity"]', style: {
'width': 'mapData(weight, 0.5, 1, 1, 4)',
'line-color': fg,
'opacity': 'mapData(weight, 0.5, 1, 0.25, 0.7)',
'curve-style': 'bezier',
'target-arrow-shape': 'none',
} },
{ selector: 'edge[type = "session"]', style: {
'width': 1.4,
'line-color': _cssVar('--color-muted-alt', '#6b7280'),
'line-style': 'dashed',
'opacity': 0.5,
'curve-style': 'bezier',
} },
{ selector: 'edge[type = "manual"]', style: {
'width': 2.4,
'line-color': accent,
'opacity': 0.85,
'curve-style': 'bezier',
} },
{ selector: '.mg-dimmed', style: { 'opacity': 0.08 } },
{ selector: 'node.mg-highlighted', style: {
'border-width': 3,
'border-color': _cssVar('--color-accent', '#00aaff'),
} },
{ selector: 'edge.mg-highlighted', style: { 'opacity': 1, 'width': 4 } },
{ selector: 'node.mg-search-match', style: {
'border-width': 3,
'border-color': _cssVar('--warn', '#f0ad4e'),
} },
{ selector: 'node.mg-link-source', style: {
'border-width': 4,
'border-color': _cssVar('--color-accent', '#00aaff'),
'border-style': 'double',
} },
{ selector: 'node:selected', style: {
'border-width': 3,
'border-color': fg,
} },
];
}
function _renderGraph() {
const canvas = document.getElementById('memory-graph-canvas');
if (!canvas) return;
canvas.innerHTML = '';
const { nodes, edges } = _toElements(_graph);
if (_cy) { _cy.destroy(); _cy = null; }
_cy = window.cytoscape({
container: canvas,
elements: { nodes, edges },
style: _cyStyle(),
layout: { name: 'cose', animate: false, padding: 30, nodeRepulsion: 8000, idealEdgeLength: 90 },
minZoom: 0.2,
maxZoom: 3,
wheelSensitivity: 0.2,
});
_wireCyEvents();
_isolateRootId = null;
_applyFilters();
_renderDemoBanner();
_renderIsolateBanner();
_selectedId = null;
_renderDetailPanel();
}
function _renderDemoBanner() {
const banner = document.getElementById('memory-graph-demo-banner');
if (banner) banner.classList.toggle('hidden', !_isDemo);
}
// ---- category filter + search + similarity threshold ----
function _renderCategoryChips() {
const container = document.getElementById('memory-graph-cat-chips');
if (!container) return;
const seen = new Set(_graph.nodes.map(n => n.category || 'fact'));
const cats = [
...KNOWN_CATEGORIES.filter(c => seen.has(c)),
...[...seen].filter(c => !KNOWN_CATEGORIES.includes(c)),
];
container.innerHTML = '';
const allChip = document.createElement('button');
allChip.type = 'button';
allChip.className = `memory-graph-cat-chip${!_activeCategory ? ' active' : ''}`;
allChip.textContent = 'all';
allChip.addEventListener('click', () => { _activeCategory = null; _renderCategoryChips(); _applyFilters(); });
container.appendChild(allChip);
cats.forEach(cat => {
const chip = document.createElement('button');
chip.type = 'button';
chip.className = `memory-graph-cat-chip${_activeCategory === cat ? ' active' : ''}`;
chip.style.setProperty('--mg-cat-color', _categoryColor(cat));
chip.textContent = cat;
chip.addEventListener('click', () => { _activeCategory = cat; _renderCategoryChips(); _applyFilters(); });
container.appendChild(chip);
});
}
// Pure BFS over a graph's edges (undirected) — the set of node ids reachable
// from rootId, including rootId itself. Used by the "Isolate" detail-panel
// action to show only a node's connected component. No DOM/Cytoscape
// dependency, so this is unit-testable in isolation (see memoryGraph tests).
function _componentNodeIds(graph, rootId) {
const adjacency = new Map();
(graph.nodes || []).forEach(n => adjacency.set(n.id, new Set()));
(graph.edges || []).forEach(e => {
if (!adjacency.has(e.source) || !adjacency.has(e.target)) return;
adjacency.get(e.source).add(e.target);
adjacency.get(e.target).add(e.source);
});
const seen = new Set();
if (!adjacency.has(rootId)) return seen;
const stack = [rootId];
while (stack.length) {
const id = stack.pop();
if (seen.has(id)) continue;
seen.add(id);
for (const neighbor of adjacency.get(id) || []) {
if (!seen.has(neighbor)) stack.push(neighbor);
}
}
return seen;
}
function _applyFilters() {
if (!_cy) return;
const isolateIds = _isolateRootId ? _componentNodeIds(_graph, _isolateRootId) : null;
_cy.batch(() => {
_cy.nodes().forEach(n => {
const categoryOk = !_activeCategory || n.data('category') === _activeCategory;
const isolateOk = !isolateIds || isolateIds.has(n.id());
n.style('display', (categoryOk && isolateOk) ? 'element' : 'none');
});
_cy.edges().forEach(e => {
const src = _cy.getElementById(e.data('source'));
const tgt = _cy.getElementById(e.data('target'));
const endpointsVisible = src.style('display') !== 'none' && tgt.style('display') !== 'none';
const passesSimilarity = e.data('type') !== 'similarity' || e.data('weight') >= _minSimilarity;
e.style('display', (endpointsVisible && passesSimilarity) ? 'element' : 'none');
});
});
_applySearch();
}
function _renderIsolateBanner() {
const banner = document.getElementById('memory-graph-isolate-banner');
if (!banner) return;
if (!_isolateRootId) { banner.classList.add('hidden'); return; }
const count = _componentNodeIds(_graph, _isolateRootId).size;
banner.textContent = `Isolated — showing ${count} connected ${count === 1 ? 'memory' : 'memories'}`;
banner.classList.remove('hidden');
}
function _toggleIsolate(id) {
_isolateRootId = (_isolateRootId === id) ? null : id;
_applyFilters();
_renderIsolateBanner();
_renderDetailPanel();
}
function _applySearch() {
if (!_cy) return;
_cy.nodes().removeClass('mg-search-match');
const term = _searchTerm.trim().toLowerCase();
if (!term) return;
// A fresh search supersedes any leftover node-selection highlight —
// otherwise .mg-dimmed's opacity:0.08 masks nodes that match the search
// but weren't part of the previously selected node's neighborhood.
if (_selectedId) { _selectedId = null; _renderDetailPanel(); }
_cy.elements().removeClass('mg-highlighted mg-dimmed');
const matches = _cy.nodes().filter(n => {
const src = _findNode(n.id());
return (src?.text || '').toLowerCase().includes(term);
});
matches.addClass('mg-search-match');
if (matches.length) {
_cy.animate({ fit: { eles: matches, padding: 60 } }, { duration: 250 });
}
}
// ---- selection / highlighting ----
function _findNode(id) {
return _graph.nodes.find(n => n.id === id) || null;
}
function _manualLinksFor(id) {
return _graph.edges
.filter(e => e.type === 'manual' && (e.source === id || e.target === id))
.map(e => {
const otherId = e.source === id ? e.target : e.source;
return { id: otherId, node: _findNode(otherId) };
})
.filter(l => l.node);
}
function _highlightNeighborhood(id) {
if (!_cy) return;
_cy.elements().removeClass('mg-highlighted mg-dimmed');
const node = _cy.getElementById(id);
if (!node || node.empty()) return;
const neighborhood = node.closedNeighborhood();
_cy.elements().difference(neighborhood).addClass('mg-dimmed');
neighborhood.addClass('mg-highlighted');
}
function _selectNode(id) {
_selectedId = id;
_highlightNeighborhood(id);
_renderDetailPanel();
}
function _clearSelection() {
_selectedId = null;
if (_isolateRootId) {
_isolateRootId = null;
_applyFilters();
_renderIsolateBanner();
}
if (_cy) _cy.elements().removeClass('mg-highlighted mg-dimmed');
_renderDetailPanel();
}
function _wireCyEvents() {
if (!_cy) return;
_cy.on('tap', 'node', (evt) => {
const node = evt.target;
if (_linkMode) { _handleLinkModeClick(node.id()); return; }
_selectNode(node.id());
});
_cy.on('tap', 'edge', (evt) => {
const edge = evt.target;
_cy.elements().removeClass('mg-highlighted mg-dimmed');
const eles = edge.connectedNodes().union(edge);
_cy.elements().difference(eles).addClass('mg-dimmed');
eles.addClass('mg-highlighted');
_selectedId = null;
});
_cy.on('tap', (evt) => {
if (evt.target === _cy) _clearSelection();
});
}
// ---- link (manual relationship) mode ----
function _setLinkMode(on) {
_linkMode = on;
_linkSourceId = null;
if (_cy) _cy.nodes().removeClass('mg-link-source');
const btn = document.getElementById('memory-graph-link-mode-btn');
if (btn) btn.classList.toggle('active', on);
}
async function _handleLinkModeClick(id) {
if (_isDemo) { uiModule.showToast?.('Demo data — add a real memory first', 2500); return; }
if (!_linkSourceId) {
_linkSourceId = id;
_cy.getElementById(id).addClass('mg-link-source');
uiModule.showToast?.('Click another memory to connect it', 2500);
return;
}
if (_linkSourceId === id) return;
const sourceId = _linkSourceId;
_cy.nodes().removeClass('mg-link-source');
_linkSourceId = null;
try {
await _apiAddLink(sourceId, id);
uiModule.showToast?.('Relationship added');
await _reloadAfterMutation();
} catch (err) {
console.error('[memoryGraph] add link failed', err);
uiModule.showToast?.('Could not add relationship');
}
}
// ---- detail panel ----
function _formatTimestamp(ts) {
if (!ts) return 'Unknown';
try { return new Date(ts * 1000).toLocaleString(); } catch { return 'Unknown'; }
}
function _renderDetailPanel() {
const panel = document.getElementById('memory-graph-detail');
if (!panel) return;
panel.classList.remove('hidden');
if (!_selectedId) {
panel.innerHTML = '<div class="memory-graph-detail-empty">Select a memory to see details.</div>';
return;
}
const node = _findNode(_selectedId);
if (!node) { _selectedId = null; _renderDetailPanel(); return; }
const color = _categoryColor(node.category);
const links = _manualLinksFor(_selectedId);
panel.innerHTML = `
<span class="memory-graph-detail-cat" style="--mg-cat-color:${color}">${escapeHtml(node.category || 'fact')}</span>
<div class="memory-graph-detail-text" id="memory-graph-detail-text">${escapeHtml(node.text || '')}</div>
<div class="memory-graph-detail-meta">
<span>${node.uses || 0} use${node.uses === 1 ? '' : 's'} · ${node.pinned ? 'pinned' : 'not pinned'}</span>
<span>${escapeHtml(_formatTimestamp(node.timestamp))}</span>
</div>
<div class="memory-graph-detail-actions" id="memory-graph-detail-actions">
<button type="button" data-action="pin">${node.pinned ? 'Unpin' : 'Pin'}</button>
<button type="button" data-action="edit">Edit</button>
<button type="button" data-action="link">Start link</button>
<button type="button" data-action="isolate">${_isolateRootId === node.id ? 'Show all' : 'Isolate'}</button>
<button type="button" class="danger" data-action="delete">Delete</button>
</div>
<div class="memory-graph-detail-links">
<div class="memory-graph-detail-links-title">Relationships (${links.length})</div>
${links.length ? links.map(l => `
<div class="memory-graph-link-row" data-target="${escapeHtml(l.id)}">
<span class="memory-graph-link-row-text">${escapeHtml((l.node.text || '').slice(0, 40))}</span>
<span class="memory-graph-link-row-remove" title="Remove relationship"></span>
</div>`).join('') : '<div style="font-size:11px;opacity:0.5;">No manual relationships yet.</div>'}
</div>
${_isDemo ? '<div style="font-size:10.5px;opacity:0.6;">Demo data — actions disabled. Add a real memory to try this.</div>' : ''}
`;
if (_isDemo) {
panel.querySelectorAll('button, .memory-graph-link-row-remove').forEach(b => {
b.disabled = true;
b.style.pointerEvents = 'none';
b.style.opacity = '0.4';
});
return;
}
panel.querySelector('[data-action="pin"]')?.addEventListener('click', () => _actionPin(node));
panel.querySelector('[data-action="edit"]')?.addEventListener('click', () => _enterEditMode(node));
panel.querySelector('[data-action="link"]')?.addEventListener('click', () => {
_setLinkMode(true);
_linkSourceId = node.id;
_cy.getElementById(node.id).addClass('mg-link-source');
uiModule.showToast?.('Click another memory to connect it', 2500);
});
panel.querySelector('[data-action="isolate"]')?.addEventListener('click', () => _toggleIsolate(node.id));
panel.querySelector('[data-action="delete"]')?.addEventListener('click', () => _actionDelete(node));
panel.querySelectorAll('.memory-graph-link-row-remove').forEach(el => {
el.addEventListener('click', (e) => {
const row = e.target.closest('.memory-graph-link-row');
const targetId = row?.dataset.target;
if (targetId) _actionRemoveLink(node.id, targetId);
});
});
}
function _enterEditMode(node) {
const textEl = document.getElementById('memory-graph-detail-text');
const actions = document.getElementById('memory-graph-detail-actions');
if (!textEl || !actions) return;
const textarea = document.createElement('textarea');
textarea.className = 'memory-graph-detail-textarea';
textarea.value = node.text || '';
textEl.replaceWith(textarea);
textarea.focus();
actions.innerHTML = `
<button type="button" data-action="save">Save</button>
<button type="button" data-action="cancel">Cancel</button>
`;
actions.querySelector('[data-action="save"]').addEventListener('click', async () => {
const newText = textarea.value.trim();
if (!newText) { uiModule.showToast?.('Memory text cannot be empty'); return; }
await _actionUpdateText(node, newText);
});
actions.querySelector('[data-action="cancel"]').addEventListener('click', () => _renderDetailPanel());
}
// ---- mutations against the Milestone 1 backend ----
function _formBody(fields) {
const body = new URLSearchParams();
Object.entries(fields || {}).forEach(([k, v]) => { if (v !== undefined && v !== null) body.set(k, String(v)); });
return body.toString();
}
async function _postForm(url, fields) {
const res = await fetch(url, {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: _formBody(fields),
});
if (!res.ok) throw new Error(`Request failed (${res.status})`);
return res.json().catch(() => ({}));
}
async function _putForm(url, fields) {
const res = await fetch(url, {
method: 'PUT',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: _formBody(fields),
});
if (!res.ok) throw new Error(`Request failed (${res.status})`);
return res.json().catch(() => ({}));
}
async function _apiAddLink(sourceId, targetId) {
const url = `${API_BASE}/api/memory/${encodeURIComponent(sourceId)}/links?target_id=${encodeURIComponent(targetId)}`;
const res = await fetch(url, { method: 'POST', credentials: 'same-origin' });
if (!res.ok) throw new Error(`Request failed (${res.status})`);
return res.json();
}
async function _reloadAfterMutation(keepSelection = true) {
const prevSelected = keepSelection ? _selectedId : null;
try {
_graph = await _fetchGraph();
_isDemo = false;
} catch (err) {
console.error('[memoryGraph] reload failed', err);
return;
}
_renderCategoryChips();
_renderGraph();
if (prevSelected && _cy && _cy.getElementById(prevSelected).nonempty()) {
_selectNode(prevSelected);
}
}
async function _actionPin(node) {
try {
await _postForm(`${API_BASE}/api/memory/${encodeURIComponent(node.id)}/pin`, { pinned: !node.pinned });
uiModule.showToast?.(node.pinned ? 'Unpinned' : 'Pinned');
await _reloadAfterMutation();
} catch (err) {
console.error('[memoryGraph] pin failed', err);
uiModule.showToast?.('Could not update pin state');
}
}
async function _actionUpdateText(node, newText) {
try {
await _putForm(`${API_BASE}/api/memory/${encodeURIComponent(node.id)}`, { text: newText, category: node.category });
uiModule.showToast?.('Memory updated');
await _reloadAfterMutation();
} catch (err) {
console.error('[memoryGraph] update failed', err);
uiModule.showToast?.('Could not update memory');
}
}
async function _actionDelete(node) {
const ok = await uiModule.styledConfirm(`Delete this memory?\n\n"${node.text}"`, { confirmText: 'Delete', danger: true });
if (!ok) return;
try {
const res = await fetch(`${API_BASE}/api/memory/${encodeURIComponent(node.id)}`, { method: 'DELETE', credentials: 'same-origin' });
if (!res.ok) throw new Error(`Request failed (${res.status})`);
uiModule.showToast?.('Memory deleted');
await _reloadAfterMutation(false);
} catch (err) {
console.error('[memoryGraph] delete failed', err);
uiModule.showToast?.('Could not delete memory');
}
}
async function _actionRemoveLink(sourceId, targetId) {
try {
const res = await fetch(`${API_BASE}/api/memory/${encodeURIComponent(sourceId)}/links/${encodeURIComponent(targetId)}`, {
method: 'DELETE',
credentials: 'same-origin',
});
if (!res.ok) throw new Error(`Request failed (${res.status})`);
uiModule.showToast?.('Relationship removed');
await _reloadAfterMutation();
} catch (err) {
console.error('[memoryGraph] remove link failed', err);
uiModule.showToast?.('Could not remove relationship');
}
}
// ---- keyboard navigation ----
function _visibleNodeIds() {
if (!_cy) return [];
return _cy.nodes().filter(n => n.style('display') !== 'none').map(n => n.id());
}
function _navigateNodes(delta) {
const ids = _visibleNodeIds();
if (!ids.length) return;
const curIdx = _selectedId ? ids.indexOf(_selectedId) : -1;
const nextIdx = curIdx === -1 ? 0 : (curIdx + delta + ids.length) % ids.length;
const id = ids[nextIdx];
_selectNode(id);
const node = _cy.getElementById(id);
if (node && node.nonempty()) _cy.animate({ center: { eles: node } }, { duration: 150 });
}
// ---- open / close ----
export function isMemoryGraphOpen() {
if (Modals.isMinimized('memory-graph-modal')) return false;
return _open;
}
export function openMemoryGraph() {
if (_open) return;
if (Modals.isMinimized('memory-graph-modal')) {
Modals.restore('memory-graph-modal');
_open = true;
return;
}
_open = true;
const modal = _getModal();
modal.classList.remove('hidden', 'modal-minimized');
const content = modal.querySelector('.modal-content');
if (content) {
content.classList.remove('modal-closing', 'sheet-ready');
content.style.transform = '';
content.style.transition = '';
content.style.animation = '';
content.style.opacity = '';
}
modal.style.display = 'flex';
Modals.register('memory-graph-modal', {
railBtnId: 'tool-memory-graph-btn',
closeFn: () => _doCloseMemoryGraph(),
restoreFn: () => { if (_cy) _cy.resize(); },
label: 'Memory Graph',
icon: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="6" cy="6" r="2.4"/><circle cx="18" cy="6" r="2.4"/><circle cx="12" cy="13" r="2.4"/><circle cx="6" cy="19" r="2.4"/><circle cx="18" cy="19" r="2.4"/></svg>',
});
const btn = document.getElementById('tool-memory-graph-btn');
if (btn) btn.classList.add('active');
_keyHandler = (e) => {
if (Modals.isMinimized('memory-graph-modal')) return;
const active = document.activeElement;
const typing = active && (active.tagName === 'INPUT' || active.tagName === 'TEXTAREA');
if (e.key === 'Escape') {
if (active && active.id === 'memory-graph-search' && active.value) {
active.value = '';
_searchTerm = '';
_applySearch();
return;
}
if (_linkMode) { _setLinkMode(false); return; }
closeMemoryGraph();
return;
}
if (typing) return; // don't hijack f/arrows while the user is typing anywhere in the modal
if (e.key === 'f' && !e.ctrlKey && !e.metaKey && !e.altKey) {
e.preventDefault();
document.getElementById('memory-graph-search')?.focus();
return;
}
if (e.key === 'ArrowRight' || e.key === 'ArrowDown') { e.preventDefault(); _navigateNodes(1); return; }
if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') { e.preventDefault(); _navigateNodes(-1); return; }
};
document.addEventListener('keydown', _keyHandler);
_wireResize();
_loadGraph();
requestAnimationFrame(() => { if (_cy) _cy.resize(); });
}
function _doCloseMemoryGraph() {
_open = false;
_setLinkMode(false);
if (_modal) { _modal.style.display = 'none'; _modal.classList.add('hidden'); }
if (_keyHandler) { document.removeEventListener('keydown', _keyHandler); _keyHandler = null; }
const btn = document.getElementById('tool-memory-graph-btn');
if (btn) btn.classList.remove('active');
}
export function closeMemoryGraph() {
if (!_open && !Modals.isMinimized('memory-graph-modal')) return;
if (Modals.isRegistered('memory-graph-modal')) {
Modals.close('memory-graph-modal');
} else {
_doCloseMemoryGraph();
}
}
const memoryGraphModule = { openMemoryGraph, closeMemoryGraph, isMemoryGraphOpen };
export default memoryGraphModule;

31
static/lib/cytoscape.min.js vendored Normal file

File diff suppressed because one or more lines are too long

View file

@ -41130,3 +41130,250 @@ body.theme-frosted .modal {
.compare-grid[data-cols] { grid-template-columns: 1fr !important; overflow-y: auto; }
.compare-pane { min-height: 60dvh; }
}
/* ── Memory Graph View (beta) ── */
.memory-graph-modal-content {
width: min(1180px, 94vw);
height: min(820px, 90vh);
display: flex;
flex-direction: column;
padding: 0;
}
.memory-graph-modal-body {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
padding: 0;
}
.memory-graph-toolbar {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
border-bottom: 1px solid var(--border);
flex-wrap: wrap;
}
.memory-graph-search {
flex: 1 1 160px;
min-width: 120px;
padding: 5px 9px;
border-radius: 6px;
border: 1px solid var(--border);
background: var(--bg);
color: var(--fg);
font-size: 12px;
}
.memory-graph-cat-chips {
display: flex;
gap: 4px;
flex-wrap: wrap;
}
.memory-graph-cat-chip {
font-size: 10.5px;
padding: 3px 8px;
border-radius: 999px;
border: 1px solid var(--border);
background: transparent;
color: var(--fg);
cursor: pointer;
opacity: 0.55;
white-space: nowrap;
}
.memory-graph-cat-chip.active {
opacity: 1;
border-color: var(--mg-cat-color, var(--accent, var(--red)));
color: var(--mg-cat-color, var(--accent, var(--red)));
background: color-mix(in srgb, var(--mg-cat-color, var(--accent, var(--red))) 14%, transparent);
}
.memory-graph-link-mode-btn {
font-size: 11px;
padding: 4px 10px;
border-radius: 6px;
border: 1px solid var(--border);
background: transparent;
color: var(--fg);
cursor: pointer;
white-space: nowrap;
}
.memory-graph-link-mode-btn.active {
background: color-mix(in srgb, var(--accent, var(--red)) 20%, transparent);
border-color: var(--accent, var(--red));
color: var(--accent, var(--red));
}
.memory-graph-slider-row {
display: flex;
align-items: center;
gap: 6px;
font-size: 10.5px;
opacity: 0.75;
white-space: nowrap;
}
.memory-graph-slider-row input[type="range"] {
width: 80px;
}
.memory-graph-main {
flex: 1;
display: flex;
min-height: 0;
position: relative;
}
.memory-graph-canvas {
position: absolute;
inset: 0;
background: var(--bg);
}
.memory-graph-loading {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
}
.memory-graph-demo-banner {
position: absolute;
top: 8px;
left: 50%;
transform: translateX(-50%);
background: color-mix(in srgb, var(--warn) 20%, var(--panel));
border: 1px solid var(--warn);
color: var(--warn);
font-size: 11px;
padding: 4px 12px;
border-radius: 999px;
z-index: 5;
white-space: nowrap;
}
.memory-graph-detail-panel {
width: 280px;
flex-shrink: 0;
border-left: 1px solid var(--border);
background: var(--panel);
padding: 12px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 10px;
}
.memory-graph-detail-panel.hidden { display: none; }
.memory-graph-detail-empty {
color: var(--color-muted);
font-size: 12px;
margin: auto;
text-align: center;
}
.memory-graph-detail-cat {
display: inline-block;
font-size: 10px;
padding: 2px 8px;
border-radius: 999px;
border: 1px solid var(--mg-cat-color, var(--border));
color: var(--mg-cat-color, var(--fg));
width: fit-content;
}
.memory-graph-detail-text {
font-size: 13px;
line-height: 1.45;
white-space: pre-wrap;
word-break: break-word;
}
.memory-graph-detail-textarea {
width: 100%;
min-height: 70px;
resize: vertical;
font: inherit;
font-size: 12.5px;
padding: 6px 8px;
border-radius: 6px;
border: 1px solid var(--border);
background: var(--bg);
color: var(--fg);
}
.memory-graph-detail-meta {
font-size: 10.5px;
color: var(--color-muted);
display: flex;
flex-direction: column;
gap: 2px;
}
.memory-graph-detail-actions {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.memory-graph-detail-actions button {
font-size: 11px;
padding: 4px 9px;
border-radius: 6px;
border: 1px solid var(--border);
background: transparent;
color: var(--fg);
cursor: pointer;
}
.memory-graph-detail-actions button:hover { background: color-mix(in srgb, var(--fg) 8%, transparent); }
.memory-graph-detail-actions button.danger { color: var(--red); border-color: var(--red); }
.memory-graph-detail-links {
display: flex;
flex-direction: column;
gap: 4px;
}
.memory-graph-detail-links-title {
font-size: 10.5px;
color: var(--color-muted);
text-transform: uppercase;
letter-spacing: 0.03em;
}
.memory-graph-link-row {
display: flex;
align-items: center;
gap: 6px;
font-size: 11.5px;
padding: 3px 6px;
border-radius: 5px;
background: color-mix(in srgb, var(--fg) 5%, transparent);
}
.memory-graph-link-row-text {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.memory-graph-link-row-remove {
cursor: pointer;
opacity: 0.6;
flex-shrink: 0;
}
.memory-graph-link-row-remove:hover { opacity: 1; color: var(--red); }
.memory-graph-legend {
position: absolute;
bottom: 8px;
left: 8px;
display: flex;
flex-direction: column;
gap: 3px;
font-size: 10px;
color: var(--color-muted);
background: color-mix(in srgb, var(--panel) 80%, transparent);
border: 1px solid var(--border);
border-radius: 6px;
padding: 6px 9px;
z-index: 4;
}
.memory-graph-legend-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
cursor: pointer;
font-weight: 600;
color: var(--fg);
opacity: 0.85;
}
.memory-graph-legend-caret { font-size: 9px; transition: transform 0.15s; }
.memory-graph-legend.collapsed .memory-graph-legend-caret { transform: rotate(-90deg); }
.memory-graph-legend.collapsed .memory-graph-legend-body { display: none; }
.memory-graph-legend-body { display: flex; flex-direction: column; gap: 3px; margin-top: 4px; }
.memory-graph-legend-row { display: flex; align-items: center; gap: 5px; }
.memory-graph-legend-line { width: 14px; height: 0; border-top: 2px solid var(--fg); display: inline-block; }
.memory-graph-legend-line.dashed { border-top-style: dashed; }

View file

@ -0,0 +1,68 @@
// Loads the pure-logic pieces of the real memoryGraph.js under Node, mirroring
// the vm.createContext() sandbox pattern used by
// tests/markdown_codefence_placeholder_regression.mjs: read the production
// source, string-shim its sibling imports out, strip `export` keywords, and
// expose the internal functions under test via `this.__name = name`.
//
// Only functions that don't touch Cytoscape/the DOM beyond getComputedStyle
// are exposed here — anything that calls `_cy.*` or builds live modal DOM
// stays covered by manual browser verification instead (see docs/progress.md).
import fs from 'node:fs';
import path from 'node:path';
import vm from 'node:vm';
import { fileURLToPath } from 'node:url';
const REPO = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..');
const SOURCE_PATH = path.join(REPO, 'static', 'js', 'memoryGraph.js');
// Fake theme: distinct colors per CSS custom property so category->color
// mapping is deterministic and each category is provably distinct.
const FAKE_CSS_VARS = {
'--fg': '#fg-color',
'--bg': '#bg-color',
'--border': '#border-color',
'--accent': '#accent-color',
'--red': '#red-color',
'--hl-keyword': '#identity-color',
'--warn': '#preference-color',
'--color-accent': '#contact-color',
'--color-brand-blue': '#project-color',
'--accent-warm': '#goal-color',
'--green': '#task-color',
'--color-muted-alt': '#fallback-color',
};
export function loadMemoryGraph() {
let src = fs.readFileSync(SOURCE_PATH, 'utf8');
src = src.replace(/^import uiModule from '\.\/ui\.js';$/m, "const uiModule = { esc: (s) => String(s) };");
src = src.replace(/^import spinnerModule from '\.\/spinner\.js';$/m, "const spinnerModule = {};");
src = src.replace(/^import \* as Modals from '\.\/modalManager\.js';$/m, "const Modals = {};");
src = src.replace(/^import \{ makeWindowDraggable \} from '\.\/windowDrag\.js';$/m, "function makeWindowDraggable() {}");
src = src.replace(/^export function /gm, 'function ');
src = src.replace(/^export const /gm, 'const ');
src = src.replace(/^const memoryGraphModule[\s\S]*?^export default memoryGraphModule;$/m, '');
src += `
this.__nodeSize = _nodeSize;
this.__categoryColor = _categoryColor;
this.__toElements = _toElements;
this.__buildQuery = _buildQuery;
this.__componentNodeIds = _componentNodeIds;
this.__DEMO_GRAPH = DEMO_GRAPH;
this.__KNOWN_CATEGORIES = KNOWN_CATEGORIES;
`;
const sandbox = {
console,
URLSearchParams,
getComputedStyle() {
return { getPropertyValue: (name) => FAKE_CSS_VARS[name] || '' };
},
document: { documentElement: {} },
window: { location: { origin: 'http://localhost' } },
};
vm.createContext(sandbox);
vm.runInContext(src, sandbox, { filename: SOURCE_PATH });
return sandbox;
}

View file

@ -0,0 +1,101 @@
// Pure-logic tests for static/js/memoryGraph.js: category->color resolution,
// API-response-to-Cytoscape-elements mapping, the fetch query string, the
// bundled demo graph's shape, and the isolate-component BFS. DOM/Cytoscape
// rendering behavior is covered by manual browser verification instead (see
// docs/progress.md) — there is no automated DOM test harness in this repo.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { loadMemoryGraph } from './graphHarness.mjs';
const mg = loadMemoryGraph();
test('_nodeSize scales with uses and clamps to [20, 60]', () => {
assert.equal(mg.__nodeSize({ uses: 0 }), 20);
assert.equal(mg.__nodeSize({}), 20);
assert.equal(mg.__nodeSize({ uses: 3 }), 32);
assert.equal(mg.__nodeSize({ uses: 100 }), 60);
});
test('_categoryColor resolves each known category to a distinct color', () => {
const colors = mg.__KNOWN_CATEGORIES.map((c) => mg.__categoryColor(c));
assert.equal(new Set(colors).size, colors.length, 'expected every known category to map to a distinct color');
});
test('_categoryColor falls back for an unrecognized category', () => {
assert.equal(mg.__categoryColor('totally-unknown-category'), '#fallback-color');
});
test('_buildQuery always over-fetches below the UI slider floor', () => {
const params = new URLSearchParams(mg.__buildQuery());
assert.equal(params.get('min_similarity'), '0.5');
assert.equal(params.get('max_edges_per_node'), '8');
});
test('_toElements maps every node and truncates long labels with an ellipsis', () => {
const graph = {
nodes: [
{ id: 'a', text: 'short', category: 'fact', uses: 1 },
{ id: 'b', text: 'x'.repeat(60), category: 'fact', uses: 1 },
],
edges: [],
};
const { nodes } = mg.__toElements(graph);
assert.equal(nodes.length, 2);
assert.equal(nodes[0].data.label, 'short');
assert.equal(nodes[1].data.label.endsWith('…'), true);
assert.equal(nodes[1].data.label.length, 43); // 42 chars + ellipsis
});
test('_toElements drops edges whose endpoints are not among the given nodes', () => {
// This is exactly the referential-integrity class of bug the demo-graph
// test below also guards: an edge naming a node id that doesn't exist
// must never reach Cytoscape (it throws on element construction otherwise).
const graph = {
nodes: [{ id: 'a', text: 'A', category: 'fact' }],
edges: [
{ source: 'a', target: 'missing', type: 'similarity', weight: 0.9 },
{ source: 'missing', target: 'a', type: 'similarity', weight: 0.9 },
],
};
const { edges } = mg.__toElements(graph);
assert.equal(edges.length, 0);
});
test('DEMO_GRAPH nodes and edges are internally consistent', () => {
const graph = mg.__DEMO_GRAPH;
const ids = new Set(graph.nodes.map((n) => n.id));
assert.equal(graph.meta.node_count, graph.nodes.length);
assert.equal(graph.meta.edge_count, graph.edges.length);
for (const edge of graph.edges) {
assert.ok(ids.has(edge.source), `edge source "${edge.source}" is not a real demo node`);
assert.ok(ids.has(edge.target), `edge target "${edge.target}" is not a real demo node`);
}
for (const node of graph.nodes) {
assert.ok(mg.__KNOWN_CATEGORIES.includes(node.category), `demo node has unknown category "${node.category}"`);
}
});
test('_componentNodeIds returns the full connected component, undirected', () => {
const graph = {
nodes: [{ id: 'a' }, { id: 'b' }, { id: 'c' }, { id: 'd' }],
edges: [
{ source: 'a', target: 'b' },
{ source: 'b', target: 'c' },
// 'd' is disconnected from a/b/c
],
};
const component = mg.__componentNodeIds(graph, 'a');
assert.deepEqual([...component].sort(), ['a', 'b', 'c']);
});
test('_componentNodeIds isolates a node with no edges to just itself', () => {
const graph = { nodes: [{ id: 'a' }, { id: 'b' }], edges: [] };
const component = mg.__componentNodeIds(graph, 'a');
assert.deepEqual([...component], ['a']);
});
test('_componentNodeIds returns an empty set for an id not in the graph', () => {
const graph = { nodes: [{ id: 'a' }], edges: [] };
const component = mg.__componentNodeIds(graph, 'does-not-exist');
assert.equal(component.size, 0);
});

View file

@ -0,0 +1,161 @@
"""Pure edge-derivation logic for the Memory Graph View.
No FastAPI, no Chroma src.memory_graph.build_graph and friends operate on
plain memory-entry dicts and a duck-typed vector-store stand-in, so these are
plain table-driven unit tests.
"""
from unittest.mock import MagicMock
from src.memory_graph import (
build_graph,
build_manual_edges,
build_session_edges,
build_similarity_edges,
)
def _mem(id_, text="text", category="fact", session_id=None, links=None, uses=0, timestamp=0, pinned=False):
entry = {
"id": id_,
"text": text,
"category": category,
"uses": uses,
"timestamp": timestamp,
"pinned": pinned,
}
if session_id is not None:
entry["session_id"] = session_id
if links is not None:
entry["links"] = links
return entry
def test_similarity_edges_respects_threshold_and_top_k():
memories = [_mem("a", text="text-a"), _mem("b", text="text-b"), _mem("c", text="text-c")]
# Each node's own nearest-neighbor query returns a distinct ranking, as a
# real per-text ANN search would (a canned identical list for every node
# would make "c" spuriously match "a"/"b" from their own query results).
neighbor_scores = {
"a": [{"memory_id": "b", "score": 0.9}, {"memory_id": "c", "score": 0.5}],
"b": [{"memory_id": "a", "score": 0.9}, {"memory_id": "c", "score": 0.4}],
"c": [{"memory_id": "a", "score": 0.5}, {"memory_id": "b", "score": 0.4}],
}
vec = MagicMock(healthy=True)
vec.search.side_effect = lambda text, k, _scores=neighbor_scores: _scores[
next(m["id"] for m in memories if m["text"] == text)
]
edges = build_similarity_edges(memories, vec, min_similarity=0.8, max_edges_per_node=5)
pairs = {frozenset((e["source"], e["target"])) for e in edges}
assert frozenset(("a", "b")) in pairs
assert all("c" not in p for p in pairs) # below threshold, excluded
def test_similarity_edges_no_self_loop_and_deduped():
memories = [_mem("a"), _mem("b")]
vec = MagicMock(healthy=True)
vec.search.side_effect = lambda text, k: [
{"memory_id": "a", "score": 1.0},
{"memory_id": "b", "score": 0.99},
]
edges = build_similarity_edges(memories, vec, min_similarity=0.5)
assert len(edges) == 1
assert edges[0]["source"] == "a" and edges[0]["target"] == "b"
def test_similarity_edges_skips_ids_outside_scope():
memories = [_mem("a")]
vec = MagicMock(healthy=True)
vec.search.side_effect = lambda text, k: [
{"memory_id": "a", "score": 1.0},
{"memory_id": "ghost-from-another-owner", "score": 0.95},
]
edges = build_similarity_edges(memories, vec, min_similarity=0.5)
assert edges == []
def test_similarity_edges_unhealthy_vector_store_returns_nothing():
memories = [_mem("a"), _mem("b")]
vec = MagicMock(healthy=False)
assert build_similarity_edges(memories, vec) == []
assert build_similarity_edges(memories, None) == []
def test_similarity_edges_single_memory_short_circuits_without_querying():
vec = MagicMock(healthy=True)
assert build_similarity_edges([_mem("a")], vec) == []
vec.search.assert_not_called()
def test_session_edges_link_same_session_only():
memories = [
_mem("a", session_id="s1"),
_mem("b", session_id="s1"),
_mem("c", session_id="s2"),
]
edges = build_session_edges(memories)
assert len(edges) == 1
assert {edges[0]["source"], edges[0]["target"]} == {"a", "b"}
assert edges[0]["type"] == "session"
def test_session_edges_ignores_singleton_sessions_and_missing_session_id():
memories = [_mem("a", session_id="solo"), _mem("b")]
assert build_session_edges(memories) == []
def test_manual_edges_reflect_links_field_bidirectionally_deduped():
memories = [_mem("a", links=["b"]), _mem("b", links=["a"]), _mem("c")]
edges = build_manual_edges(memories)
assert len(edges) == 1
assert {edges[0]["source"], edges[0]["target"]} == {"a", "b"}
assert edges[0]["type"] == "manual"
def test_manual_edges_ignore_self_links_and_dangling_targets():
memories = [_mem("a", links=["a", "does-not-exist"])]
assert build_manual_edges(memories) == []
def test_build_graph_filters_by_category():
memories = [_mem("a", category="fact"), _mem("b", category="preference")]
graph = build_graph(memories, categories=["fact"])
assert [n["id"] for n in graph["nodes"]] == ["a"]
assert graph["meta"]["total_memories"] == 1
def test_build_graph_truncates_and_keeps_most_used_recent_first():
memories = [
_mem("a", uses=0, timestamp=1),
_mem("b", uses=5, timestamp=1),
_mem("c", uses=0, timestamp=2),
]
graph = build_graph(memories, limit=2)
ids = {n["id"] for n in graph["nodes"]}
assert ids == {"b", "c"}
assert graph["meta"]["truncated"] is True
assert graph["meta"]["total_memories"] == 3
def test_build_graph_combines_all_edge_types():
memories = [
_mem("a", session_id="s1", links=["b"]),
_mem("b", session_id="s1"),
]
vec = MagicMock(healthy=True)
vec.search.side_effect = lambda text, k: [{"memory_id": "a", "score": 1.0}, {"memory_id": "b", "score": 0.99}]
graph = build_graph(memories, vec, min_similarity=0.5)
types = {e["type"] for e in graph["edges"]}
assert types == {"similarity", "session", "manual"}
assert graph["meta"]["node_count"] == 2
def test_build_graph_no_vector_store_skips_similarity_edges_only():
memories = [_mem("a", session_id="s1"), _mem("b", session_id="s1")]
graph = build_graph(memories, memory_vector=None)
assert {e["type"] for e in graph["edges"]} == {"session"}
def test_build_graph_flags_can_disable_derived_edge_types():
memories = [_mem("a", session_id="s1", links=["b"]), _mem("b", session_id="s1")]
graph = build_graph(memories, include_session_edges=False, include_manual_edges=False)
assert graph["edges"] == []

View file

@ -0,0 +1,42 @@
"""Runs the Node-based Memory Graph pure-logic suite (tests/memoryGraph/*.test.mjs).
Covers static/js/memoryGraph.js's non-DOM pieces: category->color resolution,
mapping an API graph response into Cytoscape elements (including dropping
edges with dangling endpoints), the fetch query string, the bundled demo
graph's referential integrity, and the isolate-component BFS. Loaded via a
vm.createContext() sandbox (tests/memoryGraph/graphHarness.mjs) since the
module isn't directly ESM-importable outside a browser (sibling imports of
ui.js/spinner.js/modalManager.js/windowDrag.js). Skipped when node is
unavailable, mirroring tests/test_streaming_segmenter_js.py.
Node-click/detail-panel/search/filter/isolate DOM behavior is exercised
against a running app, not here, consistent with how this project tests
browser-coupled code (see docs/progress.md Session 4 for the manual pass).
"""
import shutil
import subprocess
from pathlib import Path
import pytest
_REPO = Path(__file__).resolve().parent.parent
_HAS_NODE = shutil.which("node") is not None
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
def test_memory_graph_pure_logic_suite():
test_files = sorted(str(p) for p in (_REPO / "tests" / "memoryGraph").glob("*.test.mjs"))
assert test_files, "no memoryGraph test files found"
result = subprocess.run(
["node", "--test", *test_files],
cwd=_REPO,
capture_output=True,
timeout=180,
text=True,
)
if result.returncode != 0:
raise AssertionError(
f"node --test failed:\nSTDOUT:\n{result.stdout}\nSTDERR:\n{result.stderr}"
)

View file

@ -0,0 +1,59 @@
"""Regression guard for the one real collision risk in this feature.
routes/memory/memory_routes.py registers `GET/PUT/DELETE /api/memory/{memory_id}`
as a single-segment wildcard. Starlette matches routes in registration order
across the whole app, not by specificity, so `GET /api/memory/graph` would be
silently swallowed by that wildcard (memory_id="graph") if memory_router were
ever included before memory_graph_router. app.py documents and enforces the
required order; this test builds a minimal app the same way and proves a real
HTTP request resolves to the graph handler, not the wildcard 404 path a
plain "call the endpoint function directly" test (the repo's usual route-test
style) can't catch this class of bug because it looks up routes by exact
path-string equality, not by simulating Starlette's request matching.
"""
from unittest.mock import MagicMock
from fastapi import FastAPI
from fastapi.testclient import TestClient
from starlette.requests import Request
from routes.memory.memory_graph_routes import setup_memory_graph_routes
from routes.memory.memory_routes import setup_memory_routes
def _build_app():
app = FastAPI()
memory_manager = MagicMock()
memory_manager.load.return_value = []
session_manager = MagicMock()
# Mirrors app.py: memory_graph_router included BEFORE memory_router.
app.include_router(setup_memory_graph_routes(memory_manager, memory_vector=None))
app.include_router(setup_memory_routes(memory_manager, session_manager, memory_vector=None))
@app.middleware("http")
async def _fake_auth(request: Request, call_next):
request.state.current_user = "alice"
request.state.api_token = False
return await call_next(request)
return app
def test_graph_route_is_not_swallowed_by_memory_id_wildcard():
client = TestClient(_build_app())
resp = client.get("/api/memory/graph")
assert resp.status_code == 200
body = resp.json()
assert "nodes" in body and "edges" in body
def test_memory_id_wildcard_still_works_for_real_ids():
client = TestClient(_build_app())
resp = client.get("/api/memory/some-real-id")
# Not found (empty memory store), but resolved by the wildcard handler,
# not a 422/other error — proves the wildcard route still works normally
# once the graph route (checked first) doesn't match.
assert resp.status_code == 404
assert resp.json()["detail"] == "Memory not found"

View file

@ -0,0 +1,210 @@
"""Memory Graph View route tests.
Follows the repo's established convention (see
tests/test_memory_routes_session_owner.py): build the router via its
setup_*_routes factory directly, monkeypatch auth helpers, look up the
target endpoint by path, and call it directly with a hand-built Request
stand-in. No TestClient/ASGI app except in
test_memory_graph_route_ordering.py, which specifically needs real Starlette
path matching.
"""
from types import SimpleNamespace
from unittest.mock import MagicMock
import pytest
from fastapi import HTTPException
import routes.memory.memory_graph_routes as mgr
def _route(router, path, method):
for r in router.routes:
if r.path == path and method in getattr(r, "methods", set()):
return r.endpoint
raise AssertionError(path)
def _request(user):
return SimpleNamespace(
state=SimpleNamespace(current_user=user, api_token=False),
app=SimpleNamespace(state=SimpleNamespace(auth_manager=None)),
client=SimpleNamespace(host="127.0.0.1"),
)
def _allow_memory_management(monkeypatch, caller):
monkeypatch.setattr(mgr, "require_privilege", lambda request, key: caller)
def test_graph_only_returns_callers_own_memories(monkeypatch):
monkeypatch.setattr(mgr, "get_current_user", lambda request: "alice", raising=False)
monkeypatch.setattr(mgr, "require_user", lambda request: "alice", raising=False)
memory_manager = MagicMock()
memory_manager.load.side_effect = lambda owner=None: (
[{"id": "m1", "text": "alice's note", "owner": "alice", "category": "fact", "uses": 0, "timestamp": 1}]
if owner == "alice" else []
)
router = mgr.setup_memory_graph_routes(memory_manager, memory_vector=None)
get_graph = _route(router, "/api/memory/graph", "GET")
out = get_graph(request=_request("alice"), category=None, min_similarity=0.75,
max_edges_per_node=5, include_session_edges=True,
include_manual_edges=True, limit=1000)
assert [n["id"] for n in out["nodes"]] == ["m1"]
memory_manager.load.assert_called_with(owner="alice")
def test_graph_neighbors_rejects_foreign_owned_memory(monkeypatch):
monkeypatch.setattr(mgr, "get_current_user", lambda request: "bob", raising=False)
monkeypatch.setattr(mgr, "require_user", lambda request: "bob", raising=False)
memory_manager = MagicMock()
memory_manager.load.return_value = [
{"id": "victim-mem", "text": "alice secret", "owner": "alice", "category": "fact", "uses": 0, "timestamp": 1},
]
router = mgr.setup_memory_graph_routes(memory_manager, memory_vector=None)
neighbors = _route(router, "/api/memory/graph/{memory_id}/neighbors", "GET")
with pytest.raises(HTTPException) as exc:
neighbors(request=_request("bob"), memory_id="victim-mem", min_similarity=0.75, max_edges_per_node=5)
assert exc.value.status_code == 404
def test_graph_neighbors_returns_404_for_unknown_id(monkeypatch):
monkeypatch.setattr(mgr, "get_current_user", lambda request: "alice", raising=False)
monkeypatch.setattr(mgr, "require_user", lambda request: "alice", raising=False)
memory_manager = MagicMock()
memory_manager.load.return_value = []
router = mgr.setup_memory_graph_routes(memory_manager, memory_vector=None)
neighbors = _route(router, "/api/memory/graph/{memory_id}/neighbors", "GET")
with pytest.raises(HTTPException) as exc:
neighbors(request=_request("alice"), memory_id="nope", min_similarity=0.75, max_edges_per_node=5)
assert exc.value.status_code == 404
def test_graph_neighbors_scopes_to_connected_subgraph(monkeypatch):
monkeypatch.setattr(mgr, "get_current_user", lambda request: "alice", raising=False)
monkeypatch.setattr(mgr, "require_user", lambda request: "alice", raising=False)
memory_manager = MagicMock()
memory_manager.load.return_value = [
{"id": "a", "text": "t", "owner": "alice", "category": "fact", "uses": 0, "timestamp": 1, "session_id": "s1"},
{"id": "b", "text": "t", "owner": "alice", "category": "fact", "uses": 0, "timestamp": 1, "session_id": "s1"},
{"id": "c", "text": "t", "owner": "alice", "category": "fact", "uses": 0, "timestamp": 1},
]
router = mgr.setup_memory_graph_routes(memory_manager, memory_vector=None)
neighbors = _route(router, "/api/memory/graph/{memory_id}/neighbors", "GET")
out = neighbors(request=_request("alice"), memory_id="a", min_similarity=0.75, max_edges_per_node=5)
assert {n["id"] for n in out["nodes"]} == {"a", "b"}
def test_add_link_requires_can_manage_memory_privilege(monkeypatch):
monkeypatch.setattr(mgr, "get_current_user", lambda request: "alice", raising=False)
def deny(request, key):
raise HTTPException(403, "nope")
monkeypatch.setattr(mgr, "require_privilege", deny)
memory_manager = MagicMock()
router = mgr.setup_memory_graph_routes(memory_manager, memory_vector=None)
add_link = _route(router, "/api/memory/{memory_id}/links", "POST")
with pytest.raises(HTTPException) as exc:
add_link(request=_request("alice"), memory_id="a", target_id="b")
assert exc.value.status_code == 403
memory_manager.save.assert_not_called()
def test_add_link_rejects_foreign_owned_target(monkeypatch):
monkeypatch.setattr(mgr, "get_current_user", lambda request: "bob", raising=False)
_allow_memory_management(monkeypatch, "bob")
memory_manager = MagicMock()
memory_manager.load_all.return_value = [
{"id": "bob-mem", "text": "t", "owner": "bob"},
{"id": "alice-mem", "text": "t", "owner": "alice"},
]
router = mgr.setup_memory_graph_routes(memory_manager, memory_vector=None)
add_link = _route(router, "/api/memory/{memory_id}/links", "POST")
with pytest.raises(HTTPException) as exc:
add_link(request=_request("bob"), memory_id="bob-mem", target_id="alice-mem")
assert exc.value.status_code == 404
memory_manager.save.assert_not_called()
def test_add_link_rejects_self_link(monkeypatch):
monkeypatch.setattr(mgr, "get_current_user", lambda request: "alice", raising=False)
_allow_memory_management(monkeypatch, "alice")
memory_manager = MagicMock()
router = mgr.setup_memory_graph_routes(memory_manager, memory_vector=None)
add_link = _route(router, "/api/memory/{memory_id}/links", "POST")
with pytest.raises(HTTPException) as exc:
add_link(request=_request("alice"), memory_id="a", target_id="a")
assert exc.value.status_code == 400
memory_manager.save.assert_not_called()
def test_add_link_persists_bidirectionally_addressable_link(monkeypatch):
monkeypatch.setattr(mgr, "get_current_user", lambda request: "alice", raising=False)
_allow_memory_management(monkeypatch, "alice")
memory_manager = MagicMock()
entries = [
{"id": "a", "text": "t", "owner": "alice"},
{"id": "b", "text": "t", "owner": "alice"},
]
memory_manager.load_all.return_value = entries
router = mgr.setup_memory_graph_routes(memory_manager, memory_vector=None)
add_link = _route(router, "/api/memory/{memory_id}/links", "POST")
out = add_link(request=_request("alice"), memory_id="a", target_id="b")
assert out == {"ok": True, "links": ["b"]}
memory_manager.save.assert_called_once_with(entries)
assert entries[0]["links"] == ["b"]
def test_add_link_is_idempotent_when_link_already_present(monkeypatch):
monkeypatch.setattr(mgr, "get_current_user", lambda request: "alice", raising=False)
_allow_memory_management(monkeypatch, "alice")
memory_manager = MagicMock()
entries = [
{"id": "a", "text": "t", "owner": "alice", "links": ["b"]},
{"id": "b", "text": "t", "owner": "alice"},
]
memory_manager.load_all.return_value = entries
router = mgr.setup_memory_graph_routes(memory_manager, memory_vector=None)
add_link = _route(router, "/api/memory/{memory_id}/links", "POST")
out = add_link(request=_request("alice"), memory_id="a", target_id="b")
assert out["links"] == ["b"]
def test_remove_link_is_idempotent_when_link_absent(monkeypatch):
monkeypatch.setattr(mgr, "get_current_user", lambda request: "alice", raising=False)
_allow_memory_management(monkeypatch, "alice")
memory_manager = MagicMock()
memory_manager.load_all.return_value = [{"id": "a", "text": "t", "owner": "alice"}]
router = mgr.setup_memory_graph_routes(memory_manager, memory_vector=None)
remove_link = _route(router, "/api/memory/{memory_id}/links/{target_id}", "DELETE")
out = remove_link(request=_request("alice"), memory_id="a", target_id="never-linked")
assert out == {"ok": True, "links": []}
memory_manager.save.assert_not_called()
def test_remove_link_rejects_foreign_owned_source(monkeypatch):
monkeypatch.setattr(mgr, "get_current_user", lambda request: "bob", raising=False)
_allow_memory_management(monkeypatch, "bob")
memory_manager = MagicMock()
memory_manager.load_all.return_value = [{"id": "alice-mem", "text": "t", "owner": "alice", "links": ["x"]}]
router = mgr.setup_memory_graph_routes(memory_manager, memory_vector=None)
remove_link = _route(router, "/api/memory/{memory_id}/links/{target_id}", "DELETE")
with pytest.raises(HTTPException) as exc:
remove_link(request=_request("bob"), memory_id="alice-mem", target_id="x")
assert exc.value.status_code == 404