diff --git a/mem0-plugin/scripts/on_session_start.sh b/mem0-plugin/scripts/on_session_start.sh index d9351c752..bcabbf41f 100755 --- a/mem0-plugin/scripts/on_session_start.sh +++ b/mem0-plugin/scripts/on_session_start.sh @@ -46,7 +46,7 @@ body = json.dumps({ 'limit': 100, }).encode() req = urllib.request.Request( - 'https://api.mem0.ai/v2/memories/search/', + 'https://api.mem0.ai/v3/memories/search/', data=body, headers={'Authorization': f'Token {api_key}', 'Content-Type': 'application/json'}, method='POST', @@ -77,6 +77,20 @@ echo "- user_id: \`$MEM0_RESOLVED_USER_ID\`" echo "- app_id: \`$MEM0_PROJECT_ID\` (project scope — passed as top-level \`app_id\`, NOT in metadata)" echo "" +# Load mem0.md project config if present (best-effort, non-blocking) +MEM0_PROJECT_CONFIG="" +MEM0_CWD_RESOLVED=$(echo "$INPUT" | jq -r '.cwd // "."' 2>/dev/null || echo ".") +if command -v python3 >/dev/null 2>&1; then + MEM0_PROJECT_CONFIG=$(python3 "$SCRIPT_DIR/parse_mem0_config.py" --full "$MEM0_CWD_RESOLVED" 2>/dev/null || echo "{}") +fi +if [ -n "$MEM0_PROJECT_CONFIG" ] && [ "$MEM0_PROJECT_CONFIG" != "{}" ]; then + echo "### Project Config (mem0.md)" + echo "\`\`\`json" + echo "$MEM0_PROJECT_CONFIG" + echo "\`\`\`" + echo "" +fi + if [ "$SOURCE" = "startup" ]; then # First-run detection: auto-trigger onboarding for new projects _SAFE_PID=$(printf '%s' "$MEM0_PROJECT_ID" | tr '/:' '--') diff --git a/mem0-plugin/scripts/parse_mem0_config.py b/mem0-plugin/scripts/parse_mem0_config.py index 0e334936b..6b93b0999 100644 --- a/mem0-plugin/scripts/parse_mem0_config.py +++ b/mem0-plugin/scripts/parse_mem0_config.py @@ -91,19 +91,92 @@ def parse_retention(content: str) -> dict[str, int | None]: return policies +def parse_section_kv(content: str, heading: str) -> dict[str, str]: + """Parse a key-value section from mem0.md. + + Looks for ``## `` (case-insensitive) and reads ``key: value`` + lines until the next ``##``-level heading or end of string. + """ + pattern = rf"^##\s+{re.escape(heading)}[^\n]*\n(.*?)(?=^##\s|\Z)" + match = re.search(pattern, content, flags=re.MULTILINE | re.DOTALL | re.IGNORECASE) + if not match: + return {} + + result: dict[str, str] = {} + for line in match.group(1).splitlines(): + line = re.sub(r"#.*$", "", line).strip() + if not line: + continue + m = re.match(r"^([^:]+):\s*(.+)$", line) + if m: + result[m.group(1).strip()] = m.group(2).strip() + return result + + +def parse_section_list(content: str, heading: str) -> list[str]: + """Parse a list section from mem0.md. + + Looks for ``## `` and reads ``- item`` or bare lines. + """ + pattern = rf"^##\s+{re.escape(heading)}[^\n]*\n(.*?)(?=^##\s|\Z)" + match = re.search(pattern, content, flags=re.MULTILINE | re.DOTALL | re.IGNORECASE) + if not match: + return [] + + items: list[str] = [] + for line in match.group(1).splitlines(): + line = re.sub(r"#.*$", "", line).strip() + line = re.sub(r"^[-*]\s+", "", line).strip() + if line: + items.append(line) + return items + + +def load_full_config(cwd: str | None = None) -> dict: + """Load all config sections from mem0.md. + + Returns a dict with keys: retention, search, categories, identity. + Each is populated only if the corresponding ``##`` section exists. + """ + if cwd is None: + cwd = os.getcwd() + + config_path = find_mem0_config(cwd) + if config_path is None: + return {} + + try: + with open(config_path, encoding="utf-8") as fh: + content = fh.read() + except OSError: + return {} + + config: dict = {} + + retention = parse_retention(content) + if retention: + config["retention"] = retention + + search = parse_section_kv(content, "Search") + if search: + config["search"] = search + + categories = parse_section_list(content, "Categories") + if categories: + config["categories"] = categories + + identity = parse_section_kv(content, "Identity") + if identity: + config["identity"] = identity + + return config + + def load_retention_policies(cwd: str | None = None) -> dict[str, int | None]: """Load retention policies from the mem0.md in *cwd*. Combines :func:`find_mem0_config` and :func:`parse_retention` into a single convenience function. - - Args: - cwd: Directory to search. Defaults to ``os.getcwd()``. - - Returns: - Retention dict (category → days or ``None``). Empty dict if no - ``mem0.md`` exists in *cwd* or it contains no ``## Retention`` - section. """ if cwd is None: cwd = os.getcwd() @@ -124,12 +197,18 @@ def load_retention_policies(cwd: str | None = None) -> dict[str, int | None]: def main() -> int: """CLI entry point. - Reads cwd from ``sys.argv[1]`` (or ``os.getcwd()``), prints JSON to - stdout. + With ``--full``, prints the complete config. Without it, prints only + retention policies (backward-compatible). """ - cwd = sys.argv[1] if len(sys.argv) > 1 else os.getcwd() - policies = load_retention_policies(cwd) - print(json.dumps(policies)) + full_mode = "--full" in sys.argv + args = [a for a in sys.argv[1:] if not a.startswith("--")] + cwd = args[0] if args else os.getcwd() + + if full_mode: + config = load_full_config(cwd) + else: + config = load_retention_policies(cwd) + print(json.dumps(config)) return 0 diff --git a/mem0-plugin/skills/mem0-forget/SKILL.md b/mem0-plugin/skills/mem0-forget/SKILL.md new file mode 100644 index 000000000..8c29a7e05 --- /dev/null +++ b/mem0-plugin/skills/mem0-forget/SKILL.md @@ -0,0 +1,61 @@ +--- +name: mem0-forget +description: > + Delete memories by search query or memory ID. Shows matches for confirmation + before deleting. Safe — always confirms before destructive action. + TRIGGER: user runs /mem0:forget , or says "forget this", "delete memory", + "remove that memory about X". +--- + +# Mem0 Forget + +Delete specific memories from mem0. + +## Execution + +### Step 1: Parse input + +The user provides either: +- A search query: `/mem0:forget auth module decisions` +- A memory ID: `/mem0:forget ` + +If no argument, ask: "What should I forget? Provide a search query or memory ID." + +### Step 2: Find memories + +**If memory ID provided** (looks like a UUID or hex string): +- Call `get_memory` with the ID to verify it exists. +- Show: `Found: "" (created )` + +**If search query provided:** +- Call `search_memories` with: + - `query=` + - `user_id=` + - `filters={"AND": [{"user_id": ""}, {"app_id": ""}]}` + - `limit=10` +- Show numbered list: + ``` + Found memories matching "": + 1. (type: , created: ) [ID: ] + 2. ... + ``` + +### Step 3: Confirm + +Ask: "Delete which memories? Enter numbers (e.g., 1,3,5), 'all', or 'cancel'." + +For a single memory ID, ask: "Delete this memory? [y/N]" + +**Never delete without confirmation.** This is destructive. + +### Step 4: Delete + +For each confirmed memory, call `delete_memory` with the memory ID. + +### Step 5: Report + +``` +Deleted memories. +``` + +If any deletions failed, report which ones and why. diff --git a/mem0-plugin/skills/mem0-peek/SKILL.md b/mem0-plugin/skills/mem0-peek/SKILL.md new file mode 100644 index 000000000..f6ea48133 --- /dev/null +++ b/mem0-plugin/skills/mem0-peek/SKILL.md @@ -0,0 +1,46 @@ +--- +name: mem0-peek +description: > + Quick search — compact one-liner results. Faster and lighter than /mem0:tour. + Takes a search query as argument. + TRIGGER: user runs /mem0:peek , or says "quick search mem0", + "what do we know about X", "peek at memories about Y". +--- + +# Mem0 Peek + +Quick search with compact output. Lighter than `/mem0:tour`. + +## Execution + +### Step 1: Parse query + +The user provides a search query: `/mem0:peek auth middleware` + +If no query provided, ask: "What should I search for?" + +### Step 2: Search + +Run 2 parallel `search_memories` calls: + +1. Broad: `query=`, `filters={"AND": [{"user_id": ""}, {"app_id": ""}]}`, `limit=10` +2. Targeted: `query=`, `filters={"AND": [{"user_id": ""}, {"app_id": ""}, {"metadata": {"type": "decision"}}]}`, `limit=5` + +### Step 3: Display + +Deduplicate by ID, then show compact results: + +``` +## mem0 peek: "" ( results) + +1. [decision] Auth module uses JWT with RS256 keys (2025-05-15) [mem0:a3f8b2c1] +2. [anti_pattern] Don't use symmetric HS256 — leaked in env (2025-05-10) [mem0:7e2d9f4a] +3. [convention] All middleware in src/middleware/ (2025-05-08) [mem0:c4d5e6f7] +``` + +Format: `. [] () [mem0:]` + +If no results: +``` +No memories matching "" for project . +``` diff --git a/mem0-plugin/skills/mem0-pin/SKILL.md b/mem0-plugin/skills/mem0-pin/SKILL.md new file mode 100644 index 000000000..0dd33af93 --- /dev/null +++ b/mem0-plugin/skills/mem0-pin/SKILL.md @@ -0,0 +1,48 @@ +--- +name: mem0-pin +description: > + Pin important memories so they surface prominently. Updates metadata to mark + a memory as pinned. Pinned memories should be prioritized during search. + TRIGGER: user runs /mem0:pin , or says "pin this memory", + "mark as important", "always remember this". +--- + +# Mem0 Pin + +Pin a memory to mark it as high-priority. + +## Execution + +### Step 1: Find the memory + +The user provides either a search query or memory ID. + +**If memory ID:** +- Call `get_memory` with the ID. + +**If search query:** +- Call `search_memories` with the query, `user_id`, `app_id`, `limit=5`. +- Show numbered list with content previews. +- Ask: "Which memory to pin? Enter a number." + +### Step 2: Pin it + +Call `update_memory` with: +- `memory_id=` +- `metadata={"pinned": true}` + +The `pinned: true` metadata flag signals importance. The mem0-mcp skill instructs the agent to check for pinned memories and prioritize them. + +### Step 3: Confirm + +``` +Pinned: "..." +Memory ID: +Pinned memories surface first when relevant to a search. +``` + +### Unpin + +If the user says "unpin" or the memory is already pinned: +- Call `update_memory` with `metadata={"pinned": false}` +- Print: `Unpinned: "..."` diff --git a/mem0-plugin/skills/mem0-remember/SKILL.md b/mem0-plugin/skills/mem0-remember/SKILL.md new file mode 100644 index 000000000..e465d5fcb --- /dev/null +++ b/mem0-plugin/skills/mem0-remember/SKILL.md @@ -0,0 +1,53 @@ +--- +name: mem0-remember +description: > + Quick-add a memory from the user's input. No extraction pass — stores verbatim. + TRIGGER: user runs /mem0:remember , or says "remember this", "save this", + "store this fact", "don't forget that". +--- + +# Mem0 Remember + +Store a fact or learning directly into mem0. + +## Execution + +### Step 1: Extract the content + +The user provides the content as an argument: `/mem0:remember ` + +If no text was provided, ask: "What should I remember?" + +### Step 2: Classify the memory + +Based on the content, pick the best `metadata.type`: + +| Content signal | Type | +|---|---| +| "we decided...", "always use...", "never..." | `decision` | +| "X doesn't work because...", "don't try..." | `anti_pattern` | +| "I prefer...", "use X instead of Y" | `user_preference` | +| "the convention is...", "we always..." | `convention` | +| "learned that...", "figured out..." | `task_learning` | +| setup, env, tooling, config | `environmental` | +| anything else | `task_learning` | + +### Step 3: Store + +Call `add_memory` with: +- `messages=[{"role": "user", "content": ""}]` +- `user_id=` +- `app_id=` +- `metadata={"type": "", "branch": "", "confidence": 1.0, "source": "remember_command"}` +- `infer=False` + +`infer=False` because the user stated the fact explicitly — no extraction needed. +`confidence=1.0` because the user explicitly asked to store this. + +### Step 4: Confirm + +Print: +``` +Remembered as : "..." +Memory ID: +``` diff --git a/mem0-plugin/tests/test_parse_mem0_config.py b/mem0-plugin/tests/test_parse_mem0_config.py index 719c7b7f2..d720f7802 100644 --- a/mem0-plugin/tests/test_parse_mem0_config.py +++ b/mem0-plugin/tests/test_parse_mem0_config.py @@ -248,3 +248,177 @@ def test_cli_main_no_file_prints_empty_json(tmp_path): ) assert result.returncode == 0 assert json.loads(result.stdout) == {} + + +# --------------------------------------------------------------------------- +# parse_section_kv — unit tests +# --------------------------------------------------------------------------- + + +def test_parse_section_kv_basic(): + """parse_section_kv extracts key-value pairs from a named section.""" + from parse_mem0_config import parse_section_kv + + content = """\ +## Search + +default_limit: 10 +boost_recency: true +""" + result = parse_section_kv(content, "Search") + assert result == {"default_limit": "10", "boost_recency": "true"} + + +def test_parse_section_kv_missing_section(): + """parse_section_kv returns {} when section doesn't exist.""" + from parse_mem0_config import parse_section_kv + + result = parse_section_kv("## Other\nfoo: bar\n", "Search") + assert result == {} + + +def test_parse_section_kv_stops_at_next_heading(): + """parse_section_kv stops at the next ## heading.""" + from parse_mem0_config import parse_section_kv + + content = """\ +## Identity + +user_id: kartik +project_id: mem0 + +## Other + +ignored: yes +""" + result = parse_section_kv(content, "Identity") + assert result == {"user_id": "kartik", "project_id": "mem0"} + assert "ignored" not in result + + +# --------------------------------------------------------------------------- +# parse_section_list — unit tests +# --------------------------------------------------------------------------- + + +def test_parse_section_list_basic(): + """parse_section_list extracts list items from a named section.""" + from parse_mem0_config import parse_section_list + + content = """\ +## Categories + +- architecture_decisions +- bug_fixes +- coding_conventions +""" + result = parse_section_list(content, "Categories") + assert result == ["architecture_decisions", "bug_fixes", "coding_conventions"] + + +def test_parse_section_list_bare_lines(): + """parse_section_list works with bare lines (no bullet prefix).""" + from parse_mem0_config import parse_section_list + + content = """\ +## Categories + +architecture_decisions +bug_fixes +""" + result = parse_section_list(content, "Categories") + assert result == ["architecture_decisions", "bug_fixes"] + + +def test_parse_section_list_missing_section(): + """parse_section_list returns [] when section doesn't exist.""" + from parse_mem0_config import parse_section_list + + result = parse_section_list("## Other\n- foo\n", "Categories") + assert result == [] + + +# --------------------------------------------------------------------------- +# load_full_config — integration tests +# --------------------------------------------------------------------------- + + +def test_load_full_config_all_sections(tmp_path): + """load_full_config extracts all sections from mem0.md.""" + from parse_mem0_config import load_full_config + + mem0_md = tmp_path / "mem0.md" + mem0_md.write_text( + """\ +# My Project + +## Retention + +session_state: 90d +decision: forever + +## Search + +default_limit: 20 +boost_recency: true + +## Categories + +- architecture_decisions +- bug_fixes +- security_constraints + +## Identity + +user_id: kartik +project_id: my-project +""", + encoding="utf-8", + ) + + config = load_full_config(str(tmp_path)) + assert config["retention"] == {"session_state": 90, "decision": None} + assert config["search"] == {"default_limit": "20", "boost_recency": "true"} + assert config["categories"] == ["architecture_decisions", "bug_fixes", "security_constraints"] + assert config["identity"] == {"user_id": "kartik", "project_id": "my-project"} + + +def test_load_full_config_partial_sections(tmp_path): + """load_full_config only includes sections that exist.""" + from parse_mem0_config import load_full_config + + mem0_md = tmp_path / "mem0.md" + mem0_md.write_text("## Retention\nsession_state: 30d\n", encoding="utf-8") + + config = load_full_config(str(tmp_path)) + assert "retention" in config + assert "search" not in config + assert "categories" not in config + assert "identity" not in config + + +def test_load_full_config_no_file(tmp_path): + """load_full_config returns {} when no mem0.md exists.""" + from parse_mem0_config import load_full_config + + config = load_full_config(str(tmp_path)) + assert config == {} + + +def test_cli_full_flag(tmp_path): + """CLI: --full prints all sections as JSON.""" + mem0_md = tmp_path / "mem0.md" + mem0_md.write_text( + "## Retention\nsession_state: 90d\n\n## Search\nlimit: 10\n", + encoding="utf-8", + ) + + result = subprocess.run( + [sys.executable, os.path.join(SCRIPTS_DIR, "parse_mem0_config.py"), "--full", str(tmp_path)], + capture_output=True, + text=True, + ) + assert result.returncode == 0 + data = json.loads(result.stdout) + assert "retention" in data + assert "search" in data