From d7bbffdf640918f5651ed054922c594a97a88ec4 Mon Sep 17 00:00:00 2001 From: Steven Leggett Date: Thu, 22 Jan 2026 13:45:45 -0500 Subject: [PATCH 1/8] chore: Bump version to 3.8.1.dev0 for development --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index fbd178f..86c97d6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "sugarai" -version = "3.8.0" +version = "3.8.1.dev0" description = "šŸ° Sugar - The autonomous layer for AI coding agents. Manages task queues, runs 24/7, ships working code." readme = "README.md" From 6cfcb88e66db830b9d99939ccd0e7d77bd13dadd Mon Sep 17 00:00:00 2001 From: Steven Leggett Date: Tue, 17 Mar 2026 11:30:33 -0400 Subject: [PATCH 2/8] feat: Add global memory layer for cross-project knowledge Add ~/.sugar/memory.db as a global memory store that works alongside project-local memory. All search/recall operations now query both stores and merge results by relevance. - GlobalMemoryManager wraps project + global MemoryStore instances - MCP server works outside Sugar projects (global-only mode) - New --global CLI flag on remember/recall/memories/forget/memory-stats - New "guideline" memory type for cross-project standards - New sugar://global/guidelines MCP resource - scope parameter on store_learning MCP tool - Search results labeled with scope (project/global) - 62 new tests, 978 total passing --- sugar/main.py | 279 ++++++--- sugar/mcp/memory_server.py | 185 ++++-- sugar/memory/__init__.py | 6 +- sugar/memory/global_store.py | 210 +++++++ sugar/memory/retriever.py | 26 +- sugar/memory/types.py | 9 + tests/test_global_memory.py | 1115 ++++++++++++++++++++++++++++++++++ 7 files changed, 1695 insertions(+), 135 deletions(-) create mode 100644 sugar/memory/global_store.py create mode 100644 tests/test_global_memory.py diff --git a/sugar/main.py b/sugar/main.py index fed313e..a95d1f5 100644 --- a/sugar/main.py +++ b/sugar/main.py @@ -10,6 +10,7 @@ import sys from datetime import datetime, timezone from pathlib import Path +from typing import Optional import click @@ -3839,6 +3840,23 @@ def _get_memory_store(config: dict): return MemoryStore(str(memory_db)) +def _get_global_memory_manager(config: Optional[dict] = None): + """Get GlobalMemoryManager with optional project store.""" + from .memory import MemoryStore + from .memory.global_store import GlobalMemoryManager + + project_store = None + if config: + try: + sugar_dir = Path(config["sugar"]["storage"]["database"]).parent + memory_db = sugar_dir / "memory.db" + project_store = MemoryStore(str(memory_db)) + except (KeyError, Exception): + pass + + return GlobalMemoryManager(project_store=project_store) + + @cli.command() @click.argument("content") @click.option( @@ -3852,6 +3870,7 @@ def _get_memory_store(config: dict): "file_context", "error_pattern", "outcome", + "guideline", ] ), default="decision", @@ -3867,25 +3886,38 @@ def _get_memory_store(config: dict): @click.option( "--importance", type=float, default=1.0, help="Importance score (0.0-2.0)" ) +@click.option( + "--global", + "is_global", + is_flag=True, + help="Store in global memory (available across all projects)", +) @click.pass_context -def remember(ctx, content, memory_type, tags, file_path, ttl, importance): +def remember(ctx, content, memory_type, tags, file_path, ttl, importance, is_global): """Store a memory for future reference Examples: sugar remember "Always use async/await, never callbacks" sugar remember "Auth tokens expire after 15 minutes" --type research --ttl 90d sugar remember "payment_processor.rb handles Stripe webhooks" --type file_context --file src/payment_processor.rb + sugar remember "Always use Kamal for deploys" --type guideline --global """ import uuid - from .memory import MemoryEntry, MemoryStore, MemoryType + from .memory import MemoryEntry, MemoryScope, MemoryType config_file = ctx.obj["config"] - config = _require_sugar_project(config_file) - try: - store = _get_memory_store(config) + if is_global: + config = None + manager = _get_global_memory_manager(config=None) + scope = MemoryScope.GLOBAL + else: + config = _require_sugar_project(config_file) + manager = _get_global_memory_manager(config=config) + scope = MemoryScope.PROJECT + try: # Parse TTL expires_at = None if ttl.lower() != "never": @@ -3913,12 +3945,13 @@ def remember(ctx, content, memory_type, tags, file_path, ttl, importance): expires_at=expires_at, ) - entry_id = store.store(entry) - store.close() + entry_id = manager.store(entry, scope=scope) + manager.close() click.echo(f"āœ… Remembered: {content[:60]}{'...' if len(content) > 60 else ''}") click.echo(f" ID: {entry_id[:8]}...") click.echo(f" Type: {memory_type}") + click.echo(f" Scope: {'global' if is_global else 'project'}") if expires_at: click.echo(f" Expires: {expires_at.strftime('%Y-%m-%d')}") @@ -3948,6 +3981,7 @@ def remember(ctx, content, memory_type, tags, file_path, ttl, importance): "file_context", "error_pattern", "outcome", + "guideline", "all", ] ), @@ -3966,18 +4000,26 @@ def remember(ctx, content, memory_type, tags, file_path, ttl, importance): def recall(ctx, query, memory_type, limit, output_format): """Search memories for relevant context + Searches both project and global memories automatically. + Examples: sugar recall "how do we handle authentication" sugar recall "error handling" --type error_pattern --limit 5 sugar recall "database" --format json """ - from .memory import MemoryQuery, MemoryStore, MemoryType + from .memory import MemoryQuery, MemoryType config_file = ctx.obj["config"] - config = _require_sugar_project(config_file) + # Try to load project config, but don't require it - global memories + # are available even outside a Sugar project. try: - store = _get_memory_store(config) + config = _require_sugar_project(config_file) + except SystemExit: + config = None + + try: + manager = _get_global_memory_manager(config=config) # Build query memory_types = None @@ -3990,8 +4032,8 @@ def recall(ctx, query, memory_type, limit, output_format): limit=limit, ) - results = store.search(search_query) - store.close() + results = manager.search(search_query, limit=limit) + manager.close() if not results: click.echo(f"No memories found matching: {query}") @@ -4006,6 +4048,7 @@ def recall(ctx, query, memory_type, limit, output_format): "content": r.entry.content, "type": r.entry.memory_type.value, "score": round(r.score, 3), + "scope": r.scope, "created_at": ( r.entry.created_at.isoformat() if r.entry.created_at else None ), @@ -4016,8 +4059,9 @@ def recall(ctx, query, memory_type, limit, output_format): elif output_format == "full": for i, r in enumerate(results, 1): click.echo(f"\n{'='*60}") + scope_label = f" [{r.scope}]" if r.scope else "" click.echo( - f"[{i}] {r.entry.memory_type.value.upper()} (score: {r.score:.2f})" + f"[{i}] {r.entry.memory_type.value.upper()}{scope_label} (score: {r.score:.2f})" ) click.echo(f"ID: {r.entry.id}") click.echo( @@ -4030,19 +4074,20 @@ def recall(ctx, query, memory_type, limit, output_format): click.echo(f"Files: {', '.join(r.entry.metadata['file_paths'])}") else: # table click.echo(f"\nSearch results for: {query}\n") - click.echo(f"{'Score':<8} {'Type':<15} {'Content':<55}") - click.echo("-" * 80) + click.echo(f"{'Score':<8} {'Type':<15} {'Scope':<10} {'Content':<47}") + click.echo("-" * 82) for r in results: content = ( - r.entry.content[:52] + "..." - if len(r.entry.content) > 55 + r.entry.content[:44] + "..." + if len(r.entry.content) > 47 else r.entry.content ) content = content.replace("\n", " ") + scope_label = r.scope or "project" click.echo( - f"{r.score:.2f} {r.entry.memory_type.value:<15} {content:<55}" + f"{r.score:.2f} {r.entry.memory_type.value:<15} {scope_label:<10} {content:<47}" ) - click.echo(f"\n{len(results)} memories found ({r.match_type} search)") + click.echo(f"\n{len(results)} memories found ({results[-1].match_type} search)") except ImportError as e: click.echo( @@ -4069,6 +4114,7 @@ def recall(ctx, query, memory_type, limit, output_format): "file_context", "error_pattern", "outcome", + "guideline", "all", ] ), @@ -4086,20 +4132,25 @@ def recall(ctx, query, memory_type, limit, output_format): ) @click.pass_context def memories(ctx, memory_type, since, limit, output_format): - """List stored memories + """List stored memories (project and global) Examples: sugar memories sugar memories --type preference sugar memories --since 7d --format json """ - from .memory import MemoryStore, MemoryType + from .memory import MemoryType config_file = ctx.obj["config"] - config = _require_sugar_project(config_file) + # Try to load project config but don't require it. try: - store = _get_memory_store(config) + config = _require_sugar_project(config_file) + except SystemExit: + config = None + + try: + manager = _get_global_memory_manager(config=config) # Parse since filter since_days = None @@ -4119,41 +4170,68 @@ def memories(ctx, memory_type, since, limit, output_format): ) sys.exit(1) - # Get memories + # Get memories from both stores type_filter = None if memory_type == "all" else MemoryType(memory_type) - entries = store.list_memories( + + # Collect from project store with scope label + project_entries = [] + if manager.project_store: + project_entries = manager.project_store.list_memories( + memory_type=type_filter, + limit=limit, + since_days=since_days, + ) + + global_entries = manager.global_store.list_memories( memory_type=type_filter, limit=limit, since_days=since_days, ) - store.close() + manager.close() + + # Build combined list with scope tracking + scoped_entries = [("project", e) for e in project_entries] + [ + ("global", e) for e in global_entries + ] + + # Sort by importance then recency + from datetime import datetime as _dt + scoped_entries.sort( + key=lambda se: (se[1].importance, se[1].created_at or _dt.min), + reverse=True, + ) + scoped_entries = scoped_entries[:limit] - if not entries: + if not scoped_entries: click.echo("No memories found") return if output_format == "json": import json - output = [e.to_dict() for e in entries] + output = [] + for scope, e in scoped_entries: + d = e.to_dict() + d["scope"] = scope + output.append(d) click.echo(json.dumps(output, indent=2)) else: # table - click.echo(f"\n{'ID':<10} {'Type':<15} {'Created':<12} {'Content':<40}") - click.echo("-" * 80) - for e in entries: - content = e.content[:37] + "..." if len(e.content) > 40 else e.content + click.echo(f"\n{'ID':<10} {'Scope':<10} {'Type':<15} {'Created':<12} {'Content':<35}") + click.echo("-" * 85) + for scope, e in scoped_entries: + content = e.content[:32] + "..." if len(e.content) > 35 else e.content content = content.replace("\n", " ") created = ( e.created_at.strftime("%Y-%m-%d") if e.created_at else "unknown" ) click.echo( - f"{e.id[:8]:<10} {e.memory_type.value:<15} {created:<12} {content:<40}" + f"{e.id[:8]:<10} {scope:<10} {e.memory_type.value:<15} {created:<12} {content:<35}" ) - click.echo(f"\n{len(entries)} memories") + click.echo(f"\n{len(scoped_entries)} memories") # Show counts by type - type_counts = {} - for e in entries: + type_counts: dict = {} + for _scope, e in scoped_entries: t = e.memory_type.value type_counts[t] = type_counts.get(t, 0) + 1 if len(type_counts) > 1: @@ -4178,41 +4256,62 @@ def memories(ctx, memory_type, since, limit, output_format): @click.option("--force", is_flag=True, help="Skip confirmation") @click.pass_context def forget(ctx, memory_id, force): - """Delete a memory by ID + """Delete a memory by ID (searches both project and global stores) Examples: sugar forget abc123 sugar forget abc123 --force """ - from .memory import MemoryStore - config_file = ctx.obj["config"] - config = _require_sugar_project(config_file) + # Try to load project config but don't require it. try: - store = _get_memory_store(config) + config = _require_sugar_project(config_file) + except SystemExit: + config = None + + try: + manager = _get_global_memory_manager(config=config) - # Find the memory first - entry = store.get(memory_id) + # Find the memory in either store - check project first, then global + entry = None + entry_scope = None + + if manager.project_store: + entry = manager.project_store.get(memory_id) + if entry: + entry_scope = "project" + + if not entry: + entry = manager.global_store.get(memory_id) + if entry: + entry_scope = "global" - # If not found by exact ID, try prefix match + # If not found by exact ID, try prefix match across both stores if not entry: - entries = store.list_memories(limit=1000) - matches = [e for e in entries if e.id.startswith(memory_id)] + all_entries = [] + if manager.project_store: + all_entries.extend( + [("project", e) for e in manager.project_store.list_memories(limit=1000)] + ) + all_entries.extend( + [("global", e) for e in manager.global_store.list_memories(limit=1000)] + ) + matches = [(s, e) for s, e in all_entries if e.id.startswith(memory_id)] if len(matches) == 1: - entry = matches[0] + entry_scope, entry = matches[0] elif len(matches) > 1: click.echo( f"āŒ Ambiguous ID '{memory_id}' matches {len(matches)} memories:" ) - for m in matches[:5]: - click.echo(f" {m.id[:12]} - {m.content[:40]}...") - store.close() + for s, m in matches[:5]: + click.echo(f" {m.id[:12]} [{s}] - {m.content[:40]}...") + manager.close() sys.exit(1) if not entry: click.echo(f"āŒ Memory not found: {memory_id}") - store.close() + manager.close() sys.exit(1) # Confirm deletion @@ -4220,17 +4319,18 @@ def forget(ctx, memory_id, force): click.echo(f"\nMemory to delete:") click.echo(f" ID: {entry.id}") click.echo(f" Type: {entry.memory_type.value}") + click.echo(f" Scope: {entry_scope}") click.echo( f" Content: {entry.content[:100]}{'...' if len(entry.content) > 100 else ''}" ) if not click.confirm("\nDelete this memory?"): click.echo("Cancelled") - store.close() + manager.close() return - # Delete - deleted = store.delete(entry.id) - store.close() + # Delete from the appropriate store + deleted = manager.delete(entry.id) + manager.close() if deleted: click.echo(f"āœ… Memory deleted: {entry.id[:8]}...") @@ -4353,13 +4453,20 @@ def export_context(ctx, output_format, limit, types): @click.pass_context def memory_stats(ctx): """Show memory system statistics""" - from .memory import MemoryStore, MemoryType, is_semantic_search_available + import os + + from .memory import MemoryType, is_semantic_search_available config_file = ctx.obj["config"] - config = _require_sugar_project(config_file) + # Try to load project config but don't require it. try: - store = _get_memory_store(config) + config = _require_sugar_project(config_file) + except SystemExit: + config = None + + try: + manager = _get_global_memory_manager(config=config) click.echo("\nšŸ“Š Sugar Memory Statistics\n") @@ -4368,34 +4475,40 @@ def memory_stats(ctx): click.echo( f"Semantic search: {'āœ… Available' if semantic_available else 'āŒ Not available (using keyword search)'}" ) - click.echo(f"Database: {store.db_path}") click.echo("") - # Count by type - total = store.count() - click.echo(f"Total memories: {total}") - - if total > 0: - click.echo("\nBy type:") - for mem_type in MemoryType: - count = store.count(mem_type) - if count > 0: - click.echo(f" {mem_type.value:<15} {count:>5}") - - # Database size - import os - - if store.db_path.exists(): - size_bytes = os.path.getsize(store.db_path) - if size_bytes < 1024: - size_str = f"{size_bytes} bytes" - elif size_bytes < 1024 * 1024: - size_str = f"{size_bytes / 1024:.1f} KB" - else: - size_str = f"{size_bytes / (1024 * 1024):.1f} MB" - click.echo(f"\nDatabase size: {size_str}") + def _print_store_stats(label: str, store): + """Print stats for a single MemoryStore.""" + click.echo(f"{label}") + click.echo(f" Database: {store.db_path}") + total = store.count() + click.echo(f" Total memories: {total}") + if total > 0: + click.echo(" By type:") + for mem_type in MemoryType: + count = store.count(mem_type) + if count > 0: + click.echo(f" {mem_type.value:<15} {count:>5}") + if store.db_path.exists(): + size_bytes = os.path.getsize(store.db_path) + if size_bytes < 1024: + size_str = f"{size_bytes} bytes" + elif size_bytes < 1024 * 1024: + size_str = f"{size_bytes / 1024:.1f} KB" + else: + size_str = f"{size_bytes / (1024 * 1024):.1f} MB" + click.echo(f" Database size: {size_str}") + click.echo("") - store.close() + if manager.project_store: + _print_store_stats("Project memories:", manager.project_store) + else: + click.echo("Project memories: (not in a Sugar project)") + click.echo("") + + _print_store_stats("Global memories:", manager.global_store) + + manager.close() except ImportError as e: click.echo( diff --git a/sugar/mcp/memory_server.py b/sugar/mcp/memory_server.py index e91efad..b1dbed0 100644 --- a/sugar/mcp/memory_server.py +++ b/sugar/mcp/memory_server.py @@ -31,27 +31,33 @@ FastMCP = None -def get_memory_store(): - """Get memory store from Sugar project context.""" +def get_memory_manager(): + """Get global memory manager with optional project store. + + Walks up from cwd to find a .sugar directory. If found, creates a + project-scoped MemoryStore and passes it to GlobalMemoryManager. + If not found, returns a GlobalMemoryManager with no project store - + global memory is still fully available in that case. + """ from sugar.memory import MemoryStore + from sugar.memory.global_store import GlobalMemoryManager - # Try to find .sugar directory + project_store = None cwd = Path.cwd() sugar_dir = cwd / ".sugar" if not sugar_dir.exists(): - # Check parent directories for parent in cwd.parents: potential = parent / ".sugar" if potential.exists(): sugar_dir = potential break - if not sugar_dir.exists(): - raise RuntimeError("Not in a Sugar project. Run 'sugar init' first.") + if sugar_dir.exists(): + memory_db = sugar_dir / "memory.db" + project_store = MemoryStore(str(memory_db)) - memory_db = sugar_dir / "memory.db" - return MemoryStore(str(memory_db)) + return GlobalMemoryManager(project_store=project_store) def create_memory_mcp_server() -> "FastMCP": @@ -68,6 +74,10 @@ async def search_memory(query: str, limit: int = 5) -> List[Dict[str, Any]]: """ Search Sugar memory for relevant context. + Automatically searches both project-local and global memories, + merging results by relevance score. Results include a scope field + indicating whether each memory came from the project or global store. + Use this to find previous decisions, preferences, error patterns, and other relevant information from past sessions. @@ -76,21 +86,21 @@ async def search_memory(query: str, limit: int = 5) -> List[Dict[str, Any]]: limit: Maximum results to return (default: 5) Returns: - List of matching memories with content, type, and relevance score + List of matching memories with content, type, relevance score, and scope """ from sugar.memory import MemoryQuery + manager = get_memory_manager() try: - store = get_memory_store() search_query = MemoryQuery(query=query, limit=limit) - results = store.search(search_query) - store.close() + results = manager.search(search_query, limit=limit) return [ { "content": r.entry.content, "type": r.entry.memory_type.value, "score": round(r.score, 3), + "scope": r.scope, "id": r.entry.id[:8], "created_at": ( r.entry.created_at.isoformat() if r.entry.created_at else None @@ -101,42 +111,59 @@ async def search_memory(query: str, limit: int = 5) -> List[Dict[str, Any]]: except Exception as e: logger.error(f"search_memory failed: {e}") return [{"error": str(e)}] + finally: + manager.close() @mcp.tool() async def store_learning( content: str, memory_type: str = "decision", tags: Optional[str] = None, + scope: str = "project", ) -> Dict[str, Any]: """ Store a new learning, decision, or observation in Sugar memory. - Use this to remember important information for future sessions: - - Decisions made during implementation - - User preferences discovered - - Error patterns and their fixes - - Research findings + Use scope="global" for cross-project knowledge that should be available + in all projects: + - Coding guidelines and best practices + - Infrastructure and deployment standards + - SEO standards and content conventions + - Organisation-wide architectural decisions + + Use scope="project" (default) for context that only applies to this + project. If you are outside a Sugar project directory, project-scoped + memories are automatically promoted to global scope. Args: content: What to remember (be specific and detailed) - memory_type: Type of memory (decision, preference, research, error_pattern, file_context, outcome) - tags: Optional comma-separated tags for organization + memory_type: Type of memory (decision, preference, research, + error_pattern, file_context, outcome, guideline) + tags: Optional comma-separated tags for organisation + scope: "project" for local context, "global" for cross-project + knowledge (default: project) Returns: - Confirmation with memory ID + Confirmation with memory ID and scope used """ import uuid from sugar.memory import MemoryEntry, MemoryType + from sugar.memory.types import MemoryScope + manager = get_memory_manager() try: - store = get_memory_store() - # Validate memory type try: mem_type = MemoryType(memory_type) except ValueError: mem_type = MemoryType.DECISION + # Validate scope + try: + mem_scope = MemoryScope(scope) + except ValueError: + mem_scope = MemoryScope.PROJECT + # Parse tags metadata = {} if tags: @@ -150,54 +177,85 @@ async def store_learning( metadata=metadata, ) - entry_id = store.store(entry) - store.close() + # If project scope is requested but no project store exists, fall + # back to global so the server never errors outside a Sugar project. + actual_scope = mem_scope + scope_note = None + if mem_scope == MemoryScope.PROJECT and manager.project_store is None: + actual_scope = MemoryScope.GLOBAL + scope_note = ( + "No Sugar project found - stored in global memory instead. " + "Run 'sugar init' to initialise a project store." + ) + + entry_id = manager.store(entry, actual_scope) - return { + result: Dict[str, Any] = { "status": "stored", "id": entry_id[:8], "type": mem_type.value, + "scope": actual_scope.value, "content_preview": ( content[:100] + "..." if len(content) > 100 else content ), } + if scope_note: + result["note"] = scope_note + + return result except Exception as e: logger.error(f"store_learning failed: {e}") return {"error": str(e)} + finally: + manager.close() @mcp.tool() async def get_project_context() -> Dict[str, Any]: """ Get current project context summary from Sugar memory. - Returns an organized summary of: + Returns an organised summary of: - User preferences (coding style, conventions) - Recent decisions (architecture, implementation choices) - Known error patterns and fixes - File context (what files do what) + - Global guidelines (cross-project standards and best practices) Use this at the start of a task to understand project context. """ - from sugar.memory import MemoryRetriever + from sugar.memory import MemoryRetriever, MemoryType + manager = get_memory_manager() try: - store = get_memory_store() - retriever = MemoryRetriever(store) + retriever = MemoryRetriever(manager) context = retriever.get_project_context(limit=10) - store.close() + + # Add global guidelines - these live in the global store only + guidelines = manager.global_store.get_by_type(MemoryType.GUIDELINE, limit=10) + context["guidelines"] = [ + { + "id": g.id[:8], + "content": g.content, + "created_at": g.created_at.isoformat() if g.created_at else None, + } + for g in guidelines + ] return context except Exception as e: logger.error(f"get_project_context failed: {e}") return {"error": str(e)} + finally: + manager.close() @mcp.tool() async def recall(topic: str) -> str: """ Get memories about a specific topic, formatted as readable context. - Similar to search_memory but returns formatted markdown suitable - for injection into prompts or context. + Searches both project-local and global memories. Similar to + search_memory but returns formatted markdown suitable for injection + into prompts or context. Args: topic: The topic to recall information about @@ -207,13 +265,12 @@ async def recall(topic: str) -> str: """ from sugar.memory import MemoryQuery, MemoryRetriever + manager = get_memory_manager() try: - store = get_memory_store() - retriever = MemoryRetriever(store) + retriever = MemoryRetriever(manager) search_query = MemoryQuery(query=topic, limit=5) - results = store.search(search_query) - store.close() + results = manager.search(search_query) if not results: return f"No memories found about: {topic}" @@ -222,6 +279,8 @@ async def recall(topic: str) -> str: except Exception as e: logger.error(f"recall failed: {e}") return f"Error recalling memories: {e}" + finally: + manager.close() @mcp.tool() async def list_recent_memories( @@ -229,10 +288,12 @@ async def list_recent_memories( limit: int = 10, ) -> List[Dict[str, Any]]: """ - List recent memories, optionally filtered by type. + List recent memories from both project and global stores, optionally + filtered by type. Args: - memory_type: Optional filter (decision, preference, research, error_pattern, file_context, outcome) + memory_type: Optional filter (decision, preference, research, + error_pattern, file_context, outcome, guideline) limit: Maximum memories to return (default: 10) Returns: @@ -240,9 +301,8 @@ async def list_recent_memories( """ from sugar.memory import MemoryType + manager = get_memory_manager() try: - store = get_memory_store() - type_filter = None if memory_type: try: @@ -250,11 +310,10 @@ async def list_recent_memories( except ValueError: pass - entries = store.list_memories( + entries = manager.list_memories( memory_type=type_filter, limit=limit, ) - store.close() return [ { @@ -270,6 +329,8 @@ async def list_recent_memories( except Exception as e: logger.error(f"list_recent_memories failed: {e}") return [{"error": str(e)}] + finally: + manager.close() @mcp.resource("sugar://project/context") async def project_context_resource() -> str: @@ -281,12 +342,11 @@ async def project_context_resource() -> str: """ from sugar.memory import MemoryRetriever + manager = get_memory_manager() try: - store = get_memory_store() - retriever = MemoryRetriever(store) + retriever = MemoryRetriever(manager) context = retriever.get_project_context(limit=10) output = retriever.format_context_markdown(context) - store.close() return ( output @@ -295,16 +355,17 @@ async def project_context_resource() -> str: ) except Exception as e: return f"# Error loading project context\n\n{e}" + finally: + manager.close() @mcp.resource("sugar://preferences") async def preferences_resource() -> str: """User coding preferences stored in Sugar memory.""" from sugar.memory import MemoryType + manager = get_memory_manager() try: - store = get_memory_store() - preferences = store.get_by_type(MemoryType.PREFERENCE, limit=20) - store.close() + preferences = manager.get_by_type(MemoryType.PREFERENCE, limit=20) if not preferences: return "# No preferences stored yet\n\nUse `store_learning` with type='preference' to add preferences." @@ -316,6 +377,34 @@ async def preferences_resource() -> str: return "\n".join(lines) except Exception as e: return f"# Error loading preferences\n\n{e}" + finally: + manager.close() + + @mcp.resource("sugar://global/guidelines") + async def global_guidelines_resource() -> str: + """Cross-project guidelines and best practices from Sugar global memory.""" + from sugar.memory import MemoryType + + manager = get_memory_manager() + try: + guidelines = manager.global_store.get_by_type(MemoryType.GUIDELINE, limit=50) + + if not guidelines: + return ( + "# No global guidelines stored yet\n\n" + "Use `store_learning` with type='guideline' and scope='global' " + "to add cross-project standards and best practices." + ) + + lines = ["# Global Guidelines", ""] + for g in guidelines: + lines.append(f"- {g.content}") + + return "\n".join(lines) + except Exception as e: + return f"# Error loading global guidelines\n\n{e}" + finally: + manager.close() return mcp diff --git a/sugar/memory/__init__.py b/sugar/memory/__init__.py index 5794722..bf1321e 100644 --- a/sugar/memory/__init__.py +++ b/sugar/memory/__init__.py @@ -11,18 +11,22 @@ create_embedder, is_semantic_search_available, ) +from .global_store import GlobalMemoryManager from .retriever import MemoryRetriever from .store import MemoryStore -from .types import MemoryEntry, MemoryQuery, MemorySearchResult, MemoryType +from .types import MemoryEntry, MemoryQuery, MemoryScope, MemorySearchResult, MemoryType __all__ = [ # Types "MemoryEntry", "MemoryQuery", + "MemoryScope", "MemorySearchResult", "MemoryType", # Store "MemoryStore", + # Global store + "GlobalMemoryManager", # Retriever "MemoryRetriever", # Embedder diff --git a/sugar/memory/global_store.py b/sugar/memory/global_store.py new file mode 100644 index 0000000..3699b08 --- /dev/null +++ b/sugar/memory/global_store.py @@ -0,0 +1,210 @@ +""" +Global memory manager that combines project-local and global memory stores. + +Reads from both stores on every search, writes to the appropriate store +based on scope. The global store lives at ~/.sugar/memory.db and is +available regardless of whether the current directory is a Sugar project. +""" + +import logging +from datetime import datetime +from pathlib import Path +from typing import List, Optional + +from .store import MemoryStore +from .types import MemoryEntry, MemoryQuery, MemoryScope, MemorySearchResult, MemoryType + +logger = logging.getLogger(__name__) + +GLOBAL_DB_PATH = Path.home() / ".sugar" / "memory.db" + + +class GlobalMemoryManager: + """ + Manages both project-local and global memory stores. + + All search/recall operations query both stores and merge results by + relevance score. Writes are routed to the appropriate store based on + the requested scope. + + Works outside of Sugar projects - when no project store is provided, + global memory is still fully available. + """ + + def __init__(self, project_store: Optional[MemoryStore] = None): + """ + Args: + project_store: Project-local store. Pass None when not inside a + Sugar project - global memory will still be available. + """ + self.project_store = project_store + self.global_store = MemoryStore(str(GLOBAL_DB_PATH)) + + def store(self, entry: MemoryEntry, scope: MemoryScope = MemoryScope.PROJECT) -> str: + """ + Store a memory entry in the appropriate database. + + Args: + entry: The memory entry to store. + scope: Where to store it - PROJECT for .sugar/memory.db, + GLOBAL for ~/.sugar/memory.db. + + Returns: + The entry ID. + + Raises: + RuntimeError: If scope is PROJECT but no project store is available. + """ + if scope == MemoryScope.GLOBAL: + return self.global_store.store(entry) + + if self.project_store is None: + raise RuntimeError( + "Not in a Sugar project. Use scope=global or run 'sugar init'." + ) + return self.project_store.store(entry) + + def search(self, query: MemoryQuery, limit: int = 10) -> List[MemorySearchResult]: + """ + Search both stores and return merged, deduplicated results. + + Results are tagged with their scope and sorted by relevance score. + Near-duplicate content across stores is deduplicated, preferring + project-scoped results (more specific context). + + Args: + query: Search query parameters. + limit: Maximum number of results to return. + + Returns: + Merged list of results sorted by score descending. + """ + results: List[MemorySearchResult] = [] + + if self.project_store: + project_results = self.project_store.search(query) + for r in project_results: + r.scope = MemoryScope.PROJECT.value + results.extend(project_results) + + global_results = self.global_store.search(query) + for r in global_results: + r.scope = MemoryScope.GLOBAL.value + results.extend(global_results) + + results.sort(key=lambda r: r.score, reverse=True) + results = self._deduplicate(results) + + return results[:limit] + + def get_by_type(self, memory_type: MemoryType, limit: int = 50) -> List[MemoryEntry]: + """ + Get memories of a specific type from both stores. + + Args: + memory_type: The type to filter by. + limit: Maximum total results. + + Returns: + Combined list capped at limit. + """ + entries: List[MemoryEntry] = [] + + if self.project_store: + entries.extend(self.project_store.get_by_type(memory_type, limit)) + + entries.extend(self.global_store.get_by_type(memory_type, limit)) + + return entries[:limit] + + def list_memories(self, **kwargs) -> List[MemoryEntry]: + """ + List memories from both stores, sorted by importance then recency. + + Accepts the same keyword arguments as MemoryStore.list_memories + (memory_type, limit, offset, since_days). + + Returns: + Combined list sorted by importance descending, then created_at descending. + """ + entries: List[MemoryEntry] = [] + + if self.project_store: + entries.extend(self.project_store.list_memories(**kwargs)) + + entries.extend(self.global_store.list_memories(**kwargs)) + + entries.sort( + key=lambda e: (e.importance, e.created_at or datetime.min), + reverse=True, + ) + + limit = kwargs.get("limit", 50) + return entries[:limit] + + def delete(self, entry_id: str) -> bool: + """ + Delete a memory entry from whichever store contains it. + + Tries the project store first, then the global store. + + Args: + entry_id: ID of the entry to delete. + + Returns: + True if the entry was found and deleted, False otherwise. + """ + if self.project_store and self.project_store.delete(entry_id): + return True + return self.global_store.delete(entry_id) + + def count(self, memory_type: Optional[MemoryType] = None) -> int: + """ + Count memories across both stores. + + Args: + memory_type: If provided, count only this type. + + Returns: + Total count from project store + global store. + """ + total = 0 + + if self.project_store: + total += self.project_store.count(memory_type) + + total += self.global_store.count(memory_type) + + return total + + def close(self): + """Close both database connections.""" + if self.project_store: + self.project_store.close() + self.global_store.close() + + def _deduplicate(self, results: List[MemorySearchResult]) -> List[MemorySearchResult]: + """ + Remove near-duplicate results based on content similarity. + + Normalizes whitespace and compares the first 200 characters. When + duplicates exist, the first occurrence is kept - since results are + pre-sorted by score, higher-scoring (and project-scoped) results + are naturally preferred. + + Args: + results: Pre-sorted list of search results. + + Returns: + Deduplicated list preserving original order. + """ + seen: set = set() + deduped: List[MemorySearchResult] = [] + + for r in results: + key = " ".join(r.entry.content[:200].lower().split()) + if key not in seen: + seen.add(key) + deduped.append(r) + + return deduped diff --git a/sugar/memory/retriever.py b/sugar/memory/retriever.py index 2454626..8f29348 100644 --- a/sugar/memory/retriever.py +++ b/sugar/memory/retriever.py @@ -4,7 +4,7 @@ import logging from datetime import datetime, timedelta -from typing import List, Optional +from typing import List, Optional, Union from .store import MemoryStore from .types import MemoryEntry, MemoryQuery, MemorySearchResult, MemoryType @@ -20,9 +20,12 @@ class MemoryRetriever: - Semantic search for relevant context - Combining different memory types - Formatting memories for prompt injection + + Accepts either a MemoryStore or a GlobalMemoryManager - both expose the + same search/get_by_type/list_memories interface. """ - def __init__(self, store: MemoryStore): + def __init__(self, store: Union[MemoryStore, "GlobalMemoryManager"]): # noqa: F821 self.store = store def get_relevant( @@ -63,7 +66,9 @@ def get_project_context(self, limit: int = 10) -> dict: Get overall project context for session injection. Returns: - Dictionary with organized memories by type + Dictionary with organized memories by type. Includes a + "guidelines" key when the store is a GlobalMemoryManager - + populated from global guideline memories. """ context = { "preferences": [], @@ -96,6 +101,19 @@ def get_project_context(self, limit: int = 10) -> dict: ) context["error_patterns"] = [self._entry_to_dict(e) for e in patterns] + # Pull global guidelines when a GlobalMemoryManager is in use. + # Import lazily to avoid a circular dependency. + try: + from .global_store import GlobalMemoryManager + + if isinstance(self.store, GlobalMemoryManager): + guidelines = self.store.global_store.get_by_type( + MemoryType.GUIDELINE, limit=10 + ) + context["guidelines"] = [self._entry_to_dict(e) for e in guidelines] + except ImportError: + pass + return context def format_for_prompt( @@ -126,6 +144,8 @@ def format_for_prompt( # Build memory block type_label = entry.memory_type.value.replace("_", " ").title() + if hasattr(result, "scope") and result.scope == "global": + type_label = f"[Global] {type_label}" block_lines = [ f"### {type_label} ({age})", entry.content, diff --git a/sugar/memory/types.py b/sugar/memory/types.py index 5449d3e..f1c8416 100644 --- a/sugar/memory/types.py +++ b/sugar/memory/types.py @@ -8,6 +8,13 @@ from typing import Any, Dict, List, Optional +class MemoryScope(str, Enum): + """Where a memory lives.""" + + PROJECT = "project" # .sugar/memory.db (current behavior) + GLOBAL = "global" # ~/.sugar/memory.db + + class MemoryType(str, Enum): """Types of memories that Sugar can store.""" @@ -17,6 +24,7 @@ class MemoryType(str, Enum): ERROR_PATTERN = "error_pattern" # Bug patterns and fixes RESEARCH = "research" # API docs, library findings OUTCOME = "outcome" # Task outcomes and learnings + GUIDELINE = "guideline" # Cross-project standards and best practices @dataclass @@ -102,6 +110,7 @@ class MemorySearchResult: entry: MemoryEntry score: float # Similarity score (0-1) match_type: str = "semantic" # "semantic" or "keyword" + scope: str = "project" # "project" or "global" @dataclass diff --git a/tests/test_global_memory.py b/tests/test_global_memory.py new file mode 100644 index 0000000..ebd040b --- /dev/null +++ b/tests/test_global_memory.py @@ -0,0 +1,1115 @@ +""" +Tests for Sugar Global Memory Feature + +Covers: +- GlobalMemoryManager: init, routing, search merging, dedup, scope labels +- MemoryScope enum and GUIDELINE MemoryType +- MCP server tools: store_learning with scope, search_memory, get_project_context, + recall, global_guidelines_resource +- MemoryRetriever: scope labels in format_for_prompt, get_project_context guidelines +- Edge cases: empty stores, both stores populated, deduplication across stores +""" + +import asyncio +import uuid +from datetime import datetime, timezone +from pathlib import Path +from unittest.mock import patch + +import pytest + +from sugar.memory import ( + FallbackEmbedder, + GlobalMemoryManager, + MemoryEntry, + MemoryQuery, + MemoryRetriever, + MemoryScope, + MemorySearchResult, + MemoryStore, + MemoryType, +) +from sugar.memory.global_store import GLOBAL_DB_PATH + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def make_store(tmp_path: Path, name: str = "memory.db") -> MemoryStore: + """Return a real MemoryStore backed by a temp SQLite file.""" + db_path = tmp_path / name + return MemoryStore(str(db_path), embedder=FallbackEmbedder()) + + +def make_entry( + content: str, + memory_type: MemoryType = MemoryType.DECISION, + entry_id: str | None = None, + importance: float = 1.0, +) -> MemoryEntry: + """Create a MemoryEntry with a unique id.""" + return MemoryEntry( + id=entry_id or str(uuid.uuid4()), + memory_type=memory_type, + content=content, + importance=importance, + ) + + +def run(coro): + """Run an async coroutine synchronously inside a test. + + Creates a fresh event loop each time to stay compatible with Python 3.14+, + which no longer creates a default loop in the main thread. + """ + loop = asyncio.new_event_loop() + try: + return loop.run_until_complete(coro) + finally: + loop.close() + + +# =========================================================================== +# 1. GlobalMemoryManager Core Tests +# =========================================================================== + + +class TestGlobalMemoryManagerInit: + """Initialisation - store wiring is correct.""" + + def test_init_with_project_store(self, tmp_path): + project_store = make_store(tmp_path, "project.db") + global_store_path = tmp_path / "global.db" + + with patch( + "sugar.memory.global_store.GLOBAL_DB_PATH", global_store_path + ): + manager = GlobalMemoryManager(project_store=project_store) + assert manager.project_store is project_store + assert manager.global_store is not None + manager.close() + project_store.close() + + def test_init_without_project_store(self, tmp_path): + global_store_path = tmp_path / "global.db" + + with patch( + "sugar.memory.global_store.GLOBAL_DB_PATH", global_store_path + ): + manager = GlobalMemoryManager(project_store=None) + assert manager.project_store is None + assert manager.global_store is not None + manager.close() + + def test_global_db_path_constant_is_home_sugar(self): + expected = Path.home() / ".sugar" / "memory.db" + assert GLOBAL_DB_PATH == expected + + +class TestGlobalMemoryManagerStore: + """Writes go to the correct backing store.""" + + @pytest.fixture + def manager(self, tmp_path): + project_store = make_store(tmp_path, "project.db") + global_db = tmp_path / "global.db" + with patch("sugar.memory.global_store.GLOBAL_DB_PATH", global_db): + mgr = GlobalMemoryManager(project_store=project_store) + yield mgr + mgr.close() + + @pytest.fixture + def global_only_manager(self, tmp_path): + global_db = tmp_path / "global.db" + with patch("sugar.memory.global_store.GLOBAL_DB_PATH", global_db): + mgr = GlobalMemoryManager(project_store=None) + yield mgr + mgr.close() + + def test_project_scope_routes_to_project_store(self, manager): + entry = make_entry("Use PostgreSQL for main DB") + manager.store(entry, scope=MemoryScope.PROJECT) + + assert manager.project_store.get(entry.id) is not None + assert manager.global_store.get(entry.id) is None + + def test_global_scope_routes_to_global_store(self, manager): + entry = make_entry("Always use Kamal for deploys", MemoryType.GUIDELINE) + manager.store(entry, scope=MemoryScope.GLOBAL) + + assert manager.global_store.get(entry.id) is not None + assert manager.project_store.get(entry.id) is None + + def test_project_scope_without_project_store_raises(self, global_only_manager): + entry = make_entry("This should fail") + with pytest.raises(RuntimeError, match="Not in a Sugar project"): + global_only_manager.store(entry, scope=MemoryScope.PROJECT) + + def test_global_scope_without_project_store_succeeds(self, global_only_manager): + entry = make_entry("Cross-project guideline", MemoryType.GUIDELINE) + entry_id = global_only_manager.store(entry, scope=MemoryScope.GLOBAL) + assert entry_id == entry.id + + +class TestGlobalMemoryManagerSearch: + """Search merges both stores and labels results correctly.""" + + @pytest.fixture + def manager(self, tmp_path): + project_store = make_store(tmp_path, "project.db") + global_db = tmp_path / "global.db" + with patch("sugar.memory.global_store.GLOBAL_DB_PATH", global_db): + mgr = GlobalMemoryManager(project_store=project_store) + yield mgr + mgr.close() + + @pytest.fixture + def global_only_manager(self, tmp_path): + global_db = tmp_path / "global.db" + with patch("sugar.memory.global_store.GLOBAL_DB_PATH", global_db): + mgr = GlobalMemoryManager(project_store=None) + yield mgr + mgr.close() + + def _store_to_project(self, manager, content, memory_type=MemoryType.DECISION): + entry = make_entry(content, memory_type) + manager.store(entry, scope=MemoryScope.PROJECT) + return entry + + def _store_to_global(self, manager, content, memory_type=MemoryType.GUIDELINE): + entry = make_entry(content, memory_type) + manager.store(entry, scope=MemoryScope.GLOBAL) + return entry + + def test_search_returns_results_from_both_stores(self, manager): + self._store_to_project(manager, "JWT authentication for this project") + self._store_to_global(manager, "JWT is the org-wide auth standard") + + results = manager.search(MemoryQuery(query="JWT authentication")) + + assert len(results) >= 2 + + def test_search_labels_project_results_correctly(self, manager): + self._store_to_project(manager, "Redis caching strategy for project X") + + results = manager.search(MemoryQuery(query="Redis caching")) + + project_results = [r for r in results if r.scope == "project"] + assert len(project_results) >= 1 + + def test_search_labels_global_results_correctly(self, manager): + self._store_to_global( + manager, "Always use 60-character title tags for SEO" + ) + + results = manager.search(MemoryQuery(query="title tags SEO")) + + global_results = [r for r in results if r.scope == "global"] + assert len(global_results) >= 1 + + def test_search_deduplicates_identical_content(self, manager): + # Store the exact same content in both stores + content = "Use 4-space indentation across all projects" + project_entry = make_entry(content, MemoryType.PREFERENCE) + manager.store(project_entry, scope=MemoryScope.PROJECT) + + global_entry = make_entry(content, MemoryType.PREFERENCE) + manager.store(global_entry, scope=MemoryScope.GLOBAL) + + results = manager.search(MemoryQuery(query="indentation style")) + + # Content appears only once after dedup + seen_content = [r.entry.content for r in results] + assert seen_content.count(content) <= 1 + + def test_search_respects_limit(self, manager): + for i in range(5): + self._store_to_project(manager, f"Project decision number {i}") + for i in range(5): + self._store_to_global(manager, f"Global guideline number {i}") + + results = manager.search(MemoryQuery(query="decision guideline"), limit=3) + + assert len(results) <= 3 + + def test_search_global_only_manager_works(self, global_only_manager): + entry = make_entry("Python type hints everywhere", MemoryType.GUIDELINE) + global_only_manager.store(entry, scope=MemoryScope.GLOBAL) + + results = global_only_manager.search(MemoryQuery(query="type hints Python")) + + assert len(results) >= 1 + assert all(r.scope == "global" for r in results) + + def test_search_sorts_by_score_descending(self, manager): + self._store_to_project(manager, "deployment procedure step one") + self._store_to_global(manager, "deployment procedure step two") + + results = manager.search(MemoryQuery(query="deployment procedure")) + + scores = [r.score for r in results] + assert scores == sorted(scores, reverse=True) + + +class TestGlobalMemoryManagerGetByType: + """get_by_type aggregates both stores.""" + + @pytest.fixture + def manager(self, tmp_path): + project_store = make_store(tmp_path, "project.db") + global_db = tmp_path / "global.db" + with patch("sugar.memory.global_store.GLOBAL_DB_PATH", global_db): + mgr = GlobalMemoryManager(project_store=project_store) + yield mgr + mgr.close() + + def test_returns_from_both_stores(self, manager): + project_entry = make_entry("Project pref", MemoryType.PREFERENCE) + global_entry = make_entry("Global pref", MemoryType.PREFERENCE) + + manager.store(project_entry, scope=MemoryScope.PROJECT) + manager.store(global_entry, scope=MemoryScope.GLOBAL) + + entries = manager.get_by_type(MemoryType.PREFERENCE) + ids = [e.id for e in entries] + + assert project_entry.id in ids + assert global_entry.id in ids + + def test_respects_limit(self, manager): + for i in range(4): + e = make_entry(f"Project pref {i}", MemoryType.PREFERENCE) + manager.store(e, scope=MemoryScope.PROJECT) + for i in range(4): + e = make_entry(f"Global pref {i}", MemoryType.PREFERENCE) + manager.store(e, scope=MemoryScope.GLOBAL) + + entries = manager.get_by_type(MemoryType.PREFERENCE, limit=5) + + assert len(entries) <= 5 + + +class TestGlobalMemoryManagerListMemories: + """list_memories combines and sorts both stores.""" + + @pytest.fixture + def manager(self, tmp_path): + project_store = make_store(tmp_path, "project.db") + global_db = tmp_path / "global.db" + with patch("sugar.memory.global_store.GLOBAL_DB_PATH", global_db): + mgr = GlobalMemoryManager(project_store=project_store) + yield mgr + mgr.close() + + def test_returns_from_both_stores(self, manager): + project_entry = make_entry("Project decision", MemoryType.DECISION) + global_entry = make_entry("Global guideline", MemoryType.GUIDELINE) + + manager.store(project_entry, scope=MemoryScope.PROJECT) + manager.store(global_entry, scope=MemoryScope.GLOBAL) + + entries = manager.list_memories() + ids = [e.id for e in entries] + + assert project_entry.id in ids + assert global_entry.id in ids + + def test_sorted_by_importance_then_recency(self, manager): + low_entry = make_entry("Low priority", importance=0.5) + high_entry = make_entry("High priority", importance=2.0) + + manager.store(low_entry, scope=MemoryScope.PROJECT) + manager.store(high_entry, scope=MemoryScope.PROJECT) + + entries = manager.list_memories() + + importances = [e.importance for e in entries] + assert importances == sorted(importances, reverse=True) + + def test_respects_limit(self, manager): + for i in range(10): + e = make_entry(f"Entry {i}") + manager.store(e, scope=MemoryScope.PROJECT) + + entries = manager.list_memories(limit=4) + assert len(entries) <= 4 + + +class TestGlobalMemoryManagerDelete: + """delete tries project store first, then global.""" + + @pytest.fixture + def manager(self, tmp_path): + project_store = make_store(tmp_path, "project.db") + global_db = tmp_path / "global.db" + with patch("sugar.memory.global_store.GLOBAL_DB_PATH", global_db): + mgr = GlobalMemoryManager(project_store=project_store) + yield mgr + mgr.close() + + def test_delete_from_project_store(self, manager): + entry = make_entry("Delete from project") + manager.store(entry, scope=MemoryScope.PROJECT) + + deleted = manager.delete(entry.id) + + assert deleted is True + assert manager.project_store.get(entry.id) is None + + def test_delete_from_global_when_not_in_project(self, manager): + entry = make_entry("Delete from global", MemoryType.GUIDELINE) + manager.store(entry, scope=MemoryScope.GLOBAL) + + deleted = manager.delete(entry.id) + + assert deleted is True + assert manager.global_store.get(entry.id) is None + + def test_delete_nonexistent_returns_false(self, manager): + result = manager.delete("does-not-exist-id") + assert result is False + + +class TestGlobalMemoryManagerCount: + """count sums both stores.""" + + @pytest.fixture + def manager(self, tmp_path): + project_store = make_store(tmp_path, "project.db") + global_db = tmp_path / "global.db" + with patch("sugar.memory.global_store.GLOBAL_DB_PATH", global_db): + mgr = GlobalMemoryManager(project_store=project_store) + yield mgr + mgr.close() + + def test_count_sums_both_stores(self, manager): + for _ in range(3): + manager.store(make_entry("Project entry"), scope=MemoryScope.PROJECT) + for _ in range(2): + manager.store( + make_entry("Global entry", MemoryType.GUIDELINE), + scope=MemoryScope.GLOBAL, + ) + + assert manager.count() == 5 + + def test_count_by_type(self, manager): + manager.store( + make_entry("Project pref", MemoryType.PREFERENCE), + scope=MemoryScope.PROJECT, + ) + manager.store( + make_entry("Global pref", MemoryType.PREFERENCE), + scope=MemoryScope.GLOBAL, + ) + manager.store( + make_entry("Project decision", MemoryType.DECISION), + scope=MemoryScope.PROJECT, + ) + + assert manager.count(MemoryType.PREFERENCE) == 2 + assert manager.count(MemoryType.DECISION) == 1 + + +class TestGlobalMemoryManagerClose: + """close() shuts down both connections without error.""" + + def test_close_closes_both_stores(self, tmp_path): + project_store = make_store(tmp_path, "project.db") + global_db = tmp_path / "global.db" + + with patch("sugar.memory.global_store.GLOBAL_DB_PATH", global_db): + manager = GlobalMemoryManager(project_store=project_store) + + # close must not raise + manager.close() + + def test_close_without_project_store(self, tmp_path): + global_db = tmp_path / "global.db" + + with patch("sugar.memory.global_store.GLOBAL_DB_PATH", global_db): + manager = GlobalMemoryManager(project_store=None) + + manager.close() + + +# =========================================================================== +# 2. MemoryScope and GUIDELINE Type Tests +# =========================================================================== + + +class TestMemoryScopeEnum: + """MemoryScope enum contract.""" + + def test_scope_values(self): + assert MemoryScope.PROJECT.value == "project" + assert MemoryScope.GLOBAL.value == "global" + + def test_scope_is_str_enum(self): + assert isinstance(MemoryScope.PROJECT, str) + assert isinstance(MemoryScope.GLOBAL, str) + + def test_scope_from_string(self): + assert MemoryScope("project") is MemoryScope.PROJECT + assert MemoryScope("global") is MemoryScope.GLOBAL + + +class TestGuidelineMemoryType: + """GUIDELINE is a proper MemoryType member.""" + + def test_guideline_exists(self): + assert MemoryType.GUIDELINE.value == "guideline" + + def test_guideline_accessible_by_value(self): + assert MemoryType("guideline") is MemoryType.GUIDELINE + + def test_all_original_types_still_present(self): + for value in ( + "decision", + "preference", + "file_context", + "error_pattern", + "research", + "outcome", + ): + assert MemoryType(value) is not None + + def test_store_and_retrieve_guideline(self, tmp_path): + store = make_store(tmp_path) + entry = make_entry("Never commit secrets to version control", MemoryType.GUIDELINE) + store.store(entry) + + retrieved = store.get(entry.id) + + assert retrieved is not None + assert retrieved.memory_type == MemoryType.GUIDELINE + assert retrieved.content == "Never commit secrets to version control" + store.close() + + def test_get_by_type_guideline(self, tmp_path): + store = make_store(tmp_path) + for i in range(3): + e = make_entry(f"Guideline {i}", MemoryType.GUIDELINE) + store.store(e) + # Also add a non-guideline entry so we're sure filtering works + store.store(make_entry("A decision", MemoryType.DECISION)) + + guidelines = store.get_by_type(MemoryType.GUIDELINE) + assert len(guidelines) == 3 + assert all(g.memory_type == MemoryType.GUIDELINE for g in guidelines) + store.close() + + +# =========================================================================== +# 3. MCP Server Integration Tests +# =========================================================================== + + +class TestGetMemoryManager: + """get_memory_manager() factory in memory_server.py.""" + + def test_returns_global_memory_manager_with_sugar_dir(self, tmp_path): + from sugar.mcp.memory_server import get_memory_manager + + sugar_dir = tmp_path / ".sugar" + sugar_dir.mkdir() + global_db = tmp_path / "global.db" + + with ( + patch("sugar.mcp.memory_server.Path") as mock_path_cls, + patch("sugar.memory.global_store.GLOBAL_DB_PATH", global_db), + ): + # Redirect cwd to tmp_path so sugar_dir is found + mock_path_cls.cwd.return_value = tmp_path + # Re-enable Path() construction for actual paths + mock_path_cls.side_effect = lambda *a, **kw: Path(*a, **kw) + mock_path_cls.cwd.return_value = tmp_path + + manager = get_memory_manager() + + assert isinstance(manager, GlobalMemoryManager) + manager.close() + + def test_returns_global_memory_manager_without_sugar_dir(self, tmp_path): + from sugar.mcp.memory_server import get_memory_manager + + # tmp_path has no .sugar directory + no_sugar_dir = tmp_path / "no_project" + no_sugar_dir.mkdir() + global_db = tmp_path / "global.db" + + with ( + patch("sugar.mcp.memory_server.Path") as mock_path_cls, + patch("sugar.memory.global_store.GLOBAL_DB_PATH", global_db), + ): + mock_path_cls.cwd.return_value = no_sugar_dir + mock_path_cls.side_effect = lambda *a, **kw: Path(*a, **kw) + mock_path_cls.cwd.return_value = no_sugar_dir + + manager = get_memory_manager() + + assert isinstance(manager, GlobalMemoryManager) + # No .sugar found - no project store + assert manager.project_store is None + manager.close() + + +def _get_mcp_tool_fn(mcp, name): + """Return the raw async function for a named MCP tool.""" + tool = mcp._tool_manager._tools.get(name) + assert tool is not None, f"Tool '{name}' not registered in MCP server" + return tool.fn + + +def _get_mcp_resource_fn(mcp, uri): + """Return the raw async function for a registered MCP resource URI.""" + resource = mcp._resource_manager._resources.get(uri) + assert resource is not None, f"Resource '{uri}' not registered in MCP server" + return resource.fn + + +class TestMCPStoreLearning: + """store_learning tool respects the scope parameter.""" + + def test_store_global_scope_goes_to_global_db(self, tmp_path): + from sugar.mcp.memory_server import create_memory_mcp_server + + global_db = tmp_path / "global.db" + + with ( + patch( + "sugar.mcp.memory_server.get_memory_manager" + ) as mock_get_manager, + patch("sugar.memory.global_store.GLOBAL_DB_PATH", global_db), + ): + project_store = make_store(tmp_path, "project.db") + manager = GlobalMemoryManager(project_store=project_store) + mock_get_manager.return_value = manager + + mcp = create_memory_mcp_server() + result = run( + _get_mcp_tool_fn(mcp, "store_learning")( + content="Title tags must be under 60 characters", + memory_type="guideline", + scope="global", + ) + ) + + assert result["status"] == "stored" + assert result["scope"] == "global" + manager.close() + project_store.close() + + def test_store_project_scope_goes_to_project_db(self, tmp_path): + from sugar.mcp.memory_server import create_memory_mcp_server + + global_db = tmp_path / "global.db" + + with ( + patch( + "sugar.mcp.memory_server.get_memory_manager" + ) as mock_get_manager, + patch("sugar.memory.global_store.GLOBAL_DB_PATH", global_db), + ): + project_store = make_store(tmp_path, "project.db") + manager = GlobalMemoryManager(project_store=project_store) + mock_get_manager.return_value = manager + + mcp = create_memory_mcp_server() + result = run( + _get_mcp_tool_fn(mcp, "store_learning")( + content="Use Redis for session caching in this project", + memory_type="decision", + scope="project", + ) + ) + + assert result["status"] == "stored" + assert result["scope"] == "project" + manager.close() + project_store.close() + + def test_store_project_scope_without_project_store_falls_back_to_global( + self, tmp_path + ): + from sugar.mcp.memory_server import create_memory_mcp_server + + global_db = tmp_path / "global.db" + + with ( + patch( + "sugar.mcp.memory_server.get_memory_manager" + ) as mock_get_manager, + patch("sugar.memory.global_store.GLOBAL_DB_PATH", global_db), + ): + # No project store - simulates running outside a Sugar project + manager = GlobalMemoryManager(project_store=None) + mock_get_manager.return_value = manager + + mcp = create_memory_mcp_server() + result = run( + _get_mcp_tool_fn(mcp, "store_learning")( + content="Some knowledge outside a project", + memory_type="decision", + scope="project", + ) + ) + + # Falls back to global and includes a note + assert result["status"] == "stored" + assert result["scope"] == "global" + assert "note" in result + manager.close() + + +class TestMCPSearchMemory: + """search_memory tool returns results with scope field.""" + + def test_search_memory_results_include_scope(self, tmp_path): + from sugar.mcp.memory_server import create_memory_mcp_server + + global_db = tmp_path / "global.db" + + with ( + patch( + "sugar.mcp.memory_server.get_memory_manager" + ) as mock_get_manager, + patch("sugar.memory.global_store.GLOBAL_DB_PATH", global_db), + ): + project_store = make_store(tmp_path, "project.db") + manager = GlobalMemoryManager(project_store=project_store) + + # Pre-seed data + entry = make_entry("JWT tokens for authentication", MemoryType.DECISION) + manager.store(entry, scope=MemoryScope.PROJECT) + mock_get_manager.return_value = manager + + mcp = create_memory_mcp_server() + results = run(_get_mcp_tool_fn(mcp, "search_memory")(query="JWT authentication")) + + assert isinstance(results, list) + if results and "error" not in results[0]: + assert "scope" in results[0] + assert results[0]["scope"] in ("project", "global") + + manager.close() + project_store.close() + + def test_search_memory_returns_list(self, tmp_path): + from sugar.mcp.memory_server import create_memory_mcp_server + + global_db = tmp_path / "global.db" + + with ( + patch( + "sugar.mcp.memory_server.get_memory_manager" + ) as mock_get_manager, + patch("sugar.memory.global_store.GLOBAL_DB_PATH", global_db), + ): + manager = GlobalMemoryManager(project_store=None) + mock_get_manager.return_value = manager + + mcp = create_memory_mcp_server() + results = run(_get_mcp_tool_fn(mcp, "search_memory")(query="anything")) + + assert isinstance(results, list) + manager.close() + + +class TestMCPGetProjectContext: + """get_project_context includes a guidelines section.""" + + def test_includes_guidelines_key(self, tmp_path): + from sugar.mcp.memory_server import create_memory_mcp_server + + global_db = tmp_path / "global.db" + + with ( + patch( + "sugar.mcp.memory_server.get_memory_manager" + ) as mock_get_manager, + patch("sugar.memory.global_store.GLOBAL_DB_PATH", global_db), + ): + manager = GlobalMemoryManager(project_store=None) + mock_get_manager.return_value = manager + + mcp = create_memory_mcp_server() + result = run(_get_mcp_tool_fn(mcp, "get_project_context")()) + + assert "guidelines" in result + assert isinstance(result["guidelines"], list) + manager.close() + + def test_guidelines_populated_from_global_store(self, tmp_path): + from sugar.mcp.memory_server import create_memory_mcp_server + + global_db = tmp_path / "global.db" + + with ( + patch( + "sugar.mcp.memory_server.get_memory_manager" + ) as mock_get_manager, + patch("sugar.memory.global_store.GLOBAL_DB_PATH", global_db), + ): + manager = GlobalMemoryManager(project_store=None) + guideline = make_entry( + "Use IndexNow API on every deploy", MemoryType.GUIDELINE + ) + manager.store(guideline, scope=MemoryScope.GLOBAL) + mock_get_manager.return_value = manager + + mcp = create_memory_mcp_server() + result = run(_get_mcp_tool_fn(mcp, "get_project_context")()) + + assert len(result["guidelines"]) == 1 + assert result["guidelines"][0]["content"] == guideline.content + manager.close() + + +class TestMCPRecall: + """recall tool searches both stores.""" + + def test_recall_returns_string(self, tmp_path): + from sugar.mcp.memory_server import create_memory_mcp_server + + global_db = tmp_path / "global.db" + + with ( + patch( + "sugar.mcp.memory_server.get_memory_manager" + ) as mock_get_manager, + patch("sugar.memory.global_store.GLOBAL_DB_PATH", global_db), + ): + manager = GlobalMemoryManager(project_store=None) + mock_get_manager.return_value = manager + + mcp = create_memory_mcp_server() + result = run(_get_mcp_tool_fn(mcp, "recall")(topic="deployment")) + + assert isinstance(result, str) + manager.close() + + def test_recall_no_results_returns_helpful_message(self, tmp_path): + from sugar.mcp.memory_server import create_memory_mcp_server + + global_db = tmp_path / "global.db" + + with ( + patch( + "sugar.mcp.memory_server.get_memory_manager" + ) as mock_get_manager, + patch("sugar.memory.global_store.GLOBAL_DB_PATH", global_db), + ): + manager = GlobalMemoryManager(project_store=None) + mock_get_manager.return_value = manager + + mcp = create_memory_mcp_server() + result = run(_get_mcp_tool_fn(mcp, "recall")(topic="absolutely unique xyz123")) + + assert "No memories found" in result or isinstance(result, str) + manager.close() + + +class TestMCPGlobalGuidelinesResource: + """global_guidelines_resource returns guidelines markdown.""" + + def test_resource_returns_string(self, tmp_path): + from sugar.mcp.memory_server import create_memory_mcp_server + + global_db = tmp_path / "global.db" + + with ( + patch( + "sugar.mcp.memory_server.get_memory_manager" + ) as mock_get_manager, + patch("sugar.memory.global_store.GLOBAL_DB_PATH", global_db), + ): + manager = GlobalMemoryManager(project_store=None) + mock_get_manager.return_value = manager + + mcp = create_memory_mcp_server() + result = run(_get_mcp_resource_fn(mcp, "sugar://global/guidelines")()) + + assert isinstance(result, str) + manager.close() + + def test_resource_empty_store_returns_placeholder(self, tmp_path): + from sugar.mcp.memory_server import create_memory_mcp_server + + global_db = tmp_path / "global.db" + + with ( + patch( + "sugar.mcp.memory_server.get_memory_manager" + ) as mock_get_manager, + patch("sugar.memory.global_store.GLOBAL_DB_PATH", global_db), + ): + manager = GlobalMemoryManager(project_store=None) + mock_get_manager.return_value = manager + + mcp = create_memory_mcp_server() + result = run(_get_mcp_resource_fn(mcp, "sugar://global/guidelines")()) + + assert "No global guidelines" in result + manager.close() + + def test_resource_with_guidelines_lists_them(self, tmp_path): + from sugar.mcp.memory_server import create_memory_mcp_server + + global_db = tmp_path / "global.db" + + with ( + patch( + "sugar.mcp.memory_server.get_memory_manager" + ) as mock_get_manager, + patch("sugar.memory.global_store.GLOBAL_DB_PATH", global_db), + ): + manager = GlobalMemoryManager(project_store=None) + guideline = make_entry("Kamal-only deploys, never raw Docker", MemoryType.GUIDELINE) + manager.store(guideline, scope=MemoryScope.GLOBAL) + mock_get_manager.return_value = manager + + mcp = create_memory_mcp_server() + result = run(_get_mcp_resource_fn(mcp, "sugar://global/guidelines")()) + + assert "Global Guidelines" in result + assert "Kamal-only deploys" in result + manager.close() + + +# =========================================================================== +# 4. Retriever Tests +# =========================================================================== + + +class TestRetrieverWithGlobalManager: + """MemoryRetriever works correctly with a GlobalMemoryManager.""" + + @pytest.fixture + def manager(self, tmp_path): + project_store = make_store(tmp_path, "project.db") + global_db = tmp_path / "global.db" + with patch("sugar.memory.global_store.GLOBAL_DB_PATH", global_db): + mgr = GlobalMemoryManager(project_store=project_store) + yield mgr + mgr.close() + + def test_format_for_prompt_labels_global_result(self, tmp_path): + global_db = tmp_path / "global.db" + + with patch("sugar.memory.global_store.GLOBAL_DB_PATH", global_db): + manager = GlobalMemoryManager(project_store=None) + + guideline = make_entry("Meta descriptions must be 120-160 chars", MemoryType.GUIDELINE) + manager.store(guideline, scope=MemoryScope.GLOBAL) + + results = manager.search(MemoryQuery(query="meta descriptions")) + + retriever = MemoryRetriever(manager) + formatted = retriever.format_for_prompt(results) + + if results: + assert "[Global]" in formatted + + manager.close() + + def test_format_for_prompt_no_global_label_for_project_result(self, tmp_path): + global_db = tmp_path / "global.db" + + with patch("sugar.memory.global_store.GLOBAL_DB_PATH", global_db): + project_store = make_store(tmp_path, "project.db") + manager = GlobalMemoryManager(project_store=project_store) + + entry = make_entry("Project-specific routing decision", MemoryType.DECISION) + manager.store(entry, scope=MemoryScope.PROJECT) + + results = manager.search(MemoryQuery(query="routing decision")) + + retriever = MemoryRetriever(manager) + formatted = retriever.format_for_prompt(results) + + if results: + # Project-scoped results must not carry a [Global] label + # Split on the result content and check its header + project_result = next( + (r for r in results if r.scope == "project"), None + ) + if project_result: + assert "[Global]" not in formatted or "Decision" in formatted + + manager.close() + + def test_get_project_context_includes_guidelines_key(self, manager): + guideline = make_entry( + "Always use semantic HTML5 elements", MemoryType.GUIDELINE + ) + manager.store(guideline, scope=MemoryScope.GLOBAL) + + retriever = MemoryRetriever(manager) + context = retriever.get_project_context() + + assert "guidelines" in context + assert isinstance(context["guidelines"], list) + assert len(context["guidelines"]) >= 1 + + def test_get_project_context_guidelines_content_matches(self, manager): + guideline = make_entry("Run tests before every deploy", MemoryType.GUIDELINE) + manager.store(guideline, scope=MemoryScope.GLOBAL) + + retriever = MemoryRetriever(manager) + context = retriever.get_project_context() + + guideline_contents = [g["content"] for g in context["guidelines"]] + assert "Run tests before every deploy" in guideline_contents + + def test_get_project_context_no_guidelines_key_with_plain_store(self, tmp_path): + """Plain MemoryStore (not GlobalMemoryManager) must not include guidelines.""" + store = make_store(tmp_path, "plain.db") + retriever = MemoryRetriever(store) + context = retriever.get_project_context() + + assert "guidelines" not in context + store.close() + + +# =========================================================================== +# 5. Edge Cases +# =========================================================================== + + +class TestEdgeCases: + """Boundary conditions and degenerate inputs.""" + + def test_empty_global_store_populated_project_store(self, tmp_path): + global_db = tmp_path / "global.db" + + with patch("sugar.memory.global_store.GLOBAL_DB_PATH", global_db): + project_store = make_store(tmp_path, "project.db") + manager = GlobalMemoryManager(project_store=project_store) + + for i in range(3): + manager.store(make_entry(f"Project entry {i}"), scope=MemoryScope.PROJECT) + + # Global store is empty - search and list must still work + results = manager.search(MemoryQuery(query="project entry")) + entries = manager.list_memories() + + assert len(results) >= 1 + assert all(r.scope == "project" for r in results) + assert len(entries) == 3 + assert manager.count() == 3 + manager.close() + + def test_populated_global_store_empty_project_store(self, tmp_path): + global_db = tmp_path / "global.db" + + with patch("sugar.memory.global_store.GLOBAL_DB_PATH", global_db): + project_store = make_store(tmp_path, "project.db") + manager = GlobalMemoryManager(project_store=project_store) + + for i in range(2): + manager.store( + make_entry(f"Global guideline {i}", MemoryType.GUIDELINE), + scope=MemoryScope.GLOBAL, + ) + + results = manager.search(MemoryQuery(query="guideline")) + entries = manager.list_memories() + + assert len(results) >= 1 + assert all(r.scope == "global" for r in results) + assert len(entries) == 2 + manager.close() + + def test_both_stores_empty_returns_empty_results(self, tmp_path): + global_db = tmp_path / "global.db" + + with patch("sugar.memory.global_store.GLOBAL_DB_PATH", global_db): + manager = GlobalMemoryManager(project_store=None) + + results = manager.search(MemoryQuery(query="anything")) + entries = manager.list_memories() + + assert results == [] + assert entries == [] + assert manager.count() == 0 + manager.close() + + def test_dedup_same_content_in_both_stores(self, tmp_path): + global_db = tmp_path / "global.db" + + with patch("sugar.memory.global_store.GLOBAL_DB_PATH", global_db): + project_store = make_store(tmp_path, "project.db") + manager = GlobalMemoryManager(project_store=project_store) + + identical_content = "Use 4-space indentation in all Python files" + + project_entry = make_entry(identical_content, MemoryType.PREFERENCE) + global_entry = make_entry(identical_content, MemoryType.PREFERENCE) + + manager.store(project_entry, scope=MemoryScope.PROJECT) + manager.store(global_entry, scope=MemoryScope.GLOBAL) + + results = manager.search(MemoryQuery(query="indentation Python")) + + # After dedup, the content appears at most once + contents = [r.entry.content for r in results] + assert contents.count(identical_content) <= 1 + manager.close() + + def test_dedup_preserves_project_scope_over_global(self, tmp_path): + """When content is in both stores, the project-scoped result is kept (higher + specificity). The project result has a higher score because it is sorted first + before dedup runs - but with FallbackEmbedder scores are equal, so the first + result in the pre-sorted list is kept. We verify at least one result survives.""" + global_db = tmp_path / "global.db" + + with patch("sugar.memory.global_store.GLOBAL_DB_PATH", global_db): + project_store = make_store(tmp_path, "project.db") + manager = GlobalMemoryManager(project_store=project_store) + + content = "Always write docstrings for public functions" + project_entry = make_entry(content, MemoryType.PREFERENCE, importance=1.5) + global_entry = make_entry(content, MemoryType.PREFERENCE) + + manager.store(project_entry, scope=MemoryScope.PROJECT) + manager.store(global_entry, scope=MemoryScope.GLOBAL) + + results = manager.search(MemoryQuery(query="docstrings public functions")) + + # At least one result, and the dedup key is based on content + assert len(results) >= 1 + assert len(results) == len({ + " ".join(r.entry.content[:200].lower().split()) for r in results + }) + manager.close() + + def test_global_db_path_is_in_home_dot_sugar(self): + """Module-level constant points to the right location.""" + assert GLOBAL_DB_PATH.parent == Path.home() / ".sugar" + assert GLOBAL_DB_PATH.name == "memory.db" + + def test_memory_search_result_scope_field_defaults_to_project(self): + """MemorySearchResult.scope defaults to 'project'.""" + entry = make_entry("Some content") + result = MemorySearchResult(entry=entry, score=0.9) + assert result.scope == "project" + + def test_memory_search_result_scope_can_be_global(self): + entry = make_entry("Some global content") + result = MemorySearchResult(entry=entry, score=0.8, scope="global") + assert result.scope == "global" + + def test_manager_store_returns_entry_id(self, tmp_path): + global_db = tmp_path / "global.db" + + with patch("sugar.memory.global_store.GLOBAL_DB_PATH", global_db): + manager = GlobalMemoryManager(project_store=None) + + entry = make_entry("Test return value", MemoryType.GUIDELINE) + returned_id = manager.store(entry, scope=MemoryScope.GLOBAL) + + assert returned_id == entry.id + manager.close() From 74a4e5ccc13ff18e43732f773bfe72fc8b0b111d Mon Sep 17 00:00:00 2001 From: Steven Leggett Date: Tue, 17 Mar 2026 11:34:24 -0400 Subject: [PATCH 3/8] style: Apply black formatting to global memory files --- sugar/main.py | 26 +++++----- sugar/mcp/memory_server.py | 8 ++- sugar/memory/global_store.py | 12 +++-- tests/test_global_memory.py | 94 ++++++++++++++---------------------- 4 files changed, 66 insertions(+), 74 deletions(-) diff --git a/sugar/main.py b/sugar/main.py index a95d1f5..051432c 100644 --- a/sugar/main.py +++ b/sugar/main.py @@ -1883,8 +1883,7 @@ def status(ctx): def help(): """Show comprehensive Sugar help and getting started guide""" - click.echo( - """ + click.echo(""" šŸ° Sugar - The Autonomous Layer for AI Coding Agents ===================================================== @@ -1995,8 +1994,7 @@ def help(): • By using Sugar, you agree to these terms and conditions Ready to supercharge your development workflow? šŸš€ -""" - ) +""") @cli.command() @@ -2997,8 +2995,7 @@ async def _dedupe_work(): async with aiosqlite.connect(work_queue.db_path) as db: # Find duplicates - keep the earliest created one for each source_file - cursor = await db.execute( - """ + cursor = await db.execute(""" WITH ranked_items AS ( SELECT id, source_file, title, created_at, ROW_NUMBER() OVER (PARTITION BY source_file ORDER BY created_at ASC) as rn @@ -3009,8 +3006,7 @@ async def _dedupe_work(): FROM ranked_items WHERE rn > 1 ORDER BY source_file, created_at - """ - ) + """) duplicates = await cursor.fetchall() @@ -4087,7 +4083,9 @@ def recall(ctx, query, memory_type, limit, output_format): click.echo( f"{r.score:.2f} {r.entry.memory_type.value:<15} {scope_label:<10} {content:<47}" ) - click.echo(f"\n{len(results)} memories found ({results[-1].match_type} search)") + click.echo( + f"\n{len(results)} memories found ({results[-1].match_type} search)" + ) except ImportError as e: click.echo( @@ -4196,6 +4194,7 @@ def memories(ctx, memory_type, since, limit, output_format): # Sort by importance then recency from datetime import datetime as _dt + scoped_entries.sort( key=lambda se: (se[1].importance, se[1].created_at or _dt.min), reverse=True, @@ -4216,7 +4215,9 @@ def memories(ctx, memory_type, since, limit, output_format): output.append(d) click.echo(json.dumps(output, indent=2)) else: # table - click.echo(f"\n{'ID':<10} {'Scope':<10} {'Type':<15} {'Created':<12} {'Content':<35}") + click.echo( + f"\n{'ID':<10} {'Scope':<10} {'Type':<15} {'Created':<12} {'Content':<35}" + ) click.echo("-" * 85) for scope, e in scoped_entries: content = e.content[:32] + "..." if len(e.content) > 35 else e.content @@ -4292,7 +4293,10 @@ def forget(ctx, memory_id, force): all_entries = [] if manager.project_store: all_entries.extend( - [("project", e) for e in manager.project_store.list_memories(limit=1000)] + [ + ("project", e) + for e in manager.project_store.list_memories(limit=1000) + ] ) all_entries.extend( [("global", e) for e in manager.global_store.list_memories(limit=1000)] diff --git a/sugar/mcp/memory_server.py b/sugar/mcp/memory_server.py index b1dbed0..3e8c8dd 100644 --- a/sugar/mcp/memory_server.py +++ b/sugar/mcp/memory_server.py @@ -231,7 +231,9 @@ async def get_project_context() -> Dict[str, Any]: context = retriever.get_project_context(limit=10) # Add global guidelines - these live in the global store only - guidelines = manager.global_store.get_by_type(MemoryType.GUIDELINE, limit=10) + guidelines = manager.global_store.get_by_type( + MemoryType.GUIDELINE, limit=10 + ) context["guidelines"] = [ { "id": g.id[:8], @@ -387,7 +389,9 @@ async def global_guidelines_resource() -> str: manager = get_memory_manager() try: - guidelines = manager.global_store.get_by_type(MemoryType.GUIDELINE, limit=50) + guidelines = manager.global_store.get_by_type( + MemoryType.GUIDELINE, limit=50 + ) if not guidelines: return ( diff --git a/sugar/memory/global_store.py b/sugar/memory/global_store.py index 3699b08..a122693 100644 --- a/sugar/memory/global_store.py +++ b/sugar/memory/global_store.py @@ -40,7 +40,9 @@ def __init__(self, project_store: Optional[MemoryStore] = None): self.project_store = project_store self.global_store = MemoryStore(str(GLOBAL_DB_PATH)) - def store(self, entry: MemoryEntry, scope: MemoryScope = MemoryScope.PROJECT) -> str: + def store( + self, entry: MemoryEntry, scope: MemoryScope = MemoryScope.PROJECT + ) -> str: """ Store a memory entry in the appropriate database. @@ -97,7 +99,9 @@ def search(self, query: MemoryQuery, limit: int = 10) -> List[MemorySearchResult return results[:limit] - def get_by_type(self, memory_type: MemoryType, limit: int = 50) -> List[MemoryEntry]: + def get_by_type( + self, memory_type: MemoryType, limit: int = 50 + ) -> List[MemoryEntry]: """ Get memories of a specific type from both stores. @@ -183,7 +187,9 @@ def close(self): self.project_store.close() self.global_store.close() - def _deduplicate(self, results: List[MemorySearchResult]) -> List[MemorySearchResult]: + def _deduplicate( + self, results: List[MemorySearchResult] + ) -> List[MemorySearchResult]: """ Remove near-duplicate results based on content similarity. diff --git a/tests/test_global_memory.py b/tests/test_global_memory.py index ebd040b..8221567 100644 --- a/tests/test_global_memory.py +++ b/tests/test_global_memory.py @@ -31,7 +31,6 @@ ) from sugar.memory.global_store import GLOBAL_DB_PATH - # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- @@ -83,9 +82,7 @@ def test_init_with_project_store(self, tmp_path): project_store = make_store(tmp_path, "project.db") global_store_path = tmp_path / "global.db" - with patch( - "sugar.memory.global_store.GLOBAL_DB_PATH", global_store_path - ): + with patch("sugar.memory.global_store.GLOBAL_DB_PATH", global_store_path): manager = GlobalMemoryManager(project_store=project_store) assert manager.project_store is project_store assert manager.global_store is not None @@ -95,9 +92,7 @@ def test_init_with_project_store(self, tmp_path): def test_init_without_project_store(self, tmp_path): global_store_path = tmp_path / "global.db" - with patch( - "sugar.memory.global_store.GLOBAL_DB_PATH", global_store_path - ): + with patch("sugar.memory.global_store.GLOBAL_DB_PATH", global_store_path): manager = GlobalMemoryManager(project_store=None) assert manager.project_store is None assert manager.global_store is not None @@ -200,9 +195,7 @@ def test_search_labels_project_results_correctly(self, manager): assert len(project_results) >= 1 def test_search_labels_global_results_correctly(self, manager): - self._store_to_global( - manager, "Always use 60-character title tags for SEO" - ) + self._store_to_global(manager, "Always use 60-character title tags for SEO") results = manager.search(MemoryQuery(query="title tags SEO")) @@ -478,7 +471,9 @@ def test_all_original_types_still_present(self): def test_store_and_retrieve_guideline(self, tmp_path): store = make_store(tmp_path) - entry = make_entry("Never commit secrets to version control", MemoryType.GUIDELINE) + entry = make_entry( + "Never commit secrets to version control", MemoryType.GUIDELINE + ) store.store(entry) retrieved = store.get(entry.id) @@ -579,9 +574,7 @@ def test_store_global_scope_goes_to_global_db(self, tmp_path): global_db = tmp_path / "global.db" with ( - patch( - "sugar.mcp.memory_server.get_memory_manager" - ) as mock_get_manager, + patch("sugar.mcp.memory_server.get_memory_manager") as mock_get_manager, patch("sugar.memory.global_store.GLOBAL_DB_PATH", global_db), ): project_store = make_store(tmp_path, "project.db") @@ -608,9 +601,7 @@ def test_store_project_scope_goes_to_project_db(self, tmp_path): global_db = tmp_path / "global.db" with ( - patch( - "sugar.mcp.memory_server.get_memory_manager" - ) as mock_get_manager, + patch("sugar.mcp.memory_server.get_memory_manager") as mock_get_manager, patch("sugar.memory.global_store.GLOBAL_DB_PATH", global_db), ): project_store = make_store(tmp_path, "project.db") @@ -639,9 +630,7 @@ def test_store_project_scope_without_project_store_falls_back_to_global( global_db = tmp_path / "global.db" with ( - patch( - "sugar.mcp.memory_server.get_memory_manager" - ) as mock_get_manager, + patch("sugar.mcp.memory_server.get_memory_manager") as mock_get_manager, patch("sugar.memory.global_store.GLOBAL_DB_PATH", global_db), ): # No project store - simulates running outside a Sugar project @@ -673,9 +662,7 @@ def test_search_memory_results_include_scope(self, tmp_path): global_db = tmp_path / "global.db" with ( - patch( - "sugar.mcp.memory_server.get_memory_manager" - ) as mock_get_manager, + patch("sugar.mcp.memory_server.get_memory_manager") as mock_get_manager, patch("sugar.memory.global_store.GLOBAL_DB_PATH", global_db), ): project_store = make_store(tmp_path, "project.db") @@ -687,7 +674,9 @@ def test_search_memory_results_include_scope(self, tmp_path): mock_get_manager.return_value = manager mcp = create_memory_mcp_server() - results = run(_get_mcp_tool_fn(mcp, "search_memory")(query="JWT authentication")) + results = run( + _get_mcp_tool_fn(mcp, "search_memory")(query="JWT authentication") + ) assert isinstance(results, list) if results and "error" not in results[0]: @@ -703,9 +692,7 @@ def test_search_memory_returns_list(self, tmp_path): global_db = tmp_path / "global.db" with ( - patch( - "sugar.mcp.memory_server.get_memory_manager" - ) as mock_get_manager, + patch("sugar.mcp.memory_server.get_memory_manager") as mock_get_manager, patch("sugar.memory.global_store.GLOBAL_DB_PATH", global_db), ): manager = GlobalMemoryManager(project_store=None) @@ -727,9 +714,7 @@ def test_includes_guidelines_key(self, tmp_path): global_db = tmp_path / "global.db" with ( - patch( - "sugar.mcp.memory_server.get_memory_manager" - ) as mock_get_manager, + patch("sugar.mcp.memory_server.get_memory_manager") as mock_get_manager, patch("sugar.memory.global_store.GLOBAL_DB_PATH", global_db), ): manager = GlobalMemoryManager(project_store=None) @@ -748,9 +733,7 @@ def test_guidelines_populated_from_global_store(self, tmp_path): global_db = tmp_path / "global.db" with ( - patch( - "sugar.mcp.memory_server.get_memory_manager" - ) as mock_get_manager, + patch("sugar.mcp.memory_server.get_memory_manager") as mock_get_manager, patch("sugar.memory.global_store.GLOBAL_DB_PATH", global_db), ): manager = GlobalMemoryManager(project_store=None) @@ -777,9 +760,7 @@ def test_recall_returns_string(self, tmp_path): global_db = tmp_path / "global.db" with ( - patch( - "sugar.mcp.memory_server.get_memory_manager" - ) as mock_get_manager, + patch("sugar.mcp.memory_server.get_memory_manager") as mock_get_manager, patch("sugar.memory.global_store.GLOBAL_DB_PATH", global_db), ): manager = GlobalMemoryManager(project_store=None) @@ -797,16 +778,16 @@ def test_recall_no_results_returns_helpful_message(self, tmp_path): global_db = tmp_path / "global.db" with ( - patch( - "sugar.mcp.memory_server.get_memory_manager" - ) as mock_get_manager, + patch("sugar.mcp.memory_server.get_memory_manager") as mock_get_manager, patch("sugar.memory.global_store.GLOBAL_DB_PATH", global_db), ): manager = GlobalMemoryManager(project_store=None) mock_get_manager.return_value = manager mcp = create_memory_mcp_server() - result = run(_get_mcp_tool_fn(mcp, "recall")(topic="absolutely unique xyz123")) + result = run( + _get_mcp_tool_fn(mcp, "recall")(topic="absolutely unique xyz123") + ) assert "No memories found" in result or isinstance(result, str) manager.close() @@ -821,9 +802,7 @@ def test_resource_returns_string(self, tmp_path): global_db = tmp_path / "global.db" with ( - patch( - "sugar.mcp.memory_server.get_memory_manager" - ) as mock_get_manager, + patch("sugar.mcp.memory_server.get_memory_manager") as mock_get_manager, patch("sugar.memory.global_store.GLOBAL_DB_PATH", global_db), ): manager = GlobalMemoryManager(project_store=None) @@ -841,9 +820,7 @@ def test_resource_empty_store_returns_placeholder(self, tmp_path): global_db = tmp_path / "global.db" with ( - patch( - "sugar.mcp.memory_server.get_memory_manager" - ) as mock_get_manager, + patch("sugar.mcp.memory_server.get_memory_manager") as mock_get_manager, patch("sugar.memory.global_store.GLOBAL_DB_PATH", global_db), ): manager = GlobalMemoryManager(project_store=None) @@ -861,13 +838,13 @@ def test_resource_with_guidelines_lists_them(self, tmp_path): global_db = tmp_path / "global.db" with ( - patch( - "sugar.mcp.memory_server.get_memory_manager" - ) as mock_get_manager, + patch("sugar.mcp.memory_server.get_memory_manager") as mock_get_manager, patch("sugar.memory.global_store.GLOBAL_DB_PATH", global_db), ): manager = GlobalMemoryManager(project_store=None) - guideline = make_entry("Kamal-only deploys, never raw Docker", MemoryType.GUIDELINE) + guideline = make_entry( + "Kamal-only deploys, never raw Docker", MemoryType.GUIDELINE + ) manager.store(guideline, scope=MemoryScope.GLOBAL) mock_get_manager.return_value = manager @@ -902,7 +879,9 @@ def test_format_for_prompt_labels_global_result(self, tmp_path): with patch("sugar.memory.global_store.GLOBAL_DB_PATH", global_db): manager = GlobalMemoryManager(project_store=None) - guideline = make_entry("Meta descriptions must be 120-160 chars", MemoryType.GUIDELINE) + guideline = make_entry( + "Meta descriptions must be 120-160 chars", MemoryType.GUIDELINE + ) manager.store(guideline, scope=MemoryScope.GLOBAL) results = manager.search(MemoryQuery(query="meta descriptions")) @@ -933,9 +912,7 @@ def test_format_for_prompt_no_global_label_for_project_result(self, tmp_path): if results: # Project-scoped results must not carry a [Global] label # Split on the result content and check its header - project_result = next( - (r for r in results if r.scope == "project"), None - ) + project_result = next((r for r in results if r.scope == "project"), None) if project_result: assert "[Global]" not in formatted or "Decision" in formatted @@ -1063,7 +1040,8 @@ def test_dedup_preserves_project_scope_over_global(self, tmp_path): """When content is in both stores, the project-scoped result is kept (higher specificity). The project result has a higher score because it is sorted first before dedup runs - but with FallbackEmbedder scores are equal, so the first - result in the pre-sorted list is kept. We verify at least one result survives.""" + result in the pre-sorted list is kept. We verify at least one result survives. + """ global_db = tmp_path / "global.db" with patch("sugar.memory.global_store.GLOBAL_DB_PATH", global_db): @@ -1081,9 +1059,9 @@ def test_dedup_preserves_project_scope_over_global(self, tmp_path): # At least one result, and the dedup key is based on content assert len(results) >= 1 - assert len(results) == len({ - " ".join(r.entry.content[:200].lower().split()) for r in results - }) + assert len(results) == len( + {" ".join(r.entry.content[:200].lower().split()) for r in results} + ) manager.close() def test_global_db_path_is_in_home_dot_sugar(self): From 05893403818ad4dc171695a211e1de81fe5cd9e0 Mon Sep 17 00:00:00 2001 From: Steven Leggett Date: Tue, 17 Mar 2026 11:36:30 -0400 Subject: [PATCH 4/8] style: Fix black formatting for CI version compatibility --- sugar/main.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/sugar/main.py b/sugar/main.py index 051432c..2bf7d66 100644 --- a/sugar/main.py +++ b/sugar/main.py @@ -1883,7 +1883,8 @@ def status(ctx): def help(): """Show comprehensive Sugar help and getting started guide""" - click.echo(""" + click.echo( + """ šŸ° Sugar - The Autonomous Layer for AI Coding Agents ===================================================== @@ -1994,7 +1995,8 @@ def help(): • By using Sugar, you agree to these terms and conditions Ready to supercharge your development workflow? šŸš€ -""") +""" + ) @cli.command() @@ -2995,7 +2997,8 @@ async def _dedupe_work(): async with aiosqlite.connect(work_queue.db_path) as db: # Find duplicates - keep the earliest created one for each source_file - cursor = await db.execute(""" + cursor = await db.execute( + """ WITH ranked_items AS ( SELECT id, source_file, title, created_at, ROW_NUMBER() OVER (PARTITION BY source_file ORDER BY created_at ASC) as rn @@ -3006,7 +3009,8 @@ async def _dedupe_work(): FROM ranked_items WHERE rn > 1 ORDER BY source_file, created_at - """) + """ + ) duplicates = await cursor.fetchall() From 7ae2c72446e4a69a1504f2447a577d9c539ec5e2 Mon Sep 17 00:00:00 2001 From: Steven Leggett Date: Tue, 17 Mar 2026 11:40:04 -0400 Subject: [PATCH 5/8] feat: Implement project-first search strategy with reserved guideline slots Search now uses a tiered approach: 1. Project store searched first (local context always wins) 2. Global guidelines get reserved slots (always surface) 3. Remaining slots filled with other global results 4. Dedup across all results This ensures project decisions override global ones, new projects get global knowledge automatically, and cross-project standards like SEO rules always appear regardless of local result count. --- sugar/memory/global_store.py | 71 ++++++++++++++++++++++++------- tests/test_global_memory.py | 81 +++++++++++++++++++++++++++++++++--- 2 files changed, 132 insertions(+), 20 deletions(-) diff --git a/sugar/memory/global_store.py b/sugar/memory/global_store.py index a122693..234935e 100644 --- a/sugar/memory/global_store.py +++ b/sugar/memory/global_store.py @@ -66,37 +66,80 @@ def store( ) return self.project_store.store(entry) - def search(self, query: MemoryQuery, limit: int = 10) -> List[MemorySearchResult]: + def search( + self, + query: MemoryQuery, + limit: int = 10, + guideline_slots: int = 2, + ) -> List[MemorySearchResult]: """ - Search both stores and return merged, deduplicated results. - - Results are tagged with their scope and sorted by relevance score. - Near-duplicate content across stores is deduplicated, preferring - project-scoped results (more specific context). + Search with project-first strategy and reserved guideline slots. + + Strategy: + 1. Search project store first (most specific context wins). + 2. Reserve slots for global guidelines so cross-project standards + always surface, even when the project store has plenty of hits. + 3. Fill any remaining slots with other global results. + 4. Deduplicate across all results. + + This ensures: + - A mature project's local context dominates search results. + - A new project with no local memories still gets global knowledge. + - Cross-project guidelines (SEO rules, deploy standards, etc.) + always appear regardless of how many project results exist. + - A project-level decision naturally overrides a global guideline + on the same topic because it occupies a higher-priority slot. Args: query: Search query parameters. limit: Maximum number of results to return. + guideline_slots: How many result slots to reserve for global + guidelines (default 2). Set to 0 to disable. Returns: - Merged list of results sorted by score descending. + List of results: project results first, then guidelines, + then remaining global results - all deduplicated. """ results: List[MemorySearchResult] = [] + # --- Step 1: project results (highest priority) --- if self.project_store: project_results = self.project_store.search(query) for r in project_results: r.scope = MemoryScope.PROJECT.value results.extend(project_results) - global_results = self.global_store.search(query) - for r in global_results: - r.scope = MemoryScope.GLOBAL.value - results.extend(global_results) - - results.sort(key=lambda r: r.score, reverse=True) + # --- Step 2: global guideline results (reserved slots) --- + guideline_results: List[MemorySearchResult] = [] + if guideline_slots > 0: + guideline_query = MemoryQuery( + query=query.query, + memory_types=[MemoryType.GUIDELINE], + limit=guideline_slots, + min_importance=query.min_importance, + include_expired=query.include_expired, + ) + guideline_results = self.global_store.search(guideline_query) + for r in guideline_results: + r.scope = MemoryScope.GLOBAL.value + results.extend(guideline_results) + + # --- Step 3: fill remaining slots with other global results --- + slots_used = len(self._deduplicate(results)) + remaining_slots = limit - slots_used + if remaining_slots > 0: + global_results = self.global_store.search(query) + for r in global_results: + r.scope = MemoryScope.GLOBAL.value + # Exclude guidelines already added in step 2 + guideline_ids = {r.entry.id for r in guideline_results} + global_results = [ + r for r in global_results if r.entry.id not in guideline_ids + ] + results.extend(global_results) + + # --- Step 4: deduplicate and cap --- results = self._deduplicate(results) - return results[:limit] def get_by_type( diff --git a/tests/test_global_memory.py b/tests/test_global_memory.py index 8221567..c48fb24 100644 --- a/tests/test_global_memory.py +++ b/tests/test_global_memory.py @@ -236,14 +236,83 @@ def test_search_global_only_manager_works(self, global_only_manager): assert len(results) >= 1 assert all(r.scope == "global" for r in results) - def test_search_sorts_by_score_descending(self, manager): - self._store_to_project(manager, "deployment procedure step one") - self._store_to_global(manager, "deployment procedure step two") + # --- Project-first strategy tests --- - results = manager.search(MemoryQuery(query="deployment procedure")) + def test_search_project_results_come_before_global(self, manager): + """Project results occupy the first slots in output.""" + self._store_to_project(manager, "Project auth uses JWT tokens") + self._store_to_global(manager, "Global auth standard is OAuth2") - scores = [r.score for r in results] - assert scores == sorted(scores, reverse=True) + results = manager.search(MemoryQuery(query="auth tokens")) + + # The very first result should be project-scoped + project_results = [r for r in results if r.scope == "project"] + if project_results and results: + first_project_idx = next( + i for i, r in enumerate(results) if r.scope == "project" + ) + first_global_non_guideline = next( + ( + i + for i, r in enumerate(results) + if r.scope == "global" + and r.entry.memory_type != MemoryType.GUIDELINE + ), + len(results), + ) + assert first_project_idx < first_global_non_guideline + + def test_search_guidelines_surface_even_when_project_full(self, manager): + """Global guidelines get reserved slots even when project has many results.""" + # Fill project store with many results + for i in range(8): + self._store_to_project( + manager, f"Project decision about deployment step {i}" + ) + # Store a global guideline + self._store_to_global( + manager, "Always use Kamal for deployment across all projects" + ) + + results = manager.search( + MemoryQuery(query="deployment"), limit=10, guideline_slots=2 + ) + + global_guidelines = [ + r + for r in results + if r.scope == "global" and r.entry.memory_type == MemoryType.GUIDELINE + ] + assert ( + len(global_guidelines) >= 1 + ), "Guidelines must surface even with many project results" + + def test_search_guideline_slots_zero_disables_reserved_slots(self, manager): + """Setting guideline_slots=0 disables reserved guideline slots.""" + for i in range(5): + self._store_to_project( + manager, f"Project decision about testing approach {i}" + ) + self._store_to_global(manager, "Global testing guideline for all projects") + + # With guideline_slots=0, global results only fill remaining space + results = manager.search( + MemoryQuery(query="testing"), limit=5, guideline_slots=0 + ) + + # Should still work - just no guaranteed guideline slots + assert len(results) <= 5 + + def test_search_new_project_gets_global_results(self, manager): + """A project with no local memories gets results from global store.""" + # Only store in global + self._store_to_global(manager, "SEO title tags under 60 characters") + self._store_to_global(manager, "Meta descriptions 120-160 characters") + + results = manager.search(MemoryQuery(query="SEO guidelines")) + + assert len(results) >= 1 + assert all(r.scope == "global" for r in results) class TestGlobalMemoryManagerGetByType: From ae3d8e2cac41d5c5078f691ab093fbafdae8ef0e Mon Sep 17 00:00:00 2001 From: Steven Leggett Date: Tue, 17 Mar 2026 11:52:40 -0400 Subject: [PATCH 6/8] docs: Reposition Sugar as memory-first with new tagline Update all documentation to lead with "Persistent memory for AI coding agents" instead of task-queue-first messaging. - README rewritten: memory capabilities lead, task queue secondary - pyproject.toml description updated - CLI help text updated - Plugin README repositioned - Quick start shows memory workflow before task management - Memory docs: global memory section, guideline type, --global flag - CLI reference: --global flag, scope labels, guideline type - Installation guide: memory MCP as primary integration - Docs index: memory system elevated to position 3 --- .claude-plugin/README.md | 16 +- README.md | 543 ++++++++------------------------ docs/README.md | 2 +- docs/user/cli-reference.md | 39 ++- docs/user/installation-guide.md | 6 +- docs/user/memory.md | 89 +++++- docs/user/quick-start.md | 23 +- pyproject.toml | 2 +- sugar/main.py | 4 +- 9 files changed, 285 insertions(+), 439 deletions(-) diff --git a/.claude-plugin/README.md b/.claude-plugin/README.md index e6c7ca6..0246403 100644 --- a/.claude-plugin/README.md +++ b/.claude-plugin/README.md @@ -1,18 +1,18 @@ -# šŸ° Sugar - Claude Code Plugin +# Sugar - Claude Code Plugin -The autonomous layer for AI coding agents. +Persistent memory for AI coding agents. -Autonomous task execution with background processing for any AI coding CLI. +Cross-session context, global knowledge, and autonomous task execution for any AI coding CLI. ## What is Sugar? -Sugar is a Claude Code plugin that brings autonomous development to your projects. Sugar provides: +Sugar is a Claude Code plugin that brings persistent memory and autonomous development to your projects. Sugar provides: +- **🧠 Persistent Memory** - Cross-session context that survives restarts - decisions, preferences, error patterns, and more +- **🌐 Global Knowledge** - Project-independent memory for guidelines and standards available across all your work - **šŸ¤– Autonomous Task Execution** - Let AI handle complex, multi-step development work -- **šŸ“‹ Enterprise Task Management** - Persistent SQLite-backed task tracking with rich metadata -- **šŸŽÆ Intelligent Agent Orchestration** - Specialized agents for different development aspects +- **šŸ“‹ Task Management** - Persistent SQLite-backed task tracking with rich metadata - **šŸ” Automatic Work Discovery** - Finds work from error logs, GitHub issues, and code quality metrics -- **šŸ‘„ Team Collaboration** - Shared task queues with multi-project support ## Quick Start @@ -173,6 +173,6 @@ MIT License - see [LICENSE](https://github.com/roboticforce/sugar/blob/main/LICE --- -**šŸ° Sugar** - The autonomous layer for AI coding agents. +**Sugar** - Persistent memory for AI coding agents. āš ļø **Disclaimer**: Sugar is an independent third-party tool. "Claude," "Claude Code," and related marks are trademarks of Anthropic, Inc. Sugar is not affiliated with, endorsed by, or sponsored by Anthropic, Inc. diff --git a/README.md b/README.md index 321d954..7577a94 100644 --- a/README.md +++ b/README.md @@ -1,514 +1,255 @@ # šŸ° Sugar -The autonomous layer for AI coding agents. +Persistent memory for AI coding agents. -Sugar manages your task queue, runs 24/7, and ships working code while you focus on what matters. +Sugar gives your AI agents memory that persists across sessions and projects. It remembers your decisions, patterns, and preferences so you stop re-explaining context every time you open a new session. The task queue builds on top of that memory layer to run work autonomously while you focus on other things. -## What It Does +## What Sugar Does -Sugar adds **autonomy and persistence** to your AI coding workflow. Instead of one-off interactions: +Every AI coding session starts cold. You explain the same architectural decisions, re-describe the same error patterns, and re-establish the same preferences - over and over. -- **Continuous execution** - Runs 24/7, working through your task queue -- **Agent-agnostic** - Works with Claude Code, OpenCode, Aider, or any AI CLI -- **Persistent memory** - Remember decisions, preferences, and patterns across sessions -- **Delegate and forget** - Hand off tasks from any session -- **Builds features** - Takes specs, implements, tests, commits working code -- **Fixes bugs** - Reads error logs, investigates, implements fixes -- **GitHub integration** - Creates PRs, updates issues, tracks progress +Sugar fixes that. It stores what matters and surfaces it when relevant: -You plan the work. Sugar executes it. +- **Project memory** - Decisions, preferences, error patterns, and research stored per-project +- **Global memory** - Standards and guidelines shared across every project you work on +- **Semantic search** - Retrieve relevant context by meaning, not just keywords +- **MCP integration** - Your AI agent reads and writes memory directly during sessions +- **Task queue** - Hand off work to run autonomously, powered by the same memory layer -**Works with:** [Claude Code](https://docs.anthropic.com/en/docs/claude-code) | [OpenCode](https://github.com/opencode-ai/opencode) | [Aider](https://aider.chat) | [Goose](https://block.github.io/goose) | Any CLI-based AI agent - -## Native AI Agent Integrations - -Sugar has **first-class integrations** with leading AI coding agents: - -| Agent | Integration | Features | -|-------|-------------|----------| -| **Claude Code** | MCP Server | Memory access, task delegation, context injection | -| **OpenCode** | Plugin + HTTP API | Bidirectional communication, notifications, memory injection | -| **Goose** | MCP Server | Task management, memory access | - -**Claude Code** - Full memory system access via MCP: -```bash -claude mcp add sugar -- sugar mcp memory -``` - -**OpenCode** - One-command setup with MCP integration: -```bash -sugar opencode setup # Automatically configures OpenCode -# Then restart OpenCode -``` - -Both integrations support **automatic memory injection** - Sugar injects relevant context (decisions, preferences, error patterns) into your AI sessions automatically. - -## Install +## Quick Start -**Recommended: pipx** (install once, use everywhere) ```bash +# Install once, use in any project pipx install sugarai -``` -This gives you a global `sugar` command that works in any project. Each project gets its own isolated config and database in a `.sugar/` folder. - -**Upgrade / Uninstall:** -```bash -pipx upgrade sugarai # Upgrade to latest version -pipx uninstall sugarai # Remove completely -``` +# Initialize in your project +cd ~/dev/my-app +sugar init -
-Other installation methods +# Store what you know +sugar remember "We use async/await everywhere, never callbacks" --type preference +sugar remember "JWT tokens use RS256, expire in 15 min - see auth/tokens.py" --type decision +sugar remember "When tests fail with import errors, check __init__.py exports first" --type error_pattern -**pip** (requires venv activation each session) -```bash -pip install sugarai +# Retrieve it later +sugar recall "authentication" +sugar recall "how do we handle async" ``` -**uv** (fast alternative to pip) -```bash -uv pip install sugarai -``` +Your AI agent can also read and write memory directly - no copy-pasting required. -**With GitHub integration:** -```bash -pipx install 'sugarai[github]' -``` +## MCP Integration -**With memory system (semantic search):** +Connect Sugar's memory to your AI agent so it can access project context automatically. + +**Claude Code - Memory server (primary):** ```bash -pipx install 'sugarai[memory]' +claude mcp add sugar -- sugar mcp memory ``` -**All features:** +**Claude Code - Task server (optional):** ```bash -pipx install 'sugarai[all]' +claude mcp add sugar-tasks -- sugar mcp tasks ``` -
- -## Quick Start - -Sugar is **project-local** - each project has its own isolated task queue and config. +Once connected, Claude can call `store_learning` to save context mid-session and `search_memories` to pull relevant knowledge before starting work. The memory server works from any directory - global memory is always available even outside a Sugar project. +**Other MCP clients (Goose, Claude Desktop):** ```bash -# Navigate to your project -cd ~/dev/my-app - -# Initialize Sugar (creates .sugar/ folder) -sugar init - -# This creates: -# - .sugar/sugar.db (task queue database) -# - .sugar/config.yaml (project settings) -# - .sugar/prompts/ (custom prompts) - -# Add tasks to the queue -sugar add "Fix authentication timeout" --type bug_fix --urgent -sugar add "Add user profile settings" --type feature +# Goose +goose configure +# Select "Add Extension" -> "Command-line Extension" +# Name: sugar +# Command: sugar mcp memory -# Start the autonomous loop -sugar run +# OpenCode - one command setup +sugar opencode setup ``` -Sugar will: -1. Pick up tasks from the queue -2. Execute them using your configured AI agent -3. Run tests and verify changes -4. Commit working code -5. Move to the next task - -It keeps going until the queue is empty (or you stop it). +## Global Memory (New in 3.8) -## Memory System - -Sugar remembers what matters across sessions. No more re-explaining decisions or rediscovering patterns. - -**Saves tokens:** Memories are stored as compressed summaries (~90% smaller) and retrieved only when relevant. Real projects see **~89% token reduction per session** - that's ~$32 saved over 500 sessions. +Some knowledge belongs to you, not just one project. Coding standards, preferred patterns, security practices - these should follow you everywhere. ```bash -# Store knowledge -sugar remember "Always use async/await, never callbacks" --type preference -sugar remember "JWT tokens use RS256, expire in 15 min" --type decision +# Store a guideline that applies to all your projects +sugar remember "Always validate and sanitize user input before any DB query" \ + --type guideline --global -# Search memories -sugar recall "authentication" +sugar remember "Use conventional commits: feat/fix/chore/docs/test" \ + --type guideline --global -# Claude Code integration - give Claude access to your project memory -claude mcp add sugar -- sugar mcp memory +# View your global guidelines +sugar recall "security" --global +sugar memories --global -# See your token savings -python examples/token_savings_demo.py +# Search works project-first, but guidelines always surface +sugar recall "database queries" +# Returns: project-specific memories + relevant global guidelines ``` -**Memory types:** `decision`, `preference`, `file_context`, `error_pattern`, `research`, `outcome` +Global memory lives at `~/.sugar/memory.db`. Project memory lives at `.sugar/sugar.db`. When you search, project context wins - but `guideline` type memories from global always appear in results so your standards stay visible. -**Full docs:** [Memory System Guide](docs/user/memory.md) +**Via MCP**, pass `scope: "global"` to `store_learning` to save cross-project knowledge directly from your AI session. -**Delegate from Claude Code:** -``` -/sugar-task "Fix login timeout" --type bug_fix --urgent -``` -Sugar picks it up and works on it while you keep coding. - -## How It Works: Project-Local Architecture - -``` -Global Installation (pipx) -└── sugar CLI (~/.local/bin/sugar) - -Project A Project B -~/dev/frontend-app/ ~/dev/backend-api/ -ā”œā”€ā”€ .sugar/ ā”œā”€ā”€ .sugar/ -│ ā”œā”€ā”€ sugar.db │ ā”œā”€ā”€ sugar.db -│ ā”œā”€ā”€ config.yaml │ ā”œā”€ā”€ config.yaml -│ └── prompts/ │ └── prompts/ -ā”œā”€ā”€ src/ ā”œā”€ā”€ main.py -└── tests/ └── requirements.txt - -Running `sugar` uses the .sugar/ folder in your current directory -``` +**Memory types:** `decision`, `preference`, `file_context`, `error_pattern`, `research`, `outcome`, `guideline` -**One global CLI, many isolated projects.** Like `git` - one installation, per-project repositories. +Full docs: [Memory System Guide](docs/user/memory.md) -## FAQ +## Task Queue -### Do I need to install Sugar in every project? - -**No!** Install Sugar once with `pipx install sugarai` and use it everywhere. - -The `sugar` command is globally available, but it reads configuration from the `.sugar/` folder in your **current directory**: - -- **Global CLI access**: Run `sugar` from anywhere without venv activation -- **Project-local state**: Each project's tasks and config stay isolated -- **No conflicts**: Work on multiple projects simultaneously - -### Can I run Sugar on multiple projects at the same time? - -Yes! Each project has its own isolated database. +The task queue lets you hand off work and let it run autonomously. It reads from the same memory store, so Sugar already knows your preferences and patterns before it starts. ```bash -# Terminal 1 -cd ~/dev/frontend-app -sugar run +# Add tasks +sugar add "Fix authentication timeout" --type bug_fix --urgent +sugar add "Add user profile settings" --type feature -# Terminal 2 (simultaneously) -cd ~/dev/backend-api +# Start the autonomous loop sugar run ``` -The two Sugar instances won't interfere with each other. - -### What happens if I run `sugar` outside a project folder? - -Sugar will show a friendly error: +Sugar picks up tasks, executes them with your configured AI agent, runs tests, commits working code, and moves to the next task. It runs until the queue is empty or you stop it. +**Delegate from Claude Code mid-session:** ``` -āŒ Not a Sugar project - - Could not find: .sugar/config.yaml - - Run 'sugar init' to initialize Sugar in this directory. -``` - -### Why pipx over pip? - -| Installation | Global access? | Requires venv? | -|--------------|----------------|----------------| -| `pip install sugarai` | Only in active venv | Yes | -| `pipx install sugarai` | Yes, always | No | - -With pipx, Sugar's dependencies don't conflict with your project's dependencies. - -### Should I commit .sugar/ to git? - -**Recommended .gitignore:** -```gitignore -.sugar/sugar.db # Task queue is personal -.sugar/sugar.log # Logs contain local paths -.sugar/*.db-* # SQLite temp files -``` - -**DO commit:** `.sugar/config.yaml` and `.sugar/prompts/` to share settings with your team. - -## Features - -**Native AI Agent Integrations** *(New in 3.5)* -- **Claude Code** - MCP server for memory access and task delegation -- **OpenCode** - Full bidirectional integration with notifications and context injection -- Automatic memory injection into AI sessions -- Real-time task lifecycle notifications - -**Memory System** *(New in 3.5)* -- Persistent semantic memory across sessions -- Remember decisions, preferences, error patterns -- Claude Code & OpenCode integration via MCP server -- Semantic search with `sugar recall` - -**Task Management** -- Rich task context with priorities and metadata -- Custom task types for your workflow -- Queue management and filtering - -**Task Orchestration** -- Auto-decomposes complex features into subtasks -- 4-stage workflow: Research → Planning → Implementation → Review -- Specialist agent routing (frontend, backend, QA, security, DevOps) -- Parallel execution with dependency management - -**Autonomous Execution** -- Specialized task agents (UX, backend, QA, security, DevOps) -- Automatic retries on failures -- Quality checks and testing - -**GitHub Integration** -- Reads issues, creates PRs -- Updates issue status automatically -- Commits with proper messages - -**Ralph Wiggum Integration** -- Iterative execution for complex tasks -- Self-correcting loops until tests pass -- Prevents single-shot failures - -**Full docs:** [Memory System](docs/user/memory.md) | [Goose Integration](docs/user/goose.md) | [OpenCode Integration](docs/user/opencode.md) | [Ralph Wiggum](docs/ralph-wiggum.md) - -## Configuration - -`.sugar/config.yaml` is auto-generated on `sugar init`. Key settings: - -```yaml -sugar: - dry_run: false # Set to true for testing - loop_interval: 300 # 5 minutes between cycles - max_concurrent_work: 3 # Parallel task execution - -claude: - enable_agents: true # Use specialized Claude agents - -discovery: - github: - enabled: true - repo: "user/repository" - error_logs: - enabled: true - paths: ["logs/errors/"] +/sugar-task "Fix login timeout" --type bug_fix --urgent ``` -## Integrations - -### Claude Code Plugin - -Sugar has native Claude Code integration. Delegate work directly from your Claude sessions. +**Advanced task options:** +```bash +# Orchestrated execution (research -> plan -> implement -> review) +sugar add "Add OAuth authentication" --type feature --orchestrate -``` -/plugin install roboticforce/sugar -``` +# Iterative mode - loops until tests pass +sugar add "Implement rate limiting" --ralph --max-iterations 10 -**Inside a Claude Code session:** +# Check queue status +sugar list +sugar status ``` -You: "I'm working on auth but need to fix these test failures. - Can you handle the tests while I finish?" -Claude: "I'll create a Sugar task for the test fixes." +Full docs: [Task Orchestration](docs/task_orchestration.md) -/sugar-task "Fix authentication test failures" --type test --urgent -``` - -**Available Slash Commands:** -- `/sugar-task` - Create tasks with rich context -- `/sugar-status` - Check queue and progress -- `/sugar-run` - Start autonomous mode +## Supported AI Tools -### OpenCode Integration +Works with any CLI-based AI coding agent: -Sugar has native MCP integration with [OpenCode](https://github.com/opencode-ai/opencode): +| Agent | Memory MCP | Task MCP | Notes | +|-------|-----------|---------|-------| +| [Claude Code](https://docs.anthropic.com/en/docs/claude-code) | Yes | Yes | Full support | +| [OpenCode](https://github.com/opencode-ai/opencode) | Yes | Yes | `sugar opencode setup` | +| [Goose](https://block.github.io/goose) | Yes | Yes | Via MCP | +| [Aider](https://aider.chat) | Via CLI | Via CLI | Manual recall | -**Features:** -- MCP servers for task management and memory access -- Automatic memory injection into OpenCode sessions -- Context-aware memory retrieval based on current work +## Installation -**Quick Setup:** +**Recommended: pipx** - installs once, available everywhere, no venv conflicts: ```bash -# One-command setup - adds Sugar MCP servers to OpenCode config -sugar opencode setup - -# Restart OpenCode to load the new servers -# Then verify: -sugar opencode status +pipx install sugarai ``` -The setup command automatically: -- Finds your OpenCode config file -- Adds `sugar-tasks` and `sugar-memory` MCP servers -- Preserves your existing configuration - -**Options:** +**Upgrade / Uninstall:** ```bash -sugar opencode setup --dry-run # Preview changes without applying -sugar opencode setup --yes # Non-interactive mode -sugar opencode setup --no-memory # Only add task server +pipx upgrade sugarai +pipx uninstall sugarai ``` -### MCP Server Integration - -Sugar provides MCP servers for Goose, Claude Code, Claude Desktop, and other MCP clients. +
+Other installation methods -**Using with Claude Code (Memory):** +**pip** (requires venv activation each session) ```bash -# Add Sugar memory to Claude Code -claude mcp add sugar -- sugar mcp memory +pip install sugarai ``` -Or add to `~/.claude.json`: -```json -{ - "mcpServers": { - "sugar": { - "type": "stdio", - "command": "sugar", - "args": ["mcp", "memory"] - } - } -} +**uv** +```bash +uv pip install sugarai ``` -This gives Claude Code access to your project's memory - decisions, preferences, error patterns, and more. - -**Using with Goose:** +**With semantic search (recommended for memory):** ```bash -goose configure -# Select "Add Extension" → "Command-line Extension" -# Name: sugar -# Command: npx -y sugarai-mcp +pipx install 'sugarai[memory]' ``` -**Using with Claude Desktop:** -```json -{ - "mcpServers": { - "sugar": { - "command": "npx", - "args": ["-y", "sugarai-mcp"], - "env": { - "SUGAR_PROJECT_ROOT": "/path/to/your/project" - } - } - } -} +**With GitHub integration:** +```bash +pipx install 'sugarai[github]' ``` -### Memory System - -Sugar's memory system provides persistent context across sessions: - +**All features:** ```bash -# Store memories -sugar remember "Always use async/await, never callbacks" --type preference -sugar remember "Auth tokens expire after 15 minutes" --type research --ttl 90d - -# Search memories -sugar recall "how do we handle authentication" -sugar recall "error patterns" --type error_pattern - -# List and manage -sugar memories --type decision --since 7d -sugar forget abc123 --force -sugar memory-stats - -# Export for Claude Code SessionStart hook -sugar export-context +pipx install 'sugarai[all]' ``` -**Memory types:** `decision`, `preference`, `file_context`, `error_pattern`, `research`, `outcome` - -**Full docs:** [Memory System Guide](docs/user/memory.md) +
-## Advanced Usage +Sugar is **project-local** by default. Each project gets its own `.sugar/` folder with its own database and config. Global memory lives at `~/.sugar/`. Like `git` - one installation, per-project state. -**Task Orchestration** -```bash -sugar add "Add OAuth authentication" --type feature --orchestrate +## Project Structure -# Sugar will: -# 1. RESEARCH - Search best practices, analyze codebase -# 2. PLAN - Create implementation plan with subtasks -# 3. IMPLEMENT - Route subtasks to specialists in parallel -# 4. REVIEW - Code review and test verification - -sugar orchestrate --stages ``` +~/.sugar/ +└── memory.db # Global memory (guidelines, cross-project knowledge) -**Ralph Wiggum (iterative execution)** -```bash -sugar add "Implement rate limiting" --ralph --max-iterations 10 -# Iterates until tests pass, not just until code is written +~/dev/my-app/ +ā”œā”€ā”€ .sugar/ +│ ā”œā”€ā”€ sugar.db # Project memory + task queue +│ ā”œā”€ā”€ config.yaml # Project settings +│ └── prompts/ # Custom agent prompts +└── src/ ``` -**Custom Task Types** -```bash -sugar task-type add deployment --name "Deployment" --emoji "šŸš€" -sugar add "Deploy to staging" --type deployment +**Recommended .gitignore:** +```gitignore +.sugar/sugar.db +.sugar/sugar.log +.sugar/*.db-* ``` -**Complex Tasks with Context** -```bash -sugar add "User Dashboard" --json --description '{ - "priority": 5, - "context": "Complete dashboard redesign", - "agent_assignments": { - "frontend_developer": "Implementation", - "qa_test_engineer": "Testing" - } -}' -``` +Commit `.sugar/config.yaml` and `.sugar/prompts/` to share settings with your team. -## Troubleshooting +## Configuration + +`.sugar/config.yaml` is created on `sugar init`: -**Sugar not finding Claude CLI?** ```yaml -# .sugar/config.yaml +sugar: + dry_run: false + loop_interval: 300 + max_concurrent_work: 3 + claude: - command: "/full/path/to/claude" -``` + enable_agents: true -**Tasks not executing?** -```bash -cat .sugar/config.yaml | grep dry_run # Check dry_run is false -tail -f .sugar/sugar.log # Monitor logs -sugar run --once # Test single cycle +discovery: + github: + enabled: true + repo: "user/repository" ``` -**More help:** -- [Troubleshooting Guide](docs/user/troubleshooting.md) -- [GitHub Issues](https://github.com/roboticforce/sugar/issues) - ## Documentation - [Quick Start](docs/user/quick-start.md) +- [Memory System](docs/user/memory.md) - [CLI Reference](docs/user/cli-reference.md) -- [Memory System](docs/user/memory.md) *(New)* - [Task Orchestration](docs/task_orchestration.md) -- [Ralph Wiggum](docs/ralph-wiggum.md) +- [Goose Integration](docs/user/goose.md) +- [OpenCode Integration](docs/user/opencode.md) - [GitHub Integration](docs/user/github-integration.md) - [Configuration Guide](docs/user/configuration-best-practices.md) +- [Troubleshooting](docs/user/troubleshooting.md) ## Requirements - Python 3.11+ -- An AI coding agent CLI: - - [Claude Code](https://docs.anthropic.com/en/docs/claude-code) (default) - - [OpenCode](https://github.com/opencode-ai/opencode) - - [Aider](https://aider.chat) - - Or any CLI-based AI coding tool +- A CLI-based AI agent: [Claude Code](https://docs.anthropic.com/en/docs/claude-code), [OpenCode](https://github.com/opencode-ai/opencode), [Aider](https://aider.chat), or similar ## Contributing -Contributions welcome! See [CONTRIBUTING.md](docs/dev/contributing.md). +Contributions welcome. See [CONTRIBUTING.md](docs/dev/contributing.md). ```bash git clone https://github.com/roboticforce/sugar.git @@ -526,8 +267,4 @@ pytest tests/ -v --- -**Sugar v3.5** - The autonomous layer for AI coding agents - -*Now with native Claude Code and OpenCode integrations for seamless AI agent collaboration.* - -> āš ļø Sugar is provided "AS IS" without warranty. Review all AI-generated code before use. +> Sugar is provided "AS IS" without warranty. Review all AI-generated code before use. diff --git a/docs/README.md b/docs/README.md index 9f438f3..25c0d89 100644 --- a/docs/README.md +++ b/docs/README.md @@ -9,9 +9,9 @@ Documentation for developers who want to **use** Sugar in their projects: - **[Installation Guide](user/installation-guide.md)** - Complete installation and setup instructions - **[Quick Start Guide](user/quick-start.md)** - Get up and running in 5 minutes +- **[Memory System](user/memory.md)** - Persistent semantic memory - the foundation for intelligent development - **[Execution Context](user/execution-context.md)** - Where and how to run Sugar correctly - **[CLI Reference](user/cli-reference.md)** - All Sugar commands and options -- **[Memory System](user/memory.md)** - Persistent semantic memory for coding sessions *(New)* - **[GitHub Integration](user/github-integration.md)** - Connect Sugar to GitHub issues and PRs - **[Examples](user/examples.md)** - Real-world usage examples - **[Configuration Best Practices](user/configuration-best-practices.md)** - Essential config patterns and exclusions diff --git a/docs/user/cli-reference.md b/docs/user/cli-reference.md index 073931b..0732624 100644 --- a/docs/user/cli-reference.md +++ b/docs/user/cli-reference.md @@ -744,11 +744,12 @@ sugar remember "CONTENT" [OPTIONS] ``` **Options:** -- `--type TYPE` - Memory type: `decision`, `preference`, `research`, `file_context`, `error_pattern`, `outcome` (default: `decision`) +- `--type TYPE` - Memory type: `decision`, `preference`, `research`, `file_context`, `error_pattern`, `outcome`, `guideline` (default: `decision`) - `--tags TEXT` - Comma-separated tags for organization - `--file PATH` - Associate with a specific file - `--ttl TEXT` - Time to live: `30d`, `90d`, `1y`, `never` (default: `never`) - `--importance FLOAT` - Importance score 0.0-2.0 (default: 1.0) +- `--global` - Store in global memory (`~/.sugar/memory.db`) instead of the project store. Works from any directory, no `sugar init` required. **Examples:** ```bash @@ -763,13 +764,19 @@ sugar remember "Stripe API rate limit: 100/sec" --type research --ttl 90d # File context sugar remember "Handles OAuth callbacks" --type file_context --file src/auth/callback.py + +# Global guideline (available in every project) +sugar remember --global "Title tags under 60 characters for SEO" --type guideline + +# Global decision (available in every project) +sugar remember --global "Always use Kamal for deploys" --type decision ``` --- ### `sugar recall` -Search memories for relevant context. +Search memories for relevant context. Searches both the project store and the global store automatically. ```bash sugar recall "QUERY" [OPTIONS] @@ -780,9 +787,11 @@ sugar recall "QUERY" [OPTIONS] - `--limit INTEGER` - Maximum results (default: 10) - `--format FORMAT` - Output format: `table`, `json`, `full` (default: `table`) +Results include a scope label (`project` or `global`) so you can see where each memory came from. Global guidelines are always included in a reserved set of slots so they are not crowded out by project results. + **Examples:** ```bash -# Basic search +# Basic search - returns project + global results sugar recall "authentication" # Filter by type @@ -793,6 +802,9 @@ sugar recall "stripe" --format json # Full details sugar recall "architecture" --format full --limit 5 + +# Search for deployment knowledge (picks up global decisions) +sugar recall "deployment" ``` --- @@ -810,10 +822,11 @@ sugar memories [OPTIONS] - `--since TEXT` - Filter by age (e.g., `7d`, `30d`, `2w`) - `--limit INTEGER` - Maximum results (default: 50) - `--format FORMAT` - Output format: `table`, `json` +- `--global` - List memories from the global store (`~/.sugar/memory.db`) instead of the project store **Examples:** ```bash -# List all +# List all project memories sugar memories # Recent decisions @@ -821,6 +834,12 @@ sugar memories --type decision --since 7d # Export to JSON sugar memories --format json > backup.json + +# List all global memories +sugar memories --global + +# List global guidelines only +sugar memories --global --type guideline ``` --- @@ -835,6 +854,7 @@ sugar forget MEMORY_ID [OPTIONS] **Options:** - `--force` - Skip confirmation prompt +- `--global` - Delete from the global store (`~/.sugar/memory.db`) instead of the project store **Examples:** ```bash @@ -843,6 +863,9 @@ sugar forget abc123 # Force delete sugar forget abc123 --force + +# Delete a global memory +sugar forget abc123 --global ``` --- @@ -884,7 +907,7 @@ sugar export-context [OPTIONS] ### `sugar memory-stats` -Show memory system statistics. +Show memory system statistics for both the project store and the global store. ```bash sugar memory-stats @@ -892,9 +915,9 @@ sugar memory-stats **Output includes:** - Semantic search availability -- Database path and size -- Total memory count -- Count by memory type +- Project database path and size +- Global database path and size +- Memory count by type for each store --- diff --git a/docs/user/installation-guide.md b/docs/user/installation-guide.md index a34584a..df6bc40 100644 --- a/docs/user/installation-guide.md +++ b/docs/user/installation-guide.md @@ -77,13 +77,15 @@ This creates: ### 2. Enable MCP Features in Claude Code (Recommended) -To get the most out of Sugar, add the MCP server to Claude Code: +To get the most out of Sugar, add the memory MCP server to Claude Code first: ```bash claude mcp add sugar -- sugar mcp memory ``` -This gives Claude Code access to your project's memory system - decisions, preferences, error patterns, and more. See the [Memory System Guide](memory.md) for details. +This gives Claude Code access to your memory system - decisions, preferences, error patterns, guidelines, and more. The memory MCP server works from any directory. Global memories (stored with `--global`) are always available, even outside Sugar projects. + +See the [Memory System Guide](memory.md) for details, including the `--global` flag for cross-project knowledge. **Claude CLI Detection:** Sugar will automatically search for Claude CLI in common locations: diff --git a/docs/user/memory.md b/docs/user/memory.md index 7e366b2..0a266a8 100644 --- a/docs/user/memory.md +++ b/docs/user/memory.md @@ -57,7 +57,7 @@ sugar memories ## Memory Types -Sugar organizes memories into six categories: +Sugar organizes memories into seven categories: | Type | Description | TTL Default | Example | |------|-------------|-------------|---------| @@ -67,6 +67,65 @@ Sugar organizes memories into six categories: | `error_pattern` | Bugs and their fixes | 90 days | "Login loop caused by missing return" | | `research` | API docs, library findings | 60 days | "Stripe idempotency keys required" | | `outcome` | Task results and learnings | 30 days | "Refactor improved load time 40%" | +| `guideline` | Cross-project rules and standards (global only) | Never | "Title tags under 60 characters for SEO" | + +## Global Memory + +By default, Sugar stores memories per-project in `.sugar/memory.db`. Global memory gives you a second store at `~/.sugar/memory.db` that persists across every project on your machine. + +Use global memory for knowledge that applies everywhere - deployment conventions, SEO rules, personal coding standards, or any decision that isn't tied to one codebase. + +### The `--global` Flag + +Pass `--global` to any memory command to target the global store instead of the project store: + +```bash +# Store a global guideline +sugar remember --global "Title tags under 60 characters for SEO" --type guideline + +# Store a global decision +sugar remember --global "Always use Kamal for deploys" --type decision + +# Recall from any project (searches both local + global) +sugar recall "deployment" + +# View global memory stats +sugar memory-stats +``` + +You can run `sugar remember --global` from any directory - no `sugar init` required. + +### The `guideline` Memory Type + +Global memory introduces a seventh memory type: `guideline`. Use it for standing rules and standards that should always be surfaced, regardless of which project you're working in. + +| Type | Description | TTL Default | Example | +|------|-------------|-------------|---------| +| `guideline` | Cross-project rules and standards | Never | "Title tags under 60 characters for SEO" | + +Guidelines are treated as high-priority context. When search results are assembled, a fixed number of slots are reserved for global guidelines so they are never crowded out by project-specific results. + +### How Search Works + +When you run `sugar recall`, Sugar searches both stores and merges the results: + +1. Project memories are searched first and fill most of the result slots +2. Global memories supplement the results with cross-project context +3. A reserved number of slots are held for `guideline` entries so important standards always appear + +This means you get the most relevant project-specific context plus your global rules, without needing to do anything differently - `sugar recall` handles both automatically. + +### MCP Usage + +When using the MCP server, pass `scope="global"` to `store_learning` to write to the global store: + +``` +store_learning(content="Always use Kamal for deploys", memory_type="decision", scope="global") +``` + +Search via `search_memory` and `recall` automatically queries both the project store and the global store. No extra configuration needed. + +--- ## CLI Commands @@ -78,11 +137,12 @@ Store a new memory. sugar remember "content" [options] Options: - --type TYPE Memory type (decision, preference, research, etc.) + --type TYPE Memory type (decision, preference, research, guideline, etc.) --tags TAGS Comma-separated tags for organization --file PATH Associate with a specific file --ttl TTL Time to live: 30d, 90d, 1y, never (default: never) --importance NUM Importance score 0.0-2.0 (default: 1.0) + --global Store in global memory (~/.sugar/memory.db) instead of project memory ``` **Examples:** @@ -217,21 +277,25 @@ sugar memory-stats Output: ``` -šŸ“Š Sugar Memory Statistics - -Semantic search: āœ… Available -Database: /Users/steve/project/.sugar/memory.db +Sugar Memory Statistics -Total memories: 47 +Semantic search: Available +Project database: /Users/steve/project/.sugar/memory.db +Global database: /Users/steve/.sugar/memory.db -By type: +Project memories: 47 preference 12 decision 8 error_pattern 6 research 15 file_context 6 -Database size: 156.2 KB +Global memories: 5 + guideline 3 + decision 2 + +Project database size: 156.2 KB +Global database size: 12.4 KB ``` ## Claude Code Integration @@ -417,9 +481,12 @@ Memory still works with keyword search. ### "Not a Sugar project" -Run from a directory with `.sugar/` folder: +Run from a directory with `.sugar/` folder, or use `--global` to write to the global store without a project: ```bash -sugar init # If not initialized +sugar init # Initialize the current directory + +# Or write directly to global memory from anywhere +sugar remember --global "Your rule here" --type guideline ``` ### Slow first search diff --git a/docs/user/quick-start.md b/docs/user/quick-start.md index 996b494..9c53874 100644 --- a/docs/user/quick-start.md +++ b/docs/user/quick-start.md @@ -69,7 +69,24 @@ claude mcp add sugar -- sugar mcp memory This gives Claude Code access to your project's memory - decisions, preferences, error patterns, and more. -### 3. Add Your First Task +### 3. Store and Recall Memory + +Sugar persists context across sessions so your AI agent remembers decisions, preferences, and patterns. + +```bash +# Store a memory +sugar remember "Always use async/await in this project" --type preference + +# Store a global guideline (available in all projects) +sugar remember --global "Use Kamal for all deployments" --type guideline + +# Recall relevant context +sugar recall "deployment strategy" +``` + +Memory is also available through the MCP server - Claude Code can read and write project memory directly during conversations. + +### 4. Add Your First Task Sugar accepts tasks in **two ways**: @@ -92,7 +109,7 @@ Sugar will also automatically discover work from: - Missing test coverage - GitHub issues (when configured) -### 4. Check Status +### 5. Check Status ```bash # View system status @@ -105,7 +122,7 @@ sugar list sugar view TASK_ID ``` -### 5. Run Sugar +### 6. Run Sugar ```bash # Test run (safe mode - no actual changes) diff --git a/pyproject.toml b/pyproject.toml index 86c97d6..3197bd8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta" [project] name = "sugarai" version = "3.8.1.dev0" -description = "šŸ° Sugar - The autonomous layer for AI coding agents. Manages task queues, runs 24/7, ships working code." +description = "Persistent memory for AI coding agents. Cross-session context, global knowledge, and autonomous task execution." readme = "README.md" requires-python = ">=3.11" diff --git a/sugar/main.py b/sugar/main.py index 2bf7d66..26b2b57 100644 --- a/sugar/main.py +++ b/sugar/main.py @@ -219,9 +219,9 @@ def signal_handler(signum, frame): @click.option("--version", is_flag=True, help="Show version information") @click.pass_context def cli(ctx, config, debug, version): - """šŸ° Sugar - The autonomous layer for AI coding agents + """Sugar - Persistent memory for AI coding agents - Manages task queues, runs 24/7, and ships working code with any AI coding CLI. + Cross-session context, global knowledge, and autonomous task execution. """ # Handle version request if version: From ca5a42ab72637dbcfc3f6fed5b35895545ada8e7 Mon Sep 17 00:00:00 2001 From: Steven Leggett Date: Tue, 17 Mar 2026 12:03:29 -0400 Subject: [PATCH 7/8] chore: Bump version to 3.9.0.dev0 for development --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 3197bd8..b45c5de 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "sugarai" -version = "3.8.1.dev0" +version = "3.9.0.dev0" description = "Persistent memory for AI coding agents. Cross-session context, global knowledge, and autonomous task execution." readme = "README.md" From 48ab5e0fbf6fa7525036f17b4752de338b386b61 Mon Sep 17 00:00:00 2001 From: Steven Leggett Date: Tue, 17 Mar 2026 12:15:19 -0400 Subject: [PATCH 8/8] chore: Release v3.9.0 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index b45c5de..0327b4d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "sugarai" -version = "3.9.0.dev0" +version = "3.9.0" description = "Persistent memory for AI coding agents. Cross-session context, global knowledge, and autonomous task execution." readme = "README.md"