diff --git a/.env.example b/.env.example index 2ecc687d0..8c462825a 100644 --- a/.env.example +++ b/.env.example @@ -179,11 +179,20 @@ HUB_STATIC_DIR=../web/dist # HUGGINGFACE_OAUTH_CLIENT_ID= # Optional firm-memory plane (@corbits/memory). Uses DATABASE_URL — the -# same URL as everything else — in its own `knowledge` schema. Leave -# EMBED_BASE_URL unset to boot without memory: memory_search/memory_add/ -# memory_list then answer with a plain "memory isn't set up on this -# server yet" note instead of an error, and the tool isn't even offered -# to Myra. +# same URL as everything else — in its own `memory` schema. Recommended: +# run `bun run setup:memory` for a machine-specific recommendation (native +# Ollama, Docker, or a remote endpoint, whichever this checkout can +# actually use) instead of hand-picking the block below. +# +# Leave EMBED_BASE_URL unset to boot without memory: memory_search/ +# memory_add/memory_list then answer with a plain "memory isn't set up on +# this server yet" note instead of an error, and the tool isn't even +# offered to Myra. This is the one honest-degradation case worth being +# explicit about: setting EMBED_BASE_URL later does NOT retroactively +# embed anything written while it was unset — migrations create the +# tables either way, but there is no automatic backfill, so rows added +# before embedding was configured stay invisible to memory_search forever +# unless something re-adds them. # # Managed OpenAI embeddings: # EMBED_BASE_URL=https://api.openai.com/v1 @@ -211,12 +220,20 @@ HUB_STATIC_DIR=../web/dist # applies when unset. # EMBED_TIMEOUT_MS= # -# Optional reranking step on memory search results — leave RERANK_BASE_URL -# unset to search without reranking. +# Optional reranking step on memory search results — leave both +# RERANK_BASE_URL and RERANK_MODEL unset to search without reranking (the +# hub refuses to boot if only one of the pair is set, since a half-wired +# reranker can never work). Once both are set, a reranker outage degrades +# search quietly rather than breaking it — that's by design, not a bug. +# There's no local install for a reranker; the supported shape is a Text +# Embeddings Inference server, most easily run in Docker: +# docker run -d -p 8081:80 \ +# ghcr.io/huggingface/text-embeddings-inference:cpu-latest \ +# --model-id BAAI/bge-reranker-base # Reranker endpoint base URL. -# RERANK_BASE_URL= +# RERANK_BASE_URL=http://localhost:8081 # Reranker model name. -# RERANK_MODEL= +# RERANK_MODEL=BAAI/bge-reranker-base # Reranker API key, if the endpoint requires one. # RERANK_API_KEY= # Max characters of a candidate document sent to the reranker per call. @@ -224,6 +241,14 @@ HUB_STATIC_DIR=../web/dist # Timeout in milliseconds for a single rerank request. # RERANK_TIMEOUT_MS= +# Optional Markdown-artifact-to-PDF rendering (@corbits/gotenberg-render) +# through an operator-configured Gotenberg server. Leave unset to keep the +# PDF-render capability off entirely — nothing errors at startup either +# way. Gotenberg has no good native story, so this is Docker (or a remote +# endpoint) the same as the reranker: +# docker run --rm -p 3000:3000 gotenberg/gotenberg:8 +# GOTENBERG_URL=http://localhost:3000 + # Encrypts secrets at rest through Interchange's CredentialCipher seam — # webhook-trigger signing secrets, and the onboarding OAuth connect state # (PKCE verifier) sealed between /start and /callback so it survives a hub diff --git a/README.md b/README.md index 7175b80bf..62007603f 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,16 @@ cp .env.example .env bun run dev ``` +Recommended: run `bun run setup:memory` for a machine-specific +recommendation on turning on the memory plane (embeddings-backed recall) +and its optional reranker — native Ollama, Docker, or a remote endpoint, +whichever this machine can actually use — then add the env lines it +prints to `.env`. Skipping this leaves memory off: memory tools answer +"not set up" instead of erroring, so it's safe to add later, but rows +written before `EMBED_BASE_URL` is set are never retroactively embedded. +See [docs/local-dev.md](docs/local-dev.md#memory-plane) for the full +degradation story. + `bun run dev` validates your `.env` (reporting every missing or malformed value at once), verifies the database in `DATABASE_URL` is reachable and actually speaks Postgres, applies any pending platform migrations, builds diff --git a/apps/hub/src/memory-mount.test.ts b/apps/hub/src/memory-mount.test.ts index 8c33708e2..5152db799 100644 --- a/apps/hub/src/memory-mount.test.ts +++ b/apps/hub/src/memory-mount.test.ts @@ -10,6 +10,8 @@ const KEYS = [ "EMBED_MODEL", "EMBED_API_STYLE", "EMBED_API_KEY", + "RERANK_BASE_URL", + "RERANK_MODEL", ] as const; type EnvKey = (typeof KEYS)[number]; @@ -81,6 +83,38 @@ describe("mountMemory", () => { }), ).rejects.toThrow(/EMBED_BASE_URL/); }); + + test("throws when RERANK_BASE_URL is set without RERANK_MODEL, rather than reranking silently failing later", async () => { + stashEnv(); + process.env["DATABASE_URL"] = "postgres://localhost:5432/workbench"; + process.env["EMBED_BASE_URL"] = "http://localhost:9/v1"; + process.env["EMBED_MODEL"] = "test-embedding-model"; + process.env["RERANK_BASE_URL"] = "http://localhost:8080"; + const app = new Hono(); + await expect( + mountMemory({ + app, + grantStore: createInMemoryGrantStore([]), + conditionRegistry: {}, + }), + ).rejects.toThrow(/RERANK_BASE_URL.*RERANK_MODEL/s); + }); + + test("throws when RERANK_MODEL is set without RERANK_BASE_URL", async () => { + stashEnv(); + process.env["DATABASE_URL"] = "postgres://localhost:5432/workbench"; + process.env["EMBED_BASE_URL"] = "http://localhost:9/v1"; + process.env["EMBED_MODEL"] = "test-embedding-model"; + process.env["RERANK_MODEL"] = "bge-reranker-v2-m3"; + const app = new Hono(); + await expect( + mountMemory({ + app, + grantStore: createInMemoryGrantStore([]), + conditionRegistry: {}, + }), + ).rejects.toThrow(/RERANK_BASE_URL.*RERANK_MODEL/s); + }); }); // DB-gated: skipped when DATABASE_URL is unreachable, matching this repo's diff --git a/apps/hub/src/memory-mount.ts b/apps/hub/src/memory-mount.ts index 6276e7187..0506ee0eb 100644 --- a/apps/hub/src/memory-mount.ts +++ b/apps/hub/src/memory-mount.ts @@ -34,30 +34,71 @@ import { const log = getLogger(["hub", "memory-mount"]); -// The one boundary this module parses: whether the memory plane is -// configured at all. `"string > 0"` rejects a blank `EMBED_BASE_URL=` -// the same as an absent one — both mean "no memory plane" — while -// catching a non-string env value at the arktype boundary rather than -// letting a falsy check quietly wave through something unexpected. +// The boundary this module parses: whether the memory plane is configured +// at all, and — when a reranker is in play — whether it's configured +// completely. `"string > 0"` rejects a blank value the same as an absent +// one, while catching a non-string env value at the arktype boundary +// rather than letting a falsy check quietly wave through something +// unexpected. +// +// RERANK_BASE_URL and RERANK_MODEL must be set together: `@corbits/memory` +// treats each independently optional and would otherwise let a +// half-configured reranker surface as a confusing runtime failure deep in +// its rerank client, rather than a boot-time error naming the missing +// half — the same "fail loud on a half-configured pair" contract this +// hub already applies to GOOGLE_CLIENT_ID/SECRET (see ../config.ts). +// Reranking itself stays a soft-fail enhancement once configured (a +// reranker outage degrades search quietly, never breaks it) — this check +// only guards against shipping a pair that can never work at all. const MemoryMountEnv = type({ "EMBED_BASE_URL?": "string > 0", + "RERANK_BASE_URL?": "string > 0", + "RERANK_MODEL?": "string > 0", }); -function embedBaseUrlFrom( +type ParsedMemoryMountEnv = typeof MemoryMountEnv.infer; + +function omitUndefined( env: Record, -): string | undefined { - // Build the input object with the key OMITTED rather than present with - // an `undefined` value: arktype's optional-key check is keyed off + keys: readonly string[], +): Record { + // Build the input object with unset keys OMITTED rather than present + // with an `undefined` value: arktype's optional-key check is keyed off // property presence, and `process.env` (and this suite's env stashing) // both sometimes leave an unset variable as a present-but-`undefined` // own property rather than an absent one. - const rawValue = env["EMBED_BASE_URL"]; - const input = rawValue === undefined ? {} : { EMBED_BASE_URL: rawValue }; + const input: Record = {}; + for (const key of keys) { + const value = env[key]; + if (value !== undefined) input[key] = value; + } + return input; +} + +function parseMemoryMountEnv( + env: Record, +): ParsedMemoryMountEnv { + const input = omitUndefined(env, [ + "EMBED_BASE_URL", + "RERANK_BASE_URL", + "RERANK_MODEL", + ]); const parsed = MemoryMountEnv(input); if (parsed instanceof type.errors) { throw new Error(`invalid memory-plane environment: ${parsed.summary}`); } - return parsed.EMBED_BASE_URL; + if ( + (parsed.RERANK_BASE_URL === undefined) !== + (parsed.RERANK_MODEL === undefined) + ) { + throw new Error( + [ + "invalid memory-plane environment: RERANK_BASE_URL and RERANK_MODEL must be set together to enable reranking; only one is set", + "Set both in .env, or unset both to search without reranking; see .env.example.", + ].join("\n"), + ); + } + return parsed; } export type MountMemoryOptions = { @@ -84,7 +125,8 @@ export async function mountMemory( options: MountMemoryOptions, ): Promise { const optional = options.optional !== false; - const embedBaseUrl = embedBaseUrlFrom(process.env); + const parsedEnv = parseMemoryMountEnv(process.env); + const embedBaseUrl = parsedEnv.EMBED_BASE_URL; if (embedBaseUrl === undefined) { if (optional) { log.info("EMBED_BASE_URL not set — memory plane will not be mounted"); diff --git a/docs/local-dev.md b/docs/local-dev.md index 303ccefc8..33fb7c477 100644 --- a/docs/local-dev.md +++ b/docs/local-dev.md @@ -53,7 +53,41 @@ exactly this reason. The memory plane (embeddings-backed recall) needs `EMBED_BASE_URL` set; without it, `apps/hub/src/memory-mount.ts` skips mounting the memory plane -and logs that it did, rather than failing hub startup. +and logs that it did, rather than failing hub startup — and +`memory_search`/`memory_add`/`memory_list` answer with a plain "memory +isn't set up on this server yet" note instead of erroring. + +Run `bun run scripts/setup-memory.ts` (or `bun run setup:memory`) for a +recommendation tailored to this machine — it checks for native Ollama and +Docker and prints the exact env lines and commands for whichever it finds, +in the order the platform prefers them: + +1. **Native first.** A local `ollama pull nomic-embed-text` needs no + container and is the preferred embedding path. +2. **Docker** for the pieces with no good native story — the reranker + (`ghcr.io/huggingface/text-embeddings-inference:cpu-latest`, serving + `BAAI/bge-reranker-base`) and Gotenberg PDF rendering + (`gotenberg/gotenberg:8`) — and as a fallback for embedding when native + Ollama isn't installed. +3. **A remote endpoint**, always available as a third option — including + an existing Ollama, TEI, or Gotenberg instance running elsewhere (the + owner's own Tailscale-tunneled Ollama box is a first-class example, not + a fallback of last resort). + +Two things degrade on purpose rather than failing loudly, and both are +worth knowing before you rely on either: + +- **No embedding configured (`EMBED_BASE_URL` unset):** memory tools reply + with a "not set up" note; search finds nothing. Setting + `EMBED_BASE_URL` later does **not** retroactively embed anything written + while it was unset — migrations create the memory plane's tables either + way, but there is no automatic backfill. +- **No reranker configured (`RERANK_BASE_URL`/`RERANK_MODEL` unset, or a + configured reranker failing at request time):** search still works, + just ordered by vector/full-text fusion alone rather than a + cross-encoder pass — a reranker outage degrades search quietly rather + than breaking it. Setting only one of `RERANK_BASE_URL`/`RERANK_MODEL` + is a boot-time error, not a silently half-enabled reranker. ## Isolated capacity (exclusive per-workbench sidecars) diff --git a/package.json b/package.json index 45447ddab..dc112ab71 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "test:e2e": "bun test scripts/e2e", "dev": "bun run scripts/dev.ts", "setup": "bun packages/cli/src/index.ts setup", + "setup:memory": "bun run scripts/setup-memory.ts", "seed": "bun packages/cli/src/index.ts seed", "reset": "bun packages/cli/src/index.ts reset", "check:deletion": "bun run scripts/checks/deletion.ts", diff --git a/scripts/setup-memory.test.ts b/scripts/setup-memory.test.ts new file mode 100644 index 000000000..5cbba8d87 --- /dev/null +++ b/scripts/setup-memory.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, test } from "bun:test"; +import { planEmbedding, planRerank } from "./setup-memory"; + +describe("planEmbedding", () => { + test("recommends native Ollama first when it's on PATH", () => { + const plan = planEmbedding({ hasNativeOllama: true, hasDocker: true }); + expect(plan.strategy).toBe("native-ollama"); + expect(plan.env["EMBED_BASE_URL"]).toBe("http://localhost:11434"); + expect(plan.env["EMBED_API_STYLE"]).toBe("ollama"); + }); + + test("falls back to a Dockerized Ollama when native isn't available but Docker is", () => { + const plan = planEmbedding({ hasNativeOllama: false, hasDocker: true }); + expect(plan.strategy).toBe("docker-ollama"); + expect(plan.env["EMBED_API_STYLE"]).toBe("ollama"); + }); + + test("recommends a remote endpoint when neither native Ollama nor Docker is available", () => { + const plan = planEmbedding({ hasNativeOllama: false, hasDocker: false }); + expect(plan.strategy).toBe("endpoint"); + expect(plan.env).toEqual({}); + expect(plan.instructions.join("\n")).toMatch(/EMBED_BASE_URL/); + }); +}); + +describe("planRerank", () => { + test("recommends the Dockerized TEI reranker when Docker is available", () => { + const plan = planRerank({ hasNativeOllama: true, hasDocker: true }); + expect(plan.strategy).toBe("docker-tei"); + expect(plan.env["RERANK_BASE_URL"]).toBe("http://localhost:8081"); + expect(plan.env["RERANK_MODEL"]).toBe("BAAI/bge-reranker-base"); + }); + + test("recommends a remote endpoint when Docker is unavailable, never a native path", () => { + const plan = planRerank({ hasNativeOllama: true, hasDocker: false }); + expect(plan.strategy).toBe("endpoint"); + expect(plan.env).toEqual({}); + expect(plan.instructions.join("\n")).toMatch(/RERANK_BASE_URL/); + }); + + test("is explicit that skipping reranking still leaves search working", () => { + const plan = planRerank({ hasNativeOllama: false, hasDocker: false }); + expect(plan.instructions.join("\n")).toMatch(/search still works/i); + }); +}); diff --git a/scripts/setup-memory.ts b/scripts/setup-memory.ts new file mode 100644 index 000000000..e1150f301 --- /dev/null +++ b/scripts/setup-memory.ts @@ -0,0 +1,187 @@ +// bun run setup:memory — recommends how to turn on the memory plane +// (embeddings-backed recall) and its optional reranker for this checkout. +// Advisory only: it probes what's on this machine and prints the env lines +// and commands to run, in the order the platform actually prefers them — +// native first, Docker for the pieces with no good native story, a remote +// endpoint (including an existing Ollama/TEI instance elsewhere) always +// available as the third option. It never runs Docker or installs +// anything itself; `bun run dev` already refuses nothing you don't put in +// `.env` yourself. +// +// Embedding is the hard requirement: memory_search/memory_add/memory_list +// answer with a plain "memory isn't set up on this server yet" note +// without it (apps/hub/src/memory-mount.ts), and even once it's on, +// rows written before EMBED_BASE_URL was set are NOT retroactively +// embedded — migrations create the tables either way, but there is no +// automatic backfill. Reranking is a pure enhancement: search works +// without it, just less well-ordered, and a reranker outage degrades +// search quietly rather than breaking it. + +export type CapabilityProbe = { + readonly hasNativeOllama: boolean; + readonly hasDocker: boolean; +}; + +export type SetupPlan = { + readonly strategy: string; + readonly env: Record; + readonly instructions: readonly string[]; +}; + +export function planEmbedding(probe: CapabilityProbe): SetupPlan { + if (probe.hasNativeOllama) { + return { + strategy: "native-ollama", + env: { + EMBED_BASE_URL: "http://localhost:11434", + EMBED_MODEL: "nomic-embed-text", + EMBED_API_STYLE: "ollama", + }, + instructions: [ + "Native Ollama found on this machine — use it directly, no container needed.", + " ollama pull nomic-embed-text", + " ollama serve # if it isn't already running", + "Then add to .env:", + " EMBED_BASE_URL=http://localhost:11434", + " EMBED_MODEL=nomic-embed-text", + " EMBED_API_STYLE=ollama", + ], + }; + } + if (probe.hasDocker) { + return { + strategy: "docker-ollama", + env: { + EMBED_BASE_URL: "http://localhost:11434", + EMBED_MODEL: "nomic-embed-text", + EMBED_API_STYLE: "ollama", + }, + instructions: [ + "No native Ollama on PATH, but Docker is available — brew install ollama is", + "still the preferred path (no container to keep running); this is the fallback:", + " docker run -d --name workbench-ollama -p 11434:11434 ollama/ollama", + " docker exec workbench-ollama ollama pull nomic-embed-text", + "Then add to .env:", + " EMBED_BASE_URL=http://localhost:11434", + " EMBED_MODEL=nomic-embed-text", + " EMBED_API_STYLE=ollama", + ], + }; + } + return { + strategy: "endpoint", + env: {}, + instructions: [ + "Neither a native Ollama nor Docker was found on this machine.", + "Point EMBED_BASE_URL at an existing endpoint instead — your own remote", + "Ollama instance, a managed embedding API, or a Text Embeddings Inference", + "server running elsewhere. Add to .env:", + " EMBED_BASE_URL=", + " EMBED_MODEL=", + " EMBED_API_STYLE=", + " EMBED_API_KEY=", + "See .env.example for worked examples of each.", + ], + }; +} + +export function planRerank(probe: CapabilityProbe): SetupPlan { + // No native story here on purpose: the reranker is a cross-encoder + // server (Text Embeddings Inference), not something brew ships. Docker + // is the preferred local path when embedding didn't already need it. + if (probe.hasDocker) { + return { + strategy: "docker-tei", + env: { + RERANK_BASE_URL: "http://localhost:8081", + RERANK_MODEL: "BAAI/bge-reranker-base", + }, + instructions: [ + "Docker is available — run a Text Embeddings Inference reranker:", + " docker run -d --name workbench-reranker -p 8081:80 \\", + " ghcr.io/huggingface/text-embeddings-inference:cpu-latest \\", + " --model-id BAAI/bge-reranker-base", + "Then add to .env:", + " RERANK_BASE_URL=http://localhost:8081", + " RERANK_MODEL=BAAI/bge-reranker-base", + "Optional — leaving both unset skips reranking; search still works,", + "just ordered by vector/full-text fusion alone rather than a cross-encoder pass.", + ], + }; + } + return { + strategy: "endpoint", + env: {}, + instructions: [ + "No Docker found for a local reranker (there's no native install path for one).", + "Point RERANK_BASE_URL at a Text-Embeddings-Inference-compatible endpoint", + "running elsewhere instead. Add to .env:", + " RERANK_BASE_URL=", + " RERANK_MODEL=", + " RERANK_API_KEY=", + "Optional — leaving both unset skips reranking; search still works,", + "just ordered by vector/full-text fusion alone rather than a cross-encoder pass.", + ], + }; +} + +// `docker info` against an unreachable or slow-to-wake daemon (a stopped +// Docker Desktop, a misconfigured remote context) can hang far longer than +// a setup script should ever block for, so this probe is bounded by a +// short timeout rather than trusted to return promptly on its own — a +// probe that can hang is worse than one that under-detects Docker. +const DOCKER_PROBE_TIMEOUT_MS = 3000; + +async function dockerIsReachable(): Promise { + if (Bun.which("docker") === null) return false; + try { + const proc = Bun.spawn(["docker", "info"], { + stdout: "ignore", + stderr: "ignore", + signal: AbortSignal.timeout(DOCKER_PROBE_TIMEOUT_MS), + }); + const exitCode = await proc.exited; + return exitCode === 0; + } catch { + return false; + } +} + +async function probeCapabilities(): Promise { + const hasNativeOllama = Bun.which("ollama") !== null; + const hasDocker = await dockerIsReachable(); + return { hasNativeOllama, hasDocker }; +} + +function printPlan(title: string, plan: SetupPlan): void { + console.log(`\n${title} (${plan.strategy}):`); + for (const line of plan.instructions) console.log(` ${line}`); +} + +async function main(): Promise { + const probe = await probeCapabilities(); + console.log("Memory plane setup recommendation for this machine:"); + console.log( + ` native Ollama: ${probe.hasNativeOllama ? "found" : "not found"}`, + ); + console.log(` Docker: ${probe.hasDocker ? "available" : "not available"}`); + + printPlan( + "Embedding (required for memory search to find anything)", + planEmbedding(probe), + ); + printPlan( + "Reranking (optional — improves result ordering)", + planRerank(probe), + ); + + console.log( + "\nAfter editing .env, restart `bun run dev` — it applies memory's migrations\n" + + "automatically. If you add EMBED_BASE_URL after rows already exist, those\n" + + "existing rows are NOT retroactively embedded; only new writes get embedded.", + ); +} + +if (import.meta.main) { + await main(); +}