diff --git a/.env.example b/.env.example index ed60bb988..95d6249a1 100644 --- a/.env.example +++ b/.env.example @@ -99,3 +99,11 @@ HUB_STATIC_DIR=../web/dist # callback URL of /api/auth/callback/github. # GITHUB_CLIENT_ID= # GITHUB_CLIENT_SECRET= + +# Optional firm-memory plane (@corbits/memory). Same Postgres cluster is fine; +# schema is `knowledge`. Leave unset to boot without memory. +# KNOWLEDGE_DATABASE_URL=postgres://workbench:workbench@localhost:5432/workbench +# EMBED_BASE_URL=https://api.openai.com/v1 +# EMBED_MODEL=text-embedding-3-small +# EMBED_API_KEY= + diff --git a/apps/hub/package.json b/apps/hub/package.json index 6f1575e88..7dc0f7b33 100644 --- a/apps/hub/package.json +++ b/apps/hub/package.json @@ -17,6 +17,7 @@ "@corbits/chat": "workspace:*", "@corbits/commands": "workspace:*", "@corbits/folded-runs": "workspace:*", + "@corbits/memory": "github:corbitsdev/corbits-memory#800340252dc60393f017fa933c59a1c9e67e7264", "@corbits/routines": "workspace:*", "@corbits/webhook-triggers": "workspace:*", "@intx/authz": "workspace:*", diff --git a/apps/hub/src/index.ts b/apps/hub/src/index.ts index 5478a8ff1..e84cd088b 100644 --- a/apps/hub/src/index.ts +++ b/apps/hub/src/index.ts @@ -57,6 +57,7 @@ import { createNeedsYouRoutes } from "@corbits/approvals"; import { createEchoRoutes } from "@workbench/echo"; import { createGitWorkflowPusher } from "@workbench/hub-client"; import { createOnboardingRoutes } from "@workbench/onboarding"; +import { mountMemory } from "./memory-mount"; import { betterAuth } from "better-auth"; import { drizzleAdapter } from "better-auth/adapters/drizzle"; import { type Context, type Next } from "hono"; @@ -464,6 +465,15 @@ export async function createHub(config: HubConfig) { launcher: routineLauncher, }); + // Memory plane (optional): firm-memory HTTP under + // `/api/tenants/:tenantId/memory/*`. Degrades when + // KNOWLEDGE_DATABASE_URL / EMBED_* are unset — see memory-mount.ts. + mountMemory({ + app, + grantStore: chatGrantStore, + conditionRegistry: chatConditionRegistry, + }); + // The first-login hook mounts outside the tenant prefix, since the // session it serves belongs to no tenant yet. The route is // `@workbench/onboarding`'s; what it decides is documented in that diff --git a/apps/hub/src/memory-mount.test.ts b/apps/hub/src/memory-mount.test.ts new file mode 100644 index 000000000..95cbabefd --- /dev/null +++ b/apps/hub/src/memory-mount.test.ts @@ -0,0 +1,63 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { Hono } from "hono"; + +import { mountMemory } from "./memory-mount"; + +const KEYS = [ + "KNOWLEDGE_DATABASE_URL", + "EMBED_BASE_URL", + "EMBED_MODEL", + "EMBED_API_STYLE", + "EMBED_API_KEY", +] as const; + +type EnvKey = (typeof KEYS)[number]; + +const saved: Partial> = {}; + +function clearEnvKey(key: EnvKey): void { + // Prefer assignment over `delete process.env[key]` — eslint forbids dynamic delete. + process.env[key] = undefined; +} + +afterEach(() => { + for (const key of KEYS) { + const value = saved[key]; + if (value === undefined) clearEnvKey(key); + else process.env[key] = value; + saved[key] = undefined; + } +}); + +function stashEnv(): void { + for (const key of KEYS) { + saved[key] = process.env[key]; + clearEnvKey(key); + } +} + +describe("mountMemory", () => { + test("returns undefined when KNOWLEDGE_DATABASE_URL is unset (optional)", () => { + stashEnv(); + const app = new Hono(); + const handle = mountMemory({ + app, + grantStore: {} as never, + conditionRegistry: {}, + }); + expect(handle).toBeUndefined(); + }); + + test("throws when optional is false and env is missing", () => { + stashEnv(); + const app = new Hono(); + expect(() => + mountMemory({ + app, + grantStore: {} as never, + conditionRegistry: {}, + optional: false, + }), + ).toThrow(/KNOWLEDGE_DATABASE_URL/); + }); +}); diff --git a/apps/hub/src/memory-mount.ts b/apps/hub/src/memory-mount.ts new file mode 100644 index 000000000..f4e480ae1 --- /dev/null +++ b/apps/hub/src/memory-mount.ts @@ -0,0 +1,73 @@ +/** + * Hub-side memory plane mount — host analog of Scout dock's `mountKnowledge` + * (`packages/agent-dock/src/knowledge.ts`). `@corbits/memory` (git pin) owns + * the firm-memory plane + HTTP under `/api/tenants/:tenantId/memory/*`. + * + * Degrades cleanly when unconfigured: missing `KNOWLEDGE_DATABASE_URL` (or + * embed env) means "no memory plane", logged once at boot, never thrown — + * same optional-engine contract as the artifacts mount. When env is present, + * boot fails loudly if create/load 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. + * + * Boundary casts (`as never`) match Scout: `@corbits/memory` ships against its + * own Hono/authz type copies, and Hono env + ConditionRegistry are invariant + * across package roots — cast only at the mount boundary. + */ +import type { Hono } from "hono"; +import type { ConditionRegistry, GrantStore } from "@intx/authz"; +import { getLogger } from "@intx/log"; +import { createMemory, loadMemoryConfig, type Memory } from "@corbits/memory"; + +const log = getLogger(["hub", "memory-mount"]); + +export type MountMemoryOptions = { + /** Hub Hono app (routes register under tenant memory paths). */ + app: Hono; + grantStore: GrantStore; + conditionRegistry: ConditionRegistry; + /** + * When true (default), skip mount if `KNOWLEDGE_DATABASE_URL` is unset. + * Tests can force a mount attempt by setting env + `optional: false`. + */ + optional?: boolean; +}; + +export type MemoryMountHandle = { + memory: Memory; +}; + +/** + * Returns a memory handle when the plane is configured and mounted; `undefined` + * when optional and env is absent. + */ +export function mountMemory( + options: MountMemoryOptions, +): MemoryMountHandle | undefined { + const optional = options.optional !== false; + const knowledgeUrl = process.env["KNOWLEDGE_DATABASE_URL"]; + if (!knowledgeUrl) { + if (optional) { + log.info( + "KNOWLEDGE_DATABASE_URL not set — memory plane will not be mounted", + ); + return undefined; + } + throw new Error("KNOWLEDGE_DATABASE_URL is required to mount memory"); + } + + // loadMemoryConfig also requires EMBED_* — fail at boot when env is partial. + const config = loadMemoryConfig(); + // Cast grant/condition/app at the package boundary — see module doc. + const memory = createMemory({ + app: options.app as never, + config, + grantStore: options.grantStore as never, + conditionRegistry: options.conditionRegistry as never, + }); + + log.info("Memory plane mounted at /api/tenants/:tenantId/memory/*"); + return { memory }; +} diff --git a/bun.lock b/bun.lock index affe86385..ca52f6576 100644 --- a/bun.lock +++ b/bun.lock @@ -26,6 +26,7 @@ "@corbits/chat": "workspace:*", "@corbits/commands": "workspace:*", "@corbits/folded-runs": "workspace:*", + "@corbits/memory": "github:corbitsdev/corbits-memory#800340252dc60393f017fa933c59a1c9e67e7264", "@corbits/routines": "workspace:*", "@corbits/webhook-triggers": "workspace:*", "@intx/authz": "workspace:*", @@ -861,6 +862,8 @@ "@corbits/heartbeat-workflow": ["@corbits/heartbeat-workflow@workspace:workflows/heartbeat"], + "@corbits/memory": ["@corbits/memory@github:corbitsdev/corbits-memory#8003402", { "dependencies": { "@intx/agent": "0.2.2", "@intx/authz": "0.2.2", "@intx/hub-api": "0.2.2", "@intx/log": "0.2.2", "arktype": "^2.1.29", "drizzle-orm": "^0.45.1", "hono": "^4.9.0", "hono-openapi": "^1.3.1", "postgres": "^3.4.7" } }, "corbitsdev-corbits-memory-8003402", "sha512-F1ypnvRuJLKScrm8sgknPyh4f87xeo/v5Nnm2/JyIckWiez7HZi80crTSK8S+udGBcCkr5v8F8NhB9GdwKlX7g=="], + "@corbits/notify": ["@corbits/notify@workspace:packages/notify"], "@corbits/react-ui": ["@corbits/react-ui@github:corbitsdev/react-ui#bd5057b", { "dependencies": { "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "tailwind-merge": "^3.3.1" }, "peerDependencies": { "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-slot": "^1.2.3", "@tanstack/react-query": "^5.90.2", "lucide-react": "^0.545.0 || ^1.0.0", "react": "^18.2.0 || ^19.0.0", "react-dom": "^18.2.0 || ^19.0.0", "sonner": "^2.0.7" }, "optionalPeers": ["@tanstack/react-query"] }, "corbitsdev-react-ui-bd5057b", "sha512-sAuh/ES7keqMBLMAZ2SFVQ0PINMb1SPVozXUR/jny5pE0ALAEm3hufl/Ofbb07FxhACtO3y4LPWl/3XyFsxnOw=="],