peachmem (0.2.1)
Installation
[registries.forgejo]
index = "sparse+ " # Sparse index
# index = " " # Git
[net]
git-fetch-with-cli = truecargo add peachmem@0.2.1 --registry forgejoAbout this package
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. -
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. |
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 or project. |
list_notes |
Browse notes, most recently updated first; filter by tag, project, and/or path. |
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. |
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.
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)
cargo clippy
Dependencies
| ID | Version |
|---|---|
| anyhow | ^1 |
| chrono | ^0.4 |
| dirs | ^6 |
| dotenvy | ^0.15 |
| once_cell | ^1 |
| regex | ^1 |
| rmcp | ^3.1 |
| schemars | ^1 |
| serde | ^1 |
| serde_json | ^1 |
| sqlx | ^0.9 |
| tokio | ^1 |
| toml | ^1 |
| tracing | ^0.1 |
| tracing-subscriber | ^0.3 |
| uuid | ^1 |
| whoami | ^2 |