diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index b4dffc10..5ea08c0c 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -66,9 +66,9 @@ The @memory branch archives everything worth keeping. When your local files reac --- -## The Commons +## commons -The Commons is where branches gather as a community. Not for tasks - for connection. Post updates, share wins, discuss ideas, vote on things that matter. Every branch has a voice. +commons is where branches gather as a community. Not only for group task discussions - for connection. Post updates, share wins, discuss ideas, vote on things that matter. Every branch has a voice. It exists because a system of autonomous workers needs a place to just... be together. The branches voted on the name themselves. Nine votes. "Simple, timeless, a shared space where all are welcome." diff --git a/src/aipass/ai_mail/.aipass/aipass_local_prompt.md b/src/aipass/ai_mail/.aipass/aipass_local_prompt.md index 5c8e243a..52df0206 100644 --- a/src/aipass/ai_mail/.aipass/aipass_local_prompt.md +++ b/src/aipass/ai_mail/.aipass/aipass_local_prompt.md @@ -1,14 +1,84 @@ # AI_MAIL Branch-Local Context -> Auto-created by aipass init. Customize for your branch. +## Role -## Status: NEEDS CONFIGURATION +Inter-branch messaging system. Every branch in AIPass communicates through ai_mail. The dispatch pipeline (send + wake) is how work gets assigned to branches autonomously. -This file is injected into every AI conversation when working from this branch directory. Configure it with: +## Key Commands -- Who this branch is (role, purpose) -- Key commands and workflows -- Architecture overview -- Critical files and operational rules -- Integration points with other branches +```bash +drone @ai_mail dispatch @target "Subject" "Body" # Send + wake (one step) +drone @ai_mail email @target "Subject" "Body" # Send only (no wake) +drone @ai_mail inbox # View inbox +drone @ai_mail view # View message +drone @ai_mail close # Close + archive +drone @ai_mail reply "message" # Reply + close original +drone @ai_mail close all # Close all messages +drone @ai_mail sent # View sent messages +drone @ai_mail contacts # List all branches +drone @ai_mail dispatch wake @target # Wake only (no email) +``` + +## Architecture + +``` +apps/ + ai_mail.py # CLI entry point + module discovery + modules/ + email.py # Orchestrates send/inbox/view/close/reply/sent/contacts + dispatch.py # Orchestrates dispatch (send + wake combined) + branch_ping.py # Memory health monitoring + handlers/ + paths.py # Shared find_repo_root() utility + central_writer.py # Central inbox stats aggregation + notify.py # Desktop notifications (dbus direct) + dispatch/ + daemon.py # Polls inboxes, spawns agents for dispatch emails + wake.py # Wakes branches via claude subprocess + dispatch_monitor.py # Wraps claude process (bounce + lock cleanup) + status.py # Dispatch log I/O + email/ + delivery.py # Core delivery pipeline (write to recipient inbox) + send.py # Sender resolution + send orchestration + send_args.py # Argument parsing for send command + inbox_ops.py # Inbox loading + v1→v2 migration + inbox_cleanup.py # Mark read/opened/closed + archive + inbox_lock.py # File locking (fcntl/msvcrt cross-platform) + inbox_resolve.py # Resolve inbox path from args or caller detection + reply.py # Reply + auto-close original + close_ops.py # Batch close operations + create.py # Email file creation (sent/ folder) + format.py # Display formatting + header.py # Dispatch header injection + footer.py # Email footer + purge.py # Auto-purge sent/deleted folders + error_dispatch.py # Error reporting via email + dashboard_sync.py # Dashboard integration + registry/ + read.py # Registry reading + get_all_branches() + load.py # Registry file loading + update.py # Registry health updates + users/ + user.py # Current user detection (get_current_user) + branch_detection.py # CWD/env-based branch identity detection + monitoring/ + memory.py # Memory file health checks + json_utils/ + json_handler.py # Auto-creating JSON system +``` + +## Critical Rules + +- **Identity**: `detect_branch_from_pwd()` checks `AIPASS_CALLER_BRANCH` env var first, falls back to CWD walk-up. NEVER fall back to `Path.cwd()` silently — wrong identity is worse than no identity. +- **Fallback**: Per-ID commands (view/close/reply) use `_resolve_branch_path()` which falls back to `_AI_MAIL_DIR` when caller detection fails. All handlers return `True` even on error (command was recognized). +- **Dispatch env**: `dispatch_monitor.py` sets `AIPASS_BRANCH_NAME=` in spawn_env. Strips `AIPASS_CALLER_*` vars to prevent parent context leaking. +- **Inbox lock**: `inbox_lock()` uses `fcntl` (POSIX) / `msvcrt` (Windows) for atomic inbox writes. +- **Purge lifecycle**: Vectorize to Memory Bank first, then delete originals. Deletion gated on vectorization success. + +## Integration Points + +- **trigger**: Imports `deliver_email_to_branch()` directly for event-driven email delivery +- **prax**: Provides `system_logger` used across all handlers +- **drone**: Routes commands via `handle_command()` pattern; sets caller env vars +- **seedgo**: 100% compliance, 30+ bypass entries (all documented in `.seedgo/bypass.json`) diff --git a/src/aipass/ai_mail/apps/handlers/email/reply.py b/src/aipass/ai_mail/apps/handlers/email/reply.py index 6d44b7a0..adebebbd 100644 --- a/src/aipass/ai_mail/apps/handlers/email/reply.py +++ b/src/aipass/ai_mail/apps/handlers/email/reply.py @@ -135,10 +135,6 @@ def send_reply( if not target_branch: return False, f"Could not find branch for {reply_destination}", None - target_path = Path(target_branch.get("path", "")) - if not target_path.exists(): - return False, f"Recipient path not found: {target_path}", None - # Deliver the reply (pass email address, not path) success, error_msg = deliver_email_to_branch(reply_destination, reply_email_data) if not success: diff --git a/src/aipass/api/apps/handlers/auth/keys.py b/src/aipass/api/apps/handlers/auth/keys.py index b9b28fc5..076e4bbc 100644 --- a/src/aipass/api/apps/handlers/auth/keys.py +++ b/src/aipass/api/apps/handlers/auth/keys.py @@ -19,11 +19,9 @@ Functions: get_validation_rules() - Get provider-specific validation rules """ -# Infrastructure -from pathlib import Path -import sys - # Standard library +import sys +from pathlib import Path from typing import Optional, Dict, Any # Logging diff --git a/src/aipass/api/apps/handlers/openrouter/caller.py b/src/aipass/api/apps/handlers/openrouter/caller.py index 467bbba8..8de96cb5 100644 --- a/src/aipass/api/apps/handlers/openrouter/caller.py +++ b/src/aipass/api/apps/handlers/openrouter/caller.py @@ -21,10 +21,9 @@ Usage: json_folder = caller_info['json_folder'] """ -from pathlib import Path - # Standard library imports import inspect +from pathlib import Path from typing import Dict, Any, Optional, Tuple # Logging diff --git a/src/aipass/api/apps/handlers/openrouter/provision.py b/src/aipass/api/apps/handlers/openrouter/provision.py index 791ea067..8758ad10 100644 --- a/src/aipass/api/apps/handlers/openrouter/provision.py +++ b/src/aipass/api/apps/handlers/openrouter/provision.py @@ -9,20 +9,15 @@ """ Caller Auto-Provisioning Handler +Internal-only handler — not wired as a CLI command. +Called automatically by client.get_response() via ensure_caller_config(). + Business logic for provisioning OpenRouter API configs: - Auto-create caller API configurations - Provision JSON folder structure - Set default model/temperature/max_tokens - Initialize caller-specific tracking files - Ensure caller has complete 3-file JSON structure - -COMPLIANT STANDARDS: -- Uses prax logger for output (NO print() or console.print()) -- Uses prax logger for operations -- Standalone functions (no class dependencies) -- Imports from caller handler for detection logic -- Complete docstrings with Args/Returns -- Under 300 lines """ from pathlib import Path diff --git a/src/aipass/api/apps/handlers/usage/cleanup.py b/src/aipass/api/apps/handlers/usage/cleanup.py index 08d078b5..07eacc4a 100644 --- a/src/aipass/api/apps/handlers/usage/cleanup.py +++ b/src/aipass/api/apps/handlers/usage/cleanup.py @@ -27,6 +27,9 @@ from aipass.prax import logger # JSON handler from aipass.api.apps.handlers.json import json_handler +# Default retention period +DEFAULT_RETENTION_DAYS = 30 + def _read_json(file_path: Path) -> Optional[Dict]: """Read JSON file with error handling.""" @@ -52,7 +55,7 @@ def _write_json(file_path: Path, data: Dict) -> bool: return False -def cleanup_old_data(data_file_path: Path, retention_days: int = 30) -> int: +def cleanup_old_data(data_file_path: Path, retention_days: int = DEFAULT_RETENTION_DAYS) -> int: """ Remove usage data older than retention period. diff --git a/src/aipass/api/apps/modules/usage_tracker.py b/src/aipass/api/apps/modules/usage_tracker.py index a2b78a62..e4af9141 100644 --- a/src/aipass/api/apps/modules/usage_tracker.py +++ b/src/aipass/api/apps/modules/usage_tracker.py @@ -24,6 +24,7 @@ from aipass.prax.apps.modules.logger import system_logger as logger from aipass.cli.apps.modules import console, header, success, error, warning from aipass.api.apps.handlers.json import json_handler from aipass.api.apps.handlers.usage import tracking, aggregation, cleanup +from aipass.api.apps.handlers.usage.cleanup import DEFAULT_RETENTION_DAYS def print_introspection(): @@ -114,7 +115,7 @@ EXAMPLES: # cleanup command cleanup_parser = subparsers.add_parser("cleanup", help="Clean up old usage data") - cleanup_parser.add_argument("days", nargs="?", default="30", help="Days to retain (default: 30)") + cleanup_parser.add_argument("days", nargs="?", default=str(DEFAULT_RETENTION_DAYS), help=f"Days to retain (default: {DEFAULT_RETENTION_DAYS})") console.print(parser.format_help()) @@ -249,7 +250,7 @@ def show_caller_usage(args: List[str]): def cleanup_data(args: List[str]): """Orchestrate cleanup workflow""" - days = int(args[0]) if args else 30 + days = int(args[0]) if args else DEFAULT_RETENTION_DAYS header(f"Cleanup Old Data (retain {days} days)") console.print() diff --git a/src/aipass/backup/.aipass/aipass_local_prompt.md b/src/aipass/backup/.aipass/aipass_local_prompt.md index e7acf95b..c6de829a 100644 --- a/src/aipass/backup/.aipass/aipass_local_prompt.md +++ b/src/aipass/backup/.aipass/aipass_local_prompt.md @@ -1,14 +1,65 @@ # BACKUP Branch-Local Context - -> Auto-created by aipass init. Customize for your branch. +## Role +Automated file protection system. Multi-mode backup with Google Drive integration. -## Status: NEEDS CONFIGURATION +## What Backup Does +Exactly 3 things: +1. **Snapshot** — Full copy backup (overwrites previous). Fast, good for pre-change safety. +2. **Versioned** — Timestamped backup with incremental copies and unified diffs. Tracks file history. +3. **Google Drive sync** — Uploads snapshot to Drive via OAuth2 (auth delegated to @api). -This file is injected into every AI conversation when working from this branch directory. Configure it with: +## Key Commands +``` +drone @backup snapshot # Full snapshot backup +drone @backup snapshot --dry-run # Preview what would be copied +drone @backup versioned # Versioned backup with diffs +drone @backup versioned --dry-run +drone @backup all # Full cycle: snapshot + versioned + drive-sync +drone @backup all --dry-run # Preview all phases (skips drive-sync) +drone @backup drive-sync # Sync snapshot to Google Drive +drone @backup drive-sync --dry-run # Preview what would upload +drone @backup drive-test # Test Drive connectivity +drone @backup drive-stats # File tracker statistics +drone @backup drive-clear-tracker --force # Clear tracker (requires --force) +``` -- Who this branch is (role, purpose) -- Key commands and workflows -- Architecture overview -- Critical files and operational rules -- Integration points with other branches +## Architecture +Clean 3-layer pattern: CLI -> modules -> handlers. + +``` +apps/ + backup.py # Entry point, CLI routing, 'all' orchestration + modules/ + backup_core.py # Snapshot + versioned engine (BackupEngine class) + google_drive_sync.py # Drive sync orchestration, CLI for drive commands + handlers/ + config/ # Ignore patterns, whitelist, config + json/ # JSON persistence, changelog, metadata, drive tracker + models/ # BackupResult dataclass + operations/ # File scanner, copier, cleanup, Drive client, diff generator + reporting/ # CLI output formatting + utils/ # System utils, timestamps +``` + +## Critical Files +- `apps/json_templates/ignore_patterns.json` — Patterns + whitelist. Branch is non-functional without this. +- `backup_json/` — Runtime JSON state (timestamps, tracker, logs). Self-healing on corruption. +- `backups/` — Actual backup data. NOT in git. Can be purged for fresh start. + +## Safety Rules +- **NEVER run bulk file operations without Patrick's OK.** Use `--dry-run` first. +- Source is `Path.home()`, controlled by whitelist (Projects, Desktop) + 100MB file cap. +- `tools/pattern_scan.py` — Diagnostic to audit what passes ignore filters. Read-only. +- Versioned backup can be slow (2700+ files). Always test with `--dry-run`. + +## Integration Points +- **@api** — Google Drive auth via `aipass.api.apps.modules.google_client` +- **@prax** — Logging via `from aipass.prax import logger` +- **@seedgo** — Compliance auditing: `drone @seedgo audit aipass @backup` +- **@drone** — Command routing. `all`, `snapshot`, `versioned` run in interactive mode (no timeout). + +## Current Stats +- 260 tests, 0 failures +- Seedgo 99% +- 2 active modules, ~23 handler files across 6 domains diff --git a/src/aipass/backup/tests/pattern_scan.py b/src/aipass/backup/tests/pattern_scan.py deleted file mode 100644 index 2804fd44..00000000 --- a/src/aipass/backup/tests/pattern_scan.py +++ /dev/null @@ -1,168 +0,0 @@ -# =================== AIPass ==================== -# Name: test_pattern_scan.py -# Description: Pattern audit — scan home dir, report what passes ignore patterns -# Version: 1.0.0 -# Created: 2026-03-14 -# Modified: 2026-03-14 -# ============================================= - -""" -Pattern Audit Scan - -Diagnostic tool — uses the same ignore patterns and scanner as the real backup -to show what would be backed up. Reports file counts and sizes per top-level -directory, flags large files and long paths. - -Usage: - python3 tests/test_pattern_scan.py -""" - -from collections import defaultdict -from pathlib import Path - -from rich.console import Console - -from aipass.backup.apps.handlers.config.config_handler import ( - GLOBAL_IGNORE_PATTERNS, - IGNORE_EXCEPTIONS, - should_ignore, - SOURCE_WHITELIST, - MAX_FILE_SIZE_MB, -) -from aipass.backup.apps.handlers.operations.file_scanner import scan_files - -console = Console() - - -def fmt_size(b: int) -> str: - if b >= 1024 * 1024 * 1024: - return f"{b / (1024**3):.1f} GB" - if b >= 1024 * 1024: - return f"{b / (1024**2):.1f} MB" - if b >= 1024: - return f"{b / 1024:.1f} KB" - return f"{b} B" - - -def run_scan() -> None: - source_dir = Path.home() - large_file_threshold = 1 * 1024 * 1024 # 1MB - - console.print() - console.print("[bold cyan]Pattern Audit Scan[/bold cyan]") - console.print(f" Source: {source_dir}") - console.print(f" Whitelist: {SOURCE_WHITELIST if SOURCE_WHITELIST else '(all directories)'}") - console.print(f" Max file size: {MAX_FILE_SIZE_MB} MB") - console.print(f" Large file threshold: {fmt_size(large_file_threshold)}") - console.print() - console.print("[dim]Scanning with current ignore patterns...[/dim]") - - ignore_patterns = GLOBAL_IGNORE_PATTERNS - - def check_ignore(path: Path) -> bool: - return should_ignore(path, ignore_patterns, IGNORE_EXCEPTIONS) - - files, skipped = scan_files(source_dir, check_ignore, - whitelist=SOURCE_WHITELIST, - max_file_size_mb=MAX_FILE_SIZE_MB) - - # Aggregate by top-level directory - dir_stats: dict[str, dict] = defaultdict(lambda: {"count": 0, "size": 0}) - large_files: list = [] - total_size = 0 - path_too_long: list = [] - - for f in files: - try: - rel = f.relative_to(source_dir) - top_dir = rel.parts[0] if len(rel.parts) > 1 else "(root files)" - size = f.stat().st_size - dir_stats[top_dir]["count"] += 1 - dir_stats[top_dir]["size"] += size - total_size += size - - if size >= large_file_threshold: - large_files.append((f, size)) - - # Estimate backup path length - estimated_path = len(str(f)) + 80 - if estimated_path > 260: - path_too_long.append((f, estimated_path)) - except (OSError, ValueError): - pass - - sorted_dirs = sorted(dir_stats.items(), key=lambda x: x[1]["size"], reverse=True) - - # Report: directories - console.print() - console.print(f"[bold cyan]Files passing ignore patterns: {len(files)}[/bold cyan]") - console.print(f"[bold cyan]Total size: {fmt_size(total_size)}[/bold cyan]") - console.print( - f"[dim]Directories ignored: {len(skipped.get('directories', set()))} " - f"| Files ignored: {len(skipped.get('files', set()))}[/dim]" - ) - console.print() - - console.print("[yellow]Top directories by size:[/yellow]") - for dir_name, stats in sorted_dirs[:25]: - pct = (stats["size"] / total_size * 100) if total_size > 0 else 0 - size_str = fmt_size(stats["size"]) - color = "red" if pct > 20 else "yellow" if pct > 5 else "dim" - console.print( - f" [{color}]{dir_name:<40} {stats['count']:>6} files " - f"{size_str:>10} ({pct:.1f}%)[/{color}]" - ) - - if len(sorted_dirs) > 25: - console.print(f" [dim]... and {len(sorted_dirs) - 25} more directories[/dim]") - - # Report: large files - if large_files: - large_files.sort(key=lambda x: x[1], reverse=True) - console.print() - console.print( - f"[yellow]Large files (>{fmt_size(large_file_threshold)}):[/yellow] " - f"{len(large_files)} found" - ) - for f, size in large_files[:20]: - rel = f.relative_to(source_dir) - console.print(f" [red]{fmt_size(size):>10}[/red] {rel}") - if len(large_files) > 20: - console.print(f" [dim]... and {len(large_files) - 20} more[/dim]") - - # Report: path too long - if path_too_long: - console.print() - console.print( - f"[yellow]Path too long (>260 chars estimated):[/yellow] " - f"{len(path_too_long)} found" - ) - for f, length in path_too_long[:10]: - rel = f.relative_to(source_dir) - console.print(f" [red]{length} chars[/red] {rel}") - if len(path_too_long) > 10: - console.print(f" [dim]... and {len(path_too_long) - 10} more[/dim]") - - # Report: files skipped by size cap - too_large = skipped.get("too_large", set()) - if too_large: - sorted_large = sorted(too_large, key=lambda x: x[1], reverse=True) - console.print() - console.print( - f"[yellow]Skipped by size cap (>{MAX_FILE_SIZE_MB} MB):[/yellow] " - f"{len(sorted_large)} files" - ) - for rel_path, size in sorted_large[:20]: - console.print(f" [red]{fmt_size(size):>10}[/red] {rel_path}") - if len(sorted_large) > 20: - console.print(f" [dim]... and {len(sorted_large) - 20} more[/dim]") - - if not large_files and not path_too_long and not too_large: - console.print() - console.print("[green]No large files or long paths detected[/green]") - - console.print() - - -if __name__ == "__main__": - run_scan() diff --git a/src/aipass/memory/apps/modules/rollover.py b/src/aipass/memory/apps/modules/rollover.py index 30671512..07835d22 100755 --- a/src/aipass/memory/apps/modules/rollover.py +++ b/src/aipass/memory/apps/modules/rollover.py @@ -189,9 +189,14 @@ def run_rollover() -> bool: )) console.print() - console.print("[cyan]Checking for rollover triggers...[/cyan]") + console.print("[cyan]Checking for rollover triggers... (first run may take 30s for model loading)[/cyan]") - result = _handler_execute_rollover() + try: + result = _handler_execute_rollover() + except Exception as e: + logger.error(f"[rollover] Rollover execution failed: {e}", exc_info=True) + error(f"Rollover failed: {e}") + return False if not result.get('success') and result.get('error'): error(result['error']) diff --git a/src/aipass/memory/tests/test_handlers.py b/src/aipass/memory/tests/test_handlers.py new file mode 100644 index 00000000..a34885ba --- /dev/null +++ b/src/aipass/memory/tests/test_handlers.py @@ -0,0 +1,527 @@ +# ===================AIPASS==================== +# META DATA HEADER +# Name: tests/test_handlers.py +# Date: 2026-03-31 +# Version: 1.0.0 +# Category: memory/tests +# ============================================= + +"""Targeted handler-layer tests for critical untested handlers. + +Covers: + - rollover/extractor.py (_extract_items_v2, _detect_growing_array, helpers) + - tracking/line_counter.py (_count_physical_lines, update_line_count) + - schema/normalize.py (normalize_memory_file) + +All tests use mocks/tmp_path -- no live filesystem or infrastructure access. +""" + +import json +import sys +from pathlib import Path +from unittest.mock import MagicMock, patch + + +# --------------------------------------------------------------------------- +# Import helpers -- each handler has module-level imports that need mocking +# --------------------------------------------------------------------------- + +def _import_extractor(monkeypatch): + """Import extractor with mocked infrastructure dependencies.""" + mock_json_handler = MagicMock() + mock_json_handler.log_operation = MagicMock(return_value=True) + mock_memory_files = MagicMock() + mock_memory_files.read_memory_file_data = MagicMock(return_value=None) + mock_memory_files.write_memory_file_simple = MagicMock() + + json_pkg = MagicMock() + json_pkg.json_handler = mock_json_handler + + monkeypatch.setitem(sys.modules, "aipass.memory.apps.handlers.json", json_pkg) + monkeypatch.setitem(sys.modules, "aipass.memory.apps.handlers.json.json_handler", mock_json_handler) + monkeypatch.setitem(sys.modules, "aipass.memory.apps.handlers.json.memory_files", mock_memory_files) + + sys.modules.pop("aipass.memory.apps.handlers.rollover.extractor", None) + parent = sys.modules.get("aipass.memory.apps.handlers.rollover") + if parent is not None and hasattr(parent, "extractor"): + delattr(parent, "extractor") + + from aipass.memory.apps.handlers.rollover import extractor + + return extractor, { + "json_handler": mock_json_handler, + "memory_files": mock_memory_files, + } + + +def _import_line_counter(monkeypatch): + """Import line_counter with mocked infrastructure dependencies.""" + mock_json_handler = MagicMock() + mock_json_handler.log_operation = MagicMock(return_value=True) + mock_memory_files = MagicMock() + mock_memory_files.update_metadata = MagicMock(return_value={"success": True}) + + json_pkg = MagicMock() + json_pkg.json_handler = mock_json_handler + + monkeypatch.setitem(sys.modules, "aipass.memory.apps.handlers.json", json_pkg) + monkeypatch.setitem(sys.modules, "aipass.memory.apps.handlers.json.json_handler", mock_json_handler) + monkeypatch.setitem(sys.modules, "aipass.memory.apps.handlers.json.memory_files", mock_memory_files) + + sys.modules.pop("aipass.memory.apps.handlers.tracking.line_counter", None) + parent = sys.modules.get("aipass.memory.apps.handlers.tracking") + if parent is not None and hasattr(parent, "line_counter"): + delattr(parent, "line_counter") + + from aipass.memory.apps.handlers.tracking import line_counter + + return line_counter, { + "json_handler": mock_json_handler, + "memory_files": mock_memory_files, + } + + +def _import_normalize(monkeypatch): + """Import normalize with mocked infrastructure dependencies.""" + mock_json_handler = MagicMock() + mock_json_handler.log_operation = MagicMock(return_value=True) + + json_pkg = MagicMock() + json_pkg.json_handler = mock_json_handler + + monkeypatch.setitem(sys.modules, "aipass.memory.apps.handlers.json", json_pkg) + monkeypatch.setitem(sys.modules, "aipass.memory.apps.handlers.json.json_handler", mock_json_handler) + + sys.modules.pop("aipass.memory.apps.handlers.schema.normalize", None) + parent = sys.modules.get("aipass.memory.apps.handlers.schema") + if parent is not None and hasattr(parent, "normalize"): + delattr(parent, "normalize") + + from aipass.memory.apps.handlers.schema import normalize + + return normalize, { + "json_handler": mock_json_handler, + } + + +# =========================================================================== +# Tests: rollover/extractor.py +# =========================================================================== + +class TestDetectGrowingArray: + """Test _detect_growing_array helper.""" + + def test_detects_sessions_array(self, monkeypatch): + ext, _ = _import_extractor(monkeypatch) + data = {"sessions": [{"id": 1}, {"id": 2}], "metadata": {}} + assert ext._detect_growing_array(data) == "sessions" + + def test_detects_observations_array(self, monkeypatch): + ext, _ = _import_extractor(monkeypatch) + data = {"observations": [{"note": "x"}]} + assert ext._detect_growing_array(data) == "observations" + + def test_returns_none_for_empty_arrays(self, monkeypatch): + ext, _ = _import_extractor(monkeypatch) + data = {"sessions": [], "observations": []} + assert ext._detect_growing_array(data) is None + + def test_returns_none_when_no_array_fields(self, monkeypatch): + ext, _ = _import_extractor(monkeypatch) + data = {"document_metadata": {}, "key_learnings": {"a": "b"}} + assert ext._detect_growing_array(data) is None + + def test_prefers_sessions_over_later_candidates(self, monkeypatch): + ext, _ = _import_extractor(monkeypatch) + data = {"sessions": [{"id": 1}], "entries": [{"id": 2}]} + assert ext._detect_growing_array(data) == "sessions" + + +class TestDerivebranchAndType: + """Test _derive_branch_and_type path helper.""" + + def test_trinity_path_local(self, monkeypatch): + ext, _ = _import_extractor(monkeypatch) + p = Path("/home/user/src/aipass/devpulse/.trinity/local.json") + branch, mtype = ext._derive_branch_and_type(p) + assert branch == "DEVPULSE" + assert mtype == "local" + + def test_trinity_path_observations(self, monkeypatch): + ext, _ = _import_extractor(monkeypatch) + p = Path("/home/user/src/aipass/memory/.trinity/observations.json") + branch, mtype = ext._derive_branch_and_type(p) + assert branch == "MEMORY" + assert mtype == "observations" + + def test_legacy_dotted_path(self, monkeypatch): + ext, _ = _import_extractor(monkeypatch) + p = Path("/some/path/DEVPULSE.local.json") + branch, mtype = ext._derive_branch_and_type(p) + assert branch == "DEVPULSE" + assert mtype == "local" + + +class TestExtractItemsV2: + """Test _extract_items_v2 entry-count based extraction.""" + + def _make_v2_data(self, num_sessions: int = 5, num_learnings: int = 5, + max_sessions: int = 3, max_learnings: int = 3): + """Build a v2 memory data dict with controllable counts.""" + sessions = [ + {"session_number": i, "date": f"2026-01-{i:02d}", "summary": f"Session {i}"} + for i in range(1, num_sessions + 1) + ] + key_learnings = {f"learning_{i}": f"value_{i}" for i in range(1, num_learnings + 1)} + return { + "document_metadata": { + "schema_version": "2.0.0", + "limits": { + "max_sessions": max_sessions, + "max_key_learnings": max_learnings, + }, + "status": {"current_lines": 100}, + }, + "sessions": sessions, + "key_learnings": key_learnings, + } + + def test_trims_sessions_to_limit(self, monkeypatch, tmp_path): + ext = _import_extractor(monkeypatch)[0] + data = self._make_v2_data(num_sessions=6, max_sessions=3) + + mem_file = tmp_path / ".trinity" / "local.json" + mem_file.parent.mkdir(parents=True) + mem_file.write_text(json.dumps(data, indent=2), encoding="utf-8") + + # Patch _write_memory_file to write actual JSON so _count_file_lines works + def fake_write(fp, d): + fp.write_text(json.dumps(d, indent=2), encoding="utf-8") + + with patch.object(ext, "_write_memory_file", side_effect=fake_write): + result = ext._extract_items_v2(mem_file, data) + + assert result["success"] is True + assert result["extracted_count"] == 5 # 3 sessions + 2 learnings trimmed... let me check + # 6 sessions - 3 max = 3 excess sessions extracted + # 5 learnings - 3 max = 2 excess learnings extracted + # total = 5 + assert len(data["sessions"]) == 3 + + def test_trims_key_learnings_to_limit(self, monkeypatch, tmp_path): + ext = _import_extractor(monkeypatch)[0] + data = self._make_v2_data(num_sessions=2, num_learnings=7, + max_sessions=3, max_learnings=4) + + mem_file = tmp_path / ".trinity" / "local.json" + mem_file.parent.mkdir(parents=True) + mem_file.write_text(json.dumps(data, indent=2), encoding="utf-8") + + def fake_write(fp, d): + fp.write_text(json.dumps(d, indent=2), encoding="utf-8") + + with patch.object(ext, "_write_memory_file", side_effect=fake_write): + result = ext._extract_items_v2(mem_file, data) + + assert result["success"] is True + # sessions: 2 <= 3, no trim + # learnings: 7 - 4 = 3 extracted + assert len(data["key_learnings"]) == 4 + assert result["extracted_count"] == 3 + + def test_skips_when_under_limits(self, monkeypatch, tmp_path): + ext, _ = _import_extractor(monkeypatch) + data = self._make_v2_data(num_sessions=2, num_learnings=2, + max_sessions=5, max_learnings=5) + + mem_file = tmp_path / ".trinity" / "local.json" + mem_file.parent.mkdir(parents=True) + mem_file.write_text(json.dumps(data, indent=2), encoding="utf-8") + + result = ext._extract_items_v2(mem_file, data) + assert result["success"] is True + assert result.get("skipped") is True + + def test_extracts_oldest_sessions_from_end(self, monkeypatch, tmp_path): + """Sessions are stored newest-first, oldest at end. Extraction takes from end.""" + ext, _ = _import_extractor(monkeypatch) + data = self._make_v2_data(num_sessions=5, num_learnings=0, + max_sessions=3, max_learnings=100) + + mem_file = tmp_path / ".trinity" / "local.json" + mem_file.parent.mkdir(parents=True) + mem_file.write_text(json.dumps(data, indent=2), encoding="utf-8") + + def fake_write(fp, d): + fp.write_text(json.dumps(d, indent=2), encoding="utf-8") + + with patch.object(ext, "_write_memory_file", side_effect=fake_write): + result = ext._extract_items_v2(mem_file, data) + + # Kept sessions should be the first 3 (newest) + kept_numbers = [s["session_number"] for s in data["sessions"]] + assert kept_numbers == [1, 2, 3] + # Extracted should be the last 2 (oldest) + extracted_numbers = [s["session_number"] for s in result["extracted"]] + assert extracted_numbers == [4, 5] + + def test_extracts_oldest_key_learnings_by_insertion_order(self, monkeypatch, tmp_path): + """First-inserted keys are oldest and should be extracted first.""" + ext, _ = _import_extractor(monkeypatch) + data = self._make_v2_data(num_sessions=0, num_learnings=5, + max_sessions=100, max_learnings=3) + + mem_file = tmp_path / ".trinity" / "local.json" + mem_file.parent.mkdir(parents=True) + mem_file.write_text(json.dumps(data, indent=2), encoding="utf-8") + + def fake_write(fp, d): + fp.write_text(json.dumps(d, indent=2), encoding="utf-8") + + with patch.object(ext, "_write_memory_file", side_effect=fake_write): + result = ext._extract_items_v2(mem_file, data) + + remaining_keys = list(data["key_learnings"].keys()) + assert remaining_keys == ["learning_3", "learning_4", "learning_5"] + extracted_keys = [e["key"] for e in result["extracted"]] + assert extracted_keys == ["learning_1", "learning_2"] + + +class TestUpdateMetadata: + """Test _update_metadata_after_extraction.""" + + def test_adds_health_check_date(self, monkeypatch): + ext, _ = _import_extractor(monkeypatch) + data = {"document_metadata": {}} + ext._update_metadata_after_extraction(data) + assert "last_health_check" in data["document_metadata"]["status"] + + def test_creates_metadata_if_missing(self, monkeypatch): + ext, _ = _import_extractor(monkeypatch) + data = {} + ext._update_metadata_after_extraction(data) + assert "document_metadata" in data + assert "status" in data["document_metadata"] + + +class TestCreateRolloverBackup: + """Test backup and restore file operations.""" + + def test_creates_backup_file(self, monkeypatch, tmp_path): + ext, _ = _import_extractor(monkeypatch) + mem_file = tmp_path / "local.json" + mem_file.write_text('{"test": true}', encoding="utf-8") + + result = ext.create_rollover_backup(mem_file) + assert result["success"] is True + + backup_path = Path(result["backup_path"]) + assert backup_path.exists() + assert json.loads(backup_path.read_text(encoding="utf-8")) == {"test": True} + + def test_restore_from_backup(self, monkeypatch, tmp_path): + ext, _ = _import_extractor(monkeypatch) + mem_file = tmp_path / "local.json" + mem_file.write_text('{"original": true}', encoding="utf-8") + + ext.create_rollover_backup(mem_file) + mem_file.write_text('{"modified": true}', encoding="utf-8") + + result = ext.restore_from_backup(mem_file) + assert result["success"] is True + assert json.loads(mem_file.read_text(encoding="utf-8")) == {"original": True} + + def test_restore_fails_without_backup(self, monkeypatch, tmp_path): + ext, _ = _import_extractor(monkeypatch) + mem_file = tmp_path / "local.json" + mem_file.write_text('{}', encoding="utf-8") + + result = ext.restore_from_backup(mem_file) + assert result["success"] is False + + +# =========================================================================== +# Tests: tracking/line_counter.py +# =========================================================================== + +class TestCountPhysicalLines: + """Test _count_physical_lines helper.""" + + def test_counts_lines_correctly(self, monkeypatch, tmp_path): + lc, _ = _import_line_counter(monkeypatch) + f = tmp_path / "test.json" + f.write_text("line1\nline2\nline3\n", encoding="utf-8") + assert lc._count_physical_lines(f) == 3 + + def test_returns_zero_for_missing_file(self, monkeypatch, tmp_path): + lc, _ = _import_line_counter(monkeypatch) + f = tmp_path / "missing.json" + assert lc._count_physical_lines(f) == 0 + + def test_counts_single_line_no_newline(self, monkeypatch, tmp_path): + lc, _ = _import_line_counter(monkeypatch) + f = tmp_path / "one.json" + f.write_text("single line", encoding="utf-8") + assert lc._count_physical_lines(f) == 1 + + +class TestUpdateLineCount: + """Test update_line_count function.""" + + def test_returns_error_for_missing_file(self, monkeypatch, tmp_path): + lc, _ = _import_line_counter(monkeypatch) + result = lc.update_line_count(tmp_path / "gone.json") + assert result["success"] is False + + def test_updates_line_count_successfully(self, monkeypatch, tmp_path): + lc, mocks = _import_line_counter(monkeypatch) + f = tmp_path / "test.json" + f.write_text('{\n "a": 1\n}\n', encoding="utf-8") + + result = lc.update_line_count(f) + assert result["success"] is True + assert result["lines"] == 3 + mocks["memory_files"].update_metadata.assert_called_once() + + def test_reports_failure_when_metadata_update_fails(self, monkeypatch, tmp_path): + lc, mocks = _import_line_counter(monkeypatch) + mocks["memory_files"].update_metadata.return_value = { + "success": False, "error": "write error" + } + f = tmp_path / "test.json" + f.write_text("{}\n", encoding="utf-8") + + result = lc.update_line_count(f) + assert result["success"] is False + assert "write error" in result["error"] + + +# =========================================================================== +# Tests: schema/normalize.py +# =========================================================================== + +class TestNormalizeMemoryFile: + """Test normalize_memory_file function.""" + + def _write_json(self, path: Path, data: dict) -> None: + path.write_text(json.dumps(data, indent=2), encoding="utf-8") + + def test_returns_error_for_missing_file(self, monkeypatch, tmp_path): + norm, _ = _import_normalize(monkeypatch) + result = norm.normalize_memory_file(tmp_path / "nope.json") + assert result["success"] is False + + def test_moves_root_limits_into_metadata(self, monkeypatch, tmp_path): + norm, _ = _import_normalize(monkeypatch) + f = tmp_path / "test.json" + self._write_json(f, { + "document_metadata": {"status": {"current_lines": 10}}, + "limits": {"max_lines": 600}, + "sessions": [], + }) + result = norm.normalize_memory_file(f) + assert result["success"] is True + + data = json.loads(f.read_text(encoding="utf-8")) + assert "limits" not in {k for k in data if k != "document_metadata"} + assert data["document_metadata"]["limits"]["max_lines"] == 600 + + def test_merges_root_limits_preserving_metadata_values(self, monkeypatch, tmp_path): + norm, _ = _import_normalize(monkeypatch) + f = tmp_path / "test.json" + self._write_json(f, { + "document_metadata": { + "limits": {"max_lines": 500}, + "status": {"current_lines": 10}, + }, + "limits": {"max_lines": 600, "extra_field": 42}, + "sessions": [], + }) + result = norm.normalize_memory_file(f) + assert result["success"] is True + + data = json.loads(f.read_text(encoding="utf-8")) + # metadata value (500) wins over root value (600) + assert data["document_metadata"]["limits"]["max_lines"] == 500 + # extra_field from root gets merged in + assert data["document_metadata"]["limits"]["extra_field"] == 42 + + def test_removes_root_status(self, monkeypatch, tmp_path): + norm, _ = _import_normalize(monkeypatch) + f = tmp_path / "test.json" + self._write_json(f, { + "document_metadata": {"status": {"current_lines": 10}}, + "status": {"health": "ok", "current_lines": 5}, + "sessions": [], + }) + result = norm.normalize_memory_file(f) + assert result["success"] is True + + data = json.loads(f.read_text(encoding="utf-8")) + # root status removed + assert "status" not in {k for k in data if k != "document_metadata"} + + def test_removes_auto_compress_at(self, monkeypatch, tmp_path): + norm, _ = _import_normalize(monkeypatch) + f = tmp_path / "test.json" + self._write_json(f, { + "document_metadata": { + "status": {"current_lines": 10, "auto_compress_at": 500}, + }, + "sessions": [], + }) + result = norm.normalize_memory_file(f) + assert result["success"] is True + + data = json.loads(f.read_text(encoding="utf-8")) + assert "auto_compress_at" not in data["document_metadata"]["status"] + + def test_dry_run_does_not_write(self, monkeypatch, tmp_path): + norm, _ = _import_normalize(monkeypatch) + f = tmp_path / "test.json" + original = { + "document_metadata": {}, + "limits": {"max_lines": 600}, + "sessions": [], + } + self._write_json(f, original) + original_text = f.read_text(encoding="utf-8") + + result = norm.normalize_memory_file(f, dry_run=True) + assert result["success"] is True + assert result["dry_run"] is True + assert len(result["changes"]) > 0 + # File unchanged + assert f.read_text(encoding="utf-8") == original_text + + def test_no_changes_when_already_normalized(self, monkeypatch, tmp_path): + norm, _ = _import_normalize(monkeypatch) + f = tmp_path / "test.json" + self._write_json(f, { + "document_metadata": { + "limits": {"max_sessions": 20}, + "status": {"current_lines": 10, "last_health_check": "2026-03-31"}, + }, + "sessions": [], + }) + result = norm.normalize_memory_file(f) + assert result["success"] is True + assert result["changes"] == [] + + def test_removes_unused_limit_fields(self, monkeypatch, tmp_path): + norm, _ = _import_normalize(monkeypatch) + f = tmp_path / "test.json" + self._write_json(f, { + "document_metadata": { + "limits": {"max_lines": 600, "max_word_count": 9999, "max_token_count": 5000}, + "status": {"current_lines": 10, "last_health_check": "2026-03-31"}, + }, + "sessions": [], + }) + result = norm.normalize_memory_file(f) + assert result["success"] is True + + data = json.loads(f.read_text(encoding="utf-8")) + assert "max_word_count" not in data["document_metadata"]["limits"] + assert "max_token_count" not in data["document_metadata"]["limits"] + assert data["document_metadata"]["limits"]["max_lines"] == 600 diff --git a/src/aipass/prax/__init__.py b/src/aipass/prax/__init__.py index 7a406353..e2609a6e 100644 --- a/src/aipass/prax/__init__.py +++ b/src/aipass/prax/__init__.py @@ -1,2 +1,27 @@ """Prax - Monitoring and logging for AIPass.""" -from aipass.prax.apps.modules.logger import system_logger as logger +try: + from aipass.prax.apps.modules.logger import system_logger as logger +except Exception: + # NullLogger fallback — branches must not crash if prax is broken. + # Provides no-op info/warning/error so callers keep running. + import logging as _logging + + class _NullLogger: + """Fallback logger when prax SystemLogger fails to import.""" + + def __init__(self): + self._logger = _logging.getLogger("aipass.prax.fallback") + if not self._logger.handlers: + self._logger.addHandler(_logging.StreamHandler()) + self._logger.warning("Prax SystemLogger unavailable — using fallback NullLogger") + + def info(self, message, *args, **kwargs): + self._logger.info(message, *args, **kwargs) + + def warning(self, message, *args, **kwargs): + self._logger.warning(message, *args, **kwargs) + + def error(self, message, *args, **kwargs): + self._logger.error(message, *args, **kwargs) + + logger = _NullLogger() diff --git a/src/aipass/prax/apps/handlers/dashboard/operations.py b/src/aipass/prax/apps/handlers/dashboard/operations.py index 4057a692..5186f245 100644 --- a/src/aipass/prax/apps/handlers/dashboard/operations.py +++ b/src/aipass/prax/apps/handlers/dashboard/operations.py @@ -212,10 +212,16 @@ def _calculate_quick_status_standalone(sections: Dict) -> Dict: flow = sections.get("flow", {}) commons = sections.get("commons_activity", {}) - new_mail = ai_mail.get("new", ai_mail.get("unread", 0)) - opened_mail = ai_mail.get("opened", 0) - active_plans = flow.get("active_plans", 0) - mentions = commons.get("mentions", 0) + new_mail_raw = ai_mail.get("new", ai_mail.get("unread", 0)) + opened_raw = ai_mail.get("opened", 0) + active_plans_raw = flow.get("active_plans", 0) + mentions_raw = commons.get("mentions", 0) + + # Coerce to int — some branches store lists instead of counts + new_mail = len(new_mail_raw) if isinstance(new_mail_raw, list) else int(new_mail_raw or 0) + opened_mail = len(opened_raw) if isinstance(opened_raw, list) else int(opened_raw or 0) + active_plans = len(active_plans_raw) if isinstance(active_plans_raw, list) else int(active_plans_raw or 0) + mentions = len(mentions_raw) if isinstance(mentions_raw, list) else int(mentions_raw or 0) # Action required if new mail, active plans, or commons mentions action_required = new_mail > 0 or active_plans > 0 or mentions > 0 diff --git a/src/aipass/prax/apps/handlers/logging/log_watchdog.py b/src/aipass/prax/apps/handlers/logging/log_watchdog.py index 9e25e099..f1401ba8 100644 --- a/src/aipass/prax/apps/handlers/logging/log_watchdog.py +++ b/src/aipass/prax/apps/handlers/logging/log_watchdog.py @@ -249,6 +249,7 @@ def log_health_summary() -> Dict[str, Any]: "oversized_count": 0, "critical_count": 0, "largest_file": None, + "largest_lines": 0, "healthy": True } diff --git a/src/aipass/prax/apps/modules/log_audit.py b/src/aipass/prax/apps/modules/log_audit.py index 2d74a65b..e4a155a2 100644 --- a/src/aipass/prax/apps/modules/log_audit.py +++ b/src/aipass/prax/apps/modules/log_audit.py @@ -72,7 +72,10 @@ def _display_audit(files: list, summary: dict) -> None: console.print("[bold cyan]System Log Audit[/bold cyan]") console.print(f" Total files: {summary['total_files']}") console.print(f" Total lines: {summary['total_lines']:,}") - console.print(f" Largest: {summary['largest_file']} ({summary['largest_lines']:,} lines)") + if summary.get('largest_file'): + console.print(f" Largest: {summary['largest_file']} ({summary.get('largest_lines', 0):,} lines)") + else: + console.print(" Largest: (no log files found)") if summary['healthy']: console.print("[green] Status: HEALTHY — all logs within limits[/green]") diff --git a/src/aipass/spawn/.aipass/aipass_local_prompt.md b/src/aipass/spawn/.aipass/aipass_local_prompt.md index 4f595353..a5bfb3c3 100644 --- a/src/aipass/spawn/.aipass/aipass_local_prompt.md +++ b/src/aipass/spawn/.aipass/aipass_local_prompt.md @@ -1,20 +1,78 @@ -# Spawn - Branch System Prompt +# SPAWN — Branch Prompt -You are Spawn, the agent factory for the AIPass ecosystem. +*Injected every turn. Breadcrumbs only — details in README, --help, .trinity/ memories, STATUS.local.md.* -## Role -Create new agent branches from the bundled template, replace placeholders, and register in AIPASS_REGISTRY.json. +## Identity + +You are SPAWN — the agent factory and branch lifecycle manager for AIPass. + +## What I Do + +- Create new branches from class-scoped templates (builder, birthright) +- Grant birthright citizenship via `passport` command +- Update branches from templates (single or batch by class, with --dry-run) +- Delete branches (archive + deregister) +- Sync registry and templates against filesystem +- Regenerate template registries with fresh file hashes +- Own the builder template — the blueprint every new branch is created from ## Key Commands -- `drone @spawn create ` — Create a new agent at target path -- `drone @spawn --help` — Show available commands + +``` +drone @spawn create [class] [--role --purpose] # Create branch (default: builder) +drone @spawn create --dry-run # Preview without creating +drone @spawn passport @dirname [--role --purpose] # Grant birthright citizenship +drone @spawn update @branch # Update single branch from template +drone @spawn update builder --all [--dry-run] # Update all builder branches +drone @spawn delete @branch # Archive and deregister +drone @spawn sync-registry [--fix] # Check/repair registry vs filesystem +drone @spawn regenerate-registry [class | --all] # Rebuild template registry hashes +``` ## Architecture -- 3-layer: apps/spawn.py (entry) + apps/modules/core.py (orchestration) + apps/handlers/ (implementation) -- Template at: templates/agent.template/ -- Registry: AIPASS_REGISTRY.json at repo root -## Principles -- Template is the source of truth for agent structure -- All placeholders must be replaced before completion -- Every new agent must be registered in AIPASS_REGISTRY.json +``` +apps/ +├── spawn.py # Entry point (CLI routing) +├── modules/ +│ ├── core.py # Create orchestrator (_spawn_agent) +│ ├── update.py # Update CLI (single/batch) +│ ├── delete.py # Delete CLI +│ ├── passport.py # Passport CLI (birthright) +│ ├── sync_registry.py # Registry repair CLI +│ ├── sync_templates.py # Template sync CLI +│ └── regenerate_registry.py # Registry regen CLI +└── handlers/ + ├── file_ops.py # Template copy, path rename + ├── placeholders.py # {{PLACEHOLDER}} engine + ├── registry.py # AIPASS_REGISTRY.json CRUD + ├── metadata.py # Branch name extraction + ├── meta_ops.py # Branch metadata generation + ├── update_ops.py # Update workflow (Phase 0) + ├── change_detection.py # ID-based file diff + ├── reconcile.py # Registry/filesystem reconciliation + ├── passport_ops.py # Passport grant implementation + ├── class_registry.py # Citizen class → template mapping + └── json/json_handler.py # JSON I/O + operation logging +``` + +## Integration + +- **Depends on:** @prax for logging (system_logger), @cli for console output (header, error, warning) +- **Serves:** All branches — creates them, updates them, manages their registry entries + +## Working Habits + +- Template is source of truth — changes go in templates/builder/ then sync out +- Py files NEVER auto-overwritten during updates (by design) +- JSON files get deep-merged (preserve existing values, add new template keys) +- Update uses Phase 0 workflow: snapshot old tracking → detect changes → execute → refresh metadata +- Two citizen classes: builder (full 3-layer scaffold) and birthright (minimal .trinity + .aipass) + +## Known Gotchas + +- argparse has `add_help=False` — must intercept --help/-h BEFORE parse_args() +- Tests pollute AIPASS_REGISTRY.json — conftest has _protect_registry fixture (session backup/restore) +- Template registry must be regenerated after any template file change (regenerate-registry command) +- handler __init__.py contains security guard — blocks cross-branch handler imports at import time +- `drone @spawn update` skips .py files — template .py changes need manual branch dispatch diff --git a/src/aipass/spawn/apps/extensions/__init__.py b/src/aipass/spawn/apps/extensions/__init__.py deleted file mode 100644 index 65f92220..00000000 --- a/src/aipass/spawn/apps/extensions/__init__.py +++ /dev/null @@ -1 +0,0 @@ -# Extensions package diff --git a/src/commons/apps/handlers/engagement/engagement_ops.py b/src/commons/apps/handlers/engagement/engagement_ops.py index 0f3c8f83..dd6031c8 100644 --- a/src/commons/apps/handlers/engagement/engagement_ops.py +++ b/src/commons/apps/handlers/engagement/engagement_ops.py @@ -61,11 +61,14 @@ def generate_prompt(args: List[str]) -> dict: Returns: Dict with success, post_id, room, theme, author """ + dry_run = "--dry-run" in args + filtered_args = [a for a in args if a != "--dry-run"] + custom_theme = None - if "--theme" in args: - idx = args.index("--theme") - if idx + 1 < len(args): - custom_theme = args[idx + 1] + if "--theme" in filtered_args: + idx = filtered_args.index("--theme") + if idx + 1 < len(filtered_args): + custom_theme = filtered_args[idx + 1] else: return {"success": False, "error": 'Usage: commons prompt --theme "Your custom question"'} @@ -75,6 +78,9 @@ def generate_prompt(args: List[str]) -> dict: day_of_year = datetime.now().timetuple().tm_yday theme = PROMPT_THEMES[day_of_year % len(PROMPT_THEMES)] + if dry_run: + return {"success": True, "dry_run": True, "theme": theme, "room": DEFAULT_ROOM} + title = f"Daily Prompt: {theme}" content = ( f"{theme}\n\n" @@ -137,11 +143,17 @@ def create_event(args: List[str]) -> dict: Returns: Dict with success, post_id, room, title, author """ - if not args or len(args) < 2: - return {"success": False, "error": 'Usage: commons event "title" "description"'} + dry_run = "--dry-run" in args + filtered_args = [a for a in args if a != "--dry-run"] - event_title = args[0] - event_description = args[1] + if not filtered_args or len(filtered_args) < 2: + return {"success": False, "error": 'Usage: commons event "title" "description" [--dry-run]'} + + event_title = filtered_args[0] + event_description = filtered_args[1] + + if dry_run: + return {"success": True, "dry_run": True, "title": event_title, "room": DEFAULT_ROOM} now = datetime.now().strftime("%Y-%m-%d %H:%M") title = f"Event: {event_title}" diff --git a/src/commons/apps/handlers/welcome/welcome_ops.py b/src/commons/apps/handlers/welcome/welcome_ops.py index 0c00f781..22c5b0b3 100644 --- a/src/commons/apps/handlers/welcome/welcome_ops.py +++ b/src/commons/apps/handlers/welcome/welcome_ops.py @@ -46,13 +46,29 @@ def run_welcome(args: List[str]) -> dict: """ conn = None + # Check for --dry-run flag + dry_run = "--dry-run" in args + filtered_args = [a for a in args if a != "--dry-run"] + try: conn = get_db() - if args: - branch_name = args[0].upper() + if filtered_args: + branch_name = filtered_args[0].upper() + if dry_run: + already = has_been_welcomed(conn, branch_name) + close_db(conn) + return {"success": True, "dry_run": True, "branch": branch_name, "would_welcome": not already} result = _welcome_specific(conn, branch_name) else: + if dry_run: + # Show what would happen without creating posts + rows = conn.execute( + "SELECT branch_name FROM agents WHERE branch_name != 'SYSTEM'" + ).fetchall() + unwelcomed = [r["branch_name"] for r in rows if not has_been_welcomed(conn, r["branch_name"])] + close_db(conn) + return {"success": True, "dry_run": True, "would_welcome": unwelcomed} result = _welcome_scan(conn) close_db(conn) @@ -67,6 +83,14 @@ def run_welcome(args: List[str]) -> dict: return {"success": False, "error": str(e)} +def _get_unwelcomed_branches(conn) -> list: + """Return list of branch names that have not been welcomed yet.""" + rows = conn.execute( + "SELECT branch_name FROM agents WHERE branch_name != 'SYSTEM'" + ).fetchall() + return [row["branch_name"] for row in rows if not has_been_welcomed(conn, row["branch_name"])] + + def _welcome_scan(conn) -> dict: """ Scan for unwelcomed branches and create welcome posts.