Files

9.1 KiB

ai-memory-relay

A companion CLI for sending externally captured lifecycle events to ai-memory. It records events in a local SQLite queue and sends them through POST /hook/batch. Use it when an orchestrator needs delivery to survive an unavailable server or a process restart.

The relay has its own Cargo workspace. It does not open ai-memory's database or wiki, launch agents, or claim handoffs. See the external lifecycle contract for capture ownership and the companion policy for the core boundary.

Build and use

From the repository root:

cargo build --locked --manifest-path companions/ai-memory-relay/Cargo.toml

The executable is companions/ai-memory-relay/target/debug/ai-memory-relay, unless CARGO_TARGET_DIR selects another target directory. The commands below assume the executable is on PATH and an ai-memory server is running.

Initialize a queue. Its parent directory must exist; the relay creates the queue directory itself.

ai-memory-relay init \
  --queue-dir "$HOME/.ai-memory-relay" \
  --server-url http://127.0.0.1:49374 \
  --producer my-orchestrator \
  --actor developer \
  --workspace work \
  --project example

This binds the queue to one destination and scope. Repeating an identical init is safe. A different binding is rejected. actor is a stable namespace used to derive retry keys; authentication still comes from the server's bearer token. It does not select or impersonate a server user.

When the orchestrator launches a harness whose lifecycle it captures, pass AI_MEMORY_CAPTURE_OWNER=my-orchestrator to that process. Native capture is then suppressed as described in the lifecycle contract. Supported handoff delivery remains active, and the agent can keep using MCP for retrieval and deliberate memory writes. A standalone harness without that context keeps normal hooks.

The orchestrator supplies a JSON array, for example events.json:

[
  {
    "event_id": "event-001",
    "agent": "claude-code",
    "event": "session-start",
    "body": {
      "session_id": "native-session-123",
      "cwd": "/workspace/example"
    }
  },
  {
    "event_id": "event-002",
    "agent": "claude-code",
    "event": "user-prompt-submit",
    "body": {
      "session_id": "native-session-123",
      "cwd": "/workspace/example",
      "prompt": "Check the failing test."
    }
  }
]
ai-memory-relay enqueue --queue-dir "$HOME/.ai-memory-relay" --file events.json
ai-memory-relay flush --queue-dir "$HOME/.ai-memory-relay"
ai-memory-relay status --queue-dir "$HOME/.ai-memory-relay"

enqueue validates the whole array and commits it in one transaction. Each body must be an object with explicit session_id and cwd strings. Use the harness's native identity and canonical hook event names. Other body fields keep their values, including an _ai_memory_capture block. The relay does not translate provider transcripts or infer parent agents and workflows.

For authenticated servers, supply AI_MEMORY_AUTH_TOKEN through the flush process's environment. The relay does not store or print it. The destination must use HTTP or HTTPS, with no credentials, query string or fragment in the base URL. Redirects are refused.

Delivery and retries

Each event gets a deterministic ingest_key from the producer, actor, native agent/session identity, event name and producer-assigned event_id, following the lifecycle contract. extension carries the producer and source_event carries the event name.

Re-enqueueing the same identity and body is recognized while the pending event or receipt remains in the queue. JSON object key order does not matter. Reusing that identity with different content rejects the entire input array. Two equal bodies with different event IDs remain separate events.

Only the oldest pending event from each session enters a batch. The next event for that session is eligible after acknowledgement. This keeps session-end behind earlier events while allowing other sessions to proceed. Order is the queue's committed enqueue order; producers must serialize events within a session before enqueueing them. Separate queue directories do not coordinate session order.

One process can flush a queue at a time. Other processes can enqueue during a flush. HTTP runs outside the SQLite write transaction. If a response is lost after the server commits, the event stays pending and a retry uses the same key.

The relay validates the complete batch acknowledgement before changing its queue. It honors both contiguous and noncontiguous accepted indexes, including partial success in HTTP 200 or 429 responses. Malformed acknowledgements, authentication failures and transport errors retain unacknowledged events. A failed head blocks its own session. HTTP 429 ends the flush; the caller should back off before scheduling another one.

An acknowledgement can also mean the server deliberately dropped an event, for example because of capture policy or a session identity collision. A drained queue does not prove that every event became an observation. The relay catches a native session changing agents within one queue, but server ownership checks still apply across queues and producers.

The server's ingest keys expire after 30 days. The relay records the first attempt before sending and retains expired pending events without replaying them automatically. Unsent events can remain offline longer. Keep the host clock accurate; the retry deadline uses wall-clock timestamps. Recreating a queue discards its attempt history and receipts, so it is not a safe way to recover an ambiguous delivery after the retry window.

flush attempts at most 64 batches by default; --max-batches changes that count. Requests, transport retries and the total flush duration also have finite bounds. Unfinished work stays queued for the next invocation.

Exit codes are:

Code Meaning
0 Command succeeded; a flush has no pending events.
2 Command failed. Inspect stderr; unacknowledged events remain queued.
3 Flush ended with events still pending.

status emits a JSON object with counters, including pending_items, pending_bytes, pending_sessions, expired_items and receipts. It does not print event bodies. Receipts count acknowledgements, including policy drops.

Local data and recovery

The queue contains producer-supplied event bodies before server sanitization. The producer must apply its capture exclusions before enqueueing. The relay does not load the project's [capture] ignore_paths settings or inspect tool payloads for sensitive paths. Server sanitization cannot protect a local queue that already contains those payloads.

On Unix, new queue directories use mode 0700 and files use 0600. Existing queue paths must already be private. Queue symlinks and Windows reparse points are rejected. On Windows, the relay does not set ACLs; use a directory restricted to the account that runs the orchestrator.

SQLite uses WAL mode and synchronous=FULL. Keep the database and its sidecar files together. To move or back up a queue, stop producers and flush processes first. Reopen the same queue after a restart; do not edit its SQLite tables to mark events as delivered. An unknown queue schema is rejected.

The queue uses these limits:

Resource Limit
Pending events 50,000
Pending body bytes 64 MiB
Pending events plus retained receipts 200,000
Retained session identities 50,000
One event body 256 KiB
One input file 32 MiB and 10,000 events
One HTTP batch 8 MiB and 256 events
Acknowledgement body 64 KiB

Reaching a limit rejects new input instead of dropping older events. Receipts and unused session records can expire after the 30-day retry window. Pending events are retained. Review expired entries and producer failures before choosing how to archive a queue; the CLI has no command that discards them automatically. These are logical record limits, not a fixed SQLite file size.

Validation

The package needs separate checks because it is outside the root workspace:

cargo fmt --check --manifest-path companions/ai-memory-relay/Cargo.toml
cargo clippy --locked --manifest-path companions/ai-memory-relay/Cargo.toml --all-targets -- -D warnings
cargo test --locked --manifest-path companions/ai-memory-relay/Cargo.toml

Run the integration test against locally built binaries:

cargo build --workspace
cargo build --locked --manifest-path companions/ai-memory-relay/Cargo.toml
uv run --no-project python tests/e2e/external_relay_smoke.py \
  --ai-memory-bin "$PWD/target/debug/ai-memory" \
  --relay-bin "$PWD/companions/ai-memory-relay/target/debug/ai-memory-relay"

Adjust the binary paths if CARGO_TARGET_DIR is set; Windows executables end in .exe. The test starts an isolated real ai-memory server and invokes its native hooks. It checks persistent observations for offline recovery, retries after a lost response, distinct event identities and 15 concurrent sessions. It also checks handoff delivery with native capture suppressed, rejected authentication and acknowledged session collisions. Temporary data and logs are retained, and the test prints their directory.