diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7f6ee41..36d4434 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -37,7 +37,7 @@ jobs: run: | python -m pip install --upgrade pip pip install -e . - pip install pytest pytest-asyncio pytest-cov black flake8 mypy + pip install pytest pytest-asyncio pytest-cov 'black>=24.0.0,<26.0.0' flake8 mypy - name: Lint with flake8 run: | diff --git a/CHANGELOG.md b/CHANGELOG.md index a860475..2d55867 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,57 @@ All notable changes to the Sugar autonomous development system will be documente The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [3.5.0] - Unreleased +## [3.7.0] - Unreleased + +### šŸ”— MINOR RELEASE: OpenCode Integration Improvements + +One-command setup for OpenCode integration and new memory slash commands. + +### Added + +#### OpenCode Setup Command +- **`sugar opencode setup`** - One-command configuration for OpenCode + - Auto-detects OpenCode config file location + - Adds `sugar-tasks` and `sugar-memory` MCP servers + - Supports `--dry-run`, `--yes`, `--no-memory`, `--no-tasks` flags + - Parses JSON/JSONC safely, preserves existing configuration + - Idempotent - safe to run multiple times + +#### Memory Slash Commands for OpenCode +- **`/sugar-remember`** - Store learnings, decisions, preferences in Sugar memory +- **`/sugar-recall`** - Search memory for relevant context +- **`/sugar-context`** - Load full project context at session start + +### Changed + +#### Improved Error Messages +- OpenCode client now properly distinguishes connection errors from server errors +- "Cannot connect to OpenCode server" instead of misleading "Server responded but health check failed" +- `health_check()` and `notify()` now propagate connection errors for proper handling + +### Documentation +- Updated README with new `sugar opencode setup` command +- Updated `docs/user/opencode.md` with complete setup guide +- Updated static site documentation at sugar.roboticforce.io + +--- + +## [3.6.0] - 2025-01-20 + +### šŸ”Œ MINOR RELEASE: OpenCode Integration + +Full integration with OpenCode via HTTP API, notifications, and memory injection. + +### Added +- OpenCode HTTP client for bidirectional communication +- Task notifications (start, complete, fail) to OpenCode TUI +- Memory injection into OpenCode sessions +- Event subscription via SSE +- CLI commands: `sugar opencode status`, `sugar opencode test`, `sugar opencode notify` + +--- + +## [3.5.0] - 2025-01-15 ### 🧠 MINOR RELEASE: Memory System diff --git a/README.md b/README.md index 9f792a9..cd167d9 100644 --- a/README.md +++ b/README.md @@ -37,13 +37,10 @@ Sugar has **first-class integrations** with leading AI coding agents: claude mcp add sugar -- sugar mcp memory ``` -**OpenCode** - Native plugin with real-time notifications: +**OpenCode** - One-command setup with MCP integration: ```bash -# Check integration status -sugar opencode status - -# Test connection -sugar opencode test +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. @@ -328,37 +325,35 @@ Claude: "I'll create a Sugar task for the test fixes." ### OpenCode Integration -Sugar has native bidirectional integration with [OpenCode](https://github.com/opencode-ai/opencode): +Sugar has native MCP integration with [OpenCode](https://github.com/opencode-ai/opencode): **Features:** -- Real-time task notifications (start, complete, fail) +- MCP servers for task management and memory access - Automatic memory injection into OpenCode sessions - Context-aware memory retrieval based on current work -- Learning capture from session outcomes **Quick Setup:** ```bash -# Install OpenCode integration dependencies -pipx inject sugarai aiohttp +# One-command setup - adds Sugar MCP servers to OpenCode config +sugar opencode setup -# Check integration status +# Restart OpenCode to load the new servers +# Then verify: sugar opencode status - -# Test connection (requires OpenCode server running) -sugar opencode test - -# Send a test notification -sugar opencode notify "Hello from Sugar!" --title "Test" --level success ``` -**Environment Variables:** +The setup command automatically: +- Finds your OpenCode config file +- Adds `sugar-tasks` and `sugar-memory` MCP servers +- Preserves your existing configuration + +**Options:** ```bash -OPENCODE_SERVER_URL=http://localhost:4096 # OpenCode server URL -SUGAR_OPENCODE_ENABLED=true # Enable/disable integration +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 ``` -When Sugar executes tasks, it automatically sends notifications to OpenCode so you can track progress in real-time. - ### MCP Server Integration Sugar provides MCP servers for Goose, Claude Code, Claude Desktop, and other MCP clients. diff --git a/docs/user/opencode.md b/docs/user/opencode.md index 0b84655..59494c9 100644 --- a/docs/user/opencode.md +++ b/docs/user/opencode.md @@ -1,32 +1,36 @@ # Sugar OpenCode Integration -Sugar integrates with [OpenCode](https://github.com/opencode-ai/opencode) to provide bidirectional communication between Sugar's autonomous task queue and OpenCode sessions. +Sugar integrates with [OpenCode](https://github.com/opencode-ai/opencode) to provide MCP-based communication between Sugar's autonomous task queue and OpenCode sessions. ## Overview The OpenCode integration enables: -- **Task notifications** - Get notified in OpenCode when Sugar tasks complete or fail +- **MCP servers** - Task management and memory access directly in OpenCode - **Memory injection** - Automatically inject relevant context into OpenCode sessions - **Learning capture** - Capture learnings from OpenCode sessions back to Sugar memory -- **Event subscription** - React to OpenCode events in Sugar ## Quick Start ```bash -# Install with OpenCode support -pipx install 'sugarai[opencode]' -# Or add to existing installation -pipx inject sugarai aiohttp +# One-command setup - configures OpenCode automatically +sugar opencode setup + +# Restart OpenCode to load the new MCP servers -# Check OpenCode connection +# Verify setup sugar opencode status +``` -# Test notification -sugar opencode test +The setup command: +- Finds your OpenCode config file (`~/.config/opencode/opencode.json` or `.opencode/opencode.json`) +- Adds `sugar-tasks` and `sugar-memory` MCP servers +- Preserves your existing configuration -# Send manual notification -sugar opencode notify "Build completed" --level success -``` +After setup, OpenCode will have access to Sugar's tools: +- `sugar_add_task` - Add tasks to the queue +- `sugar_list_tasks` - View queued tasks +- `sugar_remember` - Store memories +- `sugar_recall` - Search memories ## Configuration @@ -69,9 +73,46 @@ integrations: ## CLI Commands +### `sugar opencode setup` + +Automatically configure OpenCode to use Sugar's MCP servers. + +```bash +sugar opencode setup [options] + +Options: + --yes, -y Skip confirmation prompts + --dry-run Show what would be changed without modifying files + --config PATH Path to OpenCode config file (auto-detected if not specified) + --no-memory Don't add the memory MCP server + --no-tasks Don't add the tasks MCP server +``` + +**Examples:** + +```bash +# Interactive setup (recommended) +sugar opencode setup + +# Non-interactive for scripts/CI +sugar opencode setup --yes + +# Preview changes without applying +sugar opencode setup --dry-run + +# Only add task management (no memory) +sugar opencode setup --no-memory +``` + +The command searches for OpenCode config in this order: +1. `OPENCODE_CONFIG` environment variable +2. `OPENCODE_CONFIG_DIR` environment variable +3. `.opencode/opencode.json` (project-local) +4. `~/.config/opencode/opencode.json` (user config) + ### `sugar opencode status` -Check OpenCode server connectivity and configuration. +Check OpenCode integration status and configuration. ```bash sugar opencode status @@ -80,14 +121,15 @@ sugar opencode status Output: ``` OpenCode Integration Status + Enabled: Yes + aiohttp: Installed Server URL: http://localhost:4096 - Status: Connected - Active Sessions: 2 + Auto-inject: Yes ``` ### `sugar opencode test` -Send a test notification to verify the integration works. +Test connection to the OpenCode HTTP server (if enabled). ```bash sugar opencode test diff --git a/packages/opencode-plugin/commands/sugar-context.md b/packages/opencode-plugin/commands/sugar-context.md new file mode 100644 index 0000000..d1fabae --- /dev/null +++ b/packages/opencode-plugin/commands/sugar-context.md @@ -0,0 +1,61 @@ +--- +name: sugar-context +description: Load project context from Sugar's memory at session start +--- + +# Project Context + +Load and display the full project context from Sugar's memory system. + +## When to Use + +- At the start of a new session +- Before working on an unfamiliar part of the codebase +- When you need a refresher on project conventions + +## Process + +1. Call `get_project_context` MCP tool +2. Present the context in a clear, organized format +3. Highlight any critical preferences or patterns + +## Presentation + +Format the context as: + +``` +Project Context from Sugar Memory +================================= + +Coding Preferences: +- Always use async/await, never callbacks +- Prefer early returns over nested conditionals +- Use TypeScript strict mode + +Recent Decisions: +- Using PostgreSQL for the database (ACID, JSON support) +- JWT with RS256 for authentication tokens +- Monorepo structure with pnpm workspaces + +Known Error Patterns: +- Login loop: missing return after redirect +- Connection timeout: increase pool size to 20 + +File Context: +- src/auth/ handles all authentication logic +- src/api/routes/ contains REST endpoints +``` + +## After Loading + +1. Summarize key points relevant to the current task +2. Mention `/sugar-remember` to add new context +3. Suggest `/sugar-recall [topic]` for deeper dives + +## If No Context + +If memory is empty: + +1. Explain this is a new project or memory hasn't been populated +2. Suggest storing key decisions with `/sugar-remember` +3. Offer to help identify important context to store diff --git a/packages/opencode-plugin/commands/sugar-recall.md b/packages/opencode-plugin/commands/sugar-recall.md new file mode 100644 index 0000000..710d1f9 --- /dev/null +++ b/packages/opencode-plugin/commands/sugar-recall.md @@ -0,0 +1,79 @@ +--- +name: sugar-recall +description: Search Sugar's memory for relevant context +--- + +# Recall Memory + +You are helping the user retrieve relevant information from Sugar's persistent memory system. + +## Process + +1. **Understand the Query**: What information does the user need? +2. **Search Memory**: Use the appropriate MCP tool +3. **Present Results**: Format findings clearly +4. **Suggest Actions**: Offer to store new learnings if relevant + +## Available Tools + +| Tool | Use When | +| ---- | -------- | +| `recall` | Get formatted context about a topic (best for general queries) | +| `search_memory` | Search with scoring, get structured results | +| `get_project_context` | Get overall project context (preferences, decisions, patterns) | +| `list_recent_memories` | Browse recent entries, optionally by type | + +## Example Interactions + +**General recall:** + +> User: "what do we know about authentication?" +> Use: recall with topic="authentication" + +**Specific search:** + +> User: "find all our database decisions" +> Use: search_memory with query="database decisions architecture" + +**Project context:** + +> User: "what are our coding preferences?" +> Use: get_project_context (returns all preferences, decisions, patterns) + +**Recent memories:** + +> User: "what did we learn recently?" +> Use: list_recent_memories with limit=10 + +## Presentation + +Format results clearly: + +``` +Found 3 relevant memories: + +1. [decision] Using PostgreSQL for ACID compliance + Stored: 2 days ago + +2. [preference] Always use prepared statements for queries + Stored: 1 week ago + +3. [error_pattern] Connection pool exhaustion: increase max_connections + Stored: 3 days ago +``` + +## When No Results + +If no memories found: + +1. Confirm what was searched +2. Suggest alternative search terms +3. Offer to store new information with `/sugar-remember` + +## Proactive Use + +Consider using memory tools proactively: + +- At the start of a task: `get_project_context` +- When encountering errors: `search_memory` for similar patterns +- Before making decisions: `recall` related past decisions diff --git a/packages/opencode-plugin/commands/sugar-remember.md b/packages/opencode-plugin/commands/sugar-remember.md new file mode 100644 index 0000000..d591ef7 --- /dev/null +++ b/packages/opencode-plugin/commands/sugar-remember.md @@ -0,0 +1,57 @@ +--- +name: sugar-remember +description: Store a learning, decision, or preference in Sugar's memory +--- + +# Store Memory + +You are helping the user store important information in Sugar's persistent memory system. + +## Process + +1. **Understand What to Remember**: Parse what the user wants to store +2. **Classify the Memory**: Determine the appropriate type +3. **Store It**: Use the `store_learning` MCP tool +4. **Confirm**: Tell the user what was stored + +## Memory Types + +| Type | Use When | +| ---- | -------- | +| `decision` | Architecture choices, implementation decisions, "we decided to..." | +| `preference` | Coding style, conventions, "always use...", "never do..." | +| `error_pattern` | Bug patterns and their fixes, "this error means..." | +| `research` | Findings, discoveries, documentation notes | +| `file_context` | What files do, where things are located | +| `outcome` | Results of previous attempts, what worked/didn't | + +## Example Interactions + +**Storing a preference:** + +> User: "remember that we always use async/await in this project" +> Use: store_learning with content="Always use async/await, never callbacks", type="preference" + +**Storing a decision:** + +> User: "remember we chose PostgreSQL for the database" +> Use: store_learning with content="Using PostgreSQL as the main database for ACID compliance and JSON support", type="decision" + +**Storing an error pattern:** + +> User: "remember that the login loop bug was caused by missing return statement" +> Use: store_learning with content="Login loop bug: caused by missing return statement in auth handler after redirect", type="error_pattern" + +## Best Practices + +When storing memories: + +1. **Be specific**: Include context and reasoning, not just facts +2. **Add tags**: Use relevant tags for easier recall (e.g., "auth,security") +3. **Include the "why"**: Future sessions benefit from understanding reasoning + +## After Storing + +1. Confirm the memory was stored with its ID +2. Mention they can search memories with `/sugar-recall` +3. Suggest related memories if relevant diff --git a/pyproject.toml b/pyproject.toml index 7297f39..e41b280 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "sugarai" -version = "3.6.0" +version = "3.7.0" description = "šŸ° Sugar - The autonomous layer for AI coding agents. Manages task queues, runs 24/7, ships working code." readme = "README.md" diff --git a/sugar/integrations/opencode/client.py b/sugar/integrations/opencode/client.py index 0cb2ed7..b1654e9 100644 --- a/sugar/integrations/opencode/client.py +++ b/sugar/integrations/opencode/client.py @@ -80,16 +80,24 @@ async def close(self) -> None: self._connected = False async def health_check(self) -> bool: - """Check if OpenCode server is reachable.""" + """ + Check if OpenCode server is reachable. + + Returns: + True if server is healthy + + Raises: + aiohttp.ClientConnectorError: If server is unreachable + """ if not self._session: return False - try: - async with self._session.get("/health") as resp: - return resp.status == 200 - except Exception as e: - logger.debug(f"Health check failed: {e}") - return False + # Let connection errors propagate so callers can distinguish + # between "server not running" vs "server returned non-200" + async with self._session.get("/health") as resp: + if resp.status != 200: + logger.debug(f"Health check returned status {resp.status}") + return resp.status == 200 # ========================================================================= # Session Management @@ -245,6 +253,9 @@ async def notify( Returns: True if notification was sent + + Raises: + aiohttp.ClientConnectorError: If server is unreachable """ if not self._session: raise RuntimeError("Client not connected. Use async with context.") @@ -255,12 +266,11 @@ async def notify( "level": level.value, } - try: - async with self._session.post("/tui/notify", json=payload) as resp: - return resp.status == 200 - except Exception as e: - logger.debug(f"Notification failed: {e}") - return False + # Let connection errors propagate so callers can handle appropriately + async with self._session.post("/tui/notify", json=payload) as resp: + if resp.status != 200: + logger.debug(f"Notification failed with status {resp.status}") + return resp.status == 200 async def notify_task_completed( self, diff --git a/sugar/main.py b/sugar/main.py index 5dd508e..fed313e 100644 --- a/sugar/main.py +++ b/sugar/main.py @@ -1882,7 +1882,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 ===================================================== @@ -1993,7 +1994,8 @@ def help(): • By using Sugar, you agree to these terms and conditions Ready to supercharge your development workflow? šŸš€ -""") +""" + ) @cli.command() @@ -2994,7 +2996,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 @@ -3005,7 +3008,8 @@ async def _dedupe_work(): FROM ranked_items WHERE rn > 1 ORDER BY source_file, created_at - """) + """ + ) duplicates = await cursor.fetchall() @@ -4625,14 +4629,24 @@ def opencode_test(ctx, server): click.echo(f"šŸ”— Testing connection to {config.server_url}...") async def test_connection(): + import aiohttp + try: async with OpenCodeClient(config) as client: if await client.health_check(): click.echo("āœ… Connection successful!") return True else: - click.echo("āŒ Server responded but health check failed") + click.echo( + "āŒ Server responded but health check failed (non-200 status)" + ) + click.echo(" Check that OpenCode is running correctly") return False + except aiohttp.ClientConnectorError: + click.echo("āŒ Cannot connect to OpenCode server") + click.echo(f" Is OpenCode running at {config.server_url}?") + click.echo(" Start OpenCode first, then try again") + return False except Exception as e: click.echo(f"āŒ Connection failed: {e}") return False @@ -4679,6 +4693,8 @@ def opencode_notify(ctx, message, title, level): } async def send_notification(): + import aiohttp + try: async with OpenCodeClient(config) as client: success = await client.notify( @@ -4691,6 +4707,10 @@ async def send_notification(): else: click.echo("āŒ Failed to send notification") return success + except aiohttp.ClientConnectorError: + click.echo("āŒ Cannot connect to OpenCode server") + click.echo(f" Is OpenCode running at {config.server_url}?") + return False except Exception as e: click.echo(f"āŒ Error: {e}") return False @@ -4703,5 +4723,235 @@ async def send_notification(): sys.exit(1) +@opencode.command("setup") +@click.option("--yes", "-y", is_flag=True, help="Skip confirmation prompts") +@click.option( + "--dry-run", is_flag=True, help="Show what would be changed without modifying files" +) +@click.option( + "--config", "config_path", type=click.Path(), help="Path to OpenCode config file" +) +@click.option("--memory/--no-memory", default=True, help="Include memory MCP server") +@click.option("--tasks/--no-tasks", default=True, help="Include tasks MCP server") +@click.pass_context +def opencode_setup(ctx, yes, dry_run, config_path, memory, tasks): + """Configure OpenCode to use Sugar's MCP servers + + Automatically finds and updates your OpenCode config file to add + Sugar's MCP servers for memory and task management. + + Examples: + + sugar opencode setup # Interactive setup + + sugar opencode setup --yes # Non-interactive, apply changes + + sugar opencode setup --dry-run # Preview changes without applying + """ + import json + import os + import re + from pathlib import Path + + click.echo("šŸ”— OpenCode Setup for Sugar") + click.echo("=" * 40) + + # Find OpenCode config file + def find_opencode_config(): + """Find OpenCode config file in order of precedence.""" + candidates = [] + + # Check explicit config path + if config_path: + return Path(config_path) if Path(config_path).exists() else None + + # Check environment variable + env_config = os.environ.get("OPENCODE_CONFIG") + if env_config: + candidates.append(Path(env_config)) + + env_config_dir = os.environ.get("OPENCODE_CONFIG_DIR") + if env_config_dir: + candidates.append(Path(env_config_dir) / "opencode.json") + candidates.append(Path(env_config_dir) / "opencode.jsonc") + + # Check project-local config + candidates.extend( + [ + Path(".opencode") / "opencode.json", + Path(".opencode") / "opencode.jsonc", + ] + ) + + # Check user config directory + home = Path.home() + candidates.extend( + [ + home / ".config" / "opencode" / "opencode.json", + home / ".config" / "opencode" / "opencode.jsonc", + ] + ) + + for candidate in candidates: + if candidate.exists(): + return candidate + + return None + + def parse_jsonc(content): + """Parse JSON with comments (JSONC).""" + # First try standard JSON - most configs won't have comments + try: + return json.loads(content) + except json.JSONDecodeError: + pass + + # Fall back to JSONC parsing + # Remove single-line comments (but not :// in URLs) + # Match // only when preceded by whitespace or start of line + content = re.sub(r"(? ~/.config/opencode/opencode.json' + ) + click.echo("\nThen run: sugar opencode setup") + sys.exit(1) + + click.echo(f"\nšŸ“ Found config: {config_file}") + + # Read existing config + try: + content = config_file.read_text() + if content.strip(): + config = parse_jsonc(content) + else: + config = {} + except json.JSONDecodeError as e: + click.echo(f"\nāŒ Failed to parse config file: {e}") + click.echo("Please fix the JSON syntax and try again") + sys.exit(1) + except Exception as e: + click.echo(f"\nāŒ Failed to read config file: {e}") + sys.exit(1) + + # Build MCP server configs + mcp_servers = {} + + if tasks: + mcp_servers["sugar-tasks"] = { + "type": "local", + "command": ["sugar", "mcp", "tasks"], + } + + if memory: + mcp_servers["sugar-memory"] = { + "type": "local", + "command": ["sugar", "mcp", "memory"], + } + + if not mcp_servers: + click.echo("\nāš ļø No servers selected. Use --tasks and/or --memory") + sys.exit(1) + + # Check what's already configured + existing_mcp = config.get("mcp", {}) + servers_to_add = {} + servers_existing = [] + + for name, server_config in mcp_servers.items(): + if name in existing_mcp: + servers_existing.append(name) + else: + servers_to_add[name] = server_config + + if not servers_to_add: + click.echo("\nāœ… Sugar MCP servers already configured!") + click.echo("\nConfigured servers:") + for name in servers_existing: + click.echo(f" • {name}") + click.echo("\nRun 'sugar opencode test' to verify connectivity") + sys.exit(0) + + # Show what will be added + click.echo("\nšŸ“¦ Sugar MCP servers to add:") + for name, server_config in servers_to_add.items(): + desc = "task queue management" if "tasks" in name else "memory/context system" + click.echo(f" + {name} ({desc})") + + if servers_existing: + click.echo("\nāœ“ Already configured:") + for name in servers_existing: + click.echo(f" • {name}") + + # Build new config + new_config = config.copy() + if "mcp" not in new_config: + new_config["mcp"] = {} + new_config["mcp"].update(servers_to_add) + + # Show preview + click.echo("\nšŸ“ Config changes:") + click.echo("-" * 40) + + # Show just the mcp section that will be added + preview = {"mcp": servers_to_add} + click.echo(format_json(preview)) + click.echo("-" * 40) + + if dry_run: + click.echo("\nšŸ” Dry run - no changes made") + click.echo("\nFull config would be:") + click.echo(format_json(new_config)) + sys.exit(0) + + # Confirm + if not yes: + if not click.confirm("\nApply changes?", default=True): + click.echo("Cancelled") + sys.exit(0) + + # Write config + try: + config_file.write_text(format_json(new_config) + "\n") + click.echo(f"\nāœ… Config updated: {config_file}") + except Exception as e: + click.echo(f"\nāŒ Failed to write config: {e}") + sys.exit(1) + + # Success message + click.echo("\n" + "=" * 40) + click.echo("šŸŽ‰ Setup complete!") + click.echo("\nNext steps:") + click.echo(" 1. Restart OpenCode to load the new MCP servers") + click.echo(" 2. Run 'sugar opencode test' to verify connectivity") + click.echo("\nIn OpenCode, you'll have access to Sugar tools:") + if tasks: + click.echo(" • sugar_add_task - Add tasks to the queue") + click.echo(" • sugar_list_tasks - View queued tasks") + click.echo(" • sugar_get_task - Get task details") + if memory: + click.echo(" • sugar_remember - Store memories") + click.echo(" • sugar_recall - Search memories") + + if __name__ == "__main__": cli() diff --git a/sugar/memory/store.py b/sugar/memory/store.py index b8a9848..6512395 100644 --- a/sugar/memory/store.py +++ b/sugar/memory/store.py @@ -97,7 +97,8 @@ def _init_db(self): cursor = conn.cursor() # Main memory entries table - cursor.execute(""" + cursor.execute( + """ CREATE TABLE IF NOT EXISTS memory_entries ( id TEXT PRIMARY KEY, memory_type TEXT NOT NULL, @@ -111,24 +112,32 @@ def _init_db(self): access_count INTEGER DEFAULT 0, expires_at TIMESTAMP ) - """) + """ + ) # Indexes - cursor.execute(""" + cursor.execute( + """ CREATE INDEX IF NOT EXISTS idx_memory_type ON memory_entries(memory_type) - """) - cursor.execute(""" + """ + ) + cursor.execute( + """ CREATE INDEX IF NOT EXISTS idx_memory_importance ON memory_entries(importance DESC) - """) - cursor.execute(""" + """ + ) + cursor.execute( + """ CREATE INDEX IF NOT EXISTS idx_memory_created ON memory_entries(created_at DESC) - """) + """ + ) # FTS5 for keyword search (always available) - cursor.execute(""" + cursor.execute( + """ CREATE VIRTUAL TABLE IF NOT EXISTS memory_fts USING fts5( id, content, @@ -136,39 +145,48 @@ def _init_db(self): content='memory_entries', content_rowid='rowid' ) - """) + """ + ) # Triggers to keep FTS in sync - cursor.execute(""" + cursor.execute( + """ CREATE TRIGGER IF NOT EXISTS memory_ai AFTER INSERT ON memory_entries BEGIN INSERT INTO memory_fts(rowid, id, content, summary) VALUES (new.rowid, new.id, new.content, new.summary); END - """) - cursor.execute(""" + """ + ) + cursor.execute( + """ CREATE TRIGGER IF NOT EXISTS memory_ad AFTER DELETE ON memory_entries BEGIN INSERT INTO memory_fts(memory_fts, rowid, id, content, summary) VALUES ('delete', old.rowid, old.id, old.content, old.summary); END - """) - cursor.execute(""" + """ + ) + cursor.execute( + """ CREATE TRIGGER IF NOT EXISTS memory_au AFTER UPDATE ON memory_entries BEGIN INSERT INTO memory_fts(memory_fts, rowid, id, content, summary) VALUES ('delete', old.rowid, old.id, old.content, old.summary); INSERT INTO memory_fts(rowid, id, content, summary) VALUES (new.rowid, new.id, new.content, new.summary); END - """) + """ + ) # Vector storage table (if sqlite-vec available) if self._has_vec: try: - cursor.execute(f""" + cursor.execute( + f""" CREATE VIRTUAL TABLE IF NOT EXISTS memory_vectors USING vec0( id TEXT PRIMARY KEY, embedding float[{EMBEDDING_DIM}] ) - """) + """ + ) except Exception as e: logger.warning(f"Failed to create vector table: {e}") self._has_vec = False @@ -584,20 +602,24 @@ def prune_expired(self) -> int: cursor = conn.cursor() # Get IDs to delete (for vector cleanup) - cursor.execute(""" + cursor.execute( + """ SELECT id FROM memory_entries WHERE expires_at IS NOT NULL AND expires_at < datetime('now') - """) + """ + ) expired_ids = [row["id"] for row in cursor.fetchall()] if not expired_ids: return 0 # Delete from main table - cursor.execute(""" + cursor.execute( + """ DELETE FROM memory_entries WHERE expires_at IS NOT NULL AND expires_at < datetime('now') - """) + """ + ) deleted = cursor.rowcount # Clean up vectors diff --git a/sugar/orchestration/task_orchestrator.py b/sugar/orchestration/task_orchestrator.py index 0bfcf62..2a1694e 100644 --- a/sugar/orchestration/task_orchestrator.py +++ b/sugar/orchestration/task_orchestrator.py @@ -721,7 +721,9 @@ def _build_stage_prompt( """ if stage == OrchestrationStage.RESEARCH: - return base_prompt + """ + return ( + base_prompt + + """ ## Your Role You are conducting research for this task. Your goals: 1. Search for relevant best practices and documentation @@ -736,6 +738,7 @@ def _build_stage_prompt( - Technical requirements - Recommendations for implementation """ + ) elif stage == OrchestrationStage.PLANNING: research_context = "" @@ -744,7 +747,10 @@ def _build_stage_prompt( f"\n## Research Findings\n{context['research_output']}\n" ) - return base_prompt + research_context + """ + return ( + base_prompt + + research_context + + """ ## Your Role You are creating an implementation plan for this task. Your goals: 1. Break down the task into manageable subtasks @@ -769,12 +775,15 @@ def _build_stage_prompt( ## Dependencies Explain the order of execution and why. """ + ) elif stage == OrchestrationStage.REVIEW: impl_results = context.get("subtask_results", []) files_modified = context.get("files_modified", []) - return base_prompt + f""" + return ( + base_prompt + + f""" ## Implementation Complete The following subtasks have been completed: {json.dumps(impl_results, indent=2)} @@ -797,6 +806,7 @@ def _build_stage_prompt( - Recommendations for improvement - Overall assessment (pass/fail) """ + ) else: return base_prompt diff --git a/sugar/storage/issue_response_manager.py b/sugar/storage/issue_response_manager.py index 6f243c6..8ac85a3 100644 --- a/sugar/storage/issue_response_manager.py +++ b/sugar/storage/issue_response_manager.py @@ -26,7 +26,8 @@ async def initialize(self) -> None: return async with aiosqlite.connect(self.db_path) as db: - await db.execute(""" + await db.execute( + """ CREATE TABLE IF NOT EXISTS issue_responses ( id TEXT PRIMARY KEY, repo TEXT NOT NULL, @@ -40,12 +41,15 @@ async def initialize(self) -> None: was_auto_posted BOOLEAN DEFAULT 0, UNIQUE(repo, issue_number, response_type) ) - """) + """ + ) - await db.execute(""" + await db.execute( + """ CREATE INDEX IF NOT EXISTS idx_issue_responses_repo_number ON issue_responses (repo, issue_number) - """) + """ + ) await db.commit() diff --git a/sugar/storage/task_type_manager.py b/sugar/storage/task_type_manager.py index 5b255cf..d1ac8c9 100644 --- a/sugar/storage/task_type_manager.py +++ b/sugar/storage/task_type_manager.py @@ -35,7 +35,8 @@ async def initialize(self): if not table_exists: # Create task_types table - await db.execute(""" + await db.execute( + """ CREATE TABLE task_types ( id TEXT PRIMARY KEY, name TEXT NOT NULL, @@ -56,7 +57,8 @@ async def initialize(self): created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) - """) + """ + ) # Populate with default types default_types = self._get_default_task_types() diff --git a/sugar/storage/work_queue.py b/sugar/storage/work_queue.py index 0ef6863..80b159b 100644 --- a/sugar/storage/work_queue.py +++ b/sugar/storage/work_queue.py @@ -28,7 +28,8 @@ async def initialize(self): return async with aiosqlite.connect(self.db_path) as db: - await db.execute(""" + await db.execute( + """ CREATE TABLE IF NOT EXISTS work_items ( id TEXT PRIMARY KEY, type TEXT NOT NULL, @@ -51,17 +52,22 @@ async def initialize(self): total_elapsed_time REAL DEFAULT 0.0, commit_sha TEXT ) - """) + """ + ) - await db.execute(""" + await db.execute( + """ CREATE INDEX IF NOT EXISTS idx_work_items_priority_status ON work_items (priority ASC, status, created_at) - """) + """ + ) - await db.execute(""" + await db.execute( + """ CREATE INDEX IF NOT EXISTS idx_work_items_status ON work_items (status) - """) + """ + ) # Migrate existing databases to add timing columns and task types table await self._migrate_timing_columns(db) @@ -120,7 +126,8 @@ async def _migrate_task_types_table(self, db): if not table_exists: # Create task_types table - await db.execute(""" + await db.execute( + """ CREATE TABLE task_types ( id TEXT PRIMARY KEY, name TEXT NOT NULL, @@ -133,7 +140,8 @@ async def _migrate_task_types_table(self, db): created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) - """) + """ + ) # Insert default task types default_types = [ @@ -261,10 +269,12 @@ async def _migrate_orchestration_columns(self, db): logger.info("Added assigned_agent column to existing database") # Create index for parent_task_id queries - await db.execute(""" + await db.execute( + """ CREATE INDEX IF NOT EXISTS idx_work_items_parent_task_id ON work_items (parent_task_id) - """) + """ + ) except Exception as e: logger.warning(f"Orchestration migration warning (non-critical): {e}") @@ -446,12 +456,14 @@ async def get_next_work(self) -> Optional[Dict[str, Any]]: db.row_factory = aiosqlite.Row # Get highest priority pending work item (exclude hold status) - cursor = await db.execute(""" + cursor = await db.execute( + """ SELECT * FROM work_items WHERE status = 'pending' ORDER BY priority ASC, created_at ASC LIMIT 1 - """) + """ + ) row = await cursor.fetchone() @@ -694,11 +706,13 @@ async def get_stats(self) -> Dict[str, int]: stats = {} # Count by status - cursor = await db.execute(""" + cursor = await db.execute( + """ SELECT status, COUNT(*) as count FROM work_items GROUP BY status - """) + """ + ) rows = await cursor.fetchall() for row in rows: @@ -712,10 +726,12 @@ async def get_stats(self) -> Dict[str, int]: stats["total"] = sum(stats.values()) # Recent activity (last 24 hours) - cursor = await db.execute(""" + cursor = await db.execute( + """ SELECT COUNT(*) FROM work_items WHERE created_at > datetime('now', '-1 day') - """) + """ + ) stats["recent_24h"] = (await cursor.fetchone())[0] return stats @@ -723,11 +739,15 @@ async def get_stats(self) -> Dict[str, int]: async def cleanup_old_items(self, days_old: int = 30): """Clean up old completed/failed items""" async with aiosqlite.connect(self.db_path) as db: - cursor = await db.execute(""" + cursor = await db.execute( + """ DELETE FROM work_items WHERE status IN ('completed', 'failed') AND created_at < datetime('now', '-{} days') - """.format(days_old)) + """.format( + days_old + ) + ) deleted_count = cursor.rowcount await db.commit() diff --git a/tests/test_opencode_integration.py b/tests/test_opencode_integration.py index 158f7d0..a08c1a0 100644 --- a/tests/test_opencode_integration.py +++ b/tests/test_opencode_integration.py @@ -632,12 +632,171 @@ async def test_health_check_success(self): OpenCodeClient, AIOHTTP_AVAILABLE, ) + from sugar.integrations.opencode.config import OpenCodeConfig + + if not AIOHTTP_AVAILABLE: + pytest.skip("aiohttp not installed") + + config = OpenCodeConfig(server_url="http://test:4096") + client = OpenCodeClient(config) + + # Mock the session and response + mock_response = AsyncMock() + mock_response.status = 200 + mock_response.__aenter__ = AsyncMock(return_value=mock_response) + mock_response.__aexit__ = AsyncMock(return_value=None) + + mock_session = MagicMock() + mock_session.get = MagicMock(return_value=mock_response) + mock_session.close = AsyncMock() + + client._session = mock_session + + result = await client.health_check() + assert result is True + mock_session.get.assert_called_once_with("/health") + + except ImportError: + pytest.skip("aiohttp not installed") + + async def test_health_check_failure(self): + """Test health check returns False on non-200""" + try: + from sugar.integrations.opencode.client import ( + OpenCodeClient, + AIOHTTP_AVAILABLE, + ) + from sugar.integrations.opencode.config import OpenCodeConfig + + if not AIOHTTP_AVAILABLE: + pytest.skip("aiohttp not installed") + + config = OpenCodeConfig(server_url="http://test:4096") + client = OpenCodeClient(config) + + # Mock the session with 500 response + mock_response = AsyncMock() + mock_response.status = 500 + mock_response.__aenter__ = AsyncMock(return_value=mock_response) + mock_response.__aexit__ = AsyncMock(return_value=None) + + mock_session = MagicMock() + mock_session.get = MagicMock(return_value=mock_response) + mock_session.close = AsyncMock() + + client._session = mock_session + + result = await client.health_check() + assert result is False + + except ImportError: + pytest.skip("aiohttp not installed") + + async def test_health_check_connection_error(self): + """Test health check raises on connection error""" + try: + import aiohttp + from sugar.integrations.opencode.client import ( + OpenCodeClient, + AIOHTTP_AVAILABLE, + ) + from sugar.integrations.opencode.config import OpenCodeConfig + + if not AIOHTTP_AVAILABLE: + pytest.skip("aiohttp not installed") + + config = OpenCodeConfig(server_url="http://test:4096") + client = OpenCodeClient(config) + + # Mock the session to raise connection error + mock_session = MagicMock() + mock_session.get = MagicMock( + side_effect=aiohttp.ClientConnectorError( + MagicMock(), OSError("Connection refused") + ) + ) + mock_session.close = AsyncMock() + + client._session = mock_session + + # Should raise the connection error (not catch it) + with pytest.raises(aiohttp.ClientConnectorError): + await client.health_check() + + except ImportError: + pytest.skip("aiohttp not installed") + + async def test_notify_success(self): + """Test notify returns True on 200""" + try: + from sugar.integrations.opencode.client import ( + OpenCodeClient, + AIOHTTP_AVAILABLE, + ) + from sugar.integrations.opencode.config import OpenCodeConfig + from sugar.integrations.opencode.models import NotificationLevel + + if not AIOHTTP_AVAILABLE: + pytest.skip("aiohttp not installed") + + config = OpenCodeConfig(server_url="http://test:4096") + client = OpenCodeClient(config) + + # Mock the session and response + mock_response = AsyncMock() + mock_response.status = 200 + mock_response.__aenter__ = AsyncMock(return_value=mock_response) + mock_response.__aexit__ = AsyncMock(return_value=None) + + mock_session = MagicMock() + mock_session.post = MagicMock(return_value=mock_response) + mock_session.close = AsyncMock() + + client._session = mock_session + + result = await client.notify( + title="Test", message="Test message", level=NotificationLevel.INFO + ) + assert result is True + mock_session.post.assert_called_once() + + except ImportError: + pytest.skip("aiohttp not installed") + + async def test_notify_connection_error(self): + """Test notify raises on connection error""" + try: + import aiohttp + from sugar.integrations.opencode.client import ( + OpenCodeClient, + AIOHTTP_AVAILABLE, + ) + from sugar.integrations.opencode.config import OpenCodeConfig + from sugar.integrations.opencode.models import NotificationLevel if not AIOHTTP_AVAILABLE: pytest.skip("aiohttp not installed") - # Would need to mock aiohttp session here - pass + config = OpenCodeConfig(server_url="http://test:4096") + client = OpenCodeClient(config) + + # Mock the session to raise connection error + mock_session = MagicMock() + mock_session.post = MagicMock( + side_effect=aiohttp.ClientConnectorError( + MagicMock(), OSError("Connection refused") + ) + ) + mock_session.close = AsyncMock() + + client._session = mock_session + + # Should raise the connection error (not catch it) + with pytest.raises(aiohttp.ClientConnectorError): + await client.notify( + title="Test", message="Test message", level=NotificationLevel.INFO + ) + except ImportError: pytest.skip("aiohttp not installed") @@ -749,3 +908,253 @@ def test_half_life_values(self): assert half_life["decision"] >= 365 * 5 # Outcomes should expire fastest assert half_life["outcome"] < half_life["error_pattern"] + + +# ============================================================================= +# OpenCode Setup CLI Tests +# ============================================================================= + + +class TestOpenCodeSetupCommand: + """Tests for the sugar opencode setup CLI command""" + + def test_setup_finds_config_file(self, tmp_path): + """Test that setup command finds OpenCode config file""" + from click.testing import CliRunner + from sugar.main import cli + + # Create a mock OpenCode config + config_file = tmp_path / "opencode.json" + config_file.write_text('{"$schema": "https://opencode.ai/config.json"}') + + runner = CliRunner() + result = runner.invoke( + cli, ["opencode", "setup", "--dry-run", "--config", str(config_file)] + ) + + assert result.exit_code == 0 + assert "Found config" in result.output + assert "sugar-tasks" in result.output + assert "sugar-memory" in result.output + + def test_setup_dry_run_no_changes(self, tmp_path): + """Test that --dry-run doesn't modify files""" + from click.testing import CliRunner + from sugar.main import cli + + # Create a mock OpenCode config + config_file = tmp_path / "opencode.json" + original_content = '{"$schema": "https://opencode.ai/config.json"}' + config_file.write_text(original_content) + + runner = CliRunner() + result = runner.invoke( + cli, ["opencode", "setup", "--dry-run", "--config", str(config_file)] + ) + + assert result.exit_code == 0 + assert "Dry run" in result.output + # File should be unchanged + assert config_file.read_text() == original_content + + def test_setup_adds_mcp_servers(self, tmp_path): + """Test that setup adds sugar MCP servers to config""" + from click.testing import CliRunner + from sugar.main import cli + import json + + # Create a mock OpenCode config + config_file = tmp_path / "opencode.json" + config_file.write_text('{"$schema": "https://opencode.ai/config.json"}') + + runner = CliRunner() + result = runner.invoke( + cli, ["opencode", "setup", "--yes", "--config", str(config_file)] + ) + + assert result.exit_code == 0 + assert "Config updated" in result.output + + # Verify MCP servers were added + updated_config = json.loads(config_file.read_text()) + assert "mcp" in updated_config + assert "sugar-tasks" in updated_config["mcp"] + assert "sugar-memory" in updated_config["mcp"] + assert updated_config["mcp"]["sugar-tasks"]["command"] == [ + "sugar", + "mcp", + "tasks", + ] + + def test_setup_preserves_existing_config(self, tmp_path): + """Test that setup preserves existing configuration""" + from click.testing import CliRunner + from sugar.main import cli + import json + + # Create a mock OpenCode config with existing settings + config_file = tmp_path / "opencode.json" + config_file.write_text( + json.dumps( + { + "$schema": "https://opencode.ai/config.json", + "plugin": ["existing-plugin"], + "mcp": {"existing-server": {"type": "local", "command": ["test"]}}, + } + ) + ) + + runner = CliRunner() + result = runner.invoke( + cli, ["opencode", "setup", "--yes", "--config", str(config_file)] + ) + + assert result.exit_code == 0 + + # Verify existing config preserved + updated_config = json.loads(config_file.read_text()) + assert "plugin" in updated_config + assert "existing-plugin" in updated_config["plugin"] + assert "existing-server" in updated_config["mcp"] + assert "sugar-tasks" in updated_config["mcp"] + + def test_setup_idempotent(self, tmp_path): + """Test that running setup twice is safe""" + from click.testing import CliRunner + from sugar.main import cli + import json + + # Create a mock OpenCode config + config_file = tmp_path / "opencode.json" + config_file.write_text('{"$schema": "https://opencode.ai/config.json"}') + + runner = CliRunner() + # Run setup first time + result1 = runner.invoke( + cli, ["opencode", "setup", "--yes", "--config", str(config_file)] + ) + assert result1.exit_code == 0 + + # Run setup second time + result2 = runner.invoke( + cli, ["opencode", "setup", "--yes", "--config", str(config_file)] + ) + assert result2.exit_code == 0 + assert "already configured" in result2.output + + def test_setup_no_config_file_error(self, tmp_path): + """Test error when no OpenCode config file exists""" + from click.testing import CliRunner + from sugar.main import cli + + runner = CliRunner() + # Use non-existent config file path + result = runner.invoke( + cli, + ["opencode", "setup", "--config", str(tmp_path / "nonexistent.json")], + ) + + assert result.exit_code == 1 + assert "Could not find OpenCode config file" in result.output + + def test_setup_no_memory_flag(self, tmp_path): + """Test --no-memory flag excludes memory server""" + from click.testing import CliRunner + from sugar.main import cli + import json + + # Create a mock OpenCode config + config_file = tmp_path / "opencode.json" + config_file.write_text('{"$schema": "https://opencode.ai/config.json"}') + + runner = CliRunner() + result = runner.invoke( + cli, + ["opencode", "setup", "--yes", "--no-memory", "--config", str(config_file)], + ) + + assert result.exit_code == 0 + + updated_config = json.loads(config_file.read_text()) + assert "sugar-tasks" in updated_config["mcp"] + assert "sugar-memory" not in updated_config["mcp"] + + def test_setup_no_tasks_flag(self, tmp_path): + """Test --no-tasks flag excludes tasks server""" + from click.testing import CliRunner + from sugar.main import cli + import json + + # Create a mock OpenCode config + config_file = tmp_path / "opencode.json" + config_file.write_text('{"$schema": "https://opencode.ai/config.json"}') + + runner = CliRunner() + result = runner.invoke( + cli, + ["opencode", "setup", "--yes", "--no-tasks", "--config", str(config_file)], + ) + + assert result.exit_code == 0 + + updated_config = json.loads(config_file.read_text()) + assert "sugar-tasks" not in updated_config["mcp"] + assert "sugar-memory" in updated_config["mcp"] + + def test_setup_malformed_json_error(self, tmp_path): + """Test error handling for malformed JSON config""" + from click.testing import CliRunner + from sugar.main import cli + + # Create a malformed config + config_file = tmp_path / "opencode.json" + config_file.write_text("{ invalid json }") + + runner = CliRunner() + result = runner.invoke(cli, ["opencode", "setup", "--config", str(config_file)]) + + assert result.exit_code == 1 + assert "Failed to parse config file" in result.output + + def test_setup_parses_jsonc(self, tmp_path): + """Test that setup can parse JSONC (JSON with comments)""" + from click.testing import CliRunner + from sugar.main import cli + import json + + # Create a JSONC config with comments + config_file = tmp_path / "opencode.json" + jsonc_content = """{ + // This is a comment + "$schema": "https://opencode.ai/config.json", + "plugin": ["test"] // trailing comment +}""" + config_file.write_text(jsonc_content) + + runner = CliRunner() + result = runner.invoke( + cli, ["opencode", "setup", "--yes", "--config", str(config_file)] + ) + + assert result.exit_code == 0 + assert "Config updated" in result.output + + def test_setup_custom_config_path(self, tmp_path): + """Test --config option for custom config path""" + from click.testing import CliRunner + from sugar.main import cli + import json + + # Create config in non-standard location + custom_config = tmp_path / "my-opencode-config.json" + custom_config.write_text('{"$schema": "https://opencode.ai/config.json"}') + + runner = CliRunner() + result = runner.invoke( + cli, ["opencode", "setup", "--yes", "--config", str(custom_config)] + ) + + assert result.exit_code == 0 + + updated_config = json.loads(custom_config.read_text()) + assert "sugar-tasks" in updated_config["mcp"] diff --git a/tests/test_storage.py b/tests/test_storage.py index 1feaa18..8b6d701 100644 --- a/tests/test_storage.py +++ b/tests/test_storage.py @@ -470,7 +470,8 @@ async def test_migration_adds_timing_columns(self, temp_dir): import aiosqlite async with aiosqlite.connect(str(db_path)) as db: - await db.execute(""" + await db.execute( + """ CREATE TABLE work_items ( id TEXT PRIMARY KEY, type TEXT NOT NULL, @@ -489,7 +490,8 @@ async def test_migration_adds_timing_columns(self, temp_dir): result TEXT, error_message TEXT ) - """) + """ + ) await db.commit() # Initialize WorkQueue (should trigger migration) diff --git a/uv.lock b/uv.lock index eac5fc2..4f2de8c 100644 --- a/uv.lock +++ b/uv.lock @@ -2725,7 +2725,7 @@ wheels = [ [[package]] name = "sugarai" -version = "3.5.1.dev0" +version = "3.7.0.dev0" source = { editable = "." } dependencies = [ { name = "aiosqlite" },