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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/key-files.md
Original file line number Diff line number Diff line change
Expand Up @@ -186,8 +186,10 @@ else. Grouped loosely by subsystem in migration/PR order.
| `packages/mcp-core/src/memory/read-refs.ts` (+ its edge mirror) | The pure core behind batch `memory.read` (`refs`), shared by MCP's `toolRead` and REST's `handleRead`. Import-free by construction — it takes only already-PARSED `{scope,key}` pairs, never re-deriving the `scope::key` grammar `parseMemoryRefs` alone owns. `groupRefsByScope` collapses N refs into one `{scope, keys}` group per DISTINCT scope (plan D5) — the transport issues exactly one `.eq('scope', s).in('key', keys)` query per group, awaited concurrently, instead of one query per ref. `isQueryableKey` rejects a key carrying a comma/paren/quote/backslash BEFORE it reaches the query, since postgrest-js's `.in()` filter quotes a comma/paren but does not escape a quote or backslash inside the value — a bad ref becomes a `missing` entry rather than a malformed filter list. `missingRefs` reports every unresolved ref as its own caller-supplied `scope::key` string, in REQUEST order — but it only ever sees what `parseMemoryRefs` RETURNED, so a ref dropped as malformed or truncated past the 32-ref cap reaches neither `entries` nor `missing` (the gap is visible as `lorekit.refs.requested` vs `lorekit.refs.count`); matching stays against the VERBATIM stored scope, deliberately diverging from the singular `memory.read` path's `validateScope` normalisation, because the transports differ in what they hand `memory_write` and no reader can infer how a row was cased — see `docs/decisions.md#memoryread-batching-is-conditional-verbatim-scoped-and-inflates-a-signal-it-doesnt-count` |
| `supabase/migrations/00108_retention_conditions_on_aggregates.sql` | Gives `lorekit_memory_facets` (24→31 args), `lorekit_memory_activity` (26→31) and `lorekit_memory_pivot` (27→34) the five retention thresholds `lorekit_memory_list` has had since 00092, plus a `created_since`/`created_until` pair on facets and pivot (activity's own `since`/`until` already bound `created_at`). **The bug it fixes is a DISAGREEMENT, not a missing feature**: only the list applied the thresholds, so `max_opened_count => 0` narrowed the Explorer's rows and left its facet counts, stat cards and matrix counting the un-narrowed population — every number on the page describing a different set from the one below it. Introduces `lorekit_match_retention`, ONE inlinable row test now composed by all four readers, so a fourth inline copy cannot drift (00101 records that exact drift happening once already between two copies). It takes CUTOFF INSTANTS rather than day counts, which is what keeps it `immutable` and index-usable — each caller resolves its `now()`-relative bounds once per query in a declare block — and it holds 00100's never-opened rule (`coalesce(last_opened_at, created_at)`) in one place. **Applied in the `base` CTE's WHERE, never as an `ok_*` flag**, so a threshold narrows even the SELF-EXCLUDED dimension: self-exclusion exists so the dimension you are standing in still lists what you could switch to, and switching `host` does not stop you looking at lore older than 30 days. The three aggregates are DROPPED at their exact old signatures and recreated (`create or replace` cannot change a signature, and leaving both overloads makes every PostgREST named-arg call ambiguous — 00092's footgun); `lorekit_memory_list` is a true `create or replace` at its verbatim 00105 signature, so its grants persist. Pinned by §108 |
| `supabase/migrations/00113_explorer_reverse_activity_filters_and_opened_sort.sql` | The Explorer's REVERSE directions: `opened_within_days` and the `min_seen_count`/`min_read_count`/`min_opened_count` floors, the mirror of four of the five retention thresholds, for "which lore IS used?" rather than "what can I archive?". Widens `lorekit_match_retention` 10→14 args (the 10-arg form is dropped LAST, after every caller is recreated), appends the four to `_facets`/`_activity`/`_pivot` and, with a `p_cursor_count`, to `lorekit_memory_list`, which also accepts `p_sort => 'opened_count'` (the format() splices `$44` or `$29` as the keyset placeholder). `opened_within_days` does NOT age a never-opened lesson from `created_at` — the opposite of 00100's rule for `unseen_days`, on purpose. READ-only: no groom RPC or `GroomConditionsSchema` change. Restores the `anon` revoke on `lorekit_memory_list` that 00105's drop-and-recreate lost. Pinned by §113; §108's helper-ACL check now derives the signature |
| `supabase/migrations/00115_explorer_recent_read_sort.sql` | The Explorer's **Recent read** order: `lorekit_memory_list(p_sort => 'last_opened_at')` orders by the last DELIBERATE agent read (00099's column, not the bulk-moved `last_read_at`), `desc NULLS LAST` with `id desc` breaking ties, so never-opened lessons trail as a tail rather than leading or vanishing (the total is unchanged). Same signature as 00114 (`create or replace`): the nullable keyset rides in `p_cursor_ts`/`p_cursor_id`, where a NULL timestamp with a set id means "inside the never-opened tail". The keyset predicate and ORDER BY are built per sort in the declare block. Pinned by §115 |
| `supabase/migrations/00114_exclude_tags_dimension.sql` | `p_exclude_tags text[]` appended to `lorekit_memory_list`/`_facets`/`_activity`/`_pivot` (each dropped at its exact 00113 signature first): an always-applied "carries none of these labels" conjunct, independent of `p_tags_mode`, reusing `lorekit_match_tags(…, 'none')` — so "tagged a AND NOT tagged b" is expressible. In `_facets` it is an `ok_tag_exclusion` flag the tag facet NEVER self-excludes (an excluded label stays listed at `count = 0`); `_pivot` narrows `base`; the list binds it as `$45`. The dashboard sends it only when a tag include is also active — a lone exclusion still travels as `tags_mode=none`, and retention policies never gain it. Pinned by §114 |
| `packages/mcp-core/src/rest/count-cursor.ts` | The `sort=opened_count` keyset cursor (`{ id, opened_count }`, base64url) and its `buildCountPage`. Separate from `_shared/api/paginate.ts` because that cursor carries a timestamp and is byte-mirrored into the MCP function, which has no count sort. Mirrored verbatim into `supabase/functions/_shared/api/count-cursor.ts`, guarded by `edge-parity.spec.ts`; tested by `count-cursor.spec.ts` |
| `packages/mcp-core/src/rest/last-opened-cursor.ts` | The `sort=last_opened_at` keyset cursor (`{ id, last_opened_at }`, base64url, the timestamp NULLABLE because a page can end inside the never-opened tail) and its `buildLastOpenedPage`. Its own codec for `count-cursor.ts`'s reason: `paginate.ts` rejects a missing timestamp and is byte-mirrored into the MCP function. Requires the `last_opened_at` KEY, so it rejects the date and count cursors (and they reject it). Mirrored verbatim into `supabase/functions/_shared/api/last-opened-cursor.ts`, guarded by `edge-parity.spec.ts`; tested by `last-opened-cursor.spec.ts` |
| `supabase/functions/_shared/api/retention.ts` | 00108's fix one layer up: `retentionFrom` picks the five thresholds off a validated query or body, `retentionRpcParams` maps them to the `p_*` arguments all four RPCs share. Used by `list.ts` and the three aggregate handlers, so a route cannot be given the parameter and then forget to forward it. `?? null` and never a truthiness check — `max_opened_count => 0` is the whole point of 00105, so a falsy test turns the most useful threshold in the set into no filter at all |
| `packages/web/src/lib/retention-filter.ts` → `retentionConditionsToAggregateBody` | The client half. Delegates to `retentionConditionsToListBody` (the field names are identical by construction — one shared `retentionConditionBodyFields` spread in the schemas) but is typed against `ListFacetsBody`, so a schema that stops carrying one of the five is a TYPE error rather than a threshold the aggregate silently ignores. Its spec reads each field back off the PARSED schema output rather than asserting `safeParse(...).success`: these schemas are not `.strict()`, so zod strips an unknown key and still reports success — a success-only check would pass against the pre-00108 routes and prove nothing |
| `packages/cli/src/core/lessons.mjs` → `citationHint` | The Stop-hook ask. Reads the session's already-recorded shown-set and names those `scope::key` refs, capped at `CITED_HINT_MAX` (8) with a `(+N more)` tail — an agent picking from a list is a different task from one recalling a convention. Empty when nothing was injected (with no candidates the ask is an instruction to invent references). Refs are taken in INJECTION order, so the SessionStart set leads |
2 changes: 1 addition & 1 deletion docs/otel.md
Original file line number Diff line number Diff line change
Expand Up @@ -1015,7 +1015,7 @@ attribute here.
| `lore.memory_archive_toggled` | A memory was archived or restored | `lorekit.lore.memory.archive_action`, `.archive_outcome` |
| `lore.filter_changed` | Any filter-bar mutation, age/activity thresholds included | `lorekit.lore.filter.action`, `.field` (optional), `.operator` (optional), `.count`, `.retention_count` |
| `lore.status_changed` | Active / Archived / Expiring | `lorekit.lore.status` |
| `lore.sort_changed` | The list order was picked (Newest / Most chosen) | `lorekit.lore.sort` (`created_at`/`opened_count`) |
| `lore.sort_changed` | The list order was picked (Newest / Most chosen / Recent read) | `lorekit.lore.sort` (`created_at`/`opened_count`/`last_opened_at`) |
| `lore.search_committed` | The debounced search settled | `lorekit.lore.search.length` |
| `lore.scope_selected` | A scope chip or Browse-all row was picked | `lorekit.lore.scope.type`, `.source` (`strip`/`browse-all`) |
| `lore.range_changed` | Any of the FIVE controls that write `?range=` | `lorekit.lore.range.preset`, `.source`, `.span_days` (omitted when unbounded) |
Expand Down
3 changes: 2 additions & 1 deletion packages/cli/src/shared/deeplink-pure.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,8 @@ export const LORE_PARAM_DEFAULTS = {
// five retention thresholds plus, since 00113, their four reverse directions).
// `null` means no narrowing; absent from the URL when unset.
retention: null,
// 'created_at' | 'opened_count' | null — the list order (00113). `null` is
// 'created_at' | 'opened_count' | 'last_opened_at' | null — the list order
// (00113, 00115). `null` is
// Newest, the default, so an unsorted link carries no `sort` param.
sort: null,
// 'active' | 'archived' | 'expiring' | null — the Explorer's Status control.
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/shared/mirror-pairs.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ export const mirrorPairs = [
{ 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/rest/last-opened-cursor.ts', edge: 'supabase/functions/_shared/api/last-opened-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 },
// Which row an UNSCOPED read resolves to. Import-free on both sides (it only
// parses the scope prefix and compares strings), so the byte comparison
Expand Down
90 changes: 90 additions & 0 deletions packages/mcp-core/src/rest/last-opened-cursor.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { describe, it, expect } from 'vitest';
import {
buildLastOpenedPage,
decodeLastOpenedCursor,
encodeLastOpenedCursor,
} from './last-opened-cursor.ts';
import { decodeCountCursor, encodeCountCursor } from './count-cursor.ts';

const ID = '0f8fad5b-d9cb-469f-a165-70867728950e';
const ID2 = '7c9e6679-7425-40de-944b-e07fc1f90ae7';
const TS = '2026-10-20T09:15:00.123+00:00';

/** A `paginate.ts`-shaped date cursor, built the way that module builds one. */
function dateCursor(id: string, field: 'updated_at' | 'created_at', ts: string): string {
return btoa(JSON.stringify({ id, [field]: ts })).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
}

describe('last_opened_at cursor codec', () => {
it('round-trips an id and a timestamp', () => {
expect(decodeLastOpenedCursor(encodeLastOpenedCursor(ID, TS))).toEqual({ id: ID, ts: TS });
});

it('keeps a NULL timestamp — a page that ended inside the never-opened tail is a real boundary', () => {
expect(decodeLastOpenedCursor(encodeLastOpenedCursor(ID, null))).toEqual({ id: ID, ts: null });
});

it('emits URL-safe text with no padding', () => {
expect(encodeLastOpenedCursor(ID, TS)).toMatch(/^[A-Za-z0-9_-]+$/);
expect(encodeLastOpenedCursor(ID, null)).toMatch(/^[A-Za-z0-9_-]+$/);
});

it.each([
['an updated_at date cursor', dateCursor(ID, 'updated_at', TS)],
['a created_at date cursor', dateCursor(ID, 'created_at', TS)],
['an opened_count cursor', encodeCountCursor(ID, 3)],
])('refuses %s, so flipping sort mid-pagination restarts at page one', (_label, raw) => {
expect(decodeLastOpenedCursor(raw)).toBeNull();
});

it('is refused by the count decoder in turn', () => {
expect(decodeCountCursor(encodeLastOpenedCursor(ID, TS))).toBeNull();
expect(decodeCountCursor(encodeLastOpenedCursor(ID, null))).toBeNull();
});

it.each([
['not base64 at all', '%%%'],
['valid base64, not JSON', btoa('hello')],
['JSON, not an object', btoa('42')],
['JSON null', btoa('null')],
['the key absent (an id alone is not this cursor)', btoa(JSON.stringify({ id: ID }))],
['non-uuid id', btoa(JSON.stringify({ id: 'abc', last_opened_at: TS }))],
['numeric timestamp', btoa(JSON.stringify({ id: ID, last_opened_at: 1_760_000_000 }))],
['free-text timestamp', btoa(JSON.stringify({ id: ID, last_opened_at: 'yesterday' }))],
['a timestamp carrying a PostgREST operator', btoa(JSON.stringify({ id: ID, last_opened_at: ',id.gt.0' }))],
])('fails closed on %s', (_label, raw) => {
expect(decodeLastOpenedCursor(raw)).toBeNull();
});
});

describe('buildLastOpenedPage', () => {
const rows = [
{ id: ID, last_opened_at: TS },
{ id: ID2, last_opened_at: null },
{ id: '00000000-0000-4000-8000-000000000001', last_opened_at: null },
];

it('reports more pages from the overflow row and cursors on the LAST returned row', () => {
const page = buildLastOpenedPage(rows, 1);
expect(page.entries).toHaveLength(1);
expect(page.hasMore).toBe(true);
expect(decodeLastOpenedCursor(page.nextCursor ?? '')).toEqual({ id: ID, ts: TS });
});

it('mints a NULL-timestamp cursor when the page ends inside the never-opened tail', () => {
const page = buildLastOpenedPage(rows, 2);
expect(page.hasMore).toBe(true);
expect(decodeLastOpenedCursor(page.nextCursor ?? '')).toEqual({ id: ID2, ts: null });
});

it('has no cursor on the last page', () => {
const page = buildLastOpenedPage(rows, 5);
expect(page.hasMore).toBe(false);
expect(page.nextCursor).toBeNull();
});

it('reads a missing column as never opened rather than minting an undecodable cursor', () => {
const page = buildLastOpenedPage([{ id: ID }, { id: ID2 }], 1);
expect(decodeLastOpenedCursor(page.nextCursor ?? '')).toEqual({ id: ID, ts: null });
});
});
84 changes: 84 additions & 0 deletions packages/mcp-core/src/rest/last-opened-cursor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
/**
* The keyset cursor for the `sort=last_opened_at` list — "Recent read"
* (migration 00115): the lessons an agent deliberately read most recently,
* first, and the never-opened ones after them.
*
* Pure and shared (mirrored into `supabase/functions/_shared/api/last-opened-cursor.ts`,
* guarded by `edge-parity.spec.ts`) so the codec is unit-tested here and the
* deployed copy cannot drift from what was tested.
*
* Why it is not one more case in `_shared/api/paginate.ts`: that cursor's
* timestamp is never null (both date sorts are NOT NULL columns), its decoder
* rejects a missing one, and it is byte-mirrored into the MCP function, which
* has no such sort. `last_opened_at` is NULL for a lesson nothing ever opened,
* and a page can end inside that never-opened tail — so this cursor must carry
* a null timestamp, and say so explicitly: `{ id, last_opened_at: null }`.
*
* The three formats cannot be mistaken for each other. `paginate.ts`'s decoder
* finds neither `updated_at` nor `created_at` here, `count-cursor.ts`'s finds
* no integer `opened_count`, and this decoder requires the `last_opened_at`
* KEY to be present (with a timestamp or `null`) — so a caller who flips
* `sort` mid-pagination restarts at page one rather than receiving a page cut
* at the wrong place.
*/

/**
* The decoded keyset position: the last row's id and its `last_opened_at` —
* `null` when that row was never opened, i.e. the page ended inside the tail.
*/
export interface LastOpenedCursor {
id: string;
ts: string | null;
}

/** A cursor's `id` is a `memories` primary key, which is a `uuid`. */
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;

/** The same leading shape `paginate.ts` requires of its timestamp. */
const TS_RE = /^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}/;

/** base64url without padding — the same alphabet `paginate.ts` uses. */
export function encodeLastOpenedCursor(id: string, ts: string | null): string {
return btoa(JSON.stringify({ id, last_opened_at: ts }))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=/g, '');
}

/**
* Decode a Recent read cursor, or `null` for anything that is not one.
*
* Fails CLOSED: the values reach the RPC as typed parameters (`uuid`,
* `timestamptz`), so a malformed one would be a 500 rather than an injection —
* and the right answer to a cursor that does not parse is the first page, the
* same contract `paginate.ts` and `count-cursor.ts` keep.
*/
export function decodeLastOpenedCursor(raw: string): LastOpenedCursor | null {
try {
const b64 = raw.replace(/-/g, '+').replace(/_/g, '/');
const pad = (4 - (b64.length % 4)) % 4;
const parsed: unknown = JSON.parse(atob(b64 + '='.repeat(pad)));
if (!parsed || typeof parsed !== 'object' || !('last_opened_at' in parsed)) return null;
const { id, last_opened_at: ts } = parsed as { id?: unknown; last_opened_at?: unknown };
if (typeof id !== 'string' || !UUID_RE.test(id)) return null;
if (ts !== null && (typeof ts !== 'string' || !TS_RE.test(ts))) return null;
return { id, ts };
} catch {
return null;
}
}

/**
* The page and its next cursor, from `limit + 1` rows — the overflow row is how
* `hasMore` is known, exactly as `paginate.ts`'s `buildPage` reads it.
*/
export function buildLastOpenedPage<T extends { id: string; last_opened_at?: string | null }>(
rows: T[],
limit: number,
): { entries: T[]; hasMore: boolean; nextCursor: string | null } {
const hasMore = rows.length > limit;
const entries = hasMore ? rows.slice(0, limit) : rows;
const last = entries[entries.length - 1];
const nextCursor = hasMore && last ? encodeLastOpenedCursor(last.id, last.last_opened_at ?? null) : null;
return { entries, hasMore, nextCursor };
}
9 changes: 9 additions & 0 deletions packages/schemas/src/domain/memory-body.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,15 @@ describe('ListMemoriesBodySchema', () => {
expect(ListMemoriesBodySchema.safeParse({ sort: 'read_count' }).success).toBe(false);
});

it('accepts the last_opened_at ("Recent read") sort on both transports (00115)', () => {
expect(ListMemoriesBodySchema.parse({ sort: 'last_opened_at' }).sort).toBe('last_opened_at');
expect(ListMemoriesQuerySchema.parse({ sort: 'last_opened_at' }).sort).toBe('last_opened_at');
// `last_read_at` is the BROADER column (bulk list/search pages move it) and
// is deliberately not an order: "Recent read" means a deliberate read.
expect(ListMemoriesBodySchema.safeParse({ sort: 'last_read_at' }).success).toBe(false);
expect(ListMemoriesQuerySchema.safeParse({ sort: 'last_read_at' }).success).toBe(false);
});

it('coerces the reverse directions on the query transport like every other threshold', () => {
const r = ListMemoriesQuerySchema.parse({ sort: 'opened_count', min_opened_count: '10', opened_within_days: '7' });
expect(r.sort).toBe('opened_count');
Expand Down
Loading
Loading