Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,8 @@ git clone https://github.com/qdequele/memd && cd memd
| 🔍 Meilisearch | Pinned engine downloaded + started as a managed service (no Docker) |
| 🔌 Agents | Interactive picker — choose from detected agents (Claude Code, Codex, Gemini CLI, Cursor, Windsurf, Cline, Zed); selected ones get memd's MCP server registered |
| 📝 Directives | Usage block written into agent instruction files (Claude Code, Codex, Gemini CLI) |
| 🪝 Hooks | SessionStart (auto-recall) + Stop (auto-capture) wired into Claude Code |
| 🪝 Hooks | SessionStart (ensure daemon + auto-recall) + Stop (auto-capture) wired into Claude Code |
| 🎯 Skills | `/memd-doctor` (diagnose & repair) + `/memd-memory` (recall/save playbook) installed for Claude Code |

It's **idempotent** — re-run any time (e.g. after an upgrade) to reconverge.

Expand Down
62 changes: 62 additions & 0 deletions assets/skills/memd-doctor/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
---
name: memd-doctor
description: Diagnose and repair a broken memd memory setup. Use when memd memory isn't working — recall returns nothing, the daemon won't start, an agent shows no memd MCP tools, or the user asks to check, fix, debug, or restart memd (the local memory daemon backed by Meilisearch).
---

# memd doctor

memd is an always-on local daemon that turns a local Meilisearch into shared,
searchable long-term memory for every LLM tool. When "memory isn't working,"
the cause is almost always one of: the daemon is down, Meilisearch is down, the
database is version-mismatched, or an agent was never wired to the MCP server.

Work the steps **in order** and stop as soon as memory is healthy again.

## 1. Gather state

Run both and read the output before changing anything:

```sh
memd doctor # config, binary, Meilisearch, db version, MCP endpoint, service
memd status # health, memory count, per-agent wiring, last crawl
```

## 2. Diagnose, then repair the first failing layer

| Symptom in output | Fix |
|---|---|
| `MCP endpoint: down` / `Meilisearch: down` | `memd up` — starts the daemon (installs the service if needed). Re-check `memd status`. |
| `Database: version … != pinned … — MISMATCH` | `memd doctor --fix` — backs up the old db and recreates it on the pinned engine. |
| `Installed bin: not installed` | `memd setup` — relocates the binary and wires everything. |
| `Index: ERROR …` | `memd up` to (re)configure the index; if it persists, check `memd logs`. |
| An agent shows `detected — not configured` | `memd setup` and select that agent in the picker. |
| Daemon flaps / won't stay up | `memd logs` (or `memd logs -f`) to read the daemon log, then act on the error. |

## 3. Verify the MCP tools are reachable

Memory only works in a session if the agent can see memd's MCP tools
(`get_memory`, `save_memory`, `read_memory`, `list_memories`, ...). If they're
missing in the current agent:

- Confirm wiring: `memd status` should show that agent as `configured`.
- Re-wire if needed: `memd setup`.
- The agent must reload — start a **new** session (in Claude Code, `/mcp` lists
connected servers; `memd` should appear).

## 4. Confirm

```sh
memd status # Meilisearch: up, MCP endpoint: up
memd search "test" # round-trips through the daemon
```

Report what was wrong and what fixed it. Don't claim it's fixed until
`memd status` shows both Meilisearch and the MCP endpoint `up`.

## Notes

- `memd doctor --fix` currently only repairs a version-mismatched database, and
it **backs the old db up first** (`meili-data.mismatch.<ts>`). It is safe.
- On macOS the daemon runs under launchd (`KeepAlive`), so a crash self-restarts.
On Linux there's no supervisor by default — `memd up` re-spawns it.
- Logs live next to the data dir; `memd doctor` prints the exact paths.
54 changes: 54 additions & 0 deletions assets/skills/memd-memory/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
---
name: memd-memory
description: Use memd (shared cross-tool long-term memory) well — recall relevant context before acting, and save durable facts as you learn them. Use at the start of a task to load prior decisions and preferences, and whenever the user shares something worth remembering across sessions and tools.
---

# Using memd memory

memd is your persistent, cross-tool long-term memory: one local MCP server,
shared with every LLM tool on this machine. Memories saved in one tool are
recalled in all the others, and in every future session. Use it proactively —
don't make the user re-explain things they've already told another tool.

The MCP tools (and their CLI equivalents) are:

| Do | MCP tool | CLI |
|---|---|---|
| Recall before acting | `get_memory` | `memd search "<query>"` |
| Save a durable fact | `save_memory` | `memd add "<text>" --type <t>` |
| Read full text of one | `read_memory(id)` | — |
| Browse / list | `list_memories` | `memd search` |
| Correct an existing one | `update_memory(id, …)` | — |
| Delete a wrong/stale one | `forget_memory(id)` | `memd forget <id>` |

## Recall first

At the **start of a task**, call `get_memory` with the user's goal and set
`scope` to the project path (the current working directory) when relevant. Load
prior decisions, preferences, and facts before acting. Prefer recalling over
asking the user to repeat themselves.

## Save what's durable

Save when you learn something that should outlive this session: a decision, a
stated preference, a stable fact about the user or project, or a reusable
solution. Before saving, **search first** to avoid duplicates — if a close
memory already exists, `update_memory` it instead of adding a near-copy.

Write each memory to be useful cold, months later and in another tool:

- **One fact per memory.** Atomic memories recall and update cleanly.
- **Set the `type`** (`fact`, `preference`, `decision`, `task`,
`project_overview`, ...) and a `scope` — `global` for machine/user-wide
truths, the project path for project-specific ones.
- **Be self-contained.** Spell out the *why* for decisions and preferences;
resolve relative dates to absolute ones.
- **Don't save** what's already in the repo (code, git history, README/CLAUDE.md)
or what only matters to the current conversation.

## Keep it high-signal

- Fix memories that turn out wrong with `update_memory`; `forget_memory` ones
that are obsolete or were saved by mistake.
- If recall surfaces stale or contradictory memories, reconcile them rather than
letting both linger.
8 changes: 7 additions & 1 deletion docs/cli.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ description: "Every memd subcommand."
|---------|-------------|
| `memd up [--foreground]` | Start the daemon (Meilisearch + crawler + MCP); install the launchd service. `--foreground` runs the daemon process directly. |
| `memd down` | Stop the daemon (launchd unload, or signal the recorded pid). |
| `memd ensure` | Best-effort: start the daemon if it's down, fast and silent. Used by the SessionStart hook so recall keeps working after a crash; never errors out the caller. |
| `memd status` | Daemon + Meilisearch health, index stats, and last crawl. |
| `memd logs [-f]` | Print (or follow) the daemon log file. |
| `memd serve` | Run the daemon in the foreground (used by the service runner). |
Expand Down Expand Up @@ -93,14 +94,19 @@ These wire memd into your agents so it's used as primary memory (see
| `memd capture` | Read a **Stop**-hook JSON payload on stdin and conservatively save the turn *only* when the user's message signals durable intent ("remember…", "we decided…", …). |
| `memd directives install` | Write a managed memd-usage block into `~/.claude/CLAUDE.md`, `~/.codex/AGENTS.md`, etc. (idempotent). |
| `memd directives uninstall` | Remove the managed block. |
| `memd skills install` | Install memd's Claude Code skills into `~/.claude/skills/`: `/memd-doctor` (diagnose & repair) and `/memd-memory` (recall/save playbook). Idempotent. |
| `memd skills uninstall` | Remove memd's skills. |

`memd setup` does all of the above for the agents you select; the commands are
here for manual control and re-running after edits.

Example `~/.claude/settings.json` hooks:

```json
{
"hooks": {
"SessionStart": [{ "hooks": [{ "type": "command",
"command": "/path/to/memd context --scope \"$CLAUDE_PROJECT_DIR\"" }] }],
"command": "/path/to/memd ensure; /path/to/memd context --scope \"$CLAUDE_PROJECT_DIR\"" }] }],
"Stop": [{ "hooks": [{ "type": "command",
"command": "/path/to/memd capture" }] }]
}
Expand Down
5 changes: 4 additions & 1 deletion src/agents/hooks.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,10 @@ pub fn install_claude_hooks(installed: &Path) -> Result<bool> {
}

let exe = installed.to_string_lossy();
let session_cmd = format!("{exe} context --scope \"$CLAUDE_PROJECT_DIR\"");
// `ensure` (best-effort, silent) revives a dead daemon so recall keeps
// working even if it crashed or was never started; `;` runs `context`
// regardless of its outcome (context degrades gracefully when down).
let session_cmd = format!("{exe} ensure; {exe} context --scope \"$CLAUDE_PROJECT_DIR\"");
let stop_cmd = format!("{exe} capture");

let mut changed = false;
Expand Down
23 changes: 23 additions & 0 deletions src/agents/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
mod directives;
mod hooks;
mod mcp;
mod skills;

use crate::config::Config;
use anyhow::Result;
Expand All @@ -14,6 +15,11 @@ use std::path::{Path, PathBuf};
#[allow(unused_imports)]
pub use directives::{install_all as directives_install_all, remove_all as directives_remove_all};

// Re-exported for `cli::skills_install`/`uninstall`.
pub use skills::{
install_claude_skills as skills_install_all, remove_claude_skills as skills_remove_all,
};

/// The HTTP MCP endpoint every agent registers against.
pub fn mcp_url(cfg: &Config) -> String {
format!("http://{}:{}/mcp", cfg.mcp.host, cfg.mcp.port)
Expand Down Expand Up @@ -92,6 +98,8 @@ pub struct Agent {
mcp: McpKind,
pub directives: Option<PathBuf>,
pub hooks: bool,
/// Whether memd ships invokable skills into this agent (Claude Code only).
pub skills: bool,
}

impl Agent {
Expand Down Expand Up @@ -143,6 +151,11 @@ impl Agent {
if self.hooks && install_hooks {
let _ = hooks::install_claude_hooks(bin);
}
// Skills are inert until invoked, so they're installed regardless of the
// `--no-hooks` opt-out (which is about the auto-running session hooks).
if self.skills {
let _ = skills::install_claude_skills();
}
Ok(())
}

Expand All @@ -164,6 +177,9 @@ impl Agent {
if self.hooks {
let _ = hooks::remove_claude_hooks();
}
if self.skills {
let _ = skills::remove_claude_skills();
}
Ok(())
}
}
Expand Down Expand Up @@ -206,6 +222,7 @@ pub fn registry() -> Vec<Agent> {
mcp: McpKind::ClaudeCli,
directives: Some(h.join(".claude/CLAUDE.md")),
hooks: true,
skills: true,
},
Agent {
id: "codex",
Expand All @@ -216,6 +233,7 @@ pub fn registry() -> Vec<Agent> {
},
directives: Some(h.join(".codex/AGENTS.md")),
hooks: false,
skills: false,
},
Agent {
id: "gemini-cli",
Expand All @@ -229,6 +247,7 @@ pub fn registry() -> Vec<Agent> {
},
directives: Some(h.join(".gemini/GEMINI.md")),
hooks: false,
skills: false,
},
Agent {
id: "cursor",
Expand All @@ -242,6 +261,7 @@ pub fn registry() -> Vec<Agent> {
},
directives: None,
hooks: false,
skills: false,
},
Agent {
id: "windsurf",
Expand All @@ -255,6 +275,7 @@ pub fn registry() -> Vec<Agent> {
},
directives: None,
hooks: false,
skills: false,
},
Agent {
id: "cline",
Expand All @@ -268,6 +289,7 @@ pub fn registry() -> Vec<Agent> {
},
directives: None,
hooks: false,
skills: false,
},
Agent {
id: "zed",
Expand All @@ -281,6 +303,7 @@ pub fn registry() -> Vec<Agent> {
},
directives: None,
hooks: false,
skills: false,
},
]
}
Expand Down
116 changes: 116 additions & 0 deletions src/agents/skills.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
//! memd's Claude Code skills: invokable playbooks shipped inside the binary and
//! written into `~/.claude/skills/`. `memd-doctor` diagnoses/repairs a broken
//! setup; `memd-memory` teaches an agent to recall and save well. Idempotent;
//! memd owns these two directories by name and never touches others.

use anyhow::{Context, Result};
use std::path::{Path, PathBuf};

/// (skill directory name, `SKILL.md` contents) for every memd-managed skill.
/// Bundled at compile time so the prebuilt binary needs no extra files.
const SKILLS: &[(&str, &str)] = &[
(
"memd-doctor",
include_str!("../../assets/skills/memd-doctor/SKILL.md"),
),
(
"memd-memory",
include_str!("../../assets/skills/memd-memory/SKILL.md"),
),
];

/// `~/.claude/skills`.
fn skills_root() -> Result<PathBuf> {
Ok(directories::BaseDirs::new()
.context("home directory")?
.home_dir()
.join(".claude")
.join("skills"))
}

/// Write memd's skills into `~/.claude/skills/`. Idempotent; returns whether
/// anything changed.
pub fn install_claude_skills() -> Result<bool> {
write_skills_to(&skills_root()?)
}

/// Remove memd's skills from `~/.claude/skills/`. Returns whether anything changed.
pub fn remove_claude_skills() -> Result<bool> {
remove_skills_from(&skills_root()?)
}

/// Core of [`install_claude_skills`], parameterized on the skills root for tests.
fn write_skills_to(root: &Path) -> Result<bool> {
let mut changed = false;
for (name, body) in SKILLS {
let dir = root.join(name);
let file = dir.join("SKILL.md");
// Skip when already current so re-running setup is a clean no-op.
if std::fs::read_to_string(&file).ok().as_deref() == Some(*body) {
continue;
}
std::fs::create_dir_all(&dir).with_context(|| format!("creating {}", dir.display()))?;
std::fs::write(&file, body).with_context(|| format!("writing {}", file.display()))?;
changed = true;
}
Ok(changed)
}

/// Core of [`remove_claude_skills`], parameterized on the skills root for tests.
fn remove_skills_from(root: &Path) -> Result<bool> {
let mut changed = false;
for (name, _) in SKILLS {
let dir = root.join(name);
if dir.exists() {
std::fs::remove_dir_all(&dir).with_context(|| format!("removing {}", dir.display()))?;
changed = true;
}
}
Ok(changed)
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn install_then_remove_roundtrip() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path().join("skills");

assert!(
write_skills_to(&root).unwrap(),
"first write changes things"
);
for (name, _) in SKILLS {
assert!(
root.join(name).join("SKILL.md").is_file(),
"{name} should be written"
);
}

// Idempotent: a second write with identical content reports no change.
assert!(!write_skills_to(&root).unwrap());

assert!(remove_skills_from(&root).unwrap());
for (name, _) in SKILLS {
assert!(!root.join(name).exists(), "{name} should be gone");
}
// Removing again is a no-op.
assert!(!remove_skills_from(&root).unwrap());
}

#[test]
fn bundled_skills_have_frontmatter() {
for (name, body) in SKILLS {
assert!(
body.starts_with("---\n"),
"{name} SKILL.md must start with YAML frontmatter"
);
assert!(
body.contains(&format!("name: {name}")),
"{name} frontmatter name must match its directory"
);
}
}
}
Loading
Loading