Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.<key>` (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 |
Expand Down
1 change: 1 addition & 0 deletions docs/api-tokens.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 14 additions & 0 deletions docs/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Comment thread
dash0-dev[bot] marked this conversation as resolved.
Resolved

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** /
Expand Down
2 changes: 1 addition & 1 deletion docs/decisions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
3 changes: 2 additions & 1 deletion docs/key-files.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
15 changes: 15 additions & 0 deletions docs/limits.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
38 changes: 37 additions & 1 deletion docs/mcp-tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |

Expand Down Expand Up @@ -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.
Expand Down
16 changes: 16 additions & 0 deletions packages/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading