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
23 changes: 23 additions & 0 deletions docs/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -435,6 +435,29 @@ kept on every rewrite instead of being dropped. `migrate` to remote carries `att
`content_type`; it does not carry `supersedes` (a local id means nothing to the hosted store) or the
local `id` (the hosted store mints its own).

**Versions and `--expected-version` (migration 00120).** Every record has a `version`: `1` when it
is created, one higher on each content change (value, content type, merged attributes, tags, or a
new `supersedes` link), unchanged by an identical rewrite, a TTL change, provenance, archive or
restore. A write to an archived key starts again at `1`. The local store keeps it in the
frontmatter (appended to `FIELDS`) and applies the same rule as the hosted trigger; a file written
before the field existed reads as version `1`. `write` reports the new version (`version` in
`--json`), and `show` and `list --json` include it.

- `--expected-version <n>` — write only if the memory is still at version `n`; `0` means it must
not exist yet (an archived or expired record counts as absent, as it does for reads; an expired
record rewritten this way keeps counting from its old version, and with no `--ttl-days` /
`--clear-ttl` and no configured default its past expiry is cleared so it is live again). On a
mismatch nothing is written and the
command exits `1` naming the stored version:
`version conflict — global::k is at version 4, not 3. Nothing was written.` Works against both
stores; remotely the check is atomic in the `memory_write` RPC (`409 version_conflict`).

`show` also prints `superseded by <id>, …` when other live records name this one in their
`supersedes` (newest first; `superseded_by` in `--json`). Locally that is computed across both store
tiers on `show`/`read`; `list` and `search` do not compute it offline, because it would cost a walk
of the whole store on the hook hot path. A local relocation (`migrate --to project`) keeps each
record's `version`; `migrate` to remote does not send it — the hosted store counts its own.

### `archive`/`delete`/`restore`

(aliases: `rm` → `delete`) (the removal lifecycle, the counterpart to `write` — `archive
Expand Down
4 changes: 3 additions & 1 deletion docs/key-files.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,9 @@ else. Grouped loosely by subsystem in migration/PR order.
| `supabase/migrations/00009_memory_write_created_at.sql` | Drops + recreates the `memory_write` RPC with an optional `p_created_at timestamptz default null`; INSERT uses `coalesce(p_created_at, now())` for both `created_at` and `updated_at` (conflict-update never touches `created_at`). Lets `memory.write` backdate a migrated memory. No table change — `created_at` already existed |
| `packages/mcp-core/src/limits/ttl.ts` | Pure `parseTtlDays` — validates the optional `memory.write` `ttl_days` override (1–365 integer, rejects 0/fractional/non-finite). Mirrored self-contained in `supabase/functions/mcp/ttl.ts` (edge) and, for the days half only, in `packages/cli/src/store/ttl.mjs` (zero-dep CLI — which adds the offline-only `expiresAtFrom`/`isExpired`/`isLive`/`resolveExpiresAt`; `parseTtlSeconds`/`parseTtlMinutes`/`parseTtl` have no CLI equivalent) — the `limits.ts` pattern |
| `packages/mcp-core/src/provenance/origin.ts` | Pure `parseOrigin` (+ per-field `parseOriginRepo`/`Branch`/`Commit`/`Pr`) — validates the four optional `memory.write` provenance params (`origin_repo`/`origin_branch`/`origin_commit`/`origin_pr`), the record of WHERE a lesson was recorded from as opposed to `scope`, which says where it applies. `origin_branch` is deliberately NOT lowercased (unlike a `branch::` scope) so the dashboard's `/tree/` link resolves for a mixed-case branch. Mirrored self-contained into `supabase/functions/_shared/provenance/origin.ts` (`_shared/` because BOTH the MCP tools and the REST `POST /memories` handler validate through it) and guarded by `edge-parity.spec.ts` — the `created-at.ts` pattern |
| `packages/mcp-core/src/provenance/record-extensions.ts` | Pure `parseRecordFields` — validates the three optional record extensions on `memory.write` (`attributes`, `content_type`, `supersedes`, migration 00119), plus `splitCitedIds` (a `cited` entry may be a memory id), `translateRecordFieldDbError` (the RPC's and the table constraints' rejections → the same user error) and `compactRecordFields` (a read leaves out extensions that hold their defaults). Mirrored into `supabase/functions/_shared/provenance/record-extensions.ts` and used by MCP `toolWrite`/`toolRead`, REST `POST /memories`, `_shared/telemetry/citations.ts` and mcp-core `write.ts` — the `origin.ts` pattern |
| `packages/mcp-core/src/provenance/record-extensions.ts` | Pure `parseRecordFields` — validates the three optional record extensions on `memory.write` (`attributes`, `content_type`, `supersedes`, migration 00119), plus `splitCitedIds` (a `cited` entry may be a memory id), `translateRecordFieldDbError` (the RPC's and the table constraints' rejections → the same user error) and `compactRecordFields` (a read leaves out extensions that hold their defaults). Mirrored into `supabase/functions/_shared/provenance/record-extensions.ts` and used by MCP `toolWrite`/`toolRead`, REST `POST /memories`, `_shared/telemetry/citations.ts` and mcp-core `write.ts` — the `origin.ts` pattern. Also (migration 00120) `parseExpectedVersion`, `VersionConflictError` (the RPC's `LK003` → code `version_conflict`, REST 409 / MCP `isError`), and the pure `groupSupersededBy` / `withSupersededBy` behind the computed `superseded_by` read field |
| `supabase/functions/_shared/memory/superseded-by.ts` | `fetchSupersededBy` — the ONE query (per page, served by `memories_supersedes_idx`) that computes `superseded_by` for MCP `memory.read`/`list`/`search` and the REST read handlers. The caller passes its own tenant filter (`applyTenantScope` / `applyRestTenantScope` / RLS), so a successor the caller cannot read is never named |
| `supabase/migrations/00120_memory_version.sql` | `memories.version` + the `memories_bump_version` trigger (content changes only), `memory_write`'s `p_expected_version` compare-and-set (`LK003`), and `version` on `lorekit_memory_list` |
| `supabase/migrations/00048_memory_origin.sql` | Adds the four nullable `memories.origin_*` columns (+ CHECK backstops) and drops/recreates `memory_write` with trailing defaulted `p_origin_repo`/`p_origin_branch`/`p_origin_commit`/`p_origin_pr`. **Last KNOWN origin wins**: every branch's `do update set` uses `coalesce(excluded.origin_x, memories.origin_x)`, so a write with no git context never erases what an earlier write recorded. No index — no read path filters on origin yet. Proven by `migrations.test.sql` §62–§65 |
| `packages/web/src/lib/origin.ts` | Pure `originLinks(origin, scope?)` + the four URL builders (`originPullRequestUrl`/`originBranchUrl`/`originCommitUrl`/`originRepoUrl`) and `shortSha` — the provenance counterpart to `scope.ts`'s `scopeRepoUrl`. Total functions: a partial or malformed origin yields fewer links, never a throw or a broken href. **Complements the scope rather than repeating it**: given the memory's `scope` it drops any origin the scope-derived Repo row already links — the repo row when `scopeRepoRef(scope).repo` is the same repo (so it survives only for a `global`/`project` lesson or a genuinely cross-repo origin), and the branch row when a `branch::` scope already names that branch of that repo. Pull request and commit are never suppressed — no scope can express them. Storage stays complete (the DB always records the full origin); the de-duplication is a RENDER concern, so a memory that is later re-scoped still shows the right rows. Rendered by `components/memory/MemoryOrigin.tsx` inside `LessonDetailSheet`'s Metadata `<dl>` |
| `packages/cli/src/shared/origin.mjs` | `deriveOrigin({cwd,env,run})` / `prNumberFromEnv` / `mergeOrigin` — the CLI's provenance derivation, the counterpart to `scope.mjs`'s `deriveScope`. Git runner and env are injected so it is testable with no repo and no CI runner. Prefers `GITHUB_HEAD_REF` over `git rev-parse` (a GitHub Actions `pull_request` checkout is a detached merge commit); PR precedence is `LOREKIT_PR` → `GITHUB_REF`'s `refs/pull/<n>/merge` → `GITHUB_PR_NUMBER`. `mergeOrigin` OMITS an unknown field rather than sending null, which is what makes the RPC's coalesce rule non-destructive. Consumed by `write.mjs` (with `--no-origin` / `--origin-*` overrides) and by `mcp-server.mjs`'s `memory.write` dispatch (best-effort, caller values win) |
Expand Down
48 changes: 41 additions & 7 deletions docs/mcp-tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ Store or update a lesson. Requires a token with write permission (`lk_rw_*` or `
| `kind` | | Bucket kind: `lesson`, `bus`, or `signal`. Omit to have it inferred from a `loop::<host>-lessons` tag. See **Taxonomy** below. |
| `host` | | Owning skill/agent (e.g. `reviewer`, `aw`). Omit to have it inferred from a `loop::<host>-lessons` tag. |
| `org` | | Org slug to write under (org-owned write). Omit for a personal memory. You must be a write-capable member (`member`/`admin`/`owner`, not `viewer`) of the org — verified server-side; supplying an org you're not authorized for is rejected. |
| `ttl_days` | | Integer 1–365. The memory auto-expires after this many days. Mutually exclusive with `ttl_minutes` and `ttl_seconds`; supply at most one. On an update, refreshes the expiry; omitting all three leaves the existing expiry unchanged. |
| `ttl_days` | | Integer 1–365. The memory auto-expires after this many days. Mutually exclusive with `ttl_minutes` and `ttl_seconds`; supply at most one. On an update, refreshes the expiry; omitting all three leaves the existing expiry unchanged (except on an `expected_version` write that revives an expired memory, which clears the past expiry). |
| `ttl_minutes` | | Integer 1–525600 (365 days in minutes). The memory auto-expires after this many minutes. Mutually exclusive with `ttl_days` and `ttl_seconds`. |
| `ttl_seconds` | | Integer 1–31536000 (365 days in seconds). The memory auto-expires after this many seconds. Useful for short-lived session memories (e.g. 30 s, 5 min). Mutually exclusive with `ttl_days` and `ttl_minutes`. |
| `origin_repo` | | Provenance: `owner/name` of the repository the lesson was recorded from. Lowercased. |
Expand All @@ -111,6 +111,7 @@ Store or update a lesson. Requires a token with write permission (`lk_rw_*` or `
| `content_type` | | `text/markdown` (default) or `application/json`. With `application/json` the value must parse. Omit on an update to keep the stored type. |
| `supersedes` | | The `id` of the memory this one replaces. Must name a memory this token can read **and** that has the same owner as the one being written: your own personal memory for a personal write, a memory in the same org for an org write. Anything else, including an id that does not exist, fails with the same error, so the field cannot be used to probe for ids. Kept on update unless re-supplied. |
| `cited` | | Lessons that shaped this run, as `scope::key` strings or memory ids. Unresolvable entries are dropped silently. |
| `expected_version` | | Integer ≥ 0. Write only if the memory at `scope`+`key` is still at this `version`; `0` means it must not exist yet. A mismatch writes nothing and fails with `version_conflict`, naming the stored version. Omit for an unconditional write. See **Versions** below. |

**Provenance (`origin_*`).** `scope` says where a lesson **applies**; the four
`origin_*` fields say where it was **recorded from**, and the dashboard turns
Expand Down Expand Up @@ -166,6 +167,37 @@ All three are validated by the shared parser in
inputs with the same message. `PATCH /memories/:id` does not accept them; set
them with a write.

**Versions (`version` / `expected_version` / `superseded_by`).** Every memory
carries a `version` (migration 00120): `1` when it is created, and one higher
each time its **content** changes — `value`, `content_type`, `attributes`,
`tags`, or a `supersedes` link being set or changed. Nothing else bumps it: not
a TTL change, a counter (`seen_count`, reads), an embedding, archive/restore, a
move between owners, a link cleared because its target was deleted, or a write
that changes nothing. A database trigger maintains it, so no caller can set it.
A write to an archived key starts a new record at version `1`.

`expected_version` turns a write into a compare-and-set. Read the memory, keep
its `version`, and pass it back on the write: if anyone changed the content in
between, the write is refused instead of silently overwriting their change.
`expected_version: 0` creates the memory only if it does not exist yet — an
archived or expired memory counts as absent, exactly as reads hide it (an expired
one is rewritten in place and keeps counting from its old version; with no
`ttl_days` / `ttl_minutes` / `ttl_seconds` / `clear_ttl` on that write its past expiry is cleared, so the
record is live again). The
check is atomic in the `memory_write` RPC. A refusal is an `isError` tool
result (REST: `409`) with code `version_conflict`:

```text
version_conflict: expected_version 3 does not match the stored version 4. Re-read the memory, then retry with its current version.
```

`superseded_by` is the reverse of `supersedes`: on `memory.read`,
`memory.list` and `memory.search`, a memory that other live records name in
their `supersedes` carries their ids, newest first. It is computed on read,
never stored, with one query per page; it only names records the caller can
read (same tenant filter as the read itself) that are neither archived nor
expired, and it is omitted when empty.

**Taxonomy (`kind` / `host`).** A self-improvement loop's bucket carries two
facts: WHAT KIND of memory it is and WHICH HOST owns it. `kind` is a closed
vocabulary — `lesson` (procedural, read every run), `bus` (a transient outcome
Expand All @@ -180,7 +212,7 @@ an update the last KNOWN value wins, exactly like `origin_*`.

**Scope→org binding.** If you omit `org` but the scope is **bound to an org** (an admin set that up — see [org-sharing.md](./org-sharing.md#scope--org-binding-auto-routing)), the write auto-routes to that org **when you're a write-capable member**. A binding can also be a **wildcard prefix** (`repo::owner/*`), and when several bindings match, resolution is **most-specific-wins** (an exact scope beats any wildcard; a longer wildcard prefix beats a shorter one). If you're *not* a member of the winning org, it's saved to your personal lore instead (never rejected) and the response carries a `notice` explaining that — unless your token is limited to selected orgs (`orgs_only`), which may not write personal memories: that write is refused with an in-band `isError` result (`key_personal_denied`), so pass `org`. An explicit `org` always overrides binding resolution entirely.

**Returns:** `{ "id": "<uuid>", "created_at": "<iso>" }` — the `id` is the memory's stable identity, kept across updates — plus an optional `"expires_at": "<iso>"` when any `ttl_*` field was supplied, and an optional `"notice": "<string>"` when a write fell back to personal because the scope is bound to an org you can't write to.
**Returns:** `{ "id": "<uuid>", "created_at": "<iso>", "version": <n> }` — the `id` is the memory's stable identity, kept across updates; `version` is its version after this write — plus an optional `"expires_at": "<iso>"` when any `ttl_*` field was supplied, and an optional `"notice": "<string>"` when a write fell back to personal because the scope is bound to an org you can't write to.

**Default TTL — there isn't one, server-side.** Omitting every `ttl_*` field
means the memory never expires, and that is unchanged. Two things nonetheless
Expand Down Expand Up @@ -218,9 +250,11 @@ scope you can see (see [Unscoped reads](#unscoped-reads)).
}
```

**Returns:** `{ "id": "<uuid>", "value": "<markdown>", "updated_at": "<iso>", "scope": "<canonical scope>" }`, or
**Returns:** `{ "id": "<uuid>", "value": "<markdown>", "updated_at": "<iso>", "scope": "<canonical scope>", "version": <n> }`, or
`null` if not found. `id` is the memory's stable identity (what `supersedes` and `cited` accept).
`attributes`, `content_type` and `supersedes` are included only when they hold a non-default value. `scope` names the scope that actually answered — the same value you passed when
`attributes`, `content_type` and `supersedes` are included only when they hold a non-default value.
`superseded_by` (the ids of newer records that replace this one) is included only when non-empty —
see **Versions** under `memory.write`. `scope` names the scope that actually answered — the same value you passed when
you passed one, and the resolved winner when you did not. An ambiguous unscoped read additionally
carries `other_scopes: ["<scope>", …]`, every OTHER scope the key also exists in.

Expand Down Expand Up @@ -304,7 +338,7 @@ reference past the 32nd, so neither appears in `entries` or in `missing`: the li
report, never a malformed-input or truncation one. To tell "absent" from "never looked up", compare
`entries` + `missing` against what you sent.

**Returns:** `{ "entries": [{ "scope", "key", "value", "updated_at" }], "missing": ["scope::key", …] }`
**Returns:** `{ "entries": [{ "scope", "key", "value", "updated_at", "version" }], "missing": ["scope::key", …] }` (each entry also carries `superseded_by` when non-empty)
— `missing` names every well-formed reference within the first 32 that matched no lesson, in no
particular order.

Expand Down Expand Up @@ -342,7 +376,7 @@ a list returns every match rather than one winner.
| `host` | | Filter to the owning skill/agent, e.g. `reviewer`, `aw`, `ci-auto-fix` |
| `view` | `full` | `full` returns each entry's complete `value`. `summary` omits `value` and returns `value_bytes` + a 200-character `preview` instead |

**Returns:** `{ "entries": [{ "scope", "key", "value", "tags", "updated_at" }], "hasMore": boolean, "nextCursor": string | null }` — with `view: "summary"` each entry is `{ "scope", "key", "tags", "updated_at", "value_bytes", "preview" }` instead, and `value` is omitted entirely. `scope` is present on every entry, scoped call or not, so one response shape covers both.
**Returns:** `{ "entries": [{ "scope", "key", "value", "tags", "updated_at", "version" }], "hasMore": boolean, "nextCursor": string | null }` — each entry also carries `superseded_by` when non-empty; with `view: "summary"` each entry is `{ "scope", "key", "tags", "updated_at", "version", "value_bytes", "preview" }` instead, and `value` is omitted entirely. `scope` is present on every entry, scoped call or not, so one response shape covers both.

- `recency` (default): pass `nextCursor` back as `cursor` to read the next page.
- `rank`: a single bounded top-N page — `hasMore` is always `false` and `nextCursor` always `null`.
Expand Down Expand Up @@ -459,7 +493,7 @@ Full-text search across all lessons. Supports owner-level scope wildcards.
| `tags` | `[]` | AND-filter on tags |
| `limit` | `20` | Max results (cap: 100) |

**Returns:** `{ "entries": [{ "key", "value", "scope", "tags", "rank" }] }`
**Returns:** `{ "entries": [{ "key", "value", "scope", "tags", "version", "rank" }] }` (each entry also carries `superseded_by` when non-empty)

---

Expand Down
Loading
Loading