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
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ CI runs `typecheck` + `test` — both must pass before any push.

- `src/mount-config.ts` / `src/config.ts` — mount config + engine config
- `src/routes/` — Hono routes (`add`, `search`, `list`)
- `src/tools/` — Interchange `defineTool` factories (`@corbits/memory/tools`);
HTTP clients for mounted routes (env credentials; no in-process plane)
- `src/services/` — capture / search / transform internals (not public verbs)
- `src/ports/` — `DocumentStore` / `SourceProvider` + fakes
- `src/core/` — embed/rerank clients, merge, arktype schemas
Expand Down
7 changes: 4 additions & 3 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,9 +73,10 @@ exposes the same three verbs.
Returns an in-process `Memory` (`add`, `search`, `list`, `close`) for host
workers and ingestion modules that already resolved identity.

**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.
**Agent tools live in this package** as thin HTTP clients
(`@corbits/memory/tools` / `interchange.tools`): `defineTool` factories that
`fetch` the mounted routes with install credentials. They do not import the
in-process plane. OpenAPI→MCP remains an optional host bridge.

## Provenance

Expand Down
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- Interchange `defineTool` factories at `@corbits/memory/tools` (`memory_add`,
`memory_search`, `memory_list`) — HTTP clients for mounted hub routes with
install env `memoryBaseUrl` / `memoryTenantId` / `memoryAuthToken`. Declared
via `package.json` `interchange.tools` and `exports["./tools"]`.

### Changed

- **Breaking:** package and public surface renamed from `@corbits/knowledge-engine`
Expand Down
20 changes: 13 additions & 7 deletions IMPLEMENTATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -487,13 +487,19 @@ Each route is guarded with `grantGuard(deps, action)`, which applies the host's
| Method + path | Grant action | Request body | Response |
|---|---|---|---|
| `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). |
| `POST /api/tenants/:tenantId/memory/search` | `search` | `{ query, limit?, kinds?, entity_ids?, sources?, includeEvidence? }` (limit 1–50; `kinds`/`entity_ids`/`sources` narrow retrieval before fusion; unset or `[]` = unfiltered; `includeEvidence` adds a short evidence string when true) | `200 { items[], evidence?, degraded? }`; `400` on bad input |
| `GET /api/tenants/:tenantId/memory/list` | `search` | query `?limit=` (1–100, string on the wire) | `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.
Agent tools ship in this package as Interchange `defineTool` factories
(`@corbits/memory/tools` / `interchange.tools`): thin HTTP clients that call the
mounted routes with install env (`memoryBaseUrl`, `memoryTenantId`,
`memoryAuthToken`). They do not import the plane. Host checklist: agent principal
needs `memory:add` and/or `memory:search` grants; Bearer token only (no session
cookie path); tool results are JSON strings; pass `AbortSignal` if you need hang
protection — the client has no default timeout. OpenAPI→MCP remains an optional
host bridge. The plane surface is only `add` / `search` / `list` (plus `close`);
inference stays on the host.



Expand All @@ -518,8 +524,8 @@ Document access is Interchange authz — **not** a mini-ACL.
- Write path: `resolveAccessTags` always writes `memory.owner:<caller>` and
merges optional `accessTags` / share sugar (`tenant`, peer `principals`,
explicit `tags`). Stored on `knowledge.document.access_tags`.
- Read path (find + recent): `canAccessDocument` — creator always allowed;
otherwise `authorize(grantStore, principal, tenant, tag, "find")` for any
- Read path (search + list): `canAccessDocument` — creator always allowed;
otherwise `authorize(grantStore, principal, tenant, tag, "search")` for any
tag on the document.
- SQL retrieval is **tenant-scoped only**. Document access is grant-tag
post-filter in the plane (`canAccessDocument`); there is no SQL mini-ACL.
Expand Down
8 changes: 5 additions & 3 deletions PRODUCT.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ never creates one; it mounts onto yours.
| `loadMemoryConfig()` | Config from env |
| `runMemoryMigrations(url)` | Apply pgvector schema |
| `registerMemoryRoutes` | Low-level HTTP only (optional) |
| `@corbits/memory/tools` | Interchange `defineTool` factories (`memory_add` / `memory_search` / `memory_list`) |

### Verbs

Expand Down Expand Up @@ -53,9 +54,10 @@ Agent / ingestion module
```

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.
2. **Tools** — install `@corbits/memory/tools` (`defineTool` factories) on a
workflow with env credentials (`memoryBaseUrl`, `memoryTenantId`,
`memoryAuthToken`). Tools HTTP-call the mounted routes; identity is the
hub-authenticated principal. OpenAPI→MCP remains an optional host bridge.
3. **Ingestion** — host modules (webhooks, batch jobs) call the routes or the
returned plane with a resolved principal.

Expand Down
59 changes: 48 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,9 @@ Memory for [Interchange](https://github.com/corbitsdev) hubs: **add**, **search*

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.
— same as workflows, assets, and agents. Workflow agents install the package’s
`defineTool` factories; ingestion modules call the same routes or the in-process
plane. That’s the product.

Requires Bun 1.2+.

Expand All @@ -20,7 +20,7 @@ bun add git+https://github.com/corbitsdev/corbits-memory.git
```

Peer stack you already have on an Interchange hub: `@intx/authz`, `@intx/hub-api`,
`hono`.
`hono`. Agent tools also need `@intx/agent` (declared as a direct dependency).

## Mount (≈5 lines)

Expand Down Expand Up @@ -53,17 +53,54 @@ Missing grant → **403**.

```http
POST /api/tenants/:tenantId/memory/add { "title", "text", "access_tags"?, "share"? }
POST /api/tenants/:tenantId/memory/search { "query", "limit"? }
POST /api/tenants/:tenantId/memory/search { "query", "limit"?, "kinds"?, "entity_ids"?, "sources"?, "includeEvidence"? }
GET /api/tenants/:tenantId/memory/list ?limit=
```

## Who calls the routes
## Workflow agent tools

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):
This package exports Interchange `defineTool` factories at
`@corbits/memory/tools` (also `package.json` → `interchange.tools`). Each tool
is a thin HTTP client: install credentials in agent env, call the mounted hub
routes. No plane inject, no model-supplied identity.

| Factory id | Tool name | HTTP |
| --- | --- | --- |
| `@corbits/memory/add` | `memory_add` | `POST …/memory/add` |
| `@corbits/memory/search` | `memory_search` | `POST …/memory/search` |
| `@corbits/memory/list` | `memory_list` | `GET …/memory/list` |

**Env keys** (declared on each factory’s `requires`):

| Key | Meaning |
| --- | --- |
| `memoryBaseUrl` | Hub **origin** only, e.g. `https://hub.example` (no `/api/...` path) |
| `memoryTenantId` | Tenant path segment (must match the principal’s tenant on the hub) |
| `memoryAuthToken` | Bearer token the hub accepts for that agent principal |

**Host checklist**

1. Mount routes: `createMemory({ app, grantStore, … })` under the hub tenant tree.
2. Grant the agent principal `memory:add` and/or `memory:search` (`list` uses `search`).
3. For peer/space share visibility, also grant `search` on the relevant document tags (see `docs/AUTHZ-DOCUMENT-ACCESS.md`).
4. Install factories on the workflow and set the three env keys above.
5. Auth is **Bearer only** on the tool client — session cookies are not sent.
6. Tool results are **JSON strings** (`stringTool`); pass `AbortSignal` if you need hang protection (no default client timeout).

```ts
import { memoryAdd, memorySearch, memoryList } from "@corbits/memory/tools";

// On a workflow / agent definition — install like any open tool package:
// tools: [memoryAdd, memorySearch, memoryList]
// and supply memoryBaseUrl / memoryTenantId / memoryAuthToken in agent env.
```

OpenAPI→MCP remains available as an alternative host bridge; the shipped
`defineTool`s are the primary install path for workflow agents.

## Ingestion (in-process)

Host workers that already resolved identity can call the plane without HTTP:

```ts
await memory.add({
Expand Down
1 change: 1 addition & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

10 changes: 5 additions & 5 deletions docs/AUTHZ-DOCUMENT-ACCESS.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,9 +79,9 @@ Tag minting is **not** grant minting. For peer share to work in product:

1. When Alice adds with `share: { principals: ["bob"] }`, the document is tagged
`memory.owner:alice` and `memory.owner:bob`.
2. Bob sees it only if the host has granted Bob `find` on `memory.owner:bob`
2. Bob sees it only if the host has granted Bob `search` on `memory.owner:bob`
(or a pattern that matches). **Recommended host bootstrap:** every principal
receives `find` (and optionally `add` side-effects as you prefer) on
receives `search` (and optionally `add` side-effects as you prefer) on
`memory.owner:<self>` at signup, or a single pattern grant such as
`memory.owner:*` only if that matches your tenancy model.
3. Space/tenant tags work the same way: host must issue grants on
Expand All @@ -100,7 +100,7 @@ Deny is expressed as **absence of allow** (or an explicit deny grant in the host
### Capability (unchanged)

```ts
authorize(grantStore, principalId, tenantId, "memory", "find"|"add")
authorize(grantStore, principalId, tenantId, "memory", "search"|"add")
// effect must be "allow"
```

Expand All @@ -111,7 +111,7 @@ function canSeeDocument(doc, principalId, grantStore, tenantId):
if doc.createdByPrincipalId === principalId:
return true // creator
for tag of doc.accessTags:
r = authorize(grantStore, principalId, tenantId, tag, "find")
r = authorize(grantStore, principalId, tenantId, tag, "search")
if r.effect === "allow":
return true
return false
Expand All @@ -120,7 +120,7 @@ function canSeeDocument(doc, principalId, grantStore, tenantId):
**SQL / store path:** prefer expand-then-filter:

1. `collectGrants(principalId, tenantId)` once per request.
2. Keep allow-grants whose `action` matches `find` (exact or pattern).
2. Keep allow-grants whose `action` matches `search` (exact or pattern).
3. Document is visible if creator **or** any `accessTags[i]` is matched by any allow grant resource pattern (`matchPattern(grant.resource, tag)`), and not denied by a more specific deny.

This keeps evaluation inside Interchange authz semantics (specificity, conditions, deny).
Expand Down
7 changes: 6 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,11 @@
"exports": {
".": "./src/index.ts",
"./migrations": "./src/migrations.ts",
"./config": "./src/mount-config.ts"
"./config": "./src/mount-config.ts",
"./tools": "./src/tools/index.ts"
},
"interchange": {
"tools": "./src/tools/index.ts"
},
"license": "LGPL-2.1-only",
"type": "module",
Expand All @@ -20,6 +24,7 @@
"test:coverage": "bun test --coverage --coverage-reporter=lcov --coverage-reporter=text ./src"
},
"dependencies": {
"@intx/agent": "0.2.2",
"@intx/authz": "0.2.2",
"@intx/hub-api": "0.2.2",
"@intx/log": "0.2.2",
Expand Down
101 changes: 101 additions & 0 deletions src/http-bodies.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
/**
* Shared request bodies for hub memory HTTP and defineTool factories.
* Keep route validators and tool arg parsers on the same schemas.
*
* GET list uses a string query param on the wire (`ListQuery`); tools use
* numeric `ListArgs`. Bounds are shared via `limits.ts` and `parseListLimitString`.
*/
import { type } from "arktype";

import {
LIST_LIMIT_MAX,
LIST_LIMIT_MIN,
SEARCH_LIMIT_MAX,
SEARCH_LIMIT_MIN,
} from "./limits.ts";

export const ShareBody = type({
"tenant?": "boolean",
"principals?": "string[]",
"tags?": "string[]",
});

export const AddRequest = type({
title: "string >= 1",
text: "string >= 1",
"access_tags?": "string[]",
"share?": ShareBody,
});

export type AddRequest = typeof AddRequest.infer;

export const SearchRequest = type({
query: "string >= 1",
"limit?": type(`${SEARCH_LIMIT_MIN} <= number.integer <= ${SEARCH_LIMIT_MAX}`),
"kinds?": "string[]",
"entity_ids?": "string[]",
"sources?": "string[]",
"includeEvidence?": "boolean",
});

export type SearchRequest = typeof SearchRequest.infer;

/** HTTP query schema for GET /memory/list (string limit from the URL). */
export const ListQuery = type({
"limit?": "string",
});

export type ListQuery = typeof ListQuery.infer;

/** Tool-arg shape for memory_list (numeric limit after LLM coerce). */
export const ListArgs = type({
"limit?": type(`${LIST_LIMIT_MIN} <= number.integer <= ${LIST_LIMIT_MAX}`),
});

export type ListArgs = typeof ListArgs.infer;

/**
* Parse a list `limit` query string into a bounded integer.
* Returns `undefined` for missing/empty; `null` for invalid/out-of-range.
*/
export function parseListLimitString(
raw: string | undefined,
): number | undefined | null {
if (raw === undefined || raw === "") return undefined;
const n = Number(raw);
if (
!Number.isInteger(n) ||
n < LIST_LIMIT_MIN ||
n > LIST_LIMIT_MAX
) {
return null;
}
return n;
}

/** Coerce LLM-stringified integers before arktype number.integer checks. */
export function coerceOptionalLimitArg(
args: Record<string, unknown>,
): Record<string, unknown> {
const raw = args["limit"];
if (raw === undefined || typeof raw === "number") return args;
if (typeof raw === "string" && raw.trim() !== "") {
const n = Number(raw);
if (Number.isFinite(n)) {
return { ...args, limit: n };
}
}
return args;
}

export function parseWithArk<T>(
schema: (data: unknown) => T | type.errors,
data: unknown,
label: string,
): T {
const parsed = schema(data);
if (parsed instanceof type.errors) {
throw new Error(`${label}: ${parsed.summary}`);
}
return parsed;
}
7 changes: 7 additions & 0 deletions src/limits.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
/** Search limit bounds (hybrid search + HTTP/tool args). */
export const SEARCH_LIMIT_MIN = 1;
export const SEARCH_LIMIT_MAX = 50;

/** List/timeline limit bounds (GET list + memory_list tool). */
export const LIST_LIMIT_MIN = 1;
export const LIST_LIMIT_MAX = 100;
22 changes: 14 additions & 8 deletions src/memory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,13 @@ import type {
DocumentStoreSearchParams,
SourceProvider,
} from "./ports/types.ts";
import {
LIST_LIMIT_MAX,
LIST_LIMIT_MIN,
SEARCH_LIMIT_MAX,
SEARCH_LIMIT_MIN,
} from "./limits.ts";

// (drizzle select was used briefly for grant-tag load; raw sql keeps unit-test
// mocks simple and matches the rest of the engine store.)

Expand All @@ -53,6 +60,13 @@ export type {
LiveSearchItem,
SourceProvider,
} from "./ports/types.ts";
export {
SEARCH_LIMIT_MIN,
SEARCH_LIMIT_MAX,
LIST_LIMIT_MIN,
LIST_LIMIT_MAX,
} from "./limits.ts";

export {
resolveAccessTags,
ownerTag,
Expand All @@ -78,14 +92,6 @@ export type MemoryIdentity = {
tenantId: string;
};

/** Green find limit bounds (stricter than hybridSearch's internal MAX_K). */
export const SEARCH_LIMIT_MIN = 1;
export const SEARCH_LIMIT_MAX = 50;

/** Green recent limit bounds (matches timeline service default/cap). */
export const LIST_LIMIT_MIN = 1;
export const LIST_LIMIT_MAX = 100;

export type MemorySearchParams = MemoryIdentity & {
query: string;
/** Max items to return (1–50). Default 8. */
Expand Down
Loading
Loading