diff --git a/AGENTS.md b/AGENTS.md index f22d6b6..985934c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,9 +1,10 @@ # Agent guide — @corbits/memory -A library, not a service. `src/` is the whole product: a memory add / find / -ask / recent SDK that **mounts onto a host Interchange app**. There is no server, +A library, not a service. `src/` is the whole product: a memory **add / search / +list** SDK that **mounts onto a host Interchange app**. There is no server, port, or process entrypoint here, and there never should be. + ## Commands ```bash diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index c5ffcb7..6e1855f 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -1,97 +1,84 @@ # Corbits Memory — Architecture -A memory add / find / ask / recent SDK that mounts onto an Interchange hub. The -host owns auth, tenancy, and the process; this library owns the memory / -vector plane and the routes that read and write it. +A memory **add / search / list** SDK that mounts onto an Interchange hub. The +host owns auth, tenancy, and the process; this library owns the durable memory +plane and the protected routes that read and write it. ## Why an SDK, not a service -The memory store was originally built inside a larger backend. It turned out -to be cleanly detachable, and then cleanly *mountable*: +The store was detachable from a larger backend, then mountable: -- No memory table has a foreign key into any control-plane table — every - cross-reference (`tenant_id`, `principal_id`, source refs) is plain `text`. -- Embedding and reranking go out as plain HTTP to configured model endpoints, - not through any agent runtime. -- The ACL rule is a self-contained scope stored on the row, not a join against - a grant engine. +- No memory table has a foreign key into any control-plane table — cross-refs + (`tenant_id`, `principal_id`, source refs) are plain `text`. +- Embedding and reranking go out as plain HTTP to configured model endpoints. +- Document access is Interchange grant tags on the row (`accessTags` + creator), + not a private ACL engine inside this package. -So the library needs nothing but a pgvector Postgres and an embed/rerank -endpoint. It ships as `createMemory(opts)` — pass `app` to register HTTP. The host passes its +It ships as `createMemory({ app, … })`: the host passes its Hono app and grant +store; the library registers routes, reads identity from request context, and +talks to its DocumentStore. No second server. -Hono app and its grant store; the library mounts its routes, reads identity from -the request context, and talks to its own vector store. No second server, no -HTTP hop. +## Product path + +``` +tools / ingestion → /api/tenants/:tenantId/memory/* → Memory plane → DocumentStore + ↑ + Interchange auth + principal + grants +``` + +Mount is intentionally small. The host already has `app`, grants, and +principal middleware; memory only needs to be handed those and the vector +config (or an injected store). ## Boundaries -- **Runtime**: Bun + Hono, mounted on the host's app. **DB**: its own pgvector - Postgres (`KNOWLEDGE_DATABASE_URL`). **Types**: arktype at every route - boundary. -- **No auth of its own.** The SDK authenticates nothing. Interchange resolves - the caller (session, `cke_` API key, or MCP OAuth) and puts `principal` + - `tenant` on the request context; each mounted route reads identity from there - (`caller(c)` → `scopeId = principal.tenantId`, `subjectId = principal.id`). -- **Grants delegate to the host.** Pass `grants` (`{ grantStore, - conditionRegistry }`) and the SDK guards routes with Interchange's - `createRequireGrant`. -- **Dependencies** are public npm only — `@intx/hub-api` (`TenantEnv`, - `createRequireGrant`), `@intx/authz` (`authorize`), `@intx/log`, Hono, - Drizzle, arktype, `postgres`, `hono-openapi`. Eight total. LGPL-2.1-licensed — - see `LICENSE`. - -## Identity — read from context, stored as data - -1. **Who is calling** is the request principal, read off the Interchange - context. Clients never send `tenant_id`/`principal_id` — the handlers read - only content fields (title/text/query/limit/access_tags/share) and take identity from context. -2. **What is stored** is opaque data on every record: `tenant_id`, - `principal_id`, `created_by_kind` (human/agent/system), `source_class`, and - relations (the edge graph). Every query is scoped by `tenant_id` first; then - document access uses Interchange grant tags (`accessTags` + creator). - -Cross-tenant isolation is enforced at query time by `tenant_id`; document-level -access is grant tags via `@intx/authz` (creator always allowed). This is the -trust model. - -## Layers - -- `raw_capture` — immutable, append-only original content. The replay substrate. -- `derived` — chunks / embeddings / authority / edges, all derived from - `raw_capture`. -- `transform_config` + replay — a named, versioned transform (chunk strategy, - embed model, rerank endpoint, authority weights, MMR λ) that rebuilds the - derived layer from raw without re-fetching source. +- **Runtime**: Bun + Hono, mounted on the host app. **DB**: own pgvector + Postgres (`KNOWLEDGE_DATABASE_URL`) unless `documentStore` is injected. + **Types**: arktype at every route boundary. +- **No auth of its own.** Interchange resolves the caller and puts `principal` + + `tenant` on context; routes read identity from there + (`tenantId = principal.tenantId`, `principalId = principal.id`). +- **Grants delegate to the host.** Pass `grantStore` + `conditionRegistry`; + routes use `createRequireGrant("memory", action)`. +- **Dependencies**: `@intx/hub-api`, `@intx/authz`, `@intx/log`, Hono, Drizzle, + arktype, `postgres`, `hono-openapi`. LGPL-2.1 — see `LICENSE`. -## Mounted surface +## Identity — context in, data out + +1. **Who is calling** is the request principal. Clients never send + `tenant_id` / `principal_id` on the body. +2. **What is stored** is opaque data: `tenant_id`, `principal_id`, + `created_by_kind`, `access_tags`, source refs. Queries scope by `tenant_id` + first; document access is grant tags + creator. -`createMemory({ app })` adds, under the host app: +## Layers (default pgvector store) -- `POST /api/memory/add` — ingest a note (raw + derive). -- `POST /api/memory/search` — hybrid retrieval: FTS + dense (pgvector) → RRF - fusion → cross-encoder rerank → bounded authority/recency boosts → MMR; - optional live `SourceProvider` merge (fail-soft). -- `GET /api/memory/list` — recent documents for the caller's scope, - filtered with the same grant-tag access as local search (`canAccessDocument`). +- `raw_capture` — immutable original content (replay substrate). +- `derived` — chunks / embeddings / authority / edges from raw. +- `transform_config` + replay — rebuild derived from raw without re-fetch. + +Injected DocumentStores own their own persistence model; the plane still +exposes the same three verbs. + +## Mounted surface -It also returns an in-process `Memory` (`add`, `search`, `list`, `close`). -There is no product `ask` / `remember` / `recall` and no host-injected -`generate` on the plane — inference is host-owned and ephemeral (call your -model, then `add` / `search`). +`createMemory({ app })` registers: -MCP is not part of this package — mount `@corbitsdev/hono-openapi-mcp` to expose -these routes as MCP tools. +- `POST /api/tenants/:tenantId/memory/add` — ingest (raw + derive on the default store). +- `POST /api/tenants/:tenantId/memory/search` — hybrid retrieval (FTS + dense → RRF → rerank → + authority/recency → MMR); optional live `SourceProvider` merge (fail-soft). +- `GET /api/tenants/:tenantId/memory/list` — recent documents, same grant-tag filter as local + search. -External ingestion (Linear, GitHub, …) is not a route here — the host -authenticates the forwarder to Interchange and calls `plane.add` / a -`SourceProvider` mapper, or mounts HTTP add after its own auth. +Returns an in-process `Memory` (`add`, `search`, `list`, `close`) for host +workers and ingestion modules that already resolved identity. -Legacy paths `/capture`, `/search` (old knowledge), `/timeline`, `/find`, -`/ask`, `/recent` are not mounted (hard cutover). +**Agent tools are not in this package.** Routes are OpenAPI-described +(`describeRoute`). The host mounts `@corbitsdev/hono-openapi-mcp` (or any +OpenAPI→tools bridge) so agents call these routes under Interchange auth. ## Provenance -The framework-agnostic core (chunk strategies, embed client + model registry, -authority weighting, hybrid search, MMR, rerank client, ingestion adapters) was -extracted from an internal RAG implementation and generalized. The persistence -and the mountable surface are native to this repo. +Framework-agnostic core (chunking, embed/rerank clients, hybrid search, MMR) +was extracted from an internal RAG implementation. Persistence and the +mountable surface are native to this repo. diff --git a/CHANGELOG.md b/CHANGELOG.md index 060cc4b..fa02ede 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,15 +14,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 `registerMemoryRoutes`. `createMemory`, `loadMemoryConfig`, `runMemoryMigrations`, `Memory`, - `MemoryConfig`, `MemoryError`. HTTP paths are under `/api/memory/`; grants are + `MemoryConfig`, `MemoryError`. HTTP paths are under + `/api/tenants/:tenantId/memory/`; grants are `memory:add` / `memory:search`; access tags use `memory.owner:` / `memory.tenant:` - / `memory.space:`. Postgres schema name remains `knowledge`. See `MIGRATION.md`. + / `memory.space:`. Postgres schema name remains `knowledge`. - **Breaking:** memory plane surface is `add` / `search` / `list` with - `principalId` + `tenantId` only. Removed product verbs: `find` (→`search`), - `recent` (→`list`), `ask`, `remember`, `recall`, and any `MemoryProvider` / - `generate` path. Inference is host-owned. See `MIGRATION.md`. -- **Breaking:** HTTP routes are `POST /api/memory/add`, - `POST /api/memory/search`, `GET /api/memory/list`. Old paths are not mounted. + `principalId` + `tenantId` only. Inference is host-owned (no answer endpoint + or personal-memory side-channel on the plane). + +- **Breaking:** HTTP routes are `POST /api/tenants/:tenantId/memory/add`, + `POST /api/tenants/:tenantId/memory/search`, + `GET /api/tenants/:tenantId/memory/list` (inherits hub `resolveTenant`). + Old unscoped `/api/memory/*` paths are not mounted. - **Breaking:** grant actions are `add` and `search` (was `capture` / `find` / knowledge `search`). `list` uses the `search` grant. Capability resource is `memory`. Document-tag checks use action `search`. @@ -42,13 +45,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Optional `TextExtractor` + `file` XOR `content` on `add` - `share` sugar on `add` (maps to access tags only: owner, tenant, peers) - `access_tags` on `knowledge.document` (baseline schema; Postgres schema name unchanged) -- `MIGRATION.md` hard-cutover notes for in-repo consumers ### Removed -- Product `ask` / `remember` / `recall` and `MemoryProvider` side-channel -- Host-injected `generate` on the plane (use host inference + `add` / `search`) -- HTTP `POST /api/memory/ask`, `POST /api/memory/find`, `GET /api/memory/recent` +- Host-injected generate path on the plane (use host inference + `add` / `search`) ## [0.1.2] — 2026-07-31 diff --git a/IMPLEMENTATION.md b/IMPLEMENTATION.md index 93da8b7..556318b 100644 --- a/IMPLEMENTATION.md +++ b/IMPLEMENTATION.md @@ -329,7 +329,7 @@ returns the run summary either way. this repo; chunks left unembedded simply never populate the dense channel for the query — they're still found by lexical/FTS). Any of these failure modes sets `degraded: true` on the `CaptureResult`, surfaced by - `POST /api/memory/add` as a `degraded` field in its response — the add + `POST /api/tenants/:tenantId/memory/add` as a `degraded` field in its response — the add still succeeded (chunks are durable and lexically searchable), only the dense/vector channel for those chunks is incomplete. @@ -486,12 +486,15 @@ Each route is guarded with `grantGuard(deps, action)`, which applies the host's | Method + path | Grant action | Request body | Response | |---|---|---|---| -| `POST /api/memory/add` | `add` | `{ title, text, access_tags?, share? }` | `200 { documentId }`; `400` on validation | -| `POST /api/memory/search` | `search` | `{ query, limit?, kinds?, entity_ids? }` (limit 1–50; `kinds`/`entity_ids` narrow every retrieval channel — lexical and dense — to a document `kind` or linked entity id before fusion; unset or `[]` = unfiltered) | `200 { items[], evidence?, degraded? }`; `400` on bad input | -| `GET /api/memory/list` | `search` | — | `200 { events: [{ at, title, source, tenantId, principalId }] }` — durable recent documents for the caller's scope, filtered with grant-tag access (`canAccessDocument`). One event per document (active live version). | +| `POST /api/tenants/:tenantId/memory/add` | `add` | `{ title, text, access_tags?, share? }` | `200 { documentId }`; `400` on validation | +| `POST /api/tenants/:tenantId/memory/search` | `search` | `{ query, limit?, kinds?, entity_ids? }` (limit 1–50; `kinds`/`entity_ids` narrow every retrieval channel — lexical and dense — to a document `kind` or linked entity id before fusion; unset or `[]` = unfiltered) | `200 { items[], evidence?, degraded? }`; `400` on bad input | +| `GET /api/tenants/:tenantId/memory/list` | `search` | — | `200 { events: [{ at, title, source, tenantId, principalId }] }` — durable recent documents for the caller's scope, filtered with grant-tag access (`canAccessDocument`). One event per document (active live version). | + +`registerMemoryRoutes` and `createMemory({ app })` register the three HTTP routes. +Agent tools are a host concern — mount `@corbitsdev/hono-openapi-mcp` (or any +OpenAPI→tools bridge) against the same app. The plane surface is only +`add` / `search` / `list` (plus `close`); inference stays on the host. -`registerMemoryRoutes` and `createMemory({ app })` register the three HTTP routes. MCP is a separate package (`@corbitsdev/hono-openapi-mcp`). -There is no product `ask` / `remember` / `recall` HTTP or plane surface. ### Timeline wire fields (vs the old CaptureLog ring) diff --git a/MIGRATION.md b/MIGRATION.md deleted file mode 100644 index e7857f0..0000000 --- a/MIGRATION.md +++ /dev/null @@ -1,104 +0,0 @@ -# Migration guide — green API cutover (0.2.0) - -Hard cutover. There is no dual-path or alias period. Hosts and grant tables must -move to the new names in the same release. - -## Plane surface - -| Was | Now | -| --- | --- | -| `knowledge.capture(params)` | `memory.add(params)` | -| `knowledge.search(params)` | `memory.find(params)` | -| `knowledge.timeline(params)` | `memory.recent(params)` | -| `knowledge.ask(params)` | `memory.ask(params)`; grant action changed (below) | - -Identity fields on every call: - -| Was | Now | -| --- | --- | -| `subjectId` | `principalId` | -| `scopeId` | `tenantId` | - -### `add` - -- Returns `{ documentId }` only (no `status` / `versionId` / `chunks` on the public result). -- Exactly one of `content: { title, text }` or `file: { bytes, mimeType?, filename? }`. -- File ingest requires a host-supplied `textExtractor` on the plane options. -- Optional `share` sugar (`tenant` / `principals` / `tags`) maps to **access tags** - only (see `docs/AUTHZ-DOCUMENT-ACCESS.md`). Omit `share` for owner-only. There is - no separate `visibility` field and no `share.private` key. - -### `find` - -- Result shape: `{ items: FindItem[], evidence?, degraded? }`. -- `evidence` is omitted unless `includeEvidence: true` (HTTP always sets it). -- Hit field: `documentId` (was `document_id` on internal search hits; plane maps it). -- Limit param: `limit` (1–50), not `k`. - -### `recent` - -- Same event shape as the old timeline; param is `limit` (1–100). - -## HTTP routes - -| Was | Now | -| --- | --- | -| `POST /api/knowledge/capture` | `POST /api/memory/add` | -| `POST /api/knowledge/search` | `POST /api/memory/search` | -| `GET /api/knowledge/timeline` | `GET /api/memory/list` | -| `POST /api/memory/find` | `POST /api/memory/search` | -| `GET /api/memory/recent` | `GET /api/memory/list` | -| `POST /api/memory/ask` | **removed** (host-owned inference) | - -Old paths return **404**. No redirect, no dual mount. - -### Wire body / response deltas - -- **add** request: `{ title, text, access_tags?, share? }`. Response: `{ documentId }` (dropped `status: "captured"`). -- **search** request: `{ query, limit? }` (`k` is no longer accepted). Response: `{ items, evidence?, degraded? }` (was `{ hits, evidence, degraded? }`). -- **list** response: `{ events: [...] }` (same shape as old `recent`). -- **ask** / **remember** / **recall**: not product surface — host calls its model, then `add` / `search`. - -## Grants - -| Was | Now | -| --- | --- | -| `requireGrant("knowledge", "capture")` | `requireGrant("memory", "add")` | -| `requireGrant("knowledge", "search")` | `requireGrant("memory", "search")` | -| `requireGrant("memory", "find")` | `requireGrant("memory", "search")` | - -`search` and `list` both require the **`search`** action on resource -`memory`. Old resource/action names are not accepted — update grant rows in the -host grant store before deploy. - -## Package / public API rename (`@corbits/memory`) - -| Was | Now | -| --- | --- | -| `@corbits/knowledge-engine` | `@corbits/memory` | -| `mountKnowledgeEngine` | `createMemory({ app, … })` | -| `mountKnowledgeRoutes` | `registerMemoryRoutes` (or `createMemory({ app })`) | -| `mountMemory` / `mountMemoryRoutes` | `createMemory({ app })` / `registerMemoryRoutes` | -| `createKnowledgePlane` | `createMemory` | -| `loadKnowledgeConfig` | `loadMemoryConfig` | -| `runKnowledgeMigrations` | `runMemoryMigrations` | -| `KnowledgePlane` / `KnowledgeConfig` / `KnowledgeError` | `Memory` / `MemoryConfig` / `MemoryError` | -| plane verbs `find` / `recent` / `ask` | `search` / `list` / *(removed)* | -| `options.memory` / `options.memoryProvider` | **removed** (no MemoryProvider product path) | -| Access tags `knowledge.owner:` / `knowledge.tenant:` / `knowledge.space:` | `memory.owner:` / `memory.tenant:` / `memory.space:` | - -Postgres schema name remains **`knowledge`** (tables such as `knowledge.document`). - -## Host checklist (this package's consumers) - -1. Rename plane method calls and identity fields; switch package import to `@corbits/memory`. -2. Point HTTP clients at `/api/memory/add|search|list` (not find/ask/recent). -3. Rewrite grant rules: resource `knowledge`→`memory`, `capture`→`add`, `find`/`search`→`search`. -4. Drop any reliance on `status: "captured"` or `hits` / `k` on the wire. -5. If you use file capture, pass `textExtractor` into `createMemory` / mount options. -6. Document access is grant tags (`accessTags` + creator + host `GrantStore`), not - visibility modes or block lists. Update any host code that wrote `visibility`. -7. Drop `ask` / `remember` / `recall` / `generate` / `memoryProvider` usage — host owns inference. -7. Fresh Postgres: baseline migrations are `0001_extensions.sql` + - `0002_knowledge_baseline.sql` (schema `knowledge`, `access_tags` on document). - Existing DBs: drop/recreate the knowledge schema (no in-place dual-write migration). diff --git a/PRODUCT.md b/PRODUCT.md index c6d8b45..a31d3cc 100644 --- a/PRODUCT.md +++ b/PRODUCT.md @@ -1,164 +1,116 @@ # Corbits Memory — Product shape -A **mountable memory plane** for Interchange hubs: durable documents, hybrid -search, and recent list. Workbench and coding agents are clients — not owners -of ingestion or auth. Inference is **host-owned and ephemeral** (call your -model, then `add` / `search`); core does not ship `ask` or an ingest agent. +Memory for Interchange hubs: durable documents, hybrid search, recent list. + +**You mount it on the hub (~5 lines). That exposes protected routes. Agents +and ingestion modules call those routes.** Workbench and coding agents are +clients — not owners of ingestion or auth. Inference is host-owned (call your +model, then `add` / `search`); core does not ship an answer endpoint. ## Shape (locked) -**`src/` is the `@corbits/memory` SDK.** Interchange is the hub — the -SDK never creates one; it mounts onto yours. +**`src/` is the `@corbits/memory` SDK.** Interchange is the hub — the SDK +never creates one; it mounts onto yours. | Surface | Role | | --- | --- | -| `createMemory(opts)` | Plane; pass `app` to register HTTP on an Interchange host | -| `runMemoryMigrations(url)` | Apply pgvector schema under Postgres `knowledge` | +| `createMemory({ app, … })` | Register `/api/tenants/:tenantId/memory/*` + return the plane | | `loadMemoryConfig()` | Config from env | -| `registerMemoryRoutes` | Optional low-level HTTP registration | +| `runMemoryMigrations(url)` | Apply pgvector schema | +| `registerMemoryRoutes` | Low-level HTTP only (optional) | +### Verbs -### Green public plane (only these verbs) +| Method | HTTP | Grant | Meaning | +| --- | --- | --- | --- | +| `add` | `POST /api/tenants/:tenantId/memory/add` | `memory:add` | Capture a document | +| `search` | `POST /api/tenants/:tenantId/memory/search` | `memory:search` | Hybrid retrieval (+ optional live sources) | +| `list` | `GET /api/tenants/:tenantId/memory/list` | `memory:search` | Recent documents for the principal | -| Method | Meaning | -| --- | --- | -| `add` | Capture a document (`content` **xor** `file` + TextExtractor) | -| `search` | Hybrid retrieval (+ optional live sources) | -| `list` | Recent documents for the principal | +Identity is always **`principalId` + `tenantId`** on the plane. HTTP routes +never take body identity — they read `c.get("principal")` from Interchange +context. + +### How it is used -Hard cutover from older names: `find` → `search`, `recent` → `list`. -**Removed from product:** `ask`, `remember`, `recall`, and any -`MemoryProvider` side-channel. HTTP paths and grants match the verbs: -`POST /api/memory/add|search`, `GET /api/memory/list`; grants `memory:add` and -`memory:search` (`list` shares `search`). +``` +Agent / ingestion module + │ tool call or host worker + │ → POST|GET /api/tenants/:tenantId/memory/* + │ authenticated by Interchange (session | API key | MCP OAuth) + ▼ +┌──────────────────────────────────────────────┐ +│ Host Interchange createApp │ +│ principal + tenant on context │ +│ + createMemory({ app, grantStore, … }) │ +│ grants: memory:add | memory:search │ +│ documentStore: pgvector | host | fake │ +│ │ in-process │ +│ ▼ │ +│ Memory plane: add / search / list │ +│ → DocumentStore (sole durable backend) │ +└──────────────────────────────────────────────┘ +``` -Identity on the plane is always **`principalId` + `tenantId`** (never -`scopeId` / `subjectId`). HTTP routes never take body identity — they read -`c.get("principal")` from Interchange context. +1. **Mount** — host passes `app` + the same grant store it already uses. +2. **Tools** — host exposes the OpenAPI routes as agent tools (e.g. + `@corbitsdev/hono-openapi-mcp`). Agents call add/search/list as the + authenticated principal. +3. **Ingestion** — host modules (webhooks, batch jobs) call the routes or the + returned plane with a resolved principal. -### Ports (pluggable) +### Ports | Port | Purpose | | --- | --- | -| `DocumentStore` | **The** durable backend for add/search/list (default: engine pgvector, wrapped as a DocumentStore). Replace with any host `DocumentStore` or in-package fakes — no Postgres required when overridden. The plane is always store-backed; there is no second engine-only path. | -| `SourceProvider` | Optional **tools-shaped** live search (`searchLive`); merge is fail-soft. Not a store replacement. | - -Mount options accept `documentStore`, `sources[]`, plus in-package -**fakes** so a host can mount with fakes only and exercise add/search/list -without Postgres. Hosts that want a third-party durable backend implement -`DocumentStore` (or use an optional adapter package) and pass it as -`documentStore`, omitting `MemoryConfig` when Postgres is not needed. - -**MergeLocalLiveV1** merges local DocumentStore + live SourceProviders: fail-soft -per provider (timeout/error → degrade flags, never fail the request), dedupe by -`adapter:externalRef`, optional `sources` filter (`local` + provider ids). - -**Inference:** host-owned. Extract durable facts with the host model before -`add`, or answer with `search` + host model. No `generate` option on -`createMemory`; no auto-write on search. - -### Optional adapter packages - -Optional `DocumentStore` implementations and tools live as **sibling packages** -(not in this tree), same pattern as `@corbits/granola`: +| `DocumentStore` | Sole durable backend for add/search/list (default: pgvector). Inject fakes or a host/adapter store to skip Postgres. | +| `SourceProvider` | Optional live search merge (fail-soft). Not a store replacement. | -- [`@corbits/mem0-memory-adapter`](https://github.com/corbitsdev/corbits-mem0-memory-adapter) — Mem0 backend -- [`@corbits/supermemory-memory-adapter`](https://github.com/corbitsdev/corbits-supermemory-memory-adapter) — Supermemory backend -- [`@corbits/linear-tools`](https://github.com/corbitsdev/corbits-linear-tools) — Linear SourceProvider + webhook tools (not a store) - -Core never imports vendor SDKs. Hosts that need a store beyond default pgvector -mount their own `documentStore`. - -Not every `DocumentStore` evaluates host grant tags the same way. Some isolate by -**principal bucket** only (one private namespace per tenant+principal) and do not -multi-share via `accessTags` + host `GrantStore`. For full grant-tag ACL, use the -default pgvector store (or a store that implements -`docs/AUTHZ-DOCUMENT-ACCESS.md`). Adapter packages document their isolation model -in their own README. Never mount a durable store as `options.memoryProvider`. +Optional sibling packages (not in this tree): Mem0 / Supermemory document +stores, Linear tools. Core never imports vendor SDKs. ### What is not in scope - No auth, API keys, OAuth, webhooks, SPA, or standalone server in core. -- No dual ACL / aspirational `source_acl` writes as a security boundary. +- No answer/generation endpoint — host owns inference. - Workbench is a client, not required. -- Linear / Granola / MCP tools are **not** DocumentStore replacements. -**Default durable store:** local Postgres via `KNOWLEDGE_DATABASE_URL` only (no +**Default durable store:** Postgres via `KNOWLEDGE_DATABASE_URL` only (no `DATABASE_URL` fallback), tables under the **`knowledge`** schema. When -`documentStore` is injected, Postgres is not opened. Cross-refs (`tenant_id`, -`principal_id`) on the default store are plain `text` — no FKs into the host -control plane. - -``` -Claude Code / Codex / Workbench (clients) - │ authenticated by Interchange - ▼ -┌──────────────────────────────────────────────┐ -│ Host Interchange createApp │ -│ + createMemory({ app, … }) │ - -│ grants: memory:add | memory:search │ -│ documentStore: pgvector | host store │ -│ | fake │ -│ optional: sources, textExtractor │ -│ │ in-process │ -│ ▼ │ -│ Memory plane: add / search / list │ -│ → DocumentStore (sole durable backend) │ -└──────────────────────────────────────────────┘ -``` +`documentStore` is injected, Postgres is not opened. Cross-refs are plain +`text` — no FKs into the host control plane. -## Identity and access (Interchange authz — one system) +## Identity and access (one authz system) -Memory does **not** ship a second ACL. Document access uses the host’s -`@intx/authz` grant store — the same grants/roles as the rest of Interchange. +Memory does not ship a second ACL. Document access uses the host’s +`@intx/authz` grant store. -1. **Capability** — may this principal use memory at all? +1. **Capability** — may this principal use memory? `authorize(…, resource: "memory", action: "add" | "search")`. -2. **Document access** — each document carries **`accessTags`** (resource strings - in grant-pattern space). A principal sees a document if they are the creator - **or** `authorize(…, resource: , action: "search")` allows for any tag on - the document. Patterns (`memory.space:*`) work via `@intx/authz`. -3. **Share sugars on add** — only mint tags (owner / tenant / peer owner tags / - explicit tags). They do **not** invent visibility modes or block lists. - **Host contract:** peers named in `share.principals` only see the doc if the - host has granted them `search` on their owner tag (or matching pattern) — - typically bootstrap every principal with `search` on `memory.owner:`. - Tag minting is not grant minting. See `docs/AUTHZ-DOCUMENT-ACCESS.md`. - -Default add is **owner-only** (`memory.owner:` + creator rule). -Deny is absence of allow (or a more specific host deny grant) — not a document -block list. Full design: `docs/AUTHZ-DOCUMENT-ACCESS.md`. - -Third-party DocumentStores may be **principal-bucket** only and not evaluate -host grants; that limit belongs in the adapter's own docs, not hidden here. +2. **Document access** — each document carries **`accessTags`**. A principal + sees a document if they are the creator **or** + `authorize(…, resource: , action: "search")` allows for any tag. +3. **Share sugars on add** — mint tags only (owner / tenant / peers / + explicit). Host must still grant peers `search` on the relevant tags. + See `docs/AUTHZ-DOCUMENT-ACCESS.md`. -### On the wire +Default add is **owner-only**. Deny is absence of allow — not a document +block list. -HTTP bodies are a thin subset of the in-process plane (identity always comes -from the host principal context, never the body): +### On the wire ```http -POST /api/memory/add { "title", "text", "access_tags"?, "share"? } -POST /api/memory/search { "query", "limit?", "kinds?", "entity_ids?", "sources?", "includeEvidence?" } -GET /api/memory/list ?limit= +POST /api/tenants/:tenantId/memory/add { "title", "text", "access_tags"?, "share"? } +POST /api/tenants/:tenantId/memory/search { "query", "limit?", "kinds"?, "entity_ids"?, "sources"?, "includeEvidence"? } +GET /api/tenants/:tenantId/memory/list ?limit= ``` -`kinds` / `entity_ids` on search narrow both lexical and dense channels before -fusion (unset or `[]` = no filter). +### Live sources -### Live sources (trust) - -- **Local documents** are grant-tagged; default engine evaluates tags via the - host `GrantStore`. An injected `DocumentStore` owns enforcement for that mount. -- **Live `SourceProvider` hits** merge into search without grant tags. Auth is - the host token / connector scope. Treat live as enrichment; fail-soft. -- **Inference is host-owned.** Core does not ship `ask` / `remember` / `recall` - or a `MemoryProvider` product path. Hosts call their model, then `add` / - `search`. +Local documents are grant-tagged. Live `SourceProvider` hits merge as +enrichment under the host’s connector tokens (fail-soft, no grant tags). ## Out of scope forever here -Auth, OAuth for Linear, third-party memory/account management, embedding models +Auth, OAuth for Linear, third-party account management, embedding models in-process, and any standalone process entrypoint. - diff --git a/README.md b/README.md index b81f331..0cb4496 100644 --- a/README.md +++ b/README.md @@ -1,227 +1,108 @@ # @corbits/memory -Memory plane for [Interchange](https://github.com/corbitsdev) hubs: -**add** documents, **search** with hybrid retrieval, **list** recent events. +Memory for [Interchange](https://github.com/corbitsdev) hubs: **add**, **search**, +**list**. -**One entry point:** `createMemory(options)`. Pass `app` to register -`/api/memory/*` on your Hono host. Without `app`, you get an in-process plane -only (CLI, worker, tests). - -**Authenticates nothing.** Identity is `c.get("principal")` on HTTP; in-process -callers pass `principalId` + `tenantId`. Authorization is the host grant store -(`memory` resource + `add` / `search` actions via `@intx/authz`). Document access -uses grant tags on each row (`access_tags`); creator always sees their own docs. - -**No baked-in LLM.** Inference is host-owned and ephemeral: call your model, then -`add` / `search`. Core does not mount an ingest agent or require `generate`. +Mount it on the hub. Routes land under `/api/tenants/:tenantId/memory/*`, so +the hub’s existing `createResolveTenant` middleware supplies principal + tenant +— same as workflows, assets, and agents. Agents and ingestion modules call those +routes (tools / OpenAPI→MCP, or in-process from a host worker). That’s the +product. Requires Bun 1.2+. ## Install -Not published to npm yet. Install from git: +Not published to npm yet: ```bash bun add git+https://github.com/corbitsdev/corbits-memory.git -bun add @intx/authz@0.2.2 @intx/hub-api@0.2.2 hono ``` -## Quick start — complete mini app (no Postgres) +Peer stack you already have on an Interchange hub: `@intx/authz`, `@intx/hub-api`, +`hono`. -This is a full host you can run. It uses: +## Mount (≈5 lines) -- Hono as the HTTP app -- `@intx/authz` in-memory grant store (same type as a real Interchange hub) -- in-package fakes for storage (no DB, no embed endpoints) -- principal + tenant middleware on `/api/memory/*` (required — these routes sit - outside `/api/tenants/:tenantId/*`) - -Save as `server.ts` and run with `bun run server.ts`. +On a real hub you already have `app` (with session + +`app.use("/api/tenants/:tenantId/*", resolveTenant)`), `grantStore`, and +`conditionRegistry`: ```ts -import { Hono } from "hono"; -import type { TenantEnv } from "@intx/hub-api"; -import { createInMemoryGrantStore, type GrantRule } from "@intx/authz"; -import { - createFakeDocumentStore, - createMemory, -} from "@corbits/memory"; - -const TENANT = "tenant_demo"; -const PRINCIPAL = "principal_demo"; - -// 1. Capability grants the host would normally load from Interchange. -// Routes call requireGrant("memory", "add" | "search"). -const grantRules: GrantRule[] = [ - { - id: "g-add", - principalId: PRINCIPAL, - resource: "memory", - action: "add", - effect: "allow", - origin: "role", - conditions: null, - expiresAt: null, - roleId: null, - }, - { - id: "g-search", - principalId: PRINCIPAL, - resource: "memory", - action: "search", - effect: "allow", - origin: "role", - conditions: null, - expiresAt: null, - roleId: null, - }, -]; - -const grantStore = createInMemoryGrantStore(grantRules); -// Empty condition registry is fine when grants have no conditions. -const conditionRegistry = {}; - -// 2. Durable store. Fakes prove the port boundary; swap for Postgres or a -// sibling DocumentStore adapter later. -const documentStore = createFakeDocumentStore(); - -// 3. Hono app with principal + tenant on every request (Interchange shape). -const app = new Hono(); - -app.use("/api/memory/*", async (c, next) => { - // In a real hub, session + tenant middleware set these. - // Memory routes do NOT sit under /api/tenants/:tenantId/* — you must set them. - c.set("principal", { - id: PRINCIPAL, - tenantId: TENANT, - kind: "user", - refId: "demo-user", - status: "active", - createdAt: new Date(0), - updatedAt: new Date(0), - }); - c.set("tenant", { - id: TENANT, - name: "Demo", - slug: "demo", - domain: "demo.local", - parentId: null, - config: {}, - createdAt: new Date(0), - updatedAt: new Date(0), - }); - await next(); -}); +import { createMemory, loadMemoryConfig } from "@corbits/memory"; -// 4. One call: plane + HTTP routes. Returns Memory for in-process use too. const memory = createMemory({ app, + config: loadMemoryConfig(), // KNOWLEDGE_DATABASE_URL + embed env grantStore, conditionRegistry, - documentStore, -}); - -// 5. In-process path (CLI, worker, tests) — same plane, no second factory. -await memory.add({ - tenantId: TENANT, - principalId: PRINCIPAL, - content: { title: "Kickoff", text: "Ship memory 0→1 docs" }, }); +``` -const hits = await memory.search({ - tenantId: TENANT, - principalId: PRINCIPAL, - query: "memory docs", -}); -console.log("search hits:", hits.items.length); - -const events = await memory.list({ - tenantId: TENANT, - principalId: PRINCIPAL, -}); -console.log("list events:", events.length); - -// 6. HTTP -export default { - port: 8787, - fetch: app.fetch, -}; +That exposes: -console.log("listening on http://127.0.0.1:8787"); +| Method | Path | Grant | +| --- | --- | --- | +| POST | `/api/tenants/:tenantId/memory/add` | `("memory", "add")` | +| POST | `/api/tenants/:tenantId/memory/search` | `("memory", "search")` | +| GET | `/api/tenants/:tenantId/memory/list` | `("memory", "search")` | + +Bodies never carry tenant/principal — routes read `c.get("principal")` from +context (set by the hub’s tenant middleware). Missing principal → **401**. +Missing grant → **403**. + +```http +POST /api/tenants/:tenantId/memory/add { "title", "text", "access_tags"?, "share"? } +POST /api/tenants/:tenantId/memory/search { "query", "limit"? } +GET /api/tenants/:tenantId/memory/list ?limit= ``` -Try the HTTP routes (principal is fixed by middleware above): - -```bash -curl -sS -X POST http://127.0.0.1:8787/api/memory/add \ - -H 'content-type: application/json' \ - -d '{"title":"Note","text":"hello from curl"}' +## Who calls the routes -curl -sS -X POST http://127.0.0.1:8787/api/memory/search \ - -H 'content-type: application/json' \ - -d '{"query":"hello"}' +1. **Agent tools** — routes are OpenAPI-described (`hono-openapi`). On the host, + mount `@corbitsdev/hono-openapi-mcp` (or any OpenAPI→tools bridge) so agents + get tools that hit the memory paths under Interchange auth. +2. **Ingestion modules** — host workers that already resolved identity call the + same plane in-process (no HTTP hop): -curl -sS 'http://127.0.0.1:8787/api/memory/list' +```ts +await memory.add({ + tenantId, + principalId, + content: { title, text }, +}); +const { items } = await memory.search({ tenantId, principalId, query }); ``` -Clients never send tenant/principal in the body. Without principal middleware: -**401 `principal_required`**. Without the grant: **403**. +Inference is host-owned: run your model, then `add` / `search`. Core does not +ship an answer endpoint. -| Method | Path | Grant (`requireGrant`) | -| --- | --- | --- | -| POST | `/api/memory/add` | `("memory", "add")` | -| POST | `/api/memory/search` | `("memory", "search")` | -| GET | `/api/memory/list` | `("memory", "search")` | +## Document access -## Production host (Postgres + real Interchange) +Capability grants (`memory:add` / `memory:search`) gate the routes. Per-document +visibility is Interchange **grant tags** on the row (`access_tags`); the creator +always sees their own docs. Details: +[`docs/AUTHZ-DOCUMENT-ACCESS.md`](docs/AUTHZ-DOCUMENT-ACCESS.md). -Same `createMemory` call. Replace fakes and the demo grant store with the hub’s -real wiring. Register principal/tenant middleware **before** `createMemory({ app })`. +## Config -```ts -import { createMemory, loadMemoryConfig } from "@corbits/memory"; - -// Same grantStore + conditionRegistry you already pass to createApp / -// createRequireGrant from @intx/hub-api. -const memory = createMemory({ - app, - config: loadMemoryConfig(), // needs KNOWLEDGE_DATABASE_URL + embed env - grantStore: hubGrantStore, - conditionRegistry: hubConditionRegistry, -}); -``` - -### Inference (host-owned, ephemeral) +`loadMemoryConfig()` reads env (see `.env.example`). For the default pgvector +store you need `KNOWLEDGE_DATABASE_URL`, `EMBED_BASE_URL`, `EMBED_MODEL`. ```ts -// Host extracts durable facts with its own model, then writes: -const facts = await hostGenerate(transcript); -for (const fact of facts) { - await memory.add({ - tenantId, - principalId, - content: { title: fact.title, text: fact.text }, - }); -} -// Host answers with retrieval + its own model: -const { items } = await memory.search({ tenantId, principalId, query }); -const answer = await hostGenerate(buildPrompt(query, items)); +import { runMemoryMigrations } from "@corbits/memory/migrations"; +await runMemoryMigrations(process.env.KNOWLEDGE_DATABASE_URL!); ``` -There is no `ask` / `remember` / `recall` product path and no ingest agent in core. +Inject `documentStore` to use fakes, a host store, or a sibling adapter instead +of Postgres. -## Hard cutover notes +## More -| Old | New | -| --- | --- | -| `find` | `search` | -| `recent` | `list` | -| `ask` / `remember` / `recall` | removed (host-owned inference) | -| grant action `find` | `search` | -| `/api/memory/find` | `/api/memory/search` | -| `/api/memory/recent` | `/api/memory/list` | -| `/api/memory/ask` | removed | +- Product: [`PRODUCT.md`](PRODUCT.md) +- Architecture: [`ARCHITECTURE.md`](ARCHITECTURE.md) +- Internals: [`IMPLEMENTATION.md`](IMPLEMENTATION.md) ## License -See repository. +LGPL-2.1 — see [`LICENSE`](LICENSE). diff --git a/docs/AUTHZ-DOCUMENT-ACCESS.md b/docs/AUTHZ-DOCUMENT-ACCESS.md index cb8ae8d..83e77b5 100644 --- a/docs/AUTHZ-DOCUMENT-ACCESS.md +++ b/docs/AUTHZ-DOCUMENT-ACCESS.md @@ -1,7 +1,8 @@ # Document access = Interchange authz (not a second ACL) -**Status:** shipped hard cutover (grant tags + creator; baseline migrations) -**Problem (historical):** the green plane shipped a **mini-ACL** (`visibility` mode + principal list + block list) that was parallel to Interchange grants. That path is removed. +**Status:** grant tags + creator (baseline migrations) +**Problem (historical):** an earlier mini-ACL (`visibility` mode + principal list + block list) ran parallel to Interchange grants. That path is gone — document access is grant tags only. + ## Source of truth @@ -145,7 +146,8 @@ Unchanged intentional tradeoff: live `SourceProvider` hits are **enrichment unde Identity never in body. `access_tags` and `share` are optional; default owner-only. -`find` / `ask` / `recent` need no ACL body — principal from context + grant store. +`search` / `list` need no ACL body — principal from context + grant store. + ## Schema @@ -174,6 +176,7 @@ Fresh databases apply the baseline migrations (`0001_extensions.sql` + 2. Default add is owner-visible only (creator + owner tag). 3. Principal B sees A’s doc only when host grant allows `search` on a tag present on the doc (or B is creator). 4. Capability `memory`/`search` still required for search/list. -5. PRODUCT.md / MIGRATION.md / README describe grant tags, not mini-ACL. +5. PRODUCT.md / README describe grant tags, not mini-ACL. + 6. Engine + fakes enforce the algorithm; vendor adapters document principal-bucket limit. 7. `bun run typecheck && bun run test` green. diff --git a/src/index.ts b/src/index.ts index 1dd42e3..93eba33 100644 --- a/src/index.ts +++ b/src/index.ts @@ -99,8 +99,10 @@ export { registerMemoryRoutes, type GrantConfig } from "./routes/mount.ts"; export type CreateMemoryOptions = MemoryOptions & { /** - * When set, register `/api/memory/*` on this Hono app. Requires `grantStore` - * (routes are guarded with `requireGrant("memory", …)`). + * When set, register `/api/tenants/:tenantId/memory/*` on this Hono app. + * Requires `grantStore` (routes are guarded with `requireGrant("memory", …)`). + * On a real hub, mount under the same app that already runs + * `createResolveTenant` on `/api/tenants/:tenantId/*`. */ app?: Hono; }; @@ -127,7 +129,8 @@ export type CreateMemoryOptions = MemoryOptions & { * grantStore, * conditionRegistry, * }); - * // POST /api/memory/add | search · GET /api/memory/list + * // POST …/memory/add | search · GET …/memory/list + * // under /api/tenants/:tenantId/ * ``` */ export function createMemory(options: CreateMemoryOptions): Memory { diff --git a/src/ports/mount-fakes.test.ts b/src/ports/mount-fakes.test.ts index d0cf1ac..05369e0 100644 --- a/src/ports/mount-fakes.test.ts +++ b/src/ports/mount-fakes.test.ts @@ -121,7 +121,7 @@ describe("createMemory with fakes only", () => { }); expect(listed.some((e) => e.title === "ports note")).toBe(true); - const addRes = await app.request("/api/memory/add", { + const addRes = await app.request("/api/tenants/tenant_fake/memory/add", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ @@ -133,7 +133,7 @@ describe("createMemory with fakes only", () => { const addBody = (await addRes.json()) as { documentId: string }; expect(addBody.documentId).toMatch(/^fake_doc_/); - const searchRes = await app.request("/api/memory/search", { + const searchRes = await app.request("/api/tenants/tenant_fake/memory/search", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ query: "http path" }), diff --git a/src/routes/add.ts b/src/routes/add.ts index aeb5cd1..365c110 100644 --- a/src/routes/add.ts +++ b/src/routes/add.ts @@ -31,7 +31,8 @@ const AddResponse = type({ export function mountAddRoute(app: Hono, deps: RouteDeps): void { app.post( - "/api/memory/add", + "/api/tenants/:tenantId/memory/add", + describeRoute({ tags: ["memory"], summary: "Add a note into memory", diff --git a/src/routes/deps.ts b/src/routes/deps.ts index ec945f8..5e4f1e4 100644 --- a/src/routes/deps.ts +++ b/src/routes/deps.ts @@ -46,10 +46,9 @@ export function caller(c: Context): { * rather than as the host missing middleware. `caller()` below has a perfectly * good error message for exactly this case, but it never gets to run. * - * These routes mount at `/api/memory/*`, outside the - * `/api/tenants/:tenantId/*` prefix that Interchange's `createResolveTenant` - * covers, so an unresolved context is the DEFAULT for a host that just calls - * `createMemory({ app })`. See the README for the middleware the host supplies. + * Routes mount at `/api/tenants/:tenantId/memory/*` so a real hub's + * `createResolveTenant` already sets principal + tenant. This guard is a + * fail-closed safety net for mis-mounted hosts and unit tests. */ export function requirePrincipal(): MiddlewareHandler { return async (c, next) => { @@ -59,10 +58,9 @@ export function requirePrincipal(): MiddlewareHandler { error: { code: "principal_required", message: - "No principal on the request context. The memory routes mount " + - "at /api/memory/*, which is outside Interchange's " + - "/api/tenants/:tenantId/* tenant middleware — the host must " + - "resolve tenant + principal for this prefix. See the " + + "No principal on the request context. Mount memory under " + + "Interchange's /api/tenants/:tenantId/* tree (or equivalent " + + "host middleware that sets principal + tenant). See the " + "@corbits/memory README.", }, }, diff --git a/src/routes/list.ts b/src/routes/list.ts index e6674c3..117299a 100644 --- a/src/routes/list.ts +++ b/src/routes/list.ts @@ -41,7 +41,8 @@ function parseLimit(raw: string | undefined): number | undefined { export function mountListRoute(app: Hono, deps: RouteDeps): void { app.get( - "/api/memory/list", + "/api/tenants/:tenantId/memory/list", + describeRoute({ tags: ["memory"], summary: "List recent documents for the caller's scope", diff --git a/src/routes/routes.test.ts b/src/routes/routes.test.ts index 51d055c..4607286 100644 --- a/src/routes/routes.test.ts +++ b/src/routes/routes.test.ts @@ -164,7 +164,7 @@ describe("memory HTTP routes", () => { grant(PRINCIPAL, "search"), ]); const res = await app.request( - "/api/memory/add", + "/api/tenants/t1/memory/add", jsonPost({ title: "t", text: "body" }), ); expect(res.status).toBe(200); @@ -178,7 +178,7 @@ describe("memory HTTP routes", () => { test("add without the add grant is 403", async () => { const { app, added } = buildApp([grant(PRINCIPAL, "search")]); const res = await app.request( - "/api/memory/add", + "/api/tenants/t1/memory/add", jsonPost({ title: "t", text: "body" }), ); expect(res.status).toBe(403); @@ -188,7 +188,7 @@ describe("memory HTTP routes", () => { test("legacy capture grant does not authorize add", async () => { const { app, added } = buildApp([grant(PRINCIPAL, "capture")]); const res = await app.request( - "/api/memory/add", + "/api/tenants/t1/memory/add", jsonPost({ title: "t", text: "body" }), ); expect(res.status).toBe(403); @@ -198,7 +198,7 @@ describe("memory HTTP routes", () => { test("add validates the body (400 on missing text)", async () => { const { app } = buildApp([grant(PRINCIPAL, "add")]); const res = await app.request( - "/api/memory/add", + "/api/tenants/t1/memory/add", jsonPost({ title: "t" }), ); expect(res.status).toBe(400); @@ -207,7 +207,7 @@ describe("memory HTTP routes", () => { test("search with the search grant returns a result", async () => { const { app } = buildApp([grant(PRINCIPAL, "search")]); const res = await app.request( - "/api/memory/search", + "/api/tenants/t1/memory/search", jsonPost({ query: "hello" }), ); expect(res.status).toBe(200); @@ -222,7 +222,7 @@ describe("memory HTTP routes", () => { test("search requires the search grant", async () => { const { app } = buildApp([grant(PRINCIPAL, "add")]); const res = await app.request( - "/api/memory/search", + "/api/tenants/t1/memory/search", jsonPost({ query: "hi" }), ); expect(res.status).toBe(403); @@ -231,7 +231,7 @@ describe("memory HTTP routes", () => { test("legacy find grant does not authorize search", async () => { const { app } = buildApp([grant(PRINCIPAL, "find")]); const res = await app.request( - "/api/memory/search", + "/api/tenants/t1/memory/search", jsonPost({ query: "hi" }), ); expect(res.status).toBe(403); @@ -240,7 +240,7 @@ describe("memory HTTP routes", () => { test("search rejects out-of-range limit (400)", async () => { const { app } = buildApp([grant(PRINCIPAL, "search")]); const res = await app.request( - "/api/memory/search", + "/api/tenants/t1/memory/search", jsonPost({ query: "hi", limit: 999 }), ); expect(res.status).toBe(400); @@ -249,7 +249,7 @@ describe("memory HTTP routes", () => { test("search threads kinds and entity_ids through to the plane", async () => { const { app, searched } = buildApp([grant(PRINCIPAL, "search")]); const res = await app.request( - "/api/memory/search", + "/api/tenants/t1/memory/search", jsonPost({ query: "hello", kinds: ["artifact", "task"], @@ -269,7 +269,7 @@ describe("memory HTTP routes", () => { test("search with no kinds/entity_ids leaves them unset on the plane call", async () => { const { app, searched } = buildApp([grant(PRINCIPAL, "search")]); const res = await app.request( - "/api/memory/search", + "/api/tenants/t1/memory/search", jsonPost({ query: "hello" }), ); expect(res.status).toBe(200); @@ -281,7 +281,7 @@ describe("memory HTTP routes", () => { test("search rejects a non-string-array kinds (400)", async () => { const { app } = buildApp([grant(PRINCIPAL, "search")]); const res = await app.request( - "/api/memory/search", + "/api/tenants/t1/memory/search", jsonPost({ query: "hi", kinds: [1, 2] }), ); expect(res.status).toBe(400); @@ -290,7 +290,7 @@ describe("memory HTTP routes", () => { test("search passes an empty kinds/entity_ids array through unchanged", async () => { const { app, searched } = buildApp([grant(PRINCIPAL, "search")]); const res = await app.request( - "/api/memory/search", + "/api/tenants/t1/memory/search", jsonPost({ query: "hello", kinds: [], entity_ids: [] }), ); expect(res.status).toBe(200); @@ -301,7 +301,7 @@ describe("memory HTTP routes", () => { test("list requires the search grant", async () => { const { app } = buildApp([grant(PRINCIPAL, "add")]); - const res = await app.request("/api/memory/list"); + const res = await app.request("/api/tenants/t1/memory/list"); expect(res.status).toBe(403); }); @@ -310,7 +310,7 @@ describe("memory HTTP routes", () => { timelineCatalog: LIST_CATALOG, principalId: PRINCIPAL, }); - const res = await app.request("/api/memory/list"); + const res = await app.request("/api/tenants/t1/memory/list"); expect(res.status).toBe(200); const body = (await res.json()) as { events: TimelineEvent[] }; expect(body.events.map((e) => e.title)).toEqual([PUBLIC_TITLE]); @@ -322,7 +322,7 @@ describe("memory HTTP routes", () => { timelineCatalog: LIST_CATALOG, principalId: "alice", }); - const res = await app.request("/api/memory/list"); + const res = await app.request("/api/tenants/t1/memory/list"); expect(res.status).toBe(200); const body = (await res.json()) as { events: TimelineEvent[] }; expect(body.events.map((e) => e.title)).toContain(SECRET_TITLE); @@ -331,7 +331,7 @@ describe("memory HTTP routes", () => { test("missing principal is 401", async () => { const app = buildAppWithoutPrincipal(); const res = await app.request( - "/api/memory/search", + "/api/tenants/t1/memory/search", jsonPost({ query: "hi" }), ); expect(res.status).toBe(401); diff --git a/src/routes/search.ts b/src/routes/search.ts index df0b36f..e030bfb 100644 --- a/src/routes/search.ts +++ b/src/routes/search.ts @@ -39,7 +39,8 @@ const SearchResponse = type({ export function mountSearchRoute(app: Hono, deps: RouteDeps): void { app.post( - "/api/memory/search", + "/api/tenants/:tenantId/memory/search", + describeRoute({ tags: ["memory"], summary: "Hybrid semantic + keyword search",