Skip to content

Commit fbb74d7

Browse files
committed
memory/memu_bridge: route memU embeddings through MEMU_EMBEDDING_BASE_URL
Symptom: memory_recall returns "No relevant memories found" for any query that should match a memory written after 2026-05-06. memU's own logs reported route_category and recall succeeding, but vector search at recall time was effectively disabled. Root cause: memu_bridge gated the embedding LLM profile, the "rag" retrieve method, and the categorize_items step exclusively on self.config.openai_api_key. The 2026-05-05 sidecar work landed an Ollama service in docker-compose.yml and set MEMU_EMBEDDING_BASE_URL on the agent container, but the in-process bridge code never read the env var. With openai_api_key cleared (the sidecar was supposed to replace it) the bridge took the no-embed branch: - _categorize_no_embed replaced categorize_items, persisting every new item with embedding_json=NULL. - retrieve_config["method"] resolved to "llm" instead of "rag", so recall fanned out chat queries against the categories instead of doing a cosine search over item vectors. - _has_embeddings returned False, suppressing vector lookups upstream of recall too. 5,100 of 6,888 memu_memory_items rows on the running container had embedding_json=NULL; all 879 with embeddings dated to 2026-05-05 when the OpenAI key was still active. RCA in notes/lessons/2026-05-09-memu-embeddings-not-wired.md. Fix: resolve the embedding endpoint as env -> YAML -> OpenAI in one place, set a single embeddings_configured flag, and key every downstream behavior off that flag. Concretely: - nerve/config.py: MemoryConfig gains embedding_base_url, embedding_api_key, and llm_concurrency fields with env-var-aware defaults. llm_concurrency clamps to >= 1 since 0 deadlocks the semaphore wrapper. - nerve/memory/memu_bridge.py: - _initialize() reads MEMU_EMBEDDING_BASE_URL, MEMU_EMBEDDING_API_KEY, and MEMU_EMBED_MODEL with YAML config fallback. When base_url is set, registers the embedding profile against that endpoint with api_key="placeholder" if not provided (the OpenAI SDK requires a non-empty string; Ollama and TEI ignore it). - embeddings_configured = (env or YAML base URL set) OR (openai key set). _categorize_no_embed only takes the no-embed path when neither provider is configured. retrieve method is "rag" when configured, "llm" otherwise. memory_extract_llm_profile follows the same flag. - _has_embeddings checks all three sources (env, YAML, OpenAI). - Bounded asyncio.Semaphore wraps memU's chat calls so the per-memory-type fan-out (4-way gather in memU's extract_items pipeline) doesn't blow the Anthropic rate limit on lower API tiers. Configurable via memory.llm_concurrency, default 1. Re-instrumentation reuses the same Semaphore so callers already queued don't lose their slot. - SDK retries enabled at max_retries=4 (was 0). With concurrency bounded, retries actually drain the queue instead of stacking. - nerve/bootstrap.py: _build_docker_compose now writes the embeddings service block (Ollama + nomic-embed-text), the MEMU_EMBEDDING_* env vars on the nerve service, depends_on: embeddings: condition: service_healthy, the ~/.nerve/claude bind mount for persisted Claude Code state, the path-aligned ${HOME}/nerve-workspace and ${HOME}/projects mounts, and /var/run/docker.sock for direct daemon access. The entrypoint creates /root/* symlinks pointing at HOST_HOME so hardcoded /root/nerve-workspace and /root/projects paths still resolve. Brings nerve init regeneration in line with the live host docker-compose.yml. Tests: 18 new tests in test_memu_bridge.py covering the new MemoryConfig fields, llm_concurrency clamping, and the semaphore wrapper (serialization at concurrency=1, peak respect at concurrency=3, instance reuse across resets). 4 updates in test_bootstrap.py for the host-aligned mount assertions and the NERVE_DOCKER unset that lets the test pass when run inside the agent container. Notes: the original 2026-05-05 stash also added a "nerve-services = nerve.services:main" console-script entry to pyproject.toml and assorted comments about a docker-mcp sidecar. nerve/services lives only on the abandoned alex/docker-mcp-spike branch, so installing with that entry breaks pip install -e. The entry and the sidecar comments are dropped here. The engine-SDK-resume-guard half of the original stash already shipped on f39e62b and is excluded.
1 parent 54542e0 commit fbb74d7

5 files changed

Lines changed: 533 additions & 62 deletions

File tree

nerve/bootstrap.py

Lines changed: 214 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1847,6 +1847,19 @@ def _wrap_text(text: str, width: int = 51) -> list[str]:
18471847
&& curl -fsSL "https://github.com/steipete/gogcli/releases/download/v${GOG_VERSION}/gogcli_${GOG_VERSION}_linux_${ARCH}.tar.gz" \\
18481848
| tar xz -C /usr/local/bin gog
18491849
1850+
# Install Docker CLI (client only) so the agent can talk to the host
1851+
# docker daemon through the bind-mounted /var/run/docker.sock. Lets
1852+
# Bash run "docker compose up" / "docker run" against the host from
1853+
# inside the agent without needing a separate MCP sidecar.
1854+
RUN install -m 0755 -d /etc/apt/keyrings \\
1855+
&& curl -fsSL https://download.docker.com/linux/debian/gpg \\
1856+
| gpg --dearmor -o /etc/apt/keyrings/docker.gpg \\
1857+
&& chmod a+r /etc/apt/keyrings/docker.gpg \\
1858+
&& echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/debian $(. /etc/os-release && echo $VERSION_CODENAME) stable" \\
1859+
> /etc/apt/sources.list.d/docker.list \\
1860+
&& apt-get update && apt-get install -y --no-install-recommends docker-ce-cli docker-compose-plugin \\
1861+
&& rm -rf /var/lib/apt/lists/*
1862+
18501863
RUN mkdir -p /root/.nerve /root/nerve-workspace
18511864
18521865
ENV NERVE_DOCKER=1
@@ -1870,29 +1883,94 @@ def _wrap_text(text: str, width: int = 51) -> list[str]:
18701883
ENTRYPOINT ["/docker-entrypoint.sh"]
18711884
"""
18721885

1886+
def _host_aligned_path(path: str) -> str:
1887+
"""Return a YAML-safe representation of ``path`` that resolves to
1888+
the same absolute path on the host and inside the container.
1889+
1890+
Compose substitutes ``${HOME}`` from the user's shell env at
1891+
runtime, so a ``~/foo`` workspace becomes ``${HOME}/foo`` and
1892+
expands to the host's ``$HOME``. Absolute paths are returned
1893+
untouched. Path alignment is what lets the agent pass paths to
1894+
the bind-mounted host docker daemon when launching siblings: the
1895+
same string resolves to the same files inside and out, so
1896+
``docker run -v $PWD:$PWD ...`` from inside the agent gives the
1897+
sibling container the right host directory.
1898+
"""
1899+
if not path:
1900+
return path
1901+
if path.startswith("~/"):
1902+
return "${HOME}/" + path[2:]
1903+
if path == "~":
1904+
return "${HOME}"
1905+
return path
1906+
1907+
18731908
def _build_docker_compose(
18741909
workspace_path: str = "~/nerve-workspace",
1910+
projects_path: str = "~/projects",
18751911
extra_mounts: list[str] | None = None,
1912+
docker_socket: bool = True,
18761913
) -> str:
18771914
"""Build docker-compose.yml content with host bind-mounts.
18781915
18791916
Args:
1880-
workspace_path: Host path for the workspace (e.g. ~/nerve-workspace).
1881-
extra_mounts: Additional host:container mount pairs (e.g. ["~/code:/code"]).
1917+
workspace_path: Host path for the workspace (default ~/nerve-workspace).
1918+
projects_path: Host path for the projects directory containing
1919+
git checkouts and worktrees (default ~/projects). Mounted
1920+
with path alignment so the agent can pass the same paths to
1921+
the host daemon when starting sibling containers.
1922+
extra_mounts: Additional host:container mount pairs.
1923+
docker_socket: When True, mount the host docker socket
1924+
(``/var/run/docker.sock``) into the agent container so the
1925+
agent can run ``docker`` and ``docker compose`` directly via
1926+
Bash. Required for Grafana per-PR runs and HyperDX
1927+
``make dev`` orchestration. Works with OrbStack and Docker
1928+
Desktop on macOS (both expose a compatibility symlink at
1929+
this path) and with stock dockerd on Linux. Disable only if
1930+
you have a reason to keep the agent isolated from the host
1931+
daemon.
1932+
1933+
Note on the previous sidecar pattern:
1934+
Earlier revisions of this file shipped a ``docker-mcp`` service
1935+
that ran ``supercorp/supergateway`` wrapping ``ckreiling/mcp-
1936+
server-docker`` to expose Docker daemon verbs as MCP tools. That
1937+
approach (a) couldn't orchestrate ``docker compose`` (no compose
1938+
verbs in the underlying server) and (b) suffered a chronic
1939+
protocol-version drift between supergateway's hardcoded
1940+
``MCP-Protocol-Version`` allowlist and the version Claude Code
1941+
sends in headers. Mounting the socket directly solves both
1942+
problems in five lines. The agent already had unfettered daemon
1943+
access through the sidecar's MCP tools, so the blast radius is
1944+
unchanged.
18821945
"""
1883-
# Required mounts (always present)
1946+
# Host-aligned paths: same absolute string inside and outside the
1947+
# container, so the agent can pass them to the bind-mounted host
1948+
# docker daemon when mounting them into siblings.
1949+
workspace_aligned = _host_aligned_path(workspace_path)
1950+
projects_aligned = _host_aligned_path(projects_path)
1951+
1952+
# Required mounts. ~/.nerve stays at /root/.nerve because it is
1953+
# agent-only state and never passed through to siblings.
1954+
# ~/.nerve/claude:/root/.claude persists Claude Code's in-container
1955+
# state (config + per-conversation .jsonl files under projects/)
1956+
# across container restarts. Without this mount the .jsonl files
1957+
# are wiped on every recreate and the Nerve DB's stale
1958+
# sdk_session_id rows fail every --resume with "No conversation
1959+
# found" exit 1. The path is siloed under ~/.nerve so the agent's
1960+
# CLI is isolated from the host user's personal ~/.claude (where
1961+
# macOS stores OAuth tokens via the system Keychain; auth still
1962+
# comes from config.local.yaml, not from this directory).
18841963
volumes = [
18851964
".:/nerve",
18861965
"~/.nerve:/root/.nerve",
1887-
f"{workspace_path}:/root/nerve-workspace",
1966+
"~/.nerve/claude:/root/.claude",
1967+
f"{workspace_aligned}:{workspace_aligned}",
1968+
f"{projects_aligned}:{projects_aligned}",
18881969
]
18891970

18901971
# Optional auth mounts — only include if the host directory exists.
18911972
# Docker would create missing dirs as root-owned empties, which
18921973
# confuses the tools and pollutes the host filesystem.
1893-
# Note: ~/.claude is NOT mounted — macOS stores OAuth tokens in the
1894-
# system Keychain, not on disk. The entrypoint exports ANTHROPIC_API_KEY
1895-
# from config.local.yaml instead, which the claude CLI picks up.
18961974
_optional_mounts = [
18971975
("~/.config/gh", "/root/.config/gh", "gh CLI auth"),
18981976
("~/.config/gog", "/root/.config/gog", "gog CLI auth"),
@@ -1902,25 +1980,114 @@ def _build_docker_compose(
19021980
if os.path.isdir(expanded):
19031981
volumes.append(f"{host_path}:{container_path}")
19041982

1983+
if docker_socket:
1984+
# Direct daemon access for the agent. With this in place,
1985+
# `docker` and `docker compose` work from Bash inside the
1986+
# agent. The path-aligned ${{HOME}}/projects mount above means
1987+
# the daemon resolves bind-mount paths identically inside and
1988+
# out, so `cd ~/projects/worktrees/<repo>/<slug> && make dev`
1989+
# works from the agent and creates containers visible on the
1990+
# host. OrbStack and Docker Desktop both expose the daemon at
1991+
# this exact path on macOS via a compatibility symlink.
1992+
volumes.append("/var/run/docker.sock:/var/run/docker.sock")
1993+
19051994
if extra_mounts:
19061995
volumes.extend(extra_mounts)
19071996

1908-
# Build YAML by hand to keep formatting clean
19091997
vol_lines = "\n".join(f" - {v}" for v in volumes)
19101998

1911-
return f"""services:
1912-
nerve:
1999+
# In-agent service ports. The agent publishes ranges for dev
2000+
# servers it runs itself (docs preview, vite, storybook). Sibling
2001+
# containers (grafana, hyperdx-*) get their own host ports
2002+
# allocated when launched directly by the host daemon, so they are
2003+
# not listed here.
2004+
in_agent_port_ranges = [
2005+
("docs", 3000, 3019),
2006+
("vite", 5173, 5189),
2007+
("storybook", 6006, 6019),
2008+
]
2009+
port_lines = [' - "8900:8900"']
2010+
for label, lo, hi in in_agent_port_ranges:
2011+
port_lines.append(f' - "{lo}-{hi}:{lo}-{hi}" # {label}')
2012+
2013+
nerve_block = f""" nerve:
19132014
build: .
19142015
ports:
1915-
- "8900:8900"
2016+
{chr(10).join(port_lines)}
2017+
environment:
2018+
# Path alignment: the entrypoint creates /root/* symlinks pointing
2019+
# at HOST_HOME so legacy paths still resolve, and any path passed
2020+
# to the host docker daemon resolves identically inside and out.
2021+
HOST_HOME: ${{HOME}}
2022+
# Route memU embeddings at the local Ollama sidecar (defined
2023+
# below). The OpenAI SDK respects this base URL when it talks to
2024+
# /embeddings, so memU recall + memorize work without OpenAI
2025+
# auth or network egress. Empty disables the override; memu_bridge
2026+
# then falls back to api.openai.com if openai_api_key is set,
2027+
# otherwise embeddings are simply skipped.
2028+
MEMU_EMBEDDING_BASE_URL: http://embeddings:11434/v1
2029+
MEMU_EMBEDDING_API_KEY: placeholder
2030+
MEMU_EMBED_MODEL: nomic-embed-text
2031+
depends_on:
2032+
embeddings:
2033+
condition: service_healthy
19162034
volumes:
19172035
{vol_lines}
19182036
restart: unless-stopped
19192037
stdin_open: true
19202038
tty: true
19212039
env_file:
19222040
- path: .env
1923-
required: false
2041+
required: false"""
2042+
2043+
embeddings_block = """ # Self-hosted OpenAI-compatible embeddings service.
2044+
# Ollama serves nomic-embed-text (768-dim) at /v1/embeddings, the
2045+
# same wire format the OpenAI SDK speaks. This replaces the OpenAI
2046+
# /embeddings calls memU made for routing + recall, removing the
2047+
# quota / 401 single point of failure. Native ARM64 image; no
2048+
# emulation overhead on Apple Silicon. The first start of this
2049+
# service downloads the model (~270 MB) and caches it under
2050+
# ~/.nerve/ollama; subsequent starts are instant. nomic-embed-text
2051+
# returns 768-dim vectors (vs OpenAI ada-002's 1536), so any
2052+
# existing memu.sqlite embeddings get rebuilt on next memorize.
2053+
embeddings:
2054+
image: ollama/ollama:latest
2055+
volumes:
2056+
- ~/.nerve/ollama:/root/.ollama
2057+
expose:
2058+
- "11434"
2059+
restart: unless-stopped
2060+
# Pull the embedding model on first start, then run the server.
2061+
# `ollama serve` blocks; we pull in the background, wait until the
2062+
# API responds, then `wait` keeps the server in the foreground.
2063+
entrypoint: ["/bin/sh", "-c"]
2064+
command:
2065+
- |
2066+
set -e
2067+
/bin/ollama serve &
2068+
pid=$$!
2069+
until /bin/ollama list >/dev/null 2>&1; do sleep 1; done
2070+
if ! /bin/ollama list | awk '{print $$1}' | grep -q '^nomic-embed-text'; then
2071+
echo "Pulling nomic-embed-text..."
2072+
/bin/ollama pull nomic-embed-text
2073+
fi
2074+
wait $$pid
2075+
healthcheck:
2076+
# Ready means: server up AND embedding model loaded. We grep for
2077+
# the model name so we don't mark healthy before the first-run
2078+
# model pull finishes.
2079+
test:
2080+
- CMD-SHELL
2081+
- 'ollama list 2>/dev/null | grep -q "^nomic-embed-text"'
2082+
interval: 10s
2083+
timeout: 5s
2084+
retries: 60
2085+
start_period: 30s"""
2086+
2087+
return f"""services:
2088+
{nerve_block}
2089+
2090+
{embeddings_block}
19242091
"""
19252092

19262093
_DOCKER_ENTRYPOINT_TEMPLATE = """#!/bin/bash
@@ -1937,6 +2104,33 @@ def _build_docker_compose(
19372104
cd web && npm ci --quiet && npm run build && cd ..
19382105
fi
19392106
2107+
# --- Path alignment ---
2108+
# HOST_HOME comes from compose (set to ${HOME} on the host). For each
2109+
# host-aligned mount point, drop a symlink at the legacy /root/* path
2110+
# so anything that hard-codes /root/nerve-workspace or /root/projects
2111+
# keeps working and resolves to the same files the host docker daemon
2112+
# sees. Idempotent: if the symlink already points where we want, skip.
2113+
if [ -n "${HOST_HOME:-}" ]; then
2114+
for _name in nerve-workspace projects; do
2115+
_src="$HOST_HOME/$_name"
2116+
_dst="/root/$_name"
2117+
if [ ! -d "$_src" ]; then
2118+
continue
2119+
fi
2120+
if [ -L "$_dst" ]; then
2121+
# Already a symlink; trust it.
2122+
continue
2123+
fi
2124+
if [ -d "$_dst" ] && [ -z "$(ls -A "$_dst" 2>/dev/null)" ]; then
2125+
# Empty leftover dir from the Dockerfile mkdir or a prior
2126+
# bind mount that no longer exists. Replace with the symlink.
2127+
rmdir "$_dst" && ln -s "$_src" "$_dst"
2128+
elif [ ! -e "$_dst" ]; then
2129+
ln -s "$_src" "$_dst"
2130+
fi
2131+
done
2132+
fi
2133+
19402134
# --- Credential resolution (priority waterfall) ---
19412135
# Export credentials from config.local.yaml so tools (claude CLI, gh CLI)
19422136
# can authenticate inside Docker. macOS stores tokens in the Keychain
@@ -1960,6 +2154,14 @@ def _build_docker_compose(
19602154
[ -n "$_gh" ] && export GH_TOKEN="$_gh"
19612155
fi
19622156
2157+
# Ensure the persisted Claude Code state dir exists and is writable
2158+
# before any tool that touches /root/.claude runs. The bind mount in
2159+
# docker-compose creates it as a host-owned empty dir on first boot;
2160+
# we need it owned by root with 0700 so the CLI can drop its config
2161+
# file and projects/ tree there without ENOENT or EACCES.
2162+
mkdir -p /root/.claude
2163+
chmod 700 /root/.claude
2164+
19632165
# Clean up stale PID file from previous container runs
19642166
rm -f ~/.nerve/nerve.pid
19652167

nerve/config.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -287,6 +287,25 @@ class MemoryConfig:
287287
memorize_model: str = "claude-sonnet-4-6" # Extraction & preprocessing
288288
fast_model: str = "claude-haiku-4-5-20251001" # Category summaries, date resolution
289289
embed_model: str = ""
290+
# Optional override for the OpenAI-compatible /embeddings endpoint
291+
# memU calls. When set, takes precedence over the default
292+
# https://api.openai.com/v1. Point it at a self-hosted sidecar
293+
# (Ollama, TEI, LocalAI, etc.) to avoid the OpenAI quota / 401
294+
# single point of failure. The env vars MEMU_EMBEDDING_BASE_URL,
295+
# MEMU_EMBEDDING_API_KEY, and MEMU_EMBED_MODEL override these
296+
# config values at runtime, which is convenient for the docker
297+
# compose path where the sidecar URL is known to the entrypoint,
298+
# not the YAML config.
299+
embedding_base_url: str = ""
300+
embedding_api_key: str = ""
301+
# Cap on concurrent LLM chat calls during memorize / recall. memU
302+
# fans out per memory_type (profile, event, knowledge, behavior)
303+
# and asyncio.gathers the results, which on lower Anthropic API
304+
# tiers reliably blows the per-minute rate limit. Bounding
305+
# concurrency at 1 serializes the bursts; the SDK's exponential
306+
# backoff handles the rest. Bump to 2-4 if your API tier can
307+
# absorb the parallel load.
308+
llm_concurrency: int = 1
290309
sqlite_dsn: str = ""
291310
semantic_dedup_threshold: float = 0.85 # Cosine similarity threshold for semantic dedup
292311
knowledge_filter: bool = False # Post-extraction LLM filter for generic knowledge (extra API call)
@@ -302,6 +321,9 @@ def from_dict(cls, d: dict) -> MemoryConfig:
302321
memorize_model=d.get("memorize_model", "claude-sonnet-4-6"),
303322
fast_model=d.get("fast_model", "claude-haiku-4-5-20251001"),
304323
embed_model=d.get("embed_model", ""),
324+
embedding_base_url=d.get("embedding_base_url", ""),
325+
embedding_api_key=d.get("embedding_api_key", ""),
326+
llm_concurrency=max(1, int(d.get("llm_concurrency", 1))),
305327
sqlite_dsn=d.get("sqlite_dsn", default_dsn),
306328
semantic_dedup_threshold=float(d.get("semantic_dedup_threshold", 0.85)),
307329
knowledge_filter=bool(d.get("knowledge_filter", False)),

0 commit comments

Comments
 (0)