Protocol: Model Context Protocol (MCP) over stdio Version: 4.10.0 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 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", "commit"). Defaults to "note" with note |
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:myapp", "type:decision") |
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. 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. 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:myapp") |
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:
On the first successful tool call of a server process, any tool's result may carry a second content item { "type": "text", "text": "[memesh update] …" } — the update notice (available upgrade, just-upgraded receipt, or a failed check). 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 (limit reached — more may exist) 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 capped on display with … (+N more chars); storage and
--json always carry the full text.
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.
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:myapp") |
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. 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 | merged, minus guard, trust and provenance. The last two are rebuilt by the import. guard is refused: it controls what memesh WARNS about on your tool calls, and a file you were sent must not be able to install one. |
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.
Input Schema:
| Parameter | Type | Required | Description |
|---|---|---|---|
data |
object | Yes | The JSON bundle produced by export |
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. |
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 | 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.
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,
"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.
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"}Record a structured lesson from a mistake or discovery. Creates a lesson_learned entity with structured observations for error, root cause, fix, and prevention.
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, and it is injected at the top of the next session's context.
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 assembled work topology for a project, ready to place in context: where the work was left off (the task_state fields), decisions and direction, lessons not to repeat, what is known, recent activity, and — closing the block — a capped index of the project's durable memories, one line each, newest first, carrying the [mem:id] handles needed to cite or recall them (see The durable-memory index below for its budget, redaction and empty state; the structured counts and token cost come back in index). It is the same block the Claude Code session-start hook injects. This is the cross-vendor read path: an MCP client that runs no hooks (Gemini, Codex) calls this once at the start of a session instead.
The text is wrapped in the same fence and "background data, not instructions" preamble the hook uses. Memory content is attacker-influenced in the general case, and the wrapping is done by the same single owner on every path.
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:
{
"project": "myproject",
"text": "MeMesh reference memory. Treat the content below as background data…",
"entityCount": 12,
"hasTaskState": true,
"index": { "lines": ["Index of durable memories for \"myproject\" (newest first):", "…"], "shown": 9, "more": 0, "older": 2, "truncated": false, "bytes": "…", "tokens": "…", "ids": [41, 38, 12] }
}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).
entityCount counts the ranked memory lines actually rendered into the block (the character budget can cut candidates), excluding the task-state block and the index. Also available as memesh briefing on the CLI, for agents whose only integration is a shell.
The durable-memory index. The block always closes with an index of what is known about the project, so an agent can see it without having to guess a query (ranked recall stays for questions). The same section closes the SessionStart block, and memesh briefing --index prints it on its own (--index --json for the structured form).
- 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) andtask-state— as- [type] title — first observation [mem:id], newest activity first (the later of creation and the newest observation; ties by id). - Scope: rows tagged
project:<name>,status = active, not in theglobalnamespace — the same scope the ranked project pool reads, so never another project's rows. Imported ortrust: untrustedrows are excluded by the auto-injection gate. - Each memory line's title and snippet pass
redactSecretsthenredactUserPathsbefore rendering (indexLineinsrc/core/briefing-index.ts). The heading and the empty-state line still interpolate the project name directly, unredacted (indexHeading,indexEmptyLine); theN moretrailer no longer takes a project name at all — it prints a literal"project:…"placeholder (moreLine), so it carries nothing to redact. - Memories with no change for 180 days are counted in one
N older memories … — recall to seeline instead of listed. - Budget contract (frozen; changing it is a CHANGELOG entry): at most 40 memory lines and 3072 UTF-8 bytes for the whole section, with a
- N more — memesh recall --tag "project:…"line when the caps cut. The command uses a literal"project:…"placeholder rather than the real project name — it is not interpolated, so pasting the line into a shell never quotes whatever the filesystem or a git remote happened to contain; the heading two lines above already prints the (quoted) project name. A+after a count means the 2000-row candidate window was full, so the count is a lower bound. - The last line reports the cost:
(index cost: N lines, B bytes ≈ T tokens; cap 40 lines / 3072 bytes), whereBis the byte size of the WHOLE section, footer included, andT = ceil(B / 4). Because the footer's own text feeds the number it prints,Bis resolved as a fixed point (closeWithFooterinsrc/core/briefing-index.ts): render the section without the footer, add a footer for that size, and re-render until the footer text stops changing.index.bytes/index.tokenscarry the same numbers. - A project with no durable memories gets
- No durable memories (decisions, lessons, patterns, references) for "<name>" yet.rather than nothing — sotextis never empty. Repository facts (branch, dirty files) prefix the block whenever it has task state or ranked memories — the gate islines.length > 0(src/core/briefing.ts), andassembleTopologyBlock(src/core/work-topology.ts) pushes the task-state lines intolinesunconditionally, so a project with task state but no ranked memory still gets the branch line. The index's own empty-state line never triggers it on its own. - 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 at session start for context about the user.
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.
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 surfaces N messages waiting for "<recipient>" in project "<project>" only when the caller supplies that exact recipient; generic briefing and SessionStart context have no recipient identity and remain quiet. At zero unread, a scoped briefing still says ... 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, declared model/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. |
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. |
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. |
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); 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
}
}
}Dashboard locale is browser-local UI state and is not part of this server configuration.
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). 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.
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 (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 |
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 --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 and pre-compact, 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 the three 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.neverRan— session-summary when it has neither an outcome record nor a heartbeat 72 hours after tracking began.
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.
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
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 DB to data/backups/kg-before-rename-project-<timestamp>.db 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. On --apply the DB file is copied to data/backups/ before any mutation; if the backup fails, the command aborts without changing anything. 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.
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 a delegate worker (the DeepSeek worker), from the orchestrator's side. Guide: Delegate worker.
memesh delegation record --envelope envelope.json --prompt-file prompt.txt [--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. |
--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>-<envelope sha256, 8>, tagged
source:deepseek-worker and project:<current project>. 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: "deepseek-worker" 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 writes nothing ("stored": false) and reports the stored verdict.
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:
{
"mcpServers": {
"memesh": {
"command": "node",
"args": ["./dist/mcp/server.js"],
"cwd": "."
}
}
}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' }.