From 32c38e8dfbfe698f5ca17430a25cd95f5753038f Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Fri, 28 Aug 2026 09:01:47 -0700 Subject: [PATCH 1/2] Mount memory in local dev from Ollama embed path Hub mounts @corbits/memory when EMBED_BASE_URL or OLLAMA_BASE_URL is set. Scope stays tenant+principal. memory-hub stays as sidecar-auth adapter. https://linear.app/abklabs/issue/CL-7171 --- .env.example | 29 ++++-- README.md | 12 ++- apps/hub/src/memory-mount.test.ts | 63 +++++++++++- apps/hub/src/memory-mount.ts | 98 ++++++++++++++++--- apps/hub/src/memory-workflow-routes.test.ts | 1 + docs/local-dev.md | 16 +-- packages/memory-hub/README.md | 3 +- .../memory-hub/src/workflow-routes.test.ts | 44 ++++++++- packages/memory-hub/src/workflow-routes.ts | 4 +- packages/memory-tools/README.md | 7 +- scripts/dev.ts | 12 ++- scripts/setup-memory.test.ts | 67 ++++++++++++- scripts/setup-memory.ts | 79 +++++++++++++-- 13 files changed, 380 insertions(+), 55 deletions(-) diff --git a/.env.example b/.env.example index 589b5280c..3b7884eb9 100644 --- a/.env.example +++ b/.env.example @@ -111,6 +111,12 @@ HUB_STATIC_DIR=../web/dist # DEEPSEEK_API_KEY= # MISTRAL_API_KEY= # HUGGINGFACE_API_KEY= +# +# Local (or tailscale-tunneled) Ollama origin. Optional: auto-plants a +# probed catalog credential (no key required) on the operator bench at +# hub start, and mounts the `@corbits/memory` plane against the same +# origin when EMBED_BASE_URL is unset (`nomic-embed-text`, ollama style). +# OLLAMA_BASE_URL=http://localhost:11434 # Set to 1 to make `workbench seed` also deploy the zero-cost # catalog-test workflows (heartbeat, channel-digest), which exist only @@ -186,17 +192,20 @@ HUB_STATIC_DIR=../web/dist # 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. +# actually use) instead of hand-picking the block below. That command +# writes missing EMBED_* keys into .env when a local embed path exists. +# `bun run dev` also mounts the plane from OLLAMA_BASE_URL, or from native +# Ollama on PATH, so a local embed path does not leave memory dark. # -# 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. +# Leave EMBED_BASE_URL and OLLAMA_BASE_URL unset (and no native Ollama) 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 diff --git a/README.md b/README.md index 62007603f..5467ecc0b 100644 --- a/README.md +++ b/README.md @@ -47,11 +47,13 @@ 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 +whichever this machine can actually use. When a local embed path exists, +that command writes the missing `EMBED_*` keys into `.env`. `bun run dev` +also mounts `@corbits/memory` when `OLLAMA_BASE_URL` is set, or when +native Ollama is on PATH even if you skipped setup. Without any of those, +memory tools answer "not set up" instead of erroring; rows written before +an embed 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 diff --git a/apps/hub/src/memory-mount.test.ts b/apps/hub/src/memory-mount.test.ts index 57023c1a4..2daa6c2da 100644 --- a/apps/hub/src/memory-mount.test.ts +++ b/apps/hub/src/memory-mount.test.ts @@ -2,7 +2,7 @@ import { afterAll, afterEach, describe, expect, test } from "bun:test"; import { Hono } from "hono"; import { createInMemoryGrantStore } from "@intx/authz"; -import { mountMemory } from "./memory-mount"; +import { mountMemory, resolveMemoryEmbed } from "./memory-mount"; const KEYS = [ "DATABASE_URL", @@ -12,6 +12,7 @@ const KEYS = [ "EMBED_API_KEY", "RERANK_BASE_URL", "RERANK_MODEL", + "OLLAMA_BASE_URL", ] as const; type EnvKey = (typeof KEYS)[number]; @@ -43,8 +44,46 @@ function stashEnv(): void { } } +describe("resolveMemoryEmbed", () => { + test("returns undefined when neither EMBED_BASE_URL nor OLLAMA_BASE_URL is set", () => { + expect(resolveMemoryEmbed({})).toBeUndefined(); + }); + + test("explicit EMBED_BASE_URL wins over OLLAMA_BASE_URL", () => { + expect( + resolveMemoryEmbed({ + EMBED_BASE_URL: "https://api.openai.com/v1", + EMBED_MODEL: "text-embedding-3-small", + OLLAMA_BASE_URL: "http://localhost:11434", + }), + ).toEqual({ + embedBaseUrl: "https://api.openai.com/v1", + embedModel: "text-embedding-3-small", + embedApiStyle: "openai", + source: "EMBED_BASE_URL", + }); + }); + + test("OLLAMA_BASE_URL is a local embed path with nomic-embed-text / ollama style", () => { + expect( + resolveMemoryEmbed({ + OLLAMA_BASE_URL: "http://localhost:11434", + }), + ).toEqual({ + embedBaseUrl: "http://localhost:11434", + embedModel: "nomic-embed-text", + embedApiStyle: "ollama", + source: "OLLAMA_BASE_URL", + }); + }); + + test("treats a blank OLLAMA_BASE_URL as unset", () => { + expect(resolveMemoryEmbed({ OLLAMA_BASE_URL: "" })).toBeUndefined(); + }); +}); + describe("mountMemory", () => { - test("returns undefined when EMBED_BASE_URL is unset (optional)", async () => { + test("returns undefined when EMBED_BASE_URL and OLLAMA_BASE_URL are unset (optional)", async () => { stashEnv(); process.env["DATABASE_URL"] = "postgres://localhost:5432/workbench"; const app = new Hono(); @@ -56,7 +95,7 @@ describe("mountMemory", () => { expect(handle).toBeUndefined(); }); - test("throws when optional is false and EMBED_BASE_URL is missing", async () => { + test("throws when optional is false and EMBED_BASE_URL and OLLAMA_BASE_URL are missing", async () => { stashEnv(); process.env["DATABASE_URL"] = "postgres://localhost:5432/workbench"; const app = new Hono(); @@ -67,7 +106,7 @@ describe("mountMemory", () => { conditionRegistry: {}, optional: false, }), - ).rejects.toThrow(/EMBED_BASE_URL/); + ).rejects.toThrow(/EMBED_BASE_URL or OLLAMA_BASE_URL/); }); test("fails loudly at config parse when EMBED_BASE_URL is set but blank, rather than silently treating it as unset", async () => { @@ -169,4 +208,20 @@ describeIfDb("mountMemory: schema isolation against a real database", () => { await sql.end(); } }); + + test("mounts from OLLAMA_BASE_URL when EMBED_BASE_URL is unset", async () => { + stashEnv(); + process.env["DATABASE_URL"] = databaseUrl; + process.env["OLLAMA_BASE_URL"] = "http://localhost:9"; + + const handle = await mountMemory({ + app: new Hono(), + grantStore: createInMemoryGrantStore([]), + conditionRegistry: {}, + }); + expect(handle).toBeDefined(); + expect(process.env["EMBED_BASE_URL"]).toBe("http://localhost:9"); + expect(process.env["EMBED_MODEL"]).toBe("nomic-embed-text"); + expect(process.env["EMBED_API_STYLE"]).toBe("ollama"); + }); }); diff --git a/apps/hub/src/memory-mount.ts b/apps/hub/src/memory-mount.ts index 0506ee0eb..b21c2f6fb 100644 --- a/apps/hub/src/memory-mount.ts +++ b/apps/hub/src/memory-mount.ts @@ -8,11 +8,13 @@ * `DATABASE_URL`, and `@corbits/memory`'s own `loadMemoryConfig()` reads it * directly, so this module just calls it. * - * Degrades cleanly when unconfigured: missing `EMBED_BASE_URL` means "no - * memory plane", logged once at boot, never thrown — same optional-engine - * contract as the artifacts mount. When `EMBED_BASE_URL` is present, boot - * fails loudly if migrate/create throws so a half-wired deploy is never - * silent. + * Embed resolution: explicit `EMBED_BASE_URL` wins. Otherwise a configured + * `OLLAMA_BASE_URL` is a local embed path (same origin the hub already + * plants as an inference credential), so local/dev does not leave the plane + * dark when Ollama is already wired. Missing both means "no memory plane", + * logged once at boot, never thrown — same optional-engine contract as the + * artifacts mount. When an embed URL is present, boot fails loudly if + * migrate/create throws so a half-wired deploy is never silent. * * This module lands the mount + factory only. Capture/ingestion glue is a * later ticket; agents that want firm memory ask the returned handle. @@ -101,14 +103,73 @@ function parseMemoryMountEnv( return parsed; } +const LOCAL_OLLAMA_EMBED_MODEL = "nomic-embed-text"; +const LOCAL_OLLAMA_EMBED_STYLE = "ollama"; + +export type ResolvedMemoryEmbed = { + readonly embedBaseUrl: string; + readonly embedModel: string; + readonly embedApiStyle: string; + readonly source: "EMBED_BASE_URL" | "OLLAMA_BASE_URL"; +}; + +function nonemptyEnv( + env: Record, + key: string, +): string | undefined { + const value = env[key]; + if (value === undefined || value.trim() === "") return undefined; + return value; +} + +/** + * Explicit `EMBED_BASE_URL` wins. Otherwise `OLLAMA_BASE_URL` is the local + * embed path — nomic-embed-text / ollama style, matching `setup:memory`. + */ +export function resolveMemoryEmbed( + env: Record, +): ResolvedMemoryEmbed | undefined { + const embedBaseUrl = nonemptyEnv(env, "EMBED_BASE_URL"); + if (embedBaseUrl !== undefined) { + return { + embedBaseUrl, + embedModel: nonemptyEnv(env, "EMBED_MODEL") ?? "", + embedApiStyle: nonemptyEnv(env, "EMBED_API_STYLE") ?? "openai", + source: "EMBED_BASE_URL", + }; + } + const ollamaBaseUrl = nonemptyEnv(env, "OLLAMA_BASE_URL"); + if (ollamaBaseUrl === undefined) return undefined; + return { + embedBaseUrl: ollamaBaseUrl, + embedModel: nonemptyEnv(env, "EMBED_MODEL") ?? LOCAL_OLLAMA_EMBED_MODEL, + embedApiStyle: + nonemptyEnv(env, "EMBED_API_STYLE") ?? LOCAL_OLLAMA_EMBED_STYLE, + source: "OLLAMA_BASE_URL", + }; +} + +function applyResolvedEmbedToProcessEnv(resolved: ResolvedMemoryEmbed): void { + if (process.env["EMBED_BASE_URL"] === undefined) { + process.env["EMBED_BASE_URL"] = resolved.embedBaseUrl; + } + if (process.env["EMBED_MODEL"] === undefined && resolved.embedModel !== "") { + process.env["EMBED_MODEL"] = resolved.embedModel; + } + if (process.env["EMBED_API_STYLE"] === undefined) { + process.env["EMBED_API_STYLE"] = resolved.embedApiStyle; + } +} + export type MountMemoryOptions = { /** Hub Hono app (routes register under tenant memory paths). */ app: Hono; grantStore: GrantStore; conditionRegistry: ConditionRegistry; /** - * When true (default), skip mount if `EMBED_BASE_URL` is unset. Tests can - * force a mount attempt by setting env + `optional: false`. + * When true (default), skip mount if neither `EMBED_BASE_URL` nor + * `OLLAMA_BASE_URL` is set. Tests can force a mount attempt by setting + * env + `optional: false`. */ optional?: boolean; }; @@ -119,20 +180,31 @@ export type MemoryMountHandle = { /** * Returns a memory handle when the plane is configured and mounted; - * `undefined` when optional and `EMBED_BASE_URL` is absent. + * `undefined` when optional and neither `EMBED_BASE_URL` nor + * `OLLAMA_BASE_URL` is present. */ export async function mountMemory( options: MountMemoryOptions, ): Promise { const optional = options.optional !== false; - const parsedEnv = parseMemoryMountEnv(process.env); - const embedBaseUrl = parsedEnv.EMBED_BASE_URL; - if (embedBaseUrl === undefined) { + parseMemoryMountEnv(process.env); + const resolved = resolveMemoryEmbed(process.env); + if (resolved === undefined) { if (optional) { - log.info("EMBED_BASE_URL not set — memory plane will not be mounted"); + log.info( + "EMBED_BASE_URL / OLLAMA_BASE_URL not set — memory plane will not be mounted", + ); return undefined; } - throw new Error("EMBED_BASE_URL is required to mount memory"); + throw new Error( + "EMBED_BASE_URL or OLLAMA_BASE_URL is required to mount memory", + ); + } + applyResolvedEmbedToProcessEnv(resolved); + if (resolved.source === "OLLAMA_BASE_URL") { + log.info( + `OLLAMA_BASE_URL set — mounting memory plane with embed ${resolved.embedBaseUrl} (${resolved.embedModel})`, + ); } const config = loadMemoryConfig(); diff --git a/apps/hub/src/memory-workflow-routes.test.ts b/apps/hub/src/memory-workflow-routes.test.ts index 6fbf75d08..bda3dcb8b 100644 --- a/apps/hub/src/memory-workflow-routes.test.ts +++ b/apps/hub/src/memory-workflow-routes.test.ts @@ -36,6 +36,7 @@ const KEYS = [ "EMBED_MODEL", "EMBED_API_STYLE", "EMBED_API_KEY", + "OLLAMA_BASE_URL", ] as const; type EnvKey = (typeof KEYS)[number]; diff --git a/docs/local-dev.md b/docs/local-dev.md index 33fb7c477..47d9941f7 100644 --- a/docs/local-dev.md +++ b/docs/local-dev.md @@ -51,16 +51,19 @@ exactly this reason. ## Memory plane -The memory plane (embeddings-backed recall) needs `EMBED_BASE_URL` set; -without it, `apps/hub/src/memory-mount.ts` skips mounting the memory plane +The memory plane (embeddings-backed recall) is `@corbits/memory`, mounted +by `apps/hub/src/memory-mount.ts`. An explicit `EMBED_BASE_URL` wins; +otherwise `OLLAMA_BASE_URL` is a local embed path, and `bun run dev` +injects the native-Ollama embed env when Ollama is on PATH and neither +variable is set. Without any of those, the hub skips mounting the plane 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: +Docker, prints the exact env lines and commands, and writes missing +`EMBED_*` keys into `.env` when a local embed path exists: 1. **Native first.** A local `ollama pull nomic-embed-text` needs no container and is the preferred embedding path. @@ -77,8 +80,9 @@ in the order the platform prefers them: 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 +- **No embedding configured (`EMBED_BASE_URL` and `OLLAMA_BASE_URL` + unset, and no native Ollama for `bun run dev` to inject):** 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. diff --git a/packages/memory-hub/README.md b/packages/memory-hub/README.md index dc26a2c6d..4ab622ba3 100644 --- a/packages/memory-hub/README.md +++ b/packages/memory-hub/README.md @@ -35,7 +35,8 @@ or `principalId` in a body is parsed and then explicitly discarded, not forwarded to the plane. `createUnavailableWorkflowMemoryRoutes` answers `503` on every route -when the memory plane isn't mounted (no `EMBED_BASE_URL`). +when the memory plane isn't mounted (no `EMBED_BASE_URL` / +`OLLAMA_BASE_URL`, and no native-Ollama inject from `bun run dev`). ## Tests diff --git a/packages/memory-hub/src/workflow-routes.test.ts b/packages/memory-hub/src/workflow-routes.test.ts index e624557d6..46a7980f8 100644 --- a/packages/memory-hub/src/workflow-routes.test.ts +++ b/packages/memory-hub/src/workflow-routes.test.ts @@ -1,10 +1,11 @@ import { describe, expect, test } from "bun:test"; - +import { createFakeDocumentStore, createMemory } from "@corbits/memory"; import type { ResolvedWorkflowRunScope } from "@corbits/artifacts-hub"; import { createUnavailableWorkflowMemoryRoutes, createWorkflowMemoryRoutes, + createWorkflowMemoryStore, type WorkflowMemoryRoutesStore, } from "./workflow-routes"; @@ -298,3 +299,44 @@ describe("createUnavailableWorkflowMemoryRoutes", () => { expect(list.status).toBe(503); }); }); + +describe("createWorkflowMemoryStore over the published @corbits/memory plane", () => { + test("add/search/list go through createMemory + createFakeDocumentStore, scoped to tenant+principal", async () => { + const memory = createMemory({ + documentStore: createFakeDocumentStore(), + }); + try { + const store = createWorkflowMemoryStore(memory); + const added = await store.add(SCOPE, { + title: "Decision A", + text: "Tenant one shipped memory tools.", + }); + expect(added.documentId).toMatch(/^fake_doc_/); + await store.add(OTHER_SCOPE, { + title: "Decision B", + text: "Tenant two shipped memory tools.", + }); + const found = await store.search(SCOPE, { + query: "shipped memory tools", + }); + expect(found.items.map((item) => item.title)).toEqual(["Decision A"]); + const other = await store.search(OTHER_SCOPE, { + query: "shipped memory tools", + }); + expect(other.items.map((item) => item.title)).toEqual(["Decision B"]); + const listed = await store.list(SCOPE, 8); + expect(listed.map((event) => event.title)).toEqual(["Decision A"]); + } finally { + await memory.close(); + } + }); + + test("pins the published github @corbits/memory package, not a workbench-local store", async () => { + const pkg = (await Bun.file( + new URL("../package.json", import.meta.url), + ).json()) as { dependencies: Record }; + expect(pkg.dependencies["@corbits/memory"]).toMatch( + /^github:corbitsdev\/corbits-memory#/, + ); + }); +}); diff --git a/packages/memory-hub/src/workflow-routes.ts b/packages/memory-hub/src/workflow-routes.ts index 88a84b193..4c6948823 100644 --- a/packages/memory-hub/src/workflow-routes.ts +++ b/packages/memory-hub/src/workflow-routes.ts @@ -304,8 +304,8 @@ export function createWorkflowMemoryRoutes( /** * Honest degraded surface when the memory plane is not mounted (no - * `EMBED_BASE_URL`, see `apps/hub/src/memory-mount.ts`) — same - * convention as `createUnavailableWorkflowArtifactRoutes`. + * `EMBED_BASE_URL` / `OLLAMA_BASE_URL`, see `apps/hub/src/memory-mount.ts`) + * — same convention as `createUnavailableWorkflowArtifactRoutes`. */ export function createUnavailableWorkflowMemoryRoutes(): Hono { const app = new Hono(); diff --git a/packages/memory-tools/README.md b/packages/memory-tools/README.md index ce1561e8a..ff4d0250f 100644 --- a/packages/memory-tools/README.md +++ b/packages/memory-tools/README.md @@ -39,9 +39,10 @@ with `isError: true` — never fabricate a memory or a search result. ## When the memory plane isn't configured (CL-6168) `apps/hub/src/memory-mount.ts` decides whether the memory plane is -mounted at hub boot, from its own `EMBED_BASE_URL` config parse — never -by making a call and seeing what happens. An unmounted plane is reflected -two ways, both driven by that one boot-time decision: +mounted at hub boot — from `EMBED_BASE_URL`, `OLLAMA_BASE_URL`, or the +native-Ollama inject `bun run dev` applies — never by making a call and +seeing what happens. An unmounted plane is reflected two ways, both +driven by that one boot-time decision: - The hub's tool inventory (`listMyraUsableToolPackages` in `apps/hub/src/index.ts`) simply never offers `@corbits/memory-tools` to diff --git a/scripts/dev.ts b/scripts/dev.ts index 63fc1c654..b3ca7cf84 100644 --- a/scripts/dev.ts +++ b/scripts/dev.ts @@ -8,6 +8,7 @@ import { createConnection } from "node:net"; import { join, resolve } from "node:path"; import { readHubConfig, type HubConfig } from "../apps/hub/src/config.ts"; import { ensureSidecarIdentity, setupDatabase } from "./db-setup.ts"; +import { localDevMemoryEmbedEnv } from "./setup-memory.ts"; const repoRoot = resolve(import.meta.dir, ".."); @@ -170,8 +171,17 @@ const hubApp: App = { // mode, open it so seedDevAccount can create alice. Production hub // still defaults closed when this script is not the launcher. // Explicit WORKBENCH_SIGNUP in .env always wins (bun loads env-file). +function mergeHubEnv(extra: Record): void { + hubApp.env = { ...hubApp.env, ...extra }; +} if (process.env["WORKBENCH_SIGNUP"] === undefined) { - hubApp.env = { WORKBENCH_SIGNUP: "open" }; + mergeHubEnv({ WORKBENCH_SIGNUP: "open" }); +} +const localMemoryEmbed = localDevMemoryEmbedEnv(process.env, { + hasNativeOllama: Bun.which("ollama") !== null, +}); +if (localMemoryEmbed !== undefined) { + mergeHubEnv(localMemoryEmbed); } const apps: App[] = [ diff --git a/scripts/setup-memory.test.ts b/scripts/setup-memory.test.ts index 5cbba8d87..b5c612f21 100644 --- a/scripts/setup-memory.test.ts +++ b/scripts/setup-memory.test.ts @@ -1,5 +1,11 @@ import { describe, expect, test } from "bun:test"; -import { planEmbedding, planRerank } from "./setup-memory"; +import { + applyEnvKeysToDotenvContents, + dotenvHasActiveKey, + localDevMemoryEmbedEnv, + planEmbedding, + planRerank, +} from "./setup-memory"; describe("planEmbedding", () => { test("recommends native Ollama first when it's on PATH", () => { @@ -43,3 +49,62 @@ describe("planRerank", () => { expect(plan.instructions.join("\n")).toMatch(/search still works/i); }); }); + +describe("localDevMemoryEmbedEnv", () => { + test("injects native Ollama embed env when nothing is configured and ollama is on PATH", () => { + expect(localDevMemoryEmbedEnv({}, { hasNativeOllama: true })).toEqual({ + EMBED_BASE_URL: "http://localhost:11434", + EMBED_MODEL: "nomic-embed-text", + EMBED_API_STYLE: "ollama", + }); + }); + + test("does not inject when EMBED_BASE_URL is already set", () => { + expect( + localDevMemoryEmbedEnv( + { EMBED_BASE_URL: "https://api.openai.com/v1" }, + { hasNativeOllama: true }, + ), + ).toBeUndefined(); + }); + + test("does not inject when OLLAMA_BASE_URL is already set — mountMemory uses it", () => { + expect( + localDevMemoryEmbedEnv( + { OLLAMA_BASE_URL: "http://localhost:11434" }, + { hasNativeOllama: true }, + ), + ).toBeUndefined(); + }); + + test("does not inject when native Ollama is absent", () => { + expect( + localDevMemoryEmbedEnv({}, { hasNativeOllama: false }), + ).toBeUndefined(); + }); +}); + +describe("applyEnvKeysToDotenvContents", () => { + test("appends missing keys and ignores commented-out lines", () => { + const existing = + "# EMBED_BASE_URL=http://example\nDATABASE_URL=postgres://x\n"; + const { next, added } = applyEnvKeysToDotenvContents(existing, { + EMBED_BASE_URL: "http://localhost:11434", + EMBED_MODEL: "nomic-embed-text", + }); + expect(added).toEqual(["EMBED_BASE_URL", "EMBED_MODEL"]); + expect(next).toContain("EMBED_BASE_URL=http://localhost:11434"); + expect(dotenvHasActiveKey(next, "EMBED_BASE_URL")).toBe(true); + }); + + test("does not overwrite an active key", () => { + const existing = "EMBED_BASE_URL=https://api.openai.com/v1\n"; + const { next, added } = applyEnvKeysToDotenvContents(existing, { + EMBED_BASE_URL: "http://localhost:11434", + EMBED_MODEL: "nomic-embed-text", + }); + expect(added).toEqual(["EMBED_MODEL"]); + expect(next).toContain("EMBED_BASE_URL=https://api.openai.com/v1"); + expect(next).not.toContain("EMBED_BASE_URL=http://localhost:11434"); + }); +}); diff --git a/scripts/setup-memory.ts b/scripts/setup-memory.ts index e1150f301..e9c3a9c72 100644 --- a/scripts/setup-memory.ts +++ b/scripts/setup-memory.ts @@ -1,12 +1,9 @@ // 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. +// Advisory for reranking and remote endpoints; for a local embed path it +// also writes missing EMBED_* keys into `.env` so `bun run dev` actually +// mounts `@corbits/memory` instead of leaving the plane dark. It never +// runs Docker or installs anything itself. // // 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 @@ -16,6 +13,8 @@ // 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. +import { existsSync, readFileSync, writeFileSync } from "node:fs"; +import { join, resolve } from "node:path"; export type CapabilityProbe = { readonly hasNativeOllama: boolean; @@ -125,6 +124,48 @@ export function planRerank(probe: CapabilityProbe): SetupPlan { }; } +function nonempty(value: string | undefined): string | undefined { + if (value === undefined || value.trim() === "") return undefined; + return value; +} + +/** + * Env `bun run dev` should inject into the hub process when the operator + * has not set `EMBED_BASE_URL` / `OLLAMA_BASE_URL` but native Ollama is + * on PATH — a local embed path exists, so the plane should not stay dark. + * `OLLAMA_BASE_URL` is left to `mountMemory` (it is already a configured + * embed origin). + */ +export function localDevMemoryEmbedEnv( + env: Record, + probe: Pick, +): Record | undefined { + if (nonempty(env["EMBED_BASE_URL"]) !== undefined) return undefined; + if (nonempty(env["OLLAMA_BASE_URL"]) !== undefined) return undefined; + if (!probe.hasNativeOllama) return undefined; + return planEmbedding({ hasNativeOllama: true, hasDocker: false }).env; +} + +export function dotenvHasActiveKey(text: string, key: string): boolean { + const escaped = key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + return new RegExp(`^${escaped}=`, "m").test(text); +} + +export function applyEnvKeysToDotenvContents( + existing: string, + keys: Record, +): { next: string; added: readonly string[] } { + const added: string[] = []; + let next = existing; + for (const [key, value] of Object.entries(keys)) { + if (dotenvHasActiveKey(next, key)) continue; + added.push(key); + if (next !== "" && !next.endsWith("\n")) next += "\n"; + next += `${key}=${value}\n`; + } + return { next, added }; +} + // `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 @@ -166,15 +207,37 @@ async function main(): Promise { ); console.log(` Docker: ${probe.hasDocker ? "available" : "not available"}`); + const embedPlan = planEmbedding(probe); printPlan( "Embedding (required for memory search to find anything)", - planEmbedding(probe), + embedPlan, ); printPlan( "Reranking (optional — improves result ordering)", planRerank(probe), ); + const envPath = join(resolve(import.meta.dir, ".."), ".env"); + if (Object.keys(embedPlan.env).length > 0) { + if (!existsSync(envPath)) { + console.log( + "\nNo .env yet — cp .env.example .env, then re-run to write the embedding keys.", + ); + } else { + const current = readFileSync(envPath, "utf8"); + const { next, added } = applyEnvKeysToDotenvContents( + current, + embedPlan.env, + ); + if (added.length > 0) { + writeFileSync(envPath, next); + console.log(`\nWrote ${added.join(", ")} to .env`); + } else { + console.log("\n.env already has embedding keys — left unchanged."); + } + } + } + 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" + From de33058ccbbf09a13ef021abb4387e9f38704947 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Fri, 28 Aug 2026 09:12:57 -0700 Subject: [PATCH 2/2] Preserve tunneled Ollama when planting memory embed env setup:memory was appending localhost EMBED_BASE_URL even when .env already had OLLAMA_BASE_URL, which then won at mount. Blank EMBED_MODEL / EMBED_API_STYLE were treated as set, so Ollama defaults never applied. --- apps/hub/src/memory-mount.test.ts | 49 ++++++++++++++++++++++++++++++- apps/hub/src/memory-mount.ts | 14 +++++++-- scripts/setup-memory.test.ts | 37 +++++++++++++++++++++++ scripts/setup-memory.ts | 38 +++++++++++++++++++++++- 4 files changed, 133 insertions(+), 5 deletions(-) diff --git a/apps/hub/src/memory-mount.test.ts b/apps/hub/src/memory-mount.test.ts index 2daa6c2da..c849f3b38 100644 --- a/apps/hub/src/memory-mount.test.ts +++ b/apps/hub/src/memory-mount.test.ts @@ -2,7 +2,11 @@ import { afterAll, afterEach, describe, expect, test } from "bun:test"; import { Hono } from "hono"; import { createInMemoryGrantStore } from "@intx/authz"; -import { mountMemory, resolveMemoryEmbed } from "./memory-mount"; +import { + applyResolvedEmbedToProcessEnv, + mountMemory, + resolveMemoryEmbed, +} from "./memory-mount"; const KEYS = [ "DATABASE_URL", @@ -80,6 +84,49 @@ describe("resolveMemoryEmbed", () => { test("treats a blank OLLAMA_BASE_URL as unset", () => { expect(resolveMemoryEmbed({ OLLAMA_BASE_URL: "" })).toBeUndefined(); }); + + test("blank EMBED_MODEL / EMBED_API_STYLE on OLLAMA path resolve to nomic-embed-text / ollama", () => { + expect( + resolveMemoryEmbed({ + OLLAMA_BASE_URL: "http://localhost:11434", + EMBED_MODEL: "", + EMBED_API_STYLE: " ", + }), + ).toEqual({ + embedBaseUrl: "http://localhost:11434", + embedModel: "nomic-embed-text", + embedApiStyle: "ollama", + source: "OLLAMA_BASE_URL", + }); + }); +}); + +describe("applyResolvedEmbedToProcessEnv", () => { + test("plants OLLAMA defaults when EMBED_MODEL and EMBED_API_STYLE are blank", () => { + stashEnv(); + process.env["OLLAMA_BASE_URL"] = "http://localhost:9"; + process.env["EMBED_MODEL"] = ""; + process.env["EMBED_API_STYLE"] = " "; + const resolved = resolveMemoryEmbed(process.env); + expect(resolved).toBeDefined(); + if (resolved === undefined) return; + applyResolvedEmbedToProcessEnv(resolved); + expect(process.env["EMBED_BASE_URL"]).toBe("http://localhost:9"); + expect(process.env["EMBED_MODEL"]).toBe("nomic-embed-text"); + expect(process.env["EMBED_API_STYLE"]).toBe("ollama"); + }); + + test("does not overwrite a non-blank EMBED_MODEL", () => { + stashEnv(); + process.env["OLLAMA_BASE_URL"] = "http://localhost:9"; + process.env["EMBED_MODEL"] = "custom-embed"; + const resolved = resolveMemoryEmbed(process.env); + expect(resolved).toBeDefined(); + if (resolved === undefined) return; + applyResolvedEmbedToProcessEnv(resolved); + expect(process.env["EMBED_MODEL"]).toBe("custom-embed"); + expect(process.env["EMBED_API_STYLE"]).toBe("ollama"); + }); }); describe("mountMemory", () => { diff --git a/apps/hub/src/memory-mount.ts b/apps/hub/src/memory-mount.ts index b21c2f6fb..46b0681e2 100644 --- a/apps/hub/src/memory-mount.ts +++ b/apps/hub/src/memory-mount.ts @@ -149,14 +149,22 @@ export function resolveMemoryEmbed( }; } -function applyResolvedEmbedToProcessEnv(resolved: ResolvedMemoryEmbed): void { +export function applyResolvedEmbedToProcessEnv( + resolved: ResolvedMemoryEmbed, +): void { if (process.env["EMBED_BASE_URL"] === undefined) { process.env["EMBED_BASE_URL"] = resolved.embedBaseUrl; } - if (process.env["EMBED_MODEL"] === undefined && resolved.embedModel !== "") { + // Blank `EMBED_MODEL=` / `EMBED_API_STYLE=` is unset — same as a missing + // key — so OLLAMA defaults actually land instead of being skipped as + // "already configured". + if ( + nonemptyEnv(process.env, "EMBED_MODEL") === undefined && + resolved.embedModel !== "" + ) { process.env["EMBED_MODEL"] = resolved.embedModel; } - if (process.env["EMBED_API_STYLE"] === undefined) { + if (nonemptyEnv(process.env, "EMBED_API_STYLE") === undefined) { process.env["EMBED_API_STYLE"] = resolved.embedApiStyle; } } diff --git a/scripts/setup-memory.test.ts b/scripts/setup-memory.test.ts index b5c612f21..d91de29a5 100644 --- a/scripts/setup-memory.test.ts +++ b/scripts/setup-memory.test.ts @@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test"; import { applyEnvKeysToDotenvContents, dotenvHasActiveKey, + embedEnvKeysForDotenv, localDevMemoryEmbedEnv, planEmbedding, planRerank, @@ -84,6 +85,42 @@ describe("localDevMemoryEmbedEnv", () => { }); }); +describe("embedEnvKeysForDotenv", () => { + const localEmbed = { + EMBED_BASE_URL: "http://localhost:11434", + EMBED_MODEL: "nomic-embed-text", + EMBED_API_STYLE: "ollama", + }; + + test("omits localhost EMBED_BASE_URL when OLLAMA_BASE_URL is already set", () => { + const existing = "OLLAMA_BASE_URL=https://home-mac.example.ts.net\n"; + const keys = embedEnvKeysForDotenv(existing, localEmbed); + expect(keys["EMBED_BASE_URL"]).toBeUndefined(); + expect(keys["EMBED_MODEL"]).toBe("nomic-embed-text"); + expect(keys["EMBED_API_STYLE"]).toBe("ollama"); + const { next, added } = applyEnvKeysToDotenvContents(existing, keys); + expect(added).not.toContain("EMBED_BASE_URL"); + expect(next).not.toContain("EMBED_BASE_URL=http://localhost:11434"); + expect(next).toContain("OLLAMA_BASE_URL=https://home-mac.example.ts.net"); + }); + + test("still plants localhost EMBED_BASE_URL when OLLAMA_BASE_URL is blank", () => { + const existing = "OLLAMA_BASE_URL=\n"; + const keys = embedEnvKeysForDotenv(existing, localEmbed); + expect(keys["EMBED_BASE_URL"]).toBe("http://localhost:11434"); + }); + + test("leaves keys unchanged when EMBED_BASE_URL is already set", () => { + const existing = + "OLLAMA_BASE_URL=https://home-mac.example.ts.net\nEMBED_BASE_URL=https://api.openai.com/v1\n"; + expect(embedEnvKeysForDotenv(existing, localEmbed)).toEqual(localEmbed); + }); + + test("plants localhost EMBED_BASE_URL when OLLAMA_BASE_URL is absent", () => { + expect(embedEnvKeysForDotenv("", localEmbed)).toEqual(localEmbed); + }); +}); + describe("applyEnvKeysToDotenvContents", () => { test("appends missing keys and ignores commented-out lines", () => { const existing = diff --git a/scripts/setup-memory.ts b/scripts/setup-memory.ts index e9c3a9c72..02f90f010 100644 --- a/scripts/setup-memory.ts +++ b/scripts/setup-memory.ts @@ -151,6 +151,42 @@ export function dotenvHasActiveKey(text: string, key: string): boolean { return new RegExp(`^${escaped}=`, "m").test(text); } +/** Active `KEY=value` in dotenv text; blank and whitespace-only count as unset. */ +export function dotenvNonemptyValue( + text: string, + key: string, +): string | undefined { + const escaped = key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const match = new RegExp(`^${escaped}=(.*)$`, "m").exec(text); + if (match === null) return undefined; + const value = match[1]; + if (value === undefined || value.trim() === "") return undefined; + return value; +} + +/** + * Keys `setup:memory` may append for a local embed path. A non-empty + * `OLLAMA_BASE_URL` already is that path (including a tunneled origin); + * planting localhost `EMBED_BASE_URL` would win at mount and hide it. + */ +export function embedEnvKeysForDotenv( + existing: string, + keys: Record, +): Record { + if ( + dotenvNonemptyValue(existing, "OLLAMA_BASE_URL") === undefined || + dotenvNonemptyValue(existing, "EMBED_BASE_URL") !== undefined + ) { + return keys; + } + const next: Record = {}; + for (const [key, value] of Object.entries(keys)) { + if (key === "EMBED_BASE_URL") continue; + next[key] = value; + } + return next; +} + export function applyEnvKeysToDotenvContents( existing: string, keys: Record, @@ -227,7 +263,7 @@ async function main(): Promise { const current = readFileSync(envPath, "utf8"); const { next, added } = applyEnvKeysToDotenvContents( current, - embedPlan.env, + embedEnvKeysForDotenv(current, embedPlan.env), ); if (added.length > 0) { writeFileSync(envPath, next);