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
8 changes: 4 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ jobs:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@master
with:
toolchain: "1.87"
toolchain: "1.94"
components: rustfmt
- run: cargo fmt --all -- --check

Expand All @@ -27,7 +27,7 @@ jobs:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@master
with:
toolchain: "1.87"
toolchain: "1.94"
components: clippy
- uses: Swatinem/rust-cache@v2
- run: cargo clippy --all-targets --all-features
Expand All @@ -43,7 +43,7 @@ jobs:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@master
with:
toolchain: "1.87"
toolchain: "1.94"
- uses: Swatinem/rust-cache@v2
- run: cargo test --all-features

Expand All @@ -54,7 +54,7 @@ jobs:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@master
with:
toolchain: "1.87"
toolchain: "1.94"
- uses: Swatinem/rust-cache@v2
- run: cargo build --release

Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ jobs:

- uses: dtolnay/rust-toolchain@master
with:
toolchain: "1.87"
toolchain: "1.94"
targets: ${{ matrix.target }}

- uses: Swatinem/rust-cache@v2
Expand Down
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -28,12 +28,14 @@ bench = []
# ── Async runtime & networking ────────────────────────────────────
tokio = { version = "1", features = ["full"] }
axum = { version = "0.8", features = ["macros"] }
tower = "0.5"
tower = { version = "0.5", features = ["util"] }
tower-http = { version = "0.6", features = ["trace", "cors", "timeout"] }

# ── Serialization ─────────────────────────────────────────────────
serde = { version = "1", features = ["derive"] }
serde_json = "1"
# Required by the MCP 2026-07-28 SEP-2243 `=?base64?...?=` header sentinel.
base64 = "0.22"

# ── Configuration ─────────────────────────────────────────────────
toml = "0.8"
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -179,7 +179,7 @@ See [docs/benchmark.md](docs/benchmark.md) for full methodology, per-category br
recalld mcp
```

**HTTP API** -- Runs a standalone HTTP server (default `127.0.0.1:7680`). Also exposes an MCP endpoint at `/mcp` using the streamable HTTP transport, so MCP clients can connect via URL.
**HTTP API** -- Runs a standalone HTTP server (default `127.0.0.1:7680`). Also exposes an MCP endpoint at `/mcp`, so MCP clients can connect via URL. Both MCP transports are dual-era: stateless MCP 2026-07-28 and the legacy `initialize`/`Mcp-Session-Id` handshake (2025-06-18 and earlier) are served on the same endpoint, chosen per message.

```sh
recalld serve
Expand Down
2 changes: 1 addition & 1 deletion clippy.toml
Original file line number Diff line number Diff line change
@@ -1 +1 @@
msrv = "1.87"
msrv = "1.94"
140 changes: 137 additions & 3 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -471,7 +471,10 @@ The `QueryEngine` orchestrates a 9-stage search pipeline. All subsystem dependen
v
[8a] Compute composite score
[8b] Apply temporal boost (Gaussian falloff around query time range)
[8c] Resolve supersedes chains (replace outdated with current version)
[8c] Resolve supersedes chains (replace outdated with current version
WHEN THE REPLACEMENT PASSES THE QUERY'S FILTERS; otherwise the
original is kept, so a result is never dropped with nothing in
its place)
[8d] Sort descending, truncate to limit
|
v
Expand Down Expand Up @@ -674,16 +677,82 @@ recalld exposes the same core functionality through three transport layers:

Two transports expose the same 9 MCP tools (`store_memory`, `store_memories`, `recall_memories`, `get_memory`, `reinforce_memory`, `forget_memory`, `find_similar_memories`, `create_namespace`, `list_memories`) and memory resources:

Both transports are **dual-era**: MCP 2026-07-28 (stateless) and 2025-06-18
(legacy `initialize` handshake) are served on the same endpoint and process.
The era is decided **per message**, not per connection:

- A message whose `params._meta` carries
`io.modelcontextprotocol/protocolVersion` is served on the stateless modern
path. The key wins over the method name, so an `initialize` carrying modern
`_meta` gets `-32601` — that revision removed `initialize`.
- An `initialize` or `notifications/initialized` message takes the legacy path.
- `server/discover` and `ping` are dispatched without `_meta` and without a
handshake, so a modern client's era probe (`server/discover` first) works
before it knows which era the server speaks. Only `server/discover` is
genuinely era-neutral — both eras answer it identically. `ping` was removed
in 2026-07-28 (SEP-2575): an un-annotated `ping` is *tolerated* and answered
`{}` on the legacy path for existing health-checkers, but a `ping` carrying
modern `_meta` gets `-32601` like any other removed method.
- Anything else with neither marker is a legacy message: over HTTP it needs an
`Mcp-Session-Id`, over stdio it needs a prior `initialize`. Without either it
is rejected with `-32602` naming both remedies.

Ten methods are dispatched: `server/discover`, `initialize` (legacy only),
`ping` (legacy only), `tools/list`, `tools/call`, `resources/list`,
`resources/templates/list`, `resources/read`, plus the
`notifications/initialized` and `notifications/cancelled` notifications.

Every result carries `resultType: "complete"` and
`_meta["io.modelcontextprotocol/serverInfo"]`. The three catalogs and
`server/discover` also carry SEP-2549 caching hints (`ttlMs` 3600000,
`cacheScope: "public"` — recalld's catalogs are compile-time constants);
`resources/read` carries `ttlMs: 0`, `cacheScope: "private"` because its
contents are live instance state. These are top-level fields, siblings of
`tools`/`resources`/`contents`, never nested in `_meta`. They are emitted on
both eras: the 2025-06-18 `Result` type is an open index signature, so the
extra keys are schema-legal there, not merely tolerated.

`initialize` never echoes `2026-07-28`. It echoes the version the client
asked for when we recognise it (`2025-06-18`, `2025-03-26`, `2024-11-05`),
otherwise `2025-06-18`. Claiming the modern version in an `initialize`
response would advertise a revision in which `initialize` does not exist.

**Stdio** (`recalld mcp`):
- Runs as a subprocess of an AI agent (Claude Code, Cursor, etc.).
- Communicates via stdin/stdout using newline-delimited JSON-RPC 2.0.
- A process is **not** a session for a modern client: the single `McpServer`
held for the process lifetime carries legacy lifecycle state only, and
modern requests never consult it.

**HTTP** (`recalld serve`, endpoint `/mcp`):
- Runs alongside the REST API on the same port.
- Implements the MCP streamable HTTP transport (spec 2025-03-26).
- Session management via `Mcp-Session-Id` header.
- Responds with `application/json` for requests, `202 Accepted` for notifications.
- Recommended transport for Docker containers and remote servers.
- **Modern requests are stateless**: no session is minted, any inbound
`Mcp-Session-Id` is ignored, and dispatch takes no lock — concurrent clients
do not serialize behind one mutex. Clients must send the SEP-2243 headers
`Mcp-Method`, `MCP-Protocol-Version` and (on `tools/call`, `prompts/get`,
`resources/read`) `Mcp-Name`; each is validated against the body and a
mismatch is `400` + `-32020`. Values may use the `=?base64?…?=` sentinel.
Header validation is skipped on notification POSTs, which the revision
leaves undefined.
- **`Mcp-Session-Id` is legacy-only.** A session is created only by a
*successful* `initialize`; a repeat `initialize` bearing a live session id
reuses it rather than orphaning it. Sessions expire after 30 minutes idle
(swept lazily, at most once a minute) and are capped at 1024 (`503` +
`-32603` beyond that). An unknown or expired session gets a `404` with a
JSON-RPC body telling the client to `initialize` again.
- **`Origin` is validated** on every request (a spec MUST, and a real DNS
rebinding exposure for a loopback-bound server): absent → allowed
(non-browser client); `null` → `403`; any loopback host on any scheme or
port → allowed; otherwise it must match `server.mcp_allowed_origins`.
- **10 MiB body limit** applied on the MCP router itself. `/mcp` is merged in
outside the REST tower stack, so it inherits neither that stack's body limit
(previously unlimited here) nor its 30s timeout — the timeout is omitted
deliberately, since tool calls may run long.
- `GET /mcp`, and `DELETE` without a session id, return `405` with
`Allow: POST, DELETE`. The 2026-07-28 transport removed the GET stream
endpoint; recalld never had one.

### Daemon (Unix socket)

Expand All @@ -693,6 +762,71 @@ Two transports expose the same 9 MCP tools (`store_memory`, `store_memories`, `r
- Stale socket cleanup on startup (checks if PID is alive).
- MCP clients connect to the daemon via JSON-RPC 2.0.

#### Framing

Messages are length-prefixed JSON: a 4-byte big-endian byte count followed by
the JSON payload.

| Limit | Value | Meaning |
|---|---|---|
| `MAX_MESSAGE_SIZE` | 8 MiB | Largest frame this build will send or accept. |
| `LEGACY_MAX_MESSAGE_SIZE` | 1 MiB | What a protocol v1 peer (recalld <= 0.1.10) enforces. |
| `MAX_DRAIN_SIZE` | 64 MiB | Largest declared length still worth skipping past. |
| `DRAIN_TIMEOUT` | 30 s | How long a peer may take to deliver a frame being skipped. |
| `PAYLOAD_TIMEOUT` | 30 s | How long a peer may take to deliver a frame being kept. |
| `MAX_CONSECUTIVE_FRAME_ERRORS` | 8 | Recoverable frame errors in a row before the daemon hangs up. |

The frame limit is deliberately much larger than the 1 MiB `fullText` content
limit. The content limit measures raw UTF-8 bytes; the frame carries those
bytes *after* JSON escaping, which expands `\n` 2x and control bytes up to 6x.
A worst-case `store_memory` request with a maximum `fullText` serializes to
roughly 6.1 MiB, so the headroom belongs in the transport rather than in the
content limit.

**Drain and resync.** Once the length prefix is read, the frame boundary is
known. An oversized but believable frame (over the peer limit, under
`MAX_DRAIN_SIZE`) is skipped exactly — copied to a sink, so no allocation —
which leaves the stream sitting on the next frame boundary. Both sides then
report the error and keep the connection. A connection is closed on: a clean
EOF, a declared length beyond `MAX_DRAIN_SIZE` (the boundary is fiction), a
drain that times out, and a declared payload that never arrives within
`PAYLOAD_TIMEOUT`. A kept frame is also never allocated on the strength of its
declared length alone — the buffer grows in 64 KiB steps as bytes actually
arrive — so a peer that declares 8 MiB and sends nothing costs one chunk and
one timeout rather than a pinned connection task holding 8 MiB.

Recovery is tolerant but not unlimited: `MAX_CONSECUTIVE_FRAME_ERRORS`
recoverable errors in a row (with no good frame in between) close the
connection, since each one can cost up to `DRAIN_TIMEOUT` of one of the 32
connection slots. Any frame that parses resets the count.

Oversized messages are rejected at serialization time, **before** any byte
reaches the socket, so an outsized request cannot desynchronize a connection
for the calls that follow it.

**Protocol negotiation.** `ping` doubles as a handshake: the client sends
`{"protocolVersion": 2, "maxMessageSize": 8388608}` and the daemon answers in
kind. Each side writes frames sized for what the other announced, defaulting
to 1 MiB for a peer that announces nothing. This matters because a protocol v1
peer rejects an oversized length prefix *without* draining the payload, which
corrupts its stream permanently — so a v2 peer never sends one an oversized
frame. Negotiation is repeated on every reconnect.

**Client recovery.** A transport failure poisons the client connection; the
next call re-establishes it (3 attempts, 50/200/500 ms backoff). Only methods
with **no observable effect at all** are replayed transparently on the fresh
connection: `get_memory`, `list_memories`, `list_namespaces`,
`namespace_stats`, `scan_duplicates`, `check_health`, `ping`. Mutating methods
are never replayed — `store_memory` mints a server-side id, `reinforce_memory`
advances the FSRS schedule — and instead return an error stating that the
operation may or may not have been applied. `search` and `find_similar` are
excluded too, despite writing no memory content: both record an access per
result, which advances `last_accessed_at` and the FSRS schedule, so a silent
replay would age those memories twice off one request. They report that
nothing was written and that reissuing the call is safe. Request ids are
monotonic across reconnects, so a response left over from an abandoned call can be recognized
and discarded rather than mistaken for the current one.

### CLI client (recalld-cli)

- Separate binary that communicates with the HTTP API server (default `http://localhost:7680`).
Expand Down
11 changes: 9 additions & 2 deletions docs/benchmark.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,15 +83,22 @@ outputs zero or more structured memories to store.
- `entities` (required): people, places, proper nouns
- `topics` (required): 1--5 topic keywords
- `emotions` (optional): emotional tone
- `supersedes` (optional): ID of a memory this one replaces
- `supersedes` (optional): ID of a memory this one replaces. Over the MCP
and HTTP APIs the target must already exist and live in the same
namespace, or the store is rejected outright. The benchmark harness writes
through its own in-process path rather than those APIs, so an ID the model
invents is dropped rather than rejected — the memory is still stored, just
without the link.

**Storage pipeline per memory:**
1. Generate embedding from concatenation of summary + full_text + tags
2. Insert into storage engine (redb)
3. Add to SIMD vector index
4. Add to FTS5 full-text search index
5. Add as a node in the memory graph
6. If `supersedes` is set, add a Supersedes edge
6. If `supersedes` is set, add a Supersedes edge from the new memory to the
old one. Recall then returns the new memory in place of the old one,
provided the new one also passes the query's filters
7. Run automatic graph linking (similarity, entity, temporal)

**Graph linking** (three types, all automatic):
Expand Down
53 changes: 42 additions & 11 deletions docs/guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -188,9 +188,10 @@ HTTP API server settings.
| `bind_address` | string | `"127.0.0.1"` | IP address to bind to. |
| `port` | u16 | `7680` | TCP port to listen on. |
| `request_timeout_ms` | u64 | `30000` | Maximum request time in milliseconds before abort. |
| `max_body_bytes` | usize | `10485760` | Maximum request body size in bytes (10 MB). |
| `max_body_bytes` | usize | `10485760` | Maximum request body size in bytes (10 MB). Also applied to `/mcp`. |
| `mcp_allowed_origins` | array of string | `[]` | Extra browser origins allowed to call `/mcp`, beyond loopback. Matched case-insensitively and exactly, e.g. `["https://app.example.com"]`. Requests with no `Origin` header are always allowed; any loopback origin is allowed on any scheme or port; `Origin: null` is always rejected. |

Env vars: `RECALLD_SERVER_BIND_ADDRESS`, `RECALLD_SERVER_PORT`, `RECALLD_SERVER_REQUEST_TIMEOUT_MS`, `RECALLD_SERVER_MAX_BODY_BYTES`
Env vars: `RECALLD_SERVER_BIND_ADDRESS`, `RECALLD_SERVER_PORT`, `RECALLD_SERVER_REQUEST_TIMEOUT_MS`, `RECALLD_SERVER_MAX_BODY_BYTES`, `RECALLD_SERVER_MCP_ALLOWED_ORIGINS` (comma-separated)

### `[storage]`

Expand Down Expand Up @@ -754,23 +755,53 @@ Store a new memory.

| Parameter | Type | Required | Description |
|---|---|---|---|
| `summary` | string | Yes | Short description (max 2000 chars). |
| `fullText` | string | No | Detailed content (max 1 MB). Dropped as memory decays to ghost phase. |
| `tags` | string[] | No | Categorization tags, e.g. `["topic/rust", "type/observation"]`. Max 64. |
| `entities` | string[] | No | Named entities (people, places, orgs). Used for search indexing and graph linking. Max 32. |
| `topics` | string[] | No | Topic keywords, e.g. `["rust", "cooking"]`. Max 32. |
| `emotions` | string[] | No | Emotional tone, e.g. `["happy", "anxious"]`. Max 32. |
| `summary` | string | Yes | Short description (max 2000 bytes of UTF-8; ~2000 ASCII characters, fewer with em dashes/curly quotes/emoji). |
| `fullText` | string | No | Detailed content (max 1 MB = 1 048 576 bytes of UTF-8, counted on the raw text before JSON escaping). Dropped as memory decays to ghost phase. |
| `tags` | string[] | No | Categorization tags, e.g. `["topic/rust", "type/observation"]`. Max 64 items, 128 bytes of UTF-8 each. |
| `entities` | string[] | No | Named entities (people, places, orgs). Used for search indexing and graph linking. Max 32 items, 121 bytes of UTF-8 each (the derived `entity/<label>` tag must fit the 128-byte tag limit). |
| `topics` | string[] | No | Topic keywords, e.g. `["rust", "cooking"]`. Max 32 items, 122 bytes of UTF-8 each. |
| `emotions` | string[] | No | Emotional tone, e.g. `["happy", "anxious"]`. Max 32 items, 120 bytes of UTF-8 each. |
| `namespace` | string | No | Target namespace (default: `"default"`). |
| `parentId` | string | No | UUID of parent memory to create a hierarchical link. |
| `supersedes` | string | No | UUID of an older memory this one replaces. The old memory is deprioritized in search. |
| `parentId` | string | No | UUID of an existing memory in the same namespace, linked as this memory's parent. The store fails if the target does not exist or is in a different namespace. |
| `supersedes` | string | No | UUID of an existing memory in the same namespace that this one replaces. Recall REMOVES the old memory from results and returns this one in its place. The store fails if the target does not exist or is in a different namespace. |

#### supersedes semantics

`supersedes` is a *desired end state*, not an event, and the store result
always says what actually happened to the link.

- **Preconditions, checked before anything is written.** The target must
exist and must live in the same namespace as the new memory, and a memory
cannot supersede itself. Violating any of these fails the whole store —
no memory is created. Checking up front is what keeps a rejected link
from leaving an orphaned memory behind. A *tombstoned* (deleted) target is
fine: correcting a memory you just deleted is a legitimate thing to do.
- **Idempotent.** Storing the same correction twice is not an error; the
second call reports `alreadyApplied`.
- **Reported, never silent.** When the request names a target, the store
result carries a `supersedes` object:

| Field | Description |
|---|---|
| `target` | The memory that was superseded. |
| `status` | `applied`, `alreadyApplied`, `appliedNotDurable`, or `failed`. |
| `detail` | Explanation, present for every status except `applied`. |

`applied` means the edge was created and written to disk.
`alreadyApplied` means it was already in place. `appliedNotDurable` means
the edge is active for the running process but could not be persisted, so
it will be lost on restart. `failed` means no edge exists; the memory was
still stored.
- **Counted.** The new memory's `edgeCount` includes the supersedes edge.
Memories stored before this behaviour existed are not backfilled.

### `store_memories`

Store multiple memories in a single call. Each item has the same schema as `store_memory`. Returns an array of results, one per input memory.

| Parameter | Type | Required | Description |
|---|---|---|---|
| `memories` | array | Yes | Array of memory objects (max 100 per call). Each object has the same fields as `store_memory`. |
| `memories` | array | Yes | Array of memory objects (max 100 per call). Each object has the same fields as `store_memory`, including `supersedes` and its preconditions. |

### `recall_memories`

Expand Down
Loading
Loading