Ctrl K
ComunidadGuías…

docs/mcp-tools.md

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
> **Migration note (next release):** every MCP action now answers in one response envelope —
> `{ items, meta }` for collections, `{ item, meta }` for single objects — `cwd` is optional on
> the project-scoped actions (it narrows an answer to one project; without it a read answers
> across every project and says so in `meta`, except `memory_status action=propose`, whose plan
> stays project-pinned), and an id-shaped `query` resolves that thought directly.
> See the **Migration notes (response envelope, cwd scope, id lookup)** section at the end of this
> document for the full list of breaking changes.
>
> **Migration note (v0.19):** Project scoping on MCP tools uses `cwd` (working directory) instead of `project_id`. The exception is `memory_manage` actions `update` and `delete`, which require `project_id` to identify the project. See the **Migration notes (v0.19 MCP UX cleanup)** section at the end of this document for the full list (compact defaults, preview tokens, `edge_review` canonical keys, retired inputs).

# MCP Tools Reference

SynaptoMind exposes its graph operations as 11 MCP tools, over both the stdio and HTTP
transports. Each tool multiplexes several actions behind an `action` argument. This page is
the detailed companion to the in-band `memory_guide` tool (`src/mcp/tools/guide.ts`), which
returns a short quick map plus a pointer here at runtime.

Every thought operation below is MCP-only: the HTTP listener is an ops plane
(`/health` plus the two `/api/health-check` routes) and has no graph endpoints — see
[docs/API.md](API.md). For end-to-end usage, see
[docs/SCENARIOS.md](SCENARIOS.md). This page documents the current surface only; removals
between releases are recorded in [CHANGELOG.md](../CHANGELOG.md).

## Tool map

| Tool | Actions | Purpose |
|---|---|---|
| `memory_recall` | search, get, context, chain, clusters, list | Find and retrieve thoughts (list = paginated inventory) |
| `memory_store` | create, update, link, link_batch, unlink, unlink_batch, retype, bulk_create | Write, connect and edit thoughts |
| `memory_supersede` | archive, archive_batch, merge | Version and supersede thoughts |
| `memory_status` | slots, frontier, config, health, propose | Query system state |
| `memory_manage` | list, create, update, delete, resolve | Project management |
| `edge_review` | enqueue, list, apply, apply_batch, rollback, reject | Review and apply queued placement proposals |
| `retention_review` | list, protect, delete, compact | Review retention candidates and take explicit retention actions |
| `memory_crystallize` | graph, cluster, auto_cluster | Visualize and reorganize clusters |
| `memory_reflect` | reflect, timeline | Session management |
| `memory_primers` | list, delete | Manage the always-at-hand set (primers) |
| `memory_guide` | (no action) | In-band reference text |

## Response envelope

Every action answers in one envelope (`src/mcp/tools/envelope.ts`):

- a **collection** action returns `{ "items": [...], "meta": {...} }`;
- a **single-object** action returns `{ "item": ..., "meta": {...} }` (`item` is `null` when the
  action legitimately found nothing);
- `meta` always carries the same keys, whatever the action.

The envelope is the payload of the wire envelope described in
[Wire envelope](#wire-envelope): a client reads it from `structuredContent.result` (or from
`content[].text` for the two raw-text actions).

### `meta`

| Key | Type | Description |
|---|---|---|
| `action` | string | The action that produced the answer (`search`, `slots`, `link_batch`, ...) |
| `scope` | object \| null | Resolved project scope; `null` when the call carried no `cwd` or the action is not project-scoped |
| `counts` | object | Row accounting (see below) |
| `degraded` | boolean | `true` when the answer is degraded (semantic leg down, missing best match) |
| `warnings` | string[] | Non-fatal notes; empty when there are none |
| `reason` | string | Optional: why the answer is empty — `no_match`, `id_not_found`, `ambiguous_id` |
| `preview` | object | Optional: present only when the answer previews a destructive write |

`scope` reports the project `cwd` resolved to:

```json
{ "project": "01a172b4-d49b-76f4-ada8-7ba408661064", "project_name": "Doc Probe", "global": false }
```

- `project` is the project id and `project_name` its display name;
- `null` means the answer was not scoped to a project: the call omitted `cwd` (then `warnings`
  carries a `no cwd` note — `no cwd — the answer spans every project` for the readers that widen,
  the project-pinned note of [propose](#actionpropose) for that one) or the action is not
  project-scoped;
- `global: true` with `project: null` means the answer spans every project although a `cwd` was
  given — it names a path that is not registered (then `warnings` carries a
  `cwd "..." matches no known project` note). The call still succeeds; it just is not narrowed.
  `memory_status action=propose` is the exception: its plan never widens, so the same path yields one
  composed note that pairs the unknown-path fact with the pinning clause and never claims a
  cross-project answer ([Working directory](#working-directory-cwd)).

`counts` always carries three shared keys plus whatever the action can report:

| Key | Description |
|---|---|
| `total` | Rows the action considered (search: post-filter candidates; list: the un-paged count) |
| `returned` | Rows in `items`, or `1`/`0` for a single `item` |
| `filtered_out` | Rows deliberately dropped from `total` (relevance gate, paging, retired proposal kinds) |

Action-specific counters sit beside them (`link_batch` → `linked`/`failed`; `archive_batch` →
`archived`/`failed`; `retention_review` list, `memory_recall` list and `memory_reflect` timeline →
`limit`/`offset`; `edge_review` list → `retired_count`; `graph` → `edges`; `cluster` →
`members`/`edges`), so a caller never has to count `items` itself.

`reason` is present only when "nothing" is not the ordinary case:

| Value | Meaning |
|---|---|
| `no_match` | The action considered nothing — a search that matched no candidate, a context lookup without a best match |
| `id_not_found` | An id-shaped search query addressed no thought **and** the fallback word search matched nothing either (the same miss also appears in `warnings`) |
| `ambiguous_id` | An id prefix addressed several thoughts; `items` carries all of them (up to `limit`) |

A result set emptied by the relevance gate keeps **no** `reason`: it shows up as
`counts.filtered_out > 0` instead, so a caller can tell "nothing matched" from "everything was
gated out".

### The `memory_crystallize action=graph` exception

A graph is two collections, not one, so this is the single documented exception to
`item`/`items`: the nodes are `items` and the edges ride beside them in a named `edges` field
(`src/mcp/tools/crystallize/graph.ts`):

```json
{
  "items": [
    { "id": "01a172b6-5dfc-73e5-b28e-91236675b273", "label": "MCP_DOC_MARKER: the envelope example thought", "title": "MCP_DOC_MARKER: the envelope example thought", "group": "test", "tags": [], "category": "concept", "status": "active", "is_cluster": false, "project_name": "Doc Probe" }
  ],
  "edges": [
    { "id": "01a172b6-5dfd-7207-98e9-e76a296474fd", "source": "01a172b6-5dfc-73e5-b28e-91236675b273", "target": "01a172b6-5dfc-73e6-b63a-ded850ebfd9b", "type": "supports" }
  ],
  "meta": {
    "action": "graph",
    "scope": { "project": "01a172b6-5dfa-754c-b67e-a0a2f5915d96", "project_name": "Doc Probe", "global": false },
    "counts": { "edges": 1, "total": 4, "returned": 4, "filtered_out": 0 },
    "degraded": false,
    "warnings": []
  }
}
```

`counts` keeps the shared three keys for the **nodes**: `total` is the node count matching the
filters, `returned` the nodes of this page and `filtered_out` the nodes the page left out
(`offset`/`limit`), so `total = returned + filtered_out` holds here like in every other
collection. `edges` holds the active edges whose two endpoints are both in `items`, and
`counts.edges` is how many of them there are — not a total.

### Preview metadata

A non-mutating preview answer is marked in `meta.preview`; an executed answer carries no
`preview` key at all:

| Key | Description |
|---|---|
| `token` | Token the confirm call must send back; `null` for the tokenless previews (`unlink_batch`, `retention_review` `compact`) |
| `destructive` | Always `true` — confirming this answer writes to the graph |
| `consequence` | What confirming would do, in one sentence (where the action has one) |
| `instruction` | The exact call that confirms this preview (where the action phrases one) |

A single-object preview also keeps `status: "preview"`, `preview_token`, `consequence` and
`instruction` **inside `item`**; a collection preview keeps them only in `meta.preview`, because
the batch rows in `items` already carry their own per-row status.

## Wire envelope

Every tool declares the same output shape, `{ result: <payload> }` (`toolOutputShape`,
`src/mcp/tools/utils.ts:12`). A successful action returns that payload in `structuredContent.result`
mirroring the JSON in `content[].text` (`jsonResult`, `src/mcp/tools/utils.ts:14-22`).
Bare string payloads are wrapped as `{ text: string }` so no action ever returns a bare string in
`result`; arrays and objects pass through unchanged.

Two encodings coexist. `memory_guide` is the one raw-text tool: it keeps the human-readable
payload in `content[].text` verbatim and reports `structuredContent.result = { text }`, so
`content[].text` is **not** `JSON.stringify(result)` there. `memory_status action=config` keeps its
display text verbatim in `content[].text` too, but wraps it in the standard envelope as
`result = { item: { text }, meta }`. Every other tool goes through `jsonResult`, where
`content[].text = JSON.stringify(payload)` and `result = payload`; a string passed to `jsonResult`
is wrapped to `{ text }` and its `content[].text` becomes the JSON `{"text":"..."}`.

`memory_status action=config` therefore carries the configuration display as raw text in
`content[].text` and the same string as `structuredContent.result.item.text`, with the standard
envelope `meta` beside it (`src/mcp/tools/status/config.ts`).

An action failure returns `isError: true` with the human-readable message in `content[].text`. When
the failure carries a domain status code, the classification is additionally mirrored into
`structuredContent.result.error`:

```json
{
  "content": [{ "type": "text", "text": "Embedder request timed out" }],
  "structuredContent": {
    "result": {
      "error": { "message": "Embedder request timed out", "statusCode": 503, "retryable": true }
    }
  },
  "isError": true
}
```

- **Nesting.** The metadata sits under `result` — the key the declared output schema expects. The
  SDK client validates `structuredContent` against that schema whenever the key is present, so
  putting it anywhere else would fail the check (`src/mcp/tools/utils.ts:40-42`).
- **Classified vs unclassified.** Only errors exposing a numeric `statusCode` are enriched. A plain
  `Error` or a non-`Error` throw keeps the plain `{ content, isError }` shape with **no**
  `structuredContent` key (`src/mcp/tools/utils.ts:52-55`, `src/mcp/tools/action-tool.ts:99-108`).
- **Retryability.** `retryable` is `statusCode === 503`. The 503 class is the transient
  availability failures — embedder not ready, overloaded, timed out, send failure
  (`src/errors.ts`) — the only class safe to repeat as-is. Every other classified code
  (`400`, `404`, `409`) is reported `retryable: false` (`src/mcp/tools/action-tool.ts:107`).

A client should re-issue the identical request only when `retryable` is `true`; otherwise the
failure is a bad request and repeating it will not help.

## Working directory (`cwd`)

`cwd` is the **optional** scope argument of every project-scoped MCP action: it limits an answer to
one project, and omitting it — or passing an empty string — is never an error: a reader answers
across every project and declares that in `meta`, while a writer falls back to the default project.
The one reader that never widens is `memory_status action=propose`, whose plan stays pinned to the
project of the thought it addresses (see below).

| Tool | Actions taking `cwd` |
|---|---|
| `memory_recall` | `search`, `context`, `clusters`, `list` |
| `memory_status` | `slots`, `frontier`, `propose` (pinned, never widened — see below) |
| `memory_reflect` | `reflect`, `timeline` |
| `memory_crystallize` | `graph`, `cluster` |
| `edge_review` | `list` |
| `memory_manage` | `resolve` — the one action that requires it |

The actions outside the table either address their rows by id (`get`, `chain`, `unlink`, `archive`,
...) or are global by design (`health`, `config`, `primers`, `crystallize auto_cluster`), so they
take no `cwd`. Writes that name a target project (`memory_store` `create`/`update`, `bulk_create`,
`memory_supersede merge`, `edge_review enqueue`) accept it too, and land in the project it resolves
to.

Except `propose` (below), a read that omits `cwd` succeeds and declares the widened answer in
`meta`: `scope` is `null` and `warnings` carries `no cwd — the answer spans every project`
(`resolveEnvelopeScope`, `src/mcp/tools/envelope.ts:167-184`):

```json
{
  "items": [
    { "id": "01a172b6-5dfc-73e5-b28e-91236675b273", "content": "MCP_DOC_MARKER: the envelope example thought", "status": "active", "tags": [], "project": "Doc Probe" }
  ],
  "meta": {
    "action": "list",
    "scope": null,
    "counts": { "limit": 1, "offset": 0, "total": 4, "returned": 1, "filtered_out": 3 },
    "degraded": false,
    "warnings": ["no cwd — the answer spans every project"]
  }
}
```

`memory_manage action=resolve` is the exception: there the path is the input rather than a scope, so
a call without `cwd` fails with an `isError` envelope naming the argument
(`src/mcp/tools/manage.ts:109`).

`memory_status action=slots` adds a second warning without `cwd`, because only the global slot rows
are read and the project-scoped overrides stay unapplied (`src/mcp/tools/status/slots.ts:11-12`,
`:34-38`).

`memory_status action=propose` is the one read that never widens: `cwd` scopes its candidate pool to
one project, and without it — or with a path that resolves to no project — the plan stays pinned to
the project the addressed thought belongs to, or to the default project for draft `content`
(`src/services/placement/engine.ts:84-112`, `:151-156`; `src/services/placement/placement.ts:92-93`;
`src/mcp/tools/status/propose.ts:6-36`).
A cwd-less call replaces the broadening note with one of two pinning notes:

`no cwd — propose is not cross-project: the plan is restricted to the project the addressed thought (thought_id) belongs to`

with draft content it is
`no cwd — propose is not cross-project: the plan is restricted to the default project (draft content)`.

A path that resolves to no project gets **one** composed warning instead of the generic broadening
one (`src/mcp/tools/status/propose.ts:33-35`): the unknown-path fact with its resolve hint, then the
same pinning clause, and no `the answer spans every project` claim anywhere in it:

`cwd "<path>" matches no known project (check it with memory_manage action=resolve) — propose is not cross-project: the plan is restricted to the project the addressed thought (thought_id) belongs to`

with draft content it is
`cwd "<path>" matches no known project (check it with memory_manage action=resolve) — propose is not cross-project: the plan is restricted to the default project (draft content)`.

So the plan is still pinned, never widened, while `scope` stays the unresolved
`{ project: null, project_name: null, global: true }`: the warning, not the scope, states the project
the plan really used.

A write action resolves `cwd` the same way but lands in the project it names, or in the default
project when `cwd` is absent, and raises no scope warning — nothing widens. `memory_reflect
action=reflect` is a write in this sense; its own section below spells out where each of its outputs
lands. A read given a path that is not registered is never an error either: it reports
`scope.global: true` plus a warning and answers across every project — `propose` excepted: its plan is
always pinned, so the same path yields the single composed note above, which pairs the unknown-path
fact with the pinning clause instead of claiming a cross-project answer.

There is no HTTP counterpart to that rule: the HTTP listener serves only `/health` and the
`/api/health-check` pair, so a REST client cannot reach a thought at all
([API.md](API.md)).

## Compact output and field selection

Read tools (`memory_recall`, `memory_status`, `memory_crystallize`,
`retention_review`) accept two output controls, applied to the thought objects inside `item`/`items`:

- `compact: true` — trims each thought to the minimal field set
  `{ id, content, status, tags (as strings), project }`.
- `fields: ["id", "content", ...]` — returns only the requested fields (intersected
  with the available columns). Explicit `fields` take precedence over `compact`.

Examples:

```json
{ "action": "list", "compact": true, "cwd": "/path/to/project" }
{ "action": "list", "fields": ["id", "content"], "cwd": "/path/to/project" }
{ "action": "get", "thought_id": "01a172b4-d486-700c-a16e-5ae3d2d77a90", "compact": true }
{ "action": "search", "query": "marker", "compact": true, "cwd": "/path/to/project" }
```

Output hygiene applies to every shaped thought, compact or not: `tags` are plain strings (never
`{ id, name }`), and the storage 0/1 columns `is_protected`, `is_global` and `is_cluster` are
booleans (`src/mcp/tools/utils.ts`, `shapeThought`).

Compact defaults (v0.19 MCP UX cleanup):

- `memory_recall` — every action defaults to compact output; pass `compact: false`
  for the full thought, or `fields` for an explicit subset.
- `memory_status` `propose` — defaults to the slim plan (top 3 edges, no signals);
  pass `compact: false` or `include_signals: true` (or `max_edges`) for full output.
- `memory_crystallize` `graph`, `retention_review` `list` — compact is opt-in
  (`compact: true`); full output is the default unless `fields` is supplied.
  (`retention_review` applies `compact`/`fields` to its candidate list.)

Explicit `fields` always take precedence over `compact` on every tool.

### Opt-in url links

A thought's url links ride on the thought output behind one boolean argument,
`include_url_links` (default `false`). It is accepted by `memory_recall` `search`,
`clusters`, `list`, `get`, `context` and `chain`, and by `memory_reflect action=timeline`.

- **Shape.** `url_links: [{ text, url, label }]`, ordered by `sort_order` then creation
  time. `text` mirrors the create-time `url_links` input, so an item read from memory can be
  fed back into `memory_store action=create` unchanged; `label` defaults to `text`. Internal
  columns (`id`, `thought_id`, `created_at`) are never emitted.
- **Every thought in the response.** Not just the primary one: `get` attaches it to the
  thought *and* to its `upstream`/`downstream` neighbours, `chain`/`context` to the head and
  both neighbourhoods, and a `group_by_cluster` answer to the cluster members. "Primary only"
  is a rule a caller cannot remember.
- **One query per response.** The links of the whole answer are read in a single batch
  query, never one per thought (`getThoughtUrlLinksBatchService`,
  `src/services/url_links.service.ts`).
- **Not hidden by `compact`, not trimmed by `fields`.** The value is attached after the
  thought shaping (`src/mcp/tools/utils.ts`, `shapeThought`), so
  `include_url_links: true` with `fields: ["id"]` answers `{ id, url_links }`. The flag
  itself is a read control, not a thought column.
- **Absence is distinguishable from emptiness.** Omitted or `false`: the key is **absent**
  from the payload (existing clients see byte-identical output). `true` with no links: the
  key is `[]`.

```json
{
  "action": "get",
  "thought_id": "01a172b4-d486-700c-a16e-5ae3d2d77a90",
  "include_url_links": true
}
```

```json
{
  "item": {
    "id": "01a172b4-d486-700c-a16e-5ae3d2d77a90",
    "content": "Read the ADR before the rewrite",
    "status": "active",
    "tags": [],
    "project": "Doc Probe",
    "url_links": [
      { "text": "adr", "url": "https://forgejo.home.lan/authelia/artifacts/src/branch/main/synaptomind/adr/2026-10-26-shrink-http-api-to-ops-endpoints.md", "label": "adr" }
    ]
  },
  "meta": { "action": "get", "scope": null, "counts": { "total": 1, "returned": 1, "filtered_out": 0 }, "degraded": false, "warnings": [] }
}
```

Url links are **written at create time only** (`memory_store action=create`, `url_links` as
`[{ text, url }]`). There is no MCP action that adds a link to an existing thought or deletes
one — the former `POST`/`DELETE /api/thoughts/:id/links` routes went with the rest of the
HTTP API in 0.20 ([API.md](API.md#dropped-capabilities)).

## Dry-run / confirm pattern

Every destructive action — `memory_store` `unlink` and `retype`, `memory_supersede` `archive`,
`archive_batch` and `merge`, `memory_manage action=delete`, `edge_review` (`apply`/`apply_batch`/`rollback`),
`memory_primers action=delete`, `memory_crystallize action=auto_cluster`, and
`retention_review action=delete` — follows the same two-step pattern:

- **Preview (default).** `confirm` is absent or `false`. Nothing is written: the answer carries
  `meta.preview` (token, `destructive: true`, consequence, instruction) and, for a single-object
  action, `status: "preview"` inside `item`.
- **Execute.** `confirm: true` performs the write and returns an executed answer with no
  `meta.preview`.

Two destructive actions take no token — `memory_store action=unlink_batch` and
`retention_review action=compact` — their preview answers `meta.preview.token: null`.

Retired inputs may remain visible in the advertised tool schema as never-valued tombstones.
Some MCP clients validate arguments against that schema before sending a call, and may reject a
tombstone at the client schema boundary; such a client-side validation error is not a server
response and does not necessarily contain the server's migration text. When a call containing a
retired input reaches the server, invocation validation returns a migration error. Tool
descriptions identify the supported replacements: recall `top_k` → `limit`, `cluster` →
`is_cluster`, `mode` → `hybrid`; status `k` → `limit`, `dry_run` → `confirm`; crystallize `dry_run` → `confirm`;
store link `thought_id` → `source_id`; reflect `goals_delta` → `goals_add` / `goals_remove`;
review enqueue `max_items` → `max_proposals`. `memory_store.is_profile` has no replacement:
remove the field from requests. Retired names are not accepted aliases.

## `memory_recall` — finding and retrieving thoughts

### `action=search`

Hybrid (RRF) / vector search. Arguments: `query` (required), `cwd` (optional: it scopes the
search to one project; omitted, every project is searched, with a `meta.warnings` note), `hybrid`
(boolean, default true: RRF fusion of the vector and BM25 legs; false = vector leg only), `status`
(default active), `tag`, `is_cluster`, `group_by_cluster`, `min_importance`, `limit`,
`min_relevance`, `recency_weight`, `recency_half_life_days`, `supersession_mode`,
`contradiction_mode`.

Result: `{ items, meta }`. Each item is `{ thought }` — plus the safety annotations that apply and,
behind `compact: false`, the ranking metadata:

| Item key | When | Description |
|---|---|---|
| `thought` | always | The (shaped) thought |
| `standing` | always unless it is `current`; `compact: false` always | `superseded` or `contradicted` |
| `superseded_by` | when it applies | Ids of the thoughts that replace this one |
| `contradicted_by` | when it applies | Ids of the thoughts that contradict this one |
| `low_confidence` | when it applies; `compact: false` always | `true` for a weak vector-only hit: no BM25 anchor and a cosine below `search.confidence.vectorFloor`. Never for an id-addressed hit, which has no cosine — its contested state rides in `standing`/`superseded_by`/`contradicted_by` |
| `scores` | `compact: false` only | `{ similarity, distance, rrf_score, bm25_score, relevance, recency_score, final_score }` — only the keys that apply |
| `match_source` | `compact: false` only | `embedding`, `bm25`, `id` |

The safety annotations are never hidden by the default compact output: a superseded, contradicted
or low-confidence hit must not look clean because the caller used the default. `meta.counts.total`
is the number of candidates that survived scope/tag/standing filtering, `filtered_out` the rows the
relevance gate dropped, and `returned` the rows in `items` after the `limit` slice.

A compact hit is `{ "thought": { ... } }`; `meta` is the envelope meta described above:

```json
{
  "thought": {
    "id": "01a172b6-5dfc-73e5-b28e-91236675b273",
    "content": "MCP_DOC_MARKER: the envelope example thought",
    "status": "active",
    "tags": [],
    "project": "Doc Probe"
  }
}
```

#### Id lookup

A `query` that is a thought id resolves that thought directly, without touching the embedding or
BM25 legs (`src/db/search-id.ts`, `src/services/search-outcome.service.ts`):

- **Shape.** A full UUID (32 hex digits, hyphens optional and accepted in any position,
  case-insensitive) or a hex **prefix of at least 13 characters**. The floor is 13 because a UUIDv7
  spends its first 12 hex digits on the millisecond timestamp: below that, a hex-looking *word*
  (`20251025`, `deadbeef`) is indistinguishable from a prefix, so it stays an ordinary query.
  `01a172b4-ebd5-7` and `01A172B4EBD57` address the same prefix.
- **Scope.** The two id forms do not share a scope (`src/db/search-id.ts:19-38`, `:152-158`). A
  **full UUID** is an explicit reference: the lookup ignores `status`, `cwd` (project), `tag`
  and `min_importance`, never widens to other rows, and is never touched by the relevance gate.
  A **prefix** is a timestamp window, not a reference — its first 12 hex digits are the UUIDv7
  millisecond — so it honours the call's `status` and `cwd` (project) scope like any other
  query; a prefix that addresses nothing *inside* that scope is an id miss and falls back to the
  ordinary search (see **None**). `meta.scope` reports what `cwd` resolved to either way.
- **Cluster shape.** `is_cluster` is a shape constraint of the requested collection, not a
  scope, so it applies to both forms (`src/db/search-id.ts:29-32`): `clusters` never answers a
  non-cluster row, and `search` with `is_cluster: true` / `false` filters an id hit the same way
  even for a full UUID.
- **Hits.** Each hit carries `match_source: ["id"]` and no `relevance`, because an id has no
  cosine scale. Standing is annotated exactly as on any other hit — `standing`,
  `superseded_by`, `contradicted_by` — but an id is never suppressed: a requested
  `supersession_mode` of `suppress` degrades to `flag` here, because a caller who named the id
  must get that thought back. `low_confidence` keeps its retrieval-strength meaning and stays
  `false` on an id hit (there is no cosine to judge): the contested state is carried by
  `standing`/`superseded_by`/`contradicted_by`, never by the flag.
- **Several.** A prefix that addresses more than one thought returns them all in `items`, in id
  order, with `meta.reason: "ambiguous_id"`. It is not an error — narrow the prefix or pass the
  full id. The answer is capped at `limit` rows (hard ceiling 100, `ID_PREFIX_MATCH_HARD_CAP`),
  because one `bulk_create` batch shares the UUIDv7 timestamp window and a prefix can address the
  whole batch; a truncated answer still reports `ambiguous_id`.
- **None.** A miss never zeroes the answer: an id-shaped query that addresses no thought — a full
  UUID no row carries, or a prefix that matches nothing inside the requested scope — falls back
  to the ordinary hybrid search for the same text and adds an `id_not_found` note to
  `meta.warnings`. `meta.reason: "id_not_found"` is reported only when that fallback matched
  nothing either, so a hex-looking word stays searchable and its rows keep ordinary `search`
  semantics.

```json
{
  "items": [
    { "thought": { "id": "cafebabe-0000-7000-8000-000000000001", "content": "first ambiguous id thought", "status": "active", "tags": [], "project": "Doc Probe" } },
    { "thought": { "id": "cafebabe-0000-7000-8000-000000000002", "content": "second ambiguous id thought", "status": "active", "tags": [], "project": "Doc Probe" } }
  ],
  "meta": {
    "action": "search",
    "scope": { "project": "01a172b6-5dfa-754c-b67e-a0a2f5915d96", "project_name": "Doc Probe", "global": false },
    "counts": { "total": 2, "returned": 2, "filtered_out": 0 },
    "degraded": false,
    "warnings": [],
    "reason": "ambiguous_id"
  }
}
```

```json
{
  "items": [],
  "meta": {
    "action": "search",
    "scope": { "project": "01a172b6-5dfa-754c-b67e-a0a2f5915d96", "project_name": "Doc Probe", "global": false },
    "counts": { "total": 0, "returned": 0, "filtered_out": 0 },
    "degraded": false,
    "warnings": ["id_not_found: \"cafebabe-0000-7000-8000-0000000000ff\" is id-shaped but no thought carries it — the answer is a normal search for that text"],
    "reason": "id_not_found"
  }
}
```

The second answer is the fallback emptying out. When the same fallback *does* return rows, the
answer is a normal search result set — no `reason`, the `id_not_found` warning still in
`meta.warnings`, so the caller knows the query was read as an id first.

Anything else — a word, a hex run shorter than 13 characters — is an ordinary query and flows
through the semantic and lexical legs.

#### Calibrated `relevance` and the default gate

The raw e5-small cosine scale is compressed, so a raw similarity is not a usable threshold. Each
vector-leg hit therefore also carries a calibrated `relevance` in `[0, 1]`
(`calibrateRelevance`, `src/services/text-similarity.service.ts`):

```
relevance = clamp01((similarity - unrelatedFloor) / (vectorFloor - unrelatedFloor))
```

with `search.confidence.unrelatedFloor = 0.8` (measured zero point) and
`search.confidence.vectorFloor = 0.9` (a strong match) as defaults
([CONFIG.md](CONFIG.md#search)). On the defaults:

| Raw similarity | Calibrated `relevance` |
|---|---|
| 0.80 | 0.0 |
| 0.82 | 0.2 |
| 0.84 | 0.4 |
| 0.86 | 0.6 |
| 0.88 | 0.8 |
| 0.90 and above | 1.0 |

The measurement behind those two floors (task #2178, 200 sampled thoughts, 20 unrelated queries,
real e5-small embedder): unrelated query/thought pairs reach p50 0.7627, p95 0.8002, max 0.8355,
and the per-query maximum of unrelated hits reaches 0.8319 (p95) / 0.8355 (max); the p99 of
self-relevant similarity is 0.8435.

The default noise gate is **0.4 on that calibrated scale** (a raw similarity of 0.84,
`DEFAULT_MIN_RELEVANCE` in `src/services/search-normalize.service.ts`): it clears every unrelated pair
observed in the measurement while dropping 2 of 200 self-relevant top-1 hits.

- A row with a lexical (BM25) anchor or an id is **never** gated; only cosine-only rows must clear
  the bar.
- `min_relevance: 0` turns the gate off and restores the pre-#2178 fail-open result set.
- `min_relevance: <0–1>` overrides the threshold on the calibrated scale.
- `memory_recall action=context` is exempt: its query path runs with the gate off and keeps its
  best match (see `action=context` below).
- The gate runs after standing annotation and before the `limit` slice; the candidate pool is
  over-fetched so gated rows are backfilled. Drops appear as `counts.filtered_out`, not as a
  `reason`.

`degraded: true` means a leg of the search was unavailable (for example the embedder is down); the
answer is still the best available one.

### `action=get`

Fetch one thought by `thought_id`. Result: `{ item, meta }` where `item` is the shaped thought.
Pass `include_edges: true` to return incident edges inline (`upstream`/`downstream`, each with the
edge and its thought); edges are not thought columns, so `compact`/`fields` shape the thoughts
inside them but never remove the edge lists. A missing id fails with a `404` error envelope.

### `action=context` / `action=chain` / `action=clusters`

`context` returns one thought plus its neighbourhood: `{ item: { thought, chain }, meta }`, where
`chain` carries `upstream`/`downstream`. It accepts a text `query` (best match + its chain; `cwd`
scopes the query path when given) or a `thought_id` (that thought + its chain, no project scope).
A degraded query with no best match answers `item: null` with `meta.reason: "no_match"` instead of
throwing. The query path is **not** gated: `context` asks for the single best match, so the
default `min_relevance` gate of `search` would turn a weak-but-real neighbourhood into
`no_match`. `getContextService` therefore runs with `minRelevance: 0` unless a caller opts in
(`src/services/graph.service.ts:161`), and the `min_relevance` argument applies to
`search`/`clusters` only.

`chain` walks a parent/develops chain: `{ item: { thought, upstream, downstream }, meta }`;
`max_degree` bounds chain/context traversal and is distinct from `limit`, which caps returned
results. `clusters` is a cluster-only search: `{ items, meta }` of search items, with `cwd`
optional like `search` (omitted, it searches every project and says so in `meta.warnings`);
an explicit `is_cluster: false` is rejected, because it contradicts what the action is. An
id-shaped query goes through the same id lookup as `search`
([Id lookup](#id-lookup)), with the cluster constraint applied to both forms — a prefix is
scoped by `status`/`cwd` and the cluster shape, while a full UUID ignores the status and project
scope but still only answers a cluster thought: a non-cluster id answers nothing (the ordinary
search fallback runs, and `meta.reason` explains the empty answer) instead of leaking a row the
action exists to exclude (`src/mcp/tools/recall-id-lookup.test.ts:140-172`).

### `action=list`

Paginated inventory of thoughts without a search query. `cwd` is optional: it scopes the page to
one project, and omitted, the page spans every project with a `meta.warnings` note.

| Argument | Type | Required | Description |
|---|---|---|---|
| `status` | enum | no | `draft`, `active`, `archived`, or `all` (default: all) |
| `cwd` | string | no | Restrict the page to one project (omitted = every project) |
| `tag` | string | no | Restrict to thoughts carrying this tag |
| `is_cluster` | boolean | no | `true` = only clusters, `false` = only non-clusters |
| `limit` | integer | no | Page size (1–500, default 50) |
| `offset` | integer | no | Skip this many rows (default 0) |

Result: `{ items, meta }`; `meta.counts` carries the page that actually ran — `limit` and
`offset`, defaults applied — and `total` is the un-paged count matching the filters, `filtered_out`
the rows the page did not include, so a caller pages with `offset += limit` until
`offset >= total`.

```json
{
  "items": [
    { "id": "01a172b6-5dfc-73e5-b28e-91236675b273", "content": "MCP_DOC_MARKER: the envelope example thought", "status": "active", "tags": [], "project": "Doc Probe" }
  ],
  "meta": {
    "action": "list",
    "scope": { "project": "01a172b6-5dfa-754c-b67e-a0a2f5915d96", "project_name": "Doc Probe", "global": false },
    "counts": { "total": 4, "returned": 1, "filtered_out": 3 },
    "degraded": false,
    "warnings": []
  }
}
```

## `memory_store` — writing thoughts and edges

### `action=create` / `action=update`

`create` takes `content` (required), `tags`, `status`, `cwd`, `is_protected`, `is_global`,
`parent_id`, `url_links`; `update` takes `thought_id` (required) plus the fields to change
(`content`, `tags`, `tags_mode`, `status`, `cwd`, `is_protected`, `is_global`, `surface_after`).
Both answer `{ item: <shaped thought>, meta }`; `update` on a missing id fails with a `404` error
envelope.

`update` is **not** an archive path: `status: "archived"` is rejected with an error envelope naming
`memory_supersede action=archive` (preview it, then `confirm: true` with the returned
`preview_token`), while `draft`/`active` transitions are unchanged. Archiving is destructive, so it
keeps its single, token-gated write path.

### `action=link`

Create an edge between two thoughts. Use canonical endpoint parameters `source_id` and `target_id`;
the retired `thought_id` alias is rejected with a hint to use `source_id`. A pair carries **one**
edge: re-linking the same pair with the same type is idempotent for the symmetric types
(`related`, `contradicts`), while a directed duplicate or a second, different type on the pair is
reported as an error — except that a `related` placeholder edge is upgraded in place to the
specific type you ask for. Constraints: no self-loops, and only a cluster thought may carry a
`cluster` edge.

| Argument | Type | Required | Description |
|---|---|---|---|
| `source_id` | string | yes | The source thought |
| `target_id` | string | yes | The target thought |
| `edge_type` | enum | no | One of `related`, `parent`, `develops`, `replaces`, `cluster`, `references`, `depends_on`, `contradicts`, `supports`; default `related` |

Result: `{ item: { id, source_id, target_id, type, created_at }, meta }`.

**Naming convention.** `source_id`/`target_id` name the pair in both writers: `memory_store`
`action=link` and `memory_supersede` `action=merge`.

```json
{
  "item": { "id": "01a172b4-d4a4-77c6-8a1e-a4d19920ef2b", "source_id": "01a172b4-d486-700c-a16e-5ae3d2d77a90", "target_id": "01a172b4-d486-700d-b4f9-02962e9d2f1c", "type": "supports", "created_at": "2026-10-25T10:56:55.972Z" },
  "meta": { "action": "link", "scope": null, "counts": { "total": 1, "returned": 1, "filtered_out": 0 }, "degraded": false, "warnings": [] }
}
```

### `action=link_batch`

Create up to 1000 edges in one call. The batch runs in a **single transaction** and reuses the
`link` writer, so every single-link rule (type vocabulary, no self-loops, one type per pair,
cluster strictness, the `related` upgrade) applies item by item. A failing item is collected
rather than aborting the batch: the accepted items still commit and the failure is reported.

| Argument | Type | Required | Description |
|---|---|---|---|
| `links` | `{source, target, type?}`[] | yes | Edges to create (1–1000 items); `type` defaults to `related` |
| `edge_type` | enum | no | Ignored — each item carries its own `type` |

Result: `{ items, meta }` with one row per requested link, in input order
(`{ index, status, source, target, type?, error? }`) and the aggregate in
`meta.counts.linked` / `meta.counts.failed`:

```json
{
  "items": [
    { "index": 0, "status": "linked", "source": "01a172b3-ebd5-75ba-8a66-c98fef3e3196", "target": "01a172b3-ebf3-7706-93fc-3f4af59deb28", "type": "supports" },
    { "index": 1, "status": "failed", "source": "01a172b3-ebd5-75ba-8a66-c98fef3e3196", "target": "01a172b3-ebd5-75ba-8a66-c98fef3e3196", "error": "Edge cannot link a thought to itself (source_id and target_id are the same)" }
  ],
  "meta": {
    "action": "link_batch",
    "scope": null,
    "counts": { "linked": 1, "failed": 1, "total": 2, "returned": 2, "filtered_out": 0 },
    "degraded": false,
    "warnings": []
  }
}
```

`linked` counts the accepted items (a repeated symmetric edge is an idempotent replay and counts
too) and `failed` always equals the number of rows with an `error`. An empty or over-long `links`
array is refused with an `isError` envelope before anything runs, so a batch is never applied as a
silent prefix.

### `action=unlink`

Delete a single edge by id. Deletion is **idempotent**: a missing edge is reported as
`not_found`, not an error. There is **no cascade** — only the edge row is removed; thoughts,
tags and other edges are untouched. The action is not project-scoped (`edge_id` is globally
unique), so no `cwd` is required.

| Argument | Type | Required | Description |
|---|---|---|---|
| `edge_id` | string | yes | The edge to delete |
| `confirm` | boolean | no | Absent/`false` = preview; `true` = delete |

**Preview** (`confirm` absent/`false`) — `item` carries the preview, `meta.preview` the gate:

```json
{
  "item": {
    "status": "preview",
    "edge_id": "01a172b4-d4a4-77c6-8a1e-a4d19920ef2b",
    "edge": { "id": "01a172b4-d4a4-77c6-8a1e-a4d19920ef2b", "source_id": "01a172b4-d486-700c-a16e-5ae3d2d77a90", "target_id": "01a172b4-d486-700d-b4f9-02962e9d2f1c", "type": "supports", "created_at": "2026-10-25T10:56:55.972Z" },
    "consequence": "Removes the 'supports' edge between '01a172b4-d486-700c-a16e-5ae3d2d77a90' and '01a172b4-d486-700d-b4f9-02962e9d2f1c'.",
    "preview_token": "01a172b4-d4a5-7635-9f7a-4263e42f55d6",
    "instruction": "Call memory_store again with action=unlink, edge_id=01a172b4-d4a4-77c6-8a1e-a4d19920ef2b, confirm=true to proceed."
  },
  "meta": {
    "action": "unlink",
    "scope": null,
    "counts": { "total": 1, "returned": 1, "filtered_out": 0 },
    "degraded": false,
    "warnings": [],
    "preview": { "token": "01a172b4-d4a5-7635-9f7a-4263e42f55d6", "destructive": true }
  }
}
```

A missing edge in preview returns `item: { "status": "not_found", "edge_id": "..." }` with no
`meta.preview`.

**Execute** (`confirm: true`): `item: { "status": "deleted", "edge_id": "..." }`, or
`{ "status": "not_found", "edge_id": "..." }` if it was already gone, and no `meta.preview`.

### `action=unlink_batch`

Delete or preview up to 1000 edges in one call. The batch runs in a **single
transaction** and reuses the `unlink` writer. In `confirm` mode every `edge_id`
is validated to exist **before** any deletion happens, so a missing id aborts
the whole batch instead of leaving a partial prune behind. Preview mode
(`confirm` absent/`false`) never mutates.

| Argument | Type | Required | Description |
|---|---|---|---|
| `edge_ids` | string[] | yes | Edge IDs to delete/preview (1–1000 items) |
| `confirm` | boolean | no | Absent/`false` = preview; `true` = delete |

Result: `{ items, meta }`, one row per edge. `meta.counts` carries
`deleted`/`preview`/`failed`. In preview mode each row carries `status: "preview"` plus the edge
details and `consequence`, and `meta.preview` reports `token: null` with the confirm instruction
(this batch issues no token):

```json
{
  "items": [
    { "edge_id": "01a172b4-d4a4-77c6-8a1e-a4d19920ef2b", "status": "preview", "edge": { "id": "01a172b4-d4a4-77c6-8a1e-a4d19920ef2b", "source_id": "01a172b4-d486-700c-a16e-5ae3d2d77a90", "target_id": "01a172b4-d486-700d-b4f9-02962e9d2f1c", "type": "supports", "created_at": "2026-10-25T10:56:55.972Z" }, "consequence": "Removes the 'supports' edge between '01a172b4-d486-700c-a16e-5ae3d2d77a90' and '01a172b4-d486-700d-b4f9-02962e9d2f1c'." }
  ],
  "meta": {
    "action": "unlink_batch",
    "scope": null,
    "counts": { "deleted": 0, "preview": 1, "failed": 0, "total": 1, "returned": 1, "filtered_out": 0 },
    "degraded": false,
    "warnings": [],
    "preview": { "token": null, "destructive": true, "instruction": "Call memory_store again with action=unlink_batch, edge_ids=[...], confirm=true to proceed." }
  }
}
```

### `action=retype`

Change an edge's type in a single transaction. The original `created_at` is preserved. The
new type must be a valid edge type and satisfy the same cluster constraints as `link` (e.g.
only a cluster thought may carry a `cluster` edge).

| Argument | Type | Required | Description |
|---|---|---|---|
| `edge_id` | string | yes | The edge to retype |
| `new_type` | enum | yes | One of: `related`, `parent`, `develops`, `replaces`, `cluster`, `references`, `depends_on`, `contradicts`, `supports` |
| `confirm` | boolean | no | Absent/`false` = preview; `true` = retype |

**Preview**: `item: { status: "preview", edge_id, old_edge_id, old_type, new_type, source_id, target_id, consequence, preview_token }`
plus the same token in `meta.preview`.

**Execute**: `item: { status: "retyped", edge_id, old_edge_id, new_edge_id, old_type, new_type }`.

Errors return an [`isError` envelope](#wire-envelope): a non-existent edge
(`Edge not found`), retyping to the current type (`is already of type`), an invalid type, or a
cluster-constraint violation.

> **Id note.** Retype is a transactional delete + insert that carries `created_at` over, so
> the edge gets a **new id**. The executed answer reports both — `old_edge_id` is the id you
> passed, `edge_id`/`new_edge_id` the fresh one.

### `action=bulk_create`

Create several thoughts in one call (`thoughts[]`, 1–10000 items, in one transaction, `cwd`
optional). Result: `{ items, meta }`, one row per requested thought in input order
(`{ index, status: "created", thought }` or `{ index, status: "failed", error }`), with
`meta.counts.created` / `meta.counts.failed`.

## `memory_supersede` — archiving and merging

All three actions preview when `confirm` is omitted or `false`; only `confirm: true` executes.
Execution re-reads/revalidates current state, so previews can become stale. Preview responses include
a `preview_token` (stored in DB, ~5 min TTL) for every destructive action (unlink, retype, archive,
archive_batch, merge, delete, auto_cluster, apply, apply_batch, rollback); `confirm: true` requires a
valid `preview_token` and fails when it is missing or stale. No transactional reservation is implied.
`archive` and `archive_batch` identify thoughts by ID and do not accept a project scope; `cwd`
applies only to `merge`.

### `action=archive`

Archives one thought (`thought_id`). Already-archived thoughts are idempotent: execution returns
the existing thought without re-stamping it.

| Argument | Type | Required | Description |
|---|---|---|---|
| `thought_id` | string | yes | Thought to archive |
| `confirm` | boolean | no | Absent/`false` = preview; `true` = execute |

Preview: `item: { "status": "preview", "action": "archive", "thought_id": "...", "current_status": "active", "outcome": "would_archive", "preview_token": "<token>" }`
with the token mirrored in `meta.preview`. `outcome` is `not_found`, `already_archived` or
`would_archive`. Execution answers
`item: { "status": "executed", "action": "archive", "result": <shaped thought> }`; missing
thoughts fail with an error.

### `action=archive_batch`

Preview or archive 1–1000 thoughts per call. Preview reports each requested ID in input order,
including duplicates. Outcomes are `not_found`, `already_archived` or `would_archive`;
repeated IDs after their first occurrence preview as `already_archived`.

| Argument | Type | Required | Description |
|---|---|---|---|
| `thought_ids` | string[] | yes | Thoughts to archive (1–1000 items) |
| `confirm` | boolean | no | Absent/`false` = preview; `true` = execute |

Preview: `items` holds one row per requested occurrence (`{ "thought_id": "...", "outcome": "would_archive" }`),
`meta.counts` holds one counter per outcome (plus the shared three), and `meta.preview.token` is the
confirm token.

```json
{
  "items": [
    { "thought_id": "01a172b4-d486-700c-a16e-5ae3d2d77a90", "outcome": "would_archive" }
  ],
  "meta": {
    "action": "archive_batch",
    "scope": null,
    "counts": { "would_archive": 1, "total": 1, "returned": 1, "filtered_out": 0 },
    "degraded": false,
    "warnings": [],
    "preview": { "token": "01a172b4-d4a7-75e3-a3cb-c92314b80e3e", "destructive": true }
  }
}
```

Confirmed execution answers the same `{ items, meta }` shape with one row per requested occurrence
(`{ "thought_id": "...", "status": "archived" }` or `{ "thought_id": "...", "status": "failed", "error": "..." }`)
and `meta.counts.archived` / `meta.counts.failed`. Items are processed in input order; duplicate
IDs are idempotent. An empty or over-long array is refused with an `isError` envelope before
anything runs.

### `action=merge`

Consolidates a source thought into a target: the source's edges are recreated on the target,
the target's content/tags are replaced when supplied, the source is archived, and a `replaces`
edge target → source records the consolidation. Merging an archived source is
refused. Preview checks current eligibility and reports source/target status; it includes at
most 50 source-edge descriptors plus the full `source_edge_count` and a `truncated` flag. This
bounded list does not promise every edge will transfer.

| Argument | Type | Required | Description |
|---|---|---|---|
| `source_id` | string | yes | Thought to merge away (archived by the merge) |
| `target_id` | string | yes | Thought that survives and receives the edges |
| `merged_content` | string | no | Resulting content of the target |
| `merged_tags` | string[] | no | Resulting tag set of the target |
| `cwd` | string | no | Resolve resulting project from working directory (defaults to the target's, then the source's) |

Preview: `item` carries `status: "preview"`, `action: "merge"`, `outcome`, `source`
(`{ id, status, edges, source_edge_count, truncated }`), `target` (`{ id, status }`),
`proposed`, `effects` (`{ source_archived, target_updated, replaces_edge_created }`) and
`preview_token`; `meta.preview` mirrors the token. Missing IDs report `outcome: "not_found"`; an
ineligible merge reports `outcome: "ineligible"` and `reason`. `proposed` reflects supplied
values; `effects` describes the prospective updates. Execution answers
`item: { "status": "executed", "action": "merge", "result": { "target": <shaped thought>, "edges_transferred": 2 } }`
— `edges_transferred` is the number of source edges the target now holds, so a caller can verify
the consolidation. Not every source edge contributes: one that would collapse into a self-loop
(it joined the target to the source), a duplicate of a non-symmetric edge the target already has,
and a cluster edge the cluster rule rejects are dropped and left out of the count. An existing
symmetric pair (`related`, `contradicts`) does count — the target owns that relation after the
merge. The former HTTP twin (`POST /api/thoughts/:targetId/merge`, which executed immediately
without a preview) is gone in 0.20: `memory_supersede action=merge` is the only merge path, so
the preview→`confirm: true` contract is the whole contract ([API.md](API.md#removed-routes-020)).

## `memory_crystallize` — clusters

Three actions. `graph` (nodes + edges for a project, `status` and `limit` filters) is the one
[envelope exception](#the-memory_crystallize-actiongraph-exception): `items` are nodes and `edges`
rides beside them, with `meta.counts.total` the node count for the filters, `returned` the nodes
of the page and `filtered_out` the nodes outside it (`offset`/`limit`) — so `total = returned +
filtered_out` as for any other collection. `cluster` (create a cluster thought from `thought_ids`
with an optional `title` and `tags`) answers `item: { cluster, edges, members }`, where `cluster` and every entry of
`members` is a shaped thought, `edges` lists the `cluster` edges to the members
(`{ source_id, target_id, type }`), and `meta.counts.members`/`meta.counts.edges` repeat the two
sizes. `cwd` selects the project of the new cluster.

`auto_cluster` (Union-Find grouping over eligible active and draft thoughts, tuned by the `autoCluster.*` settings in
[CONFIG.md](CONFIG.md)) answers `{ items: <groups>, meta }` with `meta.counts.candidates` and
`meta.counts.clusters`. It previews when `confirm` is absent or false — `meta.preview` carries the
token, the consequence and the confirm instruction — and `confirm: true` executes. The retired
`dry_run` input is rejected with a hint to use `confirm`. `cwd` does not apply to `auto_cluster` —
the run is global.

Despite the name, `min_similarity` is a maximum cosine **distance**, not a minimum similarity:
neighbours qualify when their distance is strictly below the threshold. Lower values are tighter.

## `edge_review` — placement proposals

The placement review queue, owned end to end by MCP (`edge_review`). Actions: `enqueue`, `list`,
`apply`, `apply_batch`, `rollback`, `reject`. `enqueue` requires a persisted, `active`
`thought_id`; `apply`/`apply_batch`/`rollback` are preview-first and require a `preview_token`
on `confirm: true`.

- `enqueue` → `{ item: <enqueue outcome>, meta }`, with `meta.counts.proposals` and
  `meta.counts.skipped`.
- `list` → `{ items, meta }`: `items` are the queued rows for the page (retired kinds hidden unless
  `include_retired`), `meta.counts.total` the rows matching the filters ignoring the page limit,
  `meta.counts.retired_count` the retired (legacy) kinds hidden by default, and `filtered_out` the
  rows the page or the retired filter left out. The retired aliases (`proposals`, `results`, `rows`,
  `count`, `visible_count`, `returned_count`, `retired`, `retiredCount`, `hidden_retired`,
  `breakdown`) are gone; read the canonical keys above.
- `apply` → `{ item: <apply report>, meta }`; the report's `status` is `dry_run` (preview),
  `accepted`, `stale`, `failed` or `refused`, and a preview sets `meta.preview`.
- `apply_batch` → `{ items, meta }`: one row per proposal (`status` per row, failures folded in as
  `{ proposal_id, status: "failed", reason }`), with one counter per status plus `failed` in
  `meta.counts`. A run-level refusal is reported in `meta.warnings`.
- `rollback` → `{ items, meta }`: the per-row report is `items` and its summary
  (`reverted`/`skipped`/`refused`) is `meta.counts`; a preview sets `meta.preview`.
- `reject` → `{ item: <reject report>, meta }`.

## `retention_review` — retention candidates

Retention review is a manual workflow, not a scheduled deletion policy: nothing is archived or
deleted until a caller runs `delete` or `compact`. A thought is a candidate only when every signal
holds at once — status `archived` or `draft`, an age anchor (`archived_at`, falling back to
`created_at`) older than the minimum age, importance below the floor (a missing importance row
counts as `1.0`), no recorded hits (a missing count is `0`), no incoming edge from an `active`
thought, not protected, `surface_after` absent or already due, and not global
(`src/db/retention-review.ts`).

`action=list` filters are the call's eligibility control; each one falls back to the server setting,
so a single call can narrow eligibility or page without reconfiguring anything
([CONFIG.md](CONFIG.md#retention-review)):

| Argument | Type | Default | Description |
|---|---|---|---|
| `min_age_days` | number | `retentionReview.minAgeDays` (30) | Minimum age in days, measured from the age anchor; must be positive |
| `importance_floor` | number | `retentionReview.importanceFloor` (0.3) | Candidate importance must stay below this value (0–1) |
| `limit` | int | `retentionReview.pageSize` (20) | Page size (1–100) |
| `offset` | int | `0` | Pagination offset |

```json
{ "action": "list", "min_age_days": 90, "importance_floor": 0.1, "limit": 50, "offset": 0 }
```

- `list` → `{ items: <candidates>, meta }`; `meta.counts` carries the page (`limit`, `offset`) plus
  the shared three, where `total` is the candidate count for the filters ignoring the page
  (`src/mcp/tools/retention-review.ts`). Candidates come back oldest first (age anchor ascending),
  then by id, with `content` truncated to 200 characters; `compact` and `fields` apply as on every
  other read tool. The query spans every project — a candidate carries its `project_id`/
  `project_name`, and no `cwd` narrows the list.
- `protect` → `{ item: <protect report>, meta }`.
- `delete` → `{ item: <delete report>, meta }`; the preview `item` carries
  `status: "preview"`, `action: "preview"`, `deleted: false`, `preview: true`, `confirm: false`,
  `thought_id`, `dependents`, `instruction` and `preview_token`, and `meta.preview` mirrors the
  token (with the instruction as its `consequence`). Confirming requires the token.
- `compact` → `{ item, meta }`; input is a non-empty `source_ids` array (1–1000 ids) plus a
  `summary` object (`content` required, `tags` and `project_id` optional). The preview `item`
  carries `status: "preview"`, `preview: true`, `confirm: false`, `source_ids` and `instruction`,
  and `meta.preview` reports `token: null` (this action confirms on `confirm: true` alone). The
  executed item carries `status: "compacted"`, `preview: false`, `confirm: true`, `source_ids` and
  the shaped `summary` thought; `meta.counts.sources` is the number of compacted sources. The
  summary is created `active` and protected, and every source is merged into it — archived with its
  edges transferred, not hard-deleted (`src/services/retention-review-actions.service.ts`).

## `memory_reflect` — session reflection

`cwd` is optional for both actions, though the two use it differently. `action=reflect` is **not
read-only**: it writes slots and thoughts, and answers `{ item: <counts>, meta }`.

- `summary` is appended (with a timestamp) to the `project_context` slot.
- `goals_add` adds goals to `active_goals`; `goals_remove` removes stored goal lines when the
  supplied text matches as a case-insensitive substring. The retired `goals_delta` parameter is
  rejected with a hint to use `goals_add` / `goals_remove`.
- `decisions` creates **active** thoughts tagged `decision` + `reflect`. Entries
  sufficiently similar to an existing non-archived `decision` in the same project are skipped
  (Jaccard ≥ 0.6 on normalised text), as are exact live-content duplicates of any tag.
- `pending` creates **draft** thoughts tagged `pending` + `reflect`, with
  `surface_after = now + wake_days` (default 7, range 1–365). A pending thought is invisible
  to the frontier until that time, then surfaces with reason `pending` and in the
  `pending_items` slot. An existing live thought with the same content in the project is
  left unchanged and is not counted as newly created.

```json
{
  "item": { "summary_appended": true, "goals_added": 0, "goals_removed": 0, "decisions_created": 0, "pending_created": 0 },
  "meta": {
    "action": "reflect",
    "scope": { "project": "01a172b4-d49b-76f4-ada8-7ba408661064", "project_name": "Doc Probe", "global": false },
    "counts": { "total": 1, "returned": 1, "filtered_out": 0 },
    "degraded": false,
    "warnings": []
  }
}
```

The `reflect` tag marks **every** thought `reflect` created (next to the semantic tag), so the
output is identifiable and can be filtered (`memory_recall` with `tag: "reflect"`) or cleaned up
without touching hand-created thoughts. It is additive: statuses, dedup and the scheduled
surface time are unchanged, and no read path keys off it.

A `cwd`-less `reflect` is a write without a project scope, and its outputs split: `summary`
and `goals_add`/`goals_remove` land in the **global** `project_context`/`active_goals` rows (a slot
is project-scoped only when a project resolved,
`src/services/session-reflection.service.ts:106`), while `decisions` and `pending` thoughts are
created in the default project (`src/services/session-reflection.service.ts:171-186`,
`:198-207`). The answer reports `meta.scope: null` and raises no warning, like every other write
(`writeEnvelopeScope`, `src/mcp/tools/envelope.ts:214-220`). With a registered `cwd`, all of them
land in that project instead.

`action=timeline` is the plain paginated thought list (`status`, `cwd`, `limit`, `offset`) as
`{ items, meta }`; `cwd` scopes it to one project, and omitted, it lists every project with the
standard `no cwd` warning. `meta.counts` carries the `limit` and `offset` that actually ran
(defaults applied) beside the shared `total`/`returned`/`filtered_out`.

## `memory_primers` — the always-at-hand set

### `action=list`

Lists the primers, most-hit first. A primer is a pointer to a thought that was promoted after
repeated recalls, so an agent can inject project context without a search. The list is capped
(500 rows by default).

No arguments. Answers `{ items, meta }`, where each item is
`{ id, thought_id, hit_count, promoted_at, created_at }` (`Primer`, `src/db/primers.ts`).

### `action=delete`

Removes one primer from the always-at-hand set. The **thought itself is not deleted** — only the
primer row. Deletion is **idempotent**: a missing primer is reported as `not_found`, not an error.

| Argument | Type | Required | Description |
|---|---|---|---|
| `primer_id` | string | yes | The primer to delete |
| `confirm` | boolean | no | Absent/`false` = preview; `true` = delete |

**Preview** (`confirm` absent/`false`): `item` carries `status: "preview"`, `primer_id`, `primer`,
`consequence`, `preview_token` and `instruction`; `meta.preview` mirrors the token. A missing
primer in preview returns `item: { "status": "not_found", "primer_id": "..." }` with no
`meta.preview`.

**Execute** (`confirm: true`): `item: { "status": "deleted", "primer_id": "..." }`, or
`{ "status": "not_found", "primer_id": "..." }` if it was already gone.

## `memory_status` — system state

For this tool, retired `k` is replaced by `limit` (the frontier page/result limit). Depending on the MCP client,
never-valued retired arguments may be rejected by local schema validation before a request reaches
the server; if the request is sent, invocation validation returns the migration error.

### `action=slots` (read-only)

`cwd` is optional: with it the project-scoped slot row wins over the global fallback. Without it
only the **global** rows are read, and `meta.warnings` adds a second note:

`no project scope — the project-scoped slot overrides were not applied (pass cwd to read them)`

That warning is cwd-less only — a present path, registered or not, keeps the pre-existing answer
(`src/mcp/tools/status/slots.ts:11-12`, `:34-38`; the read is
`src/services/slots.service.ts:94-97`). Answers `{ items, meta }` with one item per slot
(`{ name, scope, virtual, content, truncated, max_chars, updated_at }`). Slots are **read-only**
through this tool: it never writes. `pending_items` is derived from thoughts tagged `pending` (the
same set the frontier surfaces); `architecture_decisions` from primers (most-hit first);
`project_context` and `active_goals` are stored rows. To change slot content, update or archive the
underlying thoughts (virtual slots) or use `memory_reflect` (`summary` appends to
`project_context`; `goals_add` and `goals_remove` edit `active_goals`). Paging applies to
`pending_items` via `slots_limit`/`offset`.

### `action=frontier`

`cwd` is optional: with it the answer is the frontier of one project, and omitted, it spans every
project with `meta.scope: null` and the `no cwd — the answer spans every project` warning. Answers
`{ items, meta }` with the frontier entries, `limit` (1–50) bounding the page.

### `action=config` / `action=health`

Both are read-only and take no `cwd`. `config` answers the raw-text display described in
[Wire envelope](#wire-envelope) — `content[].text` verbatim, `structuredContent.result.item.text`
the same string. `health` answers `{ item: <report>, meta }`; pass `include_details: true` for the
per-check detail arrays (omitted, and content fields shortened, by default).

### `action=propose`

Read-only placement plan (placement, edges, lifecycle); never writes. `cwd` is optional, but this
reader never widens: with it the plan is restricted to that project, and without it — or with a path
that resolves to no project — the plan stays pinned to the project of the thought it addresses
(`thought_id`), or to the default project for draft `content`.

A cwd-less call says so in `meta.warnings`, in place of the `no cwd — the answer spans every project`
note:

`no cwd — propose is not cross-project: the plan is restricted to the project the addressed thought (thought_id) belongs to`

and, for draft content,
`no cwd — propose is not cross-project: the plan is restricted to the default project (draft content)`.

A path that resolves to no project gets **one** composed warning instead of the generic note, so
`meta.warnings` holds a single string — the unknown-path fact with its resolve hint, then the pinning
clause, and no `the answer spans every project` claim:

`cwd "<path>" matches no known project (check it with memory_manage action=resolve) — propose is not cross-project: the plan is restricted to the project the addressed thought (thought_id) belongs to`

and, for draft content,
`cwd "<path>" matches no known project (check it with memory_manage action=resolve) — propose is not cross-project: the plan is restricted to the default project (draft content)`.

`meta.scope` stays the unresolved `{ project: null, project_name: null, global: true }` in that case:
the warning, not the scope, records that the plan stayed inside one project
(`src/mcp/tools/status/propose.ts:24-36`).

Answers `{ item: <plan>, meta }`; default output is slim (top 3 edges, no signals) — pass
`include_signals=true` or `compact=false` for full output. `meta.counts.total` is the number of
planned edges and `meta.degraded` mirrors the plan's degraded flag.

## `memory_manage` — projects

CRUD over projects plus resolution from a path.

- `list` → `{ items: <projects>, meta }` (`fields` narrows each project row).
- `create` → `{ item: <project>, meta }`.
- `update` requires `project_id` → `{ item: { success: true, project_id }, meta }`.
- `delete` requires `project_id` and is preview-first: the preview `item` carries `action: "preview"`, `project`,
  `thought_count`, `consequence`, `instruction` and `preview_token` (`meta.preview` mirrors the
  token); the executed `item` carries `action: "deleted"`, `project`, `thoughts_moved` and
  `moved_to`.
- `resolve` → `{ item: { id, name, local_path }, meta }`.

## Health-check remediation: `contradiction_in_cluster`

`memory_status action=health` reports `contradiction_in_cluster` (semantic consistency,
warning) when two non-archived members of the same cluster are joined by a `contradicts` edge
— the consolidated claim is ambiguous until the conflict is resolved. Each finding's
`details` carries `cluster_id`, `member_a`, `member_b` and `contradicts_edge_id`.

There is **no autofix** for this finding: the health check only detects, the agent decides.
Resolve it with the edge writers above, then re-run the check:

1. **Drop the contradiction** — `memory_store` action=unlink on `contradicts_edge_id`
   (preview, then `confirm: true`).
2. **Retype it** — `memory_store` action=retype, `new_type=supports` (or `related`), on
   `contradicts_edge_id` if the contradiction was a mistake.
3. **Archive the cluster thought** so the member pair is no longer co-clustered under a live
   consolidated claim — `memory_supersede` action=archive (idempotent; member thoughts are
   untouched).

Both edge writes are dry-run first: preview with `confirm` omitted, then re-issue with
`confirm: true`.

## Migration notes (response envelope, cwd scope, id lookup)

Breaking changes for MCP clients (the HTTP surface has no thought endpoints to be affected —
see [API.md](API.md)):

1. **One response envelope.** Collections answer `{ items, meta }`, single objects
   `{ item, meta }`; `meta` carries `action`, `scope`, `counts`, `degraded`, `warnings` and,
   where applicable, `reason` and `preview`. Per-action payload keys are gone: the read array
   used to arrive under `thoughts`, `proposals` or as a bare array, and write answers used to
   carry their own top-level keys (`linked`/`failed`, `archived`, `status` at the root, ...).
   Read `items`/`item` and `meta.counts` instead. Write-side answers that used to be a bare
   object now nest it under `item`. The one exception is
   `memory_crystallize action=graph` (nodes in `items`, edges beside them).
2. **`cwd` is optional.** No action rejects a call without `cwd` except `memory_manage
   action=resolve`, where the path is the input rather than a scope. `memory_recall`
   `search`/`clusters`/`list`, `memory_status` `slots`/`frontier` and `memory_reflect`
   `reflect`/`timeline` (plus the older optional readers `memory_recall context`,
   `memory_crystallize graph`/`cluster` and `edge_review list`) answer across every project when
   `cwd` is absent, with `meta.scope: null` and a `no cwd — the answer spans every project`
   warning; `memory_status action=slots` adds that only the global slot rows were read. An
   unknown-but-present `cwd` keeps answering globally, with its own warning.
   `memory_status action=propose` is the exception among the readers: it takes `cwd` but never
   widens, and a cwd-less call keeps the plan pinned to the addressed thought's project (or to the
   default project for a draft) with a `no cwd — propose is not cross-project: ...` warning instead
   of the broadening note. An unknown-but-present `cwd` gets a single composed warning instead — one
   string that carries the unknown-path fact and then the pinning clause,
   `cwd "..." matches no known project (check it with memory_manage action=resolve) — propose is
   not cross-project: ...` — so nothing claims the answer spans every project.
   `memory_reflect action=reflect` is a write: without `cwd` its `decisions`/`pending` thoughts go
   to the default project and its `summary`/`goals` to the global slots, with no warning.
3. **Preview gate moved.** A preview answer always sets
   `meta.preview = { token, destructive: true, consequence?, instruction? }` (token `null` for
   `unlink_batch` and `retention_review` `compact`). Single-object previews keep the same fields
   inside `item` as before; collection previews no longer carry `preview_token` at the top level.
4. **Id-shaped search queries resolve by id.** A full UUID or a hex prefix of at least 13
   characters no longer goes through semantic search: it returns that thought, annotated with its
   standing but never suppressed. A full UUID ignores status and project scope; a prefix honours
   both and falls back to the word search when it matches nothing inside that scope. A prefix
   that addresses several thoughts answers every match (at most `limit` rows) with `reason:
   "ambiguous_id"`; when nothing carries the id, the answer is the ordinary search for the same
   text plus an `id_not_found` note in `warnings` (`reason: "id_not_found"` only when that
   fallback is empty too). `low_confidence` keeps its retrieval-strength meaning and is never set
   on an id hit (it means "no BM25 anchor and a cosine below the floor", and an id hit has no
   cosine at all), so a client that drops low-confidence rows cannot lose the exact thought it
   asked for; the contested state of an id hit rides in `standing`/`superseded_by`/
   `contradicted_by` instead.
5. **Calibrated `relevance` and a default noise gate.** Vector hits carry `relevance` on the
   measured 0–1 scale (`unrelatedFloor` 0.8 → 0, `vectorFloor` 0.9 → 1), and the default gate
   drops cosine-only rows below 0.4. `min_relevance: 0` restores the old fail-open set.
   `search.confidence.unrelatedFloor` is the new zero-point setting.
6. **Output hygiene.** Tags are plain strings and the flag columns are booleans everywhere;
   scoring metadata (`scores`, `match_source`) lives behind `compact: false`, while safety
   annotations stay visible in compact output.

## Migration notes (v0.19 MCP UX cleanup)

Breaking changes for MCP clients:

1. **Project scoping uses `cwd`.** Tools that removed their `project_id` scope
   argument reject it with `project_id was removed; use cwd`; pass `cwd` instead.
   `memory_manage` is an exception: its `update` and `delete` actions require
   `project_id` to identify the project.
2. **Compact defaults.** `memory_recall` now defaults to compact output; pass
   `compact: false` for full thoughts. `memory_status` `propose` defaults to the
   slim plan (top 3 edges, no signals); pass `compact: false` or
   `include_signals: true`. Other read tools stay full by default; opt in with
   `compact: true`. Explicit `fields` always win.
3. **Preview tokens required.** Every destructive action (`memory_store`
   `unlink`/`retype`, `memory_supersede` `archive`/`archive_batch`/`merge`,
   `memory_manage` `delete`, `edge_review` `apply`/`apply_batch`/`rollback`,
   `memory_primers` `delete`, `memory_crystallize` `auto_cluster`,
   `retention_review` `delete`) now returns a `preview_token` on preview and
   requires it (valid, unexpired) on `confirm: true`. Tokens live ~5 minutes.
4. **`edge_review` list trimmed.** Response uses canonical keys only:
   `items`, `total`, `returned`, `retired_count`, `all_total`. Retired aliases were
   removed.
5. **Retired inputs.** `top_k`→`limit`, `cluster`→`is_cluster`, `k`→`limit`,
   `dry_run`→`confirm`, `thought_id` (link)→`source_id`, `goals_delta`→
   `goals_add`/`goals_remove`, `max_items`→`max_proposals`, `mode`→`hybrid`.
   `is_profile` has no replacement — remove the field. Retired names are not
   accepted aliases.