Skip to content

Commit 0001fbf

Browse files
Export Interchange defineTool memory tools (CL-5354) (#28)
* Export Interchange defineTool memory tools as HTTP clients Workflow agents install @corbits/memory/tools with hub credentials and call the mounted tenant memory routes. Tools never take model-supplied identity and do not touch the in-process plane. * Document workflow defineTool install path for memory README and product docs now lead with mount plus the shipped @corbits/memory/tools factories instead of OpenAPI-MCP only. * Harden memory tools: shared HTTP schemas, client, factory Share AddRequest/SearchRequest (and limit bounds) between routes and defineTool parsers without importing the plane. Collapse tool factories through defineMemoryHttpTool; harden the HTTP client (multi-slash base, empty/invalid JSON); strip adversarial identity args and coerce LLM string limits. Expand tools tests accordingly. * Strip nested extras from memory_add share payloads Arktype keeps undeclared nested keys; rebuild share field-by-field so model junk under share never rides the wire. Add regression tests for nested share and search/list identity strip. * Polish tools surface: shared types, list limits, docs Derive MemoryAddBody/MemorySearchBody from arktype infer; share ListQuery and parseListLimitString with the list route; clip long HTTP error bodies; document host install checklist; fix search wire table and find→search grant-tag action in IMPLEMENTATION and AUTHZ docs.
1 parent 2eb0c12 commit 0001fbf

22 files changed

Lines changed: 1167 additions & 84 deletions

‎AGENTS.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ CI runs `typecheck` + `test` — both must pass before any push.
2323

2424
- `src/mount-config.ts` / `src/config.ts` — mount config + engine config
2525
- `src/routes/` — Hono routes (`add`, `search`, `list`)
26+
- `src/tools/` — Interchange `defineTool` factories (`@corbits/memory/tools`);
27+
HTTP clients for mounted routes (env credentials; no in-process plane)
2628
- `src/services/` — capture / search / transform internals (not public verbs)
2729
- `src/ports/` — `DocumentStore` / `SourceProvider` + fakes
2830
- `src/core/` — embed/rerank clients, merge, arktype schemas

‎ARCHITECTURE.md‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -73,9 +73,10 @@ exposes the same three verbs.
7373
Returns an in-process `Memory` (`add`, `search`, `list`, `close`) for host
7474
workers and ingestion modules that already resolved identity.
7575

76-
**Agent tools are not in this package.** Routes are OpenAPI-described
77-
(`describeRoute`). The host mounts `@corbitsdev/hono-openapi-mcp` (or any
78-
OpenAPI→tools bridge) so agents call these routes under Interchange auth.
76+
**Agent tools live in this package** as thin HTTP clients
77+
(`@corbits/memory/tools` / `interchange.tools`): `defineTool` factories that
78+
`fetch` the mounted routes with install credentials. They do not import the
79+
in-process plane. OpenAPI→MCP remains an optional host bridge.
7980

8081
## Provenance
8182

‎CHANGELOG.md‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Added
11+
12+
- Interchange `defineTool` factories at `@corbits/memory/tools` (`memory_add`,
13+
`memory_search`, `memory_list`) — HTTP clients for mounted hub routes with
14+
install env `memoryBaseUrl` / `memoryTenantId` / `memoryAuthToken`. Declared
15+
via `package.json` `interchange.tools` and `exports["./tools"]`.
16+
1017
### Changed
1118

1219
- **Breaking:** package and public surface renamed from `@corbits/knowledge-engine`

‎IMPLEMENTATION.md‎

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -487,13 +487,19 @@ Each route is guarded with `grantGuard(deps, action)`, which applies the host's
487487
| Method + path | Grant action | Request body | Response |
488488
|---|---|---|---|
489489
| `POST /api/tenants/:tenantId/memory/add` | `add` | `{ title, text, access_tags?, share? }` | `200 { documentId }`; `400` on validation |
490-
| `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 |
491-
| `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). |
490+
| `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 |
491+
| `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). |
492492

493493
`registerMemoryRoutes` and `createMemory({ app })` register the three HTTP routes.
494-
Agent tools are a host concern — mount `@corbitsdev/hono-openapi-mcp` (or any
495-
OpenAPI→tools bridge) against the same app. The plane surface is only
496-
`add` / `search` / `list` (plus `close`); inference stays on the host.
494+
Agent tools ship in this package as Interchange `defineTool` factories
495+
(`@corbits/memory/tools` / `interchange.tools`): thin HTTP clients that call the
496+
mounted routes with install env (`memoryBaseUrl`, `memoryTenantId`,
497+
`memoryAuthToken`). They do not import the plane. Host checklist: agent principal
498+
needs `memory:add` and/or `memory:search` grants; Bearer token only (no session
499+
cookie path); tool results are JSON strings; pass `AbortSignal` if you need hang
500+
protection — the client has no default timeout. OpenAPI→MCP remains an optional
501+
host bridge. The plane surface is only `add` / `search` / `list` (plus `close`);
502+
inference stays on the host.
497503

498504

499505

@@ -518,8 +524,8 @@ Document access is Interchange authz — **not** a mini-ACL.
518524
- Write path: `resolveAccessTags` always writes `memory.owner:<caller>` and
519525
merges optional `accessTags` / share sugar (`tenant`, peer `principals`,
520526
explicit `tags`). Stored on `knowledge.document.access_tags`.
521-
- Read path (find + recent): `canAccessDocument` — creator always allowed;
522-
otherwise `authorize(grantStore, principal, tenant, tag, "find")` for any
527+
- Read path (search + list): `canAccessDocument` — creator always allowed;
528+
otherwise `authorize(grantStore, principal, tenant, tag, "search")` for any
523529
tag on the document.
524530
- SQL retrieval is **tenant-scoped only**. Document access is grant-tag
525531
post-filter in the plane (`canAccessDocument`); there is no SQL mini-ACL.

‎PRODUCT.md‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ never creates one; it mounts onto yours.
1818
| `loadMemoryConfig()` | Config from env |
1919
| `runMemoryMigrations(url)` | Apply pgvector schema |
2020
| `registerMemoryRoutes` | Low-level HTTP only (optional) |
21+
| `@corbits/memory/tools` | Interchange `defineTool` factories (`memory_add` / `memory_search` / `memory_list`) |
2122

2223
### Verbs
2324

@@ -53,9 +54,10 @@ Agent / ingestion module
5354
```
5455

5556
1. **Mount** — host passes `app` + the same grant store it already uses.
56-
2. **Tools** — host exposes the OpenAPI routes as agent tools (e.g.
57-
`@corbitsdev/hono-openapi-mcp`). Agents call add/search/list as the
58-
authenticated principal.
57+
2. **Tools** — install `@corbits/memory/tools` (`defineTool` factories) on a
58+
workflow with env credentials (`memoryBaseUrl`, `memoryTenantId`,
59+
`memoryAuthToken`). Tools HTTP-call the mounted routes; identity is the
60+
hub-authenticated principal. OpenAPI→MCP remains an optional host bridge.
5961
3. **Ingestion** — host modules (webhooks, batch jobs) call the routes or the
6062
returned plane with a resolved principal.
6163

‎README.md‎

Lines changed: 48 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,9 @@ Memory for [Interchange](https://github.com/corbitsdev) hubs: **add**, **search*
55

66
Mount it on the hub. Routes land under `/api/tenants/:tenantId/memory/*`, so
77
the hub’s existing `createResolveTenant` middleware supplies principal + tenant
8-
— same as workflows, assets, and agents. Agents and ingestion modules call those
9-
routes (tools / OpenAPI→MCP, or in-process from a host worker). That’s the
10-
product.
8+
— same as workflows, assets, and agents. Workflow agents install the package’s
9+
`defineTool` factories; ingestion modules call the same routes or the in-process
10+
plane. That’s the product.
1111

1212
Requires Bun 1.2+.
1313

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

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

2525
## Mount (≈5 lines)
2626

@@ -53,17 +53,54 @@ Missing grant → **403**.
5353

5454
```http
5555
POST /api/tenants/:tenantId/memory/add { "title", "text", "access_tags"?, "share"? }
56-
POST /api/tenants/:tenantId/memory/search { "query", "limit"? }
56+
POST /api/tenants/:tenantId/memory/search { "query", "limit"?, "kinds"?, "entity_ids"?, "sources"?, "includeEvidence"? }
5757
GET /api/tenants/:tenantId/memory/list ?limit=
5858
```
5959

60-
## Who calls the routes
60+
## Workflow agent tools
6161

62-
1. **Agent tools** — routes are OpenAPI-described (`hono-openapi`). On the host,
63-
mount `@corbitsdev/hono-openapi-mcp` (or any OpenAPI→tools bridge) so agents
64-
get tools that hit the memory paths under Interchange auth.
65-
2. **Ingestion modules** — host workers that already resolved identity call the
66-
same plane in-process (no HTTP hop):
62+
This package exports Interchange `defineTool` factories at
63+
`@corbits/memory/tools` (also `package.json` → `interchange.tools`). Each tool
64+
is a thin HTTP client: install credentials in agent env, call the mounted hub
65+
routes. No plane inject, no model-supplied identity.
66+
67+
| Factory id | Tool name | HTTP |
68+
| --- | --- | --- |
69+
| `@corbits/memory/add` | `memory_add` | `POST …/memory/add` |
70+
| `@corbits/memory/search` | `memory_search` | `POST …/memory/search` |
71+
| `@corbits/memory/list` | `memory_list` | `GET …/memory/list` |
72+
73+
**Env keys** (declared on each factory’s `requires`):
74+
75+
| Key | Meaning |
76+
| --- | --- |
77+
| `memoryBaseUrl` | Hub **origin** only, e.g. `https://hub.example` (no `/api/...` path) |
78+
| `memoryTenantId` | Tenant path segment (must match the principal’s tenant on the hub) |
79+
| `memoryAuthToken` | Bearer token the hub accepts for that agent principal |
80+
81+
**Host checklist**
82+
83+
1. Mount routes: `createMemory({ app, grantStore, … })` under the hub tenant tree.
84+
2. Grant the agent principal `memory:add` and/or `memory:search` (`list` uses `search`).
85+
3. For peer/space share visibility, also grant `search` on the relevant document tags (see `docs/AUTHZ-DOCUMENT-ACCESS.md`).
86+
4. Install factories on the workflow and set the three env keys above.
87+
5. Auth is **Bearer only** on the tool client — session cookies are not sent.
88+
6. Tool results are **JSON strings** (`stringTool`); pass `AbortSignal` if you need hang protection (no default client timeout).
89+
90+
```ts
91+
import { memoryAdd, memorySearch, memoryList } from "@corbits/memory/tools";
92+
93+
// On a workflow / agent definition — install like any open tool package:
94+
// tools: [memoryAdd, memorySearch, memoryList]
95+
// and supply memoryBaseUrl / memoryTenantId / memoryAuthToken in agent env.
96+
```
97+
98+
OpenAPI→MCP remains available as an alternative host bridge; the shipped
99+
`defineTool`s are the primary install path for workflow agents.
100+
101+
## Ingestion (in-process)
102+
103+
Host workers that already resolved identity can call the plane without HTTP:
67104

68105
```ts
69106
await memory.add({

‎bun.lock‎

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎docs/AUTHZ-DOCUMENT-ACCESS.md‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -79,9 +79,9 @@ Tag minting is **not** grant minting. For peer share to work in product:
7979

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

102102
```ts
103-
authorize(grantStore, principalId, tenantId, "memory", "find"|"add")
103+
authorize(grantStore, principalId, tenantId, "memory", "search"|"add")
104104
// effect must be "allow"
105105
```
106106

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

122122
1. `collectGrants(principalId, tenantId)` once per request.
123-
2. Keep allow-grants whose `action` matches `find` (exact or pattern).
123+
2. Keep allow-grants whose `action` matches `search` (exact or pattern).
124124
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.
125125

126126
This keeps evaluation inside Interchange authz semantics (specificity, conditions, deny).

‎package.json‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,11 @@
55
"exports": {
66
".": "./src/index.ts",
77
"./migrations": "./src/migrations.ts",
8-
"./config": "./src/mount-config.ts"
8+
"./config": "./src/mount-config.ts",
9+
"./tools": "./src/tools/index.ts"
10+
},
11+
"interchange": {
12+
"tools": "./src/tools/index.ts"
913
},
1014
"license": "LGPL-2.1-only",
1115
"type": "module",
@@ -20,6 +24,7 @@
2024
"test:coverage": "bun test --coverage --coverage-reporter=lcov --coverage-reporter=text ./src"
2125
},
2226
"dependencies": {
27+
"@intx/agent": "0.2.2",
2328
"@intx/authz": "0.2.2",
2429
"@intx/hub-api": "0.2.2",
2530
"@intx/log": "0.2.2",

‎src/http-bodies.ts‎

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
/**
2+
* Shared request bodies for hub memory HTTP and defineTool factories.
3+
* Keep route validators and tool arg parsers on the same schemas.
4+
*
5+
* GET list uses a string query param on the wire (`ListQuery`); tools use
6+
* numeric `ListArgs`. Bounds are shared via `limits.ts` and `parseListLimitString`.
7+
*/
8+
import { type } from "arktype";
9+
10+
import {
11+
LIST_LIMIT_MAX,
12+
LIST_LIMIT_MIN,
13+
SEARCH_LIMIT_MAX,
14+
SEARCH_LIMIT_MIN,
15+
} from "./limits.ts";
16+
17+
export const ShareBody = type({
18+
"tenant?": "boolean",
19+
"principals?": "string[]",
20+
"tags?": "string[]",
21+
});
22+
23+
export const AddRequest = type({
24+
title: "string >= 1",
25+
text: "string >= 1",
26+
"access_tags?": "string[]",
27+
"share?": ShareBody,
28+
});
29+
30+
export type AddRequest = typeof AddRequest.infer;
31+
32+
export const SearchRequest = type({
33+
query: "string >= 1",
34+
"limit?": type(`${SEARCH_LIMIT_MIN} <= number.integer <= ${SEARCH_LIMIT_MAX}`),
35+
"kinds?": "string[]",
36+
"entity_ids?": "string[]",
37+
"sources?": "string[]",
38+
"includeEvidence?": "boolean",
39+
});
40+
41+
export type SearchRequest = typeof SearchRequest.infer;
42+
43+
/** HTTP query schema for GET /memory/list (string limit from the URL). */
44+
export const ListQuery = type({
45+
"limit?": "string",
46+
});
47+
48+
export type ListQuery = typeof ListQuery.infer;
49+
50+
/** Tool-arg shape for memory_list (numeric limit after LLM coerce). */
51+
export const ListArgs = type({
52+
"limit?": type(`${LIST_LIMIT_MIN} <= number.integer <= ${LIST_LIMIT_MAX}`),
53+
});
54+
55+
export type ListArgs = typeof ListArgs.infer;
56+
57+
/**
58+
* Parse a list `limit` query string into a bounded integer.
59+
* Returns `undefined` for missing/empty; `null` for invalid/out-of-range.
60+
*/
61+
export function parseListLimitString(
62+
raw: string | undefined,
63+
): number | undefined | null {
64+
if (raw === undefined || raw === "") return undefined;
65+
const n = Number(raw);
66+
if (
67+
!Number.isInteger(n) ||
68+
n < LIST_LIMIT_MIN ||
69+
n > LIST_LIMIT_MAX
70+
) {
71+
return null;
72+
}
73+
return n;
74+
}
75+
76+
/** Coerce LLM-stringified integers before arktype number.integer checks. */
77+
export function coerceOptionalLimitArg(
78+
args: Record<string, unknown>,
79+
): Record<string, unknown> {
80+
const raw = args["limit"];
81+
if (raw === undefined || typeof raw === "number") return args;
82+
if (typeof raw === "string" && raw.trim() !== "") {
83+
const n = Number(raw);
84+
if (Number.isFinite(n)) {
85+
return { ...args, limit: n };
86+
}
87+
}
88+
return args;
89+
}
90+
91+
export function parseWithArk<T>(
92+
schema: (data: unknown) => T | type.errors,
93+
data: unknown,
94+
label: string,
95+
): T {
96+
const parsed = schema(data);
97+
if (parsed instanceof type.errors) {
98+
throw new Error(`${label}: ${parsed.summary}`);
99+
}
100+
return parsed;
101+
}

0 commit comments

Comments
 (0)