diff --git a/README.md b/README.md index 3acb7eb..b9bac1b 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/assets/skills/memd-doctor/SKILL.md b/assets/skills/memd-doctor/SKILL.md new file mode 100644 index 0000000..856beec --- /dev/null +++ b/assets/skills/memd-doctor/SKILL.md @@ -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.`). 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. diff --git a/assets/skills/memd-memory/SKILL.md b/assets/skills/memd-memory/SKILL.md new file mode 100644 index 0000000..93f9145 --- /dev/null +++ b/assets/skills/memd-memory/SKILL.md @@ -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 ""` | +| Save a durable fact | `save_memory` | `memd add "" --type ` | +| 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 ` | + +## 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. diff --git a/docs/cli.mdx b/docs/cli.mdx index 91a33b9..8aacf64 100644 --- a/docs/cli.mdx +++ b/docs/cli.mdx @@ -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). | @@ -93,6 +94,11 @@ 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: @@ -100,7 +106,7 @@ Example `~/.claude/settings.json` hooks: { "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" }] }] } diff --git a/src/agents/hooks.rs b/src/agents/hooks.rs index bf32e70..b825dd8 100644 --- a/src/agents/hooks.rs +++ b/src/agents/hooks.rs @@ -23,7 +23,10 @@ pub fn install_claude_hooks(installed: &Path) -> Result { } 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; diff --git a/src/agents/mod.rs b/src/agents/mod.rs index b5168a2..98df52b 100644 --- a/src/agents/mod.rs +++ b/src/agents/mod.rs @@ -5,6 +5,7 @@ mod directives; mod hooks; mod mcp; +mod skills; use crate::config::Config; use anyhow::Result; @@ -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) @@ -92,6 +98,8 @@ pub struct Agent { mcp: McpKind, pub directives: Option, pub hooks: bool, + /// Whether memd ships invokable skills into this agent (Claude Code only). + pub skills: bool, } impl Agent { @@ -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(()) } @@ -164,6 +177,9 @@ impl Agent { if self.hooks { let _ = hooks::remove_claude_hooks(); } + if self.skills { + let _ = skills::remove_claude_skills(); + } Ok(()) } } @@ -206,6 +222,7 @@ pub fn registry() -> Vec { mcp: McpKind::ClaudeCli, directives: Some(h.join(".claude/CLAUDE.md")), hooks: true, + skills: true, }, Agent { id: "codex", @@ -216,6 +233,7 @@ pub fn registry() -> Vec { }, directives: Some(h.join(".codex/AGENTS.md")), hooks: false, + skills: false, }, Agent { id: "gemini-cli", @@ -229,6 +247,7 @@ pub fn registry() -> Vec { }, directives: Some(h.join(".gemini/GEMINI.md")), hooks: false, + skills: false, }, Agent { id: "cursor", @@ -242,6 +261,7 @@ pub fn registry() -> Vec { }, directives: None, hooks: false, + skills: false, }, Agent { id: "windsurf", @@ -255,6 +275,7 @@ pub fn registry() -> Vec { }, directives: None, hooks: false, + skills: false, }, Agent { id: "cline", @@ -268,6 +289,7 @@ pub fn registry() -> Vec { }, directives: None, hooks: false, + skills: false, }, Agent { id: "zed", @@ -281,6 +303,7 @@ pub fn registry() -> Vec { }, directives: None, hooks: false, + skills: false, }, ] } diff --git a/src/agents/skills.rs b/src/agents/skills.rs new file mode 100644 index 0000000..990fd99 --- /dev/null +++ b/src/agents/skills.rs @@ -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 { + 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 { + write_skills_to(&skills_root()?) +} + +/// Remove memd's skills from `~/.claude/skills/`. Returns whether anything changed. +pub fn remove_claude_skills() -> Result { + remove_skills_from(&skills_root()?) +} + +/// Core of [`install_claude_skills`], parameterized on the skills root for tests. +fn write_skills_to(root: &Path) -> Result { + 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 { + 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" + ); + } + } +} diff --git a/src/cli.rs b/src/cli.rs index 64422d0..1db3dec 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -54,6 +54,46 @@ pub async fn up(foreground: bool) -> Result<()> { bail!("daemon did not become healthy in time; check `memd logs`") } +/// Best-effort: make sure the daemon is running, fast and silent. Used by the +/// Claude Code SessionStart hook so memory keeps working even if the daemon +/// died or was never started. Prints nothing to stdout (the hook injects its +/// output into the session) and never errors out the caller β€” a failure here +/// must not disrupt a session start. +pub async fn ensure() -> Result<()> { + let Ok(cfg) = Config::load_or_init() else { + return Ok(()); + }; + if daemon_healthy(&cfg).await { + return Ok(()); + } + if let Err(e) = ensure_running(&cfg).await { + eprintln!("memd ensure: could not start daemon: {e:#}"); + } + Ok(()) +} + +/// Start the daemon without `up`'s long readiness wait: kick the launchd service +/// (or spawn a detached process), then poll briefly so a following `context` +/// call in the same hook can already see it healthy. Silent on stdout. +async fn ensure_running(cfg: &Config) -> Result<()> { + if cfg!(target_os = "macos") { + if launchd::is_installed() { + let _ = launchd::load(); + } else { + launchd::install(&resolve_memd_exe())?; + } + } else { + spawn_detached()?; + } + for _ in 0..10 { + if daemon_healthy(cfg).await { + break; + } + tokio::time::sleep(Duration::from_millis(500)).await; + } + Ok(()) +} + /// Stop the daemon (launchd unload, or kill the recorded pid). pub async fn down() -> Result<()> { if launchd::is_installed() { @@ -716,6 +756,27 @@ pub fn directives_uninstall() -> Result<()> { crate::agents::directives_remove_all() } +/// Write memd's Claude Code skills into `~/.claude/skills/`. +pub fn skills_install() -> Result<()> { + if crate::agents::skills_install_all()? { + println!("Installed memd skills into ~/.claude/skills/ (memd-doctor, memd-memory)."); + println!("Start a new Claude Code session to pick them up."); + } else { + println!("memd skills are already up to date."); + } + Ok(()) +} + +/// Remove memd's Claude Code skills from `~/.claude/skills/`. +pub fn skills_uninstall() -> Result<()> { + if crate::agents::skills_remove_all()? { + println!("Removed memd skills from ~/.claude/skills/."); + } else { + println!("No memd skills to remove."); + } + Ok(()) +} + // --- helpers --------------------------------------------------------------- /// Build a service, erroring with a hint if the daemon/Meilisearch is down. diff --git a/src/main.rs b/src/main.rs index 8465db9..ac95d80 100644 --- a/src/main.rs +++ b/src/main.rs @@ -37,6 +37,8 @@ enum Command { }, /// Stop the running daemon. Down, + /// Ensure the daemon is running (fast, silent; used by the SessionStart hook). + Ensure, /// Show daemon + Meilisearch health, index stats, and last crawl. Status, /// Tail the daemon logs. @@ -146,6 +148,11 @@ enum Command { #[command(subcommand)] action: DirectivesAction, }, + /// Install/remove memd's Claude Code skills (~/.claude/skills/). + Skills { + #[command(subcommand)] + action: SkillsAction, + }, /// One-command install: relocate the binary, start the daemon, register /// with detected agents, install directives, and wire up hooks. Setup { @@ -193,12 +200,21 @@ enum DirectivesAction { Uninstall, } +#[derive(Subcommand)] +enum SkillsAction { + /// Write memd's skills into ~/.claude/skills/. + Install, + /// Remove memd's skills from ~/.claude/skills/. + Uninstall, +} + #[tokio::main] async fn main() -> anyhow::Result<()> { let cli = Cli::parse(); match cli.command { Command::Up { foreground } => cli::up(foreground).await, Command::Down => cli::down().await, + Command::Ensure => cli::ensure().await, Command::Status => cli::status().await, Command::Logs { follow } => cli::logs(follow).await, Command::Serve => daemon::serve().await, @@ -254,6 +270,10 @@ async fn main() -> anyhow::Result<()> { DirectivesAction::Install => cli::directives_install(), DirectivesAction::Uninstall => cli::directives_uninstall(), }, + Command::Skills { action } => match action { + SkillsAction::Install => cli::skills_install(), + SkillsAction::Uninstall => cli::skills_uninstall(), + }, Command::Setup { no_hooks } => cli::setup(no_hooks).await, Command::Doctor { fix } => cli::doctor(fix).await, Command::Update { check } => cli::update(check).await,