feat(backend): Phase 9 memory + layered system prompt
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.