Protocol: Model Context Protocol (MCP) over stdio Version: 4.10.11 Compatibility: Works with Claude Code plugins, Claude Managed Agents (via MCP connector), and any MCP-compatible client.
Native Integrations: Beyond MCP, MeMesh integrates as a native memory provider for Hermes Agent (Python MemoryProvider plugin). A source-only OpenClaw TypeScript memory-capability plugin is also included, but it is not published or live-tested. Neither path is an HTTP bridge. See docs/platforms/ for platform-specific guides.
MeMesh exposes 12 tools via MCP.
Prepare one bounded untrusted package, submit exactly one strictly validated result into pending human review, or defer without durable change. kind: "digest" selects a calendar cluster; kind: "transcript" selects visible turns from the newest Claude Code session associated with the client's single matching MCP workspace root. The same tool and existing proposal review path handle both kinds: this adds no relation kind and no second API or UI path.
prepare returns at most one package (or none_available). The agent must either submit one result bound to the returned package_id and ref, or defer with a listed reason. submit only stages a pending proposal for human review; agents cannot apply or reject it. A package is untrusted evidence, and its hash identifies source freshness rather than authentication. A transcript package selects the newest eligible session that is not already represented by a proposal.
Transcript packages require the MCP client to support roots/list and supply exactly one canonical directory whose MeMesh project identity matches project. Missing, malformed, non-matching, or multiple matching roots fail closed as workspace_unavailable or workspace_ambiguous. Packages carry only visible user/assistant text, in chronological order, identify their source as claude-code, and disclose clipping through coverage. They never include hidden reasoning, tool inputs or outputs, a raw transcript, or a transcript file path. Neither kind exposes or uses an API key, LLM, embedding, or vector data; no provider is called.
Transcript discovery considers files modified within the last 3 days. It refuses a directory with more than 256 transcript candidates, skips any individual source larger than 8 MiB, and returns none_available when eligible scan input exceeds 16 MiB. A transcript without a recorded cwd, or whose cwd does not match the selected workspace, is ineligible. From the selected transcript, the package retains at most the 100 most recent visible turns in chronological order and at most 48 KiB of serialized source turns. The complete returned package is capped at 64 KiB; the submitted result has its separate 16 KiB cap.
Digest discovery considers the last 56 days of active, same-project evidence with these exact entity types: commit, session_keypoint, session-insight, workflow_checkpoint, weekly-summary, weekly_summary. It excludes pinned or already-compacted rows, consolidation depth 1 or greater, and signal scores outside 0.2–0.7. Candidates are grouped by ISO week; only complete groups of 5–100 sources whose returned package fits 64 KiB are eligible.
Input schema:
| Action | Required fields | Strict result / behavior |
|---|---|---|
prepare |
project, kind ("digest" or "transcript") |
Returns one bounded package or none_available; unknown fields are rejected. |
submit |
package_id, matching ref, result |
One result only. A digest package accepts type: "digest"; a transcript package accepts "decision", "lesson_learned", or "fact". Results need a name, 1–100 observations, and 1–50 non-project: tags; the encoded result is capped at 16 KiB. |
defer |
package_id, matching ref, reason |
reason is not_now. This makes no durable change, so preparing again may return the same package. |
The ref is strict and kind-specific. A digest ref has project, sorted unique source_ids, and source_hash; a transcript ref has project, session_id, modified_at, source_hash, and workspace_hash. The workspace hash binds the package to the canonical host-provided root without exposing that path. Transcript file paths are server-resolved and are never input or output. Changed, forged, stale, or mismatched references fail without staging a proposal. A staged transcript proposal retains the bounded redacted turns and coverage metadata in its existing source_ids detail object so the human reviewer can compare the proposed memory with its evidence.
Responses: prepare returns { status: "available", package, available_action: [{ action: "submit", actor: "agent" }, { action: "defer", actor: "agent" }] }; submit returns { status: "staged", proposal_id, proposal_status: "pending", review_authority: "human" }; and defer returns { status: "deferred", durable_change: false }. Replaying the identical submission reports the existing proposal; it does not create another one.
Store knowledge as an entity with observations, tags, and relations.
If remember is called again with an existing name, MeMesh treats it as an append-style upsert: new observations are appended, tags are deduped, and the original entity type is retained. With replace: true it rewrites the entity instead (see below).
Two forms. Structured: name + type, with title / observations. Note: note alone (free text), with optional type, tags, name — the server derives the rest:
title= the first non-empty line (a leading#heading or list marker is dropped; a line over 200 characters is cut to its first sentence, then to 200). When the line had to be cut, the full original line is also kept as the first observation — nothing the caller wrote is dropped, so a long first line ends up in the response twice: shortened as the title, in full as an observation;observations= the remaining paragraphs, one each (blank-line separated; a paragraph made only of list items gives one observation per item). A one-line note keeps its line as the single observation;name(when absent) = slug of the title +-+ the first 8 hex characters of the SHA-256 of the cleaned text, so the same text twice is one memory (the second call adds nothing); two different texts landing on the same name is possible but very unlikely, not impossible — the suffix is only 32 bits; a title with no ASCII letters or digits slugs tonote;typedefaults to"note".
The note is cleaned before anything is derived from it: control characters (other than newline and tab) are removed and credential-shaped substrings are replaced with ***REDACTED***. It may be at most 20,000 characters, and the paragraphs it splits into may not derive more than 100 observations — a paragraph made only of list items yields one observation per item, so a single paragraph can push the count over the limit on its own; beyond that the call is rejected. One derived observation longer than 10,000 characters is silently truncated to that length with a trailing … — unlike a structured observations entry of the same length, which is rejected. Nothing in the response says it happened, so a caller sending one very long paragraph should split it rather than rely on the cap. note cannot be combined with title or observations. A note sent to a name that already exists appends its observations and leaves the existing title alone.
Replace: replace: true with a name rewrites that memory: its observations are replaced by the ones given (or derived from note), its tags too when tags is given (omitted tags are kept), its title when title or note is given. The previous title, observations and tags are appended to metadata.replaced_history as { replaced_at, title, observations, tags }, so the wrong line leaves recall but is not lost. The history keeps the newest 20 versions and at most 64 KB: older versions are dropped first, and a single version larger than that keeps the observations, then the tags, that fit and is marked truncated: true. Relations are untouched by a replace. recall results do not carry the history — they carry metadata.replaced_history_count — so read the versions from export or GET /v1/entities/:name. The keyword index is rewritten in the same transaction. On a name that does not exist yet there is no stored type to inherit, so replace: true needs an explicit type; with one it creates the memory and reports replaced: false, without one it is rejected. A memory archived with forget refuses replace outright: remember it again without replace to bring it back, then replace it. replace with note requires an explicit name.
Input Schema:
| Parameter | Type | Required | Description |
|---|---|---|---|
name |
string | Unless note |
Unique entity name (e.g., "auth-decision", "jwt-pattern"). Derived from the text when note is given without one |
type |
string | Unless note |
Entity type (e.g., "decision", "pattern", "lesson_learned"). Defaults to "note" with note. lesson and mistake are stored as lesson_learned (#451) |
note |
string | No | Free text instead of title + observations (see above) |
replace |
boolean | No | Rewrite the named memory instead of appending (see above). Default false |
title |
string | No | Short human-readable label shown wherever the memory is listed (e.g. "Why we dropped JWT"), max 200 characters — longer is rejected, not truncated, so the caller can shorten it themselves. On an entity that already exists, supplying this replaces the title; omitting it leaves the title it already has. Whitespace-only counts as omitted. |
observations |
string[] | No | Key facts or observations about this entity |
tags |
string[] | No | Tags for filtering (e.g., "project:<id>", where <id> is the project field of the briefing result (CLI: memesh briefing --json), "topic:database"). A plain repository name is a different project scope |
relations |
object[] | No | Relations to other entities |
namespace |
string | No | Namespace scope: "personal" (default), "team", or "global". On an entity that already exists, supplying this moves it; omitting it leaves the namespace it already has. |
Relations object:
| Field | Type | Required | Description |
|---|---|---|---|
to |
string | Yes | Target entity name (must already exist) |
type |
string | Yes | Relation type. Free-form label (e.g. "implements", "related-to") except for the two below, which change behaviour |
Relation types that do something. Every other type is an inert label; these two are the whole list, and the same list is enforced against the MCP schema by tests/relation-types-documented.test.ts:
| Type | Effect |
|---|---|
supersedes |
Archives the target entity, immediately, on write. Use it when this memory replaces an older one. |
contradicts |
Makes both memories surface as a conflict every time either is recalled (see recall → Conflict detection). Use it when two memories cannot both be true. |
Causal conventions (inert, but worth agreeing on). For links between a
decision and what it led to, use caused (direct: this decision produced
that outcome) or influenced (partial: it was one input among several),
pointing from the cause to the effect. These carry no machine behaviour —
they are ordinary free-form labels — but a shared vocabulary is what makes a
causal chain traversable later (decision —caused→ incident —caused→ lesson_learned). The principle behind stating them explicitly: MeMesh
never infers causality. Two memories being close in time, close in meaning,
or co-mentioned proves nothing about one causing the other, so no pipeline
here will ever manufacture a causal edge from timestamps. A cause you know
but do not state is a cause
the graph does not have.
Response:
{
"stored": true,
"entityId": 1,
"name": "auth-decision",
"title": null,
"type": "decision",
"observations": 2,
"tags": 1,
"relations": 0
}title is always present, and it is the title the memory HOLDS after the call
— read back from the row, not echoed from the request. It is null when the
memory has no title (the example above passed none). This matters on the two
calls that do not supply one: replace without a title, and a note sent to
a name that already exists both KEEP the existing title, and the response names
it. Do not read derived.title as the stored title — that is the title the
text would have produced, which on an existing memory is exactly the one that
was not used.
With note, the response also carries derived: { name, type, title, observations } — the shape the server derived, so a wrong title can be corrected with one more call (name + replace: true + title). type is required on a call that omits note except on a replace with a name: that call keeps the type the memory already has, so a correction does not have to restate it. Pass a type there only to reclassify — replace rewrites the stored type when it differs from what you pass, compared with the canonical form of what you pass: type: "lesson" does not retype a memory stored as lesson_learned, and it does retype one an older version stored as lesson (#451). On a replace whose name does not exist there is no stored type to inherit, so type is required to create it. With replace: true the response also carries replaced: true when an existing memory was rewritten, false when there was nothing to replace.
Three more fields are conditional. relationsCreated lists the relations actually created — report from it rather than subtracting errors from what you asked for. relationErrors is included when a relation target does not exist; the entity is still stored. movedFromNamespace appears only when the call MOVED a memory that already existed, naming the scope it came from, and pairs with metadata.previous_namespace so the move can be reversed.
Write provenance. Every entity created through remember or learn carries metadata.provenance.source_host — which surface wrote it. It is not an input parameter on any transport (a provenance field the caller's model could fill in is not provenance); the transport sets it: the MCP server stamps the client's self-declared initialize name (claude-code, codex, gemini-cli, …; mcp when the client declares none), the CLI stamps cli, and the HTTP API stamps http. The stamp lands on first insert only — appending to an existing entity from another host does not rewrite it. Memories the hooks capture on their own (commits, session summaries, the session handoff) are stamped with the host that ran the hook, claude-code or codex — the same host the hook's outcome record names — and carry no host when the hook cannot tell. The field is returned wherever entity metadata is returned (e.g. recall results).
Supersedes behavior: When a relation has type "supersedes", the target entity is automatically archived. This enables knowledge evolution — new designs replace old ones without losing history.
Examples:
// Store a decision
{
"name": "auth-decision",
"type": "decision",
"observations": [
"Chose JWT for authentication",
"Using RS256 algorithm for token signing"
],
"tags": ["project:myapp", "topic:auth"]
}
// Store a pattern with a relation
{
"name": "error-handling-pattern",
"type": "pattern",
"observations": ["All API errors return {error, code, message} format"],
"tags": ["project:myapp"],
"relations": [
{"to": "auth-decision", "type": "related-to"}
]
}Search and retrieve stored knowledge. Uses local SQLite FTS5 full-text search, with optional tag filtering and multi-factor scoring. Results are ranked by a weighted combination of search relevance, recency, access frequency, confidence, and recall-effectiveness impact. Call with no query to list recent memories.
One- and two-term queries use OR matching. Queries with three or more searchable terms first try strict all-term matching, then fall back to OR only when strict matching has no hits, so natural-language wording stays useful without allowing one frequent token to dominate a precise query. Results are ordered by relevance (BM25) before scoring. Terms appearing in more than half the indexed rows are dropped as noise — they are the ones BM25 already scores near zero — except that a query made entirely of common words keeps its rarest term rather than matching nothing, and the guard does not apply below 25 indexed rows, where a frequent word is the subject rather than a stopword. Of what survives, the first 32 in query order are used — dropping the ubiquitous terms before the cap means a bigram-segmented CJK question no longer loses its whole tail to terms that would have been discarded anyway, but the cap itself is still positional, so a query with more than 32 surviving terms does lose its tail. Punctuation inside a word splits it (kitchen's searches for kitchen and s, not for the exact phrase). Results are deterministic: BM25 ties break by recency, so the same query over the same memories returns the same list.
A query that is not empty but contains nothing searchable — ???, @#$% — returns no results rather than falling back to the recent list, so "nothing matched" is never dressed up as "here is what matched". Call with no query at all to list recent memories.
Input Schema:
| Parameter | Type | Required | Description |
|---|---|---|---|
query |
string | No | Search query (FTS5 full-text search; one- and two-term queries use OR, three or more terms use strict all-term matching with OR fallback, and the first 32 surviving terms are used). Leave empty to list recent entities. |
tag |
string | No | Filter by tag (e.g., "project:<id>", where <id> is the project field of the briefing result (CLI: memesh briefing --json)) |
limit |
number | No | Max results (default: 20, max: 100) |
include_archived |
boolean | No | Include archived (forgotten) entities in results (default: false) |
namespace |
string | No | Filter to a specific namespace ("personal", "team", "global") |
cross_project |
boolean | No | When true, lifts project-tag filter and searches all namespaces (default: false) |
Response:
Returns an object whose entities array holds the matching entities ranked by multi-factor score — relevance 0.30, recency 0.25, frequency 0.18, confidence 0.17, recall-effectiveness impact 0.10. The envelope is an object, never a bare array: Gemini CLI JSON-parses a tool's text payload into the MCP result's structuredContent, which the protocol requires to be an object — a bare array failed every Gemini recall while other hosts read it fine:
A successful tool result may carry a second content item { "type": "text", "text": "[memesh update] …" } — the update notice (available upgrade, just-upgraded receipt, or a failed check), shown once per server process on the first tool call that has an answer for it; or a stale-process notice ("this session started on v… but v… is now installed on disk") on whichever call first detects the running process has fallen behind the code on disk, which is not necessarily the first call. content[0] is always the tool's own payload; clients that read only the first item are unaffected.
{
"entities": [
{
"id": 1,
"name": "auth-decision",
"title": "Why we chose JWT",
"type": "decision",
"created_at": "2026-03-09 12:00:00",
"observations": [
"Chose JWT for authentication",
"Using RS256 algorithm for token signing"
],
"tags": ["project:myapp", "topic:auth"],
"relations": [
{"from": "auth-decision", "to": "api-design", "type": "related-to"}
],
"match": {"source": "keyword", "relevance": 0.42}
}
],
"retrieval": {"mode": "fts", "truncated": false}
}title is present on every entity that has one and null on the ones that do
not — a memory written before titles existed, or by a caller that sent none.
Show it where you would otherwise show name; name is the identifier the
other tools address the memory by, not a label meant to be read.
Retrieval metadata (retrieval): every recall envelope states that local
FTS answered the query. truncated: true means the results filled limit and
more may exist — a small hit count is a window, not a graph-wide count, and
this flag is the difference between "that is all" and "that is all I was
allowed to return". The CLI prints a (search limit reached — more may match; raise --limit) note when truncated.
Provenance (match): when the call has a query, every result carries
"source": "keyword" and the normalized FTS relevance score. The empty-query
listing (recent memories) carries no match field — a listing is not a match.
In CLI (non---json) output, observations longer than 500 characters are
additionally capped on display with … (+N more chars), and an observation
the size cap below already cut shows … (cut; full text in the dashboard)
instead; storage always carries the full text. --json is subject to the size cap below, not to this
500-character display cap.
Size cap (MCP and CLI only): the MCP recall tool and memesh recall
(including --json) cap what comes back, because this answer can go straight
into an agent's context. Each returned entity's observations + tags are
capped at 8 KB of JSON-serialized bytes, and the whole response is capped at
32 KB. file:* tags (written for pre-edit recall's own database lookups —
memesh why, the PreToolUse hook — never for reading) are omitted
unconditionally, not just when the cap is hit.
When an entity's content is cut, it carries a truncated object with the
full original counts — enough to know how much was left out, never to guess:
{
"name": "memesh-series-local-cloud-positioning-coordination",
"observations": ["Chose JWT for authentication", "..."],
"tags": ["project:memesh"],
"truncated": {"observations": {"shown": 22, "total": 157}}
}observations and tags themselves are never a special "truncated" shape —
they are the plain kept arrays; shown is always observations.length (or
tags.length) at the same moment.
An observation is dropped whole, from the end (the first ones are kept) —
except the very first one being packed: if that single observation alone is
larger than the whole 8 KB entity budget, it is cut mid-way instead of
hidden, with the observation TEXT ending in a marker showing how much was
cut, e.g. "…the text up to the cut… … (+41318 more bytes)". Tags are packed
first, so an entity whose tags fill nearly all of the 8 KB shows no
observations at all, still reported as {"shown": 0, "total": N}.
When the size cap forces whole entities out of the response (each already
capped to 8 KB, and 32 KB / 8 KB leaves room for only a few), the envelope
carries truncated: true and entities_omitted:
{
"entities": [ /* ...fewer than were matched... */ ],
"retrieval": {"mode": "fts", "truncated": false},
"truncated": true,
"entities_omitted": {"shown": 3, "total": 6}
}truncated at the top level means "something in THIS response was cut for
size" — it is unrelated to retrieval.truncated, which means "the search
LIMIT window filled; more matching entities may exist". Both can be true, or
neither.
The HTTP API (POST /v1/recall, the dashboard's data source) is NOT
capped. It returns file:* tags and full observation text, exactly as
before this cap existed — the dashboard already renders large entities with
its own UI-level truncation, and does not need this agent-context-budget
protection.
Conflict detection: When any pair of returned entities have a contradicts relation, the object gains a conflicts array beside entities. Nothing creates that relation for you — a caller states it via remember's relations (see remember), so an absent conflicts means "none stated between these results", not "checked and clean":
{
"entities": [...],
"retrieval": {"mode": "fts", "truncated": false},
"conflicts": [
"\"no-jwt\" contradicts \"use-jwt\""
]
}The CLI prints conflict warnings below the results; the --json flag outputs the same object envelope (entities + retrieval, plus conflicts when any exist).
Examples:
// Search by keyword
{"query": "authentication"}
// Search with tag filter
{"query": "auth", "tag": "project:myapp"}
// List recent (no query)
{}
// List recent with limit
{"limit": 5}Archive an entity (soft-delete) or remove a specific observation.
Behavior: forget does not permanently delete data. Entities are archived and hidden from normal recall, but preserved in the database. Use include_archived: true in recall to see archived entities.
Input Schema:
| Parameter | Type | Required | Description |
|---|---|---|---|
name |
string | Yes | Entity name to archive or modify |
observation |
string | No | If provided, only this observation is removed (entity stays active). If omitted, the entire entity is archived. |
Modes:
- Entity archive (no observation): Archives the entire entity. Hidden from recall by default.
- Observation removal (with observation): Removes one specific observation. Entity stays active.
For Stop-generated session-<id>-files, session-<id>-fixes, and
session-<id>-summary snapshots, subsequent Stops exclude that exact observation
text. Other newly derived observations can still update the snapshot. Explicitly
adding the removed text with remember clears its exclusion and restores it.
Among the CLI, MCP and HTTP transport entrypoints, only CLI JSON import
(memesh import <file>) retains bundle metadata at all. The MCP import
tool and POST /v1/import both validate the bundle against
ExportResultSchema, which does not declare a metadata field on each
entity — Zod strips unknown keys by default, so no bundle metadata
(exclusions, guard, demo, task_state, or anything else) ever reaches
buildImportedMetadata through those two transports; the CLI reads the raw
file with JSON.parse and passes it straight through. (The underlying
importMemories() function itself has no such restriction — called directly,
not through a transport, as tests/core/export-import.test.ts does, it
accepts whatever metadata it is given, same as the CLI path.)
Within a path that reaches it, a bundle's metadata is filtered by an
ALLOW-list (IMPORTABLE_METADATA_KEYS in serializer.ts): a key on it purely
describes the memory — display or provenance — and nothing IN
IMPORTABLE_METADATA_KEYS is ever read back to change what MeMesh does.
Everything that changes what MeMesh DOES
(AUTHORITY_METADATA_KEYS: guard, demo, task_state, pin,
signal_score, forgotten_observation_hashes, replaced_history,
evidence_for, consolidation_depth, compacted_into, proposal_id,
session_id, and trust/provenance, which are separately rebuilt) is
refused by default, whether the entity already exists or the import is
creating it — with four narrow, explicit, VALIDATED exceptions,
each accepted on a FRESH entity only. Unlike every other name on the
allow-list, these four DO change behaviour once accepted (compaction
eligibility, ranking, what forget excludes stays removed, or what
--replace appends future history onto) — which is exactly why each one
gets its own validator below instead of the blanket admission an allow-list
entry gets:
forgotten_observation_hashes— the exclusion list behind observation-levelforget— is never set, changed, or cleared by a bundle on an entity you already have (your own local list always wins, including when the two conflict). For an entity the import CREATES, the bundle's own list is accepted only after validation: every element must be a real SHA-256 hex digest (/^[a-f0-9]{64}$/), the list is de-duplicated, and it must not exceed 1000 entries. One invalid element, or too many, drops the whole list — never a partially-filtered one — and the entity is created with no exclusions instead. This is what makes restoring your own backup onto a new machine withmemesh import, then later append-importing an older backup the same way, not put a removed observation back: the newer restore's exclusion list has to actually land on the entity for the append path's forget-filter to have anything to check against.pin— protects a memory from the dreamer's auto-compaction. A bundle can never unpin an entity you already have (a bundle sendingpin: false, or omitting it, does nothing). A FRESH entity accepts the pin only when the bundle's value is the literal booleantrue— any other value is refused.signal_score— the ranking/default-hide weight. An entity you already have keeps its own score exactly; the bundle's value never reaches the merge, so it can neither raise nor lower it. For an entity the import CREATES, the bundle's score is accepted only when it is a finite number with0 <= x <= 1— the exact rangecomputeSignalScoreitself always produces. No partial trust: a string,NaN,Infinity, a negative number, anything above1,null, or an object is refused whole, and the entity gets its own content-derived score instead. This preserves a genuine backup's own scores for entities it recreates from nothing, without letting an untrusted bundle inflate or deflate a memory's ranking with an out-of-range value.replaced_history— the versions--replacekept, appended to by every LATER local--replaceon the same memory (rememberInTransaction, a read-modify-write, not a display-only field — a forged history in a bundle could otherwise survive an import and then have a genuine later replace silently appended onto it). For an entity you already have, the bundle's value never reaches the merge, forappendandoverwritealike:appendkeeps the local history exactly, andoverwritekeeps it and adds the version it replaced (nothing when the imported content is identical to what is stored). For an entity the import CREATES, the bundle's value is accepted only when it is an array of AT MOST 50 ENTRIES, each one shaped exactly like a real entry (replaced_at: a string;title: a string ornull;observations: an array of strings;tags: an array of strings; optionaltruncated: a boolean, marking a version whose observations, then tags, were pared down to fit the writer's own 64 KiB cap; no other key), AND the WHOLE array's own serialized JSON is AT MOST 256 KiB — a budget over the entire array together, not per entry (two 140 KiB entries are refused together even though each alone is under 256 KiB). One violation anywhere — shape, count, or the aggregate byte budget — drops the WHOLE list, never a partially-filtered one, and the entity is created with no history instead.
A restored backup therefore does NOT carry a memory's task state (goal, next
step, blocker), or its guard/demo/consolidation_depth/compacted_into/
proposal_id/session_id/evidence_for markers on ANY entity — those are
always refused, recomputed, or left absent on the machine doing the
restoring, never taken from the file, with no exception for either an
existing or a freshly-created entity. All FOUR fresh-only exceptions —
forgotten_observation_hashes, pin, signal_score, and
replaced_history — have a live effect once accepted (observation
suppression, compaction protection, ranking, and future --replace
behaviour, respectively); none of the four is "merely descriptive" the way
an ordinary IMPORTABLE_METADATA_KEYS member is. What makes them safe is
not that they are inert, but that each is validated, and each restores
ONLY onto an entity the import creates — never onto one you already have.
consolidate was removed. It deleted an entity's observations and wrote an LLM summary in their place, immediately: no proposal, no review, and nothing to restore from if the summary was wrong. It also ignored pins, and reset confidence to 1.0 on success. A failure between the delete and the write left the entity permanently empty while the result reported that nothing had happened.
MCP: the tool is gone from the registry.
HTTP: POST /v1/consolidate answers 410 Gone with a pointer, rather than 404 — a script author reads the difference.
CLI: memesh consolidate prints where to go and exits 1.
Use work_package from an already-running agent session instead. It prepares a bounded digest or visible-transcript package and stages exactly one proposal for human review. The Dashboard can inspect, accept, or reject that staged proposal; it does not create the package or wake an agent. There is no reviewed equivalent of "compress this one named entity" today.
Export memories to a portable JSON bundle. Use for personal backup, migrating between machines, or optional manual transfer between compatible agents.
Input Schema:
| Parameter | Type | Required | Description |
|---|---|---|---|
namespace |
string | No | Export only entities from this namespace ("personal", "team", "global"). Omit to export all namespaces. |
tag |
string | No | Export only entities matching this tag (e.g., "project:<id>", where <id> is the project field of the briefing result) |
limit |
number | No | Maximum number of entities to export, archived ones included (default: 1000). The default is a subset, not a backup: a graph larger than the limit exports the newest limit memories and sets truncated: true. For a full backup, pass a limit above your graph size. |
Response:
{
"version": "3.1.0",
"exported_at": "2026-04-17T00:00:00.000Z",
"entity_count": 12,
"truncated": false,
"entities": [
{
"name": "auth-decision",
"title": "Why we chose OAuth 2.0",
"type": "decision",
"namespace": "team",
"created_at": "2026-04-01 09:12:33",
"metadata": {"signal_score": 0.8},
"observations": ["Use OAuth 2.0"],
"tags": ["project:myapp", "topic:auth"],
"relations": []
}
]
}title is null for an entity that has none. Bundles written before titles existed carry no title key at all, and import reads that as "this bundle says nothing about the title" — it leaves an existing entity's title alone rather than clearing it.
What a bundle carries, and what import does with it (v3.1.0)
| field | on export | on import |
|---|---|---|
created_at |
always | restored for entities the import CREATES, and only when parseSqliteUtcMs can read the value. An entity you already had keeps its own creation time. |
status |
present only for archived entities | the entity is archived after it is created — for an entity the import CREATES. An existing entity keeps its own status: an archived one stays archived under append and overwrite unless restore_archived is set (see Archived memories under import). Archived memories are part of a backup: without them, forget then export then restore brought the memory back. |
metadata |
present when the entity has any | among the CLI, MCP and HTTP entrypoints, only CLI JSON import retains bundle metadata at all — ExportResultSchema does not declare metadata, so the MCP import tool and POST /v1/import have Zod strip it before it exists to merge (the bare importMemories() function has no such restriction). Filtered by an ALLOW-list: only a purely descriptive key (display/provenance) is ever taken from the bundle. trust and provenance are always rebuilt by the import, never read from the bundle. Every behaviour-changing key is refused by default — guard (installs a Bash-command warning), demo (demo --reset HARD-DELETES every entity carrying it, #361), task_state (injected verbatim into SessionStart/memesh briefing context — a bundle must not be able to put text in front of the agent), evidence_for (a dream accept idempotency gate — refused and rebuilt by the real dream accept path instead), consolidation_depth, compacted_into, proposal_id, session_id — for an entity you already have AND for one the import creates, no exception. Four keys get a narrow FRESH-entity-only, VALIDATED exception: forgotten_observation_hashes (64-hex SHA-256, de-duplicated, capped at 1000, or the whole list is dropped), pin (only the literal boolean true; anything else is refused), signal_score (only a finite number with 0 <= x <= 1 — computeSignalScore's own documented range; anything else is dropped and the entity gets its own content-derived score), and replaced_history (only an array of at most 50 entries shaped exactly like --replace's own history entries — replaced_at/title/observations/tags, optional truncated (a boolean), no other key, the WHOLE array's own serialized JSON at most 256 KiB — a budget over the entire array together, not per entry — or the whole list is dropped). An EXISTING entity's own value for any of these four always wins regardless of what the bundle says, same as every other authority key. |
relations |
always | created in a SECOND pass, after every entity in the bundle exists. A relation that still cannot be created points outside the bundle, and is named in skipped_relations rather than dropped — reported, but not an error, because every narrowed bundle has them. |
Bundles written by earlier versions (3.0.0) import unchanged — every added field is optional.
Examples:
// Export all memories
{}
// Export team namespace only
{"namespace": "team"}
// Export specific project
{"tag": "project:myapp"}Import memories from a JSON bundle produced by export. Three merge strategies control how conflicts with existing entities are resolved.
Imported entities are marked with import provenance and treated as untrusted for automatic Claude hook injection until they are reviewed or re-stored locally, or, for the CLI only, restored with memesh import --trust — for your own backup, never for a file someone else gave you.
Input Schema:
| Parameter | Type | Required | Description |
|---|---|---|---|
data |
object | Yes | The JSON bundle produced by export. A bundle that names one memory more than once is refused and nothing is imported. |
merge_strategy |
string | Yes | Merge strategy for conflicts: "skip", "overwrite", or "append" |
namespace |
string | No | Force imported entities into this namespace, ignoring the namespace stored in the bundle. With overwrite or append it also moves entities that already exist, in bulk, out of the scope they are in — metadata.previous_namespace records where each came from. With skip it does not: see the table below. Must be personal, team or global; anything else is refused outright. |
restore_archived |
boolean | No | Default false. With overwrite or append, a local entity that is archived (forgotten) and named by the bundle is left untouched and counted in kept_archived. true brings it back to active and merges or overwrites it like any other, and requires merge_strategy append or overwrite: with skip (which touches no existing entity) the call is refused with an error and nothing is imported. Must be a boolean; a string such as "yes" is refused. |
Merge Strategies:
| Strategy | Behaviour on existing entity | Does namespace move it? |
|---|---|---|
skip |
Keep existing entity unchanged, discard imported copy | No — "unchanged" includes its namespace |
overwrite |
Replace existing entity's observations and tags with imported values; the replaced observations, tags and title are kept in metadata.replaced_history, like remember with replace: true (an import identical to what is stored adds no version) |
Yes |
append |
Append imported observations to existing (skipping any already present verbatim), deduplicate tags | Yes |
skip is the exception because it is the one strategy that promises to touch
nothing that is already there, and a namespace move is a change — it takes the
memory out of every scoped recall that used to return it. An import asking to
skip existing entities does not get to relocate them as a side effect.
Archived memories. forget archives a memory instead of deleting it, and
the dreamer archives the sources it digests. An import does not undo either: an
existing entity that is archived stays archived and completely untouched when
the bundle names it — no observations added or replaced, no tag, title,
namespace or metadata change, nothing reactivated — under append and
overwrite alike, and the response counts it in kept_archived. Pass
restore_archived: true (CLI: --restore-archived) to bring such memories back
and merge or overwrite them as the strategy says; it requires append or
overwrite. skip already leaves every existing entity alone, so an archived
one is counted in skipped there, and restore_archived together with skip
is refused rather than silently ignored. This is specific to import: remember
still reactivates an archived memory that is stated again.
A bundle entry that is left untouched this way contributes none of its own
relations, as with skip; a relation from another entry in the bundle to it
is still created. A bundle entry's own status: "archived" applies only to
entities the import creates, as before.
A bundle's title is applied to the entities the import creates, and replaces
the title of one it updates (overwrite, append). A bundle entry with no
title — or a blank one — leaves an existing title as it was; over-long titles
are truncated rather than refused, because one bad row must not cost the whole
bundle.
Response:
{
"imported": 10,
"overwritten": 0,
"skipped": 2,
"appended": 0,
"kept_archived": 0,
"errors": [],
"skipped_relations": ["older-note -supersedes-> a-memory-not-in-this-bundle"]
}overwritten is a subset of imported: how many of those entities already
existed and had their data replaced (merge_strategy: "overwrite" hitting a
name already in the graph) rather than being created from nothing.
kept_archived counts the archived local entities the bundle named and the
import left as they were (see Archived memories above). It is not part of
imported, skipped or appended.
skipped_relations names each link the restore could not rebuild, as
from -type-> to. It is reported but is not an error and does not fail the
command: every bundle narrowed by --tag, --namespace or --limit has
relations that point outside it. errors is for entries that genuinely failed,
and only errors makes the CLI exit non-zero.
Examples:
// Import with default (skip duplicates)
{"data": {...}, "merge_strategy": "skip"}
// Import and overwrite conflicts
{"data": {...}, "merge_strategy": "overwrite"}
// File NEW entities under team; existing ones keep the namespace they have,
// because `skip` leaves existing entities alone
{"data": {...}, "merge_strategy": "skip", "namespace": "team"}
// Move existing entities into team as well as filing new ones there
{"data": {...}, "merge_strategy": "append", "namespace": "team"}
// Also bring back local memories you archived that the bundle names
{"data": {...}, "merge_strategy": "append", "restore_archived": true}Record a structured lesson from a mistake or discovery. Creates a lesson_learned entity with structured observations for error, root cause, fix, and prevention. Use it when something went wrong and the cause and fix are known; a choice between options is a remember with type decision. The project's lessons are shown at the start of later sessions.
Input Schema:
| Parameter | Type | Required | Description |
|---|---|---|---|
error |
string | Yes | What went wrong |
fix |
string | Yes | What fixed it |
root_cause |
string | No | Why it happened |
prevention |
string | No | How to prevent it next time |
severity |
string | No | Severity level: "critical", "major", or "minor" (default: "minor") |
Response:
{
"learned": true,
"name": "lesson-myproject-null-reference",
"type": "lesson_learned"
}name is generated from the project and the error text. To see what was stored — the observations and the severity: / error-pattern: tags — recall the entity by that name.
Examples:
// Record a lesson from a bug fix
{
"error": "TypeError: Cannot read property of null",
"fix": "Added optional chaining (?.) on API response",
"root_cause": "API response can be null on timeout",
"prevention": "Always validate API responses before accessing nested properties",
"severity": "major"
}
// Minimal lesson (only required fields)
{
"error": "Tests fail with SIGSEGV in native module",
"fix": "Changed vitest pool from threads to forks"
}Read or update where the work stands on a project: the goal, the next step, what is blocked, and what was just finished. There is exactly one state per project; fresh state is injected at the top of the next session's context at standard/full — see Briefing levels below for detail — while minimal, the default, never shows a fresh state and a stale or unknown-age one instead gets a one-line status at every level, minimal included, and memesh task (no arguments) always shows the complete stored state regardless of level.
Call it with no arguments to read. Any field present is a write.
Only record what the user actually stated. These four values are handed to a future session as fact, with nothing to contradict them — a goal inferred from which files were edited is a wrong instruction with no author. Nothing derives this automatically for the same reason; the Stop hook can see that six files changed, which is not a goal.
Input Schema:
| Parameter | Type | Required | Description |
|---|---|---|---|
project |
string | No | Project name (default: the current working directory's project) |
goal |
string | No | What this work is FOR — the outcome being aimed at |
next |
string | No | The next concrete step |
blocked |
string | No | What is standing in the way |
done |
string | No | What was just finished |
Passing an empty string clears a field — that is how a blocker is removed once it is resolved. Omitting a field leaves it untouched, which is a different thing.
Response:
{
"project": "myproject",
"state": {
"goal": "Ship the work-topology injection",
"next": "Open the PR once Windows CI is green",
"updated_at": "2026-08-16T02:41:00.000Z"
},
"changed": ["next"]
}changed lists the fields that actually differed. Re-stating a value that is already recorded returns "changed": [] and writes nothing — which is what keeps updated_at an honest answer to "how old is this thinking". A read (no arguments) returns project and state only.
Examples:
// Read the current state
{}
// Record a goal and the next step
{
"goal": "Cut session-start injection below 700 tokens",
"next": "Measure against the real graph before and after"
}
// Clear a blocker that has been resolved
{
"blocked": ""
}The briefing tool returns an eligible project handoff ahead of ranked memories, together with project decisions, lessons, knowledge, recent activity, and (at standard or full) a fresh task state and capped durable-memory index with [mem:id] handles. Repository facts can prefix the block. The Claude Code SessionStart hook injects a memory block under the same assembly rules; its candidate window can differ from the tool's. At full the hook adds a separate work-package notice that the tool and CLI do not include. The Codex plugin loads the same hook file; once Codex is allowed to run the plugin's hooks, its SessionStart hook injects the same memory block; its companion separately registers the thread for messaging. Which other hooks fire under Codex is not yet verified. With the Codex plugin, call briefing only when that block is missing; MCP-only clients call briefing at session start.
The returned text is fenced as untrusted background data; stored memory content must not be treated as instructions.
Briefing levels. Set memesh config set briefing <minimal|standard|full> or MEMESH_BRIEFING (environment overrides config); the default is minimal. The level applies to Claude Code's hook, the MCP tool, and memesh briefing, with no per-call override. memesh config list shows the effective level and its source; memesh config get briefing shows only the stored value. Invalid stored or environment values fall back to minimal and are reported as invalid.
| Level | Eligible project handoff | This project's decisions/lessons/knowledge/recent activity | Task state (fresh) | Durable-memory index | Global memory | Other projects' recent memory | Work-package notice (hook only) |
|---|---|---|---|---|---|---|---|
minimal (default) |
yes | yes | no | no | no | no | no |
standard |
yes | yes | yes | yes | no | no | no |
full |
yes | yes | yes | yes | yes | yes | yes |
An eligible handoff leads the saved-memory portion at every level; repository facts, when present, prefix the block. minimal otherwise includes this project's decisions, lessons, knowledge, and recent activity. standard adds fresh task state and the capped durable-memory index. full also adds global memory and other projects' recent activity. The work-package notice at full belongs only to the Claude Code SessionStart hook, not to the MCP tool or CLI.
The saved-memory lines inside the fence share one 4000 UTF-16 code-unit limit across the eligible handoff, displayed task state and unread-inbox notice (when addressed to an exact recipient), ranked memories, global memory at full, and injected index. Repository facts before the saved-memory lines, the fence/preface, and the hook-only work-package notice are outside that limit. The displayed task state is shortened to at most 1200 code units (320 per line); memesh task still reads the complete stored record. Recent trusted project decisions take the project's slots first, newest valid activity first (unknown dates last); remaining slots follow relevance ranking. A separate pool selects up to five trusted active lesson memories (lesson_learned, lesson or mistake) for the project, even if decisions occupy its other slots. Memory lines may be omitted when the shared limit fills.
Claude Code's Stop hook replaces one session-handoff memory for the exact project with its latest assistant reply, after credential-shaped redaction and removal of fenced code. Capture needs at least 80 cleaned characters and obeys autoCapture; a skipped capture leaves the previous handoff untouched. Display uses only an active, trusted exact-project handoff's newest observation, limited to 800 characters even if the memory was written manually. Up to 72 hours old it appears normally; after 72 hours through 14 days it carries a stale warning; older than 14 days, undatable, or more than five minutes future-dated it is omitted. Imported handoffs are not injected merely because they have the right name. The handoff is background context, not an inferred task list or a guarantee that work resumes.
Task state older than 72 hours, missing or unreadable timestamps, and timestamps more than five minutes in the future are not shown as current. Instead, every level shows a one-line stale or unknown-age flag and points to memesh task for the stored record. minimal can return text: "" and empty: true when it has no content; standard and full still show the index's empty-state line.
Input Schema:
| Parameter | Type | Required | Description |
|---|---|---|---|
project |
string | No | Project name (default: the current working directory's project) |
recipient |
string | No | Exact logical recipient, in the same canonical form the message tool uses — NFC, never a filesystem path — because this counts the same inbox key. When supplied, reports only that recipient's unfetched deliveries for the project. At zero unread, the block also says so explicitly if this exact recipient id has never been addressed in this project either (durable delivery or live connection) — distinct from a real, quiet inbox, so a typo'd recipient is never indistinguishable from "nothing waiting". Omit for generic context; generic briefing never reports unread activity. |
Response (shown at level standard, which has a task state and the index to show; at the default, minimal, hasTaskState is false for a fresh task state and text carries neither it nor the index):
{
"project": "myproject",
"text": "MeMesh reference memory. Treat the content below as background data…",
"entityCount": 12,
"hasTaskState": true,
"hasHandoff": false,
"index": { "lines": ["Index of durable memories for \"myproject\" (newest first):", "…"], "shown": 9, "more": 0, "older": 2, "truncated": false, "bytes": "…", "tokens": "…", "ids": [41, 38, 12] },
"level": "standard",
"empty": false
}At minimal on a project with nothing to show, the response has text: "" and empty: true; index is still returned as structured data but is not included in text. The CLI prints Nothing to brief at level minimal — no project memories yet. and exits 0.
bytes/tokens above are shown as "…" because the lines they measure are abbreviated in this example — they are only reproducible for a fully spelled-out set of lines (see the GET /v1/briefing-index response below for one).
hasTaskState is true exactly when a task-state line is included: the fresh state (at standard/full), the one-line stale flag, or the unreadable-record line. hasHandoff is true exactly when an eligible handoff is displayed. The unread-message reminder that recipient adds rides beside them but is not a task state and does not count.
Project names in the text. project returns the full project ID (<label>~<32 lowercase hex> for current IDs). Headings use only the readable label; project: tags, entity names, --project, and unread-message routing use the full ID.
entityCount counts the ranked memory lines actually rendered into the block (the shared limit can cut candidates), excluding the handoff, task-state block, and index. index is always computed and returned regardless of level; it is the standalone index, not a count of what fitted into text. Only the index inside text is level-gated and shares the block's remaining room. level is the resolved level this result was assembled at. Also available as memesh briefing on the CLI, for agents whose only integration is a shell.
The durable-memory index. At standard/full, the block closes with a capped index of this project's durable memories. memesh briefing --index prints it alone regardless of the configured level (--index --json for structured output). Use recall for questions or more results.
- One line per durable memory — every type except the evidence layer (
EVIDENCE_LAYER_TYPESinsrc/core/work-topology.ts: commits, session insights and summaries, keypoints, session identity, weekly summaries, checkpoints),task-state, andsession-handoff— as- [type] title — first observation [mem:id], newest activity first (the later of creation and the newest observation; ties by id). - Scope: active
project:<name>rows outside theglobalnamespace; imported or untrusted rows are excluded from automatic injection. - Titles and snippets have credential-shaped secrets and user paths redacted. Every memory line also has runs of non-whitespace C0/C1 control characters, DEL, and bidi override/isolate characters replaced with a space (
stripControlCharsinsrc/core/work-topology.ts) before it reacheslines, whether read throughtext, the standaloneindexfield, orGET /v1/briefing-index(#374). The heading and empty-state line use the project's readable label without redaction; theN morecommand prints a literal"project:…"placeholder rather than a real project name. - Memories with no change for 180 days are counted in one
N older memories … — recall to seeline instead of listed. - The standalone index (
--indexand the response'sindexfield) allows at most 40 memory lines and 3072 UTF-8 bytes. The index injected into astandard/fullbriefing also fits the remaining room in the shared 4000-unit block, so it may show fewer lines. When capped,- N more — memesh recall --tag "project:…"signals additional matches; replace the literal placeholder with the actual project tag before running it. A+after a count means the 2000-row candidate window was full, so the count is a lower bound. - The footer reports
(index cost: N lines, B bytes ≈ T tokens; cap 40 lines / 3072 bytes).Bcovers the whole section including footer,T = ceil(B / 4), andindex.bytes/index.tokensreport the same values. - With no durable memories,
standard/fullshow- No durable memories (decisions, lessons, patterns, references) for "<label>" yet.. Atminimal, a project with no eligible handoff, ranked memories, or stale-state flag can return empty text; a fresh task state alone is not shown. Repository facts appear only when other content is present. - SessionStart records the index's rendered ids with the injected set, so a
[mem:id]citation of an index line is credited like a ranked one. If the hook cannot read the index it says so in the block and records anerroroutcome; it never shows the empty-state line for a failed read.
Examples:
// Load context at session start
{}
// Another project's briefing
{ "project": "other-repo" }
// Load actionable inbox context for one exact recipient
{ "project": "other-repo", "recipient": "reviewer-agent" }Analyze user work patterns from existing memory. Returns work schedule (peak hours/days), tool preferences, focus areas, workflow metrics (session duration, commits/session), knowledge strengths, and learning areas. Use it when the task needs context about how the user works, such as their schedule or tool preferences; it is not part of loading a session.
Input Schema:
| Parameter | Type | Required | Description |
|---|---|---|---|
categories |
string[] | No | Specific categories to return: "workSchedule", "focusAreas", "workflow", "strengths", "learningAreas". Omit for all. |
Response (MCP returns markdown text; HTTP returns JSON):
{
"workSchedule": {
"hourDistribution": [{"hour": 9, "count": 42}, {"hour": 14, "count": 38}],
"dayDistribution": [{"dayNum": 1, "count": 50}]
},
"focusAreas": [{"type": "decision", "count": 12}],
"workflow": {
"commitsPerSession": 2.3,
"totalSessions": 20,
"totalCommits": 46
},
"strengths": [{"type": "pattern", "avgConfidence": 0.95, "count": 8}],
"learningAreas": [{"tag": "async", "count": 3}]
}Examples:
// Get all patterns
{}
// Get only workflow and schedule
{"categories": ["workflow", "workSchedule"]}Turn active memories or lessons into a governed product-improvement proposal, or inspect an existing proposal's status. This is the memory-to-product bridge: source memories remain evidence, and staging is idempotent for the same normalized project, sources, problem, change, verification scenario, success criteria, and priority.
Agents have proposal authority only. The MCP tool intentionally has no accept or reject action. A human reviews the full proposal with memesh dream show <id> or the dashboard, then applies or rejects it through the existing review surface. Acceptance means approved for product work; it does not mean implemented, effective, released, or deployed.
Propose input schema:
| Parameter | Type | Required | Description |
|---|---|---|---|
action |
"propose" |
Yes | Stage or find the idempotent proposal |
project |
string | Yes | Project that would own the product work |
source_names |
string[] | Yes | Stable names of 1–20 active source memories |
title |
string | Yes | Human-readable improvement title |
problem |
string | Yes | Evidence-backed problem observed |
proposed_change |
string | Yes | Bounded product change to consider |
verification_scenario |
string | Yes | Scenario capable of falsifying the change |
success_criteria |
string[] | Yes | One or more observable success criteria |
priority |
p0 | p1 | p2 | p3 |
No | Proposed priority; defaults to p1 |
Propose response:
{
"proposal_id": 42,
"status": "pending",
"created": true,
"title": "Add claims and leases to shared work",
"source_ids": [7, 9],
"review": {
"required": true,
"authority": "human",
"state": "pending",
"inspect": "memesh dream show 42",
"accept": "memesh dream accept 42",
"reject": "memesh dream reject 42 --reason <text>"
}
}A retry of the same normalized proposal returns the same proposal_id with created: false. Missing or archived source memories fail the call and create no proposal.
Status input schema:
| Parameter | Type | Required | Description |
|---|---|---|---|
action |
"status" |
Yes | Read proposal state |
proposal_id |
positive integer | Yes | ID returned by propose |
Status returns the proposal state, source IDs, review timestamps/reason, and accepted_entity_name after human acceptance. Accepted improvements are linked to every source by learned-from, appear in project briefing as product_improvement work, and explicitly retain implementation:unverified and outcome:unverified until later product evidence changes those states.
Discover live registrations or exchange durable exact-recipient messages between local hosts connected to the same MeMesh SQLite instance. One tool owns both surfaces so every transport uses the same validation and state semantics. discover and a principal-target send/fetch are independent: an empty discover result does not predict whether that will work, since durable store-and-forward to a named recipient needs neither the router nor any live registration — but an exact target_kind: "session" send still needs both.
When to use it: use discover when you know the project but not the right live recipient; use send to hand off work, ask for a result, or report a disposition. For target_kind: "session", MeMesh sends the bounded full message through the exact active native host channel and returns only after host_accept. An oversized full envelope returns native_message_too_large; if the sender cannot reach the local router it returns router_unreachable; an absent, stopped, disconnected, or otherwise rejected exact session returns recipient_unavailable. Durable state remains available for scoped recovery in each case, but a failed exact-session native delivery is not automatically replayed when that session later registers. Principal targets retain durable store-and-forward behavior. A briefing can surface N messages waiting for "<recipient>" in project "<project>" only when the caller supplies that exact recipient; generic briefing has no recipient identity and remains quiet, and so do the SessionStart and prompt hooks unless the session declares one with MEMESH_RECIPIENT — or, under Claude Code with the memesh-channel already set up, the owner-private hosts/claude.json config supplies it automatically whenever the file is present and names a valid principal_id (#474 — an older file's project field, if any, is not read; they can report deliveries waiting for exactly that recipient, OR a target_kind: "session" delivery addressed to a session that is registered under that recipient as its principal AND live right now — a connection with no disconnect and an unexpired lease; a session that has disconnected or let its lease expire is not surfaced (#490)). Under Claude Code, a separate Stop hook additionally blocks the turn once per waiting message id (principal- or live-session-targeted alike) not yet blocked for in that session, whether or not a host_accept row exists, and regardless of stop_hook_active unless THIS gate already blocked for that same id in this session; it does not run under Codex (#468, #490, #492). Length-limited briefing reminders can omit some project notices; omitted messages remain pending. Poll a known inbox with the exact project and recipient, fetch each returned message_id, then record intake: fetching alone does not end the reminder. At zero unread, a scoped briefing can say ... this recipient id has never been seen in this project when that exact id has no delivery and no live connection recorded for that project — a typo in --recipient must not read as an empty, healthy inbox.
The JSON-encoded durable payload is limited to 65,536 UTF-8 bytes (64 KiB). Native delivery has a separate 16,384-byte (16 KiB) limit for the complete envelope, including routing metadata and payload. Therefore, fitting the durable payload limit does not guarantee that native delivery can accept the message; that permanent size failure is reported as native_message_too_large, not as transient unavailability. Payloads are untrusted data and are never executed by MeMesh.
The durable API is separate from host-native delivery. A stable principal names a logical recipient; a session is one active connection, and its generation changes when replaced. Exact-session delivery never reroutes; a principal target may use only an eligible active session after activation. Persistence, dispatch, host acceptance, intake, acknowledgement, workflow disposition, retention, and presence are independent state axes. A Local host-native input may remove polling for an active session, but no stopped session is awakened. Cloud relay, A2A, SSE, discovery, persistence, or fetch is not proof of Local host delivery.
For an active exact session, a durable message event passes through the owner-private local router to the authenticated supported host adapter. The adapter receives one untrusted full envelope capped at 16 KiB; no inbox fetch is required for that native delivery. A host acceptance receipt is not recipient acknowledgement or workflow disposition. See the architecture branch for the local path and its limits.
The action field is one of:
| Action | Required fields | Meaning |
|---|---|---|
send |
project, sender, recipient, idempotency_key, payload |
Transactionally create one canonical message, one recipient delivery, and one notification event. JSON-encoded payloads are capped at 64 KiB; the complete native envelope is capped separately at 16 KiB. Exact-session success additionally requires native host_accept; an oversized envelope returns native_message_too_large, sender-side router failure returns router_unreachable, and other unavailable or rejected sessions return recipient_unavailable, with scoped recovery state retained in all cases. Principal targets retain durable store-and-forward behavior. Exact retries return the same IDs; a conflicting retry is rejected. |
discover |
project, optional limit (default 50, max 100) |
Read currently live registrations in one project from the router. Returns only router data (session_id, principal_id, host_kind, project, model (always null: no host tells MeMesh which model a session runs), declared work_summary or null, active, generation, and lease_expires_at_ms); performs no message or receipt operation and fails explicitly when the router is unavailable. |
poll |
project, recipient |
Read a bounded batch after an optional opaque cursor. wait_ms is 0–30000 and limit is 1–100. Events contain routing metadata, never the payload. |
fetch |
project, recipient, message_id |
Return the payload routed to that principal or exact session. Optional target_kind defaults to principal; exact-session fetches must pass session. Fetch is a read and does not imply intake or ACK. |
intake |
receipt base plus intake_state |
Record fetched or ingested without implying ACK. Refused with intended_for_other_session for a delivery with an intended_session, unless the caller is that session (MCP and CLI read it from CLAUDE_CODE_SESSION_ID, else CODEX_THREAD_ID; HTTP has none). Codex sets CODEX_THREAD_ID only for its shell commands, so a Codex session records it with memesh message intake. For a target_kind: "session" delivery, a caller that is another registered session of the project is refused the same way; a caller with no session id, or an unregistered one, is not. |
ack |
receipt base | Record explicit recipient acknowledgement. Inbox/MCP acknowledgement does not require or imply host-native acceptance. |
disposition |
receipt base plus disposition |
Record accepted, rejected, completed, cancelled, or deferred. Same intended_session rule as intake. |
activation |
receipt base plus activation |
Record woken, manual_resume_required, unsupported, or failed. |
receipts |
project, recipient, message_id |
Read one ordered audit projection containing public receipt facts plus any host acceptance, host-native ACK, and workflow facts for the authorized delivery. Each row identifies its fact_source. |
project, recipient, and the actor derived from recipient are scope identifiers: they are canonicalised to Unicode NFC and trimmed on every action, read and write, and a value spelled as an absolute filesystem path (/root, C:\work, \\host\share) is refused with an error naming the field and a valid value. Project identity is derived from a working directory and can never take that shape. Nothing else is rewritten — comparison is exact, case included, no prefix is treated as a namespace, and an identifier that merely contains a separator is accepted. sender is provenance rather than routing and is stored exactly as given. It is not the sender's live session id; to reply to one exact sender session, run discover for the project, select the current card's session_id, and send to that id with target_kind: "session". If the card disappears or its generation changes, fail closed and retain the durable reply for scoped recovery; never infer a session id from sender labels or payload text.
The receipt base is project, recipient, message_id, and a stable idempotency_key. disposition and activation also accept an optional bounded detail string.
Additional send fields:
| Parameter | Type | Required | Description |
|---|---|---|---|
target_kind |
principal | session |
No | Defaults to principal. A session target is bound to that exact active session, waits for native acceptance, and never reroutes to a replacement. |
intended_session |
string | No | Only with target_kind: "principal". The one session of that principal the message is meant for: a Claude Code session id or an ordinary Codex CLI thread id (the session_id its hooks receive). A session the router registered through any other host (acp, codex-app-server) is refused, since nothing there could say which session it is when it records intake; an id the router never registered is allowed. Only that session is reminded of it (hooks, Stop gate, briefing), only it can record intake or a disposition (anyone else, including an HTTP caller, gets intended_for_other_session), and the router pushes it only to that session's connection. Part of the idempotency request. |
fallback_to_principal |
boolean | No | Only with target_kind: "session". If that session refuses the message (recipient_unavailable), send it to the principal the session registered under, with intended_session set to it, and return that message with a fallback field naming the refused one; fallback.intended_session_connected says whether that session is connected now, and when it is not, fallback.note says only it can take the message in when it next runs (#518). A session that never registered has no known principal, and a refused session registered through a host that cannot be named is refused, so in both cases the send still fails with recipient_unavailable, saying so. router_unreachable does not trigger it. |
content_type |
text/plain | application/json |
No | Defaults to text/plain; text payloads must be strings. |
privacy |
private | team |
No | Retained message metadata; defaults to private. Delivery remains exact-recipient in both cases. |
correlation_id |
string | No | Conversation or task correlation without changing routing. |
reply_to |
message ID | No | Links this message to another message without changing delivery. |
Payload JSON is limited to 65,536 UTF-8 bytes. Sender-host provenance is supplied by the transport and cannot be provided in tool arguments. An opaque cursor is scoped to its exact project and recipient; an unknown or foreign cursor is rejected.
Exact-recipient routing is not per-agent authentication or an ACL. A caller that can access the local MeMesh instance can assert a logical recipient ID, so all callers on a shared instance must be cooperative, trusted workspace participants. HTTP bearer authentication protects instance access; it does not establish a separate cryptographic identity for each agent.
poll is a bounded compatibility and diagnostic read, not the normal active-session push path. It does not resume a stopped model session, and no action executes payload content. Poll clients persist next_cursor, fetch explicitly, and record only receipt facts that actually occurred; verified active host adapters receive the authorized envelope from the Local router without polling.
The CLI also exposes owner-operated storage accounting and bounded retention:
memesh message storage report --cutoff <ISO timestamp>reports logical payload, protected/unresolved rows, prunable terminal rows, cursor/session/presence/dispatch/acceptance audit counts, reusable SQLite pages, and main/WAL file sizes.memesh message storage prune --cutoff <ISO timestamp> [--batch-size 1..1000]is a dry-run;--applyreplaces only payloads whose every delivery has an explicit ACK and an old terminal disposition. It preserves lifecycle audit facts.MEMESH_AGENT_MESSAGE_STORAGE_QUOTA_BYTES=<non-negative integer>enables an owner-selected hard logical-payload quota. Over-quota sends fail atomically withstorage_quota_exceeded. It is not a whole-file disk quota: metadata, indexes, audit rows, reusable pages, and WAL bytes remain visible through the storage report and require an owner disk/headroom policy. No quota or automatic retention policy is enabled by default.
| Field | Type | Description |
|---|---|---|
id |
number | Auto-incremented primary key |
name |
string | Unique entity name |
type |
string | Entity type |
namespace |
string | Namespace scope ("personal", "team", "global") |
created_at |
string | ISO timestamp |
metadata |
object | Optional JSON metadata |
observations |
string[] | Associated observations |
tags |
string[] | Associated tags |
relations |
Relation[] | Outgoing relations (optional) |
| Field | Type | Description |
|---|---|---|
from |
string | Source entity name |
to |
string | Target entity name |
type |
string | Relation type |
metadata |
object | Optional JSON metadata |
All tools return errors in a standard format:
{
"content": [{"type": "text", "text": "error message"}],
"isError": true
}Common errors:
- Unknown tool name
- Zod validation failure (missing required fields, invalid types)
- Entity not found (for relations in
remember)
Root-level Zod issues return only the message. Field and unknown-key issues are prefixed with their path.
Start: memesh serve (default: localhost:3737)
Safety note: non-loopback binds are blocked by default. To expose the HTTP server beyond the local machine, you must pass memesh serve --host 0.0.0.0 --allow-remote or set MEMESH_HTTP_ALLOW_REMOTE=true.
Authentication on a remote bind. A non-loopback bind requires a bearer token on every /v1 request — MeMesh generates one before it starts listening, so there is no unauthenticated window:
| Header | Authorization: Bearer <token> |
| Token file | ~/.memesh/remote-token, mode 600, printed at startup |
| Override | MEMESH_REMOTE_TOKEN |
| Rotate | Delete the token file and restart |
The requirement is keyed to the bind address, not to the flag. --allow-remote on the default loopback host generates no token and requires no auth — the server is reachable only from this machine, and it says so at startup. Loopback requests are never challenged, even while a remote listener is running: the check is per-listener.
This is transport authentication only. It does not authorise individual callers or separate their data — everyone holding the token sees the whole graph.
All POST /v1/* endpoints enforce a 1 MB request body limit. Requests larger than this receive a structured 413 Payload Too Large response:
{
"success": false,
"errorCode": "payload.too-large",
"error": "Request body exceeds the 1MB limit",
"code": "PAYLOAD_TOO_LARGE",
"limit": "1mb",
"hint": "Split large exports/imports into smaller batches, or stream them via the CLI (`memesh export` / `memesh import`) which reads/writes files directly and is not subject to the per-request 1MB cap."
}The limit protects the server from accidentally parsing large payloads (e.g. an unbounded /v1/import with a multi-MB JSON bundle) under memory pressure. For bulk operations that exceed 1 MB, prefer the CLI: memesh export > bundle.json and memesh import bundle.json read and write files directly without buffering the whole payload through Express's body parser, so they have no per-request size cap.
| Method | Endpoint | Description |
|---|---|---|
| GET | /v1/health | Health check + version + entity count |
| GET | /v1/doctor | Run the full doctor check suite; secrets in the result are redacted before the response leaves the server |
| POST | /v1/doctor/fix | Apply one explicitly selected, recoverable doctor repair and return a fresh readback |
| POST | /v1/remember | Store knowledge |
| POST | /v1/recall | Search knowledge; with neither query nor tag it lists recent entities |
| POST | /v1/forget | Archive or remove observation |
| POST | /v1/consolidate | Retired — answers 410 Gone. Use the MCP work_package flow from an already-running agent session. |
| POST | /v1/export | Export memories as JSON bundle |
| POST | /v1/import | Import memories from JSON bundle with merge strategy |
| POST | /v1/learn | Record structured lesson from mistake or discovery |
| POST | /v1/message | Run one durable-message lifecycle action using the same schema as the MCP message tool |
| POST | /v1/why | File attribution: join caller-resolved commit hashes to commit entities, their sessions, and file-tag memories |
| GET | /v1/entities | List entities (pagination); supports ?type=<type> and ?limit=<n> |
| GET | /v1/entities/:name | Get single entity |
| GET | /v1/config | Get current supported non-model config fields |
| GET | /v1/update-status | Current/latest package version, freshness state, and update guidance |
| POST | /v1/config | Save supported non-model config fields as a partial update |
| GET | /v1/stats | Aggregate counts: entities, observations, relations, tags; type/tag/status distributions |
| GET | /v1/analytics | Health score/factors, memory-loop metric, criticalLessons, citationCompliance, 30-day timeline, ageMatrix, knowledgeRadar |
| GET | /v1/analytics/pm | Project-management velocity, flow, operational signals, and recommendations |
| GET | /v1/patterns | User work patterns: schedule, tools, focus areas, workflow, strengths, learning |
| GET | /v1/dream/proposals | List staged proposals for human review |
| GET | /v1/dream/proposals/:id | Read one proposal and its retained evidence detail |
| POST | /v1/dream/proposals/:id/accept | Human review action: accept and apply one pending proposal |
| POST | /v1/dream/proposals/:id/reject | Human review action: reject one pending proposal |
| POST | /v1/verify | Retired — answers 410 Gone. Removed with the agentic-orchestration experiment. |
| POST | /v1/demo/seed | Insert the demo tour dataset (entities tagged metadata.demo = true) |
| POST | /v1/demo/reset | Remove every demo entity; all-or-nothing transaction |
| GET | /v1/projects | Distinct projects from project:* tags and name-prefix heuristics, with per-project counts |
| GET | /v1/task-state | The owner-stated task state of one project (memesh task); requires the project query parameter |
| GET | /v1/briefing-index | The durable-memory index of one project (the section briefing closes with at standard/full); requires the project query parameter |
All responses: { success: true, data: ... } or { success: false, errorCode: "...", error: "..." } |
Every success: false envelope carries a machine-readable errorCode alongside the human error string. The error text is English prose and may be reworded in any release; errorCode is the stable contract — clients (the dashboard translates known codes into the UI locale) should branch on it instead of matching English sentences. Removing or renaming a code is a breaking change; adding one is not.
errorCode |
HTTP status | Meaning |
|---|---|---|
auth.missing-bearer |
401 | No (or blank) Authorization: Bearer <token> header on a remote-bound listener |
auth.invalid-token |
401 | A bearer token was presented but did not match |
auth.not-configured |
503 | Remote listener is up but no token was provisioned (server misconfiguration) |
auth.cross-origin |
403 | The request came from another site, or reached a loopback listener under a non-loopback Host (see The origin boundary below) |
validation.bad-body |
400 | Request body missing, not valid JSON, or failed schema validation |
validation.bad-param |
400 | A path or query parameter is invalid |
route.retired |
410 | Endpoint retired on purpose; the error text names the replacement |
route.not-found |
404 | No such route (the legacy code: "NOT_FOUND" field is also kept) |
resource.not-found |
404 | Route exists, but the named entity / proposal does not |
payload.too-large |
413 | Body exceeds the 1 MB limit (the legacy code: "PAYLOAD_TOO_LARGE" field is also kept) |
operation.failed |
400 | The request was well-formed but the operation itself rejected it |
operation.permission-denied |
500 | An explicit local repair could not write its required config or plugin files; the response contains fixed, path-free recovery guidance |
server.internal |
500/503 | Unexpected server-side failure |
The default listener binds to 127.0.0.1 and requires no authentication — the
boundary is meant to be "only this machine". A browser is on this machine, so
that is not enough on its own: a page on any site the user happens to visit can
submit a form to http://127.0.0.1:3737/v1/demo/reset without a preflight, and
the handler would run. The browser blocks the attacking page from reading the
reply, which hides the result rather than preventing it.
Every /v1/* request is therefore checked before anything else runs:
Sec-Fetch-Site— set by the browser and unsettable from page script.same-origin(the dashboard) andnone(a typed URL or bookmark) pass;cross-siteandsame-siteanswer403 auth.cross-origin.Origin— the fallback for a browser that sends noSec-Fetch-Site. It must match theHostthe request arrived on.Host— on a loopback listener it must be a loopback name. An attacker who pointsevil.exampleat127.0.0.1(DNS rebinding) makes the browser reportsame-origin; theHostheader is what still names them. A listener bound remotely is exempt from this one, because it requires a bearer token that no browser attaches on its own.
Non-browser clients — the CLI, the MCP server, curl, your scripts — send none
of these headers and are unaffected. Anything able to set headers freely is
already running locally, where it could open the database directly.
Returns the current supported non-model configuration fields that are present.
Capability diagnosis belongs to GET /v1/doctor, not this response.
Response:
{
"success": true,
"data": {
"config": {
"autoCapture": true,
"autoUpdate": "minor",
"sessionLimit": 20,
"setupCompleted": true,
"briefing": "standard"
}
}
}Dashboard locale is browser-local UI state and is not part of this server configuration.
sessionLimit (#431) — a whole number from 1 to 100, the top-N recent
memories the SessionStart hook injects. POST /v1/config rejects anything
outside 1-100 with 400; memesh config set sessionLimit rejects it too,
exiting 1. GET /v1/config does not re-check a stored value — it is a
number an older/newer memesh, or a hand-edited config.json, could have left
outside 1-100 — so it simply returns the stored number, unfiltered.
The SessionStart hook resolves what it actually uses: above 100, it uses
100; below 1, or not a whole number, it falls back to the default of 10
(same as nothing being stored). Whenever it had to adjust the value, it
records why (one hook-outcome entry per adjusted source, memesh doctor
surfaces these). memesh config list and memesh config get sessionLimit
show this same effective value and reason, e.g. 500 (above 100; the SessionStart hook uses 100), or 0 (below 1; the SessionStart hook uses the default 10). When MEMESH_SESSION_LIMIT is the adjusted source it is named
plainly, unquoted, e.g. 1000 from MEMESH_SESSION_LIMIT (above 100; the SessionStart hook uses 100) or abc from MEMESH_SESSION_LIMIT (not a whole number; the SessionStart hook uses 25); when both the env value and the
stored value are out of range, both are named, briefly, ending on the one
effective value.
briefing (#360) — minimal (default) | standard | full — controls how
much of the SessionStart / briefing tool block is assembled; see the
briefing levels table under the briefing MCP tool above. MEMESH_BRIEFING
overrides it, same precedence as MEMESH_AUTO_UPDATE below. Unlike
autoUpdate, an unrecognised stored value is passed through by GET /v1/config (still 200, with the raw stored value — whatever its JSON
type: a string, a number, a boolean, null, an array, or an object — not
narrowed to a string) rather than silently dropped or causing the read
itself to fail (resolveBriefingLevel is where it is validated and
reported, not the config reader, and not this route) — POST /v1/config
still rejects anything that is not one of the three known level strings
outright with 400 (z.enum).
autoUpdate controls the maximum permitted bump, not unattended consent. On a
supported npm-global install, the first MeMesh use in a session requests a
host-mediated consent prompt once; an explicit Upgrade records
session-scoped consent and Not now records a decline. The Stop hook
dispatches only after affirmative consent. Project-local, source-checkout, and
marketplace installs receive a channel-specific update action instead; they are
never described as self-updating when the hook cannot safely install them.
MEMESH_AUTO_UPDATE overrides the configured bump limit, but never bypasses
this consent gate.
Returns the current package version, the latest npm version MeMesh knows about, freshness metadata for the last update check, and install-channel-aware update guidance.
Use ?cached=1 to read the cached state only. Without it, MeMesh prefers a fresh npm lookup and falls back to the cached state when npm is unavailable.
Response:
{
"success": true,
"data": {
"currentVersion": "4.9.0",
"latestVersion": "4.9.0",
"checkedAt": "2026-09-07T10:15:00.000Z",
"lastAttemptAt": "2026-09-07T10:15:00.000Z",
"lastSuccessfulCheckAt": "2026-09-07T10:00:00.000Z",
"lastError": "npm unavailable",
"updateAvailable": false,
"checkSucceeded": false,
"source": "cache",
"freshness": "cached",
"installChannel": "source-checkout",
"canSelfUpdate": false,
"recommendedCommand": null
}
}Freshness values:
fresh: latest version came from a successful live npm lookupcached: using the last successful cached resultstale: using a cached result whose last success is older than the freshness thresholdunavailable: no successful update check has been recorded yet
Save a partial config update. Fields not provided are preserved.
Request body: Any supported subset of the non-model MeMeshConfig fields
(autoCapture, sessionLimit, autoUpdate, setupCompleted, briefing). Unknown fields
are rejected. Dashboard locale is stored in the browser and is not sent here.
Response: { success: true, data: <updated config> }. Clients that present
a persisted-success state should follow with GET /v1/config and render that
authoritative readback. The response is a read of what is stored, like GET:
a stored briefing that is not a known level (only writes are checked against
the level list) is returned as it is and does not turn a change to another
field into a 400.
Returns aggregate counts and distributions for the knowledge graph.
Response:
{
"success": true,
"data": {
"totalEntities": 42,
"totalObservations": 128,
"totalRelations": 15,
"totalTags": 30,
"typeDistribution": [{"type": "decision", "count": 12}, ...],
"tagDistribution": [{"tag": "project:myapp", "count": 8}, ...],
"statusDistribution": [{"status": "active", "count": 40}, {"status": "archived", "count": 2}]
}
}What the owner stated about one project with memesh task — goal, next,
blocked, done — read from the project's task-state entity, plus the
updated_at of the last statement. Fields that were never stated are absent,
not empty strings: the dashboard's Project tab renders an absent field as "not
stated" and never derives progress from memory counts (#237). project is
required (400, validation.bad-param without it); a project with no
statement is a 200 with state: {}.
Response:
{
"success": true,
"data": {
"project": "memesh",
"state": { "goal": "Ship 4.10.0", "next": "Merge #317", "updated_at": "2026-09-10T09:04:21.830Z" }
}
}The durable-memory index for one project — the same section the briefing
tool and the SessionStart block close with at standard/full (see
briefing for selection, redaction and the frozen caps). The dashboard's Project tab renders
it. project is required (400, validation.bad-param without it); a project
with no durable memories is a 200 whose lines carry the empty-state line.
staleDays is the staleness window, sent so a client does not restate it.
Response:
{
"success": true,
"data": {
"project": "memesh",
"staleDays": 180,
"lines": ["Index of durable memories for \"memesh\" (newest first):", "- [decision] Keep the index capped [mem:41]", "(index cost: 1 line, 172 bytes ≈ 43 tokens; cap 40 lines / 3072 bytes)"],
"shown": 1, "more": 0, "older": 0, "truncated": false, "bytes": 172, "tokens": 43, "ids": [41]
}
}Returns computed analytics insights for the memory database.
Response:
{
"success": true,
"data": {
"healthScore": 72,
"healthFactors": {
"activity": { "score": 20, "weight": 30, "detail": "2/3 active entities accessed in last 30 days" },
"quality": { "score": 24, "weight": 30, "detail": "4/5 active entities with confidence > 0.7" },
"freshness": { "score": 8, "weight": 20, "detail": "2 new entities this week" },
"lessons": { "score": 20, "weight": 20, "detail": "5 lessons learned" }
},
"criticalLessons": { "critical": 2, "severityTagged": 6, "total": 14 },
"citationCompliance": null,
"timeline": [
{ "date": "2026-09-01", "created": 5, "recalled": 12 }
],
"loopMetric": {
"reusedThisWeek": 12,
"trend": [ { "date": "2026-04-01", "count": 3 } ],
"computedFrom": "last_accessed_at_approximation"
},
"ageMatrix": [
{ "type": "lesson_learned", "bucket": "week", "count": 3 },
{ "type": "decision", "bucket": "month", "count": 8 }
],
"knowledgeRadar": [
{ "axis": "lessons", "count": 57, "types": ["lesson_learned", "lesson", "mistake"] },
{ "axis": "decisions", "count": 28, "types": ["decision", "architecture_decision", "design_decision"] }
]
}
}
valueMetrics,recallEffectiveness, andcleanupwere removed — they were computed on every request but never rendered by any dashboard component. The dashboard readshealthScore,healthFactors,loopMetric,criticalLessons,citationCompliance,timeline,ageMatrix, andknowledgeRadar.
Health Score Algorithm:
- Activity (30%): percentage of active entities accessed in last 30 days
- Quality (30%): percentage of active entities with confidence > 0.7
- Freshness (20%): new entities this week as a fraction of all active entities, capped at 100% (
min(newThisWeek / totalActive, 1)insrc/core/analytics.ts; this line previously said "relative to 5% of total", a formula the code never used) - Lessons (20%): lesson_learned entity count, 5+ gives full score
Runs the same check suite as memesh doctor and returns the structured result. Any secret-shaped substring (for example bearer tokens) is redacted before the response leaves the server.
Response: { "success": true, "data": { ...doctor result... } }, or 500 with { "success": false, "error": "..." } if the suite itself failed to run.
The result carries a capture object next to checks whenever the
capture-liveness check could run — the same figures as memesh doctor --json,
described under memesh doctor — capture liveness.
Applies one repair identified by a current doctor check's id. The route
re-runs doctor before changing anything, so a stale Dashboard cannot apply a
repair to a different condition. It currently supports only recoverable
actions: removing known retired top-level config keys after creating a
byte-for-byte backup, and refreshing a stale Claude Code or Codex plugin cache
through the host's existing updater. The request is never triggered by a GET
or by loading the Dashboard; it requires an explicit user action. Plugin
refresh returns restartRequired: true because the host must reload the cache.
Request: { "id": "config" } or a host-specific plugin-cache-* check id.
Response: { "success": true, "data": { "action": ..., "before": ..., "after": ..., "restartRequired": false } }.
The response is path- and secret-redacted like GET /v1/doctor.
Lists distinct projects extracted from entity tags (project:*) and entity name prefixes. The dashboard's Memories and Project tabs use it to populate the project chips.
Response:
{
"success": true,
"data": [
{ "name": "memesh", "count": 421, "types": ["decision", "lesson_learned"], "source": "mixed" }
]
}source says how the assignment was made: an explicit project: tag, the name-prefix heuristic, or both.
Back the dashboard onboarding banner: seed inserts the demo tour dataset (every entity carries metadata.demo = true), reset removes exactly those entities in one all-or-nothing transaction, routed through the knowledge-graph delete so the FTS index stays consistent. The CLI equivalent is memesh demo.
Response: { "success": true, "data": { "inserted": 12, "removed": 0 } } — counts of demo entities written or removed.
Returns PM-framed metrics: decision velocity, knowledge-graph connectedness, and staleness indicators. Designed for the dashboard PM Analytics panel.
Query parameters:
| Parameter | Type | Default | Description |
|---|---|---|---|
window |
number | 30 | Lookback window in days for velocity calculations |
Response:
{
"success": true,
"data": {
"velocity": {
"decisionsPerWeek": 2.1,
"releasesPerMonth": 0.5,
"windowDays": 30
},
"staleness": {
"stalePlanCount": 1,
"openDecisionCount": 3
},
"connectedness": {
"orphanRate": 0.117,
"totalRelations": 2970,
"activeEntities": 1326
}
}
}stalePlanCount: activeplanentities not accessed in 30+ daysopenDecisionCount: activedecisionentities created more than 14 days ago and not yet supersededorphanRate: fraction of active entities with zero relations (lower = better connected KG)
The graph half of memesh why (see the CLI section): join commit hashes to
the commit entities the hooks captured, walk each entity's
metadata.session_id to its session entities, and collect the memories
associated with the file by file:<basename> tag.
The route runs no git, ever — commit hashes come from the caller, and
the strict schema has no repo-path field on purpose: the server is never
handed a directory to execute anything in. A caller without a working tree
(e.g. the dashboard) omits commits and gets the file-tag half, plus the
no_commits_supplied abstention saying so — omitting the field is a gap in
the question, and the response must not look like the answer "this file has
no remembered commits". A caller that resolved commits itself and found none
sends "commits": [] and gets no such abstention.
{
"file": "src/auth.ts", // required
"commits": ["<hex sha, 7-40>"], // optional, max 50 — resolved by the caller
"project": "myapp", // optional scope for the file-tag half
"limit": 10 // optional, 1-50
}Response data:
{
"file": "src/auth.ts",
"basename": "auth.ts",
"project": "myapp",
"commits": [
{
"commit": { "hash": "…" },
"entity": { "id": 12, "name": "commit-abc1234", "observations": ["…"], "…": "…" },
"session": { "session_id": "…", "entities": [ { "name": "session-…-files", "…": "…" } ], "truncated": false },
"abstentions": []
}
],
"file_memories": { "basis": "file-tag", "entities": [ … ] },
"abstentions": []
}session.truncated is true when the session held more than 200 entities
and only the first 200 were returned — a ceiling with an in-band flag, because this query runs once
per commit and the schema accepts 50 of them, so the response needs both a
ceiling and a way to say the ceiling was hit.
Every gap in the chain is a typed abstention, never a guess:
no_commit_entity (the graph has no memory of that hash — it predates
capture, or was made without hooks / on another machine) and
no_session_link (the commit entity was captured before commits recorded
their session id) appear per commit; no_commits_supplied (the request
carried no commits field at all) and the git-side codes (not_a_git_repo,
file_not_tracked, git_unavailable, history_unreadable,
line_out_of_range, line_uncommitted) appear in the top-level
abstentions — the git-side ones only from the CLI, which resolves commits
locally and passes its own abstention through. history_unreadable means
git log did not answer (its output outgrew the read buffer, it exceeded the
5-second timeout, or the repository has no commits yet): the empty commit
list under that code means unknown, never none. The file_memories block is
labelled basis: "file-tag" because it is associated by basename tag —
not derived from the commits — and the two must not be read as the same
kind of evidence.
Returns the full interactive MeMesh Dashboard as a self-contained HTML page. Served by the HTTP server — no separate build step needed.
Usage: Run memesh serve (prints the dashboard URL), then open http://localhost:3737/dashboard in a browser. Bare memesh with no subcommand prints the command list.
Request/response bodies for POST /v1/remember, /v1/recall, /v1/forget, and /v1/message mirror the MCP tool schemas above (same field names, same types). HTTP responses wrap results as { "success": true, "data": ... }.
POST /v1/message supports every message action above. A waiting poll request ends when a targeted event arrives, the bounded timeout expires, or the HTTP request is cancelled. The server removes the wait listener when the request closes.
Example:
# Start the server
memesh serve
# Store knowledge
curl -s -X POST http://localhost:3737/v1/remember \
-H 'Content-Type: application/json' \
-d '{"name":"auth-decision","type":"decision","observations":["Use OAuth 2.0"]}'
# Search knowledge
curl -s -X POST http://localhost:3737/v1/recall \
-H 'Content-Type: application/json' \
-d '{"query":"auth"}'
# Health check
curl -s http://localhost:3737/v1/healthThe CLI exposes the same local lifecycle as the MCP and HTTP message surface:
| Command | Purpose |
|---|---|
memesh message send |
Durably send one exact-recipient untrusted JSON payload (64 KiB max); exact-session native envelopes have a separate 16 KiB cap and report native_message_too_large distinctly. --intended-session <id> and --fallback-to-principal are the send fields of the same names |
memesh message watch |
Emit ready, then one bounded events or timeout JSONL record with next_cursor |
memesh message fetch |
Fetch one authorized payload without acknowledging it |
memesh message intake |
Record fetched or ingested |
memesh message ack |
Record explicit acknowledgement |
memesh message disposition |
Record workflow disposition independently from ACK |
memesh message activation |
Record host activation independently from ACK/disposition |
memesh message receipts |
Read receipt facts for an authorized message |
Run memesh message <command> --help for flags. watch returns after one bounded batch; the caller persists the opaque cursor and owns restart/backoff policy.
For CLI send, the initial payload is stdin-only so it does not leak through that command's process listing or shell history. --payload is deliberately rejected. When the recipient is a native Codex session, Codex currently accepts message text only through its own --message argument, which same-user process inspection may observe while the short-lived queue command runs; do not put secrets in native agent messages.
printf '%s' '{"kind":"handoff","text":"review ready"}' | memesh message send \
--project demo --sender author --recipient reviewer --target-kind session \
--idempotency-key handoff-1 \
--content-type application/json --payload-stdin--target-kind accepts principal (the default) or session on both send and fetch. An exact-session payload must be fetched with the same target kind and is never exposed through a principal fetch.
Build a pre-filled public GitHub issue for a bug, feature request, or question:
memesh feedback --bug --message "Brief reproduction"
memesh feedback --feature
memesh feedback --question --no-diagnostics
memesh feedback --bug --no-openUnless --no-diagnostics is used, the body includes a redacted doctor report,
runtime metadata, and the anonymous local install ID. MeMesh prints the exact
public body before opening the browser; the user reviews and submits the GitHub
form. --no-open prints only the pre-filled URL and does not launch a browser.
MeMesh never submits the issue automatically. There is no MCP report_issue
tool and no HTTP report-issue endpoint. The improvement MCP tool remains a
separate private, human-governed product-proposal workflow.
memesh remember "<text>" alone (no --obs, --title or --name) is the
note form: title, observations and name are derived from the text and
validated exactly as for remember({ note }) above (the same 20,000-character
and 100-observation caps), and the output echoes the derived title. --type
and --tags apply.
--obs or --title alongside the text take a second path that keeps the
text as an observation and adds theirs, rather than replacing it —
positional text is never dropped, an explicit --title wins over the
derived one, and --obs values are appended after the text's own paragraphs.
This path is validated too, against the same 100-observation cap. Both paths
count the same unit — observations, never paragraphs, because a paragraph made
only of list items yields one observation per item and a single paragraph can
exceed the cap on its own. What differs is only what each one has to count:
the note form counts the observations the text derives ("note yields N
observations"), while the combined path counts the final observations array
it would store, the text's own plus every --obs ("that is N observations").
Measured with one 103-line text (one line becomes the title, 102 remain):
alone it is rejected — "note yields 102 observations; at most 100 are stored
per memory" — and combined with --obs "extra one" (103 observations total)
it is also rejected — "that is 103 observations; at most 100 are stored per
memory."
--replace (requires --name) rewrites the named memory and keeps its
previous version in metadata.replaced_history, as described under
Replace above. Correcting a memory this way does not need --type:
the memory keeps the type it has. Pass --type only to reclassify — a type
that differs from what is stored rewrites it, so --replace doubles as how
you reclassify a memory. --type is still required when --name is used
without --replace, and on a --replace whose name does not exist yet,
where there is no stored type to keep.
memesh remember "Use PKCE for the public client" # derived name, type note
memesh remember --name auth-choice --obs "PKCE, not implicit" --replace # keeps type
memesh remember --name auth-choice --type decision --obs "PKCE, not implicit" --replace # reclassifiesmemesh import <file> [--merge skip|overwrite|append] [--namespace <ns>] [--restore-archived] [--trust [--yes]]| Flag | Meaning |
|---|---|
--merge <strategy> |
skip (default), overwrite or append — see import under Tools |
--namespace <ns> |
Force imported entities into this namespace |
--restore-archived |
Requires --merge append or --merge overwrite (an error with skip, the default): bring back a local memory you archived (forgot) when the file names it. Without it, that memory stays archived and untouched |
--trust |
For restoring your OWN backup only — never for a file someone else gave you. Also marks every imported memory trusted, so it is injected into new sessions the way your own memories are. Behind a confirmation prompt ([y/N]) unless --yes is given; any other answer writes nothing (same convention as memesh setup). Refused together with --notes. On append, an entity that already existed keeps its own trust either way with --trust — without it, append marks it untrusted as it always has |
--yes |
Skip the --trust confirmation prompt. Refused without --trust. Without a terminal and without --yes, --trust is refused (exit 1, nothing written) |
The summary line is unchanged. When the file named archived memories that were
left alone, a second line follows — Kept archived: N (…) — with the flag that
brings them back. --restore-archived is refused together with --notes, and with --merge skip.
Without --trust, a plain import names how many rows it imported as
untrusted and, separately, how many existing memories it appended text to
(also now untrusted). It suggests memesh import <file> --merge overwrite --trust only when nothing else was skipped or appended, since that would
replace their local content too. With --trust, the summary adds only what
a plain import wouldn't already show: how many appended rows kept their own trust.
memesh import --notes ~/.claude/projects/<slug>/memory [--project <name>] [--json]Ingests every *.md file under the directory that opens with YAML frontmatter
carrying a name (Claude Code's per-project memory files have this shape):
one memory per file, name from frontmatter, title from description, type
from metadata.type (default note), observations from the body paragraphs,
tagged source:note-file and project:<name> (default: the current directory's
project). Provenance records note_path relative to the directory, a
SHA-256 content_hash of the file, and a digest identifying the directory —
never an absolute path.
- A changed file replaces its memory (the previous version goes to
metadata.replaced_history); an unchanged file is a no-op. - A file without frontmatter or without
nameis reported and skipped, not guessed at. (The.remember/handoff files have no frontmatter, so they are reported, not ingested.) - A file that disappears does not delete its memory: the memory is tagged
source:note-file:missing. Deleting stays an explicitforget; a memory archived withforgetis not revived by a later edit of its file. - A name already used by a memory that did not come from a note file, or that
was ingested from a different directory, is skipped rather than overwritten.
Within one directory, a name belongs to exactly one file, decided in this
order: the file the memory records (
provenance.note_path) when it is still there and still declares that name; otherwise a file whose bytes match the recordedcontent_hash(the recorded file was renamed); otherwise the first claimant in path order. Every other claimant is reported and left alone, and takes the name over only once the owner releases it. Names are cleaned like the body, so two names that differ only in a redacted credential collide and are reported as duplicates. - A renamed file keeps its memory: the next run re-points
note_pathand does not tag it missing. The bytes are what the memory stores, so a move alone writes no new version (repeated renames therefore cannot push the real history out of the 20 kept versions). A file coming back after being reported missing loses the tag — unless another file claimed the name while it was away, in which case the returning file is the duplicate and is reported as one. - A file that changes the
namein its frontmatter leaves the old memory behind, taggedsource:note-file:missinglike a vanished one, and creates the memory its new name asks for. A file that stops being a note file at all frees its name the same way, and creates nothing. A freed name is taken over by another file on whatever run that file turns up, cap or no cap: the missing tag is what says the name is nobody's, so it holds across runs (within a single run the handover can happen before the tag is written), and a name no memory uses is free for the asking. - A file that already has a stored memory is unchanged when its size, modification time and inode all still match — the inode is what catches two files that swap places without changing either size or timestamp. A file with no stored memory yet (skipped for its own content, or never read) has no inode on record to compare, so it is fingerprinted by size and modification time only, in the two bullets below.
- On a file change the file owns the
source:*tags; any other tag a person added is kept, and theproject:tag set on first ingestion stays. A memory a manualrememberappended to is still replaced as a whole on the next file change — the appended lines go tometadata.replaced_history. - A file skipped for its own content (no frontmatter, no name, empty, too large) is remembered by size and mtime and reported again without being re-read, until it changes.
- Read-only and bounded: symlinks and paths resolving outside the directory are
refused,
.gitandnode_modulesare not entered, files over 256 KB are skipped, and one run reads at most 500 files (the rest are reported as "more" and picked up by the next run; unchanged files are recognised from their size and mtime without being read). Credential-shaped text is redacted. A note file splits into observations exactly as anotestring does, including the silent truncation described underremember.
Under Claude Code the Stop hook runs the same ingestion on the memory directory
next to the session transcript, throttled by mtime and capped at 100 file reads
per Stop; it honours autoCapture off. There is no MCP or HTTP form.
The two relation types that change behaviour have their own flags, because they are the two worth typing:
| Flag | What it does |
|---|---|
--supersedes <name...> |
Archives the named entity immediately. Recoverable — nothing is deleted — and reported as archived as superseded: <name>. |
--contradicts <name...> |
Both memories surface as a conflict every time either is recalled (see recall → Conflict detection). |
memesh remember --name auth-v2 --type decision --obs "Sessions, not JWT" --supersedes auth-v1
memesh remember --name no-jwt --type decision --obs "JWT is out" --contradicts use-jwt
memesh recall jwt # → Warning: Conflicts detected: "no-jwt" contradicts "use-jwt"A relation whose target does not exist is reported on stderr and exits 1:
the consequence you asked for did not happen, so the command does not claim it
did. Free-form relation labels are MCP/HTTP only — as a tag with extra steps,
they have no CLI flag.
memesh doctor has a capture-liveness row that answers "has the automatic
memory layer saved anything lately, and if not, why not". memesh doctor --json
(and GET /v1/doctor) carry the evidence under a top-level capture field:
{
"status": "PASS_WITH_CONCERNS",
"hooks": [
{
"hook": "post-commit", "runs": 20, "triggeredRuns": 5, "writes": 0,
"skips": 20, "errors": 0, "notifies": 0,
"lastRunAt": "2026-09-08T00:00:00.000Z", "firstTriggeredAt": "2026-09-04T00:00:00.000Z",
"lastWriteAt": null, "lastNotifiedAt": null, "lastEntity": null, "lastSkipReason": "a git commit ran but printed no commit line",
"dominantSkipReason": "a git commit ran but printed no commit line", "dominantSkipCount": 5,
"hosts": ["claude-code"], "silent": true
}
],
"types": [{ "type": "commit", "last7": 0, "prev7": 31, "stopped": true }],
"neverRan": []
}hooks— one summary per hook, over its last 20 triggered outcome records plus its last 5 not-triggered ones (so a flood of irrelevant runs cannot push the evidence out).runscounts every record in that window;triggeredRunsleaves out skips where the hook's trigger did not apply (post-commit on a Bash call that is not a git commit).silentis true only for post-commit, session-summary, pre-compact and handoff-capture, whentriggeredRunsis at least 5 andwritesis 0.notifiescounts runs that told someone something and stored nothing, sorunsis notwrites + skips + errors. It does not rescue a hook fromsilenteither, and none of these hooks thatsilentapplies to ever notifies.types— auto-capture entities per type, this week (last7) against the week before (prev7);stoppedmeans the type wrote last week and nothing this week. The single per-projectsession-handoffentity is excluded from this trend because each Stop replaces the same row.neverRan— session-summary when it has neither an outcome record nor a heartbeat 72 hours after tracking began.
If the first silent-hook finding is handoff-capture repeatedly skipping an
archived handoff, Doctor explains that reinstalling hooks will not revive it.
When the inspected outcome window identifies exactly one complete,
shell-safe handoff name, it offers a memesh remember --name … --type session-handoff --obs restart command; otherwise it directs you to find the
exact archived name with memesh recall session-handoff --include-archived
before restoring it. Doctor inspects a bounded recent outcome window, so a
missing warning does not prove every project's handoff is current.
status is FAIL for neverRan, PASS_WITH_CONCERNS for a silent hook, a
stopped type, or heartbeats with no outcome record at all past the grace
(capture-liveness.no-records), and PASS otherwise.
The figures come from hook-outcomes.jsonl beside the database (the directory
of MEMESH_DB_PATH, ~/.memesh by default): every capture hook appends one
JSON line per run — hook, at, host, outcome (wrote / notified /
skipped / error), and a reason or entity — on every exit path. A run
that printed something for a person or model to read and stored nothing
records notified. An error records a
label — uncaught <code or name>, or a fixed literal such as malformed stdin JSON — never the exception text. Records naming a hook
MeMesh does not ship are ignored, and reason text is stripped of control
characters and capped at 200 characters. Doctor quotes a skip reason only when
it is one the shipped hooks record; any other reason is shown as
unrecognised reason.
When capture has gone quiet, SessionStart adds one line to its banner
(memesh: post-commit ran 5 times since 2026-09-04 and wrote nothing — \memesh doctor` for the reason), at most once a day (last-capture-liveness-notice.lock), and not during the first 3 sessions or 24 hours after an install or upgrade, whichever ends later (capture-liveness-grace.json`). The line disappears once the hook writes again.
Owner-local settings in ~/.memesh/config.json; memesh config set --help names the keys.
| Command | Purpose |
|---|---|
memesh config list |
Every stored key that set accepts, one key: value line each ((nothing stored — all defaults) when none). briefing is always shown as the level in effect and its source — see Briefing levels under the briefing tool. |
memesh config get <key> |
One stored value, formatted as list formats it — except briefing, where it is what is stored, not the level in effect that list prints. A valid key with nothing stored prints <key> is not set in config.json and exits 0 (it says nothing about which value applies: an environment variable can still override the default). |
memesh config set <key> <value> |
Validate and store a value; an invalid value is refused with the accepted ones named. |
memesh config unset <key> |
Remove a stored value. |
An unknown key — for get, set and unset alike — prints Unknown key: <key> and Allowed keys: … to stderr and exits 1.
Rebuild the local FTS5 full-text index. MeMesh normally keeps this index current
automatically; use this recovery command after a downgrade or when
memesh doctor reports an FTS index mismatch.
memesh reindex --ftsThe command rebuilds keyword-search data only. It does not contact a provider, generate embeddings, or create vector data.
memesh why src/auth.ts # which commits touched this file, and what memesh remembers about them
memesh why src/auth.ts --line 42 # attribute ONE line via git blame instead of file history
memesh why src/auth.ts --limit 5 # cap the commits inspected (default 10)
memesh why src/auth.ts --json # the full structured result (same shape as POST /v1/why)Local git answers which commits touched the file (git log --follow, or
git blame for --line); the graph answers what memesh remembers about
them: the commit entity the post-commit hook captured, the session it was
made in (commits record metadata.session_id going forward), and the
memories associated with the file by file:<basename> tag — printed under
an explicit "associated, not commit-derived" label.
What the chain cannot prove is said outright, never guessed: a commit with
no entity ("memesh has no memory of this commit"), an entity with no
session link, an untracked file, a line not yet committed, and a history
git could not read at all (history_unreadable — the empty list means
unknown, not none). Run it from
inside the repository — the current directory picks both the git repo and
the project scope.
Protect an entity from digest work-package selection (or release that protection).
pin marks an entity so deterministic digest-package preparation skips it;
unpin removes the mark. Pinning writes metadata.pin = true and unpinning
removes the key. Neither command runs a digest job or stages a proposal.
Usage:
memesh pin --name "auth-architecture-decision"
memesh unpin --name "auth-architecture-decision"Options:
| Option | Description |
|---|---|
--name <name> |
Entity name (required). |
--json |
Output the result as JSON ({ name, pinned, found }). pinned is null when found is false — there is no pin state to report for an entity that does not exist, so the payload never claims one. |
If the named entity does not exist, the command reports it and exits with a non-zero status (found: false, pinned: null).
Archive an entity (soft-delete), or remove one observation. See forget above for the modes and the archive-not-delete guarantee.
Usage:
memesh forget --name "old-decision"
memesh forget --name "auth-notes" --observation "the exact observation text"Options:
| Option | Description |
|---|---|
--name <name> |
Entity name (required). |
--observation <text> |
Remove only this observation instead of archiving the entity. |
--json |
Output the result as JSON. |
The command exits 1 whenever nothing changed — the named entity does not
exist, or --observation names text that matched no observation on an entity
that does exist — and exits 0 only when something was actually archived or
removed. This holds for --json too: the JSON envelope alone (archived: false / observation_removed: false) is not a script-visible failure by
itself, so the exit code is the contract to check, same as pin/unpin
above.
Export MeMesh tools in OpenAI function calling format. Use this to integrate MeMesh with any OpenAI-compatible API or SDK.
Usage:
memesh export-schema
memesh export-schema --format openaiOptions:
| Option | Description |
|---|---|
--format <format> |
Output format. Currently only openai is supported (default: openai). |
Output: A JSON array of OpenAI function calling tool definitions:
[
{
"type": "function",
"function": {
"name": "memesh_remember",
"description": "Store knowledge as an entity with observations, tags, and relations.",
"parameters": { ... }
}
},
...
]The exported schema can be passed directly to the OpenAI tools parameter or any OpenAI-compatible API:
import json, openai
with open("schema.json") as f:
tools = json.load(f)
client = openai.OpenAI()
response = client.chat.completions.create(
model="gpt-4o",
messages=[{"role": "user", "content": "Remember that we use OAuth"}],
tools=tools,
)Or generate on the fly:
memesh export-schema | python -c "
import json, sys, openai
tools = json.load(sys.stdin)
# pass tools to your OpenAI call
"There is no first-party client library. The HTTP surface documented above is the
integration point: start memesh serve and call it with whatever your language
already has.
A Python SDK used to ship in this repository, and this page told you to
pip install memesh. It was never published — PyPI answers 404 for that name —
no workflow built it, no CI ran its tests, and it still called
POST /v1/consolidate, which has answered 410 Gone since 4.2.11. It is
removed rather than repaired: an unpublished client covering only seven of the
then-available HTTP routes
is a promise this project was not keeping.
Heuristic non-LLM relation backfill for orphan entities. Five rules:
- Tag co-occurrence: two active entities sharing ≥ 2 topical tags get a
related-toedge. Topical filter excludes auto-capture noise (session_end,auto_saved,commit,completed,lesson, etc.) to prevent cartesian explosion. - Project clustering: orphan lessons / decisions / bug-fixes / patterns in a project get a
belongs-to-projectedge to the most recent release / feature / architecture / plan in the same project. - Session co-occurrence (
--session-cooccurrence): high-signal orphans (signal_score ≥ 0.6) sharing asession:*tag get aco-creatededge. Eligible types: lesson_learned, decision, architecture, feature, bug_fix, etc. - Name-token similarity (
--name-tokens): orphans whose tokenized names share ≥ 3 content tokens or Jaccard similarity ≥ 0.50 get ashares-name-tokensedge. Stopword list excludes generic qualifiers and month abbreviations to prevent cartesian explosion. - Evidence links (on by default;
--no-evidence-linksdisables): evidence-layer captures — commits, session insights, session summaries — get anevidencesedge to the work item they support. Matched by exact session id (asession:*tag, ormetadata.session_idfor commits, which carry no session tag by design); with no session match, to the most recent same-project work item created BEFORE the capture. It is the recorded link from a capture to the work it supports; until this has run, a work item has no evidence edges at all. Unlike the other rules, its sources are evidence entities rather than orphans — a commit that already relates to something else is still evidence.
Usage:
memesh kg backfill-relations [--project <name>] [--dry-run] [--max-per-source <n>] \
[--min-shared-tags <n>] [--session-cooccurrence] [--name-tokens] \
[--min-jaccard <n>] [--all-rules] [--no-evidence-links] [--include-archived] \
[--reset-idempotency] [--json]Options:
| Flag | Default | Description |
|---|---|---|
--project <name> |
(all) | Restrict to one project |
--dry-run |
off | Preview proposals without writing |
--max-per-source <n> |
3 | Max edges per orphan |
--min-shared-tags <n> |
2 | Minimum overlapping topical tags for Rule 1 |
--session-cooccurrence |
off | Enable Rule 3: session co-occurrence |
--name-tokens |
off | Enable Rule 4: name-token similarity |
--min-jaccard <n> |
0.50 | Jaccard threshold for Rule 4 |
--all-rules |
off | Enable all five rules in one pass |
--no-evidence-links |
(Rule 5 is on) | Disable Rule 5: evidence → work-item links |
--include-archived |
off | Also process archived entities |
--reset-idempotency |
off | Clear the persistent "already-attempted" orphan cache (memesh_metadata.kg_backfill_processed_v1) before running, so every orphan is reconsidered |
--json |
off | Output as JSON |
Idempotency: re-running this command is cheap by default — orphan IDs considered in a prior run are remembered in memesh_metadata and skipped on subsequent runs. Use --reset-idempotency after a schema change or when you want every orphan reconsidered from scratch. The output summary reports idempotency: skipped N orphans so you can see how many were filtered.
Merge or rename a project across every entity and every durable agent message scoped to it. Automatic identities use <readable repo label>~<32 hex> and hash either a password-free remote locator or a native real path, preventing unrelated same-basename repositories from sharing an inbox. Standard GitHub HTTPS and SSH spellings converge; generic SSH retains its login, absolute-versus-home-relative path semantics, and literal .git suffix. Existing bare Git names and older non-Git <name>-<8 hex> values are not rewritten automatically: run with no flags to inspect the stored spellings, then use an explicit mapping when one old project has one unambiguous destination. An old basename that already mixed multiple repositories has no stored provenance from which MeMesh can safely split its rows; do not guess that migration.
Usage:
memesh kg rename-project # list all project tags + counts (writes nothing)
memesh kg rename-project --from tim --to TIM # dry-run preview (writes nothing)
memesh kg rename-project --from tim --to TIM --apply # commit (backs up the DB first)Options:
| Flag | Default | Description |
|---|---|---|
--from <name> |
— | Existing project name to rewrite. Omit both --from/--to to list all project tags. |
--to <name> |
— | New project name |
--apply |
off (dry-run) | Actually write the change. Backs up the whole database to backups/kg-before-rename-project-<timestamp>.db beside the database file (~/.memesh/backups/ by default) first, and prints the restore command. |
--json |
off | Output as JSON |
A project identity is half the key of a message inbox (project + recipient) as well as an entity tag, so renaming only the tags left every message behind in a scope nobody polls. The command reports and moves both, in one transaction, and a project carried only by messages — with no tagged entity at all — is still renameable. A message row whose destination scope already holds an equivalent row is left in place and counted rather than deleted.
Safety: dry-run is the default — nothing is written until --apply. Listing and the dry run open the database read-only: they change no data (like every open, they still take group and other access off the database files and refuse a -wal/-shm whose owner permissions opening would change), run no maintenance (such as the automatic confidence decay) and need an existing database: with none, listing prints "No MeMesh database yet" (exit 0) and a --from/--to dry run exits 1, and neither creates one. Argument refusals (only one of --from/--to, --apply with neither, an invalid or identical --to) exit 1 before the database is opened, even with --apply. Like any read of a database in write-ahead-log mode, they may create the empty -wal/-shm sidecar files beside it. Both also work on a database file or directory you cannot write to (a backup, a read-only mount): with no write-ahead log beside it (or an empty one), the file is read as immutable; with a non-empty one, they stop with a one-line error rather than miss its changes. The dry run does not estimate: it copies the database to a temporary folder (the operating system's temp directory, never the database's own folder), runs the real rename on the copy exactly as --apply would (pending migrations, the database's own indexes, triggers and collations included), prints those counts and deletes the copy (an interrupted preview, such as Ctrl-C or a kill, can leave the copy in the temp folder; it is owner-only and safe to delete). So the counts, including message rows left in place because the destination already holds an equivalent row, are the ones --apply produces. It needs free space in the temp directory about the size of the database; the copy takes longer the larger the database is. A write failure during --apply other than such a collision, or an update that a trigger silently ignores or undoes (each renamed tag and moved message row is read back), aborts and rolls back the whole rename with one error line. --from and --to naming the same project is refused: every carrier would count as a merge and lose its only project tag. On --apply a consistent copy of the database (including changes still in its write-ahead log) is written to the backups/ folder beside it before any mutation; if the backup fails, the command aborts without changing anything. Restore it with every memesh process stopped: sqlite3 <database> ".restore '<backup>'". The tags table has a UNIQUE(entity_id, tag) constraint, so an entity that already carries the target tag has its old tag removed (a merge) rather than getting a duplicate.
Review proposals that an agent or deterministic rule has already staged. These commands do not generate proposals and do not wake or dispatch an agent.
memesh dream list [--status <pending|applied|rejected|all>]
memesh dream show <id> [--json]
memesh dream accept <id>
memesh dream reject <id> [--reason <text>]show prints the complete proposal before review. accept and reject are
human-authority actions; an agent using work_package can only submit a pending
proposal or defer. The Dashboard exposes the same list, detail, accept, and
reject review surface without adding another queue or execution path.
A proposal has one of five kinds, and accept's effect is specific to each:
digestfrom a calendar cluster: creates one digest entity from the proposal's source memories and archives those sources.digestfrom a transcript: purely additive — there are no source entities to archive.pattern_emergent: creates an entity and links its sources with anevidence_forrelation; the sources stay active, not archived.relation: creates one relation between two existing entities and changes nothing else — no new entity, nothing archived.guard: patches the source lesson's own metadata and creates no entity.product_improvement: creates a linked product-work entity and preserves its sources (not archived). Accepting the proposal is not a claim that the improvement was built or that it worked — implementation and outcome remain unverified until confirmed separately.
The write path of the Hermes Agent memory plugin
(extensions/hermes-memesh, see Hermes Agent).
Called by the plugin, not typed by a person. Input is one JSON object on
stdin — never on the command line, where every local process can read it —
and the result is one JSON line on stdout.
echo '{"messages": [...]}' | memesh hermes capture-session --session <id>
echo '{"user": "...", "assistant": "..."}' | memesh hermes capture-turn --session <id>| Option | Description |
|---|---|
--session <id> |
Hermes session id: 1-128 letters, digits, ., _, : or -. Required. |
capture-session runs the same rules as the Claude Code Stop hook over an
OpenAI-format message list (tool_calls on assistant messages, role: "tool"
results) and stores up to three session-insight entities:
session-<id>-files, session-<id>-fixes and session-<id>-summary. Fewer
than three tool calls stores nothing. A tool result counts as an error only
when its JSON says so (error, success: false, or a non-zero exit_code);
results that are not a JSON object are counted in toolResultsNonJson so a
host that returns plain text shows up as a blind spot, not as "no errors".
Shell commands and error text are redacted before they are stored. Running it
again for the same session adds only observations that are not already there.
capture-turn stores one conversation entity, tagged signal:decision or
signal:lesson, only when the assistant's reply states a decision or a
lesson (the user text is not classified: it can carry questions and the
injected recall block). A negated cue ("not decided yet") does not count.
Anything else stores nothing and reports {"outcome":"skipped"}. The name is a digest
of the turn text, so a retry does not add a second row.
Both stamp metadata.provenance.source_host: "hermes" and tag platform:hermes.
Bad input (not JSON, wrong shape, over 8 MiB, bad --session) exits 1 with
a message on stderr.
Record a task handed to an untrusted external worker, from the orchestrator's
side. --source names which worker/skill it went through (e.g. the DeepSeek
worker — see Delegate worker for a worked
example); it is caller-chosen and not limited to one fixed name.
memesh delegation record --envelope envelope.json --prompt-file prompt.txt --source <name> [--allow-tool <name> ...] [--verdict unreviewed|accepted|rejected] [--follow-up "<text>"] [--json]
memesh delegation verify <name> --verdict accepted|rejected [--note "<text>"] [--json]| Option | Description |
|---|---|
--envelope <file> |
The worker client's JSON envelope (record, required). It must be a JSON object with a boolean ok; at most 4 MiB. |
--prompt-file <file> |
The prompt that was sent (record, required). Only its sha256 is stored. |
--source <name> |
record, required. Which worker/skill this delegation went through, e.g. deepseek-worker. |
--allow-tool <name> |
record: a tool you granted the worker; repeat for each. This list is recorded as authoritative; if the envelope reports a different one, the mismatch is stored too. |
--verdict <verdict> |
record: unreviewed (default), accepted or rejected. verify: accepted or rejected (required). |
--follow-up <text> |
record: what you decided to do next, stored as one line. |
--note <text> |
verify: why, stored with the verdict. |
record stores one delegation entity named
delegation-<prompt sha256, 12>-<hash of --source + envelope, 8>, tagged
source:<--source value> and project:<current project>. The name's second
segment is not the plain envelope sha256 — it folds --source in, so the same
envelope recorded under two different sources gets two different names. To
find a record by the envelope's own hash, check
metadata.provenance.envelope_sha256 (the plain sha256), not the name. It
keeps the model,
mode (harness when the envelope has a task_id, otherwise direct),
the allowed tools (from --allow-tool, else the envelope's allowed_tools, else "not reported" — never a guessed "none"), usage, finish_reason, ok, and the verdict. It never
keeps the prompt text or the worker's output. metadata.provenance carries
source: "<--source value>" and trust: untrusted-until-verified until a
verdict is given, then verified or rejected; metadata.trust is
untrusted until the verdict is accepted. Recording the same envelope
again under the same --source writes nothing ("stored": false) and
reports the stored verdict; the same envelope under a DIFFERENT --source is
a separate record.
verify changes the verdict and trust in place, keeps every other
provenance field, and adds the verdict as a new observation. It refuses a
name that is not a delegation record.
There is deliberately no HTTP route or MCP tool for this: the only writer is the orchestrator's local CLI.
For applications that call the Messages API directly rather than through MCP. Claude gets a memory tool whose storage is MeMesh instead of a folder of text files, so it also gets search, ranking, decay, relations and namespaces without knowing they are there.
This is not one of the twelve MCP tools and is not exposed over HTTP or the CLI. The MCP surface serves an agent that already speaks MeMesh; this serves an application that speaks only the Messages API.
The tool is client-side: Claude only requests file operations, and your loop performs them.
import { handleMemoryCommand, MEMORY_TOOL_DEFINITION } from '@pcircle/memesh';
const message = await anthropic.messages.create({
model: 'claude-opus-5',
max_tokens: 2048,
messages,
tools: [MEMORY_TOOL_DEFINITION], // { type: 'memory_20250818', name: 'memory' }
});
for (const block of message.content) {
if (block.type === 'tool_use' && block.name === 'memory') {
const { content, isError } = handleMemoryCommand(block.input);
toolResults.push({ type: 'tool_result', tool_use_id: block.id, content, is_error: isError });
}
}handleMemoryCommand takes unknown and validates every field itself. The input comes from a model over the wire, so the declared schema describes what should arrive, not what does.
| Path | Is |
|---|---|
/memories |
The root. Lists the three namespaces. |
/memories/<namespace> |
personal, team or global. Lists that namespace's memories with type and tags. |
/memories/<namespace>/<name>.md |
One entity. Its lines are its observations. |
Entity names may contain /, so /, \ and % are percent-encoded in the filename and nothing else is — Project Apollo.md, not Project%20Apollo.md.
A file's content is the entity's observations joined by newlines, with no header — every line the model can count has to be a line it can also address, and a header would put an offset between "line 3" and "the third thing I remember".
Observations are ordered by observation id: insertion order, never score. This is the load-bearing choice. view and the edit that follows it are two separate turns, and between them a hook can write a new observation or access tracking can change a ranking. If the order the model saw came from a score, the line numbers it read would address different content by the time it sent them back — a silent wrong write, not an error.
An observation may itself contain newlines, so the line → memory map is computed from the rendered text rather than assumed one-to-one. insert_line: 2 pointing at the second line of a three-line memory inserts after that whole memory, not into the middle of it.
| Command | Parameters | Against the knowledge graph |
|---|---|---|
view |
path, view_range? |
Root → namespaces. Namespace → its active entities. File → observations with line numbers. |
create |
path, file_text |
Creates the entity, or overwrites its observations (tags are preserved). Refuses when the name is already taken in another namespace. |
str_replace |
path, old_str, new_str? |
Content-addressed edit. Omitting new_str deletes the text. |
insert |
path, insert_line, insert_text |
New observation after the memory owning that line. 0 prepends. |
delete |
path |
Archives the entity — never destroys it. |
rename |
old_path, new_path |
Renames the entity and reindexes it under the new name. |
Two behaviours worth stating because they differ from a filesystem:
deletearchives. The person whose memory it is did not ask for the deletion — a model did. From the model's side the file is gone (viewlists only active entities); from the user's side it is restorable.str_replacerefuses an ambiguousold_strrather than editing the first match, and returns the line numbers of every occurrence so the model can widen it. This is a write, and the wrong one is silent.
| Refused | Why |
|---|---|
Any path not under /memories |
Including /memories-of-you/…, which passes a naive startsWith check. |
.., ., empty segments, %2e%2e, \, NUL |
Nothing here touches a filesystem, so traversal cannot reach secrets.env — but it can resolve to a different namespace or memory than the one named, which is a silent wrong write. |
| More than two levels deep | The path space is exactly namespace/memory. |
A namespace that is not personal, team or global |
|
Writing to /memories or a namespace |
Those are directories. |
Deleting or renaming /memories or a namespace |
The contract tells Claude it cannot; this enforces it. |
| A rename onto a name taken in any namespace | Entity names are unique database-wide, so checking only the destination namespace would fail later on a UNIQUE constraint instead of returning the specified message. |
| A create onto a name taken in another namespace | Same uniqueness. Writing anyway appended to a memory at a different address than the one named — and, since an explicit namespace now moves an existing entity, would instead relocate it into this one. |
MeMesh runs as a stdio MCP server. Claude Code and Codex manage the connection automatically through their plugin manifests. Both resolve to the same bundled dist/mcp/server.js: Claude declares mcpServers: "./.claude-plugin/mcp.json", while Codex declares mcpServers: "./.codex-plugin/mcp.json".
{
"mcpServers": {
"memesh": {
"command": "node",
"args": ["${CLAUDE_PLUGIN_ROOT}/dist/mcp/server.js"],
"env": { "NODE_ENV": "production" }
}
}
}The Codex manifest uses the plugin cache as its working directory. Codex passes an MCP server only the environment variables its manifest names, so env_vars forwards a custom data directory or database:
{
"mcpServers": {
"memesh": {
"command": "node",
"args": ["./dist/mcp/server.js"],
"cwd": ".",
"env_vars": ["MEMESH_DIR", "MEMESH_DB_PATH"]
}
}
}Returns user work patterns extracted from existing memory entities.
Response fields: workSchedule (hour/day distribution), focusAreas, workflow (commits/session, totals), strengths (high-confidence types), learningAreas (tags from lessons/mistakes).
workSchedule.dayDistribution entries carry dayNum — an integer 0–6 from SQLite strftime('%w'), where 0 is Sunday and 6 is Saturday. There is no English day name field: day names are presentation, so localising dayNum into a weekday label is the client's job.
Lists proposals that an agent or deterministic rule has already staged for human review. The Dashboard reads this review queue; it does not create work packages or wake an agent.
Query parameters:
| Parameter | Type | Default | Description |
|---|---|---|---|
status |
enum | pending |
One of pending, applied, rejected, all |
Response: array of { id, project, cluster_key, source_count, digest_name, digest_observations_preview, status, created_at, kind, source_kind }. Agent-assisted work packages stage kind: "digest" with source_kind: "entities" | "transcript"; the Dashboard labels entity clusters as calendar-grouped. Other retained proposal kinds share the same review lifecycle.
Full proposal detail for the Dashboard review view.
Response: { id, project, cluster_key, source_ids, proposed_digest, status, reason, created_at, reviewed_at, kind, source_kind }. proposed_digest includes the complete kind-specific payload. Digest payloads include name, type, observations, and tags.
Apply a pending reviewed proposal. Digest acceptance creates a digest entity, inserts summarizes / evidence_for edges, and soft-archives the claimed sources. Product-improvement acceptance instead creates one team-scoped product_improvement, links it to every source with learned-from, and preserves all sources as active evidence; the new work item remains explicitly implementation/outcome unverified.
Response: { proposalId, digestEntityName, sourcesArchived, sourcesLinked, kind }.
A proposal that can no longer claim any of its sources (every source already summarised by another digest, or every source since forgotten) answers 400 with errorCode: "operation.failed" — and the server has already marked that proposal rejected, so it will not appear as pending again. This is a resolved outcome, not a server failure; do not retry it.
Mark a pending proposal as rejected. Source entities are untouched.
Body schema:
| Field | Type | Description |
|---|---|---|
reason |
string (optional, ≤500 chars) | Why this proposal was rejected |
Response: { id, status: 'rejected' }.