diff --git a/CLAUDE.md b/CLAUDE.md index d32c458db..29b9b7301 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -16,7 +16,7 @@ lets humans browse, search, and manage those lessons. | `@lorekit/core` | `packages/mcp-core/` | Scope validator, DB client, 10 tool handlers, OTel tracer/meter | | `@lorekit/feature-flags` | `packages/feature-flags/` | OpenFeature-standard flag evaluation. `registry.ts` is the single hand-authored source of truth (zod-validated, all four OpenFeature value types); `nx run feature-flags:generate` projects it into typed TS bindings + a language-neutral `flags.manifest.json`. `LoreKitFlagProvider` resolves: session override → A/B(/n) experiment (deterministic FNV-1a bucketing on a stable `targetingKey`) → static default. Telemetry is two mechanisms: `featureFlagOtelHook` stamps `feature_flag.*` span attributes per evaluation (server-side), while `packages/web`'s `FeatureFlagsProvider` tags RUM signals with `feature_flag.` (browser-side). UI-affecting experiments use a copy-and-suffix component convention (a resolver + one whole component per variant, never inline branches). `/settings/developer` (override UI) is open to any signed-in user outside production; in production it's gated by a server-side email allowlist (`developer-users.ts`, `notFound()` on the page). Package rules + file map: [`packages/feature-flags/CLAUDE.md`](./packages/feature-flags/CLAUDE.md). Full guide: [`docs/feature-flags.md`](./docs/feature-flags.md). Add/update/remove a flag via the `feature-flags` skill | | `@lorekit/web` | `packages/web/` | Next.js 15 dashboard (Vercel) | -| `@lorekit/cli` | `packages/cli/` | Zero-dep Node CLI. **Setup:** `install`/`uninstall`/`doctor`/`update` (scaffold skills + MCP + lifecycle hooks into `.claude`; connectivity/token/scope health checks; offline skill refresh). **Reads:** `list`/`search`/`show`/`stats`/`scopes`/`diff`/`tree`/`lint`/`dedupe`/`link` (Offline + Remote split, `--json`/`--scope`). **Recurrence tooling:** `obligations` (a changed-file set vs a declarative Surface-Partner Map; per-entry `state` `advisory`/`gating`/`retired`) + `invariants candidates` (candidate scan reusing `dedupe`'s clustering; never auto-compiles or gates). **Maintenance:** `purge`/`purge-expired` (remote-only, account-wide, irreversible, confirm-or-`--yes`). Plus `hook` (shared hook engine behind the plugins), `mcp` (local stdio MCP server), `migrate`, `completion`. Self-contained OTLP telemetry (`service.name=cli`). Full command reference: [`docs/cli.md`](./docs/cli.md) | +| `@lorekit/cli` | `packages/cli/` | Zero-dep Node CLI. **Setup:** `install`/`uninstall`/`doctor`/`update` (scaffold skills + MCP + lifecycle hooks into `.claude`; connectivity/token/scope health checks; offline skill refresh). **Reads:** `list`/`search`/`show`/`stats`/`scopes`/`quota`/`diff`/`tree`/`lint`/`dedupe`/`link` (Offline + Remote split, `--json`/`--scope`). **Recurrence tooling:** `obligations` (a changed-file set vs a declarative Surface-Partner Map; per-entry `state` `advisory`/`gating`/`retired`) + `invariants candidates` (candidate scan reusing `dedupe`'s clustering; never auto-compiles or gates). **Maintenance:** `purge`/`purge-expired` (remote-only, account-wide, irreversible, confirm-or-`--yes`). Plus `hook` (shared hook engine behind the plugins), `mcp` (local stdio MCP server), `migrate`, `completion`. Self-contained OTLP telemetry (`service.name=cli`). Full command reference: [`docs/cli.md`](./docs/cli.md) | | `plugins/` | `plugins/` | Per-framework deterministic bundles: `lorekit-claude` (marketplace plugin: skill + hooks + MCP), `lorekit-cursor` (rule + `stop` hook), `lorekit-codex` (feature-flagged hooks + `AGENTS.md` fallback, experimental). Root `.claude-plugin/marketplace.json` lists the Claude plugin. | | `supabase` | `supabase/` | Edge Functions (production MCP server), migrations, NX targets | diff --git a/docs/api-tokens.md b/docs/api-tokens.md index 7ad7d88cc..60e21eae7 100644 --- a/docs/api-tokens.md +++ b/docs/api-tokens.md @@ -123,6 +123,7 @@ routed into an org the token was never granted. | A write addressed BY ID (`PATCH` update, `DELETE /:id`, `POST /memories/:id/restore`) | Filtered by the allowlist and personal-only — narrowed to the caller's own rows within its scopes, never widened to org rows or another writer's. | | A removal or restore that NAMES a scope+key (`DELETE ?scope=…&key=…`, the `POST /memories/restore` body form, and the MCP `memory.delete` / `memory.archive` / `memory.restore` tools) | **Scope-authorized.** For a scope INSIDE the allowlist a scoped token may archive / hard-delete / restore **any** writer's row for that `(scope, key)` — the management authority the owner granted by scoping the key — and a 0-row result is reported as `404` (nothing there) rather than `403` (present, but not this token's to touch), so "already gone" and "not yours" stay distinguishable. A scope OUTSIDE the allowlist is refused with a `403`, like any other named scope. An UNSCOPED token is unchanged — own-rows-only. The `&org=` delete form stays role-gated inside its RPC (org membership + write capability), never widened by the scope allowlist. | | Retention policies and grooming — `policy.list/create/update/delete`, `groom.preview/run` on MCP; `GET/POST /policies`, `PATCH/DELETE /policies/:id`, `POST /groom/preview`, `POST /groom/run` on REST | **Gated the same way `memory.*` is, on both surfaces.** `policy.create` / `POST /policies` refuses (MCP `-32003`, REST `403`) a target `scope` outside the allowlist. `policy.update` / `policy.delete` and their REST twins gate the policy's **stored** scope (fetched before the mutation, not the request body — a policy's scope cannot be changed by an update) — a scope-restricted token cannot rename or delete a policy fenced outside its allowlist, even by id. `policy.list` / `GET /policies` **narrows**: a fenced-scope policy is silently dropped rather than refusing the whole list. `groom.preview` / `groom.run` and their REST twins gate the RESOLVED scope — an inline `scope`, or (via `policy_id`) the referenced policy's stored scope — so a scoped token cannot preview or archive against a saved policy it could not itself have created. Before this, these seven operations only checked read/write PERMISSION, not the scope allowlist — a scoped token could create, list, update, delete, preview or run grooming for any scope its permission tier allowed, regardless of its allowlist. | +| Your quota — `memory.quota`, `GET /memories/quota`, and the `lorekit quota` command that calls it | **Answered for every token, and its org half narrowed to the tenancy.** It names no scope and returns counts only, so there is nothing to refuse. `org_count` and `total` cover only the orgs the token reaches: `0` under `personal`, and only the `org_ids` the owner still belongs to under `selected`. `personal` is always the owner's whole personal count, whatever the scope allowlist, because the cap it is measured against is account-wide: a count cut to the allowlist would overstate `remaining`. | | The token is unscoped (the default) | Nothing changes, on any path. | ## Permission matrix diff --git a/docs/cli.md b/docs/cli.md index c5d61cd5a..bd60f67f4 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -180,6 +180,20 @@ a denied/unconfigured/unreachable/erroring remote degrades to a short, accurate `describeError` at exit 0 [the old `REMOTE_SCOPES_UNSUPPORTED` sentinel is gone — the surface supports enumeration now] +### `quota` + +Remote-only: your active memories against your plan's cap and how many writes are left, from +`GET /memories/quota` (the same answer as the `memory.quota` MCP tool). `personal` is the pair the +cap trigger enforces — personal memories against the personal limit, `remaining` = limit − count, +never negative; org-owned memories count against each org's own cap, so they are shown beside it +(`org_count`) and never subtracted; a token narrowed to `personal` or selected orgs sees `org_count` and +`total` over only the orgs it reaches. The shaping is the pure `memoryQuotaFromRow` +(`packages/mcp-core/src/limits/memory-quota.ts`, mirrored to `_shared/limits/`). Warns at the cap +and under 10 % headroom. Unlike the other read commands it **exits 1** when the remote is +denied/unconfigured/unreachable/erroring, because there is no offline answer to fall back to and a +script gating a bulk write needs to see the failure; `--json` emits +`{ available, endpoint, plan, personal, org_count, total }` or `{ available: false, reason }` + ### `diff` Compare offline vs remote for the applicable scopes and report **local-only** / **remote-only** / diff --git a/docs/decisions.md b/docs/decisions.md index 02cd3ae7a..2f55a3102 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -862,4 +862,4 @@ implementations to and could not hold a plpgsql third copy to at all. ## Client reads are never server actions -**A React Query read in `packages/web` must not call a server action.** Next's app router runs server actions one at a time per client (the router's action queue starts the next one only when the previous resolves), and an action cannot take an `AbortSignal`. A read served by one queues behind every other action in flight, cannot be cancelled when its query is superseded, and keeps `useIsFetching()` (and so the TopBar activity bar) non-zero until the queue drains. It was measured, not theorised: on `/lore` the memory badge (`getPlanUsage`), the pending-invites query (`listPendingInvitesForMe`) and the Explorer list (`listMemories`) were all actions. Every load and every window focus (`FocusRefetcher`) ran them strictly in that order, each `POST /lore` starting the millisecond the previous one ended. The list arrived last, and the bar stayed on for seconds after the parallel REST panels had loaded. In the worst case seen, a 50 s Supabase `auth/v1/user` call inside the list action, plus a focus refetch queued behind it, kept the bar on for about 85 s. The fix is transport only: the list calls `POST /memories/list` from the browser (`fetchMemoryPage`, body from the pure `lib/memory-list.ts`), and the two reads with no REST endpoint yet go through GET route handlers (`/api/plan/usage`, `/api/invites/pending`) read with `getAppRoute(path, signal)`. Moving those two onto proper edge endpoints is still the backlog `packages/web/CLAUDE.md` names; the route handlers are the step that removes the queue without a schema/handler/OpenAPI change. Server actions remain right for mutations and for server components, where calling the function is a plain call. Guarded by `lib/queries/parallel-reads.spec.ts`, which mocks the action modules to throw. +**A React Query read in `packages/web` must not call a server action.** Next's app router runs server actions one at a time per client (the router's action queue starts the next one only when the previous resolves), and an action cannot take an `AbortSignal`. A read served by one queues behind every other action in flight, cannot be cancelled when its query is superseded, and keeps `useIsFetching()` (and so the TopBar activity bar) non-zero until the queue drains. It was measured, not theorised: on `/lore` the memory badge (`getPlanUsage`), the pending-invites query (`listPendingInvitesForMe`) and the Explorer list (`listMemories`) were all actions. Every load and every window focus (`FocusRefetcher`) ran them strictly in that order, each `POST /lore` starting the millisecond the previous one ended. The list arrived last, and the bar stayed on for seconds after the parallel REST panels had loaded. In the worst case seen, a 50 s Supabase `auth/v1/user` call inside the list action, plus a focus refetch queued behind it, kept the bar on for about 85 s. The fix is transport only: the list calls `POST /memories/list` from the browser (`fetchMemoryPage`, body from the pure `lib/memory-list.ts`), and the two reads with no REST endpoint yet go through GET route handlers (`/api/plan/usage`, `/api/invites/pending`) read with `getAppRoute(path, signal)`. Moving those two onto proper edge endpoints is still the backlog `packages/web/CLAUDE.md` names; the route handlers are the step that removes the queue without a schema/handler/OpenAPI change. Server actions remain right for mutations and for server components, where calling the function is a plain call. Guarded by `lib/queries/parallel-reads.spec.ts`, which mocks the action modules to throw. **Plan usage has since left the stopgap**: it is `GET /memories/quota`, with `memory.quota` on MCP and `lorekit quota` on the CLI — the one quota read agents get, not a breach of "Dashboard analytics reads stay REST-only": it is the caller's own headroom against the cap that refuses `memory.write`, which an agent can act on before a bulk write, not an aggregate for a chart. diff --git a/docs/key-files.md b/docs/key-files.md index efdb71233..53ec2b960 100644 --- a/docs/key-files.md +++ b/docs/key-files.md @@ -117,7 +117,8 @@ else. Grouped loosely by subsystem in migration/PR order. | `packages/web/src/lib/queries/audit-log.ts` | `useAuditLog` — `useInfiniteQuery` over the `listAuditLog` server action, keyed by the URL-backed filter state (action set / name / date range); drives the feed's "Load more" | | `packages/web/src/lib/memory-list.ts` | Pure Lore Explorer list request: `MemoryFilters` → the `POST /memories/list` body (`memoryListBody`: default `created_at` sort, page-size clamp, legacy `tags` folded into a `label` filter, retention thresholds) and the response → `MemoryPage` (`memoryPageFromResponse`). Moved out of the former `listMemories` server action when `useMemories` became a browser request; `memory-list.spec.ts` pins the body | | `packages/web/src/lib/app-route.ts` | `getAppRoute(path, signal)` — the browser GET for the dashboard's own `app/api/**` route handlers (same-origin cookie auth, `no-store`, throws `AppRouteError` on non-2xx). Used for reads that still need server code but must not be server actions (see `packages/web/CLAUDE.md` → "A client read is never a server action") | -| `packages/web/src/app/api/plan/usage/route.ts`, `packages/web/src/app/api/invites/pending/route.ts` | GET route handlers wrapping `getPlanUsage` / `listPendingInvitesForMe` for `useMemoryTotal` and `usePendingInvitesForMe`. Same contract as the functions (`null` / `[]` when signed out or on failure), `force-dynamic`, `no-store`. `lib/queries/parallel-reads.spec.ts` asserts the hooks read them with React Query's signal and never call the server actions | +| `packages/web/src/app/api/invites/pending/route.ts` | GET route handler wrapping `listPendingInvitesForMe` for `usePendingInvitesForMe` — a stopgap until invites have an edge endpoint. Same contract as the function (`[]` when signed out or on failure), `force-dynamic`, `no-store`. `lib/queries/parallel-reads.spec.ts` asserts the hook reads it with React Query's signal and never calls the server action | +| `packages/mcp-core/src/limits/memory-quota.ts` (← mirrored to `supabase/functions/_shared/limits/memory-quota.ts`) | `memoryQuotaFromRow` — shapes `lorekit_memory_count` (00047) into the quota contract behind `GET /memories/quota` (`memories/handlers/quota.ts`), the `memory.quota` MCP tool (`toolQuota`) and `lorekit quota`: the PERSONAL pair the cap trigger enforces plus `org_count` beside it, because the RPC's combined `count` is measured against the personal limit only | | `supabase/migrations/00012_audit_log_search.sql` | Enables `pg_trgm` + a GIN trigram index on `audit_log.target` (index-backed `ilike` name search) and a `(user_id, created_at desc, id)` index covering the keyset seek (00010's `(user_id, created_at desc)` index lacks the `id` tiebreaker) | | `supabase/migrations/00017_org_roles_and_author.sql` | Adds `viewer` to the `org_members.role` CHECK; adds `memories.created_by`/`updated_by`; creates `lorekit_org_role` + `lorekit_org_can(user, org, capability)` — the single `SECURITY DEFINER` capability source for org writes/deletes (org-sharing Phase 2; see plan `.agent/claude/org-sharing-phase-2-writes/plan.md`) | | `supabase/migrations/00018_org_limits.sql` | `org_limits` table (mirrors `user_limits`) + `lorekit_get_org_limit`; re-creates `enforce_memory_cap()` with an org branch that runs BEFORE the `user_id IS NULL` service exemption, so an org-owned row (always `user_id NULL`) is never cap-exempt | diff --git a/docs/limits.md b/docs/limits.md index 4b796bea5..c3d4c4834 100644 --- a/docs/limits.md +++ b/docs/limits.md @@ -42,6 +42,21 @@ When a write would exceed the cap, the DB raises a custom error (SQLSTATE > delete unused memories, or upgrade your plan — see > https://lorekit.io (or contact support) to increase it." +### Reading your headroom before you hit it + +`GET /memories/quota` (REST), `memory.quota` (MCP) and `lorekit quota` (CLI) +all answer `{ plan, personal: { count, limit, remaining }, org_count, total }` +from `lorekit_memory_count` (00047), shaped by the pure `memoryQuotaFromRow` +(`packages/mcp-core/src/limits/memory-quota.ts`). `personal` is exactly the +pair the trigger above enforces — personal active rows against the personal +limit — so `remaining` is the number of new personal writes left. Org-owned +rows count against each org's own cap (`00018_org_limits.sql`), so they are +reported as `org_count` beside it and never subtracted. A token narrowed to +`personal` or to selected orgs gets `org_count` and `total` recounted over only +the orgs it reaches (`quotaForKey`, same file, applying `effectiveOrgIds`); the +personal pair is never narrowed, because the cap is account-wide. A service-role +caller has no user and is refused. Per-org quotas are not reported yet. + ### The cap is also a WRITE-COST parameter Raising it is not free, and not for the reason you would expect. The trigger diff --git a/docs/mcp-tools.md b/docs/mcp-tools.md index 2b947a267..9c65d711d 100644 --- a/docs/mcp-tools.md +++ b/docs/mcp-tools.md @@ -44,7 +44,7 @@ used to show up as write + destructive in MCP clients. | Class | Tools | |-------|-------| -| Read-only | `memory.read`, `memory.list`, `memory.search`, `memory.scopes`, `memory.list_archived`, `org.list`, `policy.list`, `groom.preview` | +| Read-only | `memory.read`, `memory.list`, `memory.search`, `memory.scopes`, `memory.quota`, `memory.list_archived`, `org.list`, `policy.list`, `groom.preview` | | Write, non-destructive | `memory.archive`, `memory.restore`, `memory.protect`, `groom.run`, `org.create`, `policy.create` | | Write, destructive | `memory.write`, `memory.delete`, `memory.purge`, `memory.purge_expired`, `org.rename`, `org.delete`, `policy.update`, `policy.delete` | @@ -526,6 +526,42 @@ so whole scopes go missing for exactly the accounts with the most lore. --- +## memory.quota + +How many active memories you hold against your plan's cap, and how many writes are left before +`memory.write` is refused with `memory_cap`. Takes no arguments. Requires a token with read +permission (`lk_rw_*` or `lk_ro_*`); a service-role token has no user and is refused. + +`personal` is the pair the cap enforces: your personal memories against your personal limit, with +`remaining` = limit − count (never negative). Org-owned memories count against each org's own cap, +so they are reported as `org_count` beside it and never subtracted. It is the same answer +`GET /memories/quota` and the `lorekit quota` command return. + +A token narrowed to `personal` or to selected orgs sees `org_count` and `total` over only the +orgs it reaches: `0` under `personal`. `personal` is never narrowed, by tenancy or by a scope +allowlist, because the cap it is measured against is account-wide +([what a scoped token sees](./api-tokens.md#what-a-scoped-token-sees)). + +```json +{ + "params": { + "name": "memory.quota", + "arguments": {} + } +} +``` + +```json +{ + "plan": "free", + "personal": { "count": 3318, "limit": 5000, "remaining": 1682 }, + "org_count": 982, + "total": 4300 +} +``` + +--- + ## memory.list_archived List soft-archived lessons for a scope, newest archived first. Requires a token with read permission (`lk_rw_*` or `lk_ro_*`). `scope` is **optional** — omit it to list the archive across every scope you can see. diff --git a/packages/cli/README.md b/packages/cli/README.md index 7dd4d69d5..3aa2b8902 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -408,6 +408,22 @@ Offline one. A denied, unconfigured, unreachable, or erroring remote degrades to a short, accurate note (network error / HTTP status — never a faked listing) at exit 0. `--endpoint` / `--token` / `--store` behave as in `list`. +### `lorekit quota` + +Your active memories against your plan's cap and how many writes are left — +remote only, since the offline store has no plan and no cap. + +```bash +lorekit quota +lorekit quota --json # { available, endpoint, plan, personal, org_count, total } +``` + +`personal` is the pair the cap enforces; org-owned memories count against each +org's own cap and are shown beside it, never subtracted. A token narrowed to +`personal` or selected orgs sees only the orgs it reaches. Exits 1 when the remote +is denied, unconfigured, unreachable or erroring, so a script can gate a bulk +write on it. + ### `lorekit diff` Compare the **offline** and **remote** stores for the applicable scopes and diff --git a/packages/cli/bin/lorekit.mjs b/packages/cli/bin/lorekit.mjs index ea0f75d05..26fe64a8d 100755 --- a/packages/cli/bin/lorekit.mjs +++ b/packages/cli/bin/lorekit.mjs @@ -66,6 +66,9 @@ ${c.bold('Commands')} with a memory count per scope — not cwd-scoped like the commands above (it lists scopes anywhere in the store). Offline is exact; the remote can't enumerate scopes (honest note). --json, --scope . + quota Show your active memories against your plan's cap and how many + writes are left (remote only). Exits 1 if the remote can't answer. + --json. diff Compare the offline and remote stores for the applicable scopes and report divergence: local-only, remote-only, and conflicting keys (grouped by scope). Needs both stores readable. --json, --scope . @@ -553,6 +556,34 @@ ${c.bold('Examples')} npx @lorekit/cli scopes npx @lorekit/cli scopes --json npx @lorekit/cli scopes --scope repo:: +`, + quota: `${c.bold('lorekit quota')} — your memories against your plan's cap + +${c.bold('Usage')} + npx @lorekit/cli quota [options] + +Shows how many active memories you hold against your plan's cap and how many +writes are left before the remote refuses one with \`memory_cap\` — from +\`GET /memories/quota\`, the same answer the \`memory.quota\` MCP tool gives. + +"personal" is the pair the cap enforces: your personal memories against your +personal limit. Org-owned memories count against each org's own cap, so they +are shown beside it and never subtracted from what is left. A token narrowed to +personal or selected orgs counts only the orgs it reaches. + +Remote only: the offline store has no plan and no cap. Exits 1 when the remote +is denied, unconfigured, unreachable or erroring, so a script can gate a bulk +write on it. + +${c.bold('Options')} + -d, --dir Target project root (default: current directory) + --json Machine-readable output + -e, --endpoint Remote endpoint override (else .mcp.json / LOREKIT_MCP_URL) + -t, --token Remote token override (else .mcp.json / LOREKIT_TOKEN) + +${c.bold('Examples')} + npx @lorekit/cli quota + npx @lorekit/cli quota --json `, diff: `${c.bold('lorekit diff')} — compare the offline and remote stores diff --git a/packages/cli/src/commands.mjs b/packages/cli/src/commands.mjs index 96709a598..836a1ccdd 100644 --- a/packages/cli/src/commands.mjs +++ b/packages/cli/src/commands.mjs @@ -44,6 +44,7 @@ import { write } from './commands/write.mjs'; import { archive, del, restore } from './commands/remove.mjs'; import { stats } from './commands/stats.mjs'; import { scopes } from './commands/scopes.mjs'; +import { quota } from './commands/quota.mjs'; import { diff } from './commands/diff.mjs'; import { tree } from './commands/tree.mjs'; import { lint } from './commands/lint.mjs'; @@ -81,6 +82,7 @@ export const COMMANDS = [ { name: 'show', run: show, traced: true, strictFlags: true, tool: 'memory.read' }, { name: 'stats', run: stats, traced: true, strictFlags: true, native: 'local rollup over the resolved store' }, { name: 'scopes', run: scopes, traced: true, strictFlags: true, tool: 'memory.scopes' }, + { name: 'quota', run: quota, traced: true, strictFlags: true, tool: 'memory.quota' }, { name: 'diff', run: diff, traced: true, strictFlags: true, native: 'compares two scopes' }, { name: 'tree', run: tree, traced: true, strictFlags: true, native: 'resolves the scope hierarchy for a directory', aliases: ['resolve'] }, { name: 'lint', run: lint, traced: true, strictFlags: true, native: 'quality pass over stored lessons' }, diff --git a/packages/cli/src/commands/quota.mjs b/packages/cli/src/commands/quota.mjs new file mode 100644 index 000000000..2c0ba866d --- /dev/null +++ b/packages/cli/src/commands/quota.mjs @@ -0,0 +1,85 @@ +// `lorekit quota` — your memories against your plan's cap (GET /memories/quota). +// Remote-only, and exits 1 when the remote cannot answer: a script gating a bulk +// write needs to see that failure, and there is no offline answer to fall back to. +import process from 'node:process'; +import { resolveProjectRoot } from '../shared/config.mjs'; +import { resolveDenies } from '../shared/control.mjs'; +import { resolveStores, remoteUnavailableReason } from '../shared/stores.mjs'; +import { describeError } from '../shared/lessons-view.mjs'; +import { log, heading, status, c } from '../shared/util.mjs'; + +/** Headroom below this share of the cap earns a warning, so the cap is never a surprise. */ +const LOW_HEADROOM_RATIO = 0.1; + +export async function quota(args) { + const root = resolveProjectRoot(args.dir); + const env = { ...process.env }; + + const { remote, connection } = resolveStores(root, { + env, + endpoint: args.endpoint, + token: args.token, + }); + const { remoteDenied } = resolveDenies(root, { env }); + + let section; + if (remoteDenied) { + section = { available: false, reason: `disabled by deny constraint (${remoteDenied.source})` }; + } else if (!remote.usable()) { + section = { available: false, reason: remoteUnavailableReason(connection) }; + } else { + const res = await remote.quota(); + section = res.ok ? { available: true, ...res.quota } : { available: false, reason: describeError(res) }; + } + + if (args.json) { + log(JSON.stringify( + section.available ? { available: true, endpoint: connection.endpoint, ...withoutFlag(section) } : section, + null, + 2, + )); + } else { + render(section, remote.usable() ? connection.endpoint : undefined); + } + + // Bounded, non-PII telemetry extras: counts, never a scope, path or token. + return { + exitCode: section.available ? 0 : 1, + 'lorekit.cli.quota.available': section.available, + 'lorekit.cli.quota.personal_count': section.available ? section.personal.count : 0, + 'lorekit.cli.quota.personal_limit': section.available ? section.personal.limit : 0, + }; +} + +function withoutFlag({ available: _available, ...rest }) { + return rest; +} + +function render(section, endpoint) { + heading('LoreKit quota'); + if (endpoint) log(` ${c.dim(endpoint)}`); + + if (!section.available) { + status('warn', 'unavailable', section.reason); + log(''); + return; + } + + const { plan, personal, org_count: orgCount, total } = section; + log(` ${c.dim('plan'.padEnd(9))}${c.bold(plan)}`); + log( + ` ${c.dim('personal'.padEnd(9))}${c.bold(`${personal.count} / ${personal.limit}`)} ` + + c.dim(`(${personal.remaining} left)`), + ); + if (orgCount > 0) { + log(` ${c.dim('org'.padEnd(9))}${orgCount} ${c.dim("(counted against each org's own cap)")}`); + } + log(` ${c.dim('total'.padEnd(9))}${total}`); + + if (personal.limit > 0 && personal.remaining === 0) { + status('warn', 'at the cap', 'new personal writes are refused until you archive or delete some'); + } else if (personal.limit > 0 && personal.remaining < personal.limit * LOW_HEADROOM_RATIO) { + status('warn', 'near the cap', `${personal.remaining} personal writes left`); + } + log(''); +} diff --git a/packages/cli/src/shared/completions.mjs b/packages/cli/src/shared/completions.mjs index bcb7c5d92..38f1c49ba 100644 --- a/packages/cli/src/shared/completions.mjs +++ b/packages/cli/src/shared/completions.mjs @@ -137,6 +137,8 @@ const COMMANDS = [ flags: ['dir', 'scope', 'json', 'endpoint', 'token', 'store'] }, { name: 'scopes', summary: 'Inventory every distinct scope', flags: ['dir', 'scope', 'json', 'endpoint', 'token', 'store'] }, + { name: 'quota', summary: 'Show your memories against your plan cap', + flags: ['dir', 'json', 'endpoint', 'token'] }, { name: 'diff', summary: 'Compare the offline and remote stores', flags: ['dir', 'scope', 'json', 'endpoint', 'token', 'store'] }, { name: 'tree', summary: 'Show scope precedence and which memory wins', aliases: ['resolve'], diff --git a/packages/cli/src/shared/mirror-pairs.mjs b/packages/cli/src/shared/mirror-pairs.mjs index 2a15599cf..ef44caf82 100644 --- a/packages/cli/src/shared/mirror-pairs.mjs +++ b/packages/cli/src/shared/mirror-pairs.mjs @@ -73,6 +73,7 @@ export const mirrorPairs = [ { core: 'packages/mcp-core/src/limits/dry-run.ts', edge: 'supabase/functions/_shared/limits/dry-run.ts', driftChecked: true }, { core: 'packages/mcp-core/src/telemetry/usage-stats.ts', edge: 'supabase/functions/_shared/telemetry/usage-stats.ts', driftChecked: true }, { core: 'packages/mcp-core/src/limits/expiring-window.ts', edge: 'supabase/functions/_shared/limits/expiring-window.ts', driftChecked: true }, + { core: 'packages/mcp-core/src/limits/memory-quota.ts', edge: 'supabase/functions/_shared/limits/memory-quota.ts', driftChecked: true }, { core: 'packages/mcp-core/src/rest/cors-origins.ts', edge: 'supabase/functions/_shared/api/cors-origins.ts', driftChecked: true }, { core: 'packages/mcp-core/src/rest/count-cursor.ts', edge: 'supabase/functions/_shared/api/count-cursor.ts', driftChecked: true }, { core: 'packages/mcp-core/src/scope/scope-type-attribute.ts', edge: 'supabase/functions/_shared/scope/scope-type-attribute.ts', driftChecked: true }, diff --git a/packages/cli/src/store/remote.mjs b/packages/cli/src/store/remote.mjs index 6733362ff..4291943e0 100644 --- a/packages/cli/src/store/remote.mjs +++ b/packages/cli/src/store/remote.mjs @@ -786,6 +786,36 @@ class RemoteStore { }; } + // `GET /memories/quota`, the REST twin of `memory.quota`. Remote-only: the + // offline store has no plan and no cap. Failures use the standard envelope. + async quota() { + const res = await this._rest('/memories/quota'); + if (!res.ok) { + return { + ok: false, + error: res.error, + httpStatus: res.httpStatus, + networkError: res.networkError, + unusable: res.unusable, + }; + } + const q = res.data ?? {}; + const count = (value) => (Number.isFinite(Number(value)) ? Number(value) : 0); + return { + ok: true, + quota: { + plan: typeof q.plan === 'string' ? q.plan : 'free', + personal: { + count: count(q.personal?.count), + limit: count(q.personal?.limit), + remaining: count(q.personal?.remaining), + }, + org_count: count(q.org_count), + total: count(q.total), + }, + }; + } + // ── Retention policies ("grooming") → REST ──────────────────────────────── // Server-side only (retention_policies table + pg_cron sweep) — v1 has no // local-store equivalent, matching the account-wide sweeps just above. diff --git a/packages/cli/src/surfaces.generated.mjs b/packages/cli/src/surfaces.generated.mjs index ff87df99a..26ac62d05 100644 --- a/packages/cli/src/surfaces.generated.mjs +++ b/packages/cli/src/surfaces.generated.mjs @@ -14,6 +14,7 @@ export const MCP_TOOL_NAMES = [ "memory.search", "memory.archive", "memory.scopes", + "memory.quota", "memory.list_archived", "memory.restore", "memory.purge", @@ -40,6 +41,7 @@ export const MEMORY_TOOL_NAMES = [ "memory.search", "memory.archive", "memory.scopes", + "memory.quota", "memory.list_archived", "memory.restore", "memory.purge", @@ -387,6 +389,20 @@ export const MCP_TOOL_DEFS = [ "openWorldHint": false } }, + { + "name": "memory.quota", + "description": "How many active memories you hold against your plan's cap, and how many writes are left before memory.write is refused with memory_cap. Takes no arguments. Check it before a bulk write or a migration instead of discovering the cap from a failed write.", + "inputSchema": { + "type": "object", + "properties": {} + }, + "annotations": { + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false + } + }, { "name": "memory.list_archived", "description": "List archived (soft-deleted) lessons, for one scope or across every scope you can see", @@ -1413,6 +1429,7 @@ export const CLI_BINDINGS = { "search": "memory.search", "archive": "memory.archive", "scopes": "memory.scopes", + "quota": "memory.quota", "restore": "memory.restore", "purge": "memory.purge", "purge-expired": "memory.purge_expired", @@ -1446,6 +1463,7 @@ export const CLI_EXEMPT = { /** Op -> why the local stdio MCP server does not dispatch it. */ export const LOCAL_MCP_EXEMPT = { + "memory.quota": "server-side plan and memory cap; the offline store has no limit", "memory.list_archived": "reachable through memory.list's archived filter on the offline store", "memory.purge": "account-wide sweep against server-side state; the offline store has no equivalent", "memory.purge_expired": "account-wide sweep against server-side state; the offline store has no equivalent", diff --git a/packages/cli/test/quota.test.mjs b/packages/cli/test/quota.test.mjs new file mode 100644 index 000000000..c18c25f32 --- /dev/null +++ b/packages/cli/test/quota.test.mjs @@ -0,0 +1,126 @@ +// `lorekit quota` — the CLI face of GET /memories/quota. Remote-only, and the +// one read command that exits 1 when the remote cannot answer, because there +// is no offline answer to fall back to. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { spawn, spawnSync } from 'node:child_process'; +import http from 'node:http'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const BIN = fileURLToPath(new URL('../bin/lorekit.mjs', import.meta.url)); +const tmp = (prefix) => fs.mkdtempSync(path.join(os.tmpdir(), prefix)); + +const QUOTA = { + plan: 'free', + personal: { count: 4600, limit: 5000, remaining: 400 }, + org_count: 982, + total: 5582, +}; + +function baseEnv(home, extraEnv = {}) { + const env = { + ...process.env, + NO_COLOR: '1', + HOME: home, + USERPROFILE: home, + LOREKIT_HOME: home, + LOREKIT_TELEMETRY: '0', + }; + delete env.LOREKIT_TOKEN; + delete env.LOREKIT_MCP_URL; + delete env.LOREKIT_ENDPOINT; + return Object.assign(env, extraEnv); +} + +function runQuotaAsync(args, extraEnv) { + const root = tmp('lk-quota-root-'); + const home = tmp('lk-quota-home-'); + return new Promise((resolve) => { + const child = spawn(process.execPath, [BIN, 'quota', ...args, '--dir', root], { + env: baseEnv(home, extraEnv), + }); + let stdout = ''; + let stderr = ''; + child.stdout.on('data', (d) => (stdout += d)); + child.stderr.on('data', (d) => (stderr += d)); + child.on('close', (status) => resolve({ status, stdout, stderr })); + }); +} + +// `LOREKIT_MCP_URL` points at `/mcp`, from which the REST base is derived, so +// the command lands on `GET /memories/quota`. +async function withMockRemote(handler, fn) { + const seen = []; + const server = http.createServer((req, res) => { + seen.push(`${req.method} ${req.url}`); + handler(req, res); + }); + await new Promise((r) => server.listen(0, '127.0.0.1', r)); + const { port } = server.address(); + try { + return await fn({ + seen, + env: { LOREKIT_MCP_URL: `http://127.0.0.1:${port}/mcp`, LOREKIT_TOKEN: 'lk_ro_test' }, + }); + } finally { + server.close(); + } +} + +const answerWith = (body, status = 200) => (_req, res) => { + res.statusCode = status; + res.setHeader('content-type', 'application/json'); + res.end(JSON.stringify(body)); +}; + +test('quota --json reports the remote quota from GET /memories/quota', async () => { + await withMockRemote(answerWith(QUOTA), async ({ seen, env }) => { + const res = await runQuotaAsync(['--json'], env); + assert.equal(res.status, 0, res.stderr); + assert.ok(seen.some((r) => /^GET \/+memories\/quota$/.test(r)), `requests seen: ${seen.join(', ')}`); + const out = JSON.parse(res.stdout); + assert.equal(out.available, true); + assert.deepEqual( + { plan: out.plan, personal: out.personal, org_count: out.org_count, total: out.total }, + QUOTA, + ); + }); +}); + +test('quota prints the personal pair, the org count beside it, and warns near the cap', async () => { + await withMockRemote(answerWith(QUOTA), async ({ env }) => { + const res = await runQuotaAsync([], env); + assert.equal(res.status, 0, res.stderr); + const text = res.stdout + res.stderr; + assert.match(text, /4600 \/ 5000/); + assert.match(text, /400 left/); + assert.match(text, /982/); + assert.match(text, /near the cap/); + }); +}); + +test('quota exits 1 with an accurate note when the remote fails', async () => { + await withMockRemote(answerWith({ error: 'kaboom' }, 500), async ({ env }) => { + const res = await runQuotaAsync(['--json'], env); + assert.equal(res.status, 1); + const out = JSON.parse(res.stdout); + assert.equal(out.available, false); + assert.match(out.reason, /500|kaboom/); + }); +}); + +test('quota exits 1 when no remote is configured', () => { + const root = tmp('lk-quota-root-'); + const home = tmp('lk-quota-home-'); + const res = spawnSync(process.execPath, [BIN, 'quota', '--json', '--dir', root], { + encoding: 'utf8', + env: baseEnv(home), + }); + assert.equal(res.status, 1, res.stderr); + const out = JSON.parse(res.stdout); + assert.equal(out.available, false); + assert.match(out.reason, /endpoint|token/); +}); diff --git a/packages/cli/test/smoke/cases.mjs b/packages/cli/test/smoke/cases.mjs index 78748e421..568ea8588 100644 --- a/packages/cli/test/smoke/cases.mjs +++ b/packages/cli/test/smoke/cases.mjs @@ -349,6 +349,15 @@ for (const [command, args] of REMOTE_ONLY) { // smoke does for the same tools. Preview's smoke token is service-role. const OWNERLESS = 'skipped: owner-keyed — a service-role credential has no owner'; +smoke('quota', 'reports the personal count against the plan cap', REMOTE, async ({ cli, ctx }) => { + if (ctx.tier === 'service') return OWNERLESS; + const q = cli.json(['quota']); + assert.equal(q.available, true, `quota is unavailable: ${q.reason}`); + assert.ok(q.personal.limit > 0, 'quota reports no personal limit'); + assert.equal(q.personal.remaining, Math.max(q.personal.limit - q.personal.count, 0), 'remaining is not limit minus count'); + assert.equal(q.total, q.personal.count + q.org_count, 'total is not personal + org'); +}); + smoke('protect', 'protects and unprotects a lesson', REMOTE, async ({ cli, ctx }) => { if (ctx.tier === 'service') return OWNERLESS; assert.equal(cli.json(['protect', `${ctx.scope}::${ctx.key('alpha')}`]).protected, true); diff --git a/packages/mcp-core/src/auth/permissions.spec.ts b/packages/mcp-core/src/auth/permissions.spec.ts index cf01ab321..98b3c55dd 100644 --- a/packages/mcp-core/src/auth/permissions.spec.ts +++ b/packages/mcp-core/src/auth/permissions.spec.ts @@ -54,9 +54,11 @@ describe('READ_TOOLS / WRITE_TOOLS', () => { // project names — it is not exempt merely because it names no scope. // `org.list` joined the read family when the org tools stopped being // JWT-only: listing the orgs you belong to is exactly what a read token - // should be able to do. + // should be able to do. `memory.quota` is your own counts against your + // cap: read-gated, so a write-only token cannot learn the account's size. [ 'memory.list', 'memory.list_archived', 'memory.read', 'memory.scopes', 'memory.search', 'org.list', + 'memory.quota', // Retention policies ("grooming") — listing saved rules and previewing // what one would archive are both reads, same family as memory.list_archived. 'policy.list', 'groom.preview', diff --git a/packages/mcp-core/src/auth/permissions.ts b/packages/mcp-core/src/auth/permissions.ts index ef90e36ee..7649b98c5 100644 --- a/packages/mcp-core/src/auth/permissions.ts +++ b/packages/mcp-core/src/auth/permissions.ts @@ -29,6 +29,9 @@ export const READ_TOOLS: ReadonlySet = new Set([ // with records that are not lore. Permission gating and the records-read // metric are different questions about the same family. 'memory.scopes', + // Your own counts against your cap. Read-gated like every other read: a + // write-only token has no business learning the account's size. + 'memory.quota', // `org.list` is a read like any other. It used to be ungated because the org // tools were JWT-only; they now serve `lk_*` tokens, and listing the orgs you // belong to is exactly the shape of thing a read token should be able to do. diff --git a/packages/mcp-core/src/auth/tenant-scope-usage.spec.ts b/packages/mcp-core/src/auth/tenant-scope-usage.spec.ts index 2c639f7e6..a26e29196 100644 --- a/packages/mcp-core/src/auth/tenant-scope-usage.spec.ts +++ b/packages/mcp-core/src/auth/tenant-scope-usage.spec.ts @@ -585,3 +585,30 @@ describe('scope-authorized removal surfaces existed → reason (00071 / 00072)', expect(body).toContain("'not_found'"); }); }); + +// `lorekit_memory_count` takes no key, so tenancy narrowing lives in `quotaForKey` (behaviour-tested in +// `limits/memory-quota.spec.ts`). A surface returning `memoryQuotaFromRow(data)` directly would show a +// `personal` key every org's count, and no vitest harness runs the Deno edge, hence a source guard. +describe('key-tenancy usage guard (quota org count)', () => { + it('GET /memories/quota narrows through quotaForKey with the calling key', () => { + const src = readHandlerSource('quota'); + expect(src).toMatch(/quotaForKey\(memoryQuotaFromRow\(data\),\s*keyRestriction\(auth\),/); + expect(src).toContain('countActiveOrgMemories(db, span, orgIds)'); + }); + + it('memory.quota narrows through quotaForKey with the calling key', () => { + const body = extractFunctionBody(source, 'toolQuota'); + expect(body).toMatch(/quotaForKey\(memoryQuotaFromRow\(data\),\s*keyScoping,/); + expect(body).toContain('countActiveOrgMemories(db, span, orgIds)'); + }); + + it('the shared org count reads only the org ids it is given, and only active rows', () => { + // The api_key path runs on a service-role client, so this filter is the boundary. + const src = readFileSync( + path.resolve(here, '../../../../supabase/functions/_shared/limits/org-memory-count.ts'), + 'utf8', + ); + expect(src).toContain(".in('org_id', orgIds)"); + expect(src).toContain(".is('archived_at', null)"); + }); +}); diff --git a/packages/mcp-core/src/limits/memory-quota.spec.ts b/packages/mcp-core/src/limits/memory-quota.spec.ts new file mode 100644 index 000000000..bbcc2debc --- /dev/null +++ b/packages/mcp-core/src/limits/memory-quota.spec.ts @@ -0,0 +1,118 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { KeyRestriction } from '../auth/tenant-scope'; +import { memoryQuotaFromRow, quotaForKey, type MemoryQuota } from './memory-quota'; + +describe('memoryQuotaFromRow', () => { + it('reports the personal pair the cap enforces, not the combined count', () => { + // The RPC's `count` is personal + org (4300) but its `limit` is the + // PERSONAL cap. Remaining must come from the personal count alone. + const quota = memoryQuotaFromRow({ count: 4300, personal_count: 3318, org_count: 982, limit: 5000, plan: 'free' }); + + expect(quota).toEqual({ + plan: 'free', + personal: { count: 3318, limit: 5000, remaining: 1682 }, + org_count: 982, + total: 4300, + }); + }); + + it('never reports negative headroom when a lowered limit sits under the count', () => { + expect(memoryQuotaFromRow({ personal_count: 120, org_count: 0, limit: 100, plan: 'free' }).personal) + .toEqual({ count: 120, limit: 100, remaining: 0 }); + }); + + it('coerces the untyped jsonb fields (numeric strings, missing, junk)', () => { + expect(memoryQuotaFromRow({ personal_count: '7', org_count: null, limit: '10', plan: 'team' })).toEqual({ + plan: 'team', + personal: { count: 7, limit: 10, remaining: 3 }, + org_count: 0, + total: 7, + }); + }); + + it('falls back to the free plan and zeros for an empty row', () => { + expect(memoryQuotaFromRow(null)).toEqual({ + plan: 'free', + personal: { count: 0, limit: 0, remaining: 0 }, + org_count: 0, + total: 0, + }); + expect(memoryQuotaFromRow({ plan: ' ' }).plan).toBe('free'); + }); +}); + +describe('quotaForKey', () => { + // The owner belongs to three orgs holding 982 org memories between them. + const OWNER_QUOTA: MemoryQuota = { + plan: 'free', + personal: { count: 3318, limit: 5000, remaining: 1682 }, + org_count: 982, + total: 4300, + }; + const MEMBER_ORGS = ['org-a', 'org-b', 'org-c']; + + function fakeReads(orgCount = 0) { + return { + memberOrgIds: vi.fn(async () => MEMBER_ORGS), + countOrgMemories: vi.fn<(orgIds: string[]) => Promise>(async () => orgCount), + }; + } + const key = (orgAccess: KeyRestriction['orgAccess'], orgIds: string[] = []): KeyRestriction => + ({ scopes: [], orgAccess, orgIds }); + + it('leaves a JWT or unrestricted caller on the owner figure, without reading anything', async () => { + for (const k of [undefined, key('all')]) { + const reads = fakeReads(); + await expect(quotaForKey(OWNER_QUOTA, k, reads)).resolves.toBe(OWNER_QUOTA); + expect(reads.memberOrgIds).not.toHaveBeenCalled(); + expect(reads.countOrgMemories).not.toHaveBeenCalled(); + } + }); + + it('shows a personal-only key no org memories, and never counts any', async () => { + const reads = fakeReads(982); + const quota = await quotaForKey(OWNER_QUOTA, key('personal'), reads); + + expect(quota).toEqual({ ...OWNER_QUOTA, org_count: 0, total: 3318 }); + expect(reads.countOrgMemories).not.toHaveBeenCalled(); + }); + + it('counts a selected-orgs key over its orgs that the owner still belongs to', async () => { + // `org-gone` is a stale allowlist entry: the owner has since left that org. + const reads = fakeReads(140); + const quota = await quotaForKey(OWNER_QUOTA, key('selected', ['org-b', 'org-gone']), reads); + + expect(reads.countOrgMemories).toHaveBeenCalledWith(['org-b']); + expect(quota).toEqual({ ...OWNER_QUOTA, org_count: 140, total: 3458 }); + }); + + it('never narrows the personal pair, whatever the scope allowlist', async () => { + const scoped: KeyRestriction = { scopes: ['repo::acme/api'], orgAccess: 'personal', orgIds: [] }; + const quota = await quotaForKey(OWNER_QUOTA, scoped, fakeReads()); + + expect(quota.personal).toEqual(OWNER_QUOTA.personal); + }); + + it('skips the count when a selected key reaches none of the owner\'s orgs', async () => { + const reads = fakeReads(982); + const quota = await quotaForKey(OWNER_QUOTA, key('selected', ['org-gone']), reads); + + expect(quota).toEqual({ ...OWNER_QUOTA, org_count: 0, total: 3318 }); + expect(reads.countOrgMemories).not.toHaveBeenCalled(); + }); + + it('never reports more org memories than the owner figure, and coerces junk to 0', async () => { + const above = await quotaForKey(OWNER_QUOTA, key('selected', ['org-a']), fakeReads(5000)); + expect(above).toEqual({ ...OWNER_QUOTA, org_count: 982, total: 4300 }); + + const junk = await quotaForKey(OWNER_QUOTA, key('selected', ['org-a']), fakeReads(Number.NaN)); + expect(junk).toEqual({ ...OWNER_QUOTA, org_count: 0, total: 3318 }); + }); + + it('rejects when a read fails instead of falling back to the owner figure', async () => { + const reads = fakeReads(); + reads.countOrgMemories.mockRejectedValueOnce(new Error('db down')); + + await expect(quotaForKey(OWNER_QUOTA, key('selected', ['org-a']), reads)).rejects.toThrow('db down'); + }); +}); diff --git a/packages/mcp-core/src/limits/memory-quota.ts b/packages/mcp-core/src/limits/memory-quota.ts new file mode 100644 index 000000000..4f092c202 --- /dev/null +++ b/packages/mcp-core/src/limits/memory-quota.ts @@ -0,0 +1,82 @@ +/** + * The caller's memory quota, shaped from `lorekit_memory_count` (00047); mirrored to `_shared/limits/`. + * Reports the PERSONAL pair because the cap trigger (00018) counts personal and org rows against + * separate limits, so a "remaining" from the RPC's combined `count` would be wrong for both. + */ + +import { effectiveOrgIds, restrictsTenancy, type KeyRestriction } from '../auth/tenant-scope.ts'; + +/** + * The row `lorekit_memory_count` returns (migration 00047): jsonb, so every + * field arrives untyped and is coerced below rather than trusted. + */ +export interface MemoryCountRow { + count?: unknown; + personal_count?: unknown; + org_count?: unknown; + limit?: unknown; + plan?: unknown; +} + +/** The caller's quota, as `GET /memories/quota` and `memory.quota` answer it. */ +export interface MemoryQuota { + /** Plan name (`free` when the caller has no `user_plans` row). */ + plan: string; + /** Personal memories against the personal cap: the pair the cap trigger enforces. */ + personal: { count: number; limit: number; remaining: number }; + /** Active org-owned memories across the orgs the caller (and its key's tenancy) reaches. Each org has its own cap. */ + org_count: number; + /** `personal.count + org_count`: every active memory the caller can see. */ + total: number; +} + +/** The two reads `quotaForKey` needs, passed in so this module does no I/O of its own. */ +export interface QuotaTenancyReads { + /** The key owner's member orgs (`lorekit_member_org_ids`). */ + memberOrgIds(): Promise; + /** Active (non-archived) org-owned memories across exactly these orgs. */ + countOrgMemories(orgIds: string[]): Promise; +} + +const DEFAULT_PLAN = 'free'; + +function nonNegativeInteger(value: unknown): number { + const n = Number(value); + return Number.isFinite(n) && n > 0 ? Math.floor(n) : 0; +} + +/** Shape a `lorekit_memory_count` row into the quota contract. Total: never throws. */ +export function memoryQuotaFromRow(row: MemoryCountRow | null | undefined): MemoryQuota { + const personalCount = nonNegativeInteger(row?.personal_count); + const orgCount = nonNegativeInteger(row?.org_count); + const limit = nonNegativeInteger(row?.limit); + const plan = typeof row?.plan === 'string' && row.plan.trim() !== '' ? row.plan : DEFAULT_PLAN; + return { + plan, + personal: { count: personalCount, limit, remaining: Math.max(limit - personalCount, 0) }, + org_count: orgCount, + total: personalCount + orgCount, + }; +} + +/** + * The quota as the calling key may see it: for a key narrowed to `personal` or selected orgs (00068), + * `org_count` and `total` recounted over `effectiveOrgIds`, capped at the owner's figure; `personal` + * is never narrowed because the cap is account-wide. + * @param quota the owner's quota, from `memoryQuotaFromRow`. + * @param key the calling key's restriction; `undefined` for a JWT or an unrestricted caller. + * @param reads the membership read and the org count, supplied by each surface. + * @returns `quota` unchanged, or a copy with `org_count` and `total` recounted. + * @throws whatever `reads` throws; a failed read must not fall back to the owner's figure. + */ +export async function quotaForKey( + quota: MemoryQuota, + key: KeyRestriction | undefined, + reads: QuotaTenancyReads, +): Promise { + if (!restrictsTenancy(key)) return quota; + const orgIds = effectiveOrgIds(await reads.memberOrgIds(), key); + const reachable = orgIds.length === 0 ? 0 : nonNegativeInteger(await reads.countOrgMemories(orgIds)); + const orgCount = Math.min(reachable, quota.org_count); + return { ...quota, org_count: orgCount, total: quota.personal.count + orgCount }; +} diff --git a/packages/mcp-core/src/mcp-guards/surface-parity.spec.ts b/packages/mcp-core/src/mcp-guards/surface-parity.spec.ts index c6d10f660..b83f6c985 100644 --- a/packages/mcp-core/src/mcp-guards/surface-parity.spec.ts +++ b/packages/mcp-core/src/mcp-guards/surface-parity.spec.ts @@ -89,6 +89,7 @@ describe('every op declares its surfaces', () => { 'memory.protect', 'memory.purge', 'memory.purge_expired', + 'memory.quota', 'policy.create', 'policy.delete', 'policy.list', diff --git a/packages/mcp-core/src/rest/rest-tool-name.ts b/packages/mcp-core/src/rest/rest-tool-name.ts index dc1dd0015..f764874de 100644 --- a/packages/mcp-core/src/rest/rest-tool-name.ts +++ b/packages/mcp-core/src/rest/rest-tool-name.ts @@ -55,6 +55,7 @@ export const REST_TOOL_NAMES: Readonly> = { 'memories POST /purge': 'memory.purge', 'memories POST /purge-expired': 'memory.purge_expired', 'memories GET /scopes': 'memory.scopes', + 'memories GET /quota': 'memory.quota', 'memories GET /usage': 'memory.usage', 'memories GET /usage/runs': 'memory.usage-runs', 'memories GET /tags': 'memory.tags', diff --git a/packages/mcp-core/src/telemetry/usage-stats.ts b/packages/mcp-core/src/telemetry/usage-stats.ts index e2eb56f01..6cfde0282 100644 --- a/packages/mcp-core/src/telemetry/usage-stats.ts +++ b/packages/mcp-core/src/telemetry/usage-stats.ts @@ -116,6 +116,7 @@ const READ_TOOL_NAMES: ReadonlySet = new Set([ 'memory.list', 'memory.search', 'memory.scopes', + 'memory.quota', 'memory.list_archived', 'memory.usage', 'org.list', diff --git a/packages/schemas/src/domain/memory.ts b/packages/schemas/src/domain/memory.ts index 546340064..e5e6cefc2 100644 --- a/packages/schemas/src/domain/memory.ts +++ b/packages/schemas/src/domain/memory.ts @@ -443,6 +443,31 @@ export type ScopeCount = z.infer; export const ScopesResponseSchema = z.object({ scopes: z.array(ScopeCountSchema) }); export type ScopesResponse = z.infer; +// ── GET /memories/quota ────────────────────────────────────────────────────── + +/** + * `GET /memories/quota` — the caller's active memories against their plan's cap, + * shaped by `memoryQuotaFromRow` (`packages/mcp-core/src/limits/memory-quota.ts`). + */ +export const MemoryQuotaResponseSchema = z.object({ + /** Plan name; `free` when the caller has no plan row. */ + plan: z.string(), + /** Personal active memories, the personal cap, and the writes left under it. */ + personal: z.object({ + count: z.number().int().nonnegative(), + limit: z.number().int().nonnegative(), + remaining: z.number().int().nonnegative(), + }), + /** + * Active org-owned memories across the orgs the caller reaches (each org has its own cap): + * every org it belongs to, or only those its token's tenancy admits (`0` under `personal`). + */ + org_count: z.number().int().nonnegative(), + /** `personal.count + org_count` — every active memory the caller can see. */ + total: z.number().int().nonnegative(), +}); +export type MemoryQuotaResponse = z.infer; + // ── GET /memories/tags ─────────────────────────────────────────────────────── /** diff --git a/packages/schemas/src/llms/template.md b/packages/schemas/src/llms/template.md index e52736036..7c32317de 100644 --- a/packages/schemas/src/llms/template.md +++ b/packages/schemas/src/llms/template.md @@ -123,8 +123,11 @@ behaves exactly as it always has. A request that NAMES a scope outside the allowlist is refused (`-32003` on MCP, `403` on REST) rather than answered with an empty list. A request that names no scope is narrowed instead. `memory.scopes` is narrowed too, so a scoped token -cannot enumerate scope names it may not read. An operation that carries no scope -at all (`memory.purge_expired`) is refused for a token that has an allowlist. +cannot enumerate scope names it may not read. An account-wide sweep, which +carries no scope at all (`memory.purge`, `memory.purge_expired`), is refused for a +token that has an allowlist. `memory.quota` is answered for every token: it +returns counts only, `org_count` and `total` cover just the orgs the tenancy +reaches (`0` under `personal`), and `personal` stays account-wide because the cap is. For a scope INSIDE the allowlist, a scoped token can archive, delete, or restore any writer's row for that scope+key — the management authority the owner granted by scoping the key — reporting `404` (nothing there) vs `403` (present, but not this token's to touch); an unscoped token stays own-rows-only. diff --git a/packages/schemas/src/openapi/spec.spec.ts b/packages/schemas/src/openapi/spec.spec.ts index 6d8198f8e..00beb8812 100644 --- a/packages/schemas/src/openapi/spec.spec.ts +++ b/packages/schemas/src/openapi/spec.spec.ts @@ -30,6 +30,7 @@ describe('generateSpec', () => { '/memories/pivot', '/memories/purge', '/memories/purge-expired', + '/memories/quota', '/memories/read', '/memories/read-activity', '/memories/read-ranking', @@ -70,6 +71,7 @@ describe('generateSpec', () => { 'RestoreResponse', 'PurgeResponse', 'ScopesResponse', + 'MemoryQuotaResponse', 'Org', 'OrgList', 'CreateOrgBody', @@ -156,6 +158,7 @@ describe('generateSpec', () => { ['/memories/purge', 'post'], ['/memories/purge-expired', 'post'], ['/memories/scopes', 'get'], + ['/memories/quota', 'get'], ] as const) { const op = spec.paths[path]![method] as { responses: Record }; expect(op.responses['200']?.content, `${method.toUpperCase()} ${path} has no 200 body`).toBeDefined(); diff --git a/packages/schemas/src/openapi/spec.ts b/packages/schemas/src/openapi/spec.ts index 2dc04e6a4..4fb8f1766 100644 --- a/packages/schemas/src/openapi/spec.ts +++ b/packages/schemas/src/openapi/spec.ts @@ -23,6 +23,7 @@ import { RestoreResponseSchema, PurgeResponseSchema, ScopesResponseSchema, + MemoryQuotaResponseSchema, ListTagsQuerySchema, TagsResponseSchema, ListFacetsQuerySchema, @@ -132,6 +133,7 @@ export function generateSpec(baseUrl = 'https://pqokxlhvnosogizsjztg.supabase.co registry.register('RestoreResponse', RestoreResponseSchema); registry.register('PurgeResponse', PurgeResponseSchema); registry.register('ScopesResponse', ScopesResponseSchema); + registry.register('MemoryQuotaResponse', MemoryQuotaResponseSchema); registry.register('UsageStatsResponse', UsageStatsResponseSchema); registry.register('Org', OrgResponseSchema); registry.register('OrgList', OrgListResponseSchema); @@ -269,6 +271,22 @@ export function generateSpec(baseUrl = 'https://pqokxlhvnosogizsjztg.supabase.co 401: errorResponse, 403: errorResponse, }, }); + registry.registerPath({ + method: 'get', path: '/memories/quota', + summary: 'Your active memories against your plan cap, and how many you can still write', tags: ['Memories'], + description: + '`personal` is the pair the memory cap enforces: personal memories against the personal limit, ' + + 'with `remaining` writes left before `POST /memories` is refused with `memory_cap`. Org-owned ' + + 'memories count against each org\'s own cap, so they are reported as `org_count` and are not ' + + 'subtracted from `remaining`. A token narrowed to `personal` or to selected orgs gets ' + + '`org_count` and `total` over only the orgs it reaches (`0` under `personal`). Requires a ' + + 'user-scoped credential; a service-role token has no user and gets 403.', + security, + responses: { + 200: { description: 'Quota', content: { 'application/json': { schema: MemoryQuotaResponseSchema } } }, + 401: errorResponse, 403: errorResponse, + }, + }); registry.registerPath({ method: 'get', path: '/memories/tags', summary: 'List every visible label with how many memories carry it', tags: ['Memories'], diff --git a/packages/schemas/src/shared/tool-catalog.ts b/packages/schemas/src/shared/tool-catalog.ts index 0a9aa1de7..c3ebd0015 100644 --- a/packages/schemas/src/shared/tool-catalog.ts +++ b/packages/schemas/src/shared/tool-catalog.ts @@ -502,6 +502,29 @@ export const MCP_TOOLS = [ inputSchema: { type: 'object', properties: {} }, returns: '`{ "scopes": [{ "scope", "count", "last_activity" }] }`, sorted by count desc then scope asc (busiest scope first). `count` is active (non-archived, non-expired) memories; `last_activity` is the newest `created_at` among them, or `null`.', }, + { + name: 'memory.quota', + description: + 'How many active memories you hold against your plan\'s cap, and how many writes are left before ' + + 'memory.write is refused with memory_cap. Takes no arguments. Check it before a bulk write or ' + + 'a migration instead of discovering the cap from a failed write.', + permission: 'read', + annotations: READ_ONLY, + auth: 'token-or-jwt', + surfaces: { + mcp: true, + cli: 'quota', + rest: 'GET /quota', + handler: 'toolQuota', + localMcpExempt: 'server-side plan and memory cap; the offline store has no limit', + }, + inputSchema: { type: 'object', properties: {} }, + returns: + '`{ "plan", "personal": { "count", "limit", "remaining" }, "org_count", "total" }`. `personal` is the ' + + 'pair the cap enforces; org-owned memories count against each org\'s own cap, so `org_count` is ' + + 'reported beside it and not subtracted from `remaining`. A token narrowed to `personal` or to selected orgs ' + + 'sees `org_count` and `total` over only the orgs it reaches. A service-role caller has no user and is refused.', + }, { name: 'memory.list_archived', description: 'List archived (soft-deleted) lessons, for one scope or across every scope you can see', diff --git a/packages/web/CLAUDE.md b/packages/web/CLAUDE.md index 1a0af9684..8b8ffec0b 100644 --- a/packages/web/CLAUDE.md +++ b/packages/web/CLAUDE.md @@ -104,7 +104,9 @@ until the whole queue drains. `fetchScopes`). If the read still needs server-side code (a surface on the migration backlog), wrap it in a GET route handler under `app/api/**` and read it with `getAppRoute(path, signal)` (`lib/app-route.ts`), as - `/api/plan/usage` and `/api/invites/pending` do. + `/api/invites/pending` does — and treat that route as a stopgap until the + read has its own edge endpoint, the way plan usage moved from + `/api/plan/usage` to `GET /memories/quota`. - **Server actions stay for mutations**, which are one user gesture each, and for server components, where calling the function is a plain call with no queue. diff --git a/packages/web/public/llms.txt b/packages/web/public/llms.txt index 4b116bc49..439af2669 100644 --- a/packages/web/public/llms.txt +++ b/packages/web/public/llms.txt @@ -134,8 +134,11 @@ behaves exactly as it always has. A request that NAMES a scope outside the allowlist is refused (`-32003` on MCP, `403` on REST) rather than answered with an empty list. A request that names no scope is narrowed instead. `memory.scopes` is narrowed too, so a scoped token -cannot enumerate scope names it may not read. An operation that carries no scope -at all (`memory.purge_expired`) is refused for a token that has an allowlist. +cannot enumerate scope names it may not read. An account-wide sweep, which +carries no scope at all (`memory.purge`, `memory.purge_expired`), is refused for a +token that has an allowlist. `memory.quota` is answered for every token: it +returns counts only, `org_count` and `total` cover just the orgs the tenancy +reaches (`0` under `personal`), and `personal` stays account-wide because the cap is. For a scope INSIDE the allowlist, a scoped token can archive, delete, or restore any writer's row for that scope+key — the management authority the owner granted by scoping the key — reporting `404` (nothing there) vs `403` (present, but not this token's to touch); an unscoped token stays own-rows-only. @@ -157,6 +160,7 @@ with `npx @lorekit/cli install --force`, which offers to keep, replace, or remov | `memory.search` | ✓ | ✓ | ✗ | | `memory.archive` | ✓ | ✗ | ✓ | | `memory.scopes` | ✓ | ✓ | ✗ | +| `memory.quota` | ✓ | ✓ | ✗ | | `memory.list_archived` | ✓ | ✓ | ✗ | | `memory.restore` | ✓ | ✗ | ✓ | | `memory.purge` | ✓ | ✗ | ✓ | @@ -364,6 +368,20 @@ Returns: `{ "scopes": [{ "scope", "count", "last_activity" }] }`, sorted by coun --- +### memory.quota + +How many active memories you hold against your plan's cap, and how many writes are left before memory.write is refused with memory_cap. Takes no arguments. Check it before a bulk write or a migration instead of discovering the cap from a failed write. + +No arguments. + +Requires **read** permission. + +MCP annotations: read-only, closed-world. + +Returns: `{ "plan", "personal": { "count", "limit", "remaining" }, "org_count", "total" }`. `personal` is the pair the cap enforces; org-owned memories count against each org's own cap, so `org_count` is reported beside it and not subtracted from `remaining`. A token narrowed to `personal` or to selected orgs sees `org_count` and `total` over only the orgs it reaches. A service-role caller has no user and is refused. + +--- + ### memory.list_archived List archived (soft-deleted) lessons, for one scope or across every scope you can see. diff --git a/packages/web/src/app/(dashboard)/settings/plan/page.tsx b/packages/web/src/app/(dashboard)/settings/plan/page.tsx index c2177c562..bcb5eefc3 100644 --- a/packages/web/src/app/(dashboard)/settings/plan/page.tsx +++ b/packages/web/src/app/(dashboard)/settings/plan/page.tsx @@ -1,6 +1,9 @@ import type { Metadata } from 'next'; import { CreditCard, ExternalLink } from 'lucide-react'; -import { getPlanUsage } from '@/lib/plan'; +import type { MemoryQuotaResponse } from '@lorekit/schemas/memory'; +import { quotaRequest } from '@/lib/api/memories'; +import { serverAccessToken } from '@/lib/api/session-server'; +import { logger } from '@/lib/telemetry'; import { PlanUsageBar } from '@/components/settings/PlanUsageBar'; import { PlanPeakUsage } from '@/components/settings/PlanPeakUsage'; import { SectionPanel } from '@/components/ui/SectionPanel'; @@ -9,8 +12,28 @@ export const metadata: Metadata = { title: 'Plan — Settings' }; const DISCORD_INVITE = 'https://discord.gg/SPa24vGa7R'; +/** + * The caller's quota from `GET /memories/quota`, or `null` when there is no + * session or the request fails — the page renders a skeleton bar rather than + * an error, as it always has. A failed request is logged + * (`lorekit.plan.quota_failed`) so the skeleton is never the only trace of it. + */ +async function loadQuota(): Promise { + const token = await serverAccessToken(); + if (!token) return null; + try { + return await quotaRequest(token); + } catch (err) { + logger.error('lorekit.plan.quota_failed', { + 'exception.type': err instanceof Error ? err.name : 'Error', + 'exception.message': err instanceof Error ? err.message : String(err), + }); + return null; + } +} + export default async function PlanSettingsPage() { - const usage = await getPlanUsage(); + const quota = await loadQuota(); return ( - {usage ? capitalize(usage.plan) : 'Free'} · Beta + {quota ? capitalize(quota.plan) : 'Free'} · Beta

During beta, LoreKit is completely free. @@ -38,9 +61,9 @@ export default async function PlanSettingsPage() {

Memory usage

- {usage ? ( + {quota ? ( <> - + {/* A live count answers "how full am I now"; this answers "how full WAS I over the last month" — usage_events.memory_count's write-time snapshots (migration 00081). Renders nothing when diff --git a/packages/web/src/app/api/plan/usage/route.ts b/packages/web/src/app/api/plan/usage/route.ts deleted file mode 100644 index 21602c693..000000000 --- a/packages/web/src/app/api/plan/usage/route.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { NextResponse } from 'next/server'; -import { getPlanUsage } from '@/lib/plan'; - -/** - * GET /api/plan/usage - * - * The caller's plan and active memory count — `getPlanUsage` over HTTP, for the - * top bar's memory badge (`useMemoryTotal`). The badge used to call the server - * action directly. Next's router runs server actions one at a time per client, - * so on `/lore` it led a queue that the pending-invites read and the Explorer's - * list waited behind on every load and every window focus. As a route handler - * it is an ordinary request that runs in parallel and the browser can abort. - * - * Same contract as the action: `null` when there is no session or the RPC - * fails, and the badge degrades to 0. Never cached, because the count moves - * whenever an agent writes. - */ -export const dynamic = 'force-dynamic'; - -export async function GET() { - const usage = await getPlanUsage(); - return NextResponse.json(usage, { headers: { 'Cache-Control': 'no-store' } }); -} diff --git a/packages/web/src/app/api/read-routes.spec.ts b/packages/web/src/app/api/read-routes.spec.ts index 3a3f89513..d238656c1 100644 --- a/packages/web/src/app/api/read-routes.spec.ts +++ b/packages/web/src/app/api/read-routes.spec.ts @@ -1,53 +1,25 @@ /** - * `GET /api/plan/usage` and `GET /api/invites/pending` are transports, not new - * logic: each wraps an existing server function so the browser can read it as - * an ordinary, parallel, abortable request instead of a queued server action. - * Pinned here: they answer exactly what the function returns (including its - * signed-out / failure shape) and are never cached. + * `GET /api/invites/pending` is a transport, not new logic: it wraps the + * existing server function so the browser can read it as an ordinary, parallel, + * abortable request instead of a queued server action. Pinned here: it answers + * exactly what the function returns (including its signed-out / failure shape) + * and is never cached. (Plan usage started here too and has since moved to the + * `GET /memories/quota` edge route.) */ import { beforeEach, describe, expect, it, vi } from 'vitest'; -const getPlanUsage = vi.fn(); const listPendingInvitesForMe = vi.fn(); -vi.mock('@/lib/plan', () => ({ getPlanUsage: () => getPlanUsage() })); vi.mock('@/lib/org-invites', () => ({ listPendingInvitesForMe: () => listPendingInvitesForMe(), })); -const planUsageRoute = await import('./plan/usage/route'); const pendingInvitesRoute = await import('./invites/pending/route'); beforeEach(() => { vi.clearAllMocks(); }); - -describe('GET /api/plan/usage', () => { - it('answers the caller plan usage as JSON, uncached', async () => { - getPlanUsage.mockResolvedValue({ count: 3318, limit: 5000, plan: 'free' }); - - const response = await planUsageRoute.GET(); - - expect(response.status).toBe(200); - expect(response.headers.get('cache-control')).toBe('no-store'); - await expect(response.json()).resolves.toEqual({ count: 3318, limit: 5000, plan: 'free' }); - }); - - it('answers null when there is no session or the RPC failed, as the action did', async () => { - getPlanUsage.mockResolvedValue(null); - - const response = await planUsageRoute.GET(); - - expect(response.status).toBe(200); - await expect(response.json()).resolves.toBeNull(); - }); - - it('is rendered per request, never statically', () => { - expect(planUsageRoute.dynamic).toBe('force-dynamic'); - }); -}); - describe('GET /api/invites/pending', () => { it('answers the invites addressed to the caller as JSON, uncached', async () => { const invites = [{ id: 'inv-1', org_id: 'org-1', status: 'pending', org: { name: 'Acme', slug: 'acme' } }]; diff --git a/packages/web/src/components/lore/MemoryExpandButton.tsx b/packages/web/src/components/lore/MemoryExpandButton.tsx index 9b7caad74..438005110 100644 --- a/packages/web/src/components/lore/MemoryExpandButton.tsx +++ b/packages/web/src/components/lore/MemoryExpandButton.tsx @@ -83,9 +83,9 @@ export function MemoryExpandButton({ // The badge count must NOT come from `data.lessons` — that is only the first // page of the list (capped at the API's 100-row maximum), so counting it stuck - // the badge at "100 memories" for any larger account. The unscoped total is the - // SAME figure the /settings/plan page shows (`useMemoryTotal` → the shared - // `lorekit_memory_count` RPC), so the two never disagree. A `scope`-filtered + // the badge at "100 memories" for any larger account. The unscoped total is + // personal + org (`useMemoryTotal` → `total` of GET /memories/quota), while + // /settings/plan shows only the personal pair against the cap. A `scope`-filtered // instance falls back to the exact per-scope aggregate from GET /memories/scopes. const total = useMemo(() => { if (scope) return data?.scopes.find((s) => s.scope === scope)?.count ?? 0; diff --git a/packages/web/src/components/settings/PlanPeakUsage.tsx b/packages/web/src/components/settings/PlanPeakUsage.tsx index a73da9775..bf1a1b616 100644 --- a/packages/web/src/components/settings/PlanPeakUsage.tsx +++ b/packages/web/src/components/settings/PlanPeakUsage.tsx @@ -4,13 +4,10 @@ * PlanPeakUsage — "peaked at N in the last 30 days", from * `GET /memories/usage`'s `summary.peak_memory_count` (migration 00081). * - * Complements {@link PlanUsageBar}, which shows the LIVE count via the - * pre-existing `lorekit_memory_count()` server action — that answers "how - * full am I right now"; this answers "how full WAS I over the last month", - * from `usage_events.memory_count`'s write-time snapshots. Goes through the - * REST API (`usageRequest`), unlike the legacy `getPlanUsage()` server - * action, which predates the "dashboard is a REST client" rule and is not - * this PR's job to migrate. + * Complements {@link PlanUsageBar}, which shows the LIVE personal count from + * `GET /memories/quota` — that answers "how full am I right now"; this answers + * "how full WAS I over the last month", from `usage_events.memory_count`'s + * write-time snapshots. * * Renders nothing while loading or when the window has no write events — * this is a bonus caption, not a load-bearing figure worth a skeleton. diff --git a/packages/web/src/components/settings/PlanUsageBar.tsx b/packages/web/src/components/settings/PlanUsageBar.tsx index 6c8f3a5a6..7133ecda5 100644 --- a/packages/web/src/components/settings/PlanUsageBar.tsx +++ b/packages/web/src/components/settings/PlanUsageBar.tsx @@ -1,7 +1,8 @@ -import type { PlanUsage } from '@/lib/plan'; +import type { MemoryQuotaResponse } from '@lorekit/schemas/memory'; interface PlanUsageBarProps { - usage: PlanUsage; + /** The personal pair from `GET /memories/quota` — not the total, since org memories have their own caps. */ + usage: Pick; } /** diff --git a/packages/web/src/content/docs/remote.mdx b/packages/web/src/content/docs/remote.mdx index fa5880671..0cea4121a 100644 --- a/packages/web/src/content/docs/remote.mdx +++ b/packages/web/src/content/docs/remote.mdx @@ -109,7 +109,7 @@ The command checks the connection and the token **before** it writes anything, s -Migrating a large store? The command paces itself under the hosted rate limit and waits out the ones it hits — it says when pacing starts, so a slow run is never a mystery — and it retries a passing server error rather than dropping the memory. A few retries is the budget, though: a memory that keeps failing is reported and the run exits non-zero, and five failures in a row stop it early with a count of what made it. Reaching the memory limit on your plan stops it the same way. In every case the migration is safe to re-run — it picks up where it left off. +Migrating a large store? The command paces itself under the hosted rate limit and waits out the ones it hits — it says when pacing starts, so a slow run is never a mystery — and it retries a passing server error rather than dropping the memory. A few retries is the budget, though: a memory that keeps failing is reported and the run exits non-zero, and five failures in a row stop it early with a count of what made it. Reaching the memory limit on your plan stops it the same way, so check your headroom first with `npx @lorekit/cli quota` — it shows your memories against your plan's cap and how many writes are left (agents can ask the same with the `memory.quota` tool). In every case the migration is safe to re-run — it picks up where it left off. diff --git a/packages/web/src/lib/api/memories.ts b/packages/web/src/lib/api/memories.ts index bd3bbf4b8..92b54d149 100644 --- a/packages/web/src/lib/api/memories.ts +++ b/packages/web/src/lib/api/memories.ts @@ -25,6 +25,7 @@ import type { ListMemoriesQuery, MemoryEntry, MemoryPageResponse, + MemoryQuotaResponse, PurgeResponse, ReadActivityQuery, ReadActivityResponse, @@ -97,6 +98,18 @@ export function getMemoryByRefRequest( ); } +/** + * `GET /memories/quota` — the caller's active memories against their plan's cap. + * `personal` is the pair the cap enforces; `total` adds org-owned memories, + * which count against each org's own cap. + */ +export function quotaRequest(accessToken: string, signal?: AbortSignal): Promise { + return restFetch('/memories/quota', { + accessToken, + ...(signal ? { signal } : {}), + }); +} + export function listScopesRequest(accessToken: string, signal?: AbortSignal): Promise { return restFetch('/memories/scopes', { accessToken, diff --git a/packages/web/src/lib/app-route.ts b/packages/web/src/lib/app-route.ts index dae9df9ff..cb7ee4c06 100644 --- a/packages/web/src/lib/app-route.ts +++ b/packages/web/src/lib/app-route.ts @@ -3,13 +3,14 @@ * read as JSON. * * This exists for the reads that still need server-side code: there is no REST - * endpoint for them yet (`packages/web/CLAUDE.md` lists invites and plan usage - * as migration backlog). They used to be called as server actions straight from - * React Query, and Next's app router runs server actions one at a time per - * client. Every read queued behind whichever action was already in flight, and - * none of them could be aborted, so the header's activity bar stayed lit until - * the whole chain had drained. A route handler is an ordinary request: it runs - * alongside the others and takes React Query's AbortSignal. + * endpoint for them yet (`packages/web/CLAUDE.md` lists invites as migration + * backlog; plan usage has since moved to `GET /memories/quota`). They used to + * be called as server actions straight from React Query, and Next's app router + * runs server actions one at a time per client. Every read queued behind + * whichever action was already in flight, and none of them could be aborted, so + * the header's activity bar stayed lit until the whole chain had drained. A + * route handler is an ordinary request: it runs alongside the others and takes + * React Query's AbortSignal. * * Same-origin, so the session cookie authenticates it; no token is passed. * `no-store` because these answer "what is true now": a refetch on window focus diff --git a/packages/web/src/lib/plan.ts b/packages/web/src/lib/plan.ts deleted file mode 100644 index 4d68eb982..000000000 --- a/packages/web/src/lib/plan.ts +++ /dev/null @@ -1,51 +0,0 @@ -'use server'; - -/** - * Server actions for the /settings/plan page. - * - * Returns the user's active memory count, plan name, and effective limit in - * one round-trip via the lorekit_memory_count() SECURITY DEFINER RPC - * (supabase/migrations/00035_memory_count.sql). - */ - -import { createServerClient } from '@/lib/supabase/server'; -import { getVerifiedUser } from '@/lib/auth/verified-user'; -import { withSpan, logger } from '@/lib/telemetry'; - -export interface PlanUsage { - count: number; - limit: number; - plan: string; -} - -/** - * Fetch the current user's plan + memory usage. - * Returns null on auth failure or RPC error — callers degrade gracefully. - */ -export async function getPlanUsage(): Promise { - return withSpan('lorekit.plan.get_usage', {}, async (span) => { - const supabase = await createServerClient(); - const user = await getVerifiedUser(); - - if (!user) return null; - - const { data, error } = await supabase.rpc('lorekit_memory_count', { - p_user_id: user.id, - }); - - if (error) { - logger.error('lorekit.plan.get_usage.failed', { - 'exception.type': 'SupabaseRpcError', - 'exception.message': error.message, - }); - return null; - } - - const row = data as { count: number; limit: number; plan: string }; - span.setAttribute('lorekit.plan', row.plan); - span.setAttribute('lorekit.memory.count', row.count); - span.setAttribute('lorekit.memory.limit', row.limit); - - return { count: row.count, limit: row.limit, plan: row.plan }; - }); -} diff --git a/packages/web/src/lib/queries/parallel-reads.spec.ts b/packages/web/src/lib/queries/parallel-reads.spec.ts index b7e9a1994..846537b58 100644 --- a/packages/web/src/lib/queries/parallel-reads.spec.ts +++ b/packages/web/src/lib/queries/parallel-reads.spec.ts @@ -27,13 +27,12 @@ interface RecordedOptions { const harness = vi.hoisted(() => ({ recorded: [] as RecordedOptions[] })); -const api = vi.hoisted(() => ({ listMemoriesPostRequest: vi.fn() })); +const api = vi.hoisted(() => ({ listMemoriesPostRequest: vi.fn(), quotaRequest: vi.fn() })); const calledServerAction = vi.hoisted(() => () => { throw new Error('a read went through a server action'); }); -vi.mock('@/lib/plan', () => ({ getPlanUsage: calledServerAction })); vi.mock('@/lib/org-invites', () => ({ listPendingInvitesForMe: calledServerAction })); vi.mock('@/lib/lore', () => ({ archiveLesson: calledServerAction, @@ -47,6 +46,7 @@ vi.mock('@/lib/api/session-browser', () => ({ vi.mock('@/lib/api/memories', async (importOriginal) => ({ ...(await importOriginal()), listMemoriesPostRequest: api.listMemoriesPostRequest, + quotaRequest: api.quotaRequest, })); vi.mock('@tanstack/react-query', async (importOriginal) => { @@ -59,7 +59,7 @@ vi.mock('@tanstack/react-query', async (importOriginal) => { }); const { useMemories } = await import('./lore'); -const { useMemoryTotal, PLAN_USAGE_PATH } = await import('./plan'); +const { useMemoryTotal } = await import('./plan'); const { usePendingInvitesForMe, PENDING_INVITES_PATH } = await import('./pending-invites'); /** The options a hook hands React Query, captured by running it once. */ @@ -130,31 +130,25 @@ describe('useMemories', () => { }); describe('useMemoryTotal', () => { - it('reads the count from GET /api/plan/usage with the query signal', async () => { - fetchMock.mockResolvedValue(jsonResponse({ count: 3318, limit: 5000, plan: 'free' })); + it('reads the total from GET /memories/quota with the query signal', async () => { + api.quotaRequest.mockResolvedValue({ + plan: 'free', + personal: { count: 3318, limit: 5000, remaining: 1682 }, + org_count: 982, + total: 4300, + }); const { signal } = new AbortController(); - await expect(optionsOf(() => useMemoryTotal()).queryFn({ signal })).resolves.toBe(3318); - expect(fetchMock).toHaveBeenCalledWith( - PLAN_USAGE_PATH, - expect.objectContaining({ method: 'GET', cache: 'no-store', signal }), - ); - }); - - it('degrades to 0 when the route answers null (no session, or the RPC failed)', async () => { - fetchMock.mockResolvedValue(jsonResponse(null)); - - await expect( - optionsOf(() => useMemoryTotal()).queryFn({ signal: new AbortController().signal }), - ).resolves.toBe(0); + await expect(optionsOf(() => useMemoryTotal()).queryFn({ signal })).resolves.toBe(4300); + expect(api.quotaRequest).toHaveBeenCalledWith('test-token', signal); }); - it('rejects on a non-2xx answer so React Query can retry it', async () => { - fetchMock.mockResolvedValue(jsonResponse({ error: 'boom' }, 500)); + it('rejects on an API failure so React Query can retry it', async () => { + api.quotaRequest.mockRejectedValue(new Error('upstream 503')); await expect( optionsOf(() => useMemoryTotal()).queryFn({ signal: new AbortController().signal }), - ).rejects.toThrow(`GET ${PLAN_USAGE_PATH} failed with HTTP 500`); + ).rejects.toThrow('upstream 503'); }); }); @@ -175,7 +169,7 @@ describe('usePendingInvitesForMe', () => { }); describe('the paths the hooks read', () => { - it.each([PLAN_USAGE_PATH, PENDING_INVITES_PATH])('%s is served by a GET route handler', (path) => { + it.each([PENDING_INVITES_PATH])('%s is served by a GET route handler', (path) => { const file = fileURLToPath(new URL(`../../app${path}/route.ts`, import.meta.url)); expect(existsSync(file)).toBe(true); diff --git a/packages/web/src/lib/queries/plan.ts b/packages/web/src/lib/queries/plan.ts index 3df7a92e8..b0aa816f3 100644 --- a/packages/web/src/lib/queries/plan.ts +++ b/packages/web/src/lib/queries/plan.ts @@ -1,38 +1,22 @@ 'use client'; import { useQuery } from '@tanstack/react-query'; -import type { PlanUsage } from '@/lib/plan'; -import { getAppRoute } from '@/lib/app-route'; - -/** The route handler (`app/api/plan/usage/route.ts`) the badge reads. */ -export const PLAN_USAGE_PATH = '/api/plan/usage'; +import { browserAccessToken } from '@/lib/api/session-browser'; +import { quotaRequest } from '@/lib/api/memories'; /** - * The caller's total memory count — the SAME figure the /settings/plan page - * shows, because both read the shared `lorekit_memory_count` RPC (personal - * active + org-owned active across the caller's orgs). NOT a paginated list - * length: the header badge used to count `useLoreData().lessons`, which is only - * the first page (capped at the API's 100-row maximum), so it stuck at - * "100 memories" for any larger account. - * - * Caveat: `lorekit_memory_count` has no `expires_at` filter, so it can over-count - * TTL-expired rows relative to the expiry-aware `lorekit_memory_scopes` / - * `lorekit_memory_activity` views the Explorer and heatmap use. - * - * `getPlanUsage` returns count + limit + plan in one round-trip; the badge needs - * only the count. Degrades to 0 when there is no session / the RPC fails, matching - * the plan page's graceful-null handling. - * - * Read through `GET /api/plan/usage` rather than by calling the server action: - * Next runs server actions one at a time per client, so this badge used to hold - * the Explorer's list and the invites read behind it on every `/lore` load and - * window focus, and none of them could be cancelled (see `lib/app-route.ts`). + * Every active memory the caller can see (personal + org), for the TopBar badge: + * the `total` of `GET /memories/quota`, read from the browser with the query's + * signal — never a server action (`packages/web/CLAUDE.md`). 0 with no session. */ export function useMemoryTotal() { return useQuery({ queryKey: ['memory-total'], - queryFn: async ({ signal }) => - (await getAppRoute(PLAN_USAGE_PATH, signal))?.count ?? 0, + queryFn: async ({ signal }) => { + const token = await browserAccessToken(); + if (!token) return 0; + return (await quotaRequest(token, signal)).total; + }, // Changes only when an agent writes — align with the other read-heavy hooks. staleTime: 60_000, }); diff --git a/scripts/smoke/smoke-mcp-tools.mjs b/scripts/smoke/smoke-mcp-tools.mjs index b5f9c0839..580e5501e 100644 --- a/scripts/smoke/smoke-mcp-tools.mjs +++ b/scripts/smoke/smoke-mcp-tools.mjs @@ -711,6 +711,20 @@ check('scopes reports each scope this run wrote, with counts', async () => { } }); +check('quota reports the personal pair against the cap', async () => { + requireUserScoped('a per-user quota'); + const r = await call('memory.quota'); + const q = r.value ?? {}; + ok(typeof q.plan === 'string' && q.plan.length > 0, 'quota has no plan'); + ok(Number.isInteger(q.personal?.count) && q.personal.count >= 0, 'quota has no personal count'); + ok(Number.isInteger(q.personal?.limit) && q.personal.limit > 0, 'quota has no personal limit'); + ok( + q.personal.remaining === Math.max(q.personal.limit - q.personal.count, 0), + `remaining ${q.personal.remaining} is not limit ${q.personal.limit} minus personal count ${q.personal.count}`, + ); + ok(q.total === q.personal.count + q.org_count, 'total is not personal count + org count'); +}); + check('purge and purge_expired answer with a count', async () => { requireUserScoped('an account-wide purge'); const purged = await call('memory.purge', { retention_days: 365 }); diff --git a/supabase/functions/_shared/limits/memory-quota.ts b/supabase/functions/_shared/limits/memory-quota.ts new file mode 100644 index 000000000..7ecbdec5a --- /dev/null +++ b/supabase/functions/_shared/limits/memory-quota.ts @@ -0,0 +1,81 @@ +/** + * Self-contained Deno mirror of `packages/mcp-core/src/limits/memory-quota.ts`; `edge-parity.spec.ts` + * compares the two with comments stripped, so keep the code below behaviourally identical. + */ + +import { effectiveOrgIds, restrictsTenancy, type KeyRestriction } from '../auth/tenant-scope.ts'; + +/** + * The row `lorekit_memory_count` returns (migration 00047): jsonb, so every + * field arrives untyped and is coerced below rather than trusted. + */ +export interface MemoryCountRow { + count?: unknown; + personal_count?: unknown; + org_count?: unknown; + limit?: unknown; + plan?: unknown; +} + +/** The caller's quota, as `GET /memories/quota` and `memory.quota` answer it. */ +export interface MemoryQuota { + /** Plan name (`free` when the caller has no `user_plans` row). */ + plan: string; + /** Personal memories against the personal cap: the pair the cap trigger enforces. */ + personal: { count: number; limit: number; remaining: number }; + /** Active org-owned memories across the orgs the caller (and its key's tenancy) reaches. Each org has its own cap. */ + org_count: number; + /** `personal.count + org_count`: every active memory the caller can see. */ + total: number; +} + +/** The two reads `quotaForKey` needs, passed in so this module does no I/O of its own. */ +export interface QuotaTenancyReads { + /** The key owner's member orgs (`lorekit_member_org_ids`). */ + memberOrgIds(): Promise; + /** Active (non-archived) org-owned memories across exactly these orgs. */ + countOrgMemories(orgIds: string[]): Promise; +} + +const DEFAULT_PLAN = 'free'; + +function nonNegativeInteger(value: unknown): number { + const n = Number(value); + return Number.isFinite(n) && n > 0 ? Math.floor(n) : 0; +} + +/** Shape a `lorekit_memory_count` row into the quota contract. Total: never throws. */ +export function memoryQuotaFromRow(row: MemoryCountRow | null | undefined): MemoryQuota { + const personalCount = nonNegativeInteger(row?.personal_count); + const orgCount = nonNegativeInteger(row?.org_count); + const limit = nonNegativeInteger(row?.limit); + const plan = typeof row?.plan === 'string' && row.plan.trim() !== '' ? row.plan : DEFAULT_PLAN; + return { + plan, + personal: { count: personalCount, limit, remaining: Math.max(limit - personalCount, 0) }, + org_count: orgCount, + total: personalCount + orgCount, + }; +} + +/** + * The quota as the calling key may see it: for a key narrowed to `personal` or selected orgs (00068), + * `org_count` and `total` recounted over `effectiveOrgIds`, capped at the owner's figure; `personal` + * is never narrowed because the cap is account-wide. + * @param quota the owner's quota, from `memoryQuotaFromRow`. + * @param key the calling key's restriction; `undefined` for a JWT or an unrestricted caller. + * @param reads the membership read and the org count, supplied by each surface. + * @returns `quota` unchanged, or a copy with `org_count` and `total` recounted. + * @throws whatever `reads` throws; a failed read must not fall back to the owner's figure. + */ +export async function quotaForKey( + quota: MemoryQuota, + key: KeyRestriction | undefined, + reads: QuotaTenancyReads, +): Promise { + if (!restrictsTenancy(key)) return quota; + const orgIds = effectiveOrgIds(await reads.memberOrgIds(), key); + const reachable = orgIds.length === 0 ? 0 : nonNegativeInteger(await reads.countOrgMemories(orgIds)); + const orgCount = Math.min(reachable, quota.org_count); + return { ...quota, org_count: orgCount, total: quota.personal.count + orgCount }; +} diff --git a/supabase/functions/_shared/limits/org-memory-count.ts b/supabase/functions/_shared/limits/org-memory-count.ts new file mode 100644 index 000000000..790be2014 --- /dev/null +++ b/supabase/functions/_shared/limits/org-memory-count.ts @@ -0,0 +1,29 @@ +/** + * The org half of `lorekit_memory_count`, recounted over a subset of the caller's orgs, for + * `quotaForKey` (`memory-quota.ts`). Shared by `GET /memories/quota` and `memory.quota`. + * Edge-only I/O, so it has no mcp-core twin; the decision about WHICH orgs lives in the + * mirrored, unit-tested `quotaForKey`. + */ + +import type { DbClient } from '../db/db-client.ts'; +import { createTracedClient, type Span } from '../telemetry/otel.ts'; + +/** + * Active (non-archived) org-owned memories across `orgIds`, matching the RPC's own org count. + * @throws Error when the count query fails; the caller must not fall back to a wider figure. + */ +export async function countActiveOrgMemories(db: DbClient, span: Span, orgIds: string[]): Promise { + if (orgIds.length === 0) return 0; + // The api_key path runs on the service-role client, which bypasses RLS: `orgIds` must already be + // membership ∩ the key's tenancy (`effectiveOrgIds`), because this filter is the whole boundary. + const { count, error } = await createTracedClient(db, span) + .from('memories') + .select('id', { head: true, count: 'exact' }) + .in('org_id', orgIds) + .is('archived_at', null); + if (error) { + span.error(`DB: ${error.message}`); + throw new Error(error.message); + } + return count ?? 0; +} diff --git a/supabase/functions/_shared/rest/rest-tool-name.ts b/supabase/functions/_shared/rest/rest-tool-name.ts index 392508339..d0fb4dd56 100644 --- a/supabase/functions/_shared/rest/rest-tool-name.ts +++ b/supabase/functions/_shared/rest/rest-tool-name.ts @@ -55,6 +55,7 @@ export const REST_TOOL_NAMES: Readonly> = { 'memories POST /purge': 'memory.purge', 'memories POST /purge-expired': 'memory.purge_expired', 'memories GET /scopes': 'memory.scopes', + 'memories GET /quota': 'memory.quota', 'memories GET /usage': 'memory.usage', 'memories GET /usage/runs': 'memory.usage-runs', 'memories GET /tags': 'memory.tags', diff --git a/supabase/functions/_shared/schemas/memory.ts b/supabase/functions/_shared/schemas/memory.ts index 5ff65b638..bef73bef6 100644 --- a/supabase/functions/_shared/schemas/memory.ts +++ b/supabase/functions/_shared/schemas/memory.ts @@ -448,6 +448,31 @@ export type ScopeCount = z.infer; export const ScopesResponseSchema = z.object({ scopes: z.array(ScopeCountSchema) }); export type ScopesResponse = z.infer; +// ── GET /memories/quota ────────────────────────────────────────────────────── + +/** + * `GET /memories/quota` — the caller's active memories against their plan's cap, + * shaped by `memoryQuotaFromRow` (`packages/mcp-core/src/limits/memory-quota.ts`). + */ +export const MemoryQuotaResponseSchema = z.object({ + /** Plan name; `free` when the caller has no plan row. */ + plan: z.string(), + /** Personal active memories, the personal cap, and the writes left under it. */ + personal: z.object({ + count: z.number().int().nonnegative(), + limit: z.number().int().nonnegative(), + remaining: z.number().int().nonnegative(), + }), + /** + * Active org-owned memories across the orgs the caller reaches (each org has its own cap): + * every org it belongs to, or only those its token's tenancy admits (`0` under `personal`). + */ + org_count: z.number().int().nonnegative(), + /** `personal.count + org_count` — every active memory the caller can see. */ + total: z.number().int().nonnegative(), +}); +export type MemoryQuotaResponse = z.infer; + // ── GET /memories/tags ─────────────────────────────────────────────────────── /** diff --git a/supabase/functions/_shared/schemas/openapi/spec.ts b/supabase/functions/_shared/schemas/openapi/spec.ts index e58ccc341..d3d7032d0 100644 --- a/supabase/functions/_shared/schemas/openapi/spec.ts +++ b/supabase/functions/_shared/schemas/openapi/spec.ts @@ -28,6 +28,7 @@ import { RestoreResponseSchema, PurgeResponseSchema, ScopesResponseSchema, + MemoryQuotaResponseSchema, ListTagsQuerySchema, TagsResponseSchema, ListFacetsQuerySchema, @@ -137,6 +138,7 @@ export function generateSpec(baseUrl = 'https://pqokxlhvnosogizsjztg.supabase.co registry.register('RestoreResponse', RestoreResponseSchema); registry.register('PurgeResponse', PurgeResponseSchema); registry.register('ScopesResponse', ScopesResponseSchema); + registry.register('MemoryQuotaResponse', MemoryQuotaResponseSchema); registry.register('UsageStatsResponse', UsageStatsResponseSchema); registry.register('Org', OrgResponseSchema); registry.register('OrgList', OrgListResponseSchema); @@ -274,6 +276,22 @@ export function generateSpec(baseUrl = 'https://pqokxlhvnosogizsjztg.supabase.co 401: errorResponse, 403: errorResponse, }, }); + registry.registerPath({ + method: 'get', path: '/memories/quota', + summary: 'Your active memories against your plan cap, and how many you can still write', tags: ['Memories'], + description: + '`personal` is the pair the memory cap enforces: personal memories against the personal limit, ' + + 'with `remaining` writes left before `POST /memories` is refused with `memory_cap`. Org-owned ' + + 'memories count against each org\'s own cap, so they are reported as `org_count` and are not ' + + 'subtracted from `remaining`. A token narrowed to `personal` or to selected orgs gets ' + + '`org_count` and `total` over only the orgs it reaches (`0` under `personal`). Requires a ' + + 'user-scoped credential; a service-role token has no user and gets 403.', + security, + responses: { + 200: { description: 'Quota', content: { 'application/json': { schema: MemoryQuotaResponseSchema } } }, + 401: errorResponse, 403: errorResponse, + }, + }); registry.registerPath({ method: 'get', path: '/memories/tags', summary: 'List every visible label with how many memories carry it', tags: ['Memories'], diff --git a/supabase/functions/_shared/schemas/tool-catalog.ts b/supabase/functions/_shared/schemas/tool-catalog.ts index 4e162fc30..d4f08e3c9 100644 --- a/supabase/functions/_shared/schemas/tool-catalog.ts +++ b/supabase/functions/_shared/schemas/tool-catalog.ts @@ -507,6 +507,29 @@ export const MCP_TOOLS = [ inputSchema: { type: 'object', properties: {} }, returns: '`{ "scopes": [{ "scope", "count", "last_activity" }] }`, sorted by count desc then scope asc (busiest scope first). `count` is active (non-archived, non-expired) memories; `last_activity` is the newest `created_at` among them, or `null`.', }, + { + name: 'memory.quota', + description: + 'How many active memories you hold against your plan\'s cap, and how many writes are left before ' + + 'memory.write is refused with memory_cap. Takes no arguments. Check it before a bulk write or ' + + 'a migration instead of discovering the cap from a failed write.', + permission: 'read', + annotations: READ_ONLY, + auth: 'token-or-jwt', + surfaces: { + mcp: true, + cli: 'quota', + rest: 'GET /quota', + handler: 'toolQuota', + localMcpExempt: 'server-side plan and memory cap; the offline store has no limit', + }, + inputSchema: { type: 'object', properties: {} }, + returns: + '`{ "plan", "personal": { "count", "limit", "remaining" }, "org_count", "total" }`. `personal` is the ' + + 'pair the cap enforces; org-owned memories count against each org\'s own cap, so `org_count` is ' + + 'reported beside it and not subtracted from `remaining`. A token narrowed to `personal` or to selected orgs ' + + 'sees `org_count` and `total` over only the orgs it reaches. A service-role caller has no user and is refused.', + }, { name: 'memory.list_archived', description: 'List archived (soft-deleted) lessons, for one scope or across every scope you can see', diff --git a/supabase/functions/_shared/telemetry/usage-stats.ts b/supabase/functions/_shared/telemetry/usage-stats.ts index e2eb56f01..6cfde0282 100644 --- a/supabase/functions/_shared/telemetry/usage-stats.ts +++ b/supabase/functions/_shared/telemetry/usage-stats.ts @@ -116,6 +116,7 @@ const READ_TOOL_NAMES: ReadonlySet = new Set([ 'memory.list', 'memory.search', 'memory.scopes', + 'memory.quota', 'memory.list_archived', 'memory.usage', 'org.list', diff --git a/supabase/functions/mcp/permissions.ts b/supabase/functions/mcp/permissions.ts index e24fc0fb5..a1856c572 100644 --- a/supabase/functions/mcp/permissions.ts +++ b/supabase/functions/mcp/permissions.ts @@ -30,6 +30,9 @@ export const READ_TOOLS: ReadonlySet = new Set([ // with records that are not lore. Permission gating and the records-read // metric are different questions about the same family. 'memory.scopes', + // Your own counts against your cap. Read-gated like every other read: a + // write-only token has no business learning the account's size. + 'memory.quota', // `org.list` is a read like any other. It used to be ungated because the org // tools were JWT-only; they now serve `lk_*` tokens, and listing the orgs you // belong to is exactly the shape of thing a read token should be able to do. diff --git a/supabase/functions/mcp/tool-dispatch.generated.ts b/supabase/functions/mcp/tool-dispatch.generated.ts index 8a1cbbb58..9cad21a93 100644 --- a/supabase/functions/mcp/tool-dispatch.generated.ts +++ b/supabase/functions/mcp/tool-dispatch.generated.ts @@ -13,6 +13,7 @@ import { toolSearch, toolArchive, toolScopes, + toolQuota, toolListArchived, toolRestore, toolPurge, @@ -40,6 +41,7 @@ export const MEMORY_TOOLS = { 'memory.search': toolSearch, 'memory.archive': toolArchive, 'memory.scopes': toolScopes, + 'memory.quota': toolQuota, 'memory.list_archived': toolListArchived, 'memory.restore': toolRestore, 'memory.purge': toolPurge, diff --git a/supabase/functions/mcp/tools.ts b/supabase/functions/mcp/tools.ts index ebbcea9f5..c0471233a 100644 --- a/supabase/functions/mcp/tools.ts +++ b/supabase/functions/mcp/tools.ts @@ -28,6 +28,8 @@ import { createTracedClient, type Span } from '../_shared/telemetry/otel.ts'; import { translateCapError } from './limits.ts'; import { translateOrgPermissionError } from './org-permissions.ts'; import { parseCreatedAt } from '../_shared/limits/created-at.ts'; +import { memoryQuotaFromRow, quotaForKey, type MemoryCountRow } from '../_shared/limits/memory-quota.ts'; +import { countActiveOrgMemories } from '../_shared/limits/org-memory-count.ts'; import { parseTtl } from './ttl.ts'; import { parseOrigin } from '../_shared/provenance/origin.ts'; import { recordAuditDeferred } from '../_shared/audit/audit.ts'; @@ -1359,6 +1361,36 @@ export async function toolScopes( return { scopes }; } +/** + * `memory.quota` — the caller's active memories against their plan's cap. + * Same RPC and shaping as `GET /memories/quota`; counts only, so a scoped key may call it. + */ +export async function toolQuota( + db: DbClient, + _params: Params, + userId: string | null, + span: Span, + keyScoping?: KeyRestriction, +) { + if (!userId) throw new UserInputError('memory.quota requires a user-scoped credential (service-role tokens have no user and no cap)'); + + const tracedDb = createTracedClient(db, span); + const { data, error } = await tracedDb.rpc('lorekit_memory_count', { p_user_id: userId }); + if (error) throw new Error((error as { message: string }).message); + + // A key narrowed to `personal` or selected orgs sees only the orgs it reaches (00068). + const quota = await quotaForKey(memoryQuotaFromRow(data), keyScoping, { + memberOrgIds: () => memberOrgIds(db, userId), + countOrgMemories: (orgIds) => countActiveOrgMemories(db, span, orgIds), + }); + span.setAttributes({ + 'lorekit.plan': quota.plan, + 'lorekit.memory.count': quota.personal.count, + 'lorekit.memory.limit': quota.personal.limit, + }); + return quota; +} + // ═══════════════════════════════════════════════════════════════════════════ // Retention policies ("grooming") — policy.*, groom.*, memory.protect. // diff --git a/supabase/functions/memories/CLAUDE.md b/supabase/functions/memories/CLAUDE.md index 9835d9052..9dd128adb 100644 --- a/supabase/functions/memories/CLAUDE.md +++ b/supabase/functions/memories/CLAUDE.md @@ -19,6 +19,7 @@ Handles all memory operations via HTTP. Auth is managed by the shared `resolveRe | POST | /purge | purge.ts | write | | POST | /purge-expired | purge.ts | write | | GET | /scopes | scopes.ts | read | +| GET | /quota | quota.ts | read | | GET | /tags | tags.ts | read | | GET | /facets | facets.ts | read | | GET | /activity | activity.ts | read | diff --git a/supabase/functions/memories/handlers/quota.ts b/supabase/functions/memories/handlers/quota.ts new file mode 100644 index 000000000..e9cc13ae0 --- /dev/null +++ b/supabase/functions/memories/handlers/quota.ts @@ -0,0 +1,41 @@ +import type { AuthContext } from '../../_shared/api/auth.ts'; +import type { DbClient } from '../../_shared/api/auth.ts'; +import { keyRestriction } from '../../_shared/api/auth.ts'; +import { ok, forbidden } from '../../_shared/api/respond.ts'; +import { getMemberOrgIds } from '../../_shared/api/tenant.ts'; +import { createTracedClient } from '../../_shared/telemetry/otel.ts'; +import type { Span } from '../../_shared/telemetry/otel.ts'; +import { memoryQuotaFromRow, quotaForKey, type MemoryCountRow } from '../../_shared/limits/memory-quota.ts'; +import { countActiveOrgMemories } from '../../_shared/limits/org-memory-count.ts'; + +/** + * GET /memories/quota — the caller's active memories against their plan's cap, with the org half + * narrowed to the calling key's tenancy (`quotaForKey`); a service-role caller has no cap and is refused. + */ +export async function handleQuota( + _req: Request, auth: AuthContext, db: DbClient, span: Span, + _params: Record, cors: Record, +): Promise { + span.setAttributes({ 'lorekit.operation': 'memories.quota' }); + const userId = auth.userId; + if (!userId) { + return forbidden('Quota requires a user-scoped credential (service-role tokens have no user and no cap)', cors); + } + + const tracedDb = createTracedClient(db, span); + const { data, error } = await tracedDb.rpc('lorekit_memory_count', { + p_user_id: userId, + }); + if (error) { span.error(`DB: ${error.message}`); throw error; } + + const quota = await quotaForKey(memoryQuotaFromRow(data), keyRestriction(auth), { + memberOrgIds: () => getMemberOrgIds(db, userId, span), + countOrgMemories: (orgIds) => countActiveOrgMemories(db, span, orgIds), + }); + span.setAttributes({ + 'lorekit.plan': quota.plan, + 'lorekit.memory.count': quota.personal.count, + 'lorekit.memory.limit': quota.personal.limit, + }); + return ok(quota, cors); +} diff --git a/supabase/functions/memories/index.ts b/supabase/functions/memories/index.ts index 47580c56b..e4e1b3fb6 100644 --- a/supabase/functions/memories/index.ts +++ b/supabase/functions/memories/index.ts @@ -15,6 +15,7 @@ import { handleSearch } from './handlers/search.ts'; import { handleRestore } from './handlers/restore.ts'; import { handlePurge, handlePurgeExpired } from './handlers/purge.ts'; import { handleScopes } from './handlers/scopes.ts'; +import { handleQuota } from './handlers/quota.ts'; import { handleUsage } from './handlers/usage.ts'; import { handleUsageRuns } from './handlers/usage-runs.ts'; import { handleTags } from './handlers/tags.ts'; @@ -72,6 +73,7 @@ const router = createRouter([ { method: 'POST', path: '/purge', handler: handlePurge, requires: 'write' }, { method: 'POST', path: '/purge-expired', handler: handlePurgeExpired, requires: 'write' }, { method: 'GET', path: '/scopes', handler: handleScopes, requires: 'read' }, + { method: 'GET', path: '/quota', handler: handleQuota, requires: 'read' }, { method: 'GET', path: '/usage', handler: handleUsage, requires: 'read' }, { method: 'GET', path: '/usage/runs', handler: handleUsageRuns, requires: 'read' }, { method: 'GET', path: '/tags', handler: handleTags, requires: 'read' },