feat(system): audit cleanup: spawn (#168)

* 📝 docs(STATUS): update last sync timestamp

- update last sync timestamp to reflect recent status synchronization

* feat(trigger): audit cleanup — fix timestamp parsing, CLI formatting, bypass entries

- Fix log_watcher timestamp parsing: log warning once after all formats fail,
  not on each format attempt (was generating hundreds of warnings per scan)
- Standardize rich formatting in branch_log_events and log_events print_help()
  to match medic.py/errors.py Panel+rule style
- Add bypass entries for intentional silent catches (timestamp loop, error display)
- Remove unused Path imports

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(memory): audit cleanup — handler tests + rollover timeout handling

- Add 32 handler-layer tests (extractor, line_counter, normalize) in test_handlers.py
- Add rollover run timeout message + try/except for graceful error handling
- Fix commons welcome_ops.py missing _get_unwelcomed_branches function

264 tests pass, 100% seedgo compliance.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(system): audit cleanup: spawn

Co-Authored-By: @devpulse <devpulse@aipass>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: @devpulse <devpulse@aipass>
This commit is contained in:
AIPass
2026-04-01 00:59:34 -07:00
committed by GitHub
co-authored by Claude Opus 4.6 @devpulse
parent 21583669df
commit 6e705eeb8e
20 changed files with 845 additions and 240 deletions
+2 -2
View File
@@ -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."
@@ -1,14 +1,84 @@
# AI_MAIL Branch-Local Context
<!-- Source: src/aipass/ai_mail/.aipass/aipass_local_prompt.md -->
> 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 <id> # View message
drone @ai_mail close <id> # Close + archive
drone @ai_mail reply <id> "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=<branch>` 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`)
@@ -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:
+2 -4
View File
@@ -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
@@ -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
@@ -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
@@ -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.
+3 -2
View File
@@ -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()
@@ -1,14 +1,65 @@
# BACKUP Branch-Local Context
<!-- Source: /home/patrick/Projects/AIPass/src/aipass/backup/.aipass/branch_system_prompt.md -->
> 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
-168
View File
@@ -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()
+7 -2
View File
@@ -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'])
+527
View File
@@ -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
+26 -1
View File
@@ -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()
@@ -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
@@ -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
}
+4 -1
View File
@@ -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]")
+71 -13
View File
@@ -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 <path>` — Create a new agent at target path
- `drone @spawn --help` — Show available commands
```
drone @spawn create [class] <path> [--role --purpose] # Create branch (default: builder)
drone @spawn create <path> --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
@@ -1 +0,0 @@
# Extensions package
@@ -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}"
@@ -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.