Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -2100,6 +2100,11 @@ PLAYGROUND_COMPARE_MAX_COLUMNS=4
# MEMORY_TYPED_DECAY_EPISODIC_DAYS=30 # episodic TTL in days; 0 = episodic immune too
# MEMORY_TYPED_DECAY_ACCESS_IMMUNITY=3 # access_count >= N → immune; 0 disables access immunity
# MEMORY_TYPED_DECAY_SWEEP_INTERVAL=0 # periodic sweep interval (seconds); 0 = no periodic sweep
# ─── Memory Backend Connectors (Generic HTTP) ──────────────────────────────
# NOTION_API_KEY=
# NOTION_API_URL=
# OBSIDIAN_API_KEY=
# OBSIDIAN_API_URL=
# AgentBridge + Traffic Inspector (Group A)

# AgentBridge
Expand Down
228 changes: 228 additions & 0 deletions docs/frameworks/MEMORY_BACKEND.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,228 @@
---
title: "MemoryBackend Provider Pattern"
version: 3.8.49
lastUpdated: 2026-07-28
---

# MemoryBackend Provider Pattern

> **Source of truth:** `src/lib/memory/backend.ts`, `src/lib/memory/genericBackend.ts`, `src/lib/memory/manager.ts`
> **Tests:** `src/lib/memory/__tests__/generic-backend.test.ts`

The MemoryBackend provider pattern introduces a **pluggable backend abstraction layer** over the existing memory engine. Instead of being tied to a single storage implementation, the memory system now supports multiple backends (SQLite, Obsidian, Notion, custom HTTP backends) with configurable primary/fallback routing.

## Architecture

```
┌──────────────────────────────────────────────────────────┐
│ API Routes │
│ (src/app/api/memory/route.ts) │
└──────────────────────┬───────────────────────────────────┘
│
┌──────────────────────▼───────────────────────────────────┐
│ MemoryManager │
│ Singleton orchestrator (manager.ts) │
│ │
│ Primary ──► Backend A (e.g. SQLite) │
│ Fallback ─► Backend B (e.g. Obsidian) │
│ Backend C (e.g. Notion via GenericBackend) │
└──────────────────────┬───────────────────────────────────┘
│
┌──────────────┼──────────────┐
▼ ▼ ▼
┌────────────┐ ┌────────────┐ ┌──────────────────┐
│ SQLite │ │ Obsidian │ │ GenericMemory │
│ Backend │ │ Backend │ │ Backend (HTTP) │
└────────────┘ └────────────┘ └──────────────────┘
```

### Core Interface (`backend.ts`)

Every backend must implement the `MemoryBackend` interface:

```typescript
interface MemoryBackend {
readonly id: string;
readonly displayName: string;

// CRUD
create(input: CreateMemoryInput): Promise<Memory>;
get(id: string): Promise<Memory | null>;
update(id: string, updates: Partial<...>): Promise<boolean>;
delete(id: string): Promise<boolean>;
list(filter: MemoryFilter): Promise<{ data: Memory[]; total: number; byType: Record<string, number> }>;

// Search
search(config: SearchConfig): Promise<Memory[]>;

// Health
health(): Promise<HealthCheckResult>;

// Lifecycle (optional)
initialize?(): Promise<void>;
shutdown?(): Promise<void>;
}
```

### MemoryManager (`manager.ts`)

Singleton orchestrator that:

- **Registers** backends via `register(backend)` — called at boot from `index.ts`
- **Configures** primary + fallback via `configure(primary, fallbacks)`
- **Routes** CRUD/search to the primary, with fallback chain on failure
- **Health checks** all backends periodically

**Fallback behavior:**

| Operation | Primary | Fallbacks |
| --------- | -------------------- | ----------------------- |
| `create` | ✅ Primary only | ❌ |
| `get` | ✅ Try primary first | ✅ Fallback if null |
| `update` | ✅ Primary only | ✅ Fire-and-forget sync |
| `delete` | ✅ Primary only | ✅ Fire-and-forget sync |
| `list` | ✅ Primary only | ❌ |
| `search` | ✅ Primary first | ✅ Fallback on error |

### GenericMemoryBackend (`genericBackend.ts`)

A generic HTTP connector that adapts any REST API into a MemoryBackend. Useful for:

- **Notion** — connect via Notion API
- **Obsidian** — connect via Obsidian Local REST API
- **Custom backends** — any service that exposes a RESTful memory API

**Configuration:**

```typescript
interface GenericBackendConfig {
baseUrl: string; // Base URL of the backend API
apiKey?: string; // Bearer token for auth
headers?: Record<string, string>; // Custom HTTP headers
timeout?: number; // Request timeout (default: 30000ms)
backendType?: string; // For logging

// Endpoint overrides (defaults use REST conventions)
endpoints?: {
search?: string; // default: "/memories/search"
create?: string; // default: "/memories"
list?: string; // default: "/memories"
get?: string; // default: "/memories/{id}"
update?: string; // default: "/memories/{id}"
delete?: string; // default: "/memories/{id}"
health?: string; // default: "/health"
};

// Query parameter name mappings
queryParams?: {
query?/apiKeyId?/limit?/offset?/strategy?/maxTokens?/type?/sessionId?/orderBy?/orderDir?/options?
};

// Path parameter name mappings
pathParams?: {
id?/memoryId?
};
}
```

**Known backends** are pre-configured in `KNOWN_BACKENDS`:

```typescript
createKnownBackend("obsidian"); // → GenericMemoryBackend pointed at localhost:27123
createKnownBackend("notion"); // → GenericMemoryBackend pointed at api.notion.com/v1
```

### Built-in Backends

#### SQLiteBackend (`sqliteBackend.ts`)

The default primary backend. Wraps the existing SQLite-based memory store using `src/lib/memory/store.ts`. Automatically registered at boot.

```typescript
import { sqliteBackend } from "./sqliteBackend";
memoryManager.register(sqliteBackend);
```

#### ObsidianBackend (`obsidianBackend.ts`)

Wraps the existing Obsidian integration (`src/lib/memory/obsidianBackend.ts`). Connects to an Obsidian vault via the Obsidian Local REST API.

## Settings

Memory backend settings are stored in the app settings table and managed via `src/lib/memory/settings.ts`:

| Setting | Env/Config Key | Default | Description |
| ----------------- | ------------------------ | ---------- | ---------------------------- |
| Primary backend | `memoryPrimaryBackend` | `"sqlite"` | ID of the primary backend |
| Fallback backends | `memoryFallbackBackends` | `[]` | Ordered fallback backend IDs |
| Backend configs | `memoryBackendConfigs` | `{}` | Per-backend config overrides |

Settings are normalized via `normalizeMemorySettings()` and cached at `getMemorySettings()`.

## Initialization Flow

```
App bootstrap
→ index.ts imports (side-effect): registers SQLiteBackend
→ initMemoryBackends() called from app lifecycle:
1. Load settings (getMemorySettings)
2. Configure primary + fallback
3. Initialize all backends (health check)
4. Ready for requests
```

## Adding a New Backend

1. **Implement `MemoryBackend`** interface in `src/lib/memory/<name>Backend.ts`
2. **Export** from `src/lib/memory/index.ts`
3. **Register** with `memoryManager.register(yourBackend)` at boot
4. **Configure** via settings: set `memoryPrimaryBackend` to your backend ID
5. **Test** with `src/lib/memory/__tests__/generic-backend.test.ts` as reference

### Example: Brain Backend

```typescript
import { createGenericMemoryBackend } from "./genericBackend";

const brainBackend = createGenericMemoryBackend("brain", "BK-Brain", {
baseUrl: process.env.BRAIN_API_URL || "http://localhost:9099",
apiKey: process.env.BRAIN_API_KEY,
endpoints: {
search: "/api/memory/search",
create: "/api/memory",
health: "/api/health",
},
});

memoryManager.register(brainBackend);
```

## Verification

### Unit tests

```bash
npx vitest run src/lib/memory/__tests__/generic-backend.test.ts --reporter=verbose
```

Expected output: **26 tests, all passing** covering:

- Constructor (2)
- Health check (4) — success, failure 500, network error, latency
- Initialize (2) — success, failure
- Create (2) — default endpoint, custom endpoint
- Get (4) — success, 404 → null, non-404 throw, custom path params
- Update (2) — success, 404 → false
- Delete (2) — success, 404 → false
- List (2) — query params, custom param names
- Search (3) — query params, custom endpoint, options serialization
- Auth headers (2) — Bearer token, custom headers
- Factory (1)

### Type check

```bash
npm run typecheck:core
```

Expected: **0 errors**.
6 changes: 5 additions & 1 deletion docs/reference/ENVIRONMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -759,9 +759,13 @@ Embedding layer, vector store and reranking knobs for the persistent memory subs
| `MEMORY_TRANSFORMERS_MODEL` | `Xenova/all-MiniLM-L6-v2` | HF repo id for the opt-in `@huggingface/transformers` local MiniLM pipeline (~23 MB int8, ~400 MB RAM). |
| `MEMORY_STATIC_MODEL` | `minishlab/potion-base-8M` | HF repo id for the static potion/Model2Vec lookup-table embedder. Downloaded lazily into the cache dir. |
| `MEMORY_STATIC_CACHE_DIR` | `<DATA_DIR>/embeddings` | Directory used to cache the static potion model files. Defaults under `DATA_DIR` when unset. |
| `HF_HUB_ENDPOINT` | `https://huggingface.co` | Override Hugging Face Hub base URL used by `staticPotion.ts` (e.g. mirror endpoint for air-gapped setups). |
| `MEMORY_VEC_TOP_K` | `20` | Default top-K used by the `sqlite-vec` brute-force vector search inside `src/lib/memory/vectorStore.ts`. |
| `MEMORY_RRF_K` | `60` | Reciprocal Rank Fusion constant `k` for hybrid FTS5 + vector retrieval (sqlite-vec recipe). |
| `HF_HUB_ENDPOINT` | `https://huggingface.co` | Override Hugging Face Hub base URL used by `staticPotion.ts` (e.g. mirror endpoint for air-gapped setups). |
| `NOTION_API_KEY` | _(unset)_ | API key for Notion backend (used by `genericBackend.ts` known backend preset). |
| `NOTION_API_URL` | `https://api.notion.com/v1`| Base URL for Notion API (can override for self-hosted Notion alternatives). |
| `OBSIDIAN_API_KEY` | _(unset)_ | API key for Obsidian Vault backend (used by `genericBackend.ts` known backend preset). |
| `OBSIDIAN_API_URL` | `http://localhost:27123` | Base URL for Obsidian Vault API (can override for remote vault). |
| `MEMORY_TYPED_DECAY_ENABLED` | `false` | TV6 typed memory decay master switch. **Opt-in (default off)** — the sweep **deletes** decayed memories. With it off, `access_count`/`last_accessed_at` are pure telemetry and nothing is ever deleted. |
| `MEMORY_TYPED_DECAY_EPISODIC_DAYS` | `30` | TTL (days) after which an unused `episodic` memory decays. `0` makes episodic immune too. Durable types (`factual`/`procedural`/`semantic`) are always immune. The decay clock re-bases on `last_accessed_at`. |
| `MEMORY_TYPED_DECAY_ACCESS_IMMUNITY` | `3` | A memory injected `>=` this many times becomes immune to decay regardless of type. `0` disables access immunity. |
Expand Down
1 change: 1 addition & 0 deletions scripts/check/check-test-discovery.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,7 @@ export const COLLECTORS = [
sources: ["vitest.mcp.config.ts"],
},
{ glob: "tests/unit/autoCombo/**/*.test.ts", sources: ["vitest.mcp.config.ts"] },
{ glob: "src/lib/memory/__tests__/generic-backend.test.ts", sources: ["vitest.mcp.config.ts"] },
{ glob: "tests/unit/encryption.spec.ts", sources: ["vitest.mcp.config.ts"] },
{ glob: "src/shared/components/**/*.test.tsx", sources: ["vitest.mcp.config.ts"] },
{ glob: "src/shared/hooks/__tests__/**/*.test.tsx", sources: ["vitest.mcp.config.ts"] },
Expand Down
12 changes: 6 additions & 6 deletions src/app/api/memory/[id]/route.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { NextResponse } from "next/server";
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
import { deleteMemory, getMemory, updateMemory } from "@/lib/memory/store";
import { memoryManager } from "@/lib/memory/manager";
import { validateBody, isValidationFailure } from "@/shared/validation/helpers";
import { MemoryUpdatePutSchema } from "@/shared/schemas/memory";
import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error.ts";
Expand All @@ -11,7 +11,7 @@ export async function DELETE(request: Request, props: { params: Promise<{ id: st

try {
const { id } = await props.params;
const success = await deleteMemory(id);
const success = await memoryManager.delete(id);
if (!success) {
return NextResponse.json({ error: "Memory not found" }, { status: 404 });
}
Expand All @@ -28,7 +28,7 @@ export async function GET(request: Request, props: { params: Promise<{ id: strin

try {
const { id } = await props.params;
const memory = await getMemory(id);
const memory = await memoryManager.get(id);
if (!memory) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
Expand All @@ -49,7 +49,7 @@ export async function PUT(request: Request, props: { params: Promise<{ id: strin
} catch {
return NextResponse.json(
{ error: { message: "Invalid JSON body", details: [] } },
{ status: 400 },
{ status: 400 }
);
}

Expand All @@ -60,12 +60,12 @@ export async function PUT(request: Request, props: { params: Promise<{ id: strin

try {
const { id } = await props.params;
const existing = await getMemory(id);
const existing = await memoryManager.get(id);
if (!existing) {
return NextResponse.json({ error: { message: "Memory not found" } }, { status: 404 });
}

await updateMemory(id, validation.data);
await memoryManager.update(id, validation.data);
return NextResponse.json({ success: true });
} catch (err: unknown) {
const message = sanitizeErrorMessage(err instanceof Error ? err.message : String(err));
Expand Down
18 changes: 14 additions & 4 deletions src/app/api/memory/route.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { NextResponse } from "next/server";
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
import { listMemories, createMemory, getMemoryTokensUsed } from "@/lib/memory/store";
import { memoryManager } from "@/lib/memory";
import { memoryCache } from "@/lib/memory/cache";
import { MemoryType } from "@/lib/memory/types";
import { parsePaginationParams, buildPaginatedResponse } from "@/shared/types/pagination";
Expand Down Expand Up @@ -38,14 +39,15 @@ export async function GET(request: Request) {
const type = (searchParams.get("type") as any) || undefined;
const sessionId = searchParams.get("sessionId") || undefined;

const result = await listMemories({
const result = await memoryManager.list({
apiKeyId,
type,
sessionId,
query,
limit: paginationParams.limit,
offset,
page: offset === undefined ? paginationParams.page : undefined,
offset:
offset ??
(offset === undefined ? undefined : (paginationParams.page - 1) * paginationParams.limit),
});

// Total tokens across all memories (computed in SQL inside the domain module
Expand Down Expand Up @@ -98,7 +100,15 @@ export async function POST(request: Request) {
if (isValidationFailure(validation)) {
return NextResponse.json(validation.error, { status: 400 });
}
const memoryId = await createMemory(validation.data);
const memoryId = await memoryManager.create({
apiKeyId: validation.data.apiKeyId,
sessionId: validation.data.sessionId,
type: validation.data.type,
key: validation.data.key,
content: validation.data.content,
metadata: validation.data.metadata,
expiresAt: validation.data.expiresAt,
});
return NextResponse.json({ success: true, id: memoryId });
} catch (err: unknown) {
const message = sanitizeErrorMessage(err instanceof Error ? err.message : String(err));
Expand Down
12 changes: 12 additions & 0 deletions src/instrumentation-node.ts
Original file line number Diff line number Diff line change
Expand Up @@ -580,6 +580,18 @@ export async function registerNodejs(): Promise<void> {
console.warn("[STARTUP] memory decay sweep failed to start (non-fatal):", msg);
}),

// MemoryBackend provider pattern (PR #8752): initialize configured memory
// backends from settings (sqlite, obsidian, notion, custom HTTP, etc.).
// Reads the DB settings synchronously (non-blocking, never fatal). Must
// run after the DB is ready AND after getSettings/applyRuntimeSettings so
// memory backend config is hydrated.
import("@/lib/memory/index")
.then((m) => m.initMemoryBackends())
.catch((err: unknown) => {
const msg = err instanceof Error ? err.message : String(err);
console.warn("[STARTUP] memory backend initialization failed (non-fatal):", msg);
}),

// Backup schedule (#8513): execute `backup-schedule.json` cron server-side.
// Reads the schedule written by `omniroute backup auto enable` and fires
// `runBackupCommand` when the cron expression matches. Self-gated: no-op
Expand Down
1 change: 1 addition & 0 deletions src/lib/db/migrations/118_provider_param_filters.sql
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,4 @@
-- { block: string[], allow: string[], models?: { [modelId]: { block?: string[], allow?: string[] } }, autoLearn?: boolean }
--
-- See: src/lib/db/paramFilters.ts
SELECT 1;
Loading
Loading