|
| 1 | +--- |
| 2 | +title: Building a Vendor-Neutral Memory Layer in TypeScript |
| 3 | +published: false |
| 4 | +description: A technical deep-dive into Reflect Memory's architecture: TypeScript, Fastify, SQLite with WAL mode, and MCP transport for AI agent memory. |
| 5 | +tags: |
| 6 | + - typescript |
| 7 | + - ai |
| 8 | + - mcp |
| 9 | + - sqlite |
| 10 | + - fastify |
| 11 | +--- |
| 12 | + |
| 13 | +# Building a Vendor-Neutral Memory Layer in TypeScript |
| 14 | + |
| 15 | +AI agents are stateless by default. Every conversation starts from zero. That works for one-off tasks, but breaks down when you want ChatGPT to remember your preferences, Claude to recall project context, and Cursor to know your coding style. The solution is a shared memory layer that any agent can read and write, regardless of vendor. |
| 16 | + |
| 17 | +Reflect Memory is an open-source memory substrate built in TypeScript. Here's why we chose each piece of the stack and how they fit together. |
| 18 | + |
| 19 | +## Why TypeScript? |
| 20 | + |
| 21 | +TypeScript gives us a single language across the entire system: the REST API, the MCP server, the SDK, and the n8n node. No context switching. The SDK uses native `fetch` and has zero runtime dependencies, so it runs anywhere Node 18+ runs. The type system catches schema mismatches at compile time, which matters when you're passing memory structures between services. |
| 22 | + |
| 23 | +```typescript |
| 24 | +// SDK usage -- zero deps, native fetch |
| 25 | +import { ReflectMemory } from "reflect-memory-sdk"; |
| 26 | + |
| 27 | +const rm = new ReflectMemory({ apiKey: process.env.REFLECT_API_KEY! }); |
| 28 | +const latest = await rm.getLatest(); |
| 29 | +``` |
| 30 | + |
| 31 | +## Fastify for the HTTP Layer |
| 32 | + |
| 33 | +We use Fastify instead of Express for the main API. Fastify's schema-based validation (via JSON Schema) enforces request shapes before handlers run. That's critical for security: we reject malformed bodies and unknown fields at the edge. Rate limiting and CORS are first-class plugins. The server stays thin: it authenticates, validates, and delegates to pure service functions. |
| 34 | + |
| 35 | +```typescript |
| 36 | +// Server setup -- schema validation, rate limit, CORS |
| 37 | +import Fastify from "fastify"; |
| 38 | +import cors from "@fastify/cors"; |
| 39 | +import rateLimit from "@fastify/rate-limit"; |
| 40 | + |
| 41 | +const app = Fastify(); |
| 42 | +await app.register(cors, { origin: true }); |
| 43 | +await app.register(rateLimit, { max: 100, timeWindow: "1 minute" }); |
| 44 | +``` |
| 45 | + |
| 46 | +## SQLite with WAL Mode |
| 47 | + |
| 48 | +We store memories in SQLite with WAL (Write-Ahead Logging) mode. WAL gives us concurrent reads while a single writer commits. The process exits at startup if WAL activation fails, so we never silently fall back to rollback journal mode. |
| 49 | + |
| 50 | +```typescript |
| 51 | +// Enforced at startup |
| 52 | +const journalMode = db.pragma("journal_mode = WAL") as Array<{ journal_mode: string }>; |
| 53 | +if (journalMode[0]?.journal_mode !== "wal") { |
| 54 | + console.error(`WAL mode not active. Got: ${journalMode[0]?.journal_mode}`); |
| 55 | + process.exit(1); |
| 56 | +} |
| 57 | +``` |
| 58 | + |
| 59 | +The schema uses `STRICT` tables and `json_type()` CHECK constraints on JSON columns. Foreign keys are enforced via `PRAGMA foreign_keys = ON`. No connection pooling needed: better-sqlite3 is synchronous and single-process. |
| 60 | + |
| 61 | +## Pure Context Builder |
| 62 | + |
| 63 | +The context builder is a pure function. No I/O, no database, no side effects. It takes memories and a query, returns a prompt string. Same inputs, same output, every time. That makes it testable and auditable. The model never decides which memories to include; that decision is made upstream based on the user's explicit filter. |
| 64 | + |
| 65 | +```typescript |
| 66 | +// Pure function -- no I/O |
| 67 | +export function buildPrompt( |
| 68 | + memories: MemoryEntry[], |
| 69 | + userQuery: string, |
| 70 | + systemPrompt: string, |
| 71 | + charBudget?: number, |
| 72 | +): PromptResult { |
| 73 | + const systemSection = systemPrompt.length > 0 ? `[System]\n${systemPrompt}` : ""; |
| 74 | + const querySection = `[User Query]\n${userQuery}`; |
| 75 | + // ... assembles prompt, respects charBudget |
| 76 | +} |
| 77 | +``` |
| 78 | + |
| 79 | +## MCP Transport |
| 80 | + |
| 81 | +The Model Context Protocol (MCP) is how Cursor, Claude Desktop, and other clients discover and call tools. We run a standalone MCP server that exposes `read_memories`, `get_memory_by_id`, `browse_memories`, `write_memory`, and `query`. Each tool is a Zod-validated function. The transport is Streamable HTTP, so it works over the network without stdio. |
| 82 | + |
| 83 | +```typescript |
| 84 | +// MCP tool registration |
| 85 | +mcp.tool( |
| 86 | + "read_memories", |
| 87 | + "Get the most recent memories. Returns full content.", |
| 88 | + { limit: z.number().min(1).max(50).default(10) }, |
| 89 | + { title: "Read Memories", readOnlyHint: true }, |
| 90 | + async ({ limit }) => { |
| 91 | + const memories = listMemories(db, userId, { by: "all" }, vendor, { limit }); |
| 92 | + return { content: [{ type: "text", text: JSON.stringify(memories, null, 2) }] }; |
| 93 | + }, |
| 94 | +); |
| 95 | +``` |
| 96 | + |
| 97 | +## One API, Many Vendors |
| 98 | + |
| 99 | +User keys get full CRUD. Agent keys (per-vendor) can only write via `POST /agent/memories` and query via `POST /query`. Agents see only memories where `allowed_vendors` includes their vendor or `"*"`. The `origin` field is set server-side from the key, never from the request body. That prevents agents from impersonating each other. |
| 100 | + |
| 101 | +The result: one memory store, one API, and one MCP server. ChatGPT, Claude, Cursor, Gemini, and n8n all talk to the same layer. No per-vendor integrations to maintain. |
| 102 | + |
| 103 | +## Hard Invariants |
| 104 | + |
| 105 | +We enforce a few invariants that keep the system predictable. Explicit intent: every request declares exactly what it wants. No inferred behavior. Hard deletion: delete means delete. One row, gone. No soft deletes or archives. Pure context builder: the prompt assembly has no I/O. Same inputs, same output. No AI write path: the model cannot create, modify, or delete memories. One-directional data flow. Deterministic visibility: every query response includes a full receipt with memories used, prompt sent, and vendor filter applied. |
| 106 | + |
| 107 | +## Getting Started |
| 108 | + |
| 109 | +Try it: `npm install reflect-memory-sdk` or `npx reflect-memory-mcp` for the MCP server. The SDK works with the hosted API at api.reflectmemory.com, or you can self-host. Docs and source: [reflectmemory.com](https://reflectmemory.com), [github.com/van-reflect/Reflect-Memory](https://github.com/van-reflect/Reflect-Memory). |
0 commit comments