Files
openwork/docs/memory-bank-architecture.md

357 lines
19 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Memory Bank — Architecture (v0)
> Status: **Draft for scoping.** Pre-spec architecture doc that feeds
> `/officeHours` → `/specToProvenPR`. Decisions captured in an interview on
> 2026-07-02 and revised after a 5-lens `/autoplan` review the same day.
> **[VERIFY]** = confirm at impl; **[DECISION]** = locked; **[FIX]** = correction
> applied after the autoplan review (see §13 for the review trail).
---
## 1. Goal & principles
A per-user **memory bank** inside OpenWork. The user opts in by chatting (e.g.
"save this memory to the memory bank"); the agent drafts a memory + relevant context,
a human verifies it, and it's persisted **server-side**. Later the user can **explicitly
search** their memories with natural-language phrasing, and **view/delete** them from a
lightweight desktop panel.
Principles (priority order):
1. **Durable interface, evolvable implementation.** The long-lived contract is the
REST/MCP **tool shape**; storage + search behind it are swappable (lexical today,
vector/entity-graph later) with no contract change.
2. **No new infrastructure dependencies.** MySQL (PlanetScale) + Drizzle only.
3. **Harness-agnostic.** Memory is a shared knowledge layer keyed to the **principal**.
4. **Server-side storage, thin client.**
5. **Human-verified writes.** Persist human-confirmed content, never raw agent output.
Note on recall (revised): v0 is **explicit, lexical** search — the user must ask. We do
**not** claim semantic/"understands meaning" recall, and we do **not** auto-recall. The
honest value bar is "saved facts are retrievable across sessions on a natural-language
query," not "the agent never makes you re-explain."
---
## 2. Surface & the durable contract
Memory is exposed as **den-api REST routes**, auto-surfaced as capabilities through the
existing meta-MCP (`/mcp/agent`). That rail exposes exactly two tools —
`search_capabilities` and `execute_capability` — so memory is **reached by executing a
discovered capability, not by a bespoke `memory_save` tool.** **[FIX B1]**
```
harness ── search_capabilities("save a memory") → finds capability "postMemory"
└─ execute_capability({ name: "postMemory", body: {...} })
harness ── search_capabilities("search my memories") → finds "getMemorySearch"
└─ execute_capability({ name: "getMemorySearch", query: { q, limit } })
```
### Routes & operationIds [FIX — corrected]
`buildOperationId` (`ee/apps/den-api/src/openapi.ts:16-38`) strips `v1` and PascalCases
path segments. The **actual** operationIds / MCP tool names are:
| Route | operationId / tool name | scope |
|---|---|---|
| `POST /v1/memory` | `postMemory` | `mcp:write` |
| `GET /v1/memory/search` | `getMemorySearch` | `mcp:read` |
| `GET /v1/memory` (list) | `getMemory` | `mcp:read` |
| `DELETE /v1/memory/:id` | `deleteMemoryById` | `mcp:write` |
Verified: none collide with `BLOCKED_OPERATION_IDS`, all stay ≤49 chars after
`structuralShorten`, and the two GETs are distinct paths (no collision). **Never** refer
to `memory_save`/`memory_search` in the prompt or docs — those names do not exist.
### v0 CRUD scope [DECISION]
save + search + **list + delete** (list + delete back the desktop management panel).
Owner-scoped everywhere (§8). `PATCH` (edit) and a `bankScope` param are future.
### Catalog inclusion [DECISION: new "Memory" tag]
Add `"Memory"` to `SAFE_INCLUDED_TAGS` (`ee/apps/den-api/src/mcp/policy.ts:2-23`) and
tag the routes `tags: ["Memory"]`. This is the only mechanism in active use (`x-mcp:true`
is dormant). Route pattern to mirror for *structure*: `routes/workers/core.ts` — **but
its authorization is org-scoped; memory must be user-scoped (see §8, [FIX B4]).**
---
## 3. Data model (MySQL / Drizzle)
```
memory
id typeid("memory") PK
user_id typeid("user") -- owner (v0 always set)
org_id typeid("organization")
scope enum('user','org') default 'user' -- two-bank model; v0 forces 'user'
content text -- HUMAN-VERIFIED memory (plaintext in v0, see §8)
source varchar -- server-set: 'chat' | 'modal'
tags json -- optional
created_at, updated_at (shared `timestamps`)
indexes: (user_id)
FULLTEXT index on (content) -- raw SQL; created idempotently, see below
memory_context
id typeid("memctx") PK
memory_id typeid("memory") FK → memory.id (ON DELETE CASCADE)
citation json -- { conversation_id?, message_id? } (all optional)
snippet text -- plaintext in v0
origin enum('active_conversation','searched_conversation')
created_at
indexes: (memory_id)
```
### [FIX B5] Register the typeid prefixes
`memory` and `memctx` must be added to `idTypesMapNameToPrefix`
(`packages/utils/.../typeid.ts`) or `denTypeIdColumn`/`denTypeIdSchema` won't compile.
This is a cross-package edit and is part of the schema task, not an afterthought.
### [DECISION] Two-bank model, personal-only in v0
`scope` present from day one, **hard-enforced to `'user'` server-side** — not merely a
column default. A client can POST `scope:'org'`; the handler must overwrite it. Every
read filters `user_id = principal.userId` **and** (defensively) `scope='user'` so a
future org row can never retroactively leak into personal reads. [FIX]
### On-device provenance
`memory_context` captures agent-gathered citations + snippets. All citation fields are
**optional** (floor: a `snippet` with no ids). **[VERIFY]** which ids the agent can read
on-device.
### Search backend [DECISION: MySQL FULLTEXT] + [FIX B2]
PlanetScale/Vitess supports `FULLTEXT` + `MATCH … AGAINST`. v0 uses **NATURAL LANGUAGE
MODE**, owner-scoped, relevance-ranked.
- **Fresh-install hazard:** new DBs bootstrap from the build-time current-schema
SQL snapshot (which cannot see a raw `FULLTEXT` index — Drizzle mysql-core has no FULLTEXT DSL,
[drizzle-orm#1495](https://github.com/drizzle-team/drizzle-orm/issues/1495)) and
baseline marks migrations applied-without-executing — so a plain migration-only index
is **silently absent in production** while working on incrementally-migrated dev DBs.
**[FIX B2]** Create the index **idempotently on a path both bootstrap and migrate
hit** — e.g. a startup/bootstrap step that checks `information_schema.STATISTICS` for
the index and runs `CREATE FULLTEXT INDEX` if missing. Add a CI/startup assertion that
the index exists.
- **Query safety [FIX]:** bind the query (`MATCH(content) AGAINST(${q} IN NATURAL
LANGUAGE MODE)` via Drizzle's `sql` template) — **never** `sql.raw`; combine the MATCH
with the `user_id` predicate in a single `and(...)`. (Precedent: `admin-tools.ts` binds
correctly.)
- **PlanetScale caveat [VERIFY]:** `innodb_ft_min_token_size` (default 3) and stopwords
are **not tunable** on PlanetScale — short/common query terms silently won't match.
The v0 mitigation is honest scoping (§1), not "tune it." If recall is weak, consider
also indexing `snippet` or a denormalized column (future).
---
## 4. Save flow (human-verified)
Trigger: *"save this memory to the memory bank."*
1. **Draft (standalone turn).** Agent composes candidate `content` + optional context.
2. **Human verification.** Human confirms/edits before persisting. Baseline = chat-driven
confirm (harness-agnostic); optional desktop modal (deferred, §9).
3. **Persist** — `POST /v1/memory`. **[FIX B3] Flattened, mostly-optional payload** so the
agent (which only sees `hasBody:true`, not the schema) can construct it. The shape is
also encoded in the route's OpenAPI `summary` (the one body hint the agent gets):
```jsonc
{
"content": "User deploys into a Daytona sandbox", // required
"tags": ["deploy", "infra"], // optional
"contexts": [ // optional
{ "snippet": "…excerpt…", // required if present
"conversation_id": "…", "message_id": "…", // optional
"origin": "active_conversation" } // optional
]
}
```
- Server **sets `source`** and **ignores/overwrites client `scope`** (forces `'user'`).
- Server wraps the `memory` + N `memory_context` inserts in **one transaction** (no
orphaned context rows). [FIX]
- **Input bounds [FIX]:** cap `content` length and `contexts` count (cheap abuse guard;
full rate-limiting/quota deferred, §9).
4. **Feedback.** The save must surface an explicit success/failure signal (see §6 states).
---
## 5. Retrieval flow (explicit, lexical)
**[DECISION] Explicit search only** — no auto-recall.
1. User asks in natural language: *"what was that deal with Acme?"*
2. Agent calls `getMemorySearch` (`GET /v1/memory/search?q=…&limit=…`). `limit` defaults
to 20, capped; results relevance-ranked via `MATCH … AGAINST`, owner-scoped.
3. **No match → empty result set, HTTP 200** (not an error) so the agent reports "nothing
found" gracefully. [FIX]
4. Agent does best-effort set reduction and presents results.
Response (stable): `{ results: [{ id, content, tags, created_at, score, contexts? }] }`.
---
## 6. Desktop client (thin) + gating
### [DECISION] Client self-serve toggle, UI-gate only; v0 metrics are qualitative
- New Preferences-tab toggle via `useLocal()` `featureFlags.memory` (mirror
`feature-flags-preferences.ts`). **Client-only, per-device, never synced.**
- Gates **UI visibility only** (soft gate): reveals the management panel, copy-prompt
button, and optional modal. Routes stay callable (owner-scoped + authz'd).
- **[DECISION] Metrics are qualitative in v0.** The client-only flag is not
server-observable, so we do **not** commit to server-computed adoption/recall metrics;
we measure via manual eval + raw save/search counts (see product.md). A server enable
event + hard gate are deferred (§7, §9).
### Management panel (list + delete) — states are the product [FIX design]
The panel and save flow must specify non-happy-path states, **reusing the desktop's
existing accessible Base UI primitives** (Switch, Dialog/AlertDialog, `ConfirmModal`,
`Empty`, Sonner `toast`) rather than building from scratch:
- Save: draft-loading, success (toast), failure/retry, offline, cancel/discard.
- Panel: loading, **enabled-but-empty** (first-run payoff — use `Empty`), error, offline.
- Delete: **optimistic delete + undo toast** (resolves the "reversible-feeling vs
permanent" tension); focus moves sensibly after removal.
- Recall: the agent renders results (prompt-owned), but the panel must not imply a search
UI it doesn't have.
- **Accessibility:** icon-button names, destructive-action focus, `aria-live`/toast
announcements — mandated via the primitives.
- **Content is rendered escaped** in the panel (stored-XSS guard; §8). [FIX]
### Agent priming [DECISION: static `## Memory Bank`, search-first]
Append a static, distinct `## Memory Bank` section to `OPENWORK_AGENT_PROMPT`
(`apps/server/src/openwork-runtime-config.ts:34-66`) — separate from the existing
`## Memory` (credential-hygiene) section. It must **[FIX B1]**:
- Be **search-first**: "to save a memory, search for a capability to save a memory, then
execute it" — never name `memory_save`.
- Cover save (draft → human-confirm → execute `postMemory`) and retrieval (search →
reduce → present).
- **[DECISION, plaintext risk]** Instruct the agent to **not persist secrets,
credentials, tokens, or sensitive PII** — this prompt guidance is the *only* v0
mitigation for plaintext-at-rest (§8).
- The copy-prompt button primes non-server harnesses (Claude Code, local opencode); on
desktop server workspaces the prompt is already injected, so the button is a secondary
cross-tool utility, not the first-run path.
---
## 7. Hard gate (deferred fast-follow) [DECISION: deferred]
v0 gate is client-only + UI-only. A hard, server-enforced gate (server per-user pref or
org `desktop-policies`, in-handler `403`, optional conditional prompt injection, and a
kill switch) is scoped but **not built**. Additive — no contract change.
---
## 8. Auth, scoping & security (v0)
- Principal always carries `userId` + `organizationId` (`mcp/auth.ts:19-24`); routes read
`c.get("user").id` / `c.get("activeOrganizationId")`. Live session + membership checks
already gate every request (revocation is immediate — verified).
- **[FIX B4] Owner-scoping (the #1 risk).** Do **not** copy the org-scoped worker
accessor. Memory reads/list/delete filter on **`user_id = principal.userId`** as the
primary predicate. Add a dedicated `getMemoryByIdForUser`; return **404** (non-leaking)
for ids the caller doesn't own; add a **cross-user access regression test as a merge
gate.**
- `DELETE` **hard-deletes** the FK'd `memory_context` rows (ON DELETE CASCADE / explicit)
for right-to-be-forgotten.
- **[DECISION — plaintext at rest, risk accepted]** `content`/`snippet` are plaintext in
v0. The repo ships `encryptedTextColumn` but v0 does **not** use it. Mitigation is
prompt guidance (§6) telling the agent not to store secrets. **This is a documented,
accepted v0 risk; encryption at rest is a pre-GA requirement (§9).**
- Query is parameterized + owner-filtered (§3). Panel escapes content (§6).
- **Deferred (§9):** per-user quota / rate-limiting (storage-exhaustion DoS). v0 keeps
only cheap input bounds (§4).
---
## 9. Out of scope for v0 (explicitly deferred)
- Vector DB / embeddings / semantic recall.
- **Encryption at rest** for content/snippet — **pre-GA requirement**, accepted risk in v0.
- Auto-recall / passive session-start recall (considered, cut).
- Cross-tool entity/relationship synthesis.
- Active org-shared bank (schema-ready, not activated).
- Hard server-enforced gate + server-side metrics + kill switch.
- Per-user quota / rate-limiting (only cheap input bounds in v0).
- **Memory reaping on account/org deletion** — there is no DB FK or offboarding hook, so deleting
a user/org does not remove their `memory`/`memory_context` rows. A cleanup hook is a pre-GA
requirement alongside encryption at rest (§8); v0 relies on the explicit `deleteMemoryById`.
- `PATCH` (edit a saved memory) — v0 is view + delete only.
- Optional desktop save/verify modal (chat-driven is the v0 baseline).
---
## 10. Durability / evolution path
| Concern | v0 | Future (no contract change) |
|---|---|---|
| Search | MySQL `FULLTEXT` (NL mode), owner-scoped | vector/embedding or hybrid ranker |
| Privacy | plaintext + prompt guidance | encryption at rest (pre-GA) |
| Context | `memory_context` citations + snippets | entity/relationship synthesis |
| Banks | `scope='user'` enforced | activate `scope='org'` |
| Gate | client toggle, UI-only, qualitative metrics | server pref / `403` / events / kill switch |
| Recall | explicit lexical | passive + semantic |
---
## 11. Decisions resolved + items to verify
Resolved (this doc):
- **Tool access** → search-first via `execute_capability`; real names `postMemory` /
`getMemorySearch` / `getMemory` / `deleteMemoryById` (§2). [FIX B1]
- **FULLTEXT bootstrap** → idempotent index creation on bootstrap+migrate path (§3). [FIX B2]
- **Save payload** → flattened, mostly-optional, shape in OpenAPI summary (§4). [FIX B3]
- **Owner-scoping** → `user_id`-primary, 404 non-owned, regression-test gate (§8). [FIX B4]
- **typeids** → register `memory`/`memctx` in `packages/utils` (§3). [FIX B5]
- **Encrypt-at-rest** → plaintext v0, prompt-guidance mitigation, pre-GA requirement (§8).
- **Gate/metrics** → client-only, qualitative metrics; hard gate deferred (§6, §7).
- **Recall** → explicit lexical only; honest value bar; no `≥60%` metric (§1, §5).
- **Search backend / tag / two-bank / citation / CRUD** → as prior (§2, §3).
Verify at impl (non-blocking):
1. **[VERIFY]** On-device citation id availability (§3).
2. **[VERIFY]** PlanetScale `innodb_ft_min_token_size`/stopword impact on recall (§3).
---
## 12. Handoff
- **Next:** `/specToProvenPR` for staged, proven PRs.
- **Suggested PR staging:**
1. **den-db:** `memory` + `memory_context` tables + migration; **register typeids
(`packages/utils`)**; **idempotent `FULLTEXT` index on the bootstrap+migrate path**;
`ON DELETE CASCADE`.
2. **den-api:** routes `postMemory`, `getMemorySearch` (bound `MATCH…AGAINST`, NL mode,
empty-set on no match), `getMemory` (list), `deleteMemoryById` — **all
`user_id`-owner-scoped, 404 non-owned, force `scope='user'`, one-tx save, input
bounds**; **structured save/search/delete events**; new `"Memory"` tag; OpenAPI
`summary` encodes the save body shape; **cross-user regression test (merge gate).**
3. **eval:** `memory-save-recall.flow.mjs` (mirror `mcp-search-capabilities.flow.mjs`)
proving save→recall end-to-end.
4. **apps/server:** static **search-first** `## Memory Bank` prompt incl. no-secrets
guidance.
5. **Desktop:** `featureFlags.memory` toggle + management panel (list + delete, all
states via Base UI, optimistic-delete + undo, escaped content) + copy-prompt button.
6. **Fast-follow (deferred):** hard gate, encryption at rest, rate-limit/quota, modal.
- **Per-PR verification gate (`AGENTS.md`) — applies to every stage above.** Each stage's
PR must: (a) **run tests with pnpm and report the exact commands + results** in the PR
body; (b) produce **test evidence** for every experience-affecting change —
`evals/results/test-runs/<test-run-id>/test-run.json` from an `@openwork/testkit` test, each
frame binding claim → user action → observable assertion → validated screenshot, and
report `Passed` **only** when the test run exists with observable assertions (else
`Incomplete`/`Failed` with repro); backend-only/types-only stages may skip but must say
so and prove the core flow is unchanged; (c) attach **end-to-end evidence** (short video
preferred, screenshots otherwise) of save → recall → view/delete; (d) meet coding
guidelines (no `any`/`as`, pnpm, `@/components`/shadcn Base UI, smallest diff). See
`TASKS.md` TASK-11.
- **Reference files:** `ee/packages/den-db/src/schema/workers.ts`,
`ee/apps/den-api/src/routes/workers/core.ts` (structure only — re-scope authz to user),
`ee/apps/den-api/src/openapi.ts` (operationId gen),
`ee/apps/den-api/src/mcp/policy.ts:2-23`, `search.ts` / `agent.ts` (rail + error shape),
`apps/server/src/openwork-runtime-config.ts:34-66`,
`apps/app/src/react-app/domains/settings/state/feature-flags-preferences.ts`,
`apps/app/src/react-app/kernel/local-provider.tsx`, `evals/flows/*cloud-mcp*.flow.mjs`.
---
## 13. Review trail
Revised after a 5-lens `/autoplan` review (product · arch · design · devex · security),
2026-07-02. Full findings + consolidated report:
`~/.agentic-workflow/joi-fairshare-openwork/plans/20260702-112912-memory-bank/consolidated-review.md`.
Blockers B1–B5 and the operationId correction are folded in above; the three product/
security tensions were decided as recorded in §11.