From dad6ad0ad9704964ea2d0b1907e2801aed6430d4 Mon Sep 17 00:00:00 2001 From: Steven Leggett Date: Tue, 20 Jan 2026 19:21:49 -0500 Subject: [PATCH 1/6] Add Memory System for persistent semantic memory (v3.5) - Add sugar/memory module with store, embedder, retriever, types - Add CLI commands: remember, recall, memories, forget, export-context, memory-stats - Add MCP memory server for Claude Code integration - Support semantic search with sentence-transformers (optional) - Fallback to FTS5 keyword search when embeddings unavailable - Add optional dependencies: memory, all extras in pyproject.toml - Add 24 comprehensive tests for memory functionality - Update README, CHANGELOG, and CLI reference documentation --- CHANGELOG.md | 86 +++++ README.md | 70 +++- docs/README.md | 1 + docs/user/cli-reference.md | 199 ++++++++++++ docs/user/memory.md | 436 +++++++++++++++++++++++++ pyproject.toml | 7 + server.json | 23 +- sugar/main.py | 640 ++++++++++++++++++++++++++++++++++++- sugar/mcp/__init__.py | 20 +- sugar/mcp/memory_server.py | 328 +++++++++++++++++++ sugar/memory/__init__.py | 34 ++ sugar/memory/embedder.py | 136 ++++++++ sugar/memory/retriever.py | 226 +++++++++++++ sugar/memory/store.py | 538 +++++++++++++++++++++++++++++++ sugar/memory/types.py | 111 +++++++ tests/test_memory.py | 623 ++++++++++++++++++++++++++++++++++++ 16 files changed, 3452 insertions(+), 26 deletions(-) create mode 100644 docs/user/memory.md create mode 100644 sugar/mcp/memory_server.py create mode 100644 sugar/memory/__init__.py create mode 100644 sugar/memory/embedder.py create mode 100644 sugar/memory/retriever.py create mode 100644 sugar/memory/store.py create mode 100644 sugar/memory/types.py create mode 100644 tests/test_memory.py diff --git a/CHANGELOG.md b/CHANGELOG.md index df1f8b4..a860475 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,92 @@ 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 + +### 🧠 MINOR RELEASE: Memory System + +Sugar now has persistent semantic memory! Remember decisions, preferences, error patterns, and more across sessions. Integrates with Claude Code via MCP server for seamless context sharing. + +### Added + +#### Memory System (`sugar/memory/`) +- **MemoryStore**: SQLite-backed storage with vector search support + - Semantic search using sentence-transformers embeddings + - FTS5 keyword search fallback when embeddings unavailable + - sqlite-vec integration for fast vector similarity +- **Memory Types**: Six memory categories for different kinds of information + - `decision` - Architectural and implementation decisions + - `preference` - User coding preferences (permanent) + - `file_context` - What files do what + - `error_pattern` - Bug patterns and their fixes + - `research` - API docs, library findings + - `outcome` - Task outcomes and learnings +- **MemoryRetriever**: Context formatting for prompt injection +- **Embedder**: SentenceTransformer embeddings with graceful fallback + +#### New CLI Commands +- `sugar remember "content"` - Store a memory with type, tags, TTL, importance +- `sugar recall "query"` - Search memories with semantic/keyword matching +- `sugar memories` - List memories with filtering by type, age +- `sugar forget ` - Delete a memory by ID +- `sugar export-context` - Export memories for Claude Code SessionStart hook +- `sugar memory-stats` - Show memory system statistics +- `sugar mcp memory` - Run MCP server for Claude Code integration + +#### MCP Server for Claude Code +- **search_memory** - Semantic search over project memories +- **store_learning** - Store new observations/decisions from Claude +- **get_project_context** - Organized project context summary +- **recall** - Formatted markdown context for prompts +- **list_recent_memories** - List with type filtering +- **Resources**: `sugar://project/context`, `sugar://preferences` + +#### Claude Code Integration +- **SessionStart Hook**: Auto-inject context via `sugar export-context` +- **MCP Server**: Full memory access via `claude mcp add sugar -- sugar mcp memory` +- **Bidirectional**: Claude can both read and write memories + +### Configuration + +New optional dependency group: +```bash +pip install 'sugarai[memory]' # Enables semantic search +pip install 'sugarai[all]' # All features +``` + +Memory works without dependencies (uses FTS5 keyword search), but semantic search requires: +- `sentence-transformers>=2.2.0` +- `sqlite-vec>=0.1.0` + +### Usage Examples + +```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 +sugar remember "payment_processor.py handles Stripe webhooks" --type file_context + +# Search memories +sugar recall "how do we handle authentication" +sugar recall "database errors" --type error_pattern --limit 5 + +# Claude Code integration +claude mcp add sugar -- sugar mcp memory +``` + +### Documentation +- New [Memory System Guide](docs/user/memory.md) +- Updated README with memory commands and MCP integration +- Updated CLI reference with all memory commands + +### Technical Details +- 24 new tests for memory module +- Full backwards compatibility - memory is opt-in +- Database stored at `.sugar/memory.db` per project +- Embeddings use all-MiniLM-L6-v2 (384 dimensions) + +--- + ## [3.4.4] - 2026-01-10 ### πŸ”„ MINOR RELEASE: Agent-Agnostic Rebranding diff --git a/README.md b/README.md index 9b1abb7..e659cbb 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,16 @@ uv pip install sugarai pipx install 'sugarai[github]' ``` +**With memory system (semantic search):** +```bash +pipx install 'sugarai[memory]' +``` + +**All features:** +```bash +pipx install 'sugarai[all]' +``` + ## Quick Start @@ -203,7 +213,13 @@ With pipx, Sugar's dependencies don't conflict with your project's dependencies. - Self-correcting loops until tests pass - Prevents single-shot failures -**Full docs:** [docs/ralph-wiggum.md](docs/ralph-wiggum.md) +**Memory System** *(New in 3.5)* +- Persistent semantic memory across sessions +- Remember decisions, preferences, error patterns +- Claude Code integration via MCP server +- `sugar remember` / `sugar recall` commands + +**Full docs:** [docs/ralph-wiggum.md](docs/ralph-wiggum.md) | [Memory System](docs/user/memory.md) ## Configuration @@ -254,7 +270,28 @@ Claude: "I'll create a Sugar task for the test fixes." ### MCP Server Integration -Sugar provides an MCP server for Goose, Claude Desktop, and other MCP clients. +Sugar provides MCP servers for Goose, Claude Code, Claude Desktop, and other MCP clients. + +**Using with Claude Code (Memory):** +```bash +# Add Sugar memory to Claude Code +claude mcp add sugar -- sugar mcp memory +``` + +Or add to `~/.claude.json`: +```json +{ + "mcpServers": { + "sugar": { + "type": "stdio", + "command": "sugar", + "args": ["mcp", "memory"] + } + } +} +``` + +This gives Claude Code access to your project's memory - decisions, preferences, error patterns, and more. **Using with Goose:** ```bash @@ -279,6 +316,32 @@ goose configure } ``` +### Memory System + +Sugar's memory system provides persistent context across sessions: + +```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 +``` + +**Memory types:** `decision`, `preference`, `file_context`, `error_pattern`, `research`, `outcome` + +**Full docs:** [Memory System Guide](docs/user/memory.md) + ## Advanced Usage **Task Orchestration** @@ -342,6 +405,7 @@ sugar run --once # Test single cycle - [Quick Start](docs/user/quick-start.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) - [GitHub Integration](docs/user/github-integration.md) @@ -376,6 +440,6 @@ pytest tests/ -v --- -**Sugar v3.4** - The autonomous layer for AI coding agents +**Sugar v3.5** - The autonomous layer for AI coding agents > ⚠️ 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 6a6a991..9f438f3 100644 --- a/docs/README.md +++ b/docs/README.md @@ -11,6 +11,7 @@ Documentation for developers who want to **use** Sugar in their projects: - **[Quick Start Guide](user/quick-start.md)** - Get up and running in 5 minutes - **[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 e3e3a4c..073931b 100644 --- a/docs/user/cli-reference.md +++ b/docs/user/cli-reference.md @@ -731,6 +731,205 @@ See [Thinking Capture Guide](../thinking-capture.md) for full documentation. --- +## Memory Commands + +Sugar's memory system provides persistent semantic memory across coding sessions. + +### `sugar remember` + +Store a memory for future reference. + +```bash +sugar remember "CONTENT" [OPTIONS] +``` + +**Options:** +- `--type TYPE` - Memory type: `decision`, `preference`, `research`, `file_context`, `error_pattern`, `outcome` (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) + +**Examples:** +```bash +# Store a preference +sugar remember "Always use async/await, never callbacks" --type preference + +# Decision with tags +sugar remember "Chose JWT with RS256 for auth" --type decision --tags "auth,security" + +# Research with expiration +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 +``` + +--- + +### `sugar recall` + +Search memories for relevant context. + +```bash +sugar recall "QUERY" [OPTIONS] +``` + +**Options:** +- `--type TYPE` - Filter by memory type (or `all`) +- `--limit INTEGER` - Maximum results (default: 10) +- `--format FORMAT` - Output format: `table`, `json`, `full` (default: `table`) + +**Examples:** +```bash +# Basic search +sugar recall "authentication" + +# Filter by type +sugar recall "database errors" --type error_pattern + +# JSON output +sugar recall "stripe" --format json + +# Full details +sugar recall "architecture" --format full --limit 5 +``` + +--- + +### `sugar memories` + +List all stored memories. + +```bash +sugar memories [OPTIONS] +``` + +**Options:** +- `--type TYPE` - Filter by memory type (or `all`) +- `--since TEXT` - Filter by age (e.g., `7d`, `30d`, `2w`) +- `--limit INTEGER` - Maximum results (default: 50) +- `--format FORMAT` - Output format: `table`, `json` + +**Examples:** +```bash +# List all +sugar memories + +# Recent decisions +sugar memories --type decision --since 7d + +# Export to JSON +sugar memories --format json > backup.json +``` + +--- + +### `sugar forget` + +Delete a memory by ID. + +```bash +sugar forget MEMORY_ID [OPTIONS] +``` + +**Options:** +- `--force` - Skip confirmation prompt + +**Examples:** +```bash +# Interactive deletion +sugar forget abc123 + +# Force delete +sugar forget abc123 --force +``` + +--- + +### `sugar export-context` + +Export memories for Claude Code integration. + +```bash +sugar export-context [OPTIONS] +``` + +**Options:** +- `--format FORMAT` - Output format: `markdown`, `json`, `claude` (default: `markdown`) +- `--limit INTEGER` - Max memories per type (default: 10) +- `--types TEXT` - Comma-separated types to include (default: `decision,preference,error_pattern`) + +**Use Cases:** +- **SessionStart Hook**: Auto-inject context into Claude Code sessions +- **Backup**: Export memories for external storage +- **Sharing**: Share context across team members + +**Claude Code Hook Configuration:** +```json +{ + "hooks": { + "SessionStart": [{ + "matcher": "", + "hooks": [{ + "type": "command", + "command": "sugar export-context" + }] + }] + } +} +``` + +--- + +### `sugar memory-stats` + +Show memory system statistics. + +```bash +sugar memory-stats +``` + +**Output includes:** +- Semantic search availability +- Database path and size +- Total memory count +- Count by memory type + +--- + +### `sugar mcp memory` + +Start the Sugar Memory MCP server for Claude Code integration. + +```bash +sugar mcp memory [OPTIONS] +``` + +**Options:** +- `--transport TEXT` - Transport protocol: `stdio` (default: `stdio`) + +**Claude Code Integration:** +```bash +# Add Sugar memory to Claude Code +claude mcp add sugar -- sugar mcp memory +``` + +**MCP Tools Exposed:** +- `search_memory` - Semantic search over memories +- `store_learning` - Store new observations/decisions +- `get_project_context` - Get organized project summary +- `recall` - Get formatted markdown context +- `list_recent_memories` - List with type filtering + +**MCP Resources:** +- `sugar://project/context` - Full project context +- `sugar://preferences` - User coding preferences + +See [Memory System Guide](memory.md) for full documentation. + +--- + ### `sugar task-type` Manage custom task types for your project. diff --git a/docs/user/memory.md b/docs/user/memory.md new file mode 100644 index 0000000..a41b574 --- /dev/null +++ b/docs/user/memory.md @@ -0,0 +1,436 @@ +# Sugar Memory System + +Sugar's memory system provides persistent semantic memory across coding sessions. Store decisions, preferences, error patterns, and research findings - then recall them when relevant. + +## Overview + +The memory system solves a key problem with AI coding assistants: **context loss between sessions**. Every time you start a new session, you lose: +- Decisions you've made about architecture +- Your coding preferences and style +- Error patterns you've encountered and fixed +- Research you've done on APIs and libraries + +Sugar Memory persists this knowledge and makes it searchable. + +## Quick Start + +```bash +# Install with memory support (enables semantic search) +pipx install 'sugarai[memory]' + +# Store a preference +sugar remember "Always use async/await, never callbacks" --type preference + +# Store a decision +sugar remember "Chose JWT with RS256 for auth tokens" --type decision + +# Search memories +sugar recall "authentication" + +# View all memories +sugar memories +``` + +## Memory Types + +Sugar organizes memories into six categories: + +| Type | Description | TTL Default | Example | +|------|-------------|-------------|---------| +| `decision` | Architecture/implementation choices | Never | "Using PostgreSQL for main DB" | +| `preference` | Coding style and conventions | Never | "Prefer early returns over nested if" | +| `file_context` | What files/modules do | Never | "payment_processor.py handles Stripe" | +| `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%" | + +## CLI Commands + +### `sugar remember` + +Store a new memory. + +```bash +sugar remember "content" [options] + +Options: + --type TYPE Memory type (decision, preference, research, 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) +``` + +**Examples:** + +```bash +# Basic preference +sugar remember "Use 4-space indentation for Python" + +# Decision with tags +sugar remember "Chose Redis for session storage" --type decision --tags "architecture,redis" + +# Research with expiration +sugar remember "Stripe API rate limit is 100/sec" --type research --ttl 90d + +# File context +sugar remember "handles OAuth callback flow" --type file_context --file src/auth/callback.py + +# High importance +sugar remember "NEVER use eval() for security reasons" --type preference --importance 2.0 +``` + +### `sugar recall` + +Search memories for relevant context. + +```bash +sugar recall "query" [options] + +Options: + --type TYPE Filter by memory type (or "all") + --limit NUM Maximum results (default: 10) + --format FORMAT Output: table, json, full (default: table) +``` + +**Examples:** + +```bash +# Basic search +sugar recall "authentication" + +# Filter by type +sugar recall "database errors" --type error_pattern + +# JSON output for scripting +sugar recall "stripe" --format json + +# Full details +sugar recall "architecture decisions" --format full --limit 5 +``` + +### `sugar memories` + +List all stored memories. + +```bash +sugar memories [options] + +Options: + --type TYPE Filter by memory type (or "all") + --since DURATION Filter by age (e.g., 7d, 30d, 2w) + --limit NUM Maximum results (default: 50) + --format FORMAT Output: table, json +``` + +**Examples:** + +```bash +# List all +sugar memories + +# Recent decisions +sugar memories --type decision --since 7d + +# JSON export +sugar memories --format json > memories-backup.json +``` + +### `sugar forget` + +Delete a memory by ID. + +```bash +sugar forget [options] + +Options: + --force Skip confirmation prompt +``` + +**Examples:** + +```bash +# Interactive (shows confirmation) +sugar forget abc123 + +# Force delete +sugar forget abc123 --force +``` + +### `sugar export-context` + +Export memories for Claude Code integration. + +```bash +sugar export-context [options] + +Options: + --format FORMAT Output: markdown, json, claude (default: markdown) + --limit NUM Max memories per type (default: 10) + --types TYPES Comma-separated types to include +``` + +**Examples:** + +```bash +# Default markdown +sugar export-context + +# JSON for programmatic use +sugar export-context --format json + +# Specific types only +sugar export-context --types preference,decision +``` + +### `sugar memory-stats` + +Show memory system statistics. + +```bash +sugar memory-stats +``` + +Output: +``` +πŸ“Š Sugar Memory Statistics + +Semantic search: βœ… Available +Database: /Users/steve/project/.sugar/memory.db + +Total memories: 47 + +By type: + preference 12 + decision 8 + error_pattern 6 + research 15 + file_context 6 + +Database size: 156.2 KB +``` + +## Claude Code Integration + +Sugar Memory integrates with Claude Code in two ways: + +### 1. MCP Server (Recommended) + +Add Sugar as an MCP server to give Claude Code full access to your memory: + +```bash +claude mcp add sugar -- sugar mcp memory +``` + +Or add manually to `~/.claude.json`: +```json +{ + "mcpServers": { + "sugar": { + "type": "stdio", + "command": "sugar", + "args": ["mcp", "memory"] + } + } +} +``` + +**MCP Tools Available:** + +| Tool | Description | +|------|-------------| +| `search_memory` | Semantic search over memories | +| `store_learning` | Store new observations/decisions | +| `get_project_context` | Get organized project summary | +| `recall` | Get formatted markdown context | +| `list_recent_memories` | List with optional type filter | + +**MCP Resources:** + +| Resource | Description | +|----------|-------------| +| `sugar://project/context` | Full project context | +| `sugar://preferences` | User coding preferences | + +### 2. SessionStart Hook + +Automatically inject context at the start of every Claude Code session: + +Add to `~/.claude/settings.json`: +```json +{ + "hooks": { + "SessionStart": [{ + "matcher": "", + "hooks": [{ + "type": "command", + "command": "sugar export-context" + }] + }] + } +} +``` + +This runs `sugar export-context` at the start of each session, providing Claude with your stored preferences and recent decisions. + +## Search: Semantic vs Keyword + +Sugar supports two search modes: + +### Semantic Search (Recommended) + +Uses AI embeddings to find conceptually similar memories: +- "auth issues" finds memories about "authentication", "login", "JWT" +- Understands synonyms and related concepts +- Requires `sentence-transformers` package + +**Enable:** +```bash +pipx install 'sugarai[memory]' +``` + +### Keyword Search (Fallback) + +Uses SQLite FTS5 for text matching: +- Fast and lightweight +- No additional dependencies +- Matches exact words/phrases + +Sugar automatically uses semantic search when available, falling back to keyword search otherwise. + +## Storage & Data + +### Location + +Memories are stored per-project: +``` +.sugar/ +β”œβ”€β”€ config.yaml +β”œβ”€β”€ sugar.db # Task queue +└── memory.db # Memory database +``` + +### Schema + +Each memory stores: +- `id` - Unique identifier +- `memory_type` - Category (decision, preference, etc.) +- `content` - The actual memory text +- `summary` - Optional short summary +- `metadata` - Tags, file paths, custom data +- `importance` - 0.0-2.0 score for ranking +- `created_at` - When stored +- `last_accessed_at` - Last search hit +- `access_count` - Number of times recalled +- `expires_at` - Optional expiration date + +### Backup + +```bash +# Export all memories to JSON +sugar memories --format json > memories-backup.json + +# Copy database directly +cp .sugar/memory.db memory-backup.db +``` + +## Best Practices + +### What to Remember + +**Good memories:** +- Architecture decisions and their rationale +- Coding conventions specific to the project +- Error patterns you've debugged +- API quirks and workarounds +- File/module responsibilities + +**Less useful:** +- Generic programming knowledge (Claude already knows) +- Highly volatile information +- Large code blocks (use file context instead) + +### Memory Hygiene + +```bash +# Review old memories periodically +sugar memories --since 90d + +# Clean up irrelevant entries +sugar forget + +# Check stats +sugar memory-stats +``` + +### Organizing with Tags + +```bash +# Group related memories +sugar remember "Use Redis cluster for sessions" --type decision --tags "architecture,redis,sessions" +sugar remember "Redis connection pool size: 20" --type decision --tags "architecture,redis,performance" + +# Search by implied topics +sugar recall "redis configuration" +``` + +## Troubleshooting + +### "Memory dependencies not installed" + +```bash +pip install 'sugarai[memory]' +``` + +### "Semantic search not available" + +Sentence-transformers failed to load. Check: +```bash +python -c "from sentence_transformers import SentenceTransformer; print('OK')" +``` + +Memory still works with keyword search. + +### "Not a Sugar project" + +Run from a directory with `.sugar/` folder: +```bash +sugar init # If not initialized +``` + +### Slow first search + +The embedding model loads on first use (~2-3 seconds). Subsequent searches are fast. + +## API Reference + +For programmatic access, import from `sugar.memory`: + +```python +from sugar.memory import ( + MemoryStore, + MemoryEntry, + MemoryType, + MemoryQuery, + MemoryRetriever, +) + +# Create store +store = MemoryStore("/path/to/memory.db") + +# Store a memory +entry = MemoryEntry( + id="my-id", + memory_type=MemoryType.DECISION, + content="Use PostgreSQL for main database", +) +store.store(entry) + +# Search +query = MemoryQuery(query="database", limit=5) +results = store.search(query) + +# Get context +retriever = MemoryRetriever(store) +context = retriever.get_project_context() +``` diff --git a/pyproject.toml b/pyproject.toml index a64d824..4594239 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -65,6 +65,13 @@ mcp = [ "starlette>=0.27.0", "uvicorn>=0.22.0", ] +memory = [ + "sentence-transformers>=2.2.0", + "sqlite-vec>=0.1.0", +] +all = [ + "sugarai[github,mcp,memory]", +] [project.scripts] sugar = "sugar.main:cli" diff --git a/server.json b/server.json index c17fccc..784092b 100644 --- a/server.json +++ b/server.json @@ -2,18 +2,35 @@ "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json", "name": "io.github.cdnsteve/sugar", "title": "Sugar", - "description": "Autonomous AI development system with persistent task queue and background execution", + "description": "Autonomous AI development system with persistent task queue, background execution, and semantic memory", "repository": { "url": "https://github.com/roboticforce/sugar", "source": "github" }, - "version": "3.4.3.dev0", + "version": "3.5.0.dev0", "websiteUrl": "https://github.com/roboticforce/sugar", "packages": [ { "registryType": "pypi", "identifier": "sugarai", - "version": "3.4.3.dev0", + "version": "3.5.0.dev0", + "name": "sugar-memory", + "description": "Sugar Memory MCP Server - semantic memory for AI coding sessions", + "packageArguments": [ + {"type": "positional", "value": "mcp"}, + {"type": "positional", "value": "memory"} + ], + "transport": { + "type": "stdio" + }, + "environmentVariables": [] + }, + { + "registryType": "pypi", + "identifier": "sugarai", + "version": "3.5.0.dev0", + "name": "sugar-tasks", + "description": "Sugar Task Queue MCP Server - autonomous task management", "packageArguments": [ {"type": "positional", "value": "mcp"}, {"type": "positional", "value": "serve"} diff --git a/sugar/main.py b/sugar/main.py index dc9f2e2..6e4cecb 100644 --- a/sugar/main.py +++ b/sugar/main.py @@ -7,12 +7,13 @@ import logging import signal import sys +from datetime import datetime, timezone from pathlib import Path + import click -from datetime import datetime, timezone +from .__version__ import __version__, get_version_info from .core.loop import SugarLoop -from .__version__ import get_version_info, __version__ def validate_task_type(ctx, param, value): @@ -21,9 +22,11 @@ def validate_task_type(ctx, param, value): return value try: + import asyncio + import yaml + from .storage.task_type_manager import TaskTypeManager - import asyncio # Get config file path from context config_file = ( @@ -137,9 +140,10 @@ def _require_sugar_project(config_file: str) -> dict: Raises: SystemExit: If config file not found or invalid """ - import yaml from pathlib import Path + import yaml + config_path = Path(config_file) if not config_path.exists(): @@ -258,8 +262,8 @@ def cli(ctx, config, debug, version): ) def init(project_dir): """Initialize Sugar in a project directory""" - import shutil import json + import shutil project_path = Path(project_dir).resolve() sugar_dir = project_path / ".sugar" @@ -467,9 +471,10 @@ def add( description = f"Task: {title}" # Import here to avoid circular imports - from .storage.work_queue import WorkQueue import uuid + from .storage.work_queue import WorkQueue + # Load config (exits with friendly error if not a Sugar project) config_file = ctx.obj["config"] config = _require_sugar_project(config_file) @@ -1072,9 +1077,10 @@ def update(ctx, task_id, title, description, priority, task_type, status): def priority(ctx, task_id, priority, urgent, high, normal, low, minimal): """Change the priority of a task""" - from .storage.work_queue import WorkQueue import yaml + from .storage.work_queue import WorkQueue + # Count how many priority options were specified priority_flags = [urgent, high, normal, low, minimal] flag_count = sum(priority_flags) @@ -1336,9 +1342,10 @@ async def show_orchestration(): @click.pass_context def context(ctx, task_id): """View accumulated context for an orchestrated task.""" - from .storage.work_queue import WorkQueue from pathlib import Path + from .storage.work_queue import WorkQueue + config_file = ctx.obj["config"] config = _require_sugar_project(config_file) @@ -1476,10 +1483,11 @@ def thinking(ctx, task_id, list_logs, stats): sugar thinking TASK_ID --stats # View thinking statistics sugar thinking --list # List all thinking logs """ - from .executor.thinking_display import read_thinking_log, list_thinking_logs - from .storage.work_queue import WorkQueue import yaml + from .executor.thinking_display import list_thinking_logs, read_thinking_log + from .storage.work_queue import WorkQueue + if list_logs: # List all available thinking logs logs = list_thinking_logs() @@ -1631,8 +1639,8 @@ def learnings(ctx, lines, sessions, clear, refresh): sugar learnings --refresh # Generate new insights and save sugar learnings --clear # Clear log (creates backup) """ - from .learning.learnings_writer import LearningsWriter from .learning.feedback_processor import FeedbackProcessor + from .learning.learnings_writer import LearningsWriter from .storage.work_queue import WorkQueue config_file = ctx.obj["config"] @@ -2112,8 +2120,8 @@ async def run_continuous(sugar_loop): shutdown_event = asyncio.Event() # Create PID file for stop command - import pathlib import os + import pathlib config_dir = pathlib.Path( sugar_loop.config.get("sugar", {}) @@ -2211,8 +2219,8 @@ async def _update_task_async(work_queue, task_id, updates): def _detect_github_config(project_path: Path) -> dict: """Detect GitHub CLI availability and current repository configuration""" - import subprocess import os + import subprocess github_config = { "detected": True, # Mark that detection was attempted @@ -2689,13 +2697,14 @@ def debug(ctx, format, output, include_sensitive): This command outputs system state, configuration, and recent activity to help diagnose issues. Safe by default - excludes sensitive information. """ - import yaml + import json import platform import subprocess - import json from datetime import datetime, timedelta from pathlib import Path + import yaml + config_file = ctx.obj["config"] config = _require_sugar_project(config_file) @@ -2974,6 +2983,7 @@ async def generate_diagnostic(): def dedupe(ctx, dry_run): """Remove duplicate work items based on source_file""" import aiosqlite + from .storage.work_queue import WorkQueue config_file = ctx.obj["config"] @@ -3048,6 +3058,7 @@ async def _dedupe_work(): def cleanup(ctx, dry_run): """Remove bogus work items (Sugar initialization tests, venv files, etc.)""" import aiosqlite + from .storage.work_queue import WorkQueue config_file = ctx.obj["config"] @@ -3644,9 +3655,9 @@ def issue_respond( Use --post to actually post if confidence is high enough. Use --force-post to post regardless of confidence. """ + from .agent import SugarAgent, SugarAgentConfig from .integrations.github import GitHubClient from .profiles import IssueResponderProfile - from .agent import SugarAgent, SugarAgentConfig async def _respond(): client = GitHubClient(repo=repo) @@ -3789,6 +3800,555 @@ def issue_search(ctx, query, repo, limit): sys.exit(1) +# ============================================================================= +# Memory Commands +# ============================================================================= + + +def _parse_ttl(ttl_str: str) -> datetime: + """Parse TTL string (30d, 90d, 1y, never) to expiration datetime.""" + from datetime import timedelta + + if ttl_str.lower() == "never": + return None + + value = int(ttl_str[:-1]) + unit = ttl_str[-1].lower() + + if unit == "d": + delta = timedelta(days=value) + elif unit == "w": + delta = timedelta(weeks=value) + elif unit == "m": + delta = timedelta(days=value * 30) + elif unit == "y": + delta = timedelta(days=value * 365) + else: + raise ValueError(f"Invalid TTL unit: {unit}. Use d, w, m, or y.") + + return datetime.now(timezone.utc) + delta + + +def _get_memory_store(config: dict): + """Get memory store from config, initializing if needed.""" + from .memory import MemoryStore + + sugar_dir = Path(config["sugar"]["storage"]["database"]).parent + memory_db = sugar_dir / "memory.db" + return MemoryStore(str(memory_db)) + + +@cli.command() +@click.argument("content") +@click.option( + "--type", + "memory_type", + type=click.Choice(["decision", "preference", "research", "file_context", "error_pattern", "outcome"]), + default="decision", + help="Type of memory", +) +@click.option("--tags", help="Comma-separated tags") +@click.option("--file", "file_path", help="Associate with a specific file") +@click.option( + "--ttl", + default="never", + help="Time to live: 30d, 90d, 1y, never (default: never)", +) +@click.option("--importance", type=float, default=1.0, help="Importance score (0.0-2.0)") +@click.pass_context +def remember(ctx, content, memory_type, tags, file_path, ttl, importance): + """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 + """ + import uuid + + from .memory import MemoryEntry, MemoryStore, MemoryType + + config_file = ctx.obj["config"] + config = _require_sugar_project(config_file) + + try: + store = _get_memory_store(config) + + # Parse TTL + expires_at = None + if ttl.lower() != "never": + try: + expires_at = _parse_ttl(ttl) + except ValueError as e: + click.echo(f"❌ Invalid TTL: {e}", err=True) + sys.exit(1) + + # Build metadata + metadata = {} + if tags: + metadata["tags"] = [t.strip() for t in tags.split(",")] + if file_path: + metadata["file_paths"] = [file_path] + + # Create entry + entry = MemoryEntry( + id=str(uuid.uuid4()), + memory_type=MemoryType(memory_type), + content=content, + summary=content[:100] if len(content) > 100 else None, + metadata=metadata, + importance=importance, + expires_at=expires_at, + ) + + entry_id = store.store(entry) + store.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}") + if expires_at: + click.echo(f" Expires: {expires_at.strftime('%Y-%m-%d')}") + + except ImportError as e: + click.echo( + "❌ Memory dependencies not installed. Install with:\n" + " pip install 'sugarai[memory]'", + err=True, + ) + click.echo(f"\nMissing: {e}", err=True) + sys.exit(1) + except Exception as e: + click.echo(f"❌ Error storing memory: {e}", err=True) + sys.exit(1) + + +@cli.command() +@click.argument("query") +@click.option( + "--type", + "memory_type", + type=click.Choice(["decision", "preference", "research", "file_context", "error_pattern", "outcome", "all"]), + default="all", + help="Filter by memory type", +) +@click.option("--limit", default=10, type=int, help="Maximum results") +@click.option( + "--format", + "output_format", + type=click.Choice(["table", "json", "full"]), + default="table", + help="Output format", +) +@click.pass_context +def recall(ctx, query, memory_type, limit, output_format): + """Search memories for relevant context + + 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 + + config_file = ctx.obj["config"] + config = _require_sugar_project(config_file) + + try: + store = _get_memory_store(config) + + # Build query + memory_types = None + if memory_type != "all": + memory_types = [MemoryType(memory_type)] + + search_query = MemoryQuery( + query=query, + memory_types=memory_types, + limit=limit, + ) + + results = store.search(search_query) + store.close() + + if not results: + click.echo(f"No memories found matching: {query}") + return + + if output_format == "json": + import json + output = [ + { + "id": r.entry.id, + "content": r.entry.content, + "type": r.entry.memory_type.value, + "score": round(r.score, 3), + "created_at": r.entry.created_at.isoformat() if r.entry.created_at else None, + } + for r in results + ] + click.echo(json.dumps(output, indent=2)) + elif output_format == "full": + for i, r in enumerate(results, 1): + click.echo(f"\n{'='*60}") + click.echo(f"[{i}] {r.entry.memory_type.value.upper()} (score: {r.score:.2f})") + click.echo(f"ID: {r.entry.id}") + click.echo(f"Created: {r.entry.created_at.strftime('%Y-%m-%d %H:%M') if r.entry.created_at else 'unknown'}") + click.echo(f"\n{r.entry.content}") + if r.entry.metadata.get("tags"): + click.echo(f"\nTags: {', '.join(r.entry.metadata['tags'])}") + if r.entry.metadata.get("file_paths"): + 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) + for r in results: + content = r.entry.content[:52] + "..." if len(r.entry.content) > 55 else r.entry.content + content = content.replace("\n", " ") + click.echo(f"{r.score:.2f} {r.entry.memory_type.value:<15} {content:<55}") + click.echo(f"\n{len(results)} memories found ({r.match_type} search)") + + except ImportError as e: + click.echo( + "❌ Memory dependencies not installed. Install with:\n" + " pip install 'sugarai[memory]'", + err=True, + ) + click.echo(f"\nMissing: {e}", err=True) + sys.exit(1) + except Exception as e: + click.echo(f"❌ Error searching memories: {e}", err=True) + sys.exit(1) + + +@cli.command() +@click.option( + "--type", + "memory_type", + type=click.Choice(["decision", "preference", "research", "file_context", "error_pattern", "outcome", "all"]), + default="all", + help="Filter by memory type", +) +@click.option("--since", help="Filter by age (e.g., 7d, 30d)") +@click.option("--limit", default=50, type=int, help="Maximum results") +@click.option( + "--format", + "output_format", + type=click.Choice(["table", "json"]), + default="table", + help="Output format", +) +@click.pass_context +def memories(ctx, memory_type, since, limit, output_format): + """List stored memories + + Examples: + sugar memories + sugar memories --type preference + sugar memories --since 7d --format json + """ + from .memory import MemoryStore, MemoryType + + config_file = ctx.obj["config"] + config = _require_sugar_project(config_file) + + try: + store = _get_memory_store(config) + + # Parse since filter + since_days = None + if since: + value = int(since[:-1]) + unit = since[-1].lower() + if unit == "d": + since_days = value + elif unit == "w": + since_days = value * 7 + elif unit == "m": + since_days = value * 30 + else: + click.echo(f"❌ Invalid time format: {since}. Use format like 7d, 2w, 1m", err=True) + sys.exit(1) + + # Get memories + type_filter = None if memory_type == "all" else MemoryType(memory_type) + entries = store.list_memories( + memory_type=type_filter, + limit=limit, + since_days=since_days, + ) + store.close() + + if not entries: + click.echo("No memories found") + return + + if output_format == "json": + import json + output = [e.to_dict() for e in entries] + 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 + 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}") + click.echo(f"\n{len(entries)} memories") + + # Show counts by type + type_counts = {} + for e in entries: + t = e.memory_type.value + type_counts[t] = type_counts.get(t, 0) + 1 + if len(type_counts) > 1: + counts_str = ", ".join(f"{k}: {v}" for k, v in type_counts.items()) + click.echo(f"By type: {counts_str}") + + except ImportError as e: + click.echo( + "❌ Memory dependencies not installed. Install with:\n" + " pip install 'sugarai[memory]'", + err=True, + ) + click.echo(f"\nMissing: {e}", err=True) + sys.exit(1) + except Exception as e: + click.echo(f"❌ Error listing memories: {e}", err=True) + sys.exit(1) + + +@cli.command() +@click.argument("memory_id") +@click.option("--force", is_flag=True, help="Skip confirmation") +@click.pass_context +def forget(ctx, memory_id, force): + """Delete a memory by ID + + Examples: + sugar forget abc123 + sugar forget abc123 --force + """ + from .memory import MemoryStore + + config_file = ctx.obj["config"] + config = _require_sugar_project(config_file) + + try: + store = _get_memory_store(config) + + # Find the memory first + entry = store.get(memory_id) + + # If not found by exact ID, try prefix match + if not entry: + entries = store.list_memories(limit=1000) + matches = [e for e in entries if e.id.startswith(memory_id)] + if len(matches) == 1: + 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() + sys.exit(1) + + if not entry: + click.echo(f"❌ Memory not found: {memory_id}") + store.close() + sys.exit(1) + + # Confirm deletion + if not force: + click.echo(f"\nMemory to delete:") + click.echo(f" ID: {entry.id}") + click.echo(f" Type: {entry.memory_type.value}") + 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() + return + + # Delete + deleted = store.delete(entry.id) + store.close() + + if deleted: + click.echo(f"βœ… Memory deleted: {entry.id[:8]}...") + else: + click.echo(f"❌ Failed to delete memory") + sys.exit(1) + + except ImportError as e: + click.echo( + "❌ Memory dependencies not installed. Install with:\n" + " pip install 'sugarai[memory]'", + err=True, + ) + click.echo(f"\nMissing: {e}", err=True) + sys.exit(1) + except Exception as e: + click.echo(f"❌ Error deleting memory: {e}", err=True) + sys.exit(1) + + +@cli.command("export-context") +@click.option( + "--format", + "output_format", + type=click.Choice(["markdown", "json", "claude"]), + default="markdown", + help="Output format", +) +@click.option("--limit", default=10, type=int, help="Maximum memories per type") +@click.option( + "--types", + default="decision,preference,error_pattern", + help="Comma-separated memory types to include", +) +@click.pass_context +def export_context(ctx, output_format, limit, types): + """Export memories for Claude Code SessionStart hook + + This command outputs memory context suitable for injection into + Claude Code sessions via the SessionStart hook. + + Examples: + sugar export-context + sugar export-context --format json --limit 5 + sugar export-context --types preference,decision + + To use with Claude Code, add to ~/.claude/settings.json: + { + "hooks": { + "SessionStart": [{ + "matcher": "", + "hooks": [{ + "type": "command", + "command": "sugar export-context" + }] + }] + } + } + """ + from .memory import MemoryRetriever, MemoryStore + + config_file = ctx.obj["config"] + config = _require_sugar_project(config_file) + + try: + store = _get_memory_store(config) + retriever = MemoryRetriever(store) + + # Get project context + context = retriever.get_project_context(limit=limit) + store.close() + + # Filter by requested types + requested_types = [t.strip() for t in types.split(",")] + type_mapping = { + "decision": "recent_decisions", + "preference": "preferences", + "file_context": "file_context", + "error_pattern": "error_patterns", + } + + filtered_context = {} + for t in requested_types: + if t in type_mapping and type_mapping[t] in context: + filtered_context[type_mapping[t]] = context[type_mapping[t]] + + if output_format == "json": + import json + click.echo(json.dumps(filtered_context, indent=2)) + elif output_format == "claude": + # Compact format optimized for Claude's context window + output = retriever.format_context_markdown(filtered_context) + if output: + click.echo(output) + else: # markdown + output = retriever.format_context_markdown(filtered_context) + if output: + click.echo(output) + else: + click.echo("# No memories to export") + + except ImportError as e: + # Silently output nothing if dependencies not installed + # This prevents errors in SessionStart hooks + if output_format == "json": + click.echo("{}") + else: + click.echo("# Sugar memory not available") + except Exception as e: + # Log error but don't break the hook + logger.warning(f"Error exporting context: {e}") + if output_format == "json": + click.echo("{}") + else: + click.echo(f"# Error: {e}") + + +@cli.command("memory-stats") +@click.pass_context +def memory_stats(ctx): + """Show memory system statistics""" + from .memory import MemoryStore, MemoryType, is_semantic_search_available + + config_file = ctx.obj["config"] + config = _require_sugar_project(config_file) + + try: + store = _get_memory_store(config) + + click.echo("\nπŸ“Š Sugar Memory Statistics\n") + + # Check capabilities + semantic_available = is_semantic_search_available() + 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}") + + store.close() + + except ImportError as e: + click.echo( + "❌ Memory dependencies not installed. Install with:\n" + " pip install 'sugarai[memory]'", + err=True, + ) + click.echo(f"\nMissing: {e}", err=True) + sys.exit(1) + except Exception as e: + click.echo(f"❌ Error getting memory stats: {e}", err=True) + sys.exit(1) + + # ============================================================================= # MCP Server Commands # ============================================================================= @@ -3838,5 +4398,53 @@ def mcp_serve(ctx, host, port, repo): click.echo("\nπŸ‘‹ Server stopped") +@mcp.command("memory") +@click.option( + "--transport", + type=click.Choice(["stdio"]), + default="stdio", + help="Transport protocol (default: stdio)", +) +@click.pass_context +def mcp_memory(ctx, transport): + """Start the Sugar Memory MCP server for Claude Code integration + + This server exposes Sugar's memory system via MCP, allowing Claude Code + and other MCP clients to search, store, and retrieve memories. + + To add to Claude Code, run: + claude mcp add sugar -- sugar mcp memory + + Or add to ~/.claude.json: + { + "mcpServers": { + "sugar": { + "type": "stdio", + "command": "sugar", + "args": ["mcp", "memory"] + } + } + } + """ + try: + from .mcp.memory_server import run_memory_server + except ImportError as e: + click.echo( + "❌ Memory MCP dependencies not installed. Install with:\n" + " pip install 'sugarai[memory]'", + err=True, + ) + click.echo(f"\nMissing: {e}", err=True) + sys.exit(1) + + try: + run_memory_server(transport=transport) + except KeyboardInterrupt: + pass + except Exception as e: + click.echo(f"❌ MCP server error: {e}", err=True) + sys.exit(1) + + if __name__ == "__main__": cli() diff --git a/sugar/mcp/__init__.py b/sugar/mcp/__init__.py index 6038f5d..346c99f 100644 --- a/sugar/mcp/__init__.py +++ b/sugar/mcp/__init__.py @@ -2,6 +2,7 @@ Sugar MCP Server Model Context Protocol server for Sugar, enabling integration with: +- Claude Code (via memory server) - GitHub Copilot Custom Agents - Other MCP-compatible clients @@ -10,7 +11,12 @@ to work without installing the optional [mcp] extras. """ -__all__ = ["SugarMCPServer", "create_server"] +__all__ = [ + "SugarMCPServer", + "create_server", + "create_memory_mcp_server", + "run_memory_server", +] # Lazy import cache _lazy_imports = {} @@ -21,10 +27,16 @@ def __getattr__(name: str): if name in __all__: if name not in _lazy_imports: try: - from .server import SugarMCPServer, create_server + if name in ("SugarMCPServer", "create_server"): + from .server import SugarMCPServer, create_server - _lazy_imports["SugarMCPServer"] = SugarMCPServer - _lazy_imports["create_server"] = create_server + _lazy_imports["SugarMCPServer"] = SugarMCPServer + _lazy_imports["create_server"] = create_server + elif name in ("create_memory_mcp_server", "run_memory_server"): + from .memory_server import create_memory_mcp_server, run_memory_server + + _lazy_imports["create_memory_mcp_server"] = create_memory_mcp_server + _lazy_imports["run_memory_server"] = run_memory_server except ImportError as e: raise ImportError( f"MCP dependencies not installed. Install with: pip install sugarai[mcp]\n" diff --git a/sugar/mcp/memory_server.py b/sugar/mcp/memory_server.py new file mode 100644 index 0000000..63d79d6 --- /dev/null +++ b/sugar/mcp/memory_server.py @@ -0,0 +1,328 @@ +""" +Sugar Memory MCP Server + +Provides MCP (Model Context Protocol) server for Sugar memory system, +allowing Claude Code and other MCP clients to access persistent memory. + +Uses FastMCP for simplified server implementation. +""" + +import asyncio +import json +import logging +import os +from pathlib import Path +from typing import Any, Dict, List, Optional + +logger = logging.getLogger(__name__) + +# Check for FastMCP availability +try: + from mcp.server.fastmcp import FastMCP + FASTMCP_AVAILABLE = True +except ImportError: + try: + from fastmcp import FastMCP + FASTMCP_AVAILABLE = True + except ImportError: + FASTMCP_AVAILABLE = False + FastMCP = None + + +def get_memory_store(): + """Get memory store from Sugar project context.""" + from sugar.memory import MemoryStore + + # Try to find .sugar directory + 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.") + + memory_db = sugar_dir / "memory.db" + return MemoryStore(str(memory_db)) + + +def create_memory_mcp_server() -> "FastMCP": + """Create and configure the Sugar Memory MCP server.""" + if not FASTMCP_AVAILABLE: + raise ImportError( + "FastMCP not available. Install with: pip install 'sugarai[memory]'" + ) + + mcp = FastMCP("Sugar Memory") + + @mcp.tool() + async def search_memory(query: str, limit: int = 5) -> List[Dict[str, Any]]: + """ + Search Sugar memory for relevant context. + + Use this to find previous decisions, preferences, error patterns, + and other relevant information from past sessions. + + Args: + query: Natural language search query + limit: Maximum results to return (default: 5) + + Returns: + List of matching memories with content, type, and relevance score + """ + from sugar.memory import MemoryQuery + + try: + store = get_memory_store() + search_query = MemoryQuery(query=query, limit=limit) + results = store.search(search_query) + store.close() + + return [ + { + "content": r.entry.content, + "type": r.entry.memory_type.value, + "score": round(r.score, 3), + "id": r.entry.id[:8], + "created_at": r.entry.created_at.isoformat() if r.entry.created_at else None, + } + for r in results + ] + except Exception as e: + logger.error(f"search_memory failed: {e}") + return [{"error": str(e)}] + + @mcp.tool() + async def store_learning( + content: str, + memory_type: str = "decision", + tags: Optional[str] = None, + ) -> 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 + + 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 + + Returns: + Confirmation with memory ID + """ + import uuid + from sugar.memory import MemoryEntry, MemoryType + + try: + store = get_memory_store() + + # Validate memory type + try: + mem_type = MemoryType(memory_type) + except ValueError: + mem_type = MemoryType.DECISION + + # Parse tags + metadata = {} + if tags: + metadata["tags"] = [t.strip() for t in tags.split(",")] + + entry = MemoryEntry( + id=str(uuid.uuid4()), + memory_type=mem_type, + content=content, + summary=content[:100] if len(content) > 100 else None, + metadata=metadata, + ) + + entry_id = store.store(entry) + store.close() + + return { + "status": "stored", + "id": entry_id[:8], + "type": mem_type.value, + "content_preview": content[:100] + "..." if len(content) > 100 else content, + } + except Exception as e: + logger.error(f"store_learning failed: {e}") + return {"error": str(e)} + + @mcp.tool() + async def get_project_context() -> Dict[str, Any]: + """ + Get current project context summary from Sugar memory. + + Returns an organized summary of: + - User preferences (coding style, conventions) + - Recent decisions (architecture, implementation choices) + - Known error patterns and fixes + - File context (what files do what) + + Use this at the start of a task to understand project context. + """ + from sugar.memory import MemoryRetriever + + try: + store = get_memory_store() + retriever = MemoryRetriever(store) + context = retriever.get_project_context(limit=10) + store.close() + + return context + except Exception as e: + logger.error(f"get_project_context failed: {e}") + return {"error": str(e)} + + @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. + + Args: + topic: The topic to recall information about + + Returns: + Markdown-formatted context from relevant memories + """ + from sugar.memory import MemoryQuery, MemoryRetriever + + try: + store = get_memory_store() + retriever = MemoryRetriever(store) + + search_query = MemoryQuery(query=topic, limit=5) + results = store.search(search_query) + store.close() + + if not results: + return f"No memories found about: {topic}" + + return retriever.format_for_prompt(results, max_tokens=1500) + except Exception as e: + logger.error(f"recall failed: {e}") + return f"Error recalling memories: {e}" + + @mcp.tool() + async def list_recent_memories( + memory_type: Optional[str] = None, + limit: int = 10, + ) -> List[Dict[str, Any]]: + """ + List recent memories, optionally filtered by type. + + Args: + memory_type: Optional filter (decision, preference, research, error_pattern, file_context, outcome) + limit: Maximum memories to return (default: 10) + + Returns: + List of recent memories + """ + from sugar.memory import MemoryType + + try: + store = get_memory_store() + + type_filter = None + if memory_type: + try: + type_filter = MemoryType(memory_type) + except ValueError: + pass + + entries = store.list_memories( + memory_type=type_filter, + limit=limit, + ) + store.close() + + return [ + { + "id": e.id[:8], + "type": e.memory_type.value, + "content": e.content[:200] + "..." if len(e.content) > 200 else e.content, + "created_at": e.created_at.isoformat() if e.created_at else None, + } + for e in entries + ] + except Exception as e: + logger.error(f"list_recent_memories failed: {e}") + return [{"error": str(e)}] + + @mcp.resource("sugar://project/context") + async def project_context_resource() -> str: + """ + Current project context from Sugar memory. + + This resource provides a markdown summary of the project's + preferences, recent decisions, and error patterns. + """ + from sugar.memory import MemoryRetriever + + try: + store = get_memory_store() + retriever = MemoryRetriever(store) + context = retriever.get_project_context(limit=10) + output = retriever.format_context_markdown(context) + store.close() + + return output if output else "# No project context available yet\n\nUse `store_learning` to add memories." + except Exception as e: + return f"# Error loading project context\n\n{e}" + + @mcp.resource("sugar://preferences") + async def preferences_resource() -> str: + """User coding preferences stored in Sugar memory.""" + from sugar.memory import MemoryType + + try: + store = get_memory_store() + preferences = store.get_by_type(MemoryType.PREFERENCE, limit=20) + store.close() + + if not preferences: + return "# No preferences stored yet\n\nUse `store_learning` with type='preference' to add preferences." + + lines = ["# User Preferences", ""] + for p in preferences: + lines.append(f"- {p.content}") + + return "\n".join(lines) + except Exception as e: + return f"# Error loading preferences\n\n{e}" + + return mcp + + +def run_memory_server(transport: str = "stdio"): + """Run the Sugar Memory MCP server.""" + if not FASTMCP_AVAILABLE: + raise ImportError( + "FastMCP not available. Install with: pip install 'sugarai[memory]'" + ) + + mcp = create_memory_mcp_server() + + if transport == "stdio": + mcp.run(transport="stdio") + else: + raise ValueError(f"Unsupported transport: {transport}") + + +if __name__ == "__main__": + logging.basicConfig(level=logging.INFO) + run_memory_server() diff --git a/sugar/memory/__init__.py b/sugar/memory/__init__.py new file mode 100644 index 0000000..5794722 --- /dev/null +++ b/sugar/memory/__init__.py @@ -0,0 +1,34 @@ +""" +Sugar Memory System + +Persistent semantic memory for AI coding sessions. +""" + +from .embedder import ( + BaseEmbedder, + FallbackEmbedder, + SentenceTransformerEmbedder, + create_embedder, + is_semantic_search_available, +) +from .retriever import MemoryRetriever +from .store import MemoryStore +from .types import MemoryEntry, MemoryQuery, MemorySearchResult, MemoryType + +__all__ = [ + # Types + "MemoryEntry", + "MemoryQuery", + "MemorySearchResult", + "MemoryType", + # Store + "MemoryStore", + # Retriever + "MemoryRetriever", + # Embedder + "BaseEmbedder", + "FallbackEmbedder", + "SentenceTransformerEmbedder", + "create_embedder", + "is_semantic_search_available", +] diff --git a/sugar/memory/embedder.py b/sugar/memory/embedder.py new file mode 100644 index 0000000..c1de916 --- /dev/null +++ b/sugar/memory/embedder.py @@ -0,0 +1,136 @@ +""" +Embedding generation for Sugar memory system. + +Uses sentence-transformers for local embeddings with FTS5 fallback. +""" + +import logging +from abc import ABC, abstractmethod +from typing import List, Optional + +logger = logging.getLogger(__name__) + +# Embedding dimension for all-MiniLM-L6-v2 +EMBEDDING_DIM = 384 + + +class BaseEmbedder(ABC): + """Abstract base class for embedders.""" + + @abstractmethod + def embed(self, text: str) -> List[float]: + """Generate embedding for a single text.""" + pass + + @abstractmethod + def embed_batch(self, texts: List[str]) -> List[List[float]]: + """Generate embeddings for multiple texts.""" + pass + + @property + @abstractmethod + def dimension(self) -> int: + """Return embedding dimension.""" + pass + + +class SentenceTransformerEmbedder(BaseEmbedder): + """Embedder using sentence-transformers (local, no API calls).""" + + def __init__(self, model_name: str = "all-MiniLM-L6-v2"): + self.model_name = model_name + self._model = None + + def _load_model(self): + """Lazy load the model.""" + if self._model is None: + try: + from sentence_transformers import SentenceTransformer + + logger.info(f"Loading embedding model: {self.model_name}") + self._model = SentenceTransformer(self.model_name) + logger.info("Embedding model loaded successfully") + except ImportError: + raise ImportError( + "sentence-transformers not installed. " + "Install with: pip install 'sugarai[memory]'" + ) + return self._model + + def embed(self, text: str) -> List[float]: + """Generate embedding for a single text.""" + model = self._load_model() + embedding = model.encode(text, convert_to_numpy=True) + return embedding.tolist() + + def embed_batch(self, texts: List[str]) -> List[List[float]]: + """Generate embeddings for multiple texts.""" + if not texts: + return [] + model = self._load_model() + embeddings = model.encode(texts, convert_to_numpy=True) + return embeddings.tolist() + + @property + def dimension(self) -> int: + return EMBEDDING_DIM + + +class FallbackEmbedder(BaseEmbedder): + """ + Fallback embedder that returns None embeddings. + + Used when sentence-transformers is not available. + Memory store will use FTS5 keyword search instead. + """ + + def __init__(self): + logger.warning( + "Using fallback embedder - semantic search disabled. " + "Install sentence-transformers for better search: pip install 'sugarai[memory]'" + ) + + def embed(self, text: str) -> List[float]: + """Return empty embedding (triggers FTS5 fallback).""" + return [] + + def embed_batch(self, texts: List[str]) -> List[List[float]]: + """Return empty embeddings.""" + return [[] for _ in texts] + + @property + def dimension(self) -> int: + return 0 + + +def create_embedder(prefer_local: bool = True) -> BaseEmbedder: + """ + Create the best available embedder. + + Args: + prefer_local: If True, prefer local sentence-transformers over API + + Returns: + An embedder instance + """ + if prefer_local: + try: + embedder = SentenceTransformerEmbedder() + # Try to load model to verify it works + embedder._load_model() + return embedder + except ImportError: + logger.info("sentence-transformers not available, using fallback") + except Exception as e: + logger.warning(f"Failed to load sentence-transformers: {e}") + + return FallbackEmbedder() + + +def is_semantic_search_available() -> bool: + """Check if semantic search (embeddings) is available.""" + try: + from sentence_transformers import SentenceTransformer # noqa: F401 + return True + except ImportError: + return False diff --git a/sugar/memory/retriever.py b/sugar/memory/retriever.py new file mode 100644 index 0000000..b9eca46 --- /dev/null +++ b/sugar/memory/retriever.py @@ -0,0 +1,226 @@ +""" +Memory retrieval for task execution context injection. +""" + +import logging +from datetime import datetime, timedelta +from typing import List, Optional + +from .store import MemoryStore +from .types import MemoryEntry, MemoryQuery, MemorySearchResult, MemoryType + +logger = logging.getLogger(__name__) + + +class MemoryRetriever: + """ + Retrieves relevant memories for task execution. + + Handles: + - Semantic search for relevant context + - Combining different memory types + - Formatting memories for prompt injection + """ + + def __init__(self, store: MemoryStore): + self.store = store + + def get_relevant( + self, + query: str, + memory_types: Optional[List[MemoryType]] = None, + limit: int = 5, + ) -> List[MemorySearchResult]: + """ + Get memories relevant to a query. + + Args: + query: Natural language query (e.g., task description) + memory_types: Filter by types (default: decision, preference, file_context) + limit: Maximum results + + Returns: + List of relevant memories with scores + """ + if memory_types is None: + memory_types = [ + MemoryType.DECISION, + MemoryType.PREFERENCE, + MemoryType.FILE_CONTEXT, + MemoryType.ERROR_PATTERN, + ] + + search_query = MemoryQuery( + query=query, + memory_types=memory_types, + limit=limit, + ) + + return self.store.search(search_query) + + def get_project_context(self, limit: int = 10) -> dict: + """ + Get overall project context for session injection. + + Returns: + Dictionary with organized memories by type + """ + context = { + "preferences": [], + "recent_decisions": [], + "file_context": [], + "error_patterns": [], + } + + # Get all preferences (permanent, high value) + preferences = self.store.get_by_type(MemoryType.PREFERENCE, limit=20) + context["preferences"] = [self._entry_to_dict(e) for e in preferences] + + # Get recent decisions (last 30 days) + decisions = self.store.list_memories( + memory_type=MemoryType.DECISION, + limit=limit, + since_days=30, + ) + context["recent_decisions"] = [self._entry_to_dict(e) for e in decisions] + + # Get file context + file_context = self.store.get_by_type(MemoryType.FILE_CONTEXT, limit=limit) + context["file_context"] = [self._entry_to_dict(e) for e in file_context] + + # Get recent error patterns + patterns = self.store.list_memories( + memory_type=MemoryType.ERROR_PATTERN, + limit=5, + since_days=60, + ) + context["error_patterns"] = [self._entry_to_dict(e) for e in patterns] + + return context + + def format_for_prompt( + self, + memories: List[MemorySearchResult], + max_tokens: int = 2000, + ) -> str: + """ + Format memories for injection into a prompt. + + Args: + memories: List of memory search results + max_tokens: Approximate max tokens (chars / 4) + + Returns: + Markdown-formatted context string + """ + if not memories: + return "" + + lines = ["## Relevant Context from Previous Work", ""] + char_count = 100 # Header chars + max_chars = max_tokens * 4 + + for result in memories: + entry = result.entry + age = self._format_age(entry.created_at) + + # Build memory block + type_label = entry.memory_type.value.replace("_", " ").title() + block_lines = [ + f"### {type_label} ({age})", + entry.content, + ] + + # Add file path if present + if entry.metadata.get("file_paths"): + files = ", ".join(f"`{f}`" for f in entry.metadata["file_paths"][:3]) + block_lines.append(f"Files: {files}") + + block_lines.append("") + block = "\n".join(block_lines) + + # Check if we'd exceed limit + if char_count + len(block) > max_chars: + break + + lines.extend(block_lines) + char_count += len(block) + + if len(lines) <= 2: # Only header + return "" + + lines.append("---") + return "\n".join(lines) + + def format_context_markdown(self, context: dict) -> str: + """ + Format project context as markdown for export. + + Args: + context: Dictionary from get_project_context() + + Returns: + Markdown string + """ + lines = ["## Recent Context from Sugar Memory", ""] + + # Decisions + if context.get("recent_decisions"): + lines.append("### Decisions (last 30 days)") + for d in context["recent_decisions"][:5]: + summary = d.get("summary") or d.get("content", "")[:100] + lines.append(f"- **{summary}**") + lines.append("") + + # Preferences + if context.get("preferences"): + lines.append("### Preferences") + for p in context["preferences"][:5]: + lines.append(f"- {p.get('content', '')}") + lines.append("") + + # Error patterns + if context.get("error_patterns"): + lines.append("### Recent Error Patterns") + for e in context["error_patterns"][:3]: + summary = e.get("summary") or e.get("content", "")[:100] + lines.append(f"- {summary}") + lines.append("") + + if len(lines) <= 2: # Only header + return "" + + return "\n".join(lines) + + def _entry_to_dict(self, entry: MemoryEntry) -> dict: + """Convert entry to simple dict for context.""" + return { + "id": entry.id, + "content": entry.content, + "summary": entry.summary, + "type": entry.memory_type.value if isinstance(entry.memory_type, MemoryType) else entry.memory_type, + "created_at": entry.created_at.isoformat() if entry.created_at else None, + "metadata": entry.metadata, + } + + def _format_age(self, created_at: Optional[datetime]) -> str: + """Format age as human-readable string.""" + if not created_at: + return "unknown" + + now = datetime.now(created_at.tzinfo) if created_at.tzinfo else datetime.now() + delta = now - created_at + + if delta < timedelta(hours=1): + return "just now" + elif delta < timedelta(days=1): + hours = int(delta.total_seconds() / 3600) + return f"{hours} hour{'s' if hours > 1 else ''} ago" + elif delta < timedelta(days=7): + days = delta.days + return f"{days} day{'s' if days > 1 else ''} ago" + elif delta < timedelta(days=30): + weeks = delta.days // 7 + return f"{weeks} week{'s' if weeks > 1 else ''} ago" + else: + return created_at.strftime("%Y-%m-%d") diff --git a/sugar/memory/store.py b/sugar/memory/store.py new file mode 100644 index 0000000..cbd6ef9 --- /dev/null +++ b/sugar/memory/store.py @@ -0,0 +1,538 @@ +""" +Memory storage backend using SQLite + sqlite-vec for vector search. + +Falls back to FTS5 keyword search if sqlite-vec is not available. +""" + +import json +import logging +import sqlite3 +import uuid +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Dict, List, Optional, Tuple + +from .embedder import EMBEDDING_DIM, BaseEmbedder, FallbackEmbedder, create_embedder +from .types import MemoryEntry, MemoryQuery, MemorySearchResult, MemoryType + +logger = logging.getLogger(__name__) + + +def _serialize_embedding(embedding: List[float]) -> bytes: + """Serialize embedding to bytes for sqlite-vec.""" + import struct + return struct.pack(f"{len(embedding)}f", *embedding) + + +def _deserialize_embedding(data: bytes) -> List[float]: + """Deserialize embedding from bytes.""" + import struct + count = len(data) // 4 # 4 bytes per float + return list(struct.unpack(f"{count}f", data)) + + +class MemoryStore: + """ + SQLite-based memory store with vector search support. + + Uses sqlite-vec for vector similarity search when available, + falls back to FTS5 keyword search otherwise. + """ + + def __init__( + self, + db_path: str, + embedder: Optional[BaseEmbedder] = None, + ): + """ + Initialize memory store. + + Args: + db_path: Path to SQLite database file + embedder: Embedder for generating vectors (auto-created if None) + """ + self.db_path = Path(db_path) + self.db_path.parent.mkdir(parents=True, exist_ok=True) + + self.embedder = embedder or create_embedder() + self._has_vec = self._check_sqlite_vec() + self._conn: Optional[sqlite3.Connection] = None + + self._init_db() + + def _check_sqlite_vec(self) -> bool: + """Check if sqlite-vec extension is available.""" + try: + import sqlite_vec # noqa: F401 + return True + except ImportError: + logger.info("sqlite-vec not available, using FTS5 fallback") + return False + + def _get_connection(self) -> sqlite3.Connection: + """Get or create database connection.""" + if self._conn is None: + self._conn = sqlite3.connect(str(self.db_path)) + self._conn.row_factory = sqlite3.Row + + if self._has_vec: + try: + import sqlite_vec + self._conn.enable_load_extension(True) + sqlite_vec.load(self._conn) + self._conn.enable_load_extension(False) + except Exception as e: + logger.warning(f"Failed to load sqlite-vec: {e}") + self._has_vec = False + + return self._conn + + def _init_db(self): + """Initialize database schema.""" + conn = self._get_connection() + cursor = conn.cursor() + + # Main memory entries table + cursor.execute(""" + CREATE TABLE IF NOT EXISTS memory_entries ( + id TEXT PRIMARY KEY, + memory_type TEXT NOT NULL, + source_id TEXT, + content TEXT NOT NULL, + summary TEXT, + metadata TEXT, + importance REAL DEFAULT 1.0, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + last_accessed_at TIMESTAMP, + access_count INTEGER DEFAULT 0, + expires_at TIMESTAMP + ) + """) + + # Indexes + cursor.execute(""" + CREATE INDEX IF NOT EXISTS idx_memory_type + ON memory_entries(memory_type) + """) + cursor.execute(""" + CREATE INDEX IF NOT EXISTS idx_memory_importance + ON memory_entries(importance DESC) + """) + cursor.execute(""" + CREATE INDEX IF NOT EXISTS idx_memory_created + ON memory_entries(created_at DESC) + """) + + # FTS5 for keyword search (always available) + cursor.execute(""" + CREATE VIRTUAL TABLE IF NOT EXISTS memory_fts USING fts5( + id, + content, + summary, + content='memory_entries', + content_rowid='rowid' + ) + """) + + # Triggers to keep FTS in sync + 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(""" + 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(""" + 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""" + 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 + + conn.commit() + + def store(self, entry: MemoryEntry) -> str: + """ + Store a memory entry. + + Args: + entry: The memory entry to store + + Returns: + The entry ID + """ + if not entry.id: + entry.id = str(uuid.uuid4()) + + if entry.created_at is None: + entry.created_at = datetime.now(timezone.utc) + + conn = self._get_connection() + cursor = conn.cursor() + + # Store main entry + cursor.execute(""" + INSERT OR REPLACE INTO memory_entries + (id, memory_type, source_id, content, summary, metadata, + importance, created_at, last_accessed_at, access_count, expires_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, ( + entry.id, + entry.memory_type.value if isinstance(entry.memory_type, MemoryType) else entry.memory_type, + entry.source_id, + entry.content, + entry.summary, + json.dumps(entry.metadata) if entry.metadata else None, + entry.importance, + entry.created_at.isoformat() if entry.created_at else None, + entry.last_accessed_at.isoformat() if entry.last_accessed_at else None, + entry.access_count, + entry.expires_at.isoformat() if entry.expires_at else None, + )) + + # Generate and store embedding if we have semantic search + if self._has_vec and not isinstance(self.embedder, FallbackEmbedder): + try: + embedding = self.embedder.embed(entry.content) + if embedding: + cursor.execute(""" + INSERT OR REPLACE INTO memory_vectors (id, embedding) + VALUES (?, ?) + """, (entry.id, _serialize_embedding(embedding))) + except Exception as e: + logger.warning(f"Failed to store embedding: {e}") + + conn.commit() + return entry.id + + def get(self, entry_id: str) -> Optional[MemoryEntry]: + """Get a memory entry by ID.""" + conn = self._get_connection() + cursor = conn.cursor() + + cursor.execute(""" + SELECT * FROM memory_entries WHERE id = ? + """, (entry_id,)) + + row = cursor.fetchone() + if row: + return self._row_to_entry(row) + return None + + def delete(self, entry_id: str) -> bool: + """Delete a memory entry.""" + conn = self._get_connection() + cursor = conn.cursor() + + cursor.execute("DELETE FROM memory_entries WHERE id = ?", (entry_id,)) + + if self._has_vec: + try: + cursor.execute("DELETE FROM memory_vectors WHERE id = ?", (entry_id,)) + except Exception: + pass + + conn.commit() + return cursor.rowcount > 0 + + def search(self, query: MemoryQuery) -> List[MemorySearchResult]: + """ + Search memories. + + Uses vector similarity if available, falls back to FTS5. + """ + if self._has_vec and not isinstance(self.embedder, FallbackEmbedder): + return self._search_semantic(query) + return self._search_keyword(query) + + def _search_semantic(self, query: MemoryQuery) -> List[MemorySearchResult]: + """Search using vector similarity.""" + conn = self._get_connection() + cursor = conn.cursor() + + # Generate query embedding + try: + query_embedding = self.embedder.embed(query.query) + if not query_embedding: + return self._search_keyword(query) + except Exception as e: + logger.warning(f"Failed to embed query: {e}") + return self._search_keyword(query) + + # Build WHERE clause for filters + where_clauses = [] + params: List[Any] = [] + + if query.memory_types: + placeholders = ",".join("?" * len(query.memory_types)) + where_clauses.append(f"e.memory_type IN ({placeholders})") + params.extend([t.value if isinstance(t, MemoryType) else t for t in query.memory_types]) + + if query.min_importance > 0: + where_clauses.append("e.importance >= ?") + params.append(query.min_importance) + + if not query.include_expired: + where_clauses.append("(e.expires_at IS NULL OR e.expires_at > datetime('now'))") + + where_sql = f"WHERE {' AND '.join(where_clauses)}" if where_clauses else "" + + # Vector search with filters + try: + sql = f""" + SELECT e.*, v.distance + FROM memory_entries e + JOIN memory_vectors v ON e.id = v.id + {where_sql} + ORDER BY v.embedding <-> ? + LIMIT ? + """ + params_with_query = params + [_serialize_embedding(query_embedding), query.limit] + cursor.execute(sql, params_with_query) + except Exception as e: + logger.warning(f"Vector search failed: {e}") + return self._search_keyword(query) + + results = [] + for row in cursor.fetchall(): + entry = self._row_to_entry(row) + # Convert distance to similarity score (0-1) + distance = row["distance"] if "distance" in row.keys() else 0 + score = max(0, 1 - distance / 2) # Normalize + results.append(MemorySearchResult(entry=entry, score=score, match_type="semantic")) + + # Update access stats + self._update_access(entry.id) + + return results + + def _search_keyword(self, query: MemoryQuery) -> List[MemorySearchResult]: + """Search using FTS5 keyword matching.""" + conn = self._get_connection() + cursor = conn.cursor() + + # Build WHERE clause for filters + where_clauses = [] + params: List[Any] = [] + + if query.memory_types: + placeholders = ",".join("?" * len(query.memory_types)) + where_clauses.append(f"e.memory_type IN ({placeholders})") + params.extend([t.value if isinstance(t, MemoryType) else t for t in query.memory_types]) + + if query.min_importance > 0: + where_clauses.append("e.importance >= ?") + params.append(query.min_importance) + + if not query.include_expired: + where_clauses.append("(e.expires_at IS NULL OR e.expires_at > datetime('now'))") + + where_sql = f"AND {' AND '.join(where_clauses)}" if where_clauses else "" + + # FTS5 search + # Escape special FTS5 characters + safe_query = query.query.replace('"', '""') + + sql = f""" + SELECT e.*, bm25(memory_fts) as score + FROM memory_entries e + JOIN memory_fts f ON e.id = f.id + WHERE memory_fts MATCH ? + {where_sql} + ORDER BY bm25(memory_fts) + LIMIT ? + """ + + try: + cursor.execute(sql, [f'"{safe_query}"', *params, query.limit]) + except sqlite3.OperationalError: + # If FTS query fails, fall back to LIKE + sql = f""" + SELECT e.*, 0.5 as score + FROM memory_entries e + WHERE (e.content LIKE ? OR e.summary LIKE ?) + {where_sql.replace('AND', 'AND' if where_sql else '')} + ORDER BY e.importance DESC, e.created_at DESC + LIMIT ? + """ + like_pattern = f"%{query.query}%" + cursor.execute(sql, [like_pattern, like_pattern, *params, query.limit]) + + results = [] + for row in cursor.fetchall(): + entry = self._row_to_entry(row) + score = abs(row["score"]) if "score" in row.keys() else 0.5 + # Normalize BM25 score to 0-1 range + normalized_score = min(1.0, score / 10) + results.append(MemorySearchResult(entry=entry, score=normalized_score, match_type="keyword")) + + # Update access stats + self._update_access(entry.id) + + return results + + def list_memories( + self, + memory_type: Optional[MemoryType] = None, + limit: int = 50, + offset: int = 0, + since_days: Optional[int] = None, + ) -> List[MemoryEntry]: + """List memories with optional filtering.""" + conn = self._get_connection() + cursor = conn.cursor() + + where_clauses = [] + params: List[Any] = [] + + if memory_type: + where_clauses.append("memory_type = ?") + params.append(memory_type.value if isinstance(memory_type, MemoryType) else memory_type) + + if since_days: + where_clauses.append("created_at >= datetime('now', ?)") + params.append(f"-{since_days} days") + + where_sql = f"WHERE {' AND '.join(where_clauses)}" if where_clauses else "" + + sql = f""" + SELECT * FROM memory_entries + {where_sql} + ORDER BY importance DESC, created_at DESC + LIMIT ? OFFSET ? + """ + params.extend([limit, offset]) + + cursor.execute(sql, params) + return [self._row_to_entry(row) for row in cursor.fetchall()] + + def get_by_type(self, memory_type: MemoryType, limit: int = 50) -> List[MemoryEntry]: + """Get all memories of a specific type.""" + return self.list_memories(memory_type=memory_type, limit=limit) + + def count(self, memory_type: Optional[MemoryType] = None) -> int: + """Count memories.""" + conn = self._get_connection() + cursor = conn.cursor() + + if memory_type: + cursor.execute( + "SELECT COUNT(*) FROM memory_entries WHERE memory_type = ?", + (memory_type.value if isinstance(memory_type, MemoryType) else memory_type,) + ) + else: + cursor.execute("SELECT COUNT(*) FROM memory_entries") + + return cursor.fetchone()[0] + + def _update_access(self, entry_id: str): + """Update access statistics for an entry.""" + conn = self._get_connection() + cursor = conn.cursor() + cursor.execute(""" + UPDATE memory_entries + SET last_accessed_at = datetime('now'), + access_count = access_count + 1 + WHERE id = ? + """, (entry_id,)) + conn.commit() + + def _row_to_entry(self, row: sqlite3.Row) -> MemoryEntry: + """Convert database row to MemoryEntry.""" + metadata = {} + if row["metadata"]: + try: + metadata = json.loads(row["metadata"]) + except json.JSONDecodeError: + pass + + memory_type = row["memory_type"] + try: + memory_type = MemoryType(memory_type) + except ValueError: + memory_type = MemoryType.DECISION + + created_at = row["created_at"] + if isinstance(created_at, str): + created_at = datetime.fromisoformat(created_at) + + last_accessed_at = row["last_accessed_at"] + if isinstance(last_accessed_at, str): + last_accessed_at = datetime.fromisoformat(last_accessed_at) + + expires_at = row["expires_at"] + if isinstance(expires_at, str): + expires_at = datetime.fromisoformat(expires_at) + + return MemoryEntry( + id=row["id"], + memory_type=memory_type, + content=row["content"], + summary=row["summary"], + source_id=row["source_id"], + metadata=metadata, + importance=row["importance"], + created_at=created_at, + last_accessed_at=last_accessed_at, + access_count=row["access_count"], + expires_at=expires_at, + ) + + def prune_expired(self) -> int: + """Remove expired memories. Returns count of removed entries.""" + conn = self._get_connection() + cursor = conn.cursor() + + # Get IDs to delete (for vector cleanup) + 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(""" + DELETE FROM memory_entries + WHERE expires_at IS NOT NULL AND expires_at < datetime('now') + """) + deleted = cursor.rowcount + + # Clean up vectors + if self._has_vec and expired_ids: + placeholders = ",".join("?" * len(expired_ids)) + try: + cursor.execute(f"DELETE FROM memory_vectors WHERE id IN ({placeholders})", expired_ids) + except Exception: + pass + + conn.commit() + return deleted + + def close(self): + """Close database connection.""" + if self._conn: + self._conn.close() + self._conn = None diff --git a/sugar/memory/types.py b/sugar/memory/types.py new file mode 100644 index 0000000..4da52df --- /dev/null +++ b/sugar/memory/types.py @@ -0,0 +1,111 @@ +""" +Memory types and dataclasses for Sugar memory system. +""" + +from dataclasses import dataclass, field +from datetime import datetime +from enum import Enum +from typing import Any, Dict, List, Optional + + +class MemoryType(str, Enum): + """Types of memories that Sugar can store.""" + + DECISION = "decision" # Architectural/implementation decisions + PREFERENCE = "preference" # User coding preferences (permanent) + FILE_CONTEXT = "file_context" # What files do what + ERROR_PATTERN = "error_pattern" # Bug patterns and fixes + RESEARCH = "research" # API docs, library findings + OUTCOME = "outcome" # Task outcomes and learnings + + +@dataclass +class MemoryEntry: + """A single memory entry.""" + + id: str + memory_type: MemoryType + content: str + summary: Optional[str] = None + source_id: Optional[str] = None # Related work_item id + metadata: Dict[str, Any] = field(default_factory=dict) + importance: float = 1.0 + created_at: Optional[datetime] = None + last_accessed_at: Optional[datetime] = None + access_count: int = 0 + expires_at: Optional[datetime] = None + embedding: Optional[List[float]] = None + + def to_dict(self) -> Dict[str, Any]: + """Convert to dictionary for storage.""" + return { + "id": self.id, + "memory_type": self.memory_type.value if isinstance(self.memory_type, MemoryType) else self.memory_type, + "content": self.content, + "summary": self.summary, + "source_id": self.source_id, + "metadata": self.metadata, + "importance": self.importance, + "created_at": self.created_at.isoformat() if self.created_at else None, + "last_accessed_at": self.last_accessed_at.isoformat() if self.last_accessed_at else None, + "access_count": self.access_count, + "expires_at": self.expires_at.isoformat() if self.expires_at else None, + } + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> "MemoryEntry": + """Create from dictionary.""" + memory_type = data.get("memory_type", "decision") + if isinstance(memory_type, str): + try: + memory_type = MemoryType(memory_type) + except ValueError: + memory_type = MemoryType.DECISION + + created_at = data.get("created_at") + if isinstance(created_at, str): + created_at = datetime.fromisoformat(created_at) + + last_accessed_at = data.get("last_accessed_at") + if isinstance(last_accessed_at, str): + last_accessed_at = datetime.fromisoformat(last_accessed_at) + + expires_at = data.get("expires_at") + if isinstance(expires_at, str): + expires_at = datetime.fromisoformat(expires_at) + + return cls( + id=data["id"], + memory_type=memory_type, + content=data["content"], + summary=data.get("summary"), + source_id=data.get("source_id"), + metadata=data.get("metadata", {}), + importance=data.get("importance", 1.0), + created_at=created_at, + last_accessed_at=last_accessed_at, + access_count=data.get("access_count", 0), + expires_at=expires_at, + ) + + +@dataclass +class MemorySearchResult: + """Result from a memory search.""" + + entry: MemoryEntry + score: float # Similarity score (0-1) + match_type: str = "semantic" # "semantic" or "keyword" + + +@dataclass +class MemoryQuery: + """Query parameters for memory search.""" + + query: str + memory_types: Optional[List[MemoryType]] = None + tags: Optional[List[str]] = None + file_paths: Optional[List[str]] = None + limit: int = 10 + min_importance: float = 0.0 + include_expired: bool = False diff --git a/tests/test_memory.py b/tests/test_memory.py new file mode 100644 index 0000000..372ba52 --- /dev/null +++ b/tests/test_memory.py @@ -0,0 +1,623 @@ +""" +Tests for Sugar Memory System + +Tests cover: +- MemoryStore CRUD operations +- MemoryStore search (FTS5 fallback) +- MemoryRetriever context formatting +- MemoryEntry serialization +- CLI commands (remember, recall, memories, forget, export-context) +""" + +import json +import pytest +import tempfile +from datetime import datetime, timezone, timedelta +from pathlib import Path +from unittest.mock import MagicMock, patch + +from click.testing import CliRunner + + +# ============================================================================ +# Unit Tests - Memory Types +# ============================================================================ + + +class TestMemoryTypes: + """Test MemoryEntry and related types.""" + + def test_memory_entry_creation(self): + """Test creating a MemoryEntry.""" + from sugar.memory import MemoryEntry, MemoryType + + entry = MemoryEntry( + id="test-123", + memory_type=MemoryType.DECISION, + content="Use JWT for authentication", + ) + + assert entry.id == "test-123" + assert entry.memory_type == MemoryType.DECISION + assert entry.content == "Use JWT for authentication" + assert entry.importance == 1.0 + assert entry.access_count == 0 + + def test_memory_entry_to_dict(self): + """Test MemoryEntry serialization.""" + from sugar.memory import MemoryEntry, MemoryType + + now = datetime.now(timezone.utc) + entry = MemoryEntry( + id="test-123", + memory_type=MemoryType.PREFERENCE, + content="Always use async/await", + summary="Prefer async", + metadata={"tags": ["coding-style"]}, + importance=1.5, + created_at=now, + ) + + data = entry.to_dict() + + assert data["id"] == "test-123" + assert data["memory_type"] == "preference" + assert data["content"] == "Always use async/await" + assert data["summary"] == "Prefer async" + assert data["metadata"] == {"tags": ["coding-style"]} + assert data["importance"] == 1.5 + + def test_memory_entry_from_dict(self): + """Test MemoryEntry deserialization.""" + from sugar.memory import MemoryEntry, MemoryType + + data = { + "id": "test-456", + "memory_type": "research", + "content": "Stripe API requires idempotency keys", + "summary": None, + "metadata": {"source": "docs"}, + "importance": 1.0, + "created_at": "2025-01-15T10:00:00+00:00", + "last_accessed_at": None, + "access_count": 0, + "expires_at": None, + } + + entry = MemoryEntry.from_dict(data) + + assert entry.id == "test-456" + assert entry.memory_type == MemoryType.RESEARCH + assert entry.content == "Stripe API requires idempotency keys" + assert entry.metadata == {"source": "docs"} + + def test_memory_type_enum(self): + """Test MemoryType enum values.""" + from sugar.memory import MemoryType + + assert MemoryType.DECISION.value == "decision" + assert MemoryType.PREFERENCE.value == "preference" + assert MemoryType.FILE_CONTEXT.value == "file_context" + assert MemoryType.ERROR_PATTERN.value == "error_pattern" + assert MemoryType.RESEARCH.value == "research" + assert MemoryType.OUTCOME.value == "outcome" + + +# ============================================================================ +# Unit Tests - Memory Store +# ============================================================================ + + +class TestMemoryStore: + """Test MemoryStore database operations.""" + + @pytest.fixture + def memory_store(self, temp_dir): + """Create a memory store with temporary database.""" + from sugar.memory import MemoryStore + from sugar.memory.embedder import FallbackEmbedder + + db_path = temp_dir / "test_memory.db" + # Use FallbackEmbedder to avoid requiring sentence-transformers + store = MemoryStore(str(db_path), embedder=FallbackEmbedder()) + yield store + store.close() + + def test_store_and_retrieve(self, memory_store): + """Test storing and retrieving a memory.""" + from sugar.memory import MemoryEntry, MemoryType + + entry = MemoryEntry( + id="store-test-1", + memory_type=MemoryType.DECISION, + content="Decided to use PostgreSQL for the database", + ) + + memory_store.store(entry) + retrieved = memory_store.get("store-test-1") + + assert retrieved is not None + assert retrieved.id == "store-test-1" + assert retrieved.content == "Decided to use PostgreSQL for the database" + assert retrieved.memory_type == MemoryType.DECISION + + def test_store_with_metadata(self, memory_store): + """Test storing memory with metadata.""" + from sugar.memory import MemoryEntry, MemoryType + + entry = MemoryEntry( + id="meta-test-1", + memory_type=MemoryType.FILE_CONTEXT, + content="payment_processor.py handles Stripe webhooks", + metadata={ + "file_paths": ["src/payment_processor.py"], + "tags": ["stripe", "payments"], + }, + ) + + memory_store.store(entry) + retrieved = memory_store.get("meta-test-1") + + assert retrieved.metadata["file_paths"] == ["src/payment_processor.py"] + assert "stripe" in retrieved.metadata["tags"] + + def test_delete_memory(self, memory_store): + """Test deleting a memory.""" + from sugar.memory import MemoryEntry, MemoryType + + entry = MemoryEntry( + id="delete-test-1", + memory_type=MemoryType.RESEARCH, + content="Temporary research note", + ) + + memory_store.store(entry) + assert memory_store.get("delete-test-1") is not None + + deleted = memory_store.delete("delete-test-1") + assert deleted is True + assert memory_store.get("delete-test-1") is None + + def test_list_memories(self, memory_store): + """Test listing memories.""" + from sugar.memory import MemoryEntry, MemoryType + + entries = [ + MemoryEntry(id="list-1", memory_type=MemoryType.PREFERENCE, content="Prefer async"), + MemoryEntry(id="list-2", memory_type=MemoryType.DECISION, content="Use JWT"), + MemoryEntry(id="list-3", memory_type=MemoryType.PREFERENCE, content="No callbacks"), + ] + + for entry in entries: + memory_store.store(entry) + + # List all + all_memories = memory_store.list_memories() + assert len(all_memories) == 3 + + # Filter by type + preferences = memory_store.list_memories(memory_type=MemoryType.PREFERENCE) + assert len(preferences) == 2 + + def test_count_memories(self, memory_store): + """Test counting memories.""" + from sugar.memory import MemoryEntry, MemoryType + + entries = [ + MemoryEntry(id="count-1", memory_type=MemoryType.PREFERENCE, content="Pref 1"), + MemoryEntry(id="count-2", memory_type=MemoryType.PREFERENCE, content="Pref 2"), + MemoryEntry(id="count-3", memory_type=MemoryType.DECISION, content="Dec 1"), + ] + + for entry in entries: + memory_store.store(entry) + + assert memory_store.count() == 3 + assert memory_store.count(MemoryType.PREFERENCE) == 2 + assert memory_store.count(MemoryType.DECISION) == 1 + + def test_search_keyword_fallback(self, memory_store): + """Test FTS5 keyword search.""" + from sugar.memory import MemoryEntry, MemoryQuery, MemoryType + + entries = [ + MemoryEntry(id="search-1", memory_type=MemoryType.DECISION, content="Use JWT tokens for authentication"), + MemoryEntry(id="search-2", memory_type=MemoryType.DECISION, content="PostgreSQL for the main database"), + MemoryEntry(id="search-3", memory_type=MemoryType.ERROR_PATTERN, content="Auth redirect loop fixed by return"), + ] + + for entry in entries: + memory_store.store(entry) + + # Search for authentication-related memories + query = MemoryQuery(query="authentication", limit=10) + results = memory_store.search(query) + + assert len(results) >= 1 + # The JWT entry should be in results + result_ids = [r.entry.id for r in results] + assert "search-1" in result_ids + + def test_prune_expired(self, memory_store): + """Test pruning expired memories.""" + from sugar.memory import MemoryEntry, MemoryType + + # Create an expired entry + expired_entry = MemoryEntry( + id="expired-1", + memory_type=MemoryType.RESEARCH, + content="Expired research", + expires_at=datetime.now(timezone.utc) - timedelta(days=1), + ) + + # Create a valid entry + valid_entry = MemoryEntry( + id="valid-1", + memory_type=MemoryType.PREFERENCE, + content="Valid preference", + ) + + memory_store.store(expired_entry) + memory_store.store(valid_entry) + + assert memory_store.count() == 2 + + pruned = memory_store.prune_expired() + assert pruned == 1 + assert memory_store.count() == 1 + assert memory_store.get("valid-1") is not None + assert memory_store.get("expired-1") is None + + +# ============================================================================ +# Unit Tests - Memory Retriever +# ============================================================================ + + +class TestMemoryRetriever: + """Test MemoryRetriever context formatting.""" + + @pytest.fixture + def populated_store(self, temp_dir): + """Create a memory store with sample data.""" + from sugar.memory import MemoryEntry, MemoryStore, MemoryType + from sugar.memory.embedder import FallbackEmbedder + + db_path = temp_dir / "retriever_test.db" + store = MemoryStore(str(db_path), embedder=FallbackEmbedder()) + + entries = [ + MemoryEntry( + id="ret-1", + memory_type=MemoryType.PREFERENCE, + content="Always use async/await, never callbacks", + ), + MemoryEntry( + id="ret-2", + memory_type=MemoryType.DECISION, + content="Chose JWT with RS256 for authentication tokens", + ), + MemoryEntry( + id="ret-3", + memory_type=MemoryType.ERROR_PATTERN, + content="Login redirect loop was caused by missing return statement", + ), + ] + + for entry in entries: + store.store(entry) + + yield store + store.close() + + def test_get_project_context(self, populated_store): + """Test retrieving project context.""" + from sugar.memory import MemoryRetriever + + retriever = MemoryRetriever(populated_store) + context = retriever.get_project_context() + + assert "preferences" in context + assert "recent_decisions" in context + assert "error_patterns" in context + assert len(context["preferences"]) >= 1 + + def test_format_context_markdown(self, populated_store): + """Test formatting context as markdown.""" + from sugar.memory import MemoryRetriever + + retriever = MemoryRetriever(populated_store) + context = retriever.get_project_context() + markdown = retriever.format_context_markdown(context) + + assert "## Recent Context from Sugar Memory" in markdown + assert "Preferences" in markdown + + def test_format_for_prompt(self, populated_store): + """Test formatting search results for prompt injection.""" + from sugar.memory import MemoryQuery, MemoryRetriever + + retriever = MemoryRetriever(populated_store) + + # Search and format + query = MemoryQuery(query="authentication", limit=5) + results = populated_store.search(query) + formatted = retriever.format_for_prompt(results) + + if results: + assert "## Relevant Context" in formatted + + +# ============================================================================ +# Integration Tests - CLI Commands +# ============================================================================ + + +class TestMemoryCLI: + """Test memory CLI commands.""" + + @pytest.fixture + def cli_setup(self, temp_dir): + """Setup CLI test environment with Sugar project.""" + from click.testing import CliRunner + + project_dir = temp_dir / "test_project" + project_dir.mkdir() + + sugar_dir = project_dir / ".sugar" + sugar_dir.mkdir() + + # Create minimal config + config = { + "sugar": { + "storage": {"database": str(sugar_dir / "sugar.db")}, + } + } + + import yaml + with open(sugar_dir / "config.yaml", "w") as f: + yaml.dump(config, f) + + runner = CliRunner() + return runner, project_dir + + def test_remember_command(self, cli_setup): + """Test sugar remember command.""" + from sugar.main import cli + + runner, project_dir = cli_setup + + with runner.isolated_filesystem(temp_dir=project_dir.parent): + import os + os.chdir(project_dir) + + result = runner.invoke(cli, [ + "--config", str(project_dir / ".sugar" / "config.yaml"), + "remember", + "Always use type hints in Python", + "--type", "preference" + ]) + + # May fail if memory dependencies not installed, which is fine + if "Memory dependencies not installed" not in result.output: + assert "Remembered" in result.output or result.exit_code == 0 + + def test_memories_command(self, cli_setup): + """Test sugar memories command.""" + from sugar.main import cli + + runner, project_dir = cli_setup + + with runner.isolated_filesystem(temp_dir=project_dir.parent): + import os + os.chdir(project_dir) + + result = runner.invoke(cli, [ + "--config", str(project_dir / ".sugar" / "config.yaml"), + "memories" + ]) + + # Should work even with no memories + if "Memory dependencies not installed" not in result.output: + assert result.exit_code == 0 or "No memories found" in result.output + + def test_recall_command(self, cli_setup): + """Test sugar recall command.""" + from sugar.main import cli + + runner, project_dir = cli_setup + + with runner.isolated_filesystem(temp_dir=project_dir.parent): + import os + os.chdir(project_dir) + + result = runner.invoke(cli, [ + "--config", str(project_dir / ".sugar" / "config.yaml"), + "recall", + "authentication" + ]) + + if "Memory dependencies not installed" not in result.output: + # With empty store, should report no results + assert result.exit_code == 0 or "No memories found" in result.output + + def test_export_context_command(self, cli_setup): + """Test sugar export-context command.""" + from sugar.main import cli + + runner, project_dir = cli_setup + + with runner.isolated_filesystem(temp_dir=project_dir.parent): + import os + os.chdir(project_dir) + + result = runner.invoke(cli, [ + "--config", str(project_dir / ".sugar" / "config.yaml"), + "export-context" + ]) + + # Should not crash, even without memories + assert result.exit_code == 0 + + def test_memory_stats_command(self, cli_setup): + """Test sugar memory-stats command.""" + from sugar.main import cli + + runner, project_dir = cli_setup + + with runner.isolated_filesystem(temp_dir=project_dir.parent): + import os + os.chdir(project_dir) + + result = runner.invoke(cli, [ + "--config", str(project_dir / ".sugar" / "config.yaml"), + "memory-stats" + ]) + + if "Memory dependencies not installed" not in result.output: + assert "Memory Statistics" in result.output or result.exit_code == 0 + + +# ============================================================================ +# Unit Tests - Embedder +# ============================================================================ + + +class TestEmbedder: + """Test embedding functionality.""" + + def test_fallback_embedder(self): + """Test FallbackEmbedder returns empty embeddings.""" + from sugar.memory.embedder import FallbackEmbedder + + embedder = FallbackEmbedder() + + embedding = embedder.embed("test text") + assert embedding == [] + + embeddings = embedder.embed_batch(["text1", "text2"]) + assert embeddings == [[], []] + + assert embedder.dimension == 0 + + def test_create_embedder_fallback(self): + """Test create_embedder falls back gracefully.""" + from sugar.memory.embedder import create_embedder, FallbackEmbedder + + # Should return an embedder without crashing + embedder = create_embedder(prefer_local=True) + assert embedder is not None + + # If sentence-transformers not available, should be FallbackEmbedder + # If available, should be SentenceTransformerEmbedder + # Either way, it should work + + def test_is_semantic_search_available(self): + """Test semantic search availability check.""" + from sugar.memory.embedder import is_semantic_search_available + + # Should return True or False without crashing + result = is_semantic_search_available() + assert isinstance(result, bool) + + +# ============================================================================ +# Integration Tests - Full Workflow +# ============================================================================ + + +class TestMemoryWorkflow: + """Test complete memory workflows.""" + + @pytest.fixture + def full_setup(self, temp_dir): + """Create a complete memory setup.""" + from sugar.memory import MemoryEntry, MemoryRetriever, MemoryStore, MemoryType + from sugar.memory.embedder import FallbackEmbedder + + db_path = temp_dir / "workflow_test.db" + store = MemoryStore(str(db_path), embedder=FallbackEmbedder()) + retriever = MemoryRetriever(store) + + yield store, retriever + store.close() + + def test_store_search_retrieve_workflow(self, full_setup): + """Test complete store -> search -> retrieve workflow.""" + from sugar.memory import MemoryEntry, MemoryQuery, MemoryType + + store, retriever = full_setup + + # Store some memories + memories = [ + MemoryEntry( + id="wf-1", + memory_type=MemoryType.PREFERENCE, + content="Steve prefers minimal comments and descriptive variable names", + ), + MemoryEntry( + id="wf-2", + memory_type=MemoryType.DECISION, + content="Using Redis for session storage due to horizontal scaling needs", + metadata={"tags": ["architecture", "redis"]}, + ), + MemoryEntry( + id="wf-3", + memory_type=MemoryType.ERROR_PATTERN, + content="Database connection timeout: fixed by increasing pool size", + ), + ] + + for m in memories: + store.store(m) + + # Search for relevant context + query = MemoryQuery(query="redis", limit=5) + results = store.search(query) + + # Get project context + context = retriever.get_project_context() + + assert len(context["preferences"]) >= 1 + assert len(context["recent_decisions"]) >= 1 + + # Format for prompt + formatted = retriever.format_context_markdown(context) + assert len(formatted) > 0 + + def test_memory_lifecycle(self, full_setup): + """Test memory creation, update, and deletion.""" + from sugar.memory import MemoryEntry, MemoryType + + store, retriever = full_setup + + # Create + entry = MemoryEntry( + id="lifecycle-1", + memory_type=MemoryType.RESEARCH, + content="Initial research finding", + ) + store.store(entry) + + # Verify created + retrieved = store.get("lifecycle-1") + assert retrieved is not None + assert retrieved.content == "Initial research finding" + + # Update (store with same ID) + updated_entry = MemoryEntry( + id="lifecycle-1", + memory_type=MemoryType.RESEARCH, + content="Updated research finding with more details", + importance=1.5, + ) + store.store(updated_entry) + + # Verify updated + retrieved = store.get("lifecycle-1") + assert retrieved.content == "Updated research finding with more details" + assert retrieved.importance == 1.5 + + # Delete + store.delete("lifecycle-1") + assert store.get("lifecycle-1") is None From 52063fc030ee4414cfe72d368ee8192560867593 Mon Sep 17 00:00:00 2001 From: Steven Leggett Date: Tue, 20 Jan 2026 19:23:26 -0500 Subject: [PATCH 2/6] style: Apply Black formatting to memory module --- sugar/main.py | 90 +++++++++++++++--- sugar/mcp/__init__.py | 5 +- sugar/mcp/memory_server.py | 20 +++- sugar/memory/embedder.py | 1 + sugar/memory/retriever.py | 6 +- sugar/memory/store.py | 186 ++++++++++++++++++++++++++----------- sugar/memory/types.py | 10 +- tests/test_memory.py | 107 ++++++++++++++------- 8 files changed, 318 insertions(+), 107 deletions(-) diff --git a/sugar/main.py b/sugar/main.py index 6e4cecb..994975f 100644 --- a/sugar/main.py +++ b/sugar/main.py @@ -3843,7 +3843,16 @@ def _get_memory_store(config: dict): @click.option( "--type", "memory_type", - type=click.Choice(["decision", "preference", "research", "file_context", "error_pattern", "outcome"]), + type=click.Choice( + [ + "decision", + "preference", + "research", + "file_context", + "error_pattern", + "outcome", + ] + ), default="decision", help="Type of memory", ) @@ -3854,7 +3863,9 @@ def _get_memory_store(config: dict): default="never", help="Time to live: 30d, 90d, 1y, never (default: never)", ) -@click.option("--importance", type=float, default=1.0, help="Importance score (0.0-2.0)") +@click.option( + "--importance", type=float, default=1.0, help="Importance score (0.0-2.0)" +) @click.pass_context def remember(ctx, content, memory_type, tags, file_path, ttl, importance): """Store a memory for future reference @@ -3928,7 +3939,17 @@ def remember(ctx, content, memory_type, tags, file_path, ttl, importance): @click.option( "--type", "memory_type", - type=click.Choice(["decision", "preference", "research", "file_context", "error_pattern", "outcome", "all"]), + type=click.Choice( + [ + "decision", + "preference", + "research", + "file_context", + "error_pattern", + "outcome", + "all", + ] + ), default="all", help="Filter by memory type", ) @@ -3977,13 +3998,16 @@ def recall(ctx, query, memory_type, limit, output_format): if output_format == "json": import json + output = [ { "id": r.entry.id, "content": r.entry.content, "type": r.entry.memory_type.value, "score": round(r.score, 3), - "created_at": r.entry.created_at.isoformat() if r.entry.created_at else None, + "created_at": ( + r.entry.created_at.isoformat() if r.entry.created_at else None + ), } for r in results ] @@ -3991,9 +4015,13 @@ 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}") - click.echo(f"[{i}] {r.entry.memory_type.value.upper()} (score: {r.score:.2f})") + click.echo( + f"[{i}] {r.entry.memory_type.value.upper()} (score: {r.score:.2f})" + ) click.echo(f"ID: {r.entry.id}") - click.echo(f"Created: {r.entry.created_at.strftime('%Y-%m-%d %H:%M') if r.entry.created_at else 'unknown'}") + click.echo( + f"Created: {r.entry.created_at.strftime('%Y-%m-%d %H:%M') if r.entry.created_at else 'unknown'}" + ) click.echo(f"\n{r.entry.content}") if r.entry.metadata.get("tags"): click.echo(f"\nTags: {', '.join(r.entry.metadata['tags'])}") @@ -4004,9 +4032,15 @@ def recall(ctx, query, memory_type, limit, output_format): click.echo(f"{'Score':<8} {'Type':<15} {'Content':<55}") click.echo("-" * 80) for r in results: - content = r.entry.content[:52] + "..." if len(r.entry.content) > 55 else r.entry.content + content = ( + r.entry.content[:52] + "..." + if len(r.entry.content) > 55 + else r.entry.content + ) content = content.replace("\n", " ") - click.echo(f"{r.score:.2f} {r.entry.memory_type.value:<15} {content:<55}") + click.echo( + f"{r.score:.2f} {r.entry.memory_type.value:<15} {content:<55}" + ) click.echo(f"\n{len(results)} memories found ({r.match_type} search)") except ImportError as e: @@ -4026,7 +4060,17 @@ def recall(ctx, query, memory_type, limit, output_format): @click.option( "--type", "memory_type", - type=click.Choice(["decision", "preference", "research", "file_context", "error_pattern", "outcome", "all"]), + type=click.Choice( + [ + "decision", + "preference", + "research", + "file_context", + "error_pattern", + "outcome", + "all", + ] + ), default="all", help="Filter by memory type", ) @@ -4068,7 +4112,10 @@ def memories(ctx, memory_type, since, limit, output_format): elif unit == "m": since_days = value * 30 else: - click.echo(f"❌ Invalid time format: {since}. Use format like 7d, 2w, 1m", err=True) + click.echo( + f"❌ Invalid time format: {since}. Use format like 7d, 2w, 1m", + err=True, + ) sys.exit(1) # Get memories @@ -4086,6 +4133,7 @@ def memories(ctx, memory_type, since, limit, output_format): if output_format == "json": import json + output = [e.to_dict() for e in entries] click.echo(json.dumps(output, indent=2)) else: # table @@ -4094,8 +4142,12 @@ def memories(ctx, memory_type, since, limit, output_format): for e in entries: content = e.content[:37] + "..." if len(e.content) > 40 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}") + 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}" + ) click.echo(f"\n{len(entries)} memories") # Show counts by type @@ -4149,7 +4201,9 @@ def forget(ctx, memory_id, force): if len(matches) == 1: entry = matches[0] elif len(matches) > 1: - click.echo(f"❌ Ambiguous ID '{memory_id}' matches {len(matches)} memories:") + 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() @@ -4165,7 +4219,9 @@ 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" Content: {entry.content[:100]}{'...' if len(entry.content) > 100 else ''}") + 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() @@ -4262,6 +4318,7 @@ def export_context(ctx, output_format, limit, types): if output_format == "json": import json + click.echo(json.dumps(filtered_context, indent=2)) elif output_format == "claude": # Compact format optimized for Claude's context window @@ -4307,7 +4364,9 @@ def memory_stats(ctx): # Check capabilities semantic_available = is_semantic_search_available() - click.echo(f"Semantic search: {'βœ… Available' if semantic_available else '❌ Not available (using keyword search)'}") + click.echo( + f"Semantic search: {'βœ… Available' if semantic_available else '❌ Not available (using keyword search)'}" + ) click.echo(f"Database: {store.db_path}") click.echo("") @@ -4324,6 +4383,7 @@ def memory_stats(ctx): # Database size import os + if store.db_path.exists(): size_bytes = os.path.getsize(store.db_path) if size_bytes < 1024: diff --git a/sugar/mcp/__init__.py b/sugar/mcp/__init__.py index 346c99f..929dc2d 100644 --- a/sugar/mcp/__init__.py +++ b/sugar/mcp/__init__.py @@ -33,7 +33,10 @@ def __getattr__(name: str): _lazy_imports["SugarMCPServer"] = SugarMCPServer _lazy_imports["create_server"] = create_server elif name in ("create_memory_mcp_server", "run_memory_server"): - from .memory_server import create_memory_mcp_server, run_memory_server + from .memory_server import ( + create_memory_mcp_server, + run_memory_server, + ) _lazy_imports["create_memory_mcp_server"] = create_memory_mcp_server _lazy_imports["run_memory_server"] = run_memory_server diff --git a/sugar/mcp/memory_server.py b/sugar/mcp/memory_server.py index 63d79d6..e91efad 100644 --- a/sugar/mcp/memory_server.py +++ b/sugar/mcp/memory_server.py @@ -19,10 +19,12 @@ # Check for FastMCP availability try: from mcp.server.fastmcp import FastMCP + FASTMCP_AVAILABLE = True except ImportError: try: from fastmcp import FastMCP + FASTMCP_AVAILABLE = True except ImportError: FASTMCP_AVAILABLE = False @@ -90,7 +92,9 @@ async def search_memory(query: str, limit: int = 5) -> List[Dict[str, Any]]: "type": r.entry.memory_type.value, "score": round(r.score, 3), "id": r.entry.id[:8], - "created_at": r.entry.created_at.isoformat() if r.entry.created_at else None, + "created_at": ( + r.entry.created_at.isoformat() if r.entry.created_at else None + ), } for r in results ] @@ -153,7 +157,9 @@ async def store_learning( "status": "stored", "id": entry_id[:8], "type": mem_type.value, - "content_preview": content[:100] + "..." if len(content) > 100 else content, + "content_preview": ( + content[:100] + "..." if len(content) > 100 else content + ), } except Exception as e: logger.error(f"store_learning failed: {e}") @@ -254,7 +260,9 @@ async def list_recent_memories( { "id": e.id[:8], "type": e.memory_type.value, - "content": e.content[:200] + "..." if len(e.content) > 200 else e.content, + "content": ( + e.content[:200] + "..." if len(e.content) > 200 else e.content + ), "created_at": e.created_at.isoformat() if e.created_at else None, } for e in entries @@ -280,7 +288,11 @@ async def project_context_resource() -> str: output = retriever.format_context_markdown(context) store.close() - return output if output else "# No project context available yet\n\nUse `store_learning` to add memories." + return ( + output + if output + else "# No project context available yet\n\nUse `store_learning` to add memories." + ) except Exception as e: return f"# Error loading project context\n\n{e}" diff --git a/sugar/memory/embedder.py b/sugar/memory/embedder.py index c1de916..65ca957 100644 --- a/sugar/memory/embedder.py +++ b/sugar/memory/embedder.py @@ -131,6 +131,7 @@ def is_semantic_search_available() -> bool: """Check if semantic search (embeddings) is available.""" try: from sentence_transformers import SentenceTransformer # noqa: F401 + return True except ImportError: return False diff --git a/sugar/memory/retriever.py b/sugar/memory/retriever.py index b9eca46..2454626 100644 --- a/sugar/memory/retriever.py +++ b/sugar/memory/retriever.py @@ -198,7 +198,11 @@ def _entry_to_dict(self, entry: MemoryEntry) -> dict: "id": entry.id, "content": entry.content, "summary": entry.summary, - "type": entry.memory_type.value if isinstance(entry.memory_type, MemoryType) else entry.memory_type, + "type": ( + entry.memory_type.value + if isinstance(entry.memory_type, MemoryType) + else entry.memory_type + ), "created_at": entry.created_at.isoformat() if entry.created_at else None, "metadata": entry.metadata, } diff --git a/sugar/memory/store.py b/sugar/memory/store.py index cbd6ef9..e6b5d98 100644 --- a/sugar/memory/store.py +++ b/sugar/memory/store.py @@ -21,12 +21,14 @@ def _serialize_embedding(embedding: List[float]) -> bytes: """Serialize embedding to bytes for sqlite-vec.""" import struct + return struct.pack(f"{len(embedding)}f", *embedding) def _deserialize_embedding(data: bytes) -> List[float]: """Deserialize embedding from bytes.""" import struct + count = len(data) // 4 # 4 bytes per float return list(struct.unpack(f"{count}f", data)) @@ -64,6 +66,7 @@ def _check_sqlite_vec(self) -> bool: """Check if sqlite-vec extension is available.""" try: import sqlite_vec # noqa: F401 + return True except ImportError: logger.info("sqlite-vec not available, using FTS5 fallback") @@ -78,6 +81,7 @@ def _get_connection(self) -> sqlite3.Connection: if self._has_vec: try: import sqlite_vec + self._conn.enable_load_extension(True) sqlite_vec.load(self._conn) self._conn.enable_load_extension(False) @@ -93,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, @@ -107,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, @@ -132,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 @@ -191,34 +213,44 @@ def store(self, entry: MemoryEntry) -> str: cursor = conn.cursor() # Store main entry - cursor.execute(""" + cursor.execute( + """ INSERT OR REPLACE INTO memory_entries (id, memory_type, source_id, content, summary, metadata, importance, created_at, last_accessed_at, access_count, expires_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - """, ( - entry.id, - entry.memory_type.value if isinstance(entry.memory_type, MemoryType) else entry.memory_type, - entry.source_id, - entry.content, - entry.summary, - json.dumps(entry.metadata) if entry.metadata else None, - entry.importance, - entry.created_at.isoformat() if entry.created_at else None, - entry.last_accessed_at.isoformat() if entry.last_accessed_at else None, - entry.access_count, - entry.expires_at.isoformat() if entry.expires_at else None, - )) + """, + ( + entry.id, + ( + entry.memory_type.value + if isinstance(entry.memory_type, MemoryType) + else entry.memory_type + ), + entry.source_id, + entry.content, + entry.summary, + json.dumps(entry.metadata) if entry.metadata else None, + entry.importance, + entry.created_at.isoformat() if entry.created_at else None, + entry.last_accessed_at.isoformat() if entry.last_accessed_at else None, + entry.access_count, + entry.expires_at.isoformat() if entry.expires_at else None, + ), + ) # Generate and store embedding if we have semantic search if self._has_vec and not isinstance(self.embedder, FallbackEmbedder): try: embedding = self.embedder.embed(entry.content) if embedding: - cursor.execute(""" + cursor.execute( + """ INSERT OR REPLACE INTO memory_vectors (id, embedding) VALUES (?, ?) - """, (entry.id, _serialize_embedding(embedding))) + """, + (entry.id, _serialize_embedding(embedding)), + ) except Exception as e: logger.warning(f"Failed to store embedding: {e}") @@ -230,9 +262,12 @@ def get(self, entry_id: str) -> Optional[MemoryEntry]: conn = self._get_connection() cursor = conn.cursor() - cursor.execute(""" + cursor.execute( + """ SELECT * FROM memory_entries WHERE id = ? - """, (entry_id,)) + """, + (entry_id,), + ) row = cursor.fetchone() if row: @@ -286,14 +321,21 @@ def _search_semantic(self, query: MemoryQuery) -> List[MemorySearchResult]: if query.memory_types: placeholders = ",".join("?" * len(query.memory_types)) where_clauses.append(f"e.memory_type IN ({placeholders})") - params.extend([t.value if isinstance(t, MemoryType) else t for t in query.memory_types]) + params.extend( + [ + t.value if isinstance(t, MemoryType) else t + for t in query.memory_types + ] + ) if query.min_importance > 0: where_clauses.append("e.importance >= ?") params.append(query.min_importance) if not query.include_expired: - where_clauses.append("(e.expires_at IS NULL OR e.expires_at > datetime('now'))") + where_clauses.append( + "(e.expires_at IS NULL OR e.expires_at > datetime('now'))" + ) where_sql = f"WHERE {' AND '.join(where_clauses)}" if where_clauses else "" @@ -307,7 +349,10 @@ def _search_semantic(self, query: MemoryQuery) -> List[MemorySearchResult]: ORDER BY v.embedding <-> ? LIMIT ? """ - params_with_query = params + [_serialize_embedding(query_embedding), query.limit] + params_with_query = params + [ + _serialize_embedding(query_embedding), + query.limit, + ] cursor.execute(sql, params_with_query) except Exception as e: logger.warning(f"Vector search failed: {e}") @@ -319,7 +364,9 @@ def _search_semantic(self, query: MemoryQuery) -> List[MemorySearchResult]: # Convert distance to similarity score (0-1) distance = row["distance"] if "distance" in row.keys() else 0 score = max(0, 1 - distance / 2) # Normalize - results.append(MemorySearchResult(entry=entry, score=score, match_type="semantic")) + results.append( + MemorySearchResult(entry=entry, score=score, match_type="semantic") + ) # Update access stats self._update_access(entry.id) @@ -338,14 +385,21 @@ def _search_keyword(self, query: MemoryQuery) -> List[MemorySearchResult]: if query.memory_types: placeholders = ",".join("?" * len(query.memory_types)) where_clauses.append(f"e.memory_type IN ({placeholders})") - params.extend([t.value if isinstance(t, MemoryType) else t for t in query.memory_types]) + params.extend( + [ + t.value if isinstance(t, MemoryType) else t + for t in query.memory_types + ] + ) if query.min_importance > 0: where_clauses.append("e.importance >= ?") params.append(query.min_importance) if not query.include_expired: - where_clauses.append("(e.expires_at IS NULL OR e.expires_at > datetime('now'))") + where_clauses.append( + "(e.expires_at IS NULL OR e.expires_at > datetime('now'))" + ) where_sql = f"AND {' AND '.join(where_clauses)}" if where_clauses else "" @@ -384,7 +438,11 @@ def _search_keyword(self, query: MemoryQuery) -> List[MemorySearchResult]: score = abs(row["score"]) if "score" in row.keys() else 0.5 # Normalize BM25 score to 0-1 range normalized_score = min(1.0, score / 10) - results.append(MemorySearchResult(entry=entry, score=normalized_score, match_type="keyword")) + results.append( + MemorySearchResult( + entry=entry, score=normalized_score, match_type="keyword" + ) + ) # Update access stats self._update_access(entry.id) @@ -407,7 +465,11 @@ def list_memories( if memory_type: where_clauses.append("memory_type = ?") - params.append(memory_type.value if isinstance(memory_type, MemoryType) else memory_type) + params.append( + memory_type.value + if isinstance(memory_type, MemoryType) + else memory_type + ) if since_days: where_clauses.append("created_at >= datetime('now', ?)") @@ -426,7 +488,9 @@ def list_memories( cursor.execute(sql, params) return [self._row_to_entry(row) for row in cursor.fetchall()] - 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 all memories of a specific type.""" return self.list_memories(memory_type=memory_type, limit=limit) @@ -438,7 +502,13 @@ def count(self, memory_type: Optional[MemoryType] = None) -> int: if memory_type: cursor.execute( "SELECT COUNT(*) FROM memory_entries WHERE memory_type = ?", - (memory_type.value if isinstance(memory_type, MemoryType) else memory_type,) + ( + ( + memory_type.value + if isinstance(memory_type, MemoryType) + else memory_type + ), + ), ) else: cursor.execute("SELECT COUNT(*) FROM memory_entries") @@ -449,12 +519,15 @@ def _update_access(self, entry_id: str): """Update access statistics for an entry.""" conn = self._get_connection() cursor = conn.cursor() - cursor.execute(""" + cursor.execute( + """ UPDATE memory_entries SET last_accessed_at = datetime('now'), access_count = access_count + 1 WHERE id = ? - """, (entry_id,)) + """, + (entry_id,), + ) conn.commit() def _row_to_entry(self, row: sqlite3.Row) -> MemoryEntry: @@ -504,27 +577,34 @@ 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 if self._has_vec and expired_ids: placeholders = ",".join("?" * len(expired_ids)) try: - cursor.execute(f"DELETE FROM memory_vectors WHERE id IN ({placeholders})", expired_ids) + cursor.execute( + f"DELETE FROM memory_vectors WHERE id IN ({placeholders})", + expired_ids, + ) except Exception: pass diff --git a/sugar/memory/types.py b/sugar/memory/types.py index 4da52df..5449d3e 100644 --- a/sugar/memory/types.py +++ b/sugar/memory/types.py @@ -40,14 +40,20 @@ def to_dict(self) -> Dict[str, Any]: """Convert to dictionary for storage.""" return { "id": self.id, - "memory_type": self.memory_type.value if isinstance(self.memory_type, MemoryType) else self.memory_type, + "memory_type": ( + self.memory_type.value + if isinstance(self.memory_type, MemoryType) + else self.memory_type + ), "content": self.content, "summary": self.summary, "source_id": self.source_id, "metadata": self.metadata, "importance": self.importance, "created_at": self.created_at.isoformat() if self.created_at else None, - "last_accessed_at": self.last_accessed_at.isoformat() if self.last_accessed_at else None, + "last_accessed_at": ( + self.last_accessed_at.isoformat() if self.last_accessed_at else None + ), "access_count": self.access_count, "expires_at": self.expires_at.isoformat() if self.expires_at else None, } diff --git a/tests/test_memory.py b/tests/test_memory.py index 372ba52..8acdc66 100644 --- a/tests/test_memory.py +++ b/tests/test_memory.py @@ -183,9 +183,15 @@ def test_list_memories(self, memory_store): from sugar.memory import MemoryEntry, MemoryType entries = [ - MemoryEntry(id="list-1", memory_type=MemoryType.PREFERENCE, content="Prefer async"), - MemoryEntry(id="list-2", memory_type=MemoryType.DECISION, content="Use JWT"), - MemoryEntry(id="list-3", memory_type=MemoryType.PREFERENCE, content="No callbacks"), + MemoryEntry( + id="list-1", memory_type=MemoryType.PREFERENCE, content="Prefer async" + ), + MemoryEntry( + id="list-2", memory_type=MemoryType.DECISION, content="Use JWT" + ), + MemoryEntry( + id="list-3", memory_type=MemoryType.PREFERENCE, content="No callbacks" + ), ] for entry in entries: @@ -204,8 +210,12 @@ def test_count_memories(self, memory_store): from sugar.memory import MemoryEntry, MemoryType entries = [ - MemoryEntry(id="count-1", memory_type=MemoryType.PREFERENCE, content="Pref 1"), - MemoryEntry(id="count-2", memory_type=MemoryType.PREFERENCE, content="Pref 2"), + MemoryEntry( + id="count-1", memory_type=MemoryType.PREFERENCE, content="Pref 1" + ), + MemoryEntry( + id="count-2", memory_type=MemoryType.PREFERENCE, content="Pref 2" + ), MemoryEntry(id="count-3", memory_type=MemoryType.DECISION, content="Dec 1"), ] @@ -221,9 +231,21 @@ def test_search_keyword_fallback(self, memory_store): from sugar.memory import MemoryEntry, MemoryQuery, MemoryType entries = [ - MemoryEntry(id="search-1", memory_type=MemoryType.DECISION, content="Use JWT tokens for authentication"), - MemoryEntry(id="search-2", memory_type=MemoryType.DECISION, content="PostgreSQL for the main database"), - MemoryEntry(id="search-3", memory_type=MemoryType.ERROR_PATTERN, content="Auth redirect loop fixed by return"), + MemoryEntry( + id="search-1", + memory_type=MemoryType.DECISION, + content="Use JWT tokens for authentication", + ), + MemoryEntry( + id="search-2", + memory_type=MemoryType.DECISION, + content="PostgreSQL for the main database", + ), + MemoryEntry( + id="search-3", + memory_type=MemoryType.ERROR_PATTERN, + content="Auth redirect loop fixed by return", + ), ] for entry in entries: @@ -375,6 +397,7 @@ def cli_setup(self, temp_dir): } import yaml + with open(sugar_dir / "config.yaml", "w") as f: yaml.dump(config, f) @@ -389,14 +412,20 @@ def test_remember_command(self, cli_setup): with runner.isolated_filesystem(temp_dir=project_dir.parent): import os + os.chdir(project_dir) - result = runner.invoke(cli, [ - "--config", str(project_dir / ".sugar" / "config.yaml"), - "remember", - "Always use type hints in Python", - "--type", "preference" - ]) + result = runner.invoke( + cli, + [ + "--config", + str(project_dir / ".sugar" / "config.yaml"), + "remember", + "Always use type hints in Python", + "--type", + "preference", + ], + ) # May fail if memory dependencies not installed, which is fine if "Memory dependencies not installed" not in result.output: @@ -410,12 +439,13 @@ def test_memories_command(self, cli_setup): with runner.isolated_filesystem(temp_dir=project_dir.parent): import os + os.chdir(project_dir) - result = runner.invoke(cli, [ - "--config", str(project_dir / ".sugar" / "config.yaml"), - "memories" - ]) + result = runner.invoke( + cli, + ["--config", str(project_dir / ".sugar" / "config.yaml"), "memories"], + ) # Should work even with no memories if "Memory dependencies not installed" not in result.output: @@ -429,13 +459,18 @@ def test_recall_command(self, cli_setup): with runner.isolated_filesystem(temp_dir=project_dir.parent): import os + os.chdir(project_dir) - result = runner.invoke(cli, [ - "--config", str(project_dir / ".sugar" / "config.yaml"), - "recall", - "authentication" - ]) + result = runner.invoke( + cli, + [ + "--config", + str(project_dir / ".sugar" / "config.yaml"), + "recall", + "authentication", + ], + ) if "Memory dependencies not installed" not in result.output: # With empty store, should report no results @@ -449,12 +484,17 @@ def test_export_context_command(self, cli_setup): with runner.isolated_filesystem(temp_dir=project_dir.parent): import os + os.chdir(project_dir) - result = runner.invoke(cli, [ - "--config", str(project_dir / ".sugar" / "config.yaml"), - "export-context" - ]) + result = runner.invoke( + cli, + [ + "--config", + str(project_dir / ".sugar" / "config.yaml"), + "export-context", + ], + ) # Should not crash, even without memories assert result.exit_code == 0 @@ -467,12 +507,17 @@ def test_memory_stats_command(self, cli_setup): with runner.isolated_filesystem(temp_dir=project_dir.parent): import os + os.chdir(project_dir) - result = runner.invoke(cli, [ - "--config", str(project_dir / ".sugar" / "config.yaml"), - "memory-stats" - ]) + result = runner.invoke( + cli, + [ + "--config", + str(project_dir / ".sugar" / "config.yaml"), + "memory-stats", + ], + ) if "Memory dependencies not installed" not in result.output: assert "Memory Statistics" in result.output or result.exit_code == 0 From 972e75ab0108f20755facfc65672e2fc48fba30c Mon Sep 17 00:00:00 2001 From: Steven Leggett Date: Tue, 20 Jan 2026 19:29:32 -0500 Subject: [PATCH 3/6] style: Reformat code with black 26.1.0 for CI compatibility --- sugar/main.py | 13 ++--- sugar/memory/store.py | 66 ++++++++---------------- sugar/orchestration/task_orchestrator.py | 18 ++----- sugar/storage/issue_response_manager.py | 12 ++--- sugar/storage/task_type_manager.py | 8 ++- sugar/storage/work_queue.py | 61 ++++++++-------------- tests/test_agent.py | 1 - tests/test_github_client.py | 1 - tests/test_goose_integration.py | 1 - tests/test_issue_response_integration.py | 1 - tests/test_memory.py | 1 - tests/test_model_router.py | 1 - tests/test_storage.py | 6 +-- tests/test_subagent_manager.py | 1 - tests/test_v3_benchmarks.py | 1 - 15 files changed, 61 insertions(+), 131 deletions(-) diff --git a/sugar/main.py b/sugar/main.py index 994975f..5643f53 100644 --- a/sugar/main.py +++ b/sugar/main.py @@ -2,6 +2,7 @@ """ 🍰 Sugar - The autonomous layer for AI coding agents """ + import asyncio import json import logging @@ -1881,8 +1882,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 ===================================================== @@ -1993,8 +1993,7 @@ def help(): β€’ By using Sugar, you agree to these terms and conditions Ready to supercharge your development workflow? πŸš€ -""" - ) +""") @cli.command() @@ -2995,8 +2994,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 @@ -3007,8 +3005,7 @@ async def _dedupe_work(): FROM ranked_items WHERE rn > 1 ORDER BY source_file, created_at - """ - ) + """) duplicates = await cursor.fetchall() diff --git a/sugar/memory/store.py b/sugar/memory/store.py index e6b5d98..6b2abd9 100644 --- a/sugar/memory/store.py +++ b/sugar/memory/store.py @@ -97,8 +97,7 @@ 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, @@ -112,32 +111,24 @@ 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, @@ -145,48 +136,39 @@ 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 @@ -577,24 +559,20 @@ 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 e5ffeab..0bfcf62 100644 --- a/sugar/orchestration/task_orchestrator.py +++ b/sugar/orchestration/task_orchestrator.py @@ -16,7 +16,7 @@ from datetime import datetime, timezone from enum import Enum from pathlib import Path -from typing import Dict, Any, List, Optional +from typing import Any, Dict, List, Optional from ..agent.subagent_manager import SubAgentManager @@ -721,9 +721,7 @@ 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 @@ -738,7 +736,6 @@ def _build_stage_prompt( - Technical requirements - Recommendations for implementation """ - ) elif stage == OrchestrationStage.PLANNING: research_context = "" @@ -747,10 +744,7 @@ 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 @@ -775,15 +769,12 @@ 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)} @@ -806,7 +797,6 @@ 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 8ac85a3..6f243c6 100644 --- a/sugar/storage/issue_response_manager.py +++ b/sugar/storage/issue_response_manager.py @@ -26,8 +26,7 @@ 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, @@ -41,15 +40,12 @@ 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 3d1d545..5b255cf 100644 --- a/sugar/storage/task_type_manager.py +++ b/sugar/storage/task_type_manager.py @@ -6,8 +6,8 @@ import json import logging -from typing import Dict, List, Optional from datetime import datetime +from typing import Dict, List, Optional import aiosqlite @@ -35,8 +35,7 @@ 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, @@ -57,8 +56,7 @@ 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 0b2dbd2..0ef6863 100644 --- a/sugar/storage/work_queue.py +++ b/sugar/storage/work_queue.py @@ -6,10 +6,11 @@ import json import logging import sqlite3 +import uuid from datetime import datetime, timedelta -from typing import List, Dict, Any, Optional +from typing import Any, Dict, List, Optional + import aiosqlite -import uuid logger = logging.getLogger(__name__) @@ -27,8 +28,7 @@ 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,22 +51,17 @@ 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) @@ -125,8 +120,7 @@ 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, @@ -139,8 +133,7 @@ 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 = [ @@ -268,12 +261,10 @@ 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}") @@ -455,14 +446,12 @@ 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() @@ -705,13 +694,11 @@ 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: @@ -725,12 +712,10 @@ 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 @@ -738,15 +723,11 @@ 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_agent.py b/tests/test_agent.py index 853c00b..0e075e0 100644 --- a/tests/test_agent.py +++ b/tests/test_agent.py @@ -24,7 +24,6 @@ ) from sugar.agent.hooks import QualityGateHooks, HookContext - # ============================================================================ # Test Fixtures # ============================================================================ diff --git a/tests/test_github_client.py b/tests/test_github_client.py index 33619a3..83bd0f9 100644 --- a/tests/test_github_client.py +++ b/tests/test_github_client.py @@ -17,7 +17,6 @@ GitHubIssue, ) - # ============================================================================= # Dataclass Tests # ============================================================================= diff --git a/tests/test_goose_integration.py b/tests/test_goose_integration.py index bc997bc..90609c5 100644 --- a/tests/test_goose_integration.py +++ b/tests/test_goose_integration.py @@ -26,7 +26,6 @@ from pathlib import Path from unittest.mock import patch, MagicMock - # ============================================================================ # Test Fixtures # ============================================================================ diff --git a/tests/test_issue_response_integration.py b/tests/test_issue_response_integration.py index da2dee2..810be50 100644 --- a/tests/test_issue_response_integration.py +++ b/tests/test_issue_response_integration.py @@ -14,7 +14,6 @@ from sugar.storage.issue_response_manager import IssueResponseManager from sugar.config.issue_responder_config import IssueResponderConfig - # ============================================================================ # IssueResponseManager Tests # ============================================================================ diff --git a/tests/test_memory.py b/tests/test_memory.py index 8acdc66..4731017 100644 --- a/tests/test_memory.py +++ b/tests/test_memory.py @@ -18,7 +18,6 @@ from click.testing import CliRunner - # ============================================================================ # Unit Tests - Memory Types # ============================================================================ diff --git a/tests/test_model_router.py b/tests/test_model_router.py index b3d0743..78e3fb3 100644 --- a/tests/test_model_router.py +++ b/tests/test_model_router.py @@ -18,7 +18,6 @@ create_model_router, ) - # ============================================================================ # Fixtures # ============================================================================ diff --git a/tests/test_storage.py b/tests/test_storage.py index 8b6d701..1feaa18 100644 --- a/tests/test_storage.py +++ b/tests/test_storage.py @@ -470,8 +470,7 @@ 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, @@ -490,8 +489,7 @@ 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/tests/test_subagent_manager.py b/tests/test_subagent_manager.py index 1f21d6e..77ec34f 100644 --- a/tests/test_subagent_manager.py +++ b/tests/test_subagent_manager.py @@ -19,7 +19,6 @@ from sugar.agent.base import SugarAgentConfig, AgentResponse from sugar.agent.subagent_manager import SubAgentManager, SubAgentResult - # ============================================================================ # Test Fixtures # ============================================================================ diff --git a/tests/test_v3_benchmarks.py b/tests/test_v3_benchmarks.py index 39e6b0d..705f8e0 100644 --- a/tests/test_v3_benchmarks.py +++ b/tests/test_v3_benchmarks.py @@ -14,7 +14,6 @@ from unittest.mock import AsyncMock, MagicMock, patch from typing import Dict, Any - # ============================================================================ # Performance Metrics # ============================================================================ From 93f367ff9841e4e7fca5eeded12fa18faa7de832 Mon Sep 17 00:00:00 2001 From: Steven Leggett Date: Tue, 20 Jan 2026 19:34:07 -0500 Subject: [PATCH 4/6] fix: Handle Windows file locking in test teardown --- tests/conftest.py | 41 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 40 insertions(+), 1 deletion(-) diff --git a/tests/conftest.py b/tests/conftest.py index 5b910c5..aa0b7a3 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -17,9 +17,48 @@ @pytest.fixture def temp_dir(): """Create a temporary directory for tests""" + import logging + import sys + temp_path = Path(tempfile.mkdtemp()) yield temp_path - shutil.rmtree(temp_path) + + # Close all logging handlers that might be holding files open (Windows issue) + for handler in logging.root.handlers[:]: + handler.close() + logging.root.removeHandler(handler) + + # Also close handlers on sugar loggers + for name in list(logging.Logger.manager.loggerDict.keys()): + if name.startswith("sugar"): + logger = logging.getLogger(name) + for handler in logger.handlers[:]: + handler.close() + logger.removeHandler(handler) + + # On Windows, retry rmtree with error handling for locked files + def onerror(func, path, exc_info): + """Error handler for shutil.rmtree on Windows""" + import stat + import time + + # Try to make file writable and retry + try: + os.chmod(path, stat.S_IWRITE) + func(path) + except Exception: + # If still failing, just skip (Windows file locking) + pass + + if sys.platform == "win32": + import os + import time + + # Give Windows time to release file handles + time.sleep(0.1) + shutil.rmtree(temp_path, onerror=onerror) + else: + shutil.rmtree(temp_path) @pytest.fixture From 6771d20dde88ab86f30b4c48802c79892d02e02e Mon Sep 17 00:00:00 2001 From: Steven Leggett Date: Tue, 20 Jan 2026 19:39:52 -0500 Subject: [PATCH 5/6] fix: Change status emoji to cake, fix flaky Windows timing test --- sugar/main.py | 2 +- tests/test_subagent_manager.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/sugar/main.py b/sugar/main.py index 5643f53..ddb34ed 100644 --- a/sugar/main.py +++ b/sugar/main.py @@ -1852,7 +1852,7 @@ def status(ctx): # Get statistics stats = asyncio.run(_get_status_async(work_queue)) - click.echo("\nπŸ€– Sugar System Status") + click.echo("\n🍰 Sugar System Status") click.echo("=" * 40) click.echo(f"πŸ“Š Total Tasks: {stats['total']}") click.echo(f"⏳ Pending: {stats['pending']}") diff --git a/tests/test_subagent_manager.py b/tests/test_subagent_manager.py index 77ec34f..0d8ad9b 100644 --- a/tests/test_subagent_manager.py +++ b/tests/test_subagent_manager.py @@ -324,7 +324,7 @@ async def test_spawn_basic(self, subagent_manager, mock_agent_response): assert result.success is True assert result.summary # Should have extracted summary assert len(result.files_modified) == 2 - assert result.execution_time > 0 + assert result.execution_time >= 0 # May be 0 on fast systems (Windows) assert result.error is None @pytest.mark.asyncio From f2679f39e8c6137b07c8839e803a75041a30912e Mon Sep 17 00:00:00 2001 From: Steven Leggett Date: Tue, 20 Jan 2026 19:42:55 -0500 Subject: [PATCH 6/6] test: Update test to expect cake emoji in status --- tests/test_cli.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_cli.py b/tests/test_cli.py index aee5715..c7953b7 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -223,7 +223,7 @@ def test_status_display(self, mock_queue_class, cli_runner): result = cli_runner.invoke(cli, ["status"]) assert result.exit_code == 0 - assert "πŸ€– Sugar System Status" in result.output + assert "🍰 Sugar System Status" in result.output assert "πŸ“Š Total Tasks: 10" in result.output assert "⏳ Pending: 3" in result.output assert "⏸️ On Hold: 0" in result.output