feat:初步实现记忆skill
This commit is contained in:
parent
a4c65ee814
commit
b419e988e6
|
|
@ -0,0 +1,130 @@
|
||||||
|
---
|
||||||
|
name: memory
|
||||||
|
description: >
|
||||||
|
This skill should be used when the user wants to store, retrieve, update, or delete
|
||||||
|
persistent memories with hierarchical categories. Supports MCP JSON-RPC interface
|
||||||
|
and CLI management with multi-format export.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Memory Manager
|
||||||
|
|
||||||
|
Hierarchical memory storage with categories, MCP support, and multi-format export.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
memory/
|
||||||
|
├── SKILL.md
|
||||||
|
├── memories/ # Root storage
|
||||||
|
│ ├── index.md # Root index
|
||||||
|
│ ├── work/ # Category: work
|
||||||
|
│ │ ├── project-a/ # Sub-category
|
||||||
|
│ │ │ └── <id>.md # Memories
|
||||||
|
│ │ └── <id>.md
|
||||||
|
│ ├── personal/ # Category: personal
|
||||||
|
│ └── preferences/ # Category: preferences
|
||||||
|
└── scripts/
|
||||||
|
└── store.py # Main storage (CLI + MCP)
|
||||||
|
```
|
||||||
|
|
||||||
|
## When to Use
|
||||||
|
|
||||||
|
- User asks to "remember", "save", or "store" something
|
||||||
|
- User wants to "recall" or "retrieve" stored information
|
||||||
|
- User wants to organize memories into categories
|
||||||
|
- User asks to "forget" or "delete" stored information
|
||||||
|
- Export memories in different formats
|
||||||
|
|
||||||
|
## CLI Usage
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Add memory to category
|
||||||
|
python store.py add -t "API Key" -c "sk-xxx" --category work/secrets --tags secret api
|
||||||
|
|
||||||
|
# Get memory
|
||||||
|
python store.py get abc12345 --category work/secrets
|
||||||
|
|
||||||
|
# List all (recursive)
|
||||||
|
python store.py list
|
||||||
|
|
||||||
|
# List specific category (no recursion)
|
||||||
|
python store.py list --category work --no-recursive
|
||||||
|
|
||||||
|
# Search in category
|
||||||
|
python store.py search "api" --category work
|
||||||
|
|
||||||
|
# Update memory
|
||||||
|
python store.py update abc12345 -c "new content"
|
||||||
|
python store.py update abc12345 --move new/category # Move to category
|
||||||
|
|
||||||
|
# Delete memory
|
||||||
|
python store.py delete abc12345
|
||||||
|
|
||||||
|
# List category structure
|
||||||
|
python store.py ls /
|
||||||
|
python store.py ls work
|
||||||
|
|
||||||
|
# Create/remove category
|
||||||
|
python store.py mkdir new/category
|
||||||
|
python store.py rmdir empty/category
|
||||||
|
|
||||||
|
# Export
|
||||||
|
python store.py export --format json -o memories.json
|
||||||
|
python store.py export --format markdown -o memories.md
|
||||||
|
python store.py export --format csv -o memories.csv
|
||||||
|
python store.py export --category work --format json
|
||||||
|
```
|
||||||
|
|
||||||
|
## Category Structure
|
||||||
|
|
||||||
|
- **Root (`/`)**: Default, memories without category
|
||||||
|
- **Nested (`work/projects/api`)**: Hierarchical categories using `/` separator
|
||||||
|
- **Case-sensitive**: `Work` ≠ `work`
|
||||||
|
- **Auto-create**: Parent categories created automatically
|
||||||
|
|
||||||
|
## Memory File Format
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
---
|
||||||
|
category: work/projects
|
||||||
|
created: 2024-01-01T00:00:00
|
||||||
|
id: abc12345
|
||||||
|
tags: [api, backend]
|
||||||
|
title: API Design
|
||||||
|
updated: 2024-01-01T00:00:00
|
||||||
|
---
|
||||||
|
|
||||||
|
Memory content goes here.
|
||||||
|
```
|
||||||
|
|
||||||
|
## MCP JSON-RPC Interface
|
||||||
|
|
||||||
|
Run as MCP server: `python store.py --mcp`
|
||||||
|
|
||||||
|
### Methods
|
||||||
|
|
||||||
|
| Method | Parameters | Description |
|
||||||
|
|--------|-----------|-------------|
|
||||||
|
| `add` | `title`, `content`, `category?`, `tags?` | Add memory |
|
||||||
|
| `get` | `id`, `category?` | Get memory |
|
||||||
|
| `list` | `category?`, `recursive?` | List memories |
|
||||||
|
| `search` | `query`, `category?` | Search memories |
|
||||||
|
| `update` | `id`, `category?`, `title?`, `content?`, `tags?`, `new_category?` | Update/Move |
|
||||||
|
| `delete` | `id`, `category?` | Delete memory |
|
||||||
|
| `ls` | `category?` | List category structure |
|
||||||
|
| `mkdir` | `category` | Create category |
|
||||||
|
| `rmdir` | `category` | Remove empty category |
|
||||||
|
| `export` | `format?`, `category?` | Export memories |
|
||||||
|
|
||||||
|
## Export Formats
|
||||||
|
|
||||||
|
- **JSON**: Array of memory objects
|
||||||
|
- **Markdown**: Human-readable with category sections
|
||||||
|
- **CSV**: Spreadsheet-compatible
|
||||||
|
|
||||||
|
## Tips
|
||||||
|
|
||||||
|
- Use categories to organize: `work/`, `personal/`, `preferences/`
|
||||||
|
- Add tags for cross-category search
|
||||||
|
- Use `ls /` to view full structure
|
||||||
|
- Export specific category: `--category work`
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
# Memory Index
|
||||||
|
|
||||||
|
| ID | Title | Tags |
|
||||||
|
|---|-------|------|
|
||||||
|
|
@ -0,0 +1,258 @@
|
||||||
|
"""Memory Manager - CLI tool for managing persistent memories."""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
MEMORY_DIR = Path(__file__).parent.parent / "memories"
|
||||||
|
INDEX_FILE = MEMORY_DIR / "index.md"
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_dirs():
|
||||||
|
"""Ensure memory directory exists."""
|
||||||
|
MEMORY_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
if not INDEX_FILE.exists():
|
||||||
|
INDEX_FILE.write_text("# Memory Index\n\n", encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def generate_id(title: str) -> str:
|
||||||
|
"""Generate a short ID from title."""
|
||||||
|
import hashlib
|
||||||
|
return hashlib.md5(title.encode()).hexdigest()[:8]
|
||||||
|
|
||||||
|
|
||||||
|
def parse_frontmatter(content: str) -> tuple[dict, str]:
|
||||||
|
"""Parse YAML frontmatter from markdown content."""
|
||||||
|
pattern = r'^---\n(.*?)\n---\n?(.*)$'
|
||||||
|
match = re.match(pattern, content, re.DOTALL)
|
||||||
|
if match:
|
||||||
|
frontmatter_text = match.group(1)
|
||||||
|
body = match.group(2)
|
||||||
|
|
||||||
|
fm = {}
|
||||||
|
for line in frontmatter_text.split('\n'):
|
||||||
|
if ':' in line:
|
||||||
|
key, value = line.split(':', 1)
|
||||||
|
key = key.strip()
|
||||||
|
value = value.strip().strip('[]"\'|')
|
||||||
|
if value.startswith('['):
|
||||||
|
fm[key] = [v.strip().strip('"\'') for v in value[1:-1].split(',')]
|
||||||
|
else:
|
||||||
|
fm[key] = value
|
||||||
|
return fm, body
|
||||||
|
return {}, content
|
||||||
|
|
||||||
|
|
||||||
|
def create_frontmatter(data: dict, body: str) -> str:
|
||||||
|
"""Create markdown with frontmatter."""
|
||||||
|
lines = ["---"]
|
||||||
|
for key, value in data.items():
|
||||||
|
if isinstance(value, list):
|
||||||
|
lines.append(f"{key}: [{', '.join(value)}]")
|
||||||
|
else:
|
||||||
|
lines.append(f"{key}: {value}")
|
||||||
|
lines.append("---\n")
|
||||||
|
if body.strip():
|
||||||
|
lines.append(body.strip())
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def add_memory(title: str, content: str, tags: list[str] = None) -> str:
|
||||||
|
"""Add a new memory."""
|
||||||
|
ensure_dirs()
|
||||||
|
|
||||||
|
memory_id = generate_id(title)
|
||||||
|
filepath = MEMORY_DIR / f"{memory_id}.md"
|
||||||
|
|
||||||
|
if filepath.exists():
|
||||||
|
print(f"Memory with title '{title}' already exists as {memory_id}", file=sys.stderr)
|
||||||
|
return memory_id
|
||||||
|
|
||||||
|
now = datetime.now().isoformat()
|
||||||
|
data = {
|
||||||
|
"id": memory_id,
|
||||||
|
"title": title,
|
||||||
|
"created": now,
|
||||||
|
"updated": now,
|
||||||
|
"tags": tags or []
|
||||||
|
}
|
||||||
|
|
||||||
|
md_content = create_frontmatter(data, content)
|
||||||
|
filepath.write_text(md_content, encoding="utf-8")
|
||||||
|
|
||||||
|
update_index(memory_id, title, tags or [])
|
||||||
|
|
||||||
|
print(f"Memory added: {memory_id}")
|
||||||
|
return memory_id
|
||||||
|
|
||||||
|
|
||||||
|
def update_index(memory_id: str, title: str, tags: list[str]):
|
||||||
|
"""Update the index file."""
|
||||||
|
lines = ["# Memory Index\n", "| ID | Title | Tags |", "|---|-------|------|"]
|
||||||
|
|
||||||
|
for md_file in sorted(MEMORY_DIR.glob("*.md")):
|
||||||
|
if md_file.name == "index.md":
|
||||||
|
continue
|
||||||
|
fm, _ = parse_frontmatter(md_file.read_text(encoding="utf-8"))
|
||||||
|
tags_str = ", ".join(fm.get("tags", []))
|
||||||
|
lines.append(f"| {fm.get('id', '')} | {fm.get('title', '')} | {tags_str} |")
|
||||||
|
|
||||||
|
INDEX_FILE.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def get_memory(memory_id: str):
|
||||||
|
"""Get a specific memory."""
|
||||||
|
filepath = MEMORY_DIR / f"{memory_id}.md"
|
||||||
|
if not filepath.exists():
|
||||||
|
print(f"Memory {memory_id} not found", file=sys.stderr)
|
||||||
|
return None
|
||||||
|
|
||||||
|
content = filepath.read_text(encoding="utf-8")
|
||||||
|
fm, body = parse_frontmatter(content)
|
||||||
|
|
||||||
|
print(f"# {fm.get('title', 'Untitled')}")
|
||||||
|
print(f"ID: {memory_id}")
|
||||||
|
print(f"Created: {fm.get('created', 'N/A')}")
|
||||||
|
print(f"Updated: {fm.get('updated', 'N/A')}")
|
||||||
|
print(f"Tags: {', '.join(fm.get('tags', []))}")
|
||||||
|
print(f"\n---\n{body}")
|
||||||
|
return fm
|
||||||
|
|
||||||
|
|
||||||
|
def list_memories():
|
||||||
|
"""List all memories."""
|
||||||
|
ensure_dirs()
|
||||||
|
|
||||||
|
memories = []
|
||||||
|
for md_file in MEMORY_DIR.glob("*.md"):
|
||||||
|
if md_file.name == "index.md":
|
||||||
|
continue
|
||||||
|
fm, _ = parse_frontmatter(md_file.read_text(encoding="utf-8"))
|
||||||
|
memories.append(fm)
|
||||||
|
|
||||||
|
if not memories:
|
||||||
|
print("No memories found.")
|
||||||
|
return
|
||||||
|
|
||||||
|
print(f"{'ID':<10} {'Title':<30} {'Tags':<20} {'Updated'}")
|
||||||
|
print("-" * 80)
|
||||||
|
for m in sorted(memories, key=lambda x: x.get('updated', ''), reverse=True):
|
||||||
|
tags = ", ".join(m.get('tags', [])[:2])
|
||||||
|
print(f"{m.get('id', ''):<10} {m.get('title', ''):<30} {tags:<20} {m.get('updated', '')[:10]}")
|
||||||
|
|
||||||
|
|
||||||
|
def search_memories(query: str):
|
||||||
|
"""Search memories by keyword."""
|
||||||
|
ensure_dirs()
|
||||||
|
|
||||||
|
results = []
|
||||||
|
for md_file in MEMORY_DIR.glob("*.md"):
|
||||||
|
if md_file.name == "index.md":
|
||||||
|
continue
|
||||||
|
content = md_file.read_text(encoding="utf-8").lower()
|
||||||
|
if query.lower() in content:
|
||||||
|
fm, body = parse_frontmatter(md_file.read_text(encoding="utf-8"))
|
||||||
|
results.append(fm)
|
||||||
|
|
||||||
|
if not results:
|
||||||
|
print(f"No memories found matching '{query}'")
|
||||||
|
return
|
||||||
|
|
||||||
|
print(f"Found {len(results)} result(s):\n")
|
||||||
|
for m in results:
|
||||||
|
print(f"- [{m.get('id')}] {m.get('title')} ({', '.join(m.get('tags', []))})")
|
||||||
|
|
||||||
|
|
||||||
|
def update_memory(memory_id: str, title: Optional[str] = None, content: Optional[str] = None, tags: list[str] = None):
|
||||||
|
"""Update an existing memory."""
|
||||||
|
filepath = MEMORY_DIR / f"{memory_id}.md"
|
||||||
|
if not filepath.exists():
|
||||||
|
print(f"Memory {memory_id} not found", file=sys.stderr)
|
||||||
|
return
|
||||||
|
|
||||||
|
fm, body = parse_frontmatter(filepath.read_text(encoding="utf-8"))
|
||||||
|
|
||||||
|
if title:
|
||||||
|
fm['title'] = title
|
||||||
|
if content:
|
||||||
|
body = content
|
||||||
|
if tags is not None:
|
||||||
|
fm['tags'] = tags
|
||||||
|
|
||||||
|
fm['updated'] = datetime.now().isoformat()
|
||||||
|
|
||||||
|
filepath.write_text(create_frontmatter(fm, body), encoding="utf-8")
|
||||||
|
update_index(memory_id, fm.get('title', ''), fm.get('tags', []))
|
||||||
|
|
||||||
|
print(f"Memory updated: {memory_id}")
|
||||||
|
|
||||||
|
|
||||||
|
def delete_memory(memory_id: str):
|
||||||
|
"""Delete a memory."""
|
||||||
|
filepath = MEMORY_DIR / f"{memory_id}.md"
|
||||||
|
if not filepath.exists():
|
||||||
|
print(f"Memory {memory_id} not found", file=sys.stderr)
|
||||||
|
return
|
||||||
|
|
||||||
|
filepath.unlink()
|
||||||
|
update_index("", "", [])
|
||||||
|
print(f"Memory deleted: {memory_id}")
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description="Memory Manager CLI")
|
||||||
|
subparsers = parser.add_subparsers(dest="command", help="Commands")
|
||||||
|
|
||||||
|
# Add command
|
||||||
|
add_parser = subparsers.add_parser("add", help="Add a new memory")
|
||||||
|
add_parser.add_argument("--title", "-t", required=True, help="Memory title")
|
||||||
|
add_parser.add_argument("--content", "-c", required=True, help="Memory content")
|
||||||
|
add_parser.add_argument("--tags", nargs="*", default=[], help="Tags")
|
||||||
|
|
||||||
|
# Get command
|
||||||
|
get_parser = subparsers.add_parser("get", help="Get a memory")
|
||||||
|
get_parser.add_argument("id", help="Memory ID")
|
||||||
|
|
||||||
|
# List command
|
||||||
|
subparsers.add_parser("list", help="List all memories")
|
||||||
|
|
||||||
|
# Search command
|
||||||
|
search_parser = subparsers.add_parser("search", help="Search memories")
|
||||||
|
search_parser.add_argument("query", help="Search query")
|
||||||
|
|
||||||
|
# Update command
|
||||||
|
update_parser = subparsers.add_parser("update", help="Update a memory")
|
||||||
|
update_parser.add_argument("id", help="Memory ID")
|
||||||
|
update_parser.add_argument("--title", "-t", help="New title")
|
||||||
|
update_parser.add_argument("--content", "-c", help="New content")
|
||||||
|
update_parser.add_argument("--tags", nargs="*", help="New tags")
|
||||||
|
|
||||||
|
# Delete command
|
||||||
|
delete_parser = subparsers.add_parser("delete", help="Delete a memory")
|
||||||
|
delete_parser.add_argument("id", help="Memory ID")
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
if args.command == "add":
|
||||||
|
add_memory(args.title, args.content, args.tags)
|
||||||
|
elif args.command == "get":
|
||||||
|
get_memory(args.id)
|
||||||
|
elif args.command == "list":
|
||||||
|
list_memories()
|
||||||
|
elif args.command == "search":
|
||||||
|
search_memories(args.query)
|
||||||
|
elif args.command == "update":
|
||||||
|
update_memory(args.id, args.title, args.content, args.tags)
|
||||||
|
elif args.command == "delete":
|
||||||
|
delete_memory(args.id)
|
||||||
|
else:
|
||||||
|
parser.print_help()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
|
|
@ -0,0 +1,483 @@
|
||||||
|
"""Memory Store - Hierarchical file-based memory storage with MCP support."""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
import hashlib
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional, List
|
||||||
|
|
||||||
|
# === Hierarchical Memory Store ===
|
||||||
|
|
||||||
|
class MemoryStore:
|
||||||
|
"""Hierarchical file-based memory storage with categories."""
|
||||||
|
|
||||||
|
def __init__(self, memory_dir: Path):
|
||||||
|
self.memory_dir = Path(memory_dir)
|
||||||
|
self.memory_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
def _generate_id(self, title: str) -> str:
|
||||||
|
return hashlib.md5(title.encode()).hexdigest()[:8]
|
||||||
|
|
||||||
|
def _parse_frontmatter(self, content: str) -> tuple[dict, str]:
|
||||||
|
pattern = r'^---\n(.*?)\n---\n?(.*)$'
|
||||||
|
match = re.match(pattern, content, re.DOTALL)
|
||||||
|
if match:
|
||||||
|
fm_text = match.group(1)
|
||||||
|
body = match.group(2)
|
||||||
|
fm = {}
|
||||||
|
for line in fm_text.split('\n'):
|
||||||
|
if ':' in line:
|
||||||
|
key, value = line.split(':', 1)
|
||||||
|
key = key.strip()
|
||||||
|
value = value.strip().strip('[]"\'')
|
||||||
|
if value.startswith('['):
|
||||||
|
fm[key] = [v.strip().strip('"\'') for v in value[1:-1].split(',') if v.strip()]
|
||||||
|
else:
|
||||||
|
fm[key] = value
|
||||||
|
return fm, body
|
||||||
|
return {}, content
|
||||||
|
|
||||||
|
def _create_frontmatter(self, data: dict, body: str) -> str:
|
||||||
|
lines = ["---"]
|
||||||
|
for key, value in sorted(data.items()):
|
||||||
|
if isinstance(value, list):
|
||||||
|
lines.append(f"{key}: [{', '.join(value)}]")
|
||||||
|
else:
|
||||||
|
lines.append(f"{key}: {value}")
|
||||||
|
lines.append("---\n")
|
||||||
|
if body.strip():
|
||||||
|
lines.append(body.strip())
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
def _get_category_path(self, category: str = None) -> Path:
|
||||||
|
if not category or category == "/":
|
||||||
|
return self.memory_dir
|
||||||
|
path = self.memory_dir / category.lstrip("/")
|
||||||
|
path.mkdir(parents=True, exist_ok=True)
|
||||||
|
return path
|
||||||
|
|
||||||
|
def _get_memory_path(self, category: str, memory_id: str) -> Path:
|
||||||
|
return self._get_category_path(category) / f"{memory_id}.md"
|
||||||
|
|
||||||
|
def _relpath(self, path: Path) -> str:
|
||||||
|
"""Get relative path from memory_dir."""
|
||||||
|
try:
|
||||||
|
return str(path.relative_to(self.memory_dir)).replace("\\", "/")
|
||||||
|
except ValueError:
|
||||||
|
return str(path)
|
||||||
|
|
||||||
|
def add(self, title: str, content: str, category: str = None, tags: List[str] = None, id: str = None) -> str:
|
||||||
|
"""Add memory to category."""
|
||||||
|
memory_id = id or self._generate_id(title)
|
||||||
|
filepath = self._get_memory_path(category, memory_id)
|
||||||
|
|
||||||
|
now = datetime.now().isoformat()
|
||||||
|
data = {
|
||||||
|
"id": memory_id,
|
||||||
|
"title": title,
|
||||||
|
"created": now,
|
||||||
|
"updated": now,
|
||||||
|
"tags": tags or [],
|
||||||
|
"category": category or "/"
|
||||||
|
}
|
||||||
|
|
||||||
|
filepath.write_text(self._create_frontmatter(data, content), encoding="utf-8")
|
||||||
|
return memory_id
|
||||||
|
|
||||||
|
def get(self, memory_id: str, category: str = None) -> Optional[dict]:
|
||||||
|
"""Get memory by ID, optionally in category."""
|
||||||
|
if category:
|
||||||
|
filepath = self._get_memory_path(category, memory_id)
|
||||||
|
if filepath.exists():
|
||||||
|
return self._read_memory(filepath)
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Search all categories
|
||||||
|
for md_file in self.memory_dir.rglob("*.md"):
|
||||||
|
fm, _ = self._parse_frontmatter(md_file.read_text(encoding="utf-8"))
|
||||||
|
if fm.get("id") == memory_id:
|
||||||
|
result = fm
|
||||||
|
result["content"] = self._read_memory(md_file)["content"]
|
||||||
|
return result
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _read_memory(self, filepath: Path) -> dict:
|
||||||
|
fm, body = self._parse_frontmatter(filepath.read_text(encoding="utf-8"))
|
||||||
|
fm["content"] = body.strip()
|
||||||
|
fm["category"] = self._relpath(filepath.parent)
|
||||||
|
return fm
|
||||||
|
|
||||||
|
def list(self, category: str = None, recursive: bool = True) -> List[dict]:
|
||||||
|
"""List memories in category."""
|
||||||
|
memories = []
|
||||||
|
cat_path = self._get_category_path(category)
|
||||||
|
|
||||||
|
pattern = "*.md" if recursive else "*.md"
|
||||||
|
for md_file in cat_path.glob(pattern):
|
||||||
|
if md_file.name.startswith("_"):
|
||||||
|
continue
|
||||||
|
fm, _ = self._parse_frontmatter(md_file.read_text(encoding="utf-8"))
|
||||||
|
fm["category"] = self._relpath(md_file.parent)
|
||||||
|
memories.append(fm)
|
||||||
|
|
||||||
|
return sorted(memories, key=lambda x: (x.get('category', ''), x.get('updated', '')), reverse=True)
|
||||||
|
|
||||||
|
def search(self, query: str, category: str = None) -> List[dict]:
|
||||||
|
"""Search memories."""
|
||||||
|
results = []
|
||||||
|
query_lower = query.lower()
|
||||||
|
base_path = self._get_category_path(category)
|
||||||
|
|
||||||
|
for md_file in base_path.rglob("*.md"):
|
||||||
|
if md_file.name.startswith("_"):
|
||||||
|
continue
|
||||||
|
content = md_file.read_text(encoding="utf-8").lower()
|
||||||
|
if query_lower in content:
|
||||||
|
fm, body = self._parse_frontmatter(md_file.read_text(encoding="utf-8"))
|
||||||
|
fm["content"] = body.strip()
|
||||||
|
fm["category"] = self._relpath(md_file.parent)
|
||||||
|
results.append(fm)
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
def update(self, memory_id: str, category: str = None, title: str = None,
|
||||||
|
content: str = None, tags: List[str] = None, new_category: str = None) -> bool:
|
||||||
|
"""Update memory, optionally move to new category."""
|
||||||
|
old_path = self._get_memory_path(category, memory_id)
|
||||||
|
if not old_path.exists():
|
||||||
|
# Try search across all
|
||||||
|
for md_file in self.memory_dir.rglob("*.md"):
|
||||||
|
fm, _ = self._parse_frontmatter(md_file.read_text(encoding="utf-8"))
|
||||||
|
if fm.get("id") == memory_id:
|
||||||
|
old_path = md_file
|
||||||
|
category = self._relpath(md_file.parent)
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
return False
|
||||||
|
|
||||||
|
fm, body = self._parse_frontmatter(old_path.read_text(encoding="utf-8"))
|
||||||
|
|
||||||
|
if title is not None:
|
||||||
|
fm['title'] = title
|
||||||
|
if content is not None:
|
||||||
|
body = content
|
||||||
|
if tags is not None:
|
||||||
|
fm['tags'] = tags
|
||||||
|
|
||||||
|
target_cat = new_category if new_category is not None else category
|
||||||
|
fm['updated'] = datetime.now().isoformat()
|
||||||
|
fm['category'] = target_cat or "/"
|
||||||
|
|
||||||
|
new_path = self._get_memory_path(target_cat, memory_id)
|
||||||
|
|
||||||
|
if new_path != old_path:
|
||||||
|
old_path.unlink()
|
||||||
|
|
||||||
|
new_path.write_text(self._create_frontmatter(fm, body), encoding="utf-8")
|
||||||
|
return True
|
||||||
|
|
||||||
|
def delete(self, memory_id: str, category: str = None) -> bool:
|
||||||
|
"""Delete memory."""
|
||||||
|
filepath = self._get_memory_path(category, memory_id)
|
||||||
|
if not filepath.exists():
|
||||||
|
# Search all
|
||||||
|
for md_file in self.memory_dir.rglob("*.md"):
|
||||||
|
fm, _ = self._parse_frontmatter(md_file.read_text(encoding="utf-8"))
|
||||||
|
if fm.get("id") == memory_id:
|
||||||
|
filepath = md_file
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
return False
|
||||||
|
|
||||||
|
filepath.unlink()
|
||||||
|
|
||||||
|
# Clean empty category dirs
|
||||||
|
cat_dir = filepath.parent
|
||||||
|
if cat_dir != self.memory_dir and not any(cat_dir.iterdir()):
|
||||||
|
cat_dir.rmdir()
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
def ls(self, category: str = None) -> dict:
|
||||||
|
"""List category structure (like ls -la)."""
|
||||||
|
cat_path = self._get_category_path(category)
|
||||||
|
result = {
|
||||||
|
"path": self._relpath(cat_path),
|
||||||
|
"categories": [],
|
||||||
|
"memories": []
|
||||||
|
}
|
||||||
|
|
||||||
|
for item in sorted(cat_path.iterdir()):
|
||||||
|
if item.name.startswith("_"):
|
||||||
|
continue
|
||||||
|
if item.is_dir():
|
||||||
|
result["categories"].append(item.name)
|
||||||
|
else:
|
||||||
|
fm, _ = self._parse_frontmatter(item.read_text(encoding="utf-8"))
|
||||||
|
result["memories"].append({
|
||||||
|
"id": fm.get("id"),
|
||||||
|
"title": fm.get("title"),
|
||||||
|
"tags": fm.get("tags", [])
|
||||||
|
})
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
def mkdir(self, category: str) -> bool:
|
||||||
|
"""Create category."""
|
||||||
|
cat_path = self._get_category_path(category)
|
||||||
|
if cat_path.exists():
|
||||||
|
return False
|
||||||
|
cat_path.mkdir(parents=True)
|
||||||
|
return True
|
||||||
|
|
||||||
|
def rmdir(self, category: str) -> bool:
|
||||||
|
"""Remove empty category."""
|
||||||
|
cat_path = self._get_category_path(category)
|
||||||
|
if cat_path == self.memory_dir or not cat_path.exists():
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Check empty
|
||||||
|
items = list(cat_path.iterdir())
|
||||||
|
if any(not i.name.startswith("_") for i in items):
|
||||||
|
return False
|
||||||
|
|
||||||
|
for item in items:
|
||||||
|
if item.is_file():
|
||||||
|
item.unlink()
|
||||||
|
elif item.is_dir():
|
||||||
|
import shutil
|
||||||
|
shutil.rmtree(item)
|
||||||
|
cat_path.rmdir()
|
||||||
|
return True
|
||||||
|
|
||||||
|
def export(self, format: str = "json", category: str = None) -> str:
|
||||||
|
"""Export memories."""
|
||||||
|
memories = self.list(category=category)
|
||||||
|
|
||||||
|
if format == "json":
|
||||||
|
return json.dumps(memories, indent=2, ensure_ascii=False)
|
||||||
|
elif format == "markdown":
|
||||||
|
lines = ["# Memory Export\n"]
|
||||||
|
current_cat = None
|
||||||
|
for m in memories:
|
||||||
|
cat = m.get("category", "/")
|
||||||
|
if cat != current_cat:
|
||||||
|
lines.append(f"\n## {cat}/\n")
|
||||||
|
current_cat = cat
|
||||||
|
lines.append(f"### {m.get('title', 'Untitled')}\n")
|
||||||
|
lines.append(f"**ID:** `{m.get('id', '')}` **Tags:** {', '.join(m.get('tags', []))}\n\n")
|
||||||
|
return "".join(lines)
|
||||||
|
elif format == "csv":
|
||||||
|
lines = ["id,title,tags,category,created,updated,content\n"]
|
||||||
|
for m in memories:
|
||||||
|
tags = "|".join(m.get('tags', []))
|
||||||
|
content = (m.get('content', '') or '').replace('"', '""').replace('\n', ' ')
|
||||||
|
lines.append(f'"{m.get("id", "")}","{m.get("title", "")}","{tags}","{m.get("category", "")}","{m.get("created", "")}","{m.get("updated", "")}","{content}"\n')
|
||||||
|
return "".join(lines)
|
||||||
|
return json.dumps(memories)
|
||||||
|
|
||||||
|
|
||||||
|
# === CLI Interface ===
|
||||||
|
|
||||||
|
def cli():
|
||||||
|
parser = argparse.ArgumentParser(description="Memory Store CLI")
|
||||||
|
parser.add_argument("--path", type=Path, default=Path(__file__).parent.parent / "memories")
|
||||||
|
|
||||||
|
subparsers = parser.add_subparsers(dest="command", help="Commands")
|
||||||
|
|
||||||
|
# Add
|
||||||
|
add_p = subparsers.add_parser("add", help="Add memory")
|
||||||
|
add_p.add_argument("-t", "--title", required=True)
|
||||||
|
add_p.add_argument("-c", "--content", required=True)
|
||||||
|
add_p.add_argument("--tags", nargs="*", default=[])
|
||||||
|
add_p.add_argument("--category", "-C", default="/")
|
||||||
|
|
||||||
|
# Get
|
||||||
|
get_p = subparsers.add_parser("get", help="Get memory")
|
||||||
|
get_p.add_argument("id")
|
||||||
|
get_p.add_argument("--category", "-C")
|
||||||
|
|
||||||
|
# List
|
||||||
|
list_p = subparsers.add_parser("list", help="List memories")
|
||||||
|
list_p.add_argument("--category", "-C")
|
||||||
|
list_p.add_argument("--no-recursive", action="store_true")
|
||||||
|
|
||||||
|
# Search
|
||||||
|
search_p = subparsers.add_parser("search", help="Search")
|
||||||
|
search_p.add_argument("query")
|
||||||
|
search_p.add_argument("--category", "-C")
|
||||||
|
|
||||||
|
# Update
|
||||||
|
update_p = subparsers.add_parser("update", help="Update")
|
||||||
|
update_p.add_argument("id")
|
||||||
|
update_p.add_argument("--category", "-C")
|
||||||
|
update_p.add_argument("-t", "--title")
|
||||||
|
update_p.add_argument("-c", "--content")
|
||||||
|
update_p.add_argument("--tags", nargs="*")
|
||||||
|
update_p.add_argument("--move", "-M", dest="new_category")
|
||||||
|
|
||||||
|
# Delete
|
||||||
|
delete_p = subparsers.add_parser("delete", help="Delete")
|
||||||
|
delete_p.add_argument("id")
|
||||||
|
delete_p.add_argument("--category", "-C")
|
||||||
|
|
||||||
|
# ls (tree)
|
||||||
|
ls_p = subparsers.add_parser("ls", help="List structure")
|
||||||
|
ls_p.add_argument("--category", "-C", default="/")
|
||||||
|
|
||||||
|
# mkdir
|
||||||
|
mkdir_p = subparsers.add_parser("mkdir", help="Create category")
|
||||||
|
mkdir_p.add_argument("category")
|
||||||
|
|
||||||
|
# rmdir
|
||||||
|
rmdir_p = subparsers.add_parser("rmdir", help="Remove empty category")
|
||||||
|
rmdir_p.add_argument("category")
|
||||||
|
|
||||||
|
# Export
|
||||||
|
export_p = subparsers.add_parser("export", help="Export")
|
||||||
|
export_p.add_argument("--format", choices=["json", "markdown", "csv"], default="json")
|
||||||
|
export_p.add_argument("-o", "--output", type=Path)
|
||||||
|
export_p.add_argument("--category", "-C")
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
store = MemoryStore(args.path)
|
||||||
|
|
||||||
|
if args.command == "add":
|
||||||
|
mid = store.add(args.title, args.content, args.category, args.tags)
|
||||||
|
print(f"Added: {mid} -> {args.category}")
|
||||||
|
|
||||||
|
elif args.command == "get":
|
||||||
|
m = store.get(args.id, args.category)
|
||||||
|
if m:
|
||||||
|
print(f"# {m['title']}\n")
|
||||||
|
print(f"ID: {m['id']} Category: {m.get('category', '/')} Tags: {', '.join(m.get('tags', []))}")
|
||||||
|
print(f"Created: {m['created']} Updated: {m['updated']}\n")
|
||||||
|
print("---")
|
||||||
|
print(m['content'])
|
||||||
|
else:
|
||||||
|
print(f"Not found: {args.id}", file=sys.stderr)
|
||||||
|
|
||||||
|
elif args.command == "list":
|
||||||
|
for m in store.list(args.category, not args.no_recursive):
|
||||||
|
cat = m.get('category', '/')
|
||||||
|
tags = ", ".join(m.get('tags', [])[:2])
|
||||||
|
print(f"{m['id']:<10} [{cat:<15}] {m['title']:<30} [{tags}]")
|
||||||
|
|
||||||
|
elif args.command == "search":
|
||||||
|
for m in store.search(args.query, args.category):
|
||||||
|
print(f"- [{m.get('category', '/')}] {m['id']} {m['title']} ({', '.join(m.get('tags', []))})")
|
||||||
|
|
||||||
|
elif args.command == "update":
|
||||||
|
ok = store.update(args.id, args.category, args.title, args.content, args.tags, args.new_category)
|
||||||
|
print("Updated" if ok else "Not found")
|
||||||
|
|
||||||
|
elif args.command == "delete":
|
||||||
|
ok = store.delete(args.id, args.category)
|
||||||
|
print("Deleted" if ok else "Not found")
|
||||||
|
|
||||||
|
elif args.command == "ls":
|
||||||
|
result = store.ls(args.category)
|
||||||
|
print(f"📁 {result['path']}/")
|
||||||
|
if result['categories']:
|
||||||
|
for c in result['categories']:
|
||||||
|
print(f" 📂 {c}/")
|
||||||
|
for m in result['memories']:
|
||||||
|
print(f" 📄 {m['id']} - {m['title']}")
|
||||||
|
|
||||||
|
elif args.command == "mkdir":
|
||||||
|
ok = store.mkdir(args.category)
|
||||||
|
print("Created" if ok else "Already exists")
|
||||||
|
|
||||||
|
elif args.command == "rmdir":
|
||||||
|
ok = store.rmdir(args.category)
|
||||||
|
print("Removed" if ok else "Failed (not empty or not found)")
|
||||||
|
|
||||||
|
elif args.command == "export":
|
||||||
|
output = store.export(args.format, args.category)
|
||||||
|
if args.output:
|
||||||
|
args.output.write_text(output, encoding="utf-8")
|
||||||
|
print(f"Exported to {args.output}")
|
||||||
|
else:
|
||||||
|
print(output)
|
||||||
|
|
||||||
|
else:
|
||||||
|
parser.print_help()
|
||||||
|
|
||||||
|
|
||||||
|
# === MCP JSON-RPC Server ===
|
||||||
|
|
||||||
|
def mcp_server():
|
||||||
|
"""Run as MCP JSON-RPC server (stdio)."""
|
||||||
|
store = MemoryStore(Path(__file__).parent.parent / "memories")
|
||||||
|
|
||||||
|
for line in sys.stdin:
|
||||||
|
try:
|
||||||
|
request = json.loads(line.strip())
|
||||||
|
method = request.get("method", "")
|
||||||
|
params = request.get("params", {})
|
||||||
|
req_id = request.get("id")
|
||||||
|
|
||||||
|
result = None
|
||||||
|
error = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
if method == "add":
|
||||||
|
result = store.add(
|
||||||
|
params.get("title", ""),
|
||||||
|
params.get("content", ""),
|
||||||
|
params.get("category"),
|
||||||
|
params.get("tags", [])
|
||||||
|
)
|
||||||
|
elif method == "get":
|
||||||
|
result = store.get(params.get("id", ""), params.get("category"))
|
||||||
|
elif method == "list":
|
||||||
|
result = store.list(params.get("category"), params.get("recursive", True))
|
||||||
|
elif method == "search":
|
||||||
|
result = store.search(params.get("query", ""), params.get("category"))
|
||||||
|
elif method == "update":
|
||||||
|
result = store.update(
|
||||||
|
params.get("id", ""),
|
||||||
|
params.get("category"),
|
||||||
|
params.get("title"),
|
||||||
|
params.get("content"),
|
||||||
|
params.get("tags"),
|
||||||
|
params.get("new_category")
|
||||||
|
)
|
||||||
|
elif method == "delete":
|
||||||
|
result = store.delete(params.get("id", ""), params.get("category"))
|
||||||
|
elif method == "ls":
|
||||||
|
result = store.ls(params.get("category"))
|
||||||
|
elif method == "mkdir":
|
||||||
|
result = store.mkdir(params.get("category", ""))
|
||||||
|
elif method == "rmdir":
|
||||||
|
result = store.rmdir(params.get("category", ""))
|
||||||
|
elif method == "export":
|
||||||
|
result = store.export(params.get("format", "json"), params.get("category"))
|
||||||
|
else:
|
||||||
|
error = {"code": -32601, "message": f"Unknown method: {method}"}
|
||||||
|
except Exception as e:
|
||||||
|
error = {"code": -32603, "message": str(e)}
|
||||||
|
|
||||||
|
response = {"jsonrpc": "2.0", "id": req_id}
|
||||||
|
if error:
|
||||||
|
response["error"] = error
|
||||||
|
else:
|
||||||
|
response["result"] = result
|
||||||
|
|
||||||
|
print(json.dumps(response), flush=True)
|
||||||
|
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
continue
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
if len(sys.argv) > 1 and sys.argv[1] == "--mcp":
|
||||||
|
mcp_server()
|
||||||
|
else:
|
||||||
|
cli()
|
||||||
Loading…
Reference in New Issue