Integration audit & plan · propose-only · 2026-07-05

Wiring Memory Into Writing — the oll-memory × ollwrite integration

We audited the memory service and the editor. The service shell is production-grade; the editor already has the BFF and auth we need. Here's the audit, how it fits the platform, the clean interface, the exact ollwrite changes, and the three things we must get right before it ships. Technical companion to the product thesis Write From Your Own Knowledge; it executes on the Platform Extensions build ledger. Feeds Backlog — it does not decide it.

TL;DR

  1. 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.
  2. 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-hash document_id that 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.
  3. 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) · Ollama nomic-embed-text 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

The service we're integrating is an internal-only, stateful document-intelligence service — LlamaIndex + 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

EndpointDoesNotes
POST /api/memoryIngest → chunk → embed → storetext/markdown only415 on PDF/binary
POST /api/memory/queryHybrid retrieve (dense + keyword), RRF-fusedReturns results[] with chunk text + metadata + RRF score
DELETE /api/memory/{collection}/documents/{document_id}Remove a document's chunksKeyed on the content-hash id — see precondition #2
POST /api/extractText → typed JSONRoutes generation through the oll-model gateway
GET /api/health{,/db,/model}Liveness + real dependency probesDB reachability + embedder/model provider checks

The engine — teach it briefly

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

None of these is a bug — each is a trusted-caller design choice that puts the burden on the integrating app. If we don't design for them from day one, we ship an IDOR, a rotting corpus, or a corrupted vector space. All three are addressed in §4–§5.

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

3 · How it fits the platform

The topology is all internal on the 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.
◐ Public — the only browser hop

Browser · Plate editor

Same-origin fetches only. No service token, never names a collection.

▼  same-origin /api/memory/*
● Trust boundary — ollwrite BFF (Next route handlers)

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.

▼ resolve id
Core

/api/auth/me

Reads the oll_token cookie → the user id. ollwrite has no JWT secret; it reuses this existing path.

▼ ingest + retrieve embed = Ollama nomic
oll-memory :5008

/api/memory · /query

X-Service-Token · own Neon oll_memory DB · pgvector · nomic embeddings. Collection pinned to mem:user:{id}.

▼ generate Groq
write-service → oll-model

/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

This is the whole answer to HIGH #1. Ownership is enforced in exactly one place — ollwrite's Next BFF — and the browser is architecturally incapable of addressing another user's corpus because it never supplies the address.

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-memoryServer injects
POST /api/memory/ingestPOST /api/memorytoken · collection · acl_tags
POST /api/memory/searchPOST /api/memory/querytoken · collection · acl
POST /api/memory/chatquery → then /api/writetoken · scope · grounded context · streams answer + results[]
GET/DELETE /api/memory/documentsDELETE .../documents/{id}token · scope — own docs only

/api/memory/chat is a composition inside the BFF

retrieve (nomic 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_TOKENnever 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

A lettered change-list a builder can execute directly. The through-line: the Plate editor and its overlay layers are untouched, and so are 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.
A

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.

B

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.

C

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).

D

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.

E

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.

F

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.

G

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

The MVP is the smallest version that delivers "write from your own knowledge" — and it's ~90% reused infra. Everything that widens the corpus or deepens the voice is deferred.

MVP — the proactive memory sidecar

  1. OLL_MEMORY_* env + generated client + BFF routes with per-user collection pinning (the security-critical piece — HIGH #1).
  2. 3-column collapsible shell around the untouched editor.
  3. LEFT: ingest + search + proactive debounced cards.
  4. RIGHT: chat (retrieve → Groq) with jump-to-source citations.
  5. 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/extract or 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

Propose-only — the shape, not a committed plan. The three HIGH preconditions are the real work; steps 1–2 are exactly them, before a single pane is built.
  1. Prereq — merge oll-memory #80 (green) + deploy with EMBED_PROVIDER=ollama (nomic) + a private Neon oll_memory DB. Sam's go (deploy is a deliberate tag/dispatch, never a merge).
  2. 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.
  3. 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.
  4. ollwrite UI — 3-col shell + LEFT sources + proactive hook + RIGHT chat + basic citations. The MVP surface.
  5. 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) · Ollama nomic embeddings (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.