# Adding a memory-hub adapter

The `MemoryHub` interface is the only contract Priority Forge depends on for
cross-team sync. Anything that can implement it — a hosted vendor (XMem,
Mem0, Pinecone, Weaviate, MongoDB Atlas Vector Search), a self-hosted store
(Postgres + pgvector, Redis, SQLite, a Hono server you own), or a
fully-local fallback (the shipped `dryrun` adapter) — can be plugged in
with **one new file plus two one-line additions**.

This document is the recipe.

> If you want a wider-than-memory plug-in (Slack notifier, Discord webhook,
> Postgres analytics sink, BI dashboard, …) you do **not** need a
> MemoryHub adapter at all. Skip to [Beyond memory adapters](#beyond-memory-adapters-other-emitter-consumers)
> at the end.

## The five-method contract

`packages/priority-forge-hub/src/types.ts` defines:

```ts
export interface MemoryHub {
  readonly backend: MemoryHubBackend;
  write(event: TeamMemoryEvent): Promise<{ accepted: boolean; remoteId?: string }>;
  read(filter: TeamActivityFilter): Promise<TeamMemoryEvent[]>;
  search(teamId: string, query: string, limit?: number): Promise<TeamMemoryEvent[]>;
  blockersFor(teamId: string, agentId: string): Promise<BlockerEdge[]>;
  health(): Promise<MemoryHubHealth>;
}
```

| Method | When called | What it must do | Failure mode |
|---|---|---|---|
| `write` | After every local PF event that passes privacy rules, plus every cross-agent blocker transition | Persist the event durably and return whether the backend accepted it | Return `{ accepted: false }` — never throw. The subscriber logs and moves on. |
| `read` | Polled by `sync-reader.ts` every `syncIntervalMs` (default 60s); also by `xmem_get_team_activity` | Return events scoped to `filter.teamId`, optionally filtered by `sinceTs`, `agentIds`, `workspaceIds`, `eventTypes`. Sort desc by timestamp. | Return `[]` and log; the reader will retry next tick. |
| `search` | When the user types a query in the dashboard or calls `xmem_search_team_memory` | Semantic / keyword search across the team's events. Limit defaults to 20. | Return `[]` and log. |
| `blockersFor` | By `xmem_get_blocking_me` | Return all `'active'` BlockerEdges where `blockedAgentId === agentId`. Most impls are a thin filter over `read({eventTypes: ['derived_blocker']})` with last-status-wins-per-pair logic. | Return `[]`. |
| `health` | By `xmem_get_status` and the dashboard pane on every poll | Cheap check — does the backend look reachable? Don't burn an API quota here. | Return `{ ok: false, detail: '…' }` rather than throwing. |

### What flows through

`TeamMemoryEvent` is the unit of currency. Defined in `types.ts`:

```ts
{
  eventId: string;
  teamId: string;            // → vendor's tenant/namespace dimension
  agentId: string;            // → vendor's per-user/per-agent dimension
  workspaceId: string;        // PF workspace; usually metadata-only for the vendor
  eventType: 'task_created' | 'task_selected' | 'task_updated'
           | 'task_completed' | 'context_switch' | 'project_created'
           | 'project_updated' | 'derived_blocker';
  timestamp: string;          // ISO-8601
  payload: Record<string, unknown>;   // structured PF payload — preserve verbatim if possible
  summary: string;            // natural-language one-liner, e.g.
                              //   "alice@acme.com started work on AUTH-001"
                              //   ideal for embedding-based search
  metadata: Record<string, string | number | boolean | string[] | null>;
}
```

The two-channel pattern (used by `xmem-adapter.ts`): write the structured
`payload` somewhere reconstructable and the natural-language `summary`
somewhere searchable. If the vendor has both a "raw" field and a
"messages-style" field (XMem does), use both. If it only has one, use the
`summary` for embeddings and stuff the JSON-serialized payload into
`metadata` so reads can rehydrate.

`BlockerEdge` (also in `types.ts`):

```ts
{
  blockedTaskId: string; blockedAgentId: string;
  blockingTaskId: string; blockingAgentId: string;
  status: 'active' | 'cleared';
  observedAt: string;
}
```

Blockers are emitted as `TeamMemoryEvent`s with `eventType: 'derived_blocker'`
and the edge serialized in `payload`. So `blockersFor()` is essentially a
specialized `read()` — see how `dryrun-adapter.ts` and `xmem-adapter.ts`
implement it (≈20 lines each).

## Recipe — adding a new adapter (e.g. `mem0`)

### Step 1 — Write the adapter

Copy `adapters/_template.ts` to `adapters/mem0-adapter.ts` and fill in the
five methods against the vendor's API. The template has TODO markers at
every decision point.

### Step 2 — Add the backend name to the union

`packages/priority-forge-hub/src/types.ts`:

```diff
- export type MemoryHubBackend = 'noop' | 'dryrun' | 'xmem';
+ export type MemoryHubBackend = 'noop' | 'dryrun' | 'xmem' | 'mem0';
```

### Step 3 — Wire the factory

`packages/priority-forge-hub/src/factory.ts`:

```diff
  switch (effective) {
    case 'xmem':
      hub = new XMemMemoryHub({ baseUrl: cfg.xmem.baseUrl, apiKey: cfg.xmem.apiKey });
      break;
+   case 'mem0':
+     hub = new Mem0MemoryHub({ apiKey: cfg.mem0.apiKey });
+     break;
    case 'dryrun':
      hub = new DryrunMemoryHub();
      break;
    case 'noop':
    default:
      hub = new NoopMemoryHub();
      break;
  }
```

If your adapter needs new config fields (most do — at minimum a `baseUrl`
and `apiKey`), extend `MemoryHubConfig` in `types.ts`:

```diff
  xmem: { baseUrl: string; apiKey: string };
+ mem0: { apiKey: string };
```

…and update `DEFAULT_MEMORY_HUB_CONFIG` and `loadMemoryHubConfig()`'s merge
in `config.ts` so old config files load with the new defaults.

### Step 4 — Update the MCP enable tool's enum

`packages/priority-forge-hub/src/mcp-tools.ts`:

```diff
- backend: { type: 'string', enum: ['noop', 'dryrun', 'xmem'], description: 'Adapter to use' },
+ backend: { type: 'string', enum: ['noop', 'dryrun', 'xmem', 'mem0'], description: 'Adapter to use' },
```

…and in the `asBackend()` validator:

```diff
- if (v === 'noop' || v === 'dryrun' || v === 'xmem') return v;
+ if (v === 'noop' || v === 'dryrun' || v === 'xmem' || v === 'mem0') return v;
```

If the new adapter has its own credential fields (e.g. `mem0ApiKey`), add
them to the tool's `inputSchema.properties` and to the `xmem_enable_sync`
case body so they get persisted. Or rename: the convention is to keep the
tool prefix `xmem_` (it's the namespace, not the active backend) and just
extend the body shape.

### Step 5 — Smoke test

```bash
# Set the backend in ~/.priority-forge/xmem.json or via the MCP tool
xmem_enable_sync backend=mem0 teamId=team_test agentId=alice@test apiKey=...

# Restart backend so the factory rebuilds its singleton
systemctl --user restart priority-forge-backend

# Verify health
curl -s http://localhost:3456/xmem/status | jq .health

# Trigger a real PF event and confirm the adapter received it
mcp_priority-forge_create_task task="probe" priority=P3 project=builds effort=low
# …then check the vendor's dashboard / API for the corresponding write
```

### Step 6 — Document

Add a row to the table in `README.md` and a short note in this file under
"Existing adapters" (below) describing tenancy quirks, auth shape, and any
concessions made during the mapping.

## Existing adapters

| Backend | File | Tenancy mapping | Auth | Notes |
|---|---|---|---|---|
| `noop` | `adapters/noop-adapter.ts` | n/a | none | Default; zero side effects. Used when sync is disabled or `XMEM_FORCE_NOOP=1`. |
| `dryrun` | `adapters/dryrun-adapter.ts` | logged verbatim, no translation | none | Append-only JSONL at `~/.priority-forge/xmem-dryrun.jsonl`. Wire-format-faithful — what the xmem adapter would have sent — so it doubles as a contract test. Reads + searches replay from the log. |
| `xmem` | `adapters/xmem-adapter.ts` | `teamId → user_id`, `agentId → agent_id` | Bearer + `X-Org-ID` | Two-channel writes: structured `memory: <JSON>` (`infer: false`) + natural-language `messages` for semantic search. Requires both `apiKey` and `orgID` (the SDK enforces both). Docs at `docs.mem.xtrace.ai/api/typescript` (npm package `@xtraceai/memory`). Adapter currently uses raw HTTP — verified field names match the SDK; gateway header name for org ID still needs a live probe. |
| `pgvector` | `adapters/pgvector-adapter.ts` | `teamId` + `agentId` + `workspaceId` as separate indexed columns | Postgres connection string | Self-hosted Postgres 14+ with the `vector` extension. Schema in `setup-pgvector.sql` — one table `team_memory_events` mirroring `TeamMemoryEvent` 1:1 plus a 384-dim `embedding` column. Built-in bag-of-words hash embedder by default (zero deps, deterministic, satisfies the contract). Pluggable: pass `embed: (text) => Promise<number[]>` to swap in Xenova/Transformers, OpenAI embeddings, or any other model. No external API dependency, no rate limits, full schema control — the canonical "no-vendor-lock-in" reference adapter. |

## Common patterns and gotchas

### Tenancy translation
Vendors model "team" vs "individual" differently. Some have one dimension
(`namespace`), some have two (`org_id` + `user_id`), some have three
(`tenant` + `project` + `user`). The xmem adapter inverts: PF's `teamId`
becomes XMem's `user_id` (so a single query returns the whole team) and
PF's `agentId` becomes XMem's `agent_id` (preserving per-person
attribution). Pick the inversion that lets one read pull everyone's data.

### Two-channel writes
If the vendor has both a structured field and a free-text field, use both.
The structured channel preserves fidelity for replay; the free-text channel
feeds whatever embedding model the vendor uses for semantic search. If
only one channel is available, prefer the structured one and synthesize
embeddings client-side or rely on substring search (what `dryrun` does).

### Filters: server-side vs client-side
`TeamActivityFilter` has five optional dimensions. Push as many to the
vendor as their query language supports; do the rest client-side after
`read()` returns. The xmem adapter pushes `user_id` and `limit` server-side
and applies `sinceTs`, `agentIds`, `workspaceIds`, `eventTypes` locally —
acceptable for a 200-event poll page, would need refactoring at 10k+.

### Cursor pagination
The reader passes `lastSeenTs` as `sinceTs` on every poll. If the vendor's
pagination is opaque-cursor instead of timestamp-based, store the cursor
in adapter-local state and translate `sinceTs` to "fetch until you reach
something older than this." Don't put cursor state in `MemoryHubConfig`;
it's a runtime concern.

### Failure modes — fail soft
**Never throw from a hub method.** Log + return empty / `{accepted:false}`.
The reader retries on the next tick; the subscriber moves on. Throwing
breaks the polling loop and silently kills sync.

### Health probes
The dashboard polls `health()` every 30s. Cheap check only — a single
HEAD/GET on a known-public endpoint, or a synthetic `read({teamId:
'__health__', limit: 1})`. Don't burn the user's quota on health.

### Idempotency
PF assigns each event a unique `eventId` (UUID v4) before it ever reaches
the adapter. Use it as the vendor's idempotency key when supported so a
retry after a network blip doesn't duplicate.

## Testing a new adapter without a live backend

The dryrun adapter's append-only JSONL is a great fixture. To smoke-test
your new adapter offline:

1. Build a small mock-server (or use the dryrun adapter as your reference).
2. Point your adapter's `baseUrl` at `http://localhost:<port>`.
3. Run the cross-agent blocker scenario from
   `packages/priority-forge-hub/README.md` against the mock.

For real CI: see `packages/priority-forge-hub/src/__tests__/contract.test.ts`.
It runs a parameterized 11-test suite against every adapter the current
environment supports:

- `dryrun` always (uses a temp JSONL log via `MEMHUB_DRYRUN_LOG`)
- `pgvector` when `MEMHUB_PGVECTOR_URL` is set
- `xmem` when `XTRACE_TEST_API_KEY` + `XTRACE_TEST_ORG_ID` are set
- `noop` is asserted separately as a sentinel (returns empty by design)

The suite checks the five-method contract: `write` is idempotent and
returns `{accepted}`, `read` honors all five filter dimensions, `search`
finds events whose summary contains the query token, `blockersFor` scopes
to active edges on the requested agent, `health` reports structurally, and
no method throws on garbage input. Runs in <300 ms per backend.

To add a new adapter to the suite: append a fixture to the `fixtures`
array in `contract.test.ts` (gate it on whatever env var the backend
needs). The suite is otherwise identical for every adapter.

## Beyond memory adapters — other emitter consumers

If your plugin doesn't need to be a memory backend at all (Slack notifier,
analytics sink, Discord webhook, Datadog metrics emitter, etc.), you don't
need to implement `MemoryHub`. Subscribe directly to the singleton emitter:

**Note:** the code below lives in the HOST package (Priority Forge), not
in `priority-forge-hub`. The hub package never reaches up into PF — hosts
subscribe to their own emitter. Paths shown are PF-relative.

```ts
// src/integrations/slack-notifier/index.ts (hypothetical, in PF)
import { syncEmitter } from '../../sync';

export function attachSlackNotifier(): void {
  syncEmitter.on('sync_event', async (event) => {
    if (event.eventType !== 'task_completed') return;
    await fetch(SLACK_WEBHOOK_URL, {
      method: 'POST',
      body: JSON.stringify({ text: `${event.userId} completed ${event.data.taskId}` }),
    });
  });
}
```

Then add one line to `src/mcp/handler.ts` (also in PF):

```ts
attachSlackNotifier();
```

The emitter is a generic Node.js `EventEmitter` — every PF mutation that
goes through MCP or REST fans into the same `'sync_event'` channel. The
memory-hub subscriber is one consumer; nothing about the emitter pretends
to be memory-specific. Multiple consumers happily co-exist.
