- Rust 97.7%
- PLpgSQL 1.5%
- Shell 0.5%
- Just 0.3%
The shared knowledge base adds a tool and changes what search_notes returns by default, so this is a minor bump rather than a patch. Cargo.toml, plugin.json, and .claude-plugin/plugin.json move together — the crate and both plugin manifests are released as one thing. |
||
|---|---|---|
| .cargo | ||
| .claude-plugin | ||
| docs/superpowers | ||
| hooks | ||
| migrations | ||
| src | ||
| tests | ||
| .env.example | ||
| .gitignore | ||
| .mcp.json | ||
| Cargo.lock | ||
| Cargo.toml | ||
| docker-compose.yml | ||
| justfile | ||
| LICENSE | ||
| mcp.json | ||
| plugin.json | ||
| README.md | ||
peachmem
a mem system for ai agents
peachmem is an MCP server that gives an agent durable memory: notes are
written as markdown, stored in Postgres, full-text searchable, and linked
into a graph via [[wikilink]] syntax in the body.
How it works
-
Storage: one row per note (
id,slug,title,body_md,tags) in Postgres.slugis derived from the title once, at creation, and never changes — so links keep resolving even after a title is edited. -
Full-text search: a generated
tsvectorcolumn (title weighted above body) with a GIN index, ranked withts_rank_cdand highlighted withts_headline. If a query matches nothing, search falls back to trigram similarity on the title (viapg_trgm) so typos and near-misses still surface something. -
Graph linking: write
[[Note Title]]or[[Note Title|display text]]anywhere in a note's body. On save, peachmem extracts those references into alinkstable (source → target slug), resolvingtarget_idwhen a matching note exists. Links to notes that don't exist yet are kept as "unresolved" and resolve automatically once that note is created — Obsidian-style. If a target note is later deleted, its incoming links revert to unresolved rather than disappearing. -
Projects: notes are grouped by project, a durable identity that is deliberately decoupled from any single checkout. A project is reachable through any number of aliases:
- git remotes — several remotes can point at one project, so a frontend and backend repo can share notes.
- hostname + local path pairs — worktrees, or the same repo cloned
more than once, on any number of machines. A
NULLhostname means "this path on any machine".
Because resolution is remote-first, two workstations that clone the same repo to different paths converge on the same project automatically, and a git worktree resolves to its main checkout's project. Each new location is recorded as another alias as it's seen. A non-git directory still gets a project, keyed by its path.
On creation a note attaches to the project peachmem is running in (set once, like the slug). Notes can also belong to several projects, or to none —
attach_workspace: falsemakes a global note, for things that aren't about any one codebase.list_notes/search_notesfilter by project using any alias;list_notesalso filters by path.Auto-detection can't infer everything, so
link_projectis the manual escape hatch: rename/describe a project, register extra remotes or paths as aliases, or merge two projects that turned out to be the same thing (notes, aliases and paths all move over).list_projectsshows every project with its full alias set and note count. -
Knowledge base: notes with
kind: "kb"hold research that's worth not repeating — how a model behaves, what it costs, a language's idioms — and by default aren't attached to the current project, because that kind of thing isn't about one codebase; an explicitattach_workspace: truestill attaches one anyway, for the rare knowledge note that is genuinely tied to where it was written. There are three ways in, all surfaced throughbrowse_knowledge: thetopictree (a/-separated path such asrust/error-handling, normalized to lowercase slugs, capped at 6 segments);kind: "hub"notes, which are curated index pages whose bodies are just[[wikilinks]]into a topic; and tag facets, which narrow to whatever co-occurs with an active tag filter. A knowledge note also carries provenance:sources(the URLs or citations it came from),confidence(high= verified against a primary source or actually tested,medium= secondary sources agree,low= inference or a single unverified source — evidence, not a feeling), andreviewed_at, which is stamped automatically the first time a note becomes knowledge — a create withkind: "kb"/"hub", or a promotion — and otherwise only moves by passingreviewed: true(or is suppressed on that first stamp withreviewed: false). It is therefore distinct fromupdated_at— a typo fix bumps the latter but shouldn't count as re-verifying the content.stale_after_dayscontrols how long a review stays trustworthy before search flags (never hides) it as stale;0marks content that doesn't decay. Omitting it on create inherits the[knowledge]config default below, but omitting it on an update leaves whatever is already stored unchanged — there's no way to put an already-set note back to inheriting the default.supersedesrecords that this note replaces another: the old note keeps appearing in search — nothing here is a deletion — but sorts after live results and carries a pointer to its replacement, which is why supersession is a fact someone declared while staleness is only ever inferred from a clock.This changes what
search_notesreturns. It now defaults tokinds: ["note", "kb", "hub"]and excludeswork_lognotes unless you ask for them — search is the ranked path, and session-summary noise was crowding out the answer someone actually wanted.list_notesis deliberately untouched and still returns every kind by default, because it's an explicit browse tool whose caller already supplies filters; that's also what keeps thelist_notes(tag: "work_log")lookup documented below working unchanged. -
Session summaries: a
SessionStarthook asynchronously summarizes the previous Claude Code session in a project into a note, so it never delays session start. It only fires onstartup(not resume, clear, compact, or fork — those continue an existing session), finds the previous session's transcript in~/.claude/projects/<cwd-slug>/, and reduces it to a digest of user prompts, assistant prose, and which tools ran — transcripts are megabytes, mostly tool output, so the raw file is never sent to a model. That digest goes to Claude Haiku, which returns three things: a short markdown worklog, a work classification (feature,bug,docs,research,refactor,test,ops, orchore), and a one-sentence high-level description. The result is saved as a note taggedwork_log, plus the classification as a second tag, plussession:<id>as an idempotency key so a session is never summarized twice. The note body leads with the description as a>blockquote, then the worklog, then a metadata footer. The one-sentence description is also injected back into the new session as context — arriving on the next turn, since the hook is async — so each session opens knowing what the last one did. Find these later withlist_notes(tag: "work_log")orsearch_notes.
Tools
| Tool | Description |
|---|---|
save_note |
Create a note, or update one in place by passing id. Optionally set tags, paths, projects, attach_workspace. Pass kind="kb"/"hub" with a topic to file it in the shared knowledge base, plus sources, confidence, stale_after_days, supersedes, and reviewed for provenance. |
get_note |
Fetch a note by id or slug, with its forward links, backlinks, projects, and paths. |
search_notes |
Ranked full-text search with snippets, fuzzy fallback; filter by tag, project, kinds, or topic, and optionally exclude_stale. Defaults to kinds: ["note","kb","hub"] — session summaries are excluded unless requested. |
list_notes |
Browse notes, most recently updated first; filter by tag, project, path, kinds, or topic, and optionally exclude_stale. Unlike search_notes, every kind is included by default. |
delete_note |
Delete a note by id or slug. |
list_projects |
List projects with their aliases (remotes, hostname/path pairs) and note counts. |
link_project |
Rename/describe a project, add remote or path aliases, and/or merge projects together. |
browse_knowledge |
Browse the shared knowledge base by topic: child topics with counts, hub entry points, notes at the topic, and tag facets. |
Configuration
All Postgres connection info — host, port, dbname, user, password
— lives in ~/.peachmem/config.toml, created automatically with
defaults the first time peachmem runs, if it doesn't already exist. The
defaults match docker-compose.yml's static credentials, so a fresh
checkout works end to end with zero setup: no secrets, no env vars, nothing
beyond the compiled binary itself. This is a local-only dev database (bound
to localhost on a nonstandard port), so static well-known credentials are
an intentional tradeoff for zero-config, cross-machine simplicity.
DATABASE_URL, if set, bypasses config.toml entirely (handy for CI or
a throwaway test database).
RUST_LOG controls log verbosity (unset → info); logs go to stderr since
stdout is reserved for the MCP protocol stream.
Migrations run automatically on startup.
The same file also has an optional [worklog] table configuring the
session-summary hook described above: which model summarizes, how much of
the transcript digest it's allowed to read, and time/cost ceilings. It's
entirely optional — an existing config.toml with only a [postgres]
table keeps working unchanged and gets these defaults:
[worklog]
enabled = true
model = "claude-haiku-4-5"
max_digest_chars = 40000
timeout_secs = 180
max_cost_usd = 0.20
Set enabled = false to turn the feature off without touching the plugin
itself.
Cost scales with max_digest_chars, and the digest is billed roughly twice
because the structured answer costs a second tool-call turn. Measured with
Haiku: about $0.015 of fixed overhead plus $0.001 per 1,000 digest
characters, so the 40,000-char default lands near $0.055 per session start.
Raising the limit buys detail on long sessions at proportionally more cost —
a 3.7 MB transcript digests to about 84,000 characters and costs about
$0.10 to summarize in full.
max_cost_usd is a safety net rather than a target, which is why it sits
well above the expected cost: exceeding it makes the CLI exit non-zero and
the worklog is dropped, so a ceiling set close to normal cost would quietly
disable the feature for exactly the long sessions most worth summarizing.
The summarizer shells out to the claude CLI, so it reuses your existing
Claude Code authentication — there's no API key to configure. If claude
isn't on PATH, the hook does nothing and the session is unaffected. The
same is true of every other failure path: if Postgres is down, the model
call fails, or anything else goes wrong, the hook exits quietly rather than
surfacing an error, and the session proceeds normally either way.
There's also an optional [knowledge] table, following the same
upgrade-in-place pattern as [worklog]: an existing config.toml with no
[knowledge] table keeps working and gets this default.
[knowledge]
default_stale_after_days = 180
default_stale_after_days is the staleness window applied to a knowledge
note that didn't set its own stale_after_days. It's read once at startup
rather than per query, so retuning it is a restart, not a migration. Since
DATABASE_URL bypasses config.toml entirely (see above), it bypasses
[knowledge] too — on that path peachmem never reads a config file at all,
so the compiled-in default (180 days) always applies regardless of what any
config.toml on disk says.
Claude Code runs an installed plugin from a cached checkout under
~/.claude/plugins/cache/, not from this working tree, so a freshly added
or changed hook won't take effect until the plugin is updated/reinstalled
from its marketplace.
Running locally
just start # starts postgres (docker compose, port 55433)
cargo run
| Command | What it does |
|---|---|
just start |
Start postgres (creates the container on first run). |
just stop |
Stop postgres without removing the container. |
just restart |
Fully tear down and recreate the postgres container. |
Point an MCP client at the built binary — nothing else required:
cargo build --release
./target/release/peachmem
Installing as a plugin
This repo is a self-contained plugin/marketplace for two ecosystems at once:
- agent-plugins.org (vendor-neutral):
plugin.json+mcp.jsonat the repo root. - Claude Code:
.claude-plugin/plugin.json+.claude-plugin/marketplace.json— the marketplace lists this same repo (source: ".") as its one plugin.
Both point their MCP server's command directly at the peachmem binary, so it must already be installed and on PATH — see Installing as a crate below. (Postgres itself still needs to be reachable per Configuration above; the plugin doesn't manage that.)
# Install the binary first so it's on PATH
cargo install --registry kerryhatcher peachmem
# Claude Code
claude plugin marketplace add /path/to/peachmem
claude plugin install peachmem@peachmem
Installing as a crate
Published to a self-hosted Forgejo Cargo registry rather than crates.io. Consumers add the registry to ~/.cargo/config.toml:
[registries.kerryhatcher]
index = "sparse+https://code.kerryhatcher.com/api/packages/kerryhatcher/cargo/"
cargo install --registry kerryhatcher peachmem
The registry is readable anonymously (auth-required: false), so installing needs no token.
Publishing a release
.cargo/config.toml in this repo already defines the registry, and publish = ["kerryhatcher"] in Cargo.toml means a bare cargo publish fails rather than reaching for crates.io. Only the token is per-machine — generate one in Forgejo with the write:packages scope and add it to ~/.cargo/credentials.toml, keeping Forgejo's literal Bearer prefix:
[registries.kerryhatcher]
token = "Bearer <token>"
cargo publish --registry kerryhatcher --dry-run # packages and verify-builds, no upload
cargo publish --registry kerryhatcher
Bump version in three places together — Cargo.toml, .claude-plugin/plugin.json, and plugin.json — since the crate and both plugin manifests are released as one thing; claude plugin validate . checks the manifests agree. A given name/version can't be re-uploaded without deleting the existing package first, so dry-run before publishing for real.
Development
cargo test # unit tests (wikilink parsing, slugify, repo detection, worklog digest, topic normalization) — no database required
just test-db # database-backed integration tests (starts postgres) — behind the `db-tests` feature, so a bare `cargo test` stays green without Postgres running
cargo clippy