diff --git a/skills/cognitive-memory/SCHEMA.md b/skills/cognitive-memory/SCHEMA.md index c5f48bd..9ffef36 100644 --- a/skills/cognitive-memory/SCHEMA.md +++ b/skills/cognitive-memory/SCHEMA.md @@ -69,11 +69,34 @@ Added socket_keepalive=True and socket_timeout=300 to Redis connection configura | `direction` | string | Yes | `outgoing` or `incoming` | | `strength` | float 0.0-1.0 | No | Relationship strength | | `context` | string (quoted) | No | Context description | +| `edge_id` | string (UUID) | No | Link to edge file for rich description | ### Relationship Types `SOLVES`, `CAUSES`, `BUILDS_ON`, `ALTERNATIVE_TO`, `REQUIRES`, `FOLLOWS`, `RELATED_TO` +### Edge File Format + +Edge files live in `graph/edges/` with full descriptions. Filename: `{from-slug}--{TYPE}--{to-slug}-{6char}.md` + +```markdown +--- +id: +type: SOLVES +from_id: +from_title: "Fixed Redis connection timeouts" +to_id: +to_title: "Redis connection drops under load" +strength: 0.8 +created: +updated: +--- + +The keepalive fix directly resolves the idle disconnection problem because... +``` + +Edge frontmatter fields: id, type, from_id, from_title, to_id, to_title, strength, created, updated. + --- ## _index.json @@ -82,9 +105,18 @@ Computed index for fast lookups. Rebuilt by `reindex` command. **Source of truth ```json { - "version": 1, + "version": 2, "updated": "2025-12-13T10:30:00+00:00", "count": 313, + "edges": { + "": { + "type": "SOLVES", + "from_id": "", + "to_id": "", + "strength": 0.8, + "path": "graph/edges/fixed-redis--SOLVES--redis-drops-a1b2c3.md" + } + }, "entries": { "a1b2c3d4-e5f6-7890-abcd-ef1234567890": { "title": "Fixed Redis connection timeouts", @@ -353,16 +385,37 @@ Standard deploy workflow for the Major Domo Discord bot. --- +## _config.json + +Embedding provider configuration. **Gitignored** (may contain API key). + +```json +{ + "embedding_provider": "ollama", + "openai_api_key": null, + "ollama_model": "nomic-embed-text", + "openai_model": "text-embedding-3-small" +} +``` + +**Notes:** +- `embedding_provider`: `"ollama"` (default) or `"openai"` +- Provider changes trigger automatic re-embedding (dimension mismatch safety: ollama=768, openai=1536) +- Configure via: `claude-memory config --provider openai --openai-key "sk-..."` + +--- + ## .gitignore ``` _state.json _index.json _embeddings.json +_config.json ``` -Only markdown files (memories, CORE.md, REFLECTION.md, episodes) are git-tracked. Index, state, and embeddings are derived/mutable data that can be regenerated. +Only markdown files (memories, CORE.md, REFLECTION.md, episodes) are git-tracked. Index, state, embeddings, and config are derived/mutable data that can be regenerated. --- -*Schema version: 2.0.0 | Created: 2026-02-13 | Updated: 2026-02-13* +*Schema version: 3.0.0 | Created: 2026-02-13 | Updated: 2026-02-19* diff --git a/skills/cognitive-memory/SKILL.md b/skills/cognitive-memory/SKILL.md index 37be681..e2aeb6e 100644 --- a/skills/cognitive-memory/SKILL.md +++ b/skills/cognitive-memory/SKILL.md @@ -73,7 +73,7 @@ claude-memory tags related "python" claude-memory tags suggest ``` -**Full command list:** `store`, `recall`, `get`, `search`, `update`, `delete`, `stats`, `recent`, `decay`, `core`, `episode`, `reindex`, `pin`, `embed`, `reflect`, `reflection`, `tags`, `procedure`, `merge` +**Full command list:** `store`, `recall`, `get`, `search`, `update`, `delete`, `stats`, `recent`, `decay`, `core`, `episode`, `reindex`, `pin`, `embed`, `reflect`, `reflection`, `tags`, `procedure`, `merge`, `edge-get`, `edge-search`, `edge-update`, `edge-delete`, `config` ### Memory Types @@ -116,7 +116,8 @@ claude-memory tags suggest │ ├── errors/ # Error memories │ ├── general/ # General memories │ ├── procedures/ # Procedural memories (steps/pre/postconditions) -│ └── insights/ # Reflection-generated insights +│ ├── insights/ # Reflection-generated insights +│ └── edges/ # Rich edge files (first-class relationship objects) ├── episodes/ # Daily session logs (YYYY-MM-DD.md) ├── vault/ # Pinned memories (never decay) ├── _index.json # Computed index for fast lookups @@ -257,6 +258,36 @@ Auto-generated summary of memory themes, cross-project patterns, and access stat Requires Ollama running locally with the `nomic-embed-text` model. Generate embeddings with `claude-memory embed`, then use `--semantic` flag on recall to merge keyword and embedding-based results. Semantic search provides deeper matching beyond exact title/tag keywords - useful for finding conceptually related memories even when different terminology was used. +## MCP Server + +Cognitive Memory v3.0 includes a native MCP server for direct Claude Code tool integration. Instead of CLI calls via Bash, Claude Code can call memory tools directly. + +**Registration:** Configured in `~/.claude.json` under `mcpServers.cognitive-memory`. + +**Available tools:** `memory_store`, `memory_recall`, `memory_get`, `memory_search`, `memory_relate`, `memory_related`, `memory_edge_get`, `memory_edge_search`, `memory_reflect`, `memory_reflection`, `memory_stats`, `memory_episode`, `memory_tags_list`, `memory_tags_related`, `memory_embed`, `memory_core`, `memory_decay`, `memory_config` + +## Rich Edges + +Relationships between memories are now first-class objects with their own markdown files in `graph/edges/`. Each edge has: +- Full YAML frontmatter (id, type, from/to IDs and titles, strength, timestamps) +- Description body explaining the relationship in detail +- Backward-compatible: `edge_id` field added to memory relation entries for fast BFS traversal + +**Edge CLI commands:** `edge-get`, `edge-search`, `edge-update`, `edge-delete` +**Edge file format:** `{from-slug}--{TYPE}--{to-slug}-{6char}.md` + +## Embedding Providers + +Supports multiple embedding providers with automatic fallback: +- **Ollama** (default): Local, free, uses `nomic-embed-text` (768 dimensions) +- **OpenAI** (optional): Higher quality, uses `text-embedding-3-small` (1536 dimensions) + +Configure with: `claude-memory config --provider openai --openai-key "sk-..."` +View config: `claude-memory config --show` + +Provider changes trigger automatic re-embedding (dimension mismatch safety). +Config stored in `_config.json` (gitignored, may contain API key). + ## Episode Logging Daily markdown files appended during sessions, providing chronological context: @@ -294,6 +325,6 @@ This skill should be used proactively when: **Location**: `~/.claude/skills/cognitive-memory/` **Data**: `~/.claude/memory/` -**Version**: 2.0.0 +**Version**: 3.0.0 **Created**: 2026-02-13 **Migrated from**: MemoryGraph (SQLite) diff --git a/skills/cognitive-memory/client.py b/skills/cognitive-memory/client.py index 07400f0..8d0b513 100644 --- a/skills/cognitive-memory/client.py +++ b/skills/cognitive-memory/client.py @@ -34,7 +34,6 @@ from pathlib import Path from typing import Any, Dict, List, Optional, Tuple from urllib.error import URLError - # ============================================================================= # CONSTANTS # ============================================================================= @@ -46,6 +45,9 @@ EMBEDDINGS_PATH = MEMORY_DIR / "_embeddings.json" OLLAMA_URL = "http://localhost:11434" EMBEDDING_MODEL = "nomic-embed-text" EMBEDDING_TIMEOUT = 5 # seconds +CONFIG_PATH = MEMORY_DIR / "_config.json" +OPENAI_EMBED_URL = "https://api.openai.com/v1/embeddings" +OPENAI_MODEL_DEFAULT = "text-embedding-3-small" # Memory type -> directory name mapping TYPE_DIRS = { @@ -88,15 +90,43 @@ THRESHOLD_DORMANT = 0.05 # Relationship types (subset from MemoryGraph, focused on most useful) VALID_RELATION_TYPES = [ - "SOLVES", "CAUSES", "BUILDS_ON", "ALTERNATIVE_TO", - "REQUIRES", "FOLLOWS", "RELATED_TO", + "SOLVES", + "CAUSES", + "BUILDS_ON", + "ALTERNATIVE_TO", + "REQUIRES", + "FOLLOWS", + "RELATED_TO", +] + +# Edge file constants +EDGES_DIR_NAME = "edges" +EDGE_FIELD_ORDER = [ + "id", + "type", + "from_id", + "from_title", + "to_id", + "to_title", + "strength", + "created", + "updated", ] # Frontmatter field order for consistent output FIELD_ORDER = [ - "id", "type", "title", "tags", "importance", "confidence", - "steps", "preconditions", "postconditions", - "created", "updated", "relations", + "id", + "type", + "title", + "tags", + "importance", + "confidence", + "steps", + "preconditions", + "postconditions", + "created", + "updated", + "relations", ] # CORE.md token budget (approximate, 1 token ~= 4 chars) @@ -107,25 +137,26 @@ CORE_MAX_CHARS = 12000 # ~3K tokens # YAML FRONTMATTER PARSING (stdlib only) # ============================================================================= + def _needs_quoting(s: str) -> bool: """Check if a YAML string value needs quoting.""" if not s: return True - if any(c in s for c in ':#{}[]&*?|>!%@`'): + if any(c in s for c in ":#{}[]&*?|>!%@`"): return True try: float(s) return True except ValueError: pass - if s.lower() in ('true', 'false', 'null', 'yes', 'no', 'on', 'off'): + if s.lower() in ("true", "false", "null", "yes", "no", "on", "off"): return True return False def _quote_yaml(s: str) -> str: """Quote a string for YAML, escaping internal quotes.""" - escaped = s.replace('\\', '\\\\').replace('"', '\\"') + escaped = s.replace("\\", "\\\\").replace('"', '\\"') return f'"{escaped}"' @@ -146,21 +177,23 @@ def _format_yaml_value(value: Any, force_quote: bool = False) -> str: def _parse_scalar(value: str) -> Any: """Parse a YAML scalar value to Python type.""" v = value.strip() - if not v or v == 'null': + if not v or v == "null": return None - if v == 'true': + if v == "true": return True - if v == 'false': + if v == "false": return False # Try numeric try: - if '.' in v: + if "." in v: return float(v) return int(v) except ValueError: pass # Strip quotes - if (v.startswith('"') and v.endswith('"')) or (v.startswith("'") and v.endswith("'")): + if (v.startswith('"') and v.endswith('"')) or ( + v.startswith("'") and v.endswith("'") + ): return v[1:-1] return v @@ -181,7 +214,9 @@ def serialize_frontmatter(data: Dict[str, Any]) -> str: else: lines.append("tags: []") - elif key in ("steps", "preconditions", "postconditions") and isinstance(value, list): + elif key in ("steps", "preconditions", "postconditions") and isinstance( + value, list + ): if not value: continue lines.append(f"{key}:") @@ -194,13 +229,22 @@ def serialize_frontmatter(data: Dict[str, Any]) -> str: lines.append("relations:") for rel in value: first = True - for rk in ["target", "type", "direction", "strength", "context"]: + for rk in [ + "target", + "type", + "direction", + "strength", + "context", + "edge_id", + ]: if rk not in rel: continue rv = rel[rk] prefix = " - " if first else " " force_q = rk in ("context",) - lines.append(f"{prefix}{rk}: {_format_yaml_value(rv, force_quote=force_q)}") + lines.append( + f"{prefix}{rk}: {_format_yaml_value(rv, force_quote=force_q)}" + ) first = False elif key == "title": @@ -222,7 +266,7 @@ def parse_frontmatter(text: str) -> Tuple[Dict[str, Any], str]: return {}, text # Find closing --- - end_match = re.search(r'\n---\s*\n', text[3:]) + end_match = re.search(r"\n---\s*\n", text[3:]) if not end_match: # Try end of string if text.rstrip().endswith("---"): @@ -236,7 +280,7 @@ def parse_frontmatter(text: str) -> Tuple[Dict[str, Any], str]: else: end_pos = end_match.start() + 3 # Offset from text[3:] fm_text = text[4:end_pos] - body = text[end_pos + end_match.end() - end_match.start():] + body = text[end_pos + end_match.end() - end_match.start() :] body = body.lstrip("\n") data = {} @@ -290,7 +334,9 @@ def parse_frontmatter(text: str) -> Tuple[Dict[str, Any], str]: if rest.startswith("[") and rest.endswith("]"): inner = rest[1:-1] if inner.strip(): - data[key] = [_parse_scalar(v.strip()) for v in inner.split(",") if v.strip()] + data[key] = [ + _parse_scalar(v.strip()) for v in inner.split(",") if v.strip() + ] else: data[key] = [] else: @@ -331,15 +377,16 @@ def _parse_relations_block(lines: List[str]) -> List[Dict[str, Any]]: # HELPER FUNCTIONS # ============================================================================= + def slugify(text: str, max_length: int = 60) -> str: """Convert text to a URL-friendly slug.""" text = text.lower().strip() - text = re.sub(r'[^\w\s-]', '', text) - text = re.sub(r'[\s_]+', '-', text) - text = re.sub(r'-+', '-', text) - text = text.strip('-') + text = re.sub(r"[^\w\s-]", "", text) + text = re.sub(r"[\s_]+", "-", text) + text = re.sub(r"-+", "-", text) + text = text.strip("-") if len(text) > max_length: - text = text[:max_length].rstrip('-') + text = text[:max_length].rstrip("-") return text or "untitled" @@ -351,10 +398,7 @@ def make_filename(title: str, memory_id: str) -> str: def calculate_decay_score( - importance: float, - days_since_access: float, - access_count: int, - type_weight: float + importance: float, days_since_access: float, access_count: int, type_weight: float ) -> float: """Calculate decay score for a memory. @@ -365,7 +409,9 @@ def calculate_decay_score( return importance * time_factor * usage_factor * type_weight -def _ollama_embed(texts: List[str], timeout: int = EMBEDDING_TIMEOUT) -> Optional[List[List[float]]]: +def _ollama_embed( + texts: List[str], timeout: int = EMBEDDING_TIMEOUT +) -> Optional[List[List[float]]]: """Get embeddings from Ollama for a list of texts. Returns list of embedding vectors, or None if Ollama is unavailable. @@ -386,8 +432,75 @@ def _ollama_embed(texts: List[str], timeout: int = EMBEDDING_TIMEOUT) -> Optiona if embeddings and isinstance(embeddings, list): return embeddings return None - except (ConnectionRefusedError, URLError, TimeoutError, OSError, - json.JSONDecodeError, ValueError, KeyError): + except ( + ConnectionRefusedError, + URLError, + TimeoutError, + OSError, + json.JSONDecodeError, + ValueError, + KeyError, + ): + return None + + +def _load_memory_config(config_path: Optional[Path] = None) -> Dict[str, Any]: + """Read _config.json, return defaults if missing.""" + path = config_path or CONFIG_PATH + defaults = { + "embedding_provider": "ollama", + "openai_api_key": None, + "ollama_model": EMBEDDING_MODEL, + "openai_model": OPENAI_MODEL_DEFAULT, + } + if path.exists(): + try: + data = json.loads(path.read_text()) + for k, v in defaults.items(): + data.setdefault(k, v) + return data + except (json.JSONDecodeError, OSError): + pass + return defaults + + +def _openai_embed( + texts: List[str], + api_key: str, + model: str = OPENAI_MODEL_DEFAULT, + timeout: int = 30, +) -> Optional[List[List[float]]]: + """Get embeddings from OpenAI API (stdlib-only, same interface as _ollama_embed).""" + try: + payload = json.dumps({"input": texts, "model": model}).encode("utf-8") + req = urllib.request.Request( + OPENAI_EMBED_URL, + data=payload, + headers={ + "Content-Type": "application/json", + "Authorization": f"Bearer {api_key}", + }, + method="POST", + ) + with urllib.request.urlopen(req, timeout=timeout) as resp: + if resp.status != 200: + return None + data = json.loads(resp.read().decode("utf-8")) + items = data.get("data", []) + if items and isinstance(items, list): + # Sort by index to ensure order matches input + items.sort(key=lambda x: x.get("index", 0)) + return [item["embedding"] for item in items] + return None + except ( + ConnectionRefusedError, + URLError, + TimeoutError, + OSError, + json.JSONDecodeError, + ValueError, + KeyError, + ): return None @@ -401,10 +514,36 @@ def _cosine_similarity(a: List[float], b: List[float]) -> float: return dot / (norm_a * norm_b) +def _make_edge_filename( + from_title: str, rel_type: str, to_title: str, edge_id: str +) -> str: + """Produce edge filename: {from-slug}--{TYPE}--{to-slug}-{6char}.md""" + from_slug = slugify(from_title, max_length=30) + to_slug = slugify(to_title, max_length=30) + suffix = edge_id[:6] + return f"{from_slug}--{rel_type}--{to_slug}-{suffix}.md" + + +def serialize_edge_frontmatter(data: Dict[str, Any]) -> str: + """Serialize an edge dict to YAML frontmatter string.""" + lines = ["---"] + for key in EDGE_FIELD_ORDER: + if key not in data: + continue + value = data[key] + if key in ("from_title", "to_title"): + lines.append(f"{key}: {_format_yaml_value(value, force_quote=True)}") + else: + lines.append(f"{key}: {_format_yaml_value(value)}") + lines.append("---") + return "\n".join(lines) + + # ============================================================================= # CLIENT # ============================================================================= + class CognitiveMemoryClient: """Client for markdown-based cognitive memory system.""" @@ -418,6 +557,7 @@ class CognitiveMemoryClient: """Create directory structure if needed.""" for type_dir in TYPE_DIRS.values(): (self.memory_dir / "graph" / type_dir).mkdir(parents=True, exist_ok=True) + (self.memory_dir / "graph" / EDGES_DIR_NAME).mkdir(parents=True, exist_ok=True) (self.memory_dir / "episodes").mkdir(parents=True, exist_ok=True) (self.memory_dir / "vault").mkdir(parents=True, exist_ok=True) @@ -432,10 +572,12 @@ class CognitiveMemoryClient: return json.loads(self.index_path.read_text()) except (json.JSONDecodeError, OSError): pass - return {"version": 1, "updated": "", "count": 0, "entries": {}} + return {"version": 2, "updated": "", "count": 0, "entries": {}, "edges": {}} def _save_index(self, index: Dict): """Write _index.json.""" + index["version"] = 2 + index.setdefault("edges", {}) index["updated"] = datetime.now(timezone.utc).isoformat() index["count"] = len(index.get("entries", {})) self.index_path.write_text(json.dumps(index, indent=2, default=str)) @@ -506,7 +648,8 @@ class CognitiveMemoryClient: result = subprocess.run( ["git", "rev-parse", "--git-dir"], cwd=str(self.memory_dir), - capture_output=True, timeout=5 + capture_output=True, + timeout=5, ) if result.returncode != 0: return # Not a git repo, skip @@ -517,19 +660,22 @@ class CognitiveMemoryClient: subprocess.run( ["git", "add", str(rel)], cwd=str(self.memory_dir), - capture_output=True, timeout=5 + capture_output=True, + timeout=5, ) else: subprocess.run( ["git", "add", "-A"], cwd=str(self.memory_dir), - capture_output=True, timeout=5 + capture_output=True, + timeout=5, ) subprocess.run( ["git", "commit", "-m", message, "--allow-empty"], cwd=str(self.memory_dir), - capture_output=True, timeout=10 + capture_output=True, + timeout=10, ) except (subprocess.TimeoutExpired, FileNotFoundError, OSError): pass # Git operations are best-effort @@ -539,7 +685,10 @@ class CognitiveMemoryClient: # ------------------------------------------------------------------------- def _update_index_entry( - self, memory_id: str, frontmatter: Dict[str, Any], rel_path: str, + self, + memory_id: str, + frontmatter: Dict[str, Any], + rel_path: str, content_preview: str = "", ): """Add or update an entry in the index.""" @@ -589,6 +738,58 @@ class CognitiveMemoryClient: except (json.JSONDecodeError, ValueError, OSError): self.decay() + # ------------------------------------------------------------------------- + # Edge index helpers + # ------------------------------------------------------------------------- + + def _update_edge_index( + self, edge_id: str, edge_data: Dict[str, Any], rel_path: str + ): + """Add or update an edge entry in the index.""" + index = self._load_index() + index.setdefault("edges", {})[edge_id] = { + "type": edge_data.get("type", ""), + "from_id": edge_data.get("from_id", ""), + "from_title": edge_data.get("from_title", ""), + "to_id": edge_data.get("to_id", ""), + "to_title": edge_data.get("to_title", ""), + "strength": edge_data.get("strength", 0.8), + "created": edge_data.get("created", ""), + "updated": edge_data.get("updated", ""), + "path": rel_path, + } + self._save_index(index) + + def _remove_edge_index(self, edge_id: str): + """Remove an edge entry from the index.""" + index = self._load_index() + index.get("edges", {}).pop(edge_id, None) + self._save_index(index) + + def _scan_for_edge(self, edge_id: str) -> Optional[Path]: + """Fallback file scan for an edge by ID if index is stale.""" + edges_dir = self.memory_dir / "graph" / EDGES_DIR_NAME + if not edges_dir.exists(): + return None + for md_file in edges_dir.glob("*.md"): + try: + fm, _ = self._read_memory_file(md_file) + if fm.get("id") == edge_id: + return md_file + except Exception: + continue + return None + + def _resolve_edge_path(self, edge_id: str) -> Optional[Path]: + """Find the file path for an edge by ID using the index.""" + index = self._load_index() + entry = index.get("edges", {}).get(edge_id) + if entry: + path = self.memory_dir / entry["path"] + if path.exists(): + return path + return self._scan_for_edge(edge_id) + # ========================================================================= # PUBLIC API # ========================================================================= @@ -651,7 +852,9 @@ class CognitiveMemoryClient: last_space = preview.rfind(" ") if last_space > 0: preview = preview[:last_space] - self._update_index_entry(memory_id, frontmatter, rel_path, content_preview=preview) + self._update_index_entry( + memory_id, frontmatter, rel_path, content_preview=preview + ) # Init state entry state = self._load_state() @@ -719,17 +922,19 @@ class CognitiveMemoryClient: # Weight by decay score weighted_score = score * (1 + decay) - results.append({ - "id": mid, - "type": entry.get("type"), - "title": entry.get("title"), - "tags": entry.get("tags", []), - "importance": entry.get("importance"), - "decay_score": round(decay, 3), - "path": entry.get("path"), - "created": entry.get("created"), - "_score": weighted_score, - }) + results.append( + { + "id": mid, + "type": entry.get("type"), + "title": entry.get("title"), + "tags": entry.get("tags", []), + "importance": entry.get("importance"), + "decay_score": round(decay, 3), + "path": entry.get("path"), + "created": entry.get("created"), + "_score": weighted_score, + } + ) results.sort(key=lambda x: x.pop("_score", 0), reverse=True) keyword_results = results[:limit] @@ -795,11 +1000,14 @@ class CognitiveMemoryClient: # Update access count in state state = self._load_state() now = datetime.now(timezone.utc).isoformat() - entry = state.setdefault("entries", {}).setdefault(memory_id, { - "access_count": 0, - "last_accessed": now, - "decay_score": 0.5, - }) + entry = state.setdefault("entries", {}).setdefault( + memory_id, + { + "access_count": 0, + "last_accessed": now, + "decay_score": 0.5, + }, + ) entry["access_count"] = entry.get("access_count", 0) + 1 entry["last_accessed"] = now @@ -839,49 +1047,82 @@ class CognitiveMemoryClient: rel_type: str, strength: float = 0.8, context: Optional[str] = None, - ) -> bool: - """Create a relationship between two memories. + description: Optional[str] = None, + ) -> str: + """Create a relationship between two memories with an edge file. - Updates the source memory's frontmatter with the relation. + Returns edge_id string, or empty string if duplicate. """ if rel_type not in VALID_RELATION_TYPES: - raise ValueError(f"Invalid relation type: {rel_type}. Valid: {VALID_RELATION_TYPES}") + raise ValueError( + f"Invalid relation type: {rel_type}. Valid: {VALID_RELATION_TYPES}" + ) from_path = self._resolve_memory_path(from_id) to_path = self._resolve_memory_path(to_id) if not from_path or not to_path: raise ValueError(f"Memory not found: {from_id if not from_path else to_id}") - # Update source memory frontmatter + # Read source memory fm, body = self._read_memory_file(from_path) relations = fm.get("relations", []) # Check for duplicate for r in relations: if r.get("target") == to_id and r.get("type") == rel_type: - return False # Already exists + return "" # Already exists + # Read target memory for title + to_fm, to_body = self._read_memory_file(to_path) + + # Create edge file + edge_id = str(uuid.uuid4()) + now = datetime.now(timezone.utc).isoformat() + from_title = fm.get("title", from_id[:8]) + to_title = to_fm.get("title", to_id[:8]) + clamped_strength = max(0.0, min(1.0, strength)) + + edge_data = { + "id": edge_id, + "type": rel_type, + "from_id": from_id, + "from_title": from_title, + "to_id": to_id, + "to_title": to_title, + "strength": clamped_strength, + "created": now, + "updated": now, + } + edge_filename = _make_edge_filename(from_title, rel_type, to_title, edge_id) + edge_path = self.memory_dir / "graph" / EDGES_DIR_NAME / edge_filename + edge_fm_str = serialize_edge_frontmatter(edge_data) + edge_body = description.strip() if description else "" + edge_content = ( + f"{edge_fm_str}\n\n{edge_body}\n" if edge_body else f"{edge_fm_str}\n" + ) + edge_path.write_text(edge_content, encoding="utf-8") + + # Update source memory frontmatter with edge_id new_rel = { "target": to_id, "type": rel_type, "direction": "outgoing", - "strength": max(0.0, min(1.0, strength)), + "strength": clamped_strength, + "edge_id": edge_id, } if context: new_rel["context"] = context - relations.append(new_rel) fm["relations"] = relations - fm["updated"] = datetime.now(timezone.utc).isoformat() + fm["updated"] = now self._write_memory_file(from_path, fm, body) - # Also add incoming relation to target - to_fm, to_body = self._read_memory_file(to_path) + # Add incoming relation to target with edge_id to_relations = to_fm.get("relations", []) - - # Check for duplicate has_incoming = any( - r.get("target") == from_id and r.get("type") == rel_type and r.get("direction") == "incoming" + r.get("target") == from_id + and r.get("type") == rel_type + and r.get("direction") == "incoming" for r in to_relations ) if not has_incoming: @@ -889,22 +1130,178 @@ class CognitiveMemoryClient: "target": from_id, "type": rel_type, "direction": "incoming", - "strength": max(0.0, min(1.0, strength)), + "strength": clamped_strength, + "edge_id": edge_id, } if context: incoming_rel["context"] = context to_relations.append(incoming_rel) to_fm["relations"] = to_relations - to_fm["updated"] = datetime.now(timezone.utc).isoformat() + to_fm["updated"] = now self._write_memory_file(to_path, to_fm, to_body) - # Update index + # Update memory index rel_from = str(from_path.relative_to(self.memory_dir)) rel_to = str(to_path.relative_to(self.memory_dir)) self._update_index_entry(from_id, fm, rel_from) self._update_index_entry(to_id, to_fm, rel_to) - self._git_commit(f"relate: {from_id[:8]} --{rel_type}--> {to_id[:8]}", [from_path, to_path]) + # Update edge index + self._update_edge_index( + edge_id, edge_data, f"graph/{EDGES_DIR_NAME}/{edge_filename}" + ) + + self._git_commit( + f"relate: {from_id[:8]} --{rel_type}--> {to_id[:8]}", + [from_path, to_path, edge_path], + ) + return edge_id + + def edge_get(self, edge_id: str) -> Optional[Dict[str, Any]]: + """Read full edge file (frontmatter + body).""" + path = self._resolve_edge_path(edge_id) + if not path: + return None + fm, body = self._read_memory_file(path) + return { + "id": fm.get("id", edge_id), + "type": fm.get("type", ""), + "from_id": fm.get("from_id", ""), + "from_title": fm.get("from_title", ""), + "to_id": fm.get("to_id", ""), + "to_title": fm.get("to_title", ""), + "strength": fm.get("strength", 0.8), + "created": fm.get("created", ""), + "updated": fm.get("updated", ""), + "description": body.strip(), + "path": str(path.relative_to(self.memory_dir)), + } + + def edge_search( + self, + query: Optional[str] = None, + types: Optional[List[str]] = None, + from_id: Optional[str] = None, + to_id: Optional[str] = None, + limit: int = 20, + ) -> List[Dict[str, Any]]: + """Search edges via index.""" + index = self._load_index() + results = [] + query_lower = query.lower().strip() if query else "" + + for eid, entry in index.get("edges", {}).items(): + if types and entry.get("type") not in types: + continue + if from_id and entry.get("from_id") != from_id: + continue + if to_id and entry.get("to_id") != to_id: + continue + if query_lower: + searchable = f"{entry.get('from_title', '')} {entry.get('to_title', '')} {entry.get('type', '')}".lower() + if query_lower not in searchable: + continue + results.append({"id": eid, **entry}) + + results.sort(key=lambda x: x.get("created", ""), reverse=True) + return results[:limit] + + def edge_update( + self, + edge_id: str, + description: Optional[str] = None, + strength: Optional[float] = None, + ) -> bool: + """Update edge body/metadata, sync strength to memory frontmatter.""" + path = self._resolve_edge_path(edge_id) + if not path: + return False + + fm, body = self._read_memory_file(path) + now = datetime.now(timezone.utc).isoformat() + + if description is not None: + body = description + if strength is not None: + fm["strength"] = max(0.0, min(1.0, strength)) + fm["updated"] = now + + # Write edge file + edge_fm_str = serialize_edge_frontmatter(fm) + edge_body = body.strip() if body else "" + edge_content = ( + f"{edge_fm_str}\n\n{edge_body}\n" if edge_body else f"{edge_fm_str}\n" + ) + path.write_text(edge_content, encoding="utf-8") + + # Sync strength to memory frontmatter if changed + if strength is not None: + for mid_key in ("from_id", "to_id"): + mid = fm.get(mid_key) + if not mid: + continue + mem_path = self._resolve_memory_path(mid) + if not mem_path: + continue + mem_fm, mem_body = self._read_memory_file(mem_path) + for rel in mem_fm.get("relations", []): + if rel.get("edge_id") == edge_id: + rel["strength"] = fm["strength"] + mem_fm["updated"] = now + self._write_memory_file(mem_path, mem_fm, mem_body) + + # Update edge index + rel_path = str(path.relative_to(self.memory_dir)) + self._update_edge_index(edge_id, fm, rel_path) + self._git_commit(f"edge-update: {edge_id[:8]}", [path]) + return True + + def edge_delete(self, edge_id: str) -> bool: + """Remove edge file and clean frontmatter refs from both memories.""" + path = self._resolve_edge_path(edge_id) + if not path: + return False + + fm, _ = self._read_memory_file(path) + now = datetime.now(timezone.utc).isoformat() + files_to_commit: List[Path] = [] + + # Clean edge_id references from both memories + for mid_key in ("from_id", "to_id"): + mid = fm.get(mid_key) + if not mid: + continue + mem_path = self._resolve_memory_path(mid) + if not mem_path: + continue + mem_fm, mem_body = self._read_memory_file(mem_path) + original_rels = mem_fm.get("relations", []) + mem_fm["relations"] = [ + r for r in original_rels if r.get("edge_id") != edge_id + ] + if len(mem_fm["relations"]) != len(original_rels): + mem_fm["updated"] = now + self._write_memory_file(mem_path, mem_fm, mem_body) + rel_p = str(mem_path.relative_to(self.memory_dir)) + self._update_index_entry(mid, mem_fm, rel_p) + files_to_commit.append(mem_path) + + # Remove edge file + path.unlink() + self._remove_edge_index(edge_id) + + # Git stage deletion + try: + rel_path = path.relative_to(self.memory_dir) + subprocess.run( + ["git", "rm", "--cached", str(rel_path)], + cwd=str(self.memory_dir), + capture_output=True, + timeout=5, + ) + except Exception: + pass + self._git_commit(f"edge-delete: {edge_id[:8]}") return True def search( @@ -947,16 +1344,18 @@ class CognitiveMemoryClient: continue s = state.get("entries", {}).get(mid, {}) - results.append({ - "id": mid, - "type": entry.get("type"), - "title": entry.get("title"), - "tags": entry.get("tags", []), - "importance": entry.get("importance"), - "decay_score": round(s.get("decay_score", 0.5), 3), - "path": entry.get("path"), - "created": entry.get("created"), - }) + results.append( + { + "id": mid, + "type": entry.get("type"), + "title": entry.get("title"), + "tags": entry.get("tags", []), + "importance": entry.get("importance"), + "decay_score": round(s.get("decay_score", 0.5), 3), + "path": entry.get("path"), + "created": entry.get("created"), + } + ) # Sort by importance descending results.sort(key=lambda x: x.get("importance", 0), reverse=True) @@ -1005,7 +1404,7 @@ class CognitiveMemoryClient: return True def delete(self, memory_id: str) -> bool: - """Delete a memory file and remove from index.""" + """Delete a memory file, cascade-delete edges, remove from index.""" path = self._resolve_memory_path(memory_id) if not path: return False @@ -1013,6 +1412,15 @@ class CognitiveMemoryClient: fm, _ = self._read_memory_file(path) title = fm.get("title", memory_id[:8]) + # Cascade-delete edges where from_id or to_id matches + index = self._load_index() + edges_to_delete = [] + for eid, edata in index.get("edges", {}).items(): + if edata.get("from_id") == memory_id or edata.get("to_id") == memory_id: + edges_to_delete.append(eid) + for eid in edges_to_delete: + self.edge_delete(eid) + # Remove file path.unlink() @@ -1030,13 +1438,13 @@ class CognitiveMemoryClient: rels = [r for r in rels if r.get("target") != memory_id] if len(rels) != original_count: entry["relations"] = rels - # Also update the actual file other_path = self._resolve_memory_path(mid) if other_path: try: other_fm, other_body = self._read_memory_file(other_path) other_fm["relations"] = [ - r for r in other_fm.get("relations", []) + r + for r in other_fm.get("relations", []) if r.get("target") != memory_id ] self._write_memory_file(other_path, other_fm, other_body) @@ -1050,7 +1458,8 @@ class CognitiveMemoryClient: subprocess.run( ["git", "rm", "--cached", str(rel_path)], cwd=str(self.memory_dir), - capture_output=True, timeout=5 + capture_output=True, + timeout=5, ) except Exception: pass @@ -1088,15 +1497,17 @@ class CognitiveMemoryClient: target_entry = index.get("entries", {}).get(target_id) if target_entry: - results.append({ - "id": target_id, - "type": target_entry.get("type"), - "title": target_entry.get("title"), - "relationship": rel.get("type"), - "direction": rel.get("direction", "outgoing"), - "strength": rel.get("strength"), - "depth": depth, - }) + results.append( + { + "id": target_id, + "type": target_entry.get("type"), + "title": target_entry.get("title"), + "relationship": rel.get("type"), + "direction": rel.get("direction", "outgoing"), + "strength": rel.get("strength"), + "depth": depth, + } + ) traverse(target_id, depth + 1) traverse(memory_id, 1) @@ -1121,14 +1532,34 @@ class CognitiveMemoryClient: d = self.memory_dir / "graph" / type_dir if d.exists(): dir_counts[type_dir] = len(list(d.glob("*.md"))) - vault_count = len(list((self.memory_dir / "vault").glob("*.md"))) if (self.memory_dir / "vault").exists() else 0 + vault_count = ( + len(list((self.memory_dir / "vault").glob("*.md"))) + if (self.memory_dir / "vault").exists() + else 0 + ) # Decay stats state_entries = state.get("entries", {}) - active = sum(1 for s in state_entries.values() if s.get("decay_score", 0) >= THRESHOLD_ACTIVE) - fading = sum(1 for s in state_entries.values() if THRESHOLD_FADING <= s.get("decay_score", 0) < THRESHOLD_ACTIVE) - dormant = sum(1 for s in state_entries.values() if THRESHOLD_DORMANT <= s.get("decay_score", 0) < THRESHOLD_FADING) - archived = sum(1 for s in state_entries.values() if s.get("decay_score", 0) < THRESHOLD_DORMANT) + active = sum( + 1 + for s in state_entries.values() + if s.get("decay_score", 0) >= THRESHOLD_ACTIVE + ) + fading = sum( + 1 + for s in state_entries.values() + if THRESHOLD_FADING <= s.get("decay_score", 0) < THRESHOLD_ACTIVE + ) + dormant = sum( + 1 + for s in state_entries.values() + if THRESHOLD_DORMANT <= s.get("decay_score", 0) < THRESHOLD_FADING + ) + archived = sum( + 1 + for s in state_entries.values() + if s.get("decay_score", 0) < THRESHOLD_DORMANT + ) # Unique outgoing relations only (avoid double-counting) unique_relations = total_relations // 2 if total_relations > 0 else 0 @@ -1152,8 +1583,7 @@ class CognitiveMemoryClient: """Get most recently created memories.""" index = self._load_index() entries = [ - {"id": mid, **entry} - for mid, entry in index.get("entries", {}).items() + {"id": mid, **entry} for mid, entry in index.get("entries", {}).items() ] entries.sort(key=lambda x: x.get("created", ""), reverse=True) return entries[:limit] @@ -1166,11 +1596,14 @@ class CognitiveMemoryClient: updated_count = 0 for mid, entry in index.get("entries", {}).items(): - s = state.setdefault("entries", {}).setdefault(mid, { - "access_count": 0, - "last_accessed": entry.get("created", now.isoformat()), - "decay_score": 0.5, - }) + s = state.setdefault("entries", {}).setdefault( + mid, + { + "access_count": 0, + "last_accessed": entry.get("created", now.isoformat()), + "decay_score": 0.5, + }, + ) # Calculate days since last access last_str = s.get("last_accessed", entry.get("created", "")) @@ -1193,7 +1626,8 @@ class CognitiveMemoryClient: s["decay_score"] = 999.0 # Pinned memories never decay else: s["decay_score"] = round( - calculate_decay_score(importance, days, access_count, type_weight), 4 + calculate_decay_score(importance, days, access_count, type_weight), + 4, ) updated_count += 1 @@ -1204,10 +1638,26 @@ class CognitiveMemoryClient: state_entries = state.get("entries", {}) return { "updated": updated_count, - "active": sum(1 for s in state_entries.values() if s.get("decay_score", 0) >= THRESHOLD_ACTIVE), - "fading": sum(1 for s in state_entries.values() if THRESHOLD_FADING <= s.get("decay_score", 0) < THRESHOLD_ACTIVE), - "dormant": sum(1 for s in state_entries.values() if THRESHOLD_DORMANT <= s.get("decay_score", 0) < THRESHOLD_FADING), - "archived": sum(1 for s in state_entries.values() if 0 < s.get("decay_score", 0) < THRESHOLD_DORMANT), + "active": sum( + 1 + for s in state_entries.values() + if s.get("decay_score", 0) >= THRESHOLD_ACTIVE + ), + "fading": sum( + 1 + for s in state_entries.values() + if THRESHOLD_FADING <= s.get("decay_score", 0) < THRESHOLD_ACTIVE + ), + "dormant": sum( + 1 + for s in state_entries.values() + if THRESHOLD_DORMANT <= s.get("decay_score", 0) < THRESHOLD_FADING + ), + "archived": sum( + 1 + for s in state_entries.values() + if 0 < s.get("decay_score", 0) < THRESHOLD_DORMANT + ), } def core(self) -> str: @@ -1235,11 +1685,11 @@ class CognitiveMemoryClient: # Extract first sentence (split on '. ') dot_pos = first_line.find(". ") if 0 < dot_pos < max_len: - sentence = first_line[:dot_pos + 1] + sentence = first_line[: dot_pos + 1] else: sentence = first_line if len(sentence) > max_len: - sentence = sentence[:max_len - 3].rstrip() + "..." + sentence = sentence[: max_len - 3].rstrip() + "..." return sentence # Collect all memories with decay scores @@ -1249,15 +1699,17 @@ class CognitiveMemoryClient: decay = s.get("decay_score", 0.5) if decay < THRESHOLD_FADING: continue # Skip low-relevance - memories.append({ - "id": mid, - "title": entry.get("title", ""), - "type": entry.get("type", "general"), - "path": entry.get("path", ""), - "importance": entry.get("importance", 0.5), - "decay_score": decay, - "tags": entry.get("tags", []), - }) + memories.append( + { + "id": mid, + "title": entry.get("title", ""), + "type": entry.get("type", "general"), + "path": entry.get("path", ""), + "importance": entry.get("importance", 0.5), + "decay_score": decay, + "tags": entry.get("tags", []), + } + ) # Sort by decay score descending memories.sort(key=lambda x: x["decay_score"], reverse=True) @@ -1377,7 +1829,7 @@ class CognitiveMemoryClient: def reindex(self) -> int: """Rebuild _index.json from all markdown files. Recovery command.""" - index = {"version": 1, "updated": "", "count": 0, "entries": {}} + index = {"version": 2, "updated": "", "count": 0, "entries": {}, "edges": {}} count = 0 search_dirs = [ @@ -1385,17 +1837,21 @@ class CognitiveMemoryClient: ("vault", self.memory_dir / "vault"), ] - for prefix, search_dir in search_dirs: + edges_dir = self.memory_dir / "graph" / EDGES_DIR_NAME + + for _prefix, search_dir in search_dirs: if not search_dir.exists(): continue for md_file in search_dir.rglob("*.md"): + # Skip edge files — handled separately + if edges_dir.exists() and md_file.parent == edges_dir: + continue try: fm, body = self._read_memory_file(md_file) mid = fm.get("id") if not mid: continue rel_path = str(md_file.relative_to(self.memory_dir)) - # Build content preview (truncate at word boundary) preview = body.strip()[:200] if len(body.strip()) > 200: last_space = preview.rfind(" ") @@ -1417,6 +1873,31 @@ class CognitiveMemoryClient: except Exception as e: print(f"Warning: Failed to index {md_file}: {e}", file=sys.stderr) + # Scan edge files + if edges_dir.exists(): + for md_file in edges_dir.glob("*.md"): + try: + fm, _ = self._read_memory_file(md_file) + eid = fm.get("id") + if not eid: + continue + rel_path = str(md_file.relative_to(self.memory_dir)) + index["edges"][eid] = { + "type": fm.get("type", ""), + "from_id": fm.get("from_id", ""), + "from_title": fm.get("from_title", ""), + "to_id": fm.get("to_id", ""), + "to_title": fm.get("to_title", ""), + "strength": fm.get("strength", 0.8), + "created": fm.get("created", ""), + "updated": fm.get("updated", ""), + "path": rel_path, + } + except Exception as e: + print( + f"Warning: Failed to index edge {md_file}: {e}", file=sys.stderr + ) + self._save_index(index) return count @@ -1467,10 +1948,7 @@ class CognitiveMemoryClient: if normalized: tag_counts[normalized] = tag_counts.get(normalized, 0) + 1 - results = [ - {"tag": tag, "count": count} - for tag, count in tag_counts.items() - ] + results = [{"tag": tag, "count": count} for tag, count in tag_counts.items()] results.sort(key=lambda x: x["count"], reverse=True) if limit > 0: @@ -1546,21 +2024,86 @@ class CognitiveMemoryClient: return results[:10] # ------------------------------------------------------------------------- - # Embedding-based semantic search (optional, requires Ollama) + # Embedding-based semantic search (hybrid: Ollama + OpenAI) # ------------------------------------------------------------------------- - def embed(self) -> Dict[str, Any]: - """Generate embeddings for all memories via Ollama. + def _get_embedding_provider(self) -> Dict[str, Any]: + """Load embedding config from _config.json.""" + return _load_memory_config(self.memory_dir / "_config.json") + def _embed_texts_with_fallback( + self, + texts: List[str], + timeout: int = 300, + ) -> Tuple[Optional[List[List[float]]], str, str]: + """Embed texts with fallback chain. Returns (vectors, provider_used, model_used).""" + config = self._get_embedding_provider() + provider = config.get("embedding_provider", "ollama") + + # Try configured provider first + if provider == "openai": + api_key = config.get("openai_api_key") + model = config.get("openai_model", OPENAI_MODEL_DEFAULT) + if api_key: + vectors = _openai_embed(texts, api_key, model, timeout=timeout) + if vectors is not None: + return vectors, "openai", model + # Fallback to ollama + ollama_model = config.get("ollama_model", EMBEDDING_MODEL) + vectors = _ollama_embed(texts, timeout=timeout) + if vectors is not None: + return vectors, "ollama", ollama_model + else: + # ollama first + ollama_model = config.get("ollama_model", EMBEDDING_MODEL) + vectors = _ollama_embed(texts, timeout=timeout) + if vectors is not None: + return vectors, "ollama", ollama_model + # Fallback to openai + api_key = config.get("openai_api_key") + model = config.get("openai_model", OPENAI_MODEL_DEFAULT) + if api_key: + vectors = _openai_embed(texts, api_key, model, timeout=timeout) + if vectors is not None: + return vectors, "openai", model + + return None, "", "" + + def embed(self) -> Dict[str, Any]: + """Generate embeddings for all memories using configured provider. + + Detects provider changes and re-embeds everything (dimension mismatch safety). Stores vectors in _embeddings.json (not git-tracked). - Returns summary with count of embedded memories and model used. """ index = self._load_index() entries = index.get("entries", {}) if not entries: - return {"embedded": 0, "model": EMBEDDING_MODEL, "path": str(EMBEDDINGS_PATH)} + return { + "embedded": 0, + "provider": "none", + "model": "", + "path": str(EMBEDDINGS_PATH), + } - # Build texts to embed: "title. content_preview" for each memory + # Check for provider change + embeddings_path = self.memory_dir / "_embeddings.json" + old_provider = "" + if embeddings_path.exists(): + try: + old_data = json.loads(embeddings_path.read_text()) + old_provider = old_data.get("provider", "ollama") + except (json.JSONDecodeError, OSError): + pass + + config = self._get_embedding_provider() + new_provider = config.get("embedding_provider", "ollama") + if old_provider and old_provider != new_provider: + print( + f"Provider changed ({old_provider} -> {new_provider}), re-embedding all memories...", + file=sys.stderr, + ) + + # Build texts to embed memory_ids = list(entries.keys()) texts = [] for mid in memory_ids: @@ -1572,36 +2115,44 @@ class CognitiveMemoryClient: # Batch embed in groups of 50 all_embeddings: Dict[str, List[float]] = {} batch_size = 50 + provider_used = "" + model_used = "" for i in range(0, len(texts), batch_size): - batch_texts = texts[i:i + batch_size] - batch_ids = memory_ids[i:i + batch_size] - # Use long timeout: model may need pulling on first call - vectors = _ollama_embed(batch_texts, timeout=300) + batch_texts = texts[i : i + batch_size] + batch_ids = memory_ids[i : i + batch_size] + vectors, provider_used, model_used = self._embed_texts_with_fallback( + batch_texts, + timeout=300, + ) if vectors is None: - return {"error": "Ollama unavailable or embedding failed", "embedded": len(all_embeddings)} + return { + "error": "All embedding providers unavailable", + "embedded": len(all_embeddings), + } for mid, vec in zip(batch_ids, vectors): all_embeddings[mid] = vec - # Write embeddings file + # Write embeddings file with provider info embeddings_data = { - "model": EMBEDDING_MODEL, + "provider": provider_used, + "model": model_used, "updated": datetime.now(timezone.utc).isoformat(), "entries": all_embeddings, } - embeddings_path = self.memory_dir / "_embeddings.json" embeddings_path.write_text(json.dumps(embeddings_data, default=str)) return { "embedded": len(all_embeddings), - "model": EMBEDDING_MODEL, + "provider": provider_used, + "model": model_used, "path": str(embeddings_path), } def semantic_recall(self, query: str, limit: int = 10) -> List[Dict[str, Any]]: """Search memories by semantic similarity using embeddings. - Returns top results sorted by cosine similarity. - Falls back to empty list if embeddings unavailable. + Uses the same provider that generated stored embeddings to embed the query. + Skips vectors with dimension mismatch as safety guard. """ embeddings_path = self.memory_dir / "_embeddings.json" if not embeddings_path.exists(): @@ -1616,27 +2167,53 @@ class CognitiveMemoryClient: if not stored: return [] - # Embed the query - query_vectors = _ollama_embed([query]) - if query_vectors is None or not query_vectors: + # Embed query with matching provider + stored_provider = emb_data.get("provider", "ollama") + config = self._get_embedding_provider() + query_vec = None + + if stored_provider == "openai": + api_key = config.get("openai_api_key") + model = emb_data.get("model", OPENAI_MODEL_DEFAULT) + if api_key: + vecs = _openai_embed([query], api_key, model) + if vecs: + query_vec = vecs[0] + if query_vec is None and stored_provider == "ollama": + vecs = _ollama_embed([query]) + if vecs: + query_vec = vecs[0] + # Last resort: try any available provider + if query_vec is None: + vecs, _, _ = self._embed_texts_with_fallback([query], timeout=30) + if vecs: + query_vec = vecs[0] + + if query_vec is None: return [] - query_vec = query_vectors[0] + + query_dim = len(query_vec) # Score all memories by cosine similarity index = self._load_index() scored = [] for mid, vec in stored.items(): + # Skip dimension mismatch + if len(vec) != query_dim: + continue sim = _cosine_similarity(query_vec, vec) entry = index.get("entries", {}).get(mid) if entry: - scored.append({ - "id": mid, - "title": entry.get("title", ""), - "type": entry.get("type", "general"), - "tags": entry.get("tags", []), - "similarity": round(sim, 4), - "path": entry.get("path", ""), - }) + scored.append( + { + "id": mid, + "title": entry.get("title", ""), + "type": entry.get("type", "general"), + "tags": entry.get("tags", []), + "similarity": round(sim, 4), + "path": entry.get("path", ""), + } + ) scored.sort(key=lambda x: x["similarity"], reverse=True) return scored[:limit] @@ -1680,12 +2257,14 @@ class CognitiveMemoryClient: # Compare date portion only (YYYY-MM-DD) created_date = created[:10] if created else "" if created_date >= since_date: - recent_memories.append({ - "id": mid, - "title": entry.get("title", ""), - "type": entry.get("type", "general"), - "tags": [t.lower().strip() for t in entry.get("tags", [])], - }) + recent_memories.append( + { + "id": mid, + "title": entry.get("title", ""), + "type": entry.get("type", "general"), + "tags": [t.lower().strip() for t in entry.get("tags", [])], + } + ) total_reviewed = len(recent_memories) @@ -1731,17 +2310,21 @@ class CognitiveMemoryClient: types_seen = set() for idx in indices: mem = recent_memories[idx] - members.append({ - "id": mem["id"], - "title": mem["title"], - "type": mem["type"], - "tags": mem["tags"], - }) + members.append( + { + "id": mem["id"], + "title": mem["title"], + "type": mem["type"], + "tags": mem["tags"], + } + ) all_tag_sets.append(set(mem["tags"])) types_seen.add(mem["type"]) # Common tags = intersection of ALL members - common_tags = sorted(set.intersection(*all_tag_sets)) if all_tag_sets else [] + common_tags = ( + sorted(set.intersection(*all_tag_sets)) if all_tag_sets else [] + ) # Shared tags = tags appearing in 2+ members tag_counts: Dict[str, int] = {} @@ -1751,18 +2334,24 @@ class CognitiveMemoryClient: shared_tags = sorted(t for t, c in tag_counts.items() if c >= 2) # Suggested topic - tag_label = ", ".join(common_tags[:4]) if common_tags else ", ".join(shared_tags[:4]) + tag_label = ( + ", ".join(common_tags[:4]) + if common_tags + else ", ".join(shared_tags[:4]) + ) type_label = ", ".join(sorted(types_seen)) suggested_topic = f"Pattern: {tag_label} across {type_label}" - clusters.append({ - "cluster_id": cluster_id, - "members": members, - "common_tags": common_tags, - "shared_tags": shared_tags, - "suggested_topic": suggested_topic, - "member_count": len(members), - }) + clusters.append( + { + "cluster_id": cluster_id, + "members": members, + "common_tags": common_tags, + "shared_tags": shared_tags, + "suggested_topic": suggested_topic, + "member_count": len(members), + } + ) # Sort clusters by member count descending clusters.sort(key=lambda c: c["member_count"], reverse=True) @@ -1906,7 +2495,9 @@ class CognitiveMemoryClient: last_space = preview.rfind(" ") if last_space > 0: preview = preview[:last_space] - self._update_index_entry(keep_id, keep_fm, keep_rel_path, content_preview=preview) + self._update_index_entry( + keep_id, keep_fm, keep_rel_path, content_preview=preview + ) # Update all other memories that reference absorb_id for mid in updated_others: @@ -1951,7 +2542,8 @@ class CognitiveMemoryClient: subprocess.run( ["git", "rm", "--cached", str(absorb_rel)], cwd=str(self.memory_dir), - capture_output=True, timeout=5, + capture_output=True, + timeout=5, ) except Exception: pass @@ -2037,7 +2629,9 @@ class CognitiveMemoryClient: sorted_pairs = sorted(pair_data.items(), key=lambda x: len(x[1]), reverse=True) for (tag_a, tag_b), titles in sorted_pairs[:8]: example_titles = ", ".join(f'"{t}"' for t in titles[:3]) - lines.append(f"- **{tag_a} + {tag_b}**: {len(titles)} memories ({example_titles})") + lines.append( + f"- **{tag_a} + {tag_b}**: {len(titles)} memories ({example_titles})" + ) if not sorted_pairs: lines.append("- No co-occurrence data available yet.") @@ -2049,8 +2643,12 @@ class CognitiveMemoryClient: lines.append("") known_projects = [ - "major-domo", "paper-dynasty", "homelab", "vagabond-rpg", - "foundryvtt", "strat-gameplay-webapp", + "major-domo", + "paper-dynasty", + "homelab", + "vagabond-rpg", + "foundryvtt", + "strat-gameplay-webapp", ] # Build tag -> {project -> count} mapping tag_project_map: Dict[str, Dict[str, int]] = {} @@ -2075,7 +2673,10 @@ class CognitiveMemoryClient: cross_project.sort(key=lambda x: (len(x[1]), x[2]), reverse=True) for tag, projects, total in cross_project[:10]: - proj_parts = ", ".join(f"{p} ({c})" for p, c in sorted(projects.items(), key=lambda x: x[1], reverse=True)) + proj_parts = ", ".join( + f"{p} ({c})" + for p, c in sorted(projects.items(), key=lambda x: x[1], reverse=True) + ) lines.append(f"- **{tag}**: appears in {proj_parts}") if not cross_project: @@ -2094,7 +2695,14 @@ class CognitiveMemoryClient: if count > 0: entry = entries.get(mid) if entry: - accessed.append((mid, entry.get("title", "untitled"), entry.get("path", ""), count)) + accessed.append( + ( + mid, + entry.get("title", "untitled"), + entry.get("path", ""), + count, + ) + ) accessed.sort(key=lambda x: x[3], reverse=True) for mid, title, path, count in accessed[:10]: @@ -2162,6 +2770,7 @@ class CognitiveMemoryClient: # CLI INTERFACE # ============================================================================= + def main(): parser = argparse.ArgumentParser( description="Cognitive Memory - Markdown-based memory system with decay scoring", @@ -2171,20 +2780,34 @@ def main(): # store sp = subparsers.add_parser("store", help="Store a new memory") - sp.add_argument("--type", "-t", required=True, choices=VALID_TYPES, help="Memory type") + sp.add_argument( + "--type", "-t", required=True, choices=VALID_TYPES, help="Memory type" + ) sp.add_argument("--title", required=True, help="Memory title") sp.add_argument("--content", "-c", required=True, help="Memory content") sp.add_argument("--tags", help="Comma-separated tags") - sp.add_argument("--importance", "-i", type=float, default=0.5, help="Importance 0.0-1.0") + sp.add_argument( + "--importance", "-i", type=float, default=0.5, help="Importance 0.0-1.0" + ) sp.add_argument("--confidence", type=float, default=0.8, help="Confidence 0.0-1.0") - sp.add_argument("--episode", action="store_true", default=False, help="Also log an episode entry") + sp.add_argument( + "--episode", + action="store_true", + default=False, + help="Also log an episode entry", + ) # recall sp = subparsers.add_parser("recall", help="Search memories by query") sp.add_argument("query", help="Search query") sp.add_argument("--types", help="Comma-separated memory types") sp.add_argument("--limit", "-n", type=int, default=10, help="Max results") - sp.add_argument("--semantic", action="store_true", default=False, help="Also use embedding similarity (requires embed first)") + sp.add_argument( + "--semantic", + action="store_true", + default=False, + help="Also use embedding similarity (requires embed first)", + ) # get sp = subparsers.add_parser("get", help="Get memory by ID") @@ -2197,6 +2820,29 @@ def main(): sp.add_argument("rel_type", choices=VALID_RELATION_TYPES, help="Relationship type") sp.add_argument("--strength", type=float, default=0.8, help="Strength 0.0-1.0") sp.add_argument("--context", help="Context description") + sp.add_argument("--description", help="Rich edge description body") + + # edge-get + sp = subparsers.add_parser("edge-get", help="Get edge by ID") + sp.add_argument("edge_id", help="Edge UUID") + + # edge-search + sp = subparsers.add_parser("edge-search", help="Search edges") + sp.add_argument("--query", "-q", help="Text query") + sp.add_argument("--types", help="Comma-separated relation types") + sp.add_argument("--from-id", help="Filter by source memory ID") + sp.add_argument("--to-id", help="Filter by target memory ID") + sp.add_argument("--limit", "-n", type=int, default=20, help="Max results") + + # edge-update + sp = subparsers.add_parser("edge-update", help="Update an edge") + sp.add_argument("edge_id", help="Edge UUID") + sp.add_argument("--description", help="New description body") + sp.add_argument("--strength", type=float, help="New strength 0.0-1.0") + + # edge-delete + sp = subparsers.add_parser("edge-delete", help="Delete an edge") + sp.add_argument("edge_id", help="Edge UUID") # search sp = subparsers.add_parser("search", help="Filter memories") @@ -2250,22 +2896,32 @@ def main(): subparsers.add_parser("reindex", help="Rebuild index from files") # embed - subparsers.add_parser("embed", help="Generate embeddings for all memories via Ollama") + subparsers.add_parser( + "embed", help="Generate embeddings for all memories via Ollama" + ) # pin sp = subparsers.add_parser("pin", help="Move memory to vault (never decays)") sp.add_argument("memory_id", help="Memory UUID") # reflect - sp = subparsers.add_parser("reflect", help="Review recent memories and identify clusters") + sp = subparsers.add_parser( + "reflect", help="Review recent memories and identify clusters" + ) sp.add_argument("--since", help="ISO date (YYYY-MM-DD) to review from") - sp.add_argument("--dry-run", action="store_true", help="Preview without updating state") + sp.add_argument( + "--dry-run", action="store_true", help="Preview without updating state" + ) # merge - sp = subparsers.add_parser("merge", help="Merge two memories (absorb one into another)") + sp = subparsers.add_parser( + "merge", help="Merge two memories (absorb one into another)" + ) sp.add_argument("keep_id", help="Memory UUID to keep") sp.add_argument("absorb_id", help="Memory UUID to absorb and delete") - sp.add_argument("--dry-run", action="store_true", help="Preview merge without writing") + sp.add_argument( + "--dry-run", action="store_true", help="Preview merge without writing" + ) # reflection subparsers.add_parser("reflection", help="Generate REFLECTION.md summary") @@ -2282,14 +2938,26 @@ def main(): sp4.add_argument("memory_id", help="Memory UUID") # procedure - sp = subparsers.add_parser("procedure", help="Store a procedure memory (convenience wrapper)") + sp = subparsers.add_parser( + "procedure", help="Store a procedure memory (convenience wrapper)" + ) sp.add_argument("--title", required=True, help="Procedure title") sp.add_argument("--content", "-c", required=True, help="Procedure description") sp.add_argument("--steps", help="Comma-separated ordered steps") sp.add_argument("--preconditions", help="Comma-separated preconditions") sp.add_argument("--postconditions", help="Comma-separated postconditions") sp.add_argument("--tags", help="Comma-separated tags") - sp.add_argument("--importance", "-i", type=float, default=0.5, help="Importance 0.0-1.0") + sp.add_argument( + "--importance", "-i", type=float, default=0.5, help="Importance 0.0-1.0" + ) + + # config + sp = subparsers.add_parser("config", help="Manage embedding config") + sp.add_argument("--show", action="store_true", help="Display current config") + sp.add_argument( + "--provider", choices=["ollama", "openai"], help="Set embedding provider" + ) + sp.add_argument("--openai-key", help="Set OpenAI API key") args = parser.parse_args() @@ -2336,7 +3004,9 @@ def main(): elif args.command == "recall": types = [t.strip() for t in args.types.split(",")] if args.types else None - result = client.recall(args.query, memory_types=types, limit=args.limit, semantic=args.semantic) + result = client.recall( + args.query, memory_types=types, limit=args.limit, semantic=args.semantic + ) elif args.command == "get": result = client.get(args.memory_id) @@ -2344,10 +3014,41 @@ def main(): result = {"error": "Memory not found"} elif args.command == "relate": - success = client.relate( - args.from_id, args.to_id, args.rel_type, - strength=args.strength, context=args.context, + edge_id = client.relate( + args.from_id, + args.to_id, + args.rel_type, + strength=args.strength, + context=args.context, + description=args.description, ) + result = {"success": bool(edge_id), "edge_id": edge_id} + + elif args.command == "edge-get": + result = client.edge_get(args.edge_id) + if not result: + result = {"error": "Edge not found"} + + elif args.command == "edge-search": + types = [t.strip() for t in args.types.split(",")] if args.types else None + result = client.edge_search( + query=args.query, + types=types, + from_id=getattr(args, "from_id", None), + to_id=getattr(args, "to_id", None), + limit=args.limit, + ) + + elif args.command == "edge-update": + success = client.edge_update( + args.edge_id, + description=args.description, + strength=args.strength, + ) + result = {"success": success} + + elif args.command == "edge-delete": + success = client.edge_delete(args.edge_id) result = {"success": success} elif args.command == "search": @@ -2396,7 +3097,11 @@ def main(): elif args.command == "core": content = client.core() # Print path, not content (content is written to file) - result = {"success": True, "path": str(MEMORY_DIR / "CORE.md"), "chars": len(content)} + result = { + "success": True, + "path": str(MEMORY_DIR / "CORE.md"), + "chars": len(content), + } elif args.command == "episode": tags = [t.strip() for t in args.tags.split(",")] if args.tags else None @@ -2414,7 +3119,10 @@ def main(): result = {"success": True, "indexed": count} elif args.command == "embed": - print("Generating embeddings (this may take a while if model needs to be pulled)...", file=sys.stderr) + print( + "Generating embeddings (this may take a while if model needs to be pulled)...", + file=sys.stderr, + ) result = client.embed() elif args.command == "pin": @@ -2436,7 +3144,11 @@ def main(): elif args.command == "reflection": content = client.reflection_summary() - result = {"success": True, "path": str(MEMORY_DIR / "REFLECTION.md"), "chars": len(content)} + result = { + "success": True, + "path": str(MEMORY_DIR / "REFLECTION.md"), + "chars": len(content), + } elif args.command == "tags": if args.tags_command == "list": @@ -2459,8 +3171,16 @@ def main(): elif args.command == "procedure": tags = [t.strip() for t in args.tags.split(",")] if args.tags else None steps = [s.strip() for s in args.steps.split(",")] if args.steps else None - preconditions = [p.strip() for p in args.preconditions.split(",")] if args.preconditions else None - postconditions = [p.strip() for p in args.postconditions.split(",")] if args.postconditions else None + preconditions = ( + [p.strip() for p in args.preconditions.split(",")] + if args.preconditions + else None + ) + postconditions = ( + [p.strip() for p in args.postconditions.split(",")] + if args.postconditions + else None + ) memory_id = client.store( type="procedure", title=args.title, @@ -2473,6 +3193,29 @@ def main(): ) result = {"success": True, "memory_id": memory_id} + elif args.command == "config": + config_path = MEMORY_DIR / "_config.json" + config = _load_memory_config(config_path) + changed = False + + if args.provider: + config["embedding_provider"] = args.provider + changed = True + if args.openai_key: + config["openai_api_key"] = args.openai_key + changed = True + + if changed: + config_path.write_text(json.dumps(config, indent=2)) + result = {"success": True, "updated": True} + elif args.show or not changed: + # Mask API key for display + display = dict(config) + key = display.get("openai_api_key") + if key and isinstance(key, str) and len(key) > 8: + display["openai_api_key"] = key[:4] + "..." + key[-4:] + result = display + print(json.dumps(result, indent=2, default=str)) diff --git a/skills/cognitive-memory/feature.json b/skills/cognitive-memory/feature.json index dbb92bc..d912aab 100644 --- a/skills/cognitive-memory/feature.json +++ b/skills/cognitive-memory/feature.json @@ -1,7 +1,7 @@ { "name": "cognitive-memory", - "version": "2.0.0", - "description": "Markdown-based memory system with decay scoring, episodic logging, semantic search, reflection cycles, and auto-curated CORE.md", + "version": "3.0.0", + "description": "Markdown-based memory system with decay scoring, episodic logging, semantic search, reflection cycles, auto-curated CORE.md, native MCP server integration, rich edge files, and hybrid Ollama/OpenAI embeddings", "created": "2026-02-13", "migrated_from": "memorygraph", "status": "active", @@ -35,6 +35,14 @@ "embed: Generate Ollama embeddings for semantic search", "tags list: Show all tags with usage counts", "tags related: Find co-occurring tags", - "tags suggest: Recommend tags based on co-occurrence patterns" + "tags suggest: Recommend tags based on co-occurrence patterns", + "edge-get: Get full edge details by ID", + "edge-search: Search edges by query, type, from/to IDs", + "edge-update: Update edge description or strength", + "edge-delete: Remove edge and clean memory references", + "config: Manage embedding provider (ollama/openai) with fallback", + "MCP server: Native Claude Code tool integration via JSON-RPC stdio", + "Hybrid embeddings: Ollama (local) + OpenAI (optional) with automatic fallback", + "Rich edges: First-class edge files in graph/edges/ with descriptions" ] } diff --git a/skills/cognitive-memory/mcp_server.py b/skills/cognitive-memory/mcp_server.py new file mode 100644 index 0000000..3cd5b48 --- /dev/null +++ b/skills/cognitive-memory/mcp_server.py @@ -0,0 +1,625 @@ +#!/usr/bin/env python3 +""" +Cognitive Memory MCP Server + +JSON-RPC 2.0 stdio MCP server that wraps CognitiveMemoryClient. +Exposes 18 memory operations as MCP tools for Claude Code. +""" + +import json +import sys +from pathlib import Path +from typing import Any, Dict + +# Allow imports from this directory (client.py lives here) +sys.path.insert(0, str(Path(__file__).parent)) + +from client import CognitiveMemoryClient, _load_memory_config, MEMORY_DIR + + +def create_tools() -> list: + """Define all 18 MCP tool definitions with inputSchema.""" + return [ + { + "name": "memory_store", + "description": ( + "Store a new memory in the cognitive memory system. " + "Creates a markdown file with YAML frontmatter and returns the new memory UUID. " + "Valid types: solution, fix, decision, configuration, problem, workflow, " + "code_pattern, error, general, procedure, insight." + ), + "inputSchema": { + "type": "object", + "properties": { + "type": { + "type": "string", + "description": "Memory type (solution, fix, decision, configuration, problem, workflow, code_pattern, error, general, procedure, insight)", + }, + "title": { + "type": "string", + "description": "Short descriptive title for the memory", + }, + "content": { + "type": "string", + "description": "Full content/body of the memory in markdown", + }, + "tags": { + "type": "array", + "items": {"type": "string"}, + "description": "List of lowercase tags for categorisation (e.g. ['python', 'fix', 'discord'])", + }, + "importance": { + "type": "number", + "description": "Importance score from 0.0 to 1.0 (default 0.5)", + }, + }, + "required": ["type", "title", "content"], + }, + }, + { + "name": "memory_recall", + "description": ( + "Search memories by a natural language query, ranked by relevance and decay score. " + "Set semantic=true to merge keyword results with vector similarity when embeddings exist." + ), + "inputSchema": { + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "Natural language search query", + }, + "semantic": { + "type": "boolean", + "description": "Merge with semantic/vector similarity search (requires embeddings, default false)", + }, + "limit": { + "type": "integer", + "description": "Maximum number of results to return (default 10)", + }, + }, + "required": ["query"], + }, + }, + { + "name": "memory_get", + "description": ( + "Retrieve a single memory by its UUID, including full content, frontmatter metadata, " + "relations, and current decay score." + ), + "inputSchema": { + "type": "object", + "properties": { + "memory_id": { + "type": "string", + "description": "UUID of the memory to retrieve", + } + }, + "required": ["memory_id"], + }, + }, + { + "name": "memory_search", + "description": ( + "Filter memories by type, tags, and/or minimum importance score. " + "Optionally include a text query. Returns results sorted by importance descending. " + "Use this for structured browsing; use memory_recall for ranked relevance search." + ), + "inputSchema": { + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "Optional text query to filter results", + }, + "memory_types": { + "type": "array", + "items": {"type": "string"}, + "description": "Filter by memory types (e.g. ['solution', 'fix'])", + }, + "tags": { + "type": "array", + "items": {"type": "string"}, + "description": "Filter by tags — memory must have at least one of these", + }, + "min_importance": { + "type": "number", + "description": "Minimum importance score (0.0 to 1.0)", + }, + }, + }, + }, + { + "name": "memory_relate", + "description": ( + "Create a typed relationship (edge) between two memories. " + "Valid relation types: SOLVES, CAUSES, BUILDS_ON, ALTERNATIVE_TO, REQUIRES, FOLLOWS, RELATED_TO. " + "Returns the new edge UUID, or empty string if the relationship already exists." + ), + "inputSchema": { + "type": "object", + "properties": { + "from_id": { + "type": "string", + "description": "UUID of the source memory", + }, + "to_id": { + "type": "string", + "description": "UUID of the target memory", + }, + "rel_type": { + "type": "string", + "description": "Relationship type (SOLVES, CAUSES, BUILDS_ON, ALTERNATIVE_TO, REQUIRES, FOLLOWS, RELATED_TO)", + }, + "description": { + "type": "string", + "description": "Optional human-readable description of the relationship", + }, + "strength": { + "type": "number", + "description": "Relationship strength from 0.0 to 1.0 (default 0.8)", + }, + }, + "required": ["from_id", "to_id", "rel_type"], + }, + }, + { + "name": "memory_related", + "description": ( + "Traverse the relationship graph from a given memory, returning connected memories " + "up to max_depth hops away. Optionally filter by relationship type." + ), + "inputSchema": { + "type": "object", + "properties": { + "memory_id": { + "type": "string", + "description": "UUID of the starting memory", + }, + "rel_types": { + "type": "array", + "items": {"type": "string"}, + "description": "Filter by relation types (e.g. ['SOLVES', 'BUILDS_ON'])", + }, + "max_depth": { + "type": "integer", + "description": "Maximum traversal depth (1-5, default 1)", + }, + }, + "required": ["memory_id"], + }, + }, + { + "name": "memory_edge_get", + "description": ( + "Retrieve a single relationship edge by its UUID, including metadata and description body." + ), + "inputSchema": { + "type": "object", + "properties": { + "edge_id": { + "type": "string", + "description": "UUID of the edge to retrieve", + } + }, + "required": ["edge_id"], + }, + }, + { + "name": "memory_edge_search", + "description": ( + "Search relationship edges by type, connected memory IDs, or a text query " + "that matches against the from/to memory titles and relationship type." + ), + "inputSchema": { + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "Text query to match against edge titles and type", + }, + "types": { + "type": "array", + "items": {"type": "string"}, + "description": "Filter by relationship types (e.g. ['SOLVES'])", + }, + "from_id": { + "type": "string", + "description": "Filter to edges originating from this memory UUID", + }, + "to_id": { + "type": "string", + "description": "Filter to edges pointing at this memory UUID", + }, + }, + }, + }, + { + "name": "memory_reflect", + "description": ( + "Review memories created since a given date, cluster them by shared tags, " + "and return consolidation recommendations. Does NOT auto-create new memories — " + "you review the output and decide what to store. Set dry_run=true to skip " + "updating state and logging an episode entry." + ), + "inputSchema": { + "type": "object", + "properties": { + "since": { + "type": "string", + "description": "ISO date (YYYY-MM-DD) to review memories from. Defaults to last reflection date or 30 days ago.", + }, + "dry_run": { + "type": "boolean", + "description": "If true, return analysis without persisting state changes (default false)", + }, + }, + }, + }, + { + "name": "memory_reflection", + "description": ( + "Return the current REFLECTION.md summary — the auto-curated narrative of recent " + "memory themes, clusters, and activity. Use this to quickly orient at session start." + ), + "inputSchema": {"type": "object", "properties": {}}, + }, + { + "name": "memory_stats", + "description": ( + "Return statistics about the memory system: total count, breakdown by type, " + "relation count, decay distribution, embeddings count, and per-directory file counts." + ), + "inputSchema": {"type": "object", "properties": {}}, + }, + { + "name": "memory_episode", + "description": ( + "Append a timestamped entry to today's episode log file (~/.claude/memory/episodes/YYYY-MM-DD.md). " + "Use this to record significant session events, commits, or decisions without creating full memories." + ), + "inputSchema": { + "type": "object", + "properties": { + "type": { + "type": "string", + "description": "Episode entry type (e.g. fix, commit, decision, automation)", + }, + "title": { + "type": "string", + "description": "Short title for the episode entry", + }, + "tags": { + "type": "array", + "items": {"type": "string"}, + "description": "Tags for the episode entry", + }, + "summary": { + "type": "string", + "description": "Optional summary text for the entry", + }, + }, + "required": ["type", "title"], + }, + }, + { + "name": "memory_tags_list", + "description": ( + "List all tags used across the memory system, sorted by usage frequency. " + "Returns tag name and count of memories using it." + ), + "inputSchema": { + "type": "object", + "properties": { + "limit": { + "type": "integer", + "description": "Maximum number of tags to return (0 = unlimited, default 0)", + } + }, + }, + }, + { + "name": "memory_tags_related", + "description": ( + "Find tags that frequently co-occur with a given tag, sorted by co-occurrence count. " + "Useful for discovering related topics and navigating the tag graph." + ), + "inputSchema": { + "type": "object", + "properties": { + "tag": { + "type": "string", + "description": "The tag to find co-occurring tags for", + }, + "limit": { + "type": "integer", + "description": "Maximum number of related tags to return (0 = unlimited, default 0)", + }, + }, + "required": ["tag"], + }, + }, + { + "name": "memory_embed", + "description": ( + "Generate or refresh vector embeddings for all memories that do not yet have them. " + "Requires either Ollama (nomic-embed-text model) or an OpenAI API key configured. " + "Embeddings enable semantic recall via memory_recall with semantic=true." + ), + "inputSchema": {"type": "object", "properties": {}}, + }, + { + "name": "memory_core", + "description": ( + "Return the current CORE.md content — the auto-curated high-priority memory digest " + "used to seed Claude sessions. Lists critical solutions, active decisions, and key fixes." + ), + "inputSchema": {"type": "object", "properties": {}}, + }, + { + "name": "memory_decay", + "description": ( + "Run a decay pass over all memories: recalculate decay scores based on age, " + "access frequency, importance, and type weight. Archives memories whose score " + "drops below the dormant threshold. Returns a summary of updated scores." + ), + "inputSchema": {"type": "object", "properties": {}}, + }, + { + "name": "memory_config", + "description": ( + "View or update the cognitive memory embedding configuration (_config.json). " + "Set action='show' to display current config (API key is masked). " + "Provide provider='openai' or provider='ollama' to switch embedding backends. " + "Provide openai_api_key to set the OpenAI API key for embeddings." + ), + "inputSchema": { + "type": "object", + "properties": { + "action": { + "type": "string", + "description": "Set to 'show' to display current config without modifying it", + }, + "provider": { + "type": "string", + "description": "Embedding provider: 'ollama' or 'openai'", + }, + "openai_api_key": { + "type": "string", + "description": "OpenAI API key to store in config", + }, + }, + }, + }, + ] + + +def handle_tool_call( + tool_name: str, arguments: Dict[str, Any], client: CognitiveMemoryClient +) -> Dict[str, Any]: + """Dispatch MCP tool calls to the CognitiveMemoryClient.""" + + def ok(result: Any) -> Dict[str, Any]: + return { + "content": [ + {"type": "text", "text": json.dumps(result, indent=2, default=str)} + ] + } + + try: + if tool_name == "memory_store": + memory_id = client.store( + type=arguments["type"], + title=arguments["title"], + content=arguments["content"], + tags=arguments.get("tags"), + importance=arguments.get("importance", 0.5), + ) + return ok({"success": True, "memory_id": memory_id}) + + elif tool_name == "memory_recall": + results = client.recall( + query=arguments["query"], + semantic=arguments.get("semantic", False), + limit=arguments.get("limit", 10), + ) + return ok(results) + + elif tool_name == "memory_get": + result = client.get(arguments["memory_id"]) + if result is None: + return ok({"error": f"Memory not found: {arguments['memory_id']}"}) + return ok(result) + + elif tool_name == "memory_search": + results = client.search( + query=arguments.get("query"), + memory_types=arguments.get("memory_types"), + tags=arguments.get("tags"), + min_importance=arguments.get("min_importance"), + ) + return ok(results) + + elif tool_name == "memory_relate": + edge_id = client.relate( + from_id=arguments["from_id"], + to_id=arguments["to_id"], + rel_type=arguments["rel_type"], + description=arguments.get("description"), + strength=arguments.get("strength", 0.8), + ) + if edge_id: + return ok({"success": True, "edge_id": edge_id}) + return ok({"success": False, "message": "Relationship already exists"}) + + elif tool_name == "memory_related": + results = client.related( + memory_id=arguments["memory_id"], + rel_types=arguments.get("rel_types"), + max_depth=arguments.get("max_depth", 1), + ) + return ok(results) + + elif tool_name == "memory_edge_get": + result = client.edge_get(arguments["edge_id"]) + if result is None: + return ok({"error": f"Edge not found: {arguments['edge_id']}"}) + return ok(result) + + elif tool_name == "memory_edge_search": + results = client.edge_search( + query=arguments.get("query"), + types=arguments.get("types"), + from_id=arguments.get("from_id"), + to_id=arguments.get("to_id"), + ) + return ok(results) + + elif tool_name == "memory_reflect": + result = client.reflect( + since=arguments.get("since"), + dry_run=arguments.get("dry_run", False), + ) + return ok(result) + + elif tool_name == "memory_reflection": + text = client.reflection_summary() + return ok({"content": text}) + + elif tool_name == "memory_stats": + result = client.stats() + return ok(result) + + elif tool_name == "memory_episode": + client.episode( + type=arguments["type"], + title=arguments["title"], + tags=arguments.get("tags"), + summary=arguments.get("summary"), + ) + return ok({"success": True}) + + elif tool_name == "memory_tags_list": + results = client.tags_list(limit=arguments.get("limit", 0)) + return ok(results) + + elif tool_name == "memory_tags_related": + results = client.tags_related( + tag=arguments["tag"], + limit=arguments.get("limit", 0), + ) + return ok(results) + + elif tool_name == "memory_embed": + result = client.embed() + return ok(result) + + elif tool_name == "memory_core": + text = client.core() + return ok({"content": text}) + + elif tool_name == "memory_decay": + result = client.decay() + return ok(result) + + elif tool_name == "memory_config": + config_path = MEMORY_DIR / "_config.json" + config = _load_memory_config(config_path) + changed = False + + provider = arguments.get("provider") + openai_api_key = arguments.get("openai_api_key") + + if provider: + config["embedding_provider"] = provider + changed = True + if openai_api_key: + config["openai_api_key"] = openai_api_key + changed = True + + if changed: + config_path.write_text(json.dumps(config, indent=2)) + return ok({"success": True, "updated": True}) + else: + # Show config with masked API key + display = dict(config) + key = display.get("openai_api_key") + if key and isinstance(key, str) and len(key) > 8: + display["openai_api_key"] = key[:4] + "..." + key[-4:] + return ok(display) + + else: + return { + "content": [{"type": "text", "text": f"Unknown tool: {tool_name}"}], + "isError": True, + } + + except Exception as e: + return { + "content": [{"type": "text", "text": f"Error: {str(e)}"}], + "isError": True, + } + + +def main(): + """MCP stdio server main loop (JSON-RPC 2.0).""" + + client = CognitiveMemoryClient() + tools = create_tools() + + for line in sys.stdin: + line = line.strip() + if not line: + continue + + try: + message = json.loads(line) + + if message.get("method") == "initialize": + response = { + "jsonrpc": "2.0", + "id": message.get("id"), + "result": { + "protocolVersion": "2024-11-05", + "capabilities": {"tools": {}}, + "serverInfo": { + "name": "cognitive-memory-mcp-server", + "version": "3.0.0", + }, + }, + } + print(json.dumps(response), flush=True) + + elif message.get("method") == "tools/list": + response = { + "jsonrpc": "2.0", + "id": message.get("id"), + "result": {"tools": tools}, + } + print(json.dumps(response), flush=True) + + elif message.get("method") == "tools/call": + params = message.get("params", {}) + tool_name = params.get("name") + arguments = params.get("arguments", {}) + + result = handle_tool_call(tool_name, arguments, client) + + response = {"jsonrpc": "2.0", "id": message.get("id"), "result": result} + print(json.dumps(response), flush=True) + + elif message.get("method") == "notifications/initialized": + # Acknowledge but no response required for notifications + pass + + except Exception as e: + error_response = { + "jsonrpc": "2.0", + "id": message.get("id") if "message" in locals() else None, + "error": {"code": -32603, "message": str(e)}, + } + print(json.dumps(error_response), flush=True) + + +if __name__ == "__main__": + main()