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
43 changes: 34 additions & 9 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -211,19 +220,35 @@ 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.
# RERANK_MAX_DOC_CHARS=
# 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
Expand Down
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
34 changes: 34 additions & 0 deletions apps/hub/src/memory-mount.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Expand Down Expand Up @@ -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
Expand Down
68 changes: 55 additions & 13 deletions apps/hub/src/memory-mount.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, string | undefined>,
): 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<string, string> {
// 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<string, string> = {};
for (const key of keys) {
const value = env[key];
if (value !== undefined) input[key] = value;
}
return input;
}

function parseMemoryMountEnv(
env: Record<string, string | undefined>,
): 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<E extends object = object> = {
Expand All @@ -84,7 +125,8 @@ export async function mountMemory<E extends object = object>(
options: MountMemoryOptions<E>,
): Promise<MemoryMountHandle | undefined> {
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");
Expand Down
36 changes: 35 additions & 1 deletion docs/local-dev.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
45 changes: 45 additions & 0 deletions scripts/setup-memory.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading
Loading