feat(mem0-plugin): power-user skills + expanded mem0.md config (Tier 8)
Power-user tier: - /mem0:remember: quick-add with auto-classification, infer=False, confidence=1.0 - /mem0:forget: search-confirm-delete flow with numbered list - /mem0:pin: mark memories as pinned via metadata update - /mem0:peek: compact one-liner search results - parse_mem0_config.py: expanded to parse ## Search, ## Categories, ## Identity - on_session_start.sh: inject mem0.md project config into session context
This commit is contained in:
@@ -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 '/:' '--')
|
||||
|
||||
@@ -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 ``## <heading>`` (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 ``## <heading>`` 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
|
||||
|
||||
|
||||
|
||||
@@ -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 <query>, 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 <memory_id>`
|
||||
|
||||
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: "<memory content first 120 chars>" (created <date>)`
|
||||
|
||||
**If search query provided:**
|
||||
- Call `search_memories` with:
|
||||
- `query=<user's query>`
|
||||
- `user_id=<active_user_id>`
|
||||
- `filters={"AND": [{"user_id": "<id>"}, {"app_id": "<project_id>"}]}`
|
||||
- `limit=10`
|
||||
- Show numbered list:
|
||||
```
|
||||
Found <N> memories matching "<query>":
|
||||
1. <content, 120 chars> (type: <type>, created: <date>) [ID: <short_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 <N> memories.
|
||||
```
|
||||
|
||||
If any deletions failed, report which ones and why.
|
||||
@@ -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 <query>, 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=<user's query>`, `filters={"AND": [{"user_id": "<id>"}, {"app_id": "<pid>"}]}`, `limit=10`
|
||||
2. Targeted: `query=<user's query>`, `filters={"AND": [{"user_id": "<id>"}, {"app_id": "<pid>"}, {"metadata": {"type": "decision"}}]}`, `limit=5`
|
||||
|
||||
### Step 3: Display
|
||||
|
||||
Deduplicate by ID, then show compact results:
|
||||
|
||||
```
|
||||
## mem0 peek: "<query>" (<N> 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: `<number>. [<type>] <content, 80 chars> (<date>) [mem0:<short_id>]`
|
||||
|
||||
If no results:
|
||||
```
|
||||
No memories matching "<query>" for project <project_id>.
|
||||
```
|
||||
@@ -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 <query or ID>, 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=<selected_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 content, first 80 chars>..."
|
||||
Memory ID: <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: "<content>..."`
|
||||
@@ -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 <text>, 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 <text>`
|
||||
|
||||
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": "<the user's text>"}]`
|
||||
- `user_id=<active_user_id>`
|
||||
- `app_id=<active_project_id>`
|
||||
- `metadata={"type": "<classified_type>", "branch": "<active_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 <type>: "<first 80 chars of content>..."
|
||||
Memory ID: <id>
|
||||
```
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user