feat(backend): Phase 9 memory + layered system prompt

↗ view on GitHub · Claude · 2026-05-16 · 085a9d78

Implements the Phase 9 memory feature and the four-layer system-prompt
assembly the architecture doc describes. Both are gated by org_settings
switches that default in opposite directions: use_layered_prompt
defaults true (so the new assembly is active immediately); allow_memory
defaults false (so memory only activates when an admin opts in).

Migration 0022_user_memories.sql:

- user_memories table with cascade on users and SET NULL on chats.
- touch_updated_at trigger matching 0002_users.sql.
- enforce_user_memory_pin_cap trigger that raises 23514 when the
  10-pin-per-user cap would be exceeded.
- RLS enabled + forced + revoked from anon/authenticated.
- org_settings gains allow_memory, memory_max_per_user (1-500, default
  50), memory_max_chars (1-2000, default 500), and use_layered_prompt
  (default true). Two CHECK constraints bound the integer limits.

lib/memories.ts:

- listMemoriesForUser, createUserMemory, createModelMemory,
  updateUserMemory, deleteUserMemory, clearUserMemories.
- All mutations run inside a transaction with SELECT ... FOR UPDATE
  on the user's rows, eliminating the LRU eviction race when two
  concurrent add_memory calls arrive at the cap.
- isPinCapViolation translates the trigger's 23514 errcode into a
  typed PinCapExceededError.
- getInjectableMemoryBlock renders the memory list as a fenced code
  block with PINNED:/OTHER: subheadings and a hallucination
  reminder, defusing markdown injection (e.g. memory content
  starting with ## or ---).
- stampLastUsed batches one UPDATE for the ids that were injected.

lib/promptAssembly.ts:

- buildLayeredSystemPrompt assembles: org_settings.org_system_prompt
  (or fallback) -> workspace.instructions (when present) -> users.
  custom_instructions (when allow_user_instructions) -> memory block
  (when allow_memory) -> OPERATIONAL_PROMPT.
- Returns the assembled string and the memory ids used so the
  caller can stamp last_used_at after the response.

chatTools.ts:

- Split the hardcoded SYSTEM_PROMPT into the legacy constant (kept
  intact for the use_layered_prompt=false rollback path) and a new
  OPERATIONAL_PROMPT export containing only the technical
  conventions (citation markers, DOCX rules, workflows, doc
  naming). The layered builder appends OPERATIONAL_PROMPT last so
  per-user/workspace policy is never instructed away.
- buildMessages now accepts a systemPromptOverride parameter; when
  set it replaces SYSTEM_PROMPT for that turn. doc-availability and
  systemPromptExtra still append.
- New MEMORY_TOOLS export with the add_memory tool, only added to
  the active tool set when memoryContext.policy.allow_memory is
  true.
- runToolCalls dispatches add_memory through handleAddMemoryCall,
  which validates content, calls createModelMemory atomically, and
  returns a saved/skipped result. Skipped reasons:
  memory_disabled, invalid_content, empty_content, too_long,
  cap_reached, pin_cap, internal_error.
- AssistantEvent union gains memory_saved and memory_skipped.
- Saved memories emit an SSE memory_saved frame so the frontend
  can render the in-chat pill.

llmPolicy.ts:

- LlmPolicy interface extended with allow_memory, memory_max_per_user,
  memory_max_chars, use_layered_prompt, org_system_prompt,
  allow_user_instructions.
- loadLlmPolicy selects all six new columns; defaults handle the
  rollback case where org_settings is missing the new columns.

Routes (chat.ts and projectChat.ts):

- Both code paths now call buildLayeredSystemPrompt when
  use_layered_prompt is true, pass the result as
  systemPromptOverride to buildMessages, and call stampLastUsed in
  finally for the memory ids that were used. Assembly failure
  falls back to the legacy path rather than blocking the chat.
- memoryContext { chatId, policy } is threaded into runLLMStream
  when allow_memory is true.

routes/memories.ts:

- GET    /me/memories               list (always permitted; writes
                                    gated by allow_memory).
- POST   /me/memories                create (source forced to 'user').
- PATCH  /me/memories/:id            update content/pinned (ownership
                                    via WHERE user_id, pin cap via
                                    trigger).
- DELETE /me/memories/:id            delete one.
- POST   /me/memories/clear          delete all, body { confirm: true }.
                                    CSRF-safe (JSON-body POST not
                                    issuable cross-origin without
                                    explicit fetch).
- GET    /me/memories/export         JSON export. chat_id returned as
                                    bare uuid; chat metadata not
                                    hydrated to avoid leaking metadata
                                    of chats the caller no longer has
                                    access to.

UUID_RE guard on PATCH and DELETE returns 404 rather than letting
"clear"/"export" reach the DB as a bad uuid.

httpErrors.ts: new sendConflict(res, detail) for 409 responses, used
by the pin-cap path.

index.ts: mounts /me/memories with a per-user write-only limiter
(30/min default, env-configurable via RATE_LIMIT_MEMORY_WRITE_*).
Reads share the general limiter.

Type-check: npx tsc --noEmit passes clean.

Frontend, admin UI, and acceptance tests follow in subsequent
commits.
Repository cpatpa/PIP
Author Claude <noreply@anthropic.com>
Authored
Parents 84aafcd6
Stats 10 files changed , +1257 , -8
Part of Phase 9 - persistent user memory and layered system-prompt assembly

Capture this commit into my fork

Download a Markdown prompt that tells Claude how to port this exact commit into your working tree. Run it via claude -p < capture-commit-085a9d78.md from inside the repo you want the change in.

⬇ Download capture-commit-085a9d78.md