TL;DR
- The integration is additive, not a rebuild. ollwrite already has the hard parts — a real server-side BFF (
src/app/api/*), Core magic-link auth with JWT forwarding, debounce + overlay-layer hooks, a Plate.js editor, react-dnd. Adding memory = new BFF routes + two side panes; zero changes to the auth or generation paths. - oll-memory's shell is production-grade, with three HIGH preconditions. It's a clean LlamaIndex-on-pgvector service — but it has no per-user scoping (trusts caller
collection/acl), a content-hashdocument_idthat breaks doc updates, and no embedder-space migration. All three must be designed into the integration from day one — and all three are addressed below. - The MVP is the "proactive memory sidecar." Folder ingest → your docs surface beside the paragraph you're writing (debounced retrieval) → chat-with-corpus with jump-to-source citations. Reuses ~90% of ollwrite; the only new backend risk is the three preconditions.
Config locked throughout: Groq for generation (the existing /api/write path) · for embeddings (inside oll-memory). Embeddings stay private/local; generation stays on the default Groq path. The integration never crosses those wires.
1 · oll-memory audit — what we're building on
PGVectorStore on pgvector, a Flask app-factory, its own DB. It consolidates the old extract + ingest + rag trio (PR #80). Teach the surface first, then the engine.The contract
| Endpoint | Does | Notes |
|---|---|---|
POST /api/memory | Ingest → chunk → embed → store | text/markdown only — 415 on PDF/binary |
POST /api/memory/query | Hybrid retrieve (dense + keyword), RRF-fused | Returns results[] with chunk text + metadata + RRF score |
DELETE /api/memory/{collection}/documents/{document_id} | Remove a document's chunks | Keyed on the content-hash id — see precondition #2 |
POST /api/extract | Text → typed JSON | Routes generation through the oll-model gateway |
GET /api/health{,/db,/model} | Liveness + real dependency probes | DB reachability + embedder/model provider checks |
The engine — teach it briefly
- Chunking: LlamaIndex
SentenceSplitter, and each chunk carries char offsets + the nearest markdown heading in metadata — that's what makes "jump to the exact span" and provenance chips possible downstream. - Upsert: delete-then-add, idempotent, keyed on
(collection, document)— but idempotency only holds for a byte-identical re-ingest (see #2). - Hybrid retrieval: a separate dense pull (HNSW cosine over pgvector) and a keyword pull (Postgres full-text) are fused in-app with Reciprocal Rank Fusion. Consequence to remember: the returned
scoreis the RRF rank score, not a raw cosine similarity — so there's no natural 0–1 relevance threshold on it (see the MEDIUMs). - Embeddings: a deterministic local-hash provider for dev/CI (lexical, reproducible) or Ollama
nomic-embed-textfor prod (768-dim, real semantic). This provider choice is the crux of precondition #3.
Verdict — a SOLID shell
Provider-aware boot gate · X-Service-Token auth (constant-time compare) · unified {code,message,request_id} error envelope with no 500 stack leaks · security headers · real dependency health probes · architecture-boundary tests · a generous typed-error taxonomy · and a genuinely good test suite exercising real pgvector cosine + FTS + RRF + chunk-ACL end-to-end. This is not a spike — it's a service. The work of the integration is not fixing the shell; it's the three preconditions below, which are design burdens the shell deliberately pushes onto its caller.
2 · The three HIGH preconditions
HIGH #1 No per-user scoping (IDOR)
collection, acl, and filter are free-form caller input behind a single shared service token. The service enforces chunk-level ACL, but it does not enforce per-caller ownership — any token-holder could read another user's collection simply by naming it. ⇒ A trusted BFF is mandatory. It must inject collection/acl from the authenticated identity; the browser must never reach oll-memory nor supply those fields. This isn't a flaw to file — it's the security model, and the burden lands on ollwrite (§4).
HIGH #2 Content-hash document_id breaks updates
document_id = "doc_" + sha256(bytes)[:16], and the API accepts no caller-supplied stable id. Edit a doc → new bytes → new hash → a new document; the old chunks persist and keep surfacing. Idempotency only holds for a byte-identical re-ingest. A writing product re-ingests edited docs constantly, so the corpus rots with stale versions. Fix (recommended): add a caller-supplied stable document_id + upsert-by-it to oll-memory (a small additive change) — or, as a fallback, the BFF tracks external_id → last_hash and deletes-before-reingesting. The clean fix belongs in the service.
HIGH #3 Embedder-space corruption on provider/dim swap
local-hash (lexical) and nomic (semantic) are different vector spaces at the same 768-dim, with no re-embed / migration path and no dim-guard. Store data under one, query under the other → garbage rankings, silently. ⇒ Commit to the nomic embedder before storing any real data; treat any provider/dim change as a full re-ingest; and add a boot-time guard that the stored table dim matches EMBED_DIM. The local-hash default is a dev/CI crutch — prod running on it = lexical-only retrieval masquerading as semantic.
The MEDIUMs — note, don't block on
- PDF/binary deferred —
POST /api/memoryis text/markdown only (415 otherwise); PDF ingest is a v2 item via/api/extractor client-side extraction. - RRF score has no relevance floor — RRF is a rank fusion, so there's no min-similarity cutoff; a low-quality match still returns. The UI must lean on precision (empty pane > junk) rather than trust a numeric threshold.
- No chunk-count cap / rate limit — a giant ingest is unbounded; add a cap before opening it to real folders.
- Hand-written OpenAPI + DTOs, no drift gate — unlike Core, the spec isn't Schemathesis-gated; treat the generated client (§4) as needing a manual re-check on contract change.
- Verify nltk punkt is vendored — the SentenceSplitter path can reach for an nltk resource; confirm it's baked into the image so the first ingest works offline.
3 · How it fits the platform
ollam Docker network except the one browser↔ollwrite hop. This is the Core-client architecture doing exactly what it was designed for: the product frontend is the only public surface, everything else is service-to-service behind a token.Browser · Plate editor
Same-origin fetches only. No service token, never names a collection.
/api/memory/*ollwrite BFF · src/app/api/*
Holds the X-Service-Token. Resolves the user, pins the corpus to identity, injects collection/acl, fans out. Composes chat = retrieve → generate.
/api/auth/me
Reads the oll_token cookie → the user id. ollwrite has no JWT secret; it reuses this existing path.
/api/memory · /query
X-Service-Token · own Neon oll_memory DB · pgvector · nomic embeddings. Collection pinned to mem:user:{id}.
/api/write
The existing generation path. Groq is the default. Untouched by this integration.
Database-per-service (ADR-008): oll_memory is its own private Neon DB · embeddings = Ollama (private/local) · generation = Groq (default) · per-user collection = mem:user:{id} pinned server-side, never trusted from the client.
4 · The clean interface — where per-user scoping lives
ollwrite's Next BFF becomes the oll-memory client
It holds the X-Service-Token, validates the user, and pins the corpus to identity. The browser only ever speaks to same-origin ollwrite routes — it never sees the token and never names a collection.
The per-user scoping rule (server-side, never trusted from client)
collection = "mem:user:" + id // pinned server-side acl_tags/acl = ["u:" + id] // chunk-level ownership // id ← Core GET /api/auth/me (from the oll_token cookie, // memoized per request; ollwrite has NO JWT secret)
A user can't address another's collection because they never supply it — the BFF derives it from the authenticated identity on every call. The id comes from the same cookie→/me path ollwrite already uses for auth; there is no new secret and no new token flow.
Browser-facing BFF routes
| BFF route (same-origin) | → oll-memory | Server injects |
|---|---|---|
POST /api/memory/ingest | POST /api/memory | token · collection · acl_tags |
POST /api/memory/search | POST /api/memory/query | token · collection · acl |
POST /api/memory/chat | query → then /api/write | token · scope · grounded context · streams answer + results[] |
GET/DELETE /api/memory/documents | DELETE .../documents/{id} | token · scope — own docs only |
/api/memory/chat is a composition inside the BFF
retrieve ( via /api/memory/query) → assemble the grounded prompt → generate (Groq via the existing /api/write path) → stream the answer plus the results[] it used (for the citation chips). This keeps the locked config exact: embeddings live in oll-memory, generation stays on the existing Groq path — the chat feature invents no new generation route.
Contract-first client
Generate a typed TS client from services/oll-memory/openapi.yaml into src/lib/oll-memory/, called from the BFF only. Add server-only env OLL_MEMORY_BASE (default http://oll-memory:5008) + OLL_MEMORY_SERVICE_TOKEN — never NEXT_PUBLIC_ (that would leak the token into the browser bundle and re-open HIGH #1). Note the MEDIUM: oll-memory's spec has no drift gate, so re-check the generated client on any contract change.
5 · Exact modifications to ollwrite
middleware.ts, lib/auth.ts, and the generation path. Everything new is a pane, a route, or a hook that reuses machinery already in the repo.Layout → three panes
.ollw-room from centered flex → a 3-column CSS grid [LEFT sources | MIDDLE .ollw-page editor (untouched) | RIGHT chat]; collapsible, degrading to a drawer/sheet below ~1024px (the repo already ships components/ui/sheet.tsx). A new editor-shell.tsx wraps the current editor body. The Plate editor + its overlay layers are unchanged.
Client libs + BFF routes
src/lib/memory-client.ts (browser fns → same-origin /api/memory/*); src/app/api/memory/{ingest,search,chat,documents}/route.ts + a shared, pure/testable handler.ts (JWT→id resolution, collection pinning, token injection, transient retry) mirroring the existing api/write; a resolveUserId(req) helper. This is where HIGH #1 is enforced and where the tests live.
LEFT sources pane
Ingest (paste/upload .md/.txt, a "make a folder available" multi-file picker, per-file progress — call out: text/markdown only, PDF is 415/v2), a semantic search box, and proactive memory cards (text + provenance from metadata/page, draggable into the draft as a citation via the existing react-dnd).
Proactive retrieval hook
use-proactive-memory.ts reuses the existing use-debounce + overlay-layer + write-op-bus to read the active paragraph, debounce ~500ms, fire /api/memory/search, and push results to the LEFT pane — a new always-mounted layer like WriteOpLayer, no editor-plugin change. This is the "retrieval as a background sense" from the thesis, implemented with hooks already in the repo.
RIGHT chat pane
Chat-with-corpus over /api/memory/chat; assistant turns render inline citation chips built from results[]; clicking a chip jumps to the source chunk (via the stored char offsets). Reuse the existing use-chat.ts patterns.
Inline citations
A Plate inline void node (citation-kit.tsx, modeled on the existing mention/link node) carrying {document_id, index}; a drag-in or a chat-insert creates it; it serializes to a markdown footnote/link so exports stay clean. This is the one editor addition — and it's a node, not a change to the editor core.
Auth / state / config (small)
Server-only env; resolveUserId reuses the cookie→/me path; NO change to middleware.ts, lib/auth.ts, or the generation path. Memory panes render only for a signed-in user; the local dev-token maps to a fixed mem:user:dev collection for no-login testing.
6 · MVP cut vs v2
MVP — the proactive memory sidecar
OLL_MEMORY_*env + generated client + BFF routes with per-user collection pinning (the security-critical piece — HIGH #1).- 3-column collapsible shell around the untouched editor.
- LEFT: ingest + search + proactive debounced cards.
- RIGHT: chat (retrieve → Groq) with jump-to-source citations.
- Drag-a-card-in as a basic inline citation.
The smallest version that delivers "write from your own knowledge," on ~90% reused infrastructure.
v2 — defer
- PDF/binary ingest (via
/api/extractor client-side extraction) - Draft-from-bullets
- Gap-chips (flag claims with no supporting source)
- A voice/style profile as its own collection
- Richer footnote-style citation export
7 · Sequenced plan + recommendation
- Prereq — merge
oll-memory#80 (green) + deploy withEMBED_PROVIDER=ollama(nomic) + a private Neonoll_memoryDB. Sam's go (deploy is a deliberate tag/dispatch, never a merge). - Harden oll-memory for writing — add a caller-supplied stable
document_id(upsert-by-id) + a dim/provider guard at boot. Addresses HIGH #2 + #3. A small additive PR to the service. - ollwrite BFF — env + generated client +
api/memory/{ingest,search,chat}with per-user collection pinning + tests. Addresses HIGH #1 — the foundation everything else sits on. - ollwrite UI — 3-col shell + LEFT sources + proactive hook + RIGHT chat + basic citations. The MVP surface.
- Verify e2e — ingest → proactive surface → chat → citation, with Groq generation. Then v2.
Recommendation (propose-only)
This is the MVP "proactive memory sidecar" from the product thesis, reusing ~90% of ollwrite's infra. The integration is additive; the three HIGH preconditions are the real work and are all designed-in above — a trusted BFF (HIGH #1), a stable document_id upsert (HIGH #2), and an embedder commitment + dim-guard (HIGH #3). Do steps 1–2 (the service side of the preconditions) before building any pane.
⚠ Honest flag — this is the flagship, not the first franc
The humaniz.me first-stranger-franc path stays P0. This is the flagship product build-out, to run after / alongside it — not instead of it. And a hard dependency: oll-memory (#80) must merge + run on nomic first — steps 1–2 gate everything downstream. This doc feeds Backlog; Backlog decides, this informs.
Technical companion to Write From Your Own Knowledge — the ollwrite × oll-memory product thesis · executes on the Platform Extensions build ledger · feeds Backlog. Propose-only · 2026-07-05. Config: Groq generation (existing path) · (in oll-memory). Grounded in two code-level audits of services/oll-memory + the ollwrite repo; the three HIGH preconditions are code-verified design choices, not speculation.