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
29 changes: 19 additions & 10 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
12 changes: 7 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
110 changes: 106 additions & 4 deletions apps/hub/src/memory-mount.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,11 @@ import { afterAll, afterEach, describe, expect, test } from "bun:test";
import { Hono } from "hono";
import { createInMemoryGrantStore } from "@intx/authz";

import { mountMemory } from "./memory-mount";
import {
applyResolvedEmbedToProcessEnv,
mountMemory,
resolveMemoryEmbed,
} from "./memory-mount";

const KEYS = [
"DATABASE_URL",
Expand All @@ -12,6 +16,7 @@ const KEYS = [
"EMBED_API_KEY",
"RERANK_BASE_URL",
"RERANK_MODEL",
"OLLAMA_BASE_URL",
] as const;

type EnvKey = (typeof KEYS)[number];
Expand Down Expand Up @@ -43,8 +48,89 @@ 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();
});

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", () => {
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();
Expand All @@ -56,7 +142,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();
Expand All @@ -67,7 +153,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 () => {
Expand Down Expand Up @@ -169,4 +255,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");
});
});
106 changes: 93 additions & 13 deletions apps/hub/src/memory-mount.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -101,14 +103,81 @@ 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<string, string | undefined>,
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<string, string | undefined>,
): 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",
};
}

export function applyResolvedEmbedToProcessEnv(
resolved: ResolvedMemoryEmbed,
): void {
if (process.env["EMBED_BASE_URL"] === undefined) {
process.env["EMBED_BASE_URL"] = resolved.embedBaseUrl;
}
// 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 (nonemptyEnv(process.env, "EMBED_API_STYLE") === undefined) {
process.env["EMBED_API_STYLE"] = resolved.embedApiStyle;
}
}

export type MountMemoryOptions<E extends object = object> = {
/** Hub Hono app (routes register under tenant memory paths). */
app: Hono<E>;
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;
};
Expand All @@ -119,20 +188,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<E extends object = object>(
options: MountMemoryOptions<E>,
): Promise<MemoryMountHandle | undefined> {
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();
Expand Down
1 change: 1 addition & 0 deletions apps/hub/src/memory-workflow-routes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ const KEYS = [
"EMBED_MODEL",
"EMBED_API_STYLE",
"EMBED_API_KEY",
"OLLAMA_BASE_URL",
] as const;

type EnvKey = (typeof KEYS)[number];
Expand Down
16 changes: 10 additions & 6 deletions docs/local-dev.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.
Expand Down
Loading
Loading