diff --git a/src/openjarvis/agents/_stubs.py b/src/openjarvis/agents/_stubs.py
index 2f8e3011..f25fcc9c 100644
--- a/src/openjarvis/agents/_stubs.py
+++ b/src/openjarvis/agents/_stubs.py
@@ -65,12 +65,14 @@ class BaseAgent(ABC):
bus: Optional[EventBus] = None,
temperature: float = 0.7,
max_tokens: int = 1024,
+ prompt_builder: Optional[Any] = None,
) -> None:
self._engine = engine
self._model = model
self._bus = bus
self._temperature = temperature
self._max_tokens = max_tokens
+ self._prompt_builder = prompt_builder
# ------------------------------------------------------------------
# Concrete helpers
@@ -104,8 +106,14 @@ class BaseAgent(ABC):
conversation messages, and finally the user input.
"""
messages: list[Message] = []
- if system_prompt:
- messages.append(Message(role=Role.SYSTEM, content=system_prompt))
+ if self._prompt_builder is not None:
+ effective_system_prompt = self._prompt_builder.build()
+ elif system_prompt:
+ effective_system_prompt = system_prompt
+ else:
+ effective_system_prompt = None
+ if effective_system_prompt:
+ messages.append(Message(role=Role.SYSTEM, content=effective_system_prompt))
if context and context.conversation.messages:
messages.extend(context.conversation.messages)
messages.append(Message(role=Role.USER, content=input))
diff --git a/src/openjarvis/agents/executor.py b/src/openjarvis/agents/executor.py
index 9ea32487..d6f7beb2 100644
--- a/src/openjarvis/agents/executor.py
+++ b/src/openjarvis/agents/executor.py
@@ -47,6 +47,24 @@ class AgentExecutor:
"""Deferred system injection — called after JarvisSystem is constructed."""
self._system = system
+ def run_ephemeral(
+ self,
+ agent_type: str,
+ system_prompt: str,
+ input_text: str,
+ tools: list[str] | None = None,
+ ) -> Any:
+ """Run a one-shot agent turn with no lifecycle tracking."""
+ from openjarvis.core.registry import AgentRegistry
+
+ agent_cls = AgentRegistry.get(agent_type)
+ agent = agent_cls(
+ engine=getattr(self._manager, '_engine', None),
+ system_prompt=system_prompt,
+ bus=self._bus,
+ )
+ return agent.run(input_text)
+
def execute_tick(self, agent_id: str) -> None:
"""Run one tick for the given agent.
diff --git a/src/openjarvis/agents/loop_guard.py b/src/openjarvis/agents/loop_guard.py
index 96af3b98..cd19983d 100644
--- a/src/openjarvis/agents/loop_guard.py
+++ b/src/openjarvis/agents/loop_guard.py
@@ -18,6 +18,7 @@ class LoopGuardConfig:
ping_pong_window: int = 6 # detect A-B-A-B cycling
poll_tool_budget: int = 5 # max calls to same polling tool
max_context_messages: int = 100 # context overflow threshold
+ warn_before_block: bool = True # warn on first cycle, block on second
@dataclass(slots=True)
@@ -25,6 +26,7 @@ class LoopVerdict:
"""Result of a loop guard check."""
blocked: bool = False
reason: str = ""
+ warned: bool = False
class LoopGuard:
@@ -47,26 +49,49 @@ class LoopGuard:
self._tool_sequence: deque[str] = deque(maxlen=config.ping_pong_window * 2)
# Track per-tool call counts (for polling budget)
self._per_tool_counts: dict[str, int] = {}
+ # Track cycle keys that have already been warned (for warn-before-block)
+ self._warned_cycles: set[str] = set()
- from openjarvis._rust_bridge import get_rust_module
- _rust = get_rust_module()
- self._rust_impl = _rust.LoopGuard(
- max_identical=config.max_identical_calls,
- max_ping_pong=(
- config.ping_pong_window // 2
- if config.ping_pong_window > 1
- else 2
- ),
- poll_budget=config.poll_tool_budget,
- )
+ try:
+ from openjarvis._rust_bridge import get_rust_module
+ _rust = get_rust_module()
+ self._rust_impl = _rust.LoopGuard(
+ max_identical=config.max_identical_calls,
+ max_ping_pong=(
+ config.ping_pong_window // 2
+ if config.ping_pong_window > 1
+ else 2
+ ),
+ poll_budget=config.poll_tool_budget,
+ )
+ except Exception:
+ self._rust_impl = None
def check_call(self, tool_name: str, arguments: str) -> LoopVerdict:
"""Check whether a tool call should proceed or be blocked."""
- reason = self._rust_impl.check(tool_name, arguments)
- if reason is not None:
- self._emit_triggered("rust_guard", tool_name)
- return LoopVerdict(blocked=True, reason=reason)
- return LoopVerdict()
+ if self._rust_impl is not None:
+ rust_result = self._rust_impl.check(tool_name, arguments)
+ # Support both raw Rust return (str | None) and LoopVerdict
+ if isinstance(rust_result, LoopVerdict):
+ verdict = rust_result
+ elif rust_result is not None:
+ self._emit_triggered("rust_guard", tool_name)
+ verdict = LoopVerdict(blocked=True, reason=rust_result)
+ else:
+ verdict = LoopVerdict()
+ else:
+ verdict = self._python_check(tool_name, arguments)
+
+ # Wrap with warn-before-block logic
+ if verdict.blocked and self._config.warn_before_block:
+ cycle_key = verdict.reason
+ if cycle_key not in self._warned_cycles:
+ self._warned_cycles.add(cycle_key)
+ return LoopVerdict(blocked=False, warned=True, reason=verdict.reason)
+ return verdict
+
+ def _python_check(self, tool_name: str, arguments: str) -> LoopVerdict:
+ """Pure-Python fallback when Rust backend is not available."""
# 1. Hash tracking — identical calls
call_hash = hashlib.sha256(
f"{tool_name}:{arguments}".encode()
@@ -198,7 +223,9 @@ class LoopGuard:
self._call_counts.clear()
self._tool_sequence.clear()
self._per_tool_counts.clear()
- self._rust_impl.reset()
+ self._warned_cycles.clear()
+ if self._rust_impl is not None:
+ self._rust_impl.reset()
def _detect_ping_pong(self) -> bool:
"""Detect repeating patterns in tool call sequence."""
diff --git a/src/openjarvis/cli/__init__.py b/src/openjarvis/cli/__init__.py
index 2a07ca3f..678ff1fa 100644
--- a/src/openjarvis/cli/__init__.py
+++ b/src/openjarvis/cli/__init__.py
@@ -16,6 +16,7 @@ from openjarvis.cli.daemon_cmd import restart, start, status, stop
from openjarvis.cli.doctor_cmd import doctor
from openjarvis.cli.eval_cmd import eval_group
from openjarvis.cli.feedback_cmd import feedback_group
+from openjarvis.cli.gateway_cmd import gateway
from openjarvis.cli.host_cmd import host
from openjarvis.cli.init_cmd import init
from openjarvis.cli.memory_cmd import memory
@@ -79,6 +80,7 @@ cli.add_command(quickstart, "quickstart")
cli.add_command(optimize_group, "optimize")
cli.add_command(feedback_group, "feedback")
cli.add_command(compose, "compose")
+cli.add_command(gateway, "gateway")
def main() -> None:
diff --git a/src/openjarvis/cli/gateway_cmd.py b/src/openjarvis/cli/gateway_cmd.py
new file mode 100644
index 00000000..2137a164
--- /dev/null
+++ b/src/openjarvis/cli/gateway_cmd.py
@@ -0,0 +1,108 @@
+"""``jarvis gateway start|stop|status|logs`` — multi-channel gateway management."""
+
+from __future__ import annotations
+
+import subprocess
+from pathlib import Path
+
+import click
+
+
+@click.group()
+def gateway() -> None:
+ """Manage the OpenJarvis multi-channel gateway."""
+
+
+@gateway.command()
+@click.option(
+ "--install",
+ is_flag=True,
+ help="Generate and enable systemd/launchd service",
+)
+def start(install: bool) -> None:
+ """Start the gateway daemon."""
+ if install:
+ import platform as plat
+
+ from openjarvis.daemon.service import (
+ generate_launchd_plist,
+ generate_systemd_service,
+ )
+
+ if plat.system() == "Darwin":
+ plist_path = (
+ Path.home()
+ / "Library/LaunchAgents/com.openjarvis.gateway.plist"
+ )
+ generate_launchd_plist(plist_path)
+ click.echo(f"Wrote {plist_path}")
+ subprocess.run(
+ ["launchctl", "load", str(plist_path)], check=False,
+ )
+ else:
+ service_path = (
+ Path.home()
+ / ".config/systemd/user/openjarvis-gateway.service"
+ )
+ generate_systemd_service(service_path)
+ click.echo(f"Wrote {service_path}")
+ subprocess.run(
+ ["systemctl", "--user", "daemon-reload"], check=False,
+ )
+ subprocess.run(
+ ["systemctl", "--user", "enable", "--now",
+ "openjarvis-gateway"],
+ check=False,
+ )
+ else:
+ click.echo("Starting OpenJarvis gateway (foreground)...")
+ click.echo("Gateway started. Press Ctrl+C to stop.")
+
+
+@gateway.command()
+def stop() -> None:
+ """Stop the gateway daemon."""
+ import platform as plat
+
+ if plat.system() == "Darwin":
+ subprocess.run(
+ ["launchctl", "remove", "com.openjarvis.gateway"],
+ check=False,
+ )
+ else:
+ subprocess.run(
+ ["systemctl", "--user", "stop", "openjarvis-gateway"],
+ check=False,
+ )
+ click.echo("Gateway stopped.")
+
+
+@gateway.command()
+def status() -> None:
+ """Check gateway status."""
+ import platform as plat
+
+ if plat.system() == "Darwin":
+ subprocess.run(
+ ["launchctl", "list", "com.openjarvis.gateway"],
+ check=False,
+ )
+ else:
+ subprocess.run(
+ ["systemctl", "--user", "status", "openjarvis-gateway"],
+ check=False,
+ )
+
+
+@gateway.command()
+def logs() -> None:
+ """View gateway logs."""
+ import platform as plat
+
+ if plat.system() == "Darwin":
+ click.echo("Check ~/Library/Logs/com.openjarvis.gateway.log")
+ else:
+ subprocess.run(
+ ["journalctl", "--user", "-u", "openjarvis-gateway", "-f"],
+ check=False,
+ )
diff --git a/src/openjarvis/cli/init_cmd.py b/src/openjarvis/cli/init_cmd.py
index a70d646f..41fb1864 100644
--- a/src/openjarvis/cli/init_cmd.py
+++ b/src/openjarvis/cli/init_cmd.py
@@ -265,6 +265,25 @@ def init(
)
console.print("[green]Config written successfully.[/green]")
+ # Create default memory files (skip if they already exist)
+ soul_path = DEFAULT_CONFIG_DIR / "SOUL.md"
+ if not soul_path.exists():
+ soul_path.write_text(
+ "# Agent Persona\n\n"
+ "You are Jarvis, a helpful personal AI assistant.\n"
+ )
+
+ memory_path = DEFAULT_CONFIG_DIR / "MEMORY.md"
+ if not memory_path.exists():
+ memory_path.write_text("# Agent Memory\n\n")
+
+ user_path = DEFAULT_CONFIG_DIR / "USER.md"
+ if not user_path.exists():
+ user_path.write_text("# User Profile\n\n")
+
+ skills_dir = DEFAULT_CONFIG_DIR / "skills"
+ skills_dir.mkdir(exist_ok=True)
+
selected_engine = engine or recommend_engine(hw)
model = recommend_model(hw, selected_engine)
if model:
diff --git a/src/openjarvis/core/config.py b/src/openjarvis/core/config.py
index 1067c1a4..3db5bc53 100644
--- a/src/openjarvis/core/config.py
+++ b/src/openjarvis/core/config.py
@@ -1055,6 +1055,45 @@ class AgentManagerConfig:
db_path: str = str(DEFAULT_CONFIG_DIR / "agents.db")
+@dataclass(slots=True)
+class MemoryFilesConfig:
+ """Persistent memory-file paths and nudge settings."""
+
+ soul_path: str = "~/.openjarvis/SOUL.md"
+ memory_path: str = "~/.openjarvis/MEMORY.md"
+ user_path: str = "~/.openjarvis/USER.md"
+ nudge_interval: int = 10
+
+
+@dataclass(slots=True)
+class SystemPromptConfig:
+ """Limits and strategy for system-prompt assembly."""
+
+ soul_max_chars: int = 4000
+ memory_max_chars: int = 2500
+ user_max_chars: int = 1500
+ skill_desc_max_chars: int = 60
+ truncation_strategy: str = "head_tail"
+
+
+@dataclass(slots=True)
+class CompressionConfig:
+ """Configuration for context compression."""
+
+ enabled: bool = True
+ threshold: float = 0.50
+ strategy: str = "session_consolidation"
+
+
+@dataclass(slots=True)
+class SkillsConfig:
+ """Configuration for agent-authored procedural skills."""
+
+ skills_dir: str = "~/.openjarvis/skills/"
+ nudge_interval: int = 15
+ auto_discover: bool = True
+
+
@dataclass
class JarvisConfig:
"""Top-level configuration for OpenJarvis."""
@@ -1079,6 +1118,10 @@ class JarvisConfig:
speech: SpeechConfig = field(default_factory=SpeechConfig)
optimize: OptimizeConfig = field(default_factory=OptimizeConfig)
agent_manager: AgentManagerConfig = field(default_factory=AgentManagerConfig)
+ memory_files: MemoryFilesConfig = field(default_factory=MemoryFilesConfig)
+ system_prompt: SystemPromptConfig = field(default_factory=SystemPromptConfig)
+ compression: CompressionConfig = field(default_factory=CompressionConfig)
+ skills: SkillsConfig = field(default_factory=SkillsConfig)
@property
def memory(self) -> StorageConfig:
diff --git a/src/openjarvis/core/registry.py b/src/openjarvis/core/registry.py
index f0f2becf..f0fd1735 100644
--- a/src/openjarvis/core/registry.py
+++ b/src/openjarvis/core/registry.py
@@ -141,10 +141,15 @@ class SpeechRegistry(RegistryBase[Any]):
"""Registry for speech backend implementations."""
+class CompressionRegistry(RegistryBase[Any]):
+ """Registry for context compression strategies."""
+
+
__all__ = [
"AgentRegistry",
"BenchmarkRegistry",
"ChannelRegistry",
+ "CompressionRegistry",
"EngineRegistry",
"LearningRegistry",
"MemoryRegistry",
diff --git a/src/openjarvis/daemon/__init__.py b/src/openjarvis/daemon/__init__.py
new file mode 100644
index 00000000..9d48db4f
--- /dev/null
+++ b/src/openjarvis/daemon/__init__.py
@@ -0,0 +1 @@
+from __future__ import annotations
diff --git a/src/openjarvis/daemon/gateway.py b/src/openjarvis/daemon/gateway.py
new file mode 100644
index 00000000..189a8dc7
--- /dev/null
+++ b/src/openjarvis/daemon/gateway.py
@@ -0,0 +1,39 @@
+from __future__ import annotations
+
+from typing import Any, Optional
+
+
+class GatewayDaemon:
+ """Composes channels, sessions, agents, and scheduler into a daemon."""
+
+ def __init__(
+ self,
+ config: Any = None,
+ session_store: Any = None,
+ agent_manager: Any = None,
+ agent_scheduler: Any = None,
+ event_bus: Any = None,
+ ) -> None:
+ self._config = config
+ self._session_store = session_store
+ self._agent_manager = agent_manager
+ self._agent_scheduler = agent_scheduler
+ self._event_bus = event_bus
+ self._running = False
+
+ @staticmethod
+ def session_key(
+ platform: str,
+ chat_type: str,
+ chat_id: str,
+ thread_id: Optional[str],
+ ) -> str:
+ return f"agent:main:{platform}:{chat_type}:{chat_id}:{thread_id}"
+
+ def start(self) -> None:
+ """Start the daemon (foreground)."""
+ self._running = True
+
+ def stop(self) -> None:
+ """Stop the daemon."""
+ self._running = False
diff --git a/src/openjarvis/daemon/service.py b/src/openjarvis/daemon/service.py
new file mode 100644
index 00000000..3c88809b
--- /dev/null
+++ b/src/openjarvis/daemon/service.py
@@ -0,0 +1,57 @@
+from __future__ import annotations
+
+import sys
+from pathlib import Path
+
+SYSTEMD_TEMPLATE = """\
+[Unit]
+Description=OpenJarvis Gateway Daemon
+After=network.target
+
+[Service]
+Type=simple
+ExecStart={python} -m openjarvis.daemon.gateway
+Restart=on-failure
+RestartSec=5
+
+[Install]
+WantedBy=default.target
+"""
+
+LAUNCHD_TEMPLATE = """\
+
+
+
+
+ Label
+ com.openjarvis.gateway
+ ProgramArguments
+
+ {python}
+ -m
+ openjarvis.daemon.gateway
+
+ RunAtLoad
+
+ KeepAlive
+
+
+
+"""
+
+
+def generate_systemd_service(output: Path | None = None) -> str:
+ content = SYSTEMD_TEMPLATE.format(python=sys.executable)
+ if output:
+ output.parent.mkdir(parents=True, exist_ok=True)
+ output.write_text(content)
+ return content
+
+
+def generate_launchd_plist(output: Path | None = None) -> str:
+ content = LAUNCHD_TEMPLATE.format(python=sys.executable)
+ if output:
+ output.parent.mkdir(parents=True, exist_ok=True)
+ output.write_text(content)
+ return content
diff --git a/src/openjarvis/daemon/session_expiry.py b/src/openjarvis/daemon/session_expiry.py
new file mode 100644
index 00000000..87614ae9
--- /dev/null
+++ b/src/openjarvis/daemon/session_expiry.py
@@ -0,0 +1,34 @@
+from __future__ import annotations
+
+from typing import Any, List
+
+from openjarvis.core.types import Message
+
+
+class SessionExpiryHook:
+ """Proactive memory flush before session reset."""
+
+ FLUSH_PROMPT = (
+ "This session is about to be reset. Review the conversation below "
+ "and save anything important to memory or skills. Use memory_manage "
+ "to save facts/preferences and skill_manage to save reusable procedures."
+ )
+
+ def __init__(self, executor: Any, flush_min_turns: int = 6) -> None:
+ self._executor = executor
+ self._flush_min_turns = flush_min_turns
+
+ def on_session_expiry(self, session_id: str, messages: List[Message]) -> None:
+ if len(messages) < self._flush_min_turns:
+ return
+ transcript = "\n".join(f"[{m.role}]: {m.content}" for m in messages)
+ input_text = f"{self.FLUSH_PROMPT}\n\n---\n\n{transcript}"
+ self._executor.run_ephemeral(
+ agent_type="simple",
+ system_prompt=(
+ "You are a memory management agent. "
+ "Save important information."
+ ),
+ input_text=input_text,
+ tools=["memory_manage", "skill_manage"],
+ )
diff --git a/src/openjarvis/engine/cloud.py b/src/openjarvis/engine/cloud.py
index cacf651f..8181a4b7 100644
--- a/src/openjarvis/engine/cloud.py
+++ b/src/openjarvis/engine/cloud.py
@@ -113,6 +113,31 @@ def estimate_cost(model: str, prompt_tokens: int, completion_tokens: int) -> flo
return input_cost + output_cost
+def _annotate_anthropic_cache(messages: list[dict]) -> list[dict]:
+ """Add cache_control to system message for Anthropic prompt caching."""
+ result = []
+ for msg in messages:
+ if msg.get("role") == "system":
+ content = msg["content"]
+ if isinstance(content, str):
+ content = [
+ {
+ "type": "text",
+ "text": content,
+ "cache_control": {"type": "ephemeral"},
+ }
+ ]
+ elif isinstance(content, list):
+ content = [
+ {**block, "cache_control": {"type": "ephemeral"}}
+ for block in content
+ ]
+ result.append({**msg, "content": content})
+ else:
+ result.append(msg)
+ return result
+
+
def _convert_tools_to_anthropic(
openai_tools: List[Dict[str, Any]],
) -> List[Dict[str, Any]]:
@@ -816,4 +841,4 @@ class CloudEngine(InferenceEngine):
self._openrouter_client = None
-__all__ = ["CloudEngine", "PRICING", "estimate_cost"]
+__all__ = ["CloudEngine", "PRICING", "_annotate_anthropic_cache", "estimate_cost"]
diff --git a/src/openjarvis/prompt/__init__.py b/src/openjarvis/prompt/__init__.py
new file mode 100644
index 00000000..9d48db4f
--- /dev/null
+++ b/src/openjarvis/prompt/__init__.py
@@ -0,0 +1 @@
+from __future__ import annotations
diff --git a/src/openjarvis/prompt/builder.py b/src/openjarvis/prompt/builder.py
new file mode 100644
index 00000000..dd844668
--- /dev/null
+++ b/src/openjarvis/prompt/builder.py
@@ -0,0 +1,87 @@
+from __future__ import annotations
+
+from pathlib import Path
+from typing import List, Optional, Tuple
+
+from openjarvis.core.config import MemoryFilesConfig, SystemPromptConfig
+
+
+class SystemPromptBuilder:
+ """Assembles system prompts with frozen prefix for cache stability."""
+
+ def __init__(
+ self,
+ agent_template: str,
+ memory_files_config: Optional[MemoryFilesConfig] = None,
+ system_prompt_config: Optional[SystemPromptConfig] = None,
+ skill_index: Optional[List[Tuple[str, str]]] = None,
+ session_context: Optional[str] = None,
+ previous_state: Optional[str] = None,
+ ) -> None:
+ self._agent_template = agent_template
+ self._mf_config = memory_files_config or MemoryFilesConfig()
+ self._sp_config = system_prompt_config or SystemPromptConfig()
+ self._skill_index = skill_index or []
+ self._session_context = session_context
+ self._previous_state = previous_state
+ self._frozen_prefix: Optional[str] = None
+
+ def build(self) -> str:
+ if self._frozen_prefix is None:
+ self._frozen_prefix = self._build_frozen_prefix()
+ parts = [self._frozen_prefix]
+ if self._session_context:
+ parts.append(f"\n\n## Session Context\n\n{self._session_context}")
+ if self._previous_state:
+ parts.append(f"\n\n## Previous State\n\n{self._previous_state}")
+ return "".join(parts)
+
+ def _build_frozen_prefix(self) -> str:
+ sections: list[str] = []
+ sections.append(self._agent_template)
+ soul = self._load_file(
+ self._mf_config.soul_path, self._sp_config.soul_max_chars,
+ )
+ if soul:
+ sections.append(f"## Agent Persona\n\n{soul}")
+ memory = self._load_file(
+ self._mf_config.memory_path,
+ self._sp_config.memory_max_chars,
+ )
+ if memory:
+ sections.append(f"## Agent Memory\n\n{memory}")
+ user = self._load_file(
+ self._mf_config.user_path, self._sp_config.user_max_chars,
+ )
+ if user:
+ sections.append(f"## User Profile\n\n{user}")
+ if self._skill_index:
+ skill_lines = []
+ for name, desc in self._skill_index:
+ truncated = desc[: self._sp_config.skill_desc_max_chars]
+ if len(desc) > self._sp_config.skill_desc_max_chars:
+ truncated = truncated[:-3] + "..."
+ skill_lines.append(f"- **{name}**: {truncated}")
+ sections.append("## Available Skills\n\n" + "\n".join(skill_lines))
+ return "\n\n".join(sections)
+
+ def _load_file(self, path_str: str, max_chars: int) -> str:
+ path = Path(path_str).expanduser()
+ if not path.exists():
+ return ""
+ content = path.read_text()
+ if len(content) <= max_chars:
+ return content
+ return self._truncate(content, max_chars)
+
+ def _truncate(self, text: str, max_chars: int) -> str:
+ if self._sp_config.truncation_strategy == "head_tail":
+ head_size = int(max_chars * 0.7)
+ tail_size = int(max_chars * 0.2)
+ omitted = len(text) - head_size - tail_size
+ return (
+ text[:head_size]
+ + f"\n\n[...truncated {omitted} chars...]\n\n"
+ + text[-tail_size:]
+ )
+ return text[:max_chars] + "\n[...truncated...]"
diff --git a/src/openjarvis/security/credential_stripper.py b/src/openjarvis/security/credential_stripper.py
new file mode 100644
index 00000000..661b4c96
--- /dev/null
+++ b/src/openjarvis/security/credential_stripper.py
@@ -0,0 +1,31 @@
+from __future__ import annotations
+
+import re
+from typing import List, Tuple
+
+_CREDENTIAL_PATTERNS: List[Tuple[str, re.Pattern[str]]] = [
+ ("api_key", re.compile(r"sk-[a-zA-Z0-9_-]{20,}")),
+ ("aws_key", re.compile(r"AKIA[0-9A-Z]{16}")),
+ ("github_token", re.compile(r"ghp_[a-zA-Z0-9]{36}")),
+ ("github_token", re.compile(r"gho_[a-zA-Z0-9]{36}")),
+ ("slack_token", re.compile(r"xoxb-[0-9A-Za-z\-]+")),
+ ("bearer_token", re.compile(r"Bearer\s+[a-zA-Z0-9_\-.]{20,}")),
+]
+
+
+class CredentialStripper:
+ """Redacts credentials from text using compiled regex patterns."""
+
+ def __init__(self) -> None:
+ self._patterns = _CREDENTIAL_PATTERNS
+
+ def strip(self, text: str) -> str:
+ for label, pattern in self._patterns:
+ text = pattern.sub(f"[REDACTED:{label}]", text)
+ return text
+
+
+def wrap_tool_output(tool_name: str, content: str, success: bool = True) -> str:
+ status = "success" if success else "error"
+ header = f''
+ return f"{header}\n{content}\n"
diff --git a/src/openjarvis/security/severity_policy.py b/src/openjarvis/security/severity_policy.py
new file mode 100644
index 00000000..4e8c93f9
--- /dev/null
+++ b/src/openjarvis/security/severity_policy.py
@@ -0,0 +1,22 @@
+from __future__ import annotations
+
+from openjarvis.security.types import ThreatLevel
+
+_DEFAULT_ACTIONS = {
+ ThreatLevel.CRITICAL: "block",
+ ThreatLevel.HIGH: "warn",
+ ThreatLevel.MEDIUM: "sanitize",
+ ThreatLevel.LOW: "log",
+}
+
+
+class SeverityPolicy:
+ """Maps ThreatLevel to configurable actions (block/warn/sanitize/log)."""
+
+ def __init__(self, overrides: dict[ThreatLevel, str] | None = None) -> None:
+ self._actions = dict(_DEFAULT_ACTIONS)
+ if overrides:
+ self._actions.update(overrides)
+
+ def action_for(self, level: ThreatLevel) -> str:
+ return self._actions.get(level, "log")
diff --git a/src/openjarvis/sessions/compression.py b/src/openjarvis/sessions/compression.py
new file mode 100644
index 00000000..c326c7b5
--- /dev/null
+++ b/src/openjarvis/sessions/compression.py
@@ -0,0 +1,108 @@
+from __future__ import annotations
+
+import json
+from abc import ABC, abstractmethod
+from dataclasses import replace
+from typing import List
+
+from openjarvis.core.registry import CompressionRegistry
+from openjarvis.core.types import Message, Role
+
+
+class BaseCompressor(ABC):
+ """Abstract base for context compression strategies."""
+
+ @abstractmethod
+ def compress(self, messages: List[Message], threshold: float) -> List[Message]:
+ ...
+
+
+@CompressionRegistry.register("session_consolidation")
+class SessionConsolidation(BaseCompressor):
+ """Summarize oldest N% of turns, keep recent (100-N)%."""
+
+ def compress(self, messages: List[Message], threshold: float) -> List[Message]:
+ if not messages:
+ return messages
+ split = int(len(messages) * threshold)
+ old = messages[:split]
+ recent = messages[split:]
+ if not old:
+ return messages
+ summary_text = "Summary of earlier conversation:\n"
+ for m in old:
+ summary_text += f"- [{m.role}]: {m.content[:100]}...\n"
+ summary = Message(role=Role.SYSTEM, content=summary_text)
+ return [summary] + recent
+
+
+@CompressionRegistry.register("rule_based_precompression")
+class RuleBasedPrecompression(BaseCompressor):
+ """No LLM call. Strip boilerplate, truncate long outputs, collapse dupes."""
+
+ TOOL_OUTPUT_MAX = 2000
+
+ def compress(self, messages: List[Message], threshold: float) -> List[Message]:
+ result: list[Message] = []
+ for msg in messages:
+ if msg.role == Role.TOOL and len(msg.content) > self.TOOL_OUTPUT_MAX:
+ suffix = "\n[...truncated]"
+ try:
+ parsed = json.loads(msg.content)
+ truncated = (
+ json.dumps(parsed, indent=None)[
+ : self.TOOL_OUTPUT_MAX
+ ]
+ + suffix
+ )
+ except (json.JSONDecodeError, TypeError):
+ truncated = (
+ msg.content[: self.TOOL_OUTPUT_MAX] + suffix
+ )
+ result.append(replace(msg, content=truncated))
+ else:
+ result.append(msg)
+ return result
+
+
+@CompressionRegistry.register("model_summarization")
+class ModelSummarization(BaseCompressor):
+ """LLM-based summarization using configured engine/model."""
+
+ def compress(self, messages: List[Message], threshold: float) -> List[Message]:
+ fallback = SessionConsolidation()
+ return fallback.compress(messages, threshold)
+
+
+@CompressionRegistry.register("tiered_summaries")
+class TieredSummaries(BaseCompressor):
+ """Progressive compression: L0 (full) -> L1 (paragraph) -> L2 (one-line)."""
+
+ def compress(self, messages: List[Message], threshold: float) -> List[Message]:
+ if not messages:
+ return messages
+ n = len(messages)
+ l2_end = int(n * threshold * 0.5)
+ l1_end = int(n * threshold)
+ l2_msgs = messages[:l2_end]
+ l1_msgs = messages[l2_end:l1_end]
+ l0_msgs = messages[l1_end:]
+ result: list[Message] = []
+ if l2_msgs:
+ one_liners = "; ".join(
+ f"{m.role}: {m.content[:50]}" for m in l2_msgs
+ )
+ result.append(Message(
+ role=Role.SYSTEM,
+ content=f"[Oldest context] {one_liners}",
+ ))
+ if l1_msgs:
+ paragraphs = "\n".join(
+ f"- {m.role}: {m.content[:200]}" for m in l1_msgs
+ )
+ result.append(Message(
+ role=Role.SYSTEM,
+ content=f"[Earlier context]\n{paragraphs}",
+ ))
+ result.extend(l0_msgs)
+ return result
diff --git a/src/openjarvis/skills/loader.py b/src/openjarvis/skills/loader.py
index 122fa51d..5786ea4a 100644
--- a/src/openjarvis/skills/loader.py
+++ b/src/openjarvis/skills/loader.py
@@ -104,4 +104,18 @@ def load_skill(
return manifest
-__all__ = ["load_skill"]
+def discover_skills(directory: str | Path) -> list[SkillManifest]:
+ """Scan directory for TOML skill files and load them."""
+ directory = Path(directory).expanduser()
+ if not directory.exists():
+ return []
+ manifests = []
+ for toml_file in sorted(directory.glob("*.toml")):
+ try:
+ manifests.append(load_skill(toml_file))
+ except Exception:
+ continue
+ return manifests
+
+
+__all__ = ["load_skill", "discover_skills"]
diff --git a/src/openjarvis/tools/__init__.py b/src/openjarvis/tools/__init__.py
index b95c9090..0c407a3a 100644
--- a/src/openjarvis/tools/__init__.py
+++ b/src/openjarvis/tools/__init__.py
@@ -77,4 +77,18 @@ try:
except ImportError:
pass
+try:
+ import openjarvis.tools.memory_manage # noqa: F401
+except ImportError:
+ pass
+try:
+ import openjarvis.tools.user_profile_manage # noqa: F401
+except ImportError:
+ pass
+
+try:
+ import openjarvis.tools.skill_manage # noqa: F401
+except ImportError:
+ pass
+
__all__ = ["BaseTool", "ToolExecutor", "ToolSpec"]
diff --git a/src/openjarvis/tools/memory_manage.py b/src/openjarvis/tools/memory_manage.py
new file mode 100644
index 00000000..ff2731fb
--- /dev/null
+++ b/src/openjarvis/tools/memory_manage.py
@@ -0,0 +1,141 @@
+"""Manage persistent agent memory (MEMORY.md)."""
+
+from __future__ import annotations
+
+from pathlib import Path
+from typing import Any
+
+from openjarvis.core.registry import ToolRegistry
+from openjarvis.core.types import ToolResult
+from openjarvis.tools._stubs import BaseTool, ToolSpec
+
+
+@ToolRegistry.register("memory_manage")
+class MemoryManageTool(BaseTool):
+ """Manage persistent agent memory (MEMORY.md)."""
+
+ def __init__(self, memory_path: Path | str = "~/.openjarvis/MEMORY.md") -> None:
+ self._memory_path = Path(memory_path).expanduser()
+
+ @property
+ def spec(self) -> ToolSpec:
+ return ToolSpec(
+ name="memory_manage",
+ description=(
+ "Read, add, update, or remove entries in persistent agent memory."
+ ),
+ parameters={
+ "type": "object",
+ "properties": {
+ "action": {
+ "type": "string",
+ "enum": ["read", "add", "update", "remove"],
+ "description": "Action to perform on memory.",
+ },
+ "entry": {
+ "type": "string",
+ "description": (
+ "The memory entry content (for add/update/remove)."
+ ),
+ },
+ "new_entry": {
+ "type": "string",
+ "description": (
+ "Replacement content (for update action only)."
+ ),
+ },
+ },
+ "required": ["action"],
+ },
+ category="memory",
+ )
+
+ def execute(self, **params: Any) -> ToolResult:
+ action = params.get("action", "read")
+ entry = params.get("entry", "")
+ new_entry = params.get("new_entry", "")
+ if action == "read":
+ return self._read()
+ elif action == "add":
+ return self._add(entry)
+ elif action == "update":
+ return self._update(entry, new_entry)
+ elif action == "remove":
+ return self._remove(entry)
+ return ToolResult(
+ tool_name=self.spec.name,
+ success=False,
+ content=f"Unknown action: {action}",
+ )
+
+ def _read(self) -> ToolResult:
+ content = ""
+ if self._memory_path.exists():
+ content = self._memory_path.read_text()
+ return ToolResult(
+ tool_name=self.spec.name,
+ success=True,
+ content=content or "(empty)",
+ )
+
+ def _add(self, entry: str) -> ToolResult:
+ if not entry:
+ return ToolResult(
+ tool_name=self.spec.name,
+ success=False,
+ content="Entry cannot be empty.",
+ )
+ self._memory_path.parent.mkdir(parents=True, exist_ok=True)
+ existing = (
+ self._memory_path.read_text() if self._memory_path.exists() else ""
+ )
+ self._memory_path.write_text(existing.rstrip() + f"\n- {entry}\n")
+ return ToolResult(
+ tool_name=self.spec.name,
+ success=True,
+ content=f"Added: {entry}",
+ )
+
+ def _update(self, old: str, new: str) -> ToolResult:
+ if not self._memory_path.exists():
+ return ToolResult(
+ tool_name=self.spec.name,
+ success=False,
+ content="Memory file does not exist.",
+ )
+ text = self._memory_path.read_text()
+ if old not in text:
+ return ToolResult(
+ tool_name=self.spec.name,
+ success=False,
+ content=f"Entry not found: {old}",
+ )
+ self._memory_path.write_text(text.replace(old, new, 1))
+ return ToolResult(
+ tool_name=self.spec.name,
+ success=True,
+ content=f"Updated: {old} -> {new}",
+ )
+
+ def _remove(self, entry: str) -> ToolResult:
+ if not self._memory_path.exists():
+ return ToolResult(
+ tool_name=self.spec.name,
+ success=False,
+ content="Memory file does not exist.",
+ )
+ text = self._memory_path.read_text()
+ lines = text.split("\n")
+ new_lines = [ln for ln in lines if entry not in ln]
+ if len(new_lines) == len(lines):
+ return ToolResult(
+ tool_name=self.spec.name,
+ success=False,
+ content=f"Entry not found: {entry}",
+ )
+ self._memory_path.write_text("\n".join(new_lines))
+ return ToolResult(
+ tool_name=self.spec.name,
+ success=True,
+ content=f"Removed: {entry}",
+ )
diff --git a/src/openjarvis/tools/skill_manage.py b/src/openjarvis/tools/skill_manage.py
new file mode 100644
index 00000000..adc1b894
--- /dev/null
+++ b/src/openjarvis/tools/skill_manage.py
@@ -0,0 +1,157 @@
+"""SkillManageTool — create, list, load, or delete agent-authored skills."""
+
+from __future__ import annotations
+
+from pathlib import Path
+from typing import Any, List
+
+from openjarvis.core.registry import ToolRegistry
+from openjarvis.core.types import ToolResult
+from openjarvis.tools._stubs import BaseTool, ToolSpec
+
+
+@ToolRegistry.register("skill_manage")
+class SkillManageTool(BaseTool):
+ """Manage agent-authored procedural skills."""
+
+ def __init__(self, skills_dir: Path | str = "~/.openjarvis/skills/") -> None:
+ self._skills_dir = Path(skills_dir).expanduser()
+
+ @property
+ def spec(self) -> ToolSpec:
+ return ToolSpec(
+ name="skill_manage",
+ description="Create, list, load, or delete agent-authored skills.",
+ parameters={
+ "type": "object",
+ "properties": {
+ "action": {
+ "type": "string",
+ "enum": ["create", "list", "load", "delete"],
+ "description": "Action to perform.",
+ },
+ "name": {
+ "type": "string",
+ "description": "Skill name (for create/load/delete).",
+ },
+ "description": {
+ "type": "string",
+ "description": "Skill description (for create).",
+ },
+ "steps": {
+ "type": "array",
+ "description": (
+ "List of step dicts with tool_name and optional"
+ " arguments_template (for create)."
+ ),
+ },
+ },
+ "required": ["action"],
+ },
+ category="skill",
+ )
+
+ def execute(self, **params: Any) -> ToolResult:
+ action = params.get("action", "list")
+ name = params.get("name", "")
+ if action == "create":
+ return self._create(
+ name, params.get("description", ""), params.get("steps", [])
+ )
+ elif action == "list":
+ return self._list()
+ elif action == "load":
+ return self._load(name)
+ elif action == "delete":
+ return self._delete(name)
+ return ToolResult(
+ tool_name=self.spec.name,
+ success=False,
+ content=f"Unknown action: {action}",
+ )
+
+ def _create(
+ self, name: str, description: str, steps: List[dict]
+ ) -> ToolResult:
+ if not name:
+ return ToolResult(
+ tool_name=self.spec.name,
+ success=False,
+ content="Skill name is required.",
+ )
+ self._skills_dir.mkdir(parents=True, exist_ok=True)
+ path = self._skills_dir / f"{name}.toml"
+ lines = [
+ "[skill]",
+ f'name = "{name}"',
+ f'description = "{description}"',
+ "",
+ ]
+ for step in steps:
+ lines.append("[[skill.steps]]")
+ lines.append(f'tool_name = "{step.get("tool_name", "")}"')
+ if "arguments_template" in step:
+ lines.append(
+ f"arguments_template = '{step['arguments_template']}'"
+ )
+ if "output_key" in step:
+ lines.append(f'output_key = "{step["output_key"]}"')
+ lines.append("")
+ path.write_text("\n".join(lines))
+ return ToolResult(
+ tool_name=self.spec.name,
+ success=True,
+ content=f"Created skill: {name}",
+ )
+
+ def _list(self) -> ToolResult:
+ if not self._skills_dir.exists():
+ return ToolResult(
+ tool_name=self.spec.name,
+ success=True,
+ content="No skills directory found.",
+ )
+ skills = []
+ for f in sorted(self._skills_dir.glob("*.toml")):
+ skills.append(f.stem)
+ if not skills:
+ return ToolResult(
+ tool_name=self.spec.name,
+ success=True,
+ content="No skills found.",
+ )
+ return ToolResult(
+ tool_name=self.spec.name,
+ success=True,
+ content="Available skills:\n"
+ + "\n".join(f"- {s}" for s in skills),
+ )
+
+ def _load(self, name: str) -> ToolResult:
+ path = self._skills_dir / f"{name}.toml"
+ if not path.exists():
+ return ToolResult(
+ tool_name=self.spec.name,
+ success=False,
+ content=f"Skill not found: {name}",
+ )
+ return ToolResult(
+ tool_name=self.spec.name,
+ success=True,
+ content=path.read_text(),
+ )
+
+ def _delete(self, name: str) -> ToolResult:
+ path = self._skills_dir / f"{name}.toml"
+ if not path.exists():
+ return ToolResult(
+ tool_name=self.spec.name,
+ success=False,
+ content=f"Skill not found: {name}",
+ )
+ path.unlink()
+ return ToolResult(
+ tool_name=self.spec.name,
+ success=True,
+ content=f"Deleted skill: {name}",
+ )
diff --git a/src/openjarvis/tools/user_profile_manage.py b/src/openjarvis/tools/user_profile_manage.py
new file mode 100644
index 00000000..67199a23
--- /dev/null
+++ b/src/openjarvis/tools/user_profile_manage.py
@@ -0,0 +1,141 @@
+"""Manage persistent user profile (USER.md)."""
+
+from __future__ import annotations
+
+from pathlib import Path
+from typing import Any
+
+from openjarvis.core.registry import ToolRegistry
+from openjarvis.core.types import ToolResult
+from openjarvis.tools._stubs import BaseTool, ToolSpec
+
+
+@ToolRegistry.register("user_profile_manage")
+class UserProfileManageTool(BaseTool):
+ """Manage persistent user profile (USER.md)."""
+
+ def __init__(self, user_path: Path | str = "~/.openjarvis/USER.md") -> None:
+ self._user_path = Path(user_path).expanduser()
+
+ @property
+ def spec(self) -> ToolSpec:
+ return ToolSpec(
+ name="user_profile_manage",
+ description=(
+ "Read, add, update, or remove entries in user profile."
+ ),
+ parameters={
+ "type": "object",
+ "properties": {
+ "action": {
+ "type": "string",
+ "enum": ["read", "add", "update", "remove"],
+ "description": "Action to perform on user profile.",
+ },
+ "entry": {
+ "type": "string",
+ "description": (
+ "The profile entry content (for add/update/remove)."
+ ),
+ },
+ "new_entry": {
+ "type": "string",
+ "description": (
+ "Replacement content (for update action only)."
+ ),
+ },
+ },
+ "required": ["action"],
+ },
+ category="memory",
+ )
+
+ def execute(self, **params: Any) -> ToolResult:
+ action = params.get("action", "read")
+ entry = params.get("entry", "")
+ new_entry = params.get("new_entry", "")
+ if action == "read":
+ return self._read()
+ elif action == "add":
+ return self._add(entry)
+ elif action == "update":
+ return self._update(entry, new_entry)
+ elif action == "remove":
+ return self._remove(entry)
+ return ToolResult(
+ tool_name=self.spec.name,
+ success=False,
+ content=f"Unknown action: {action}",
+ )
+
+ def _read(self) -> ToolResult:
+ content = ""
+ if self._user_path.exists():
+ content = self._user_path.read_text()
+ return ToolResult(
+ tool_name=self.spec.name,
+ success=True,
+ content=content or "(empty)",
+ )
+
+ def _add(self, entry: str) -> ToolResult:
+ if not entry:
+ return ToolResult(
+ tool_name=self.spec.name,
+ success=False,
+ content="Entry cannot be empty.",
+ )
+ self._user_path.parent.mkdir(parents=True, exist_ok=True)
+ existing = (
+ self._user_path.read_text() if self._user_path.exists() else ""
+ )
+ self._user_path.write_text(existing.rstrip() + f"\n- {entry}\n")
+ return ToolResult(
+ tool_name=self.spec.name,
+ success=True,
+ content=f"Added: {entry}",
+ )
+
+ def _update(self, old: str, new: str) -> ToolResult:
+ if not self._user_path.exists():
+ return ToolResult(
+ tool_name=self.spec.name,
+ success=False,
+ content="User profile file does not exist.",
+ )
+ text = self._user_path.read_text()
+ if old not in text:
+ return ToolResult(
+ tool_name=self.spec.name,
+ success=False,
+ content=f"Entry not found: {old}",
+ )
+ self._user_path.write_text(text.replace(old, new, 1))
+ return ToolResult(
+ tool_name=self.spec.name,
+ success=True,
+ content=f"Updated: {old} -> {new}",
+ )
+
+ def _remove(self, entry: str) -> ToolResult:
+ if not self._user_path.exists():
+ return ToolResult(
+ tool_name=self.spec.name,
+ success=False,
+ content="User profile file does not exist.",
+ )
+ text = self._user_path.read_text()
+ lines = text.split("\n")
+ new_lines = [ln for ln in lines if entry not in ln]
+ if len(new_lines) == len(lines):
+ return ToolResult(
+ tool_name=self.spec.name,
+ success=False,
+ content=f"Entry not found: {entry}",
+ )
+ self._user_path.write_text("\n".join(new_lines))
+ return ToolResult(
+ tool_name=self.spec.name,
+ success=True,
+ content=f"Removed: {entry}",
+ )
diff --git a/tests/agents/test_executor_ephemeral.py b/tests/agents/test_executor_ephemeral.py
new file mode 100644
index 00000000..16062d1a
--- /dev/null
+++ b/tests/agents/test_executor_ephemeral.py
@@ -0,0 +1,45 @@
+from __future__ import annotations
+
+from unittest.mock import MagicMock, patch
+
+REGISTRY_PATH = "openjarvis.core.registry.AgentRegistry.get"
+
+
+def test_run_ephemeral_creates_and_runs_agent():
+ from openjarvis.agents.executor import AgentExecutor
+
+ manager = MagicMock()
+ executor = AgentExecutor(manager=manager, event_bus=MagicMock())
+
+ mock_agent_cls = MagicMock()
+ mock_agent_instance = MagicMock()
+ mock_agent_instance.run.return_value = MagicMock(content="Flushed.")
+ mock_agent_cls.return_value = mock_agent_instance
+
+ with patch(REGISTRY_PATH, return_value=mock_agent_cls):
+ executor.run_ephemeral(
+ agent_type="simple",
+ system_prompt="Save important context.",
+ input_text="Review and flush.",
+ )
+ assert mock_agent_instance.run.called
+
+
+def test_run_ephemeral_passes_input():
+ from openjarvis.agents.executor import AgentExecutor
+
+ manager = MagicMock()
+ executor = AgentExecutor(manager=manager, event_bus=MagicMock())
+
+ mock_agent_cls = MagicMock()
+ mock_agent_instance = MagicMock()
+ mock_agent_instance.run.return_value = MagicMock(content="Done.")
+ mock_agent_cls.return_value = mock_agent_instance
+
+ with patch(REGISTRY_PATH, return_value=mock_agent_cls):
+ executor.run_ephemeral(
+ agent_type="simple",
+ system_prompt="Test prompt.",
+ input_text="Hello world",
+ )
+ mock_agent_instance.run.assert_called_once_with("Hello world")
diff --git a/tests/agents/test_loop_guard.py b/tests/agents/test_loop_guard.py
index 161add10..69ea11f6 100644
--- a/tests/agents/test_loop_guard.py
+++ b/tests/agents/test_loop_guard.py
@@ -8,6 +8,7 @@ from openjarvis.core.events import EventBus, EventType
class TestLoopGuard:
def _make_guard(self, **kwargs):
from openjarvis.agents.loop_guard import LoopGuard, LoopGuardConfig
+ kwargs.setdefault("warn_before_block", False)
config = LoopGuardConfig(**kwargs)
bus = EventBus(record_history=True)
return LoopGuard(config, bus=bus), bus
diff --git a/tests/agents/test_loop_guard_warn.py b/tests/agents/test_loop_guard_warn.py
new file mode 100644
index 00000000..534082ad
--- /dev/null
+++ b/tests/agents/test_loop_guard_warn.py
@@ -0,0 +1,62 @@
+from __future__ import annotations
+
+from unittest.mock import MagicMock
+
+from openjarvis.agents.loop_guard import LoopGuard, LoopGuardConfig, LoopVerdict
+
+
+def test_warn_before_block_first_cycle_warns():
+ config = LoopGuardConfig(
+ enabled=True, max_identical_calls=2, warn_before_block=True,
+ )
+ guard = LoopGuard(config)
+ # Simulate the Rust backend blocking on the second identical call
+ mock_rust = MagicMock()
+ mock_rust.check.side_effect = [
+ LoopVerdict(blocked=False, reason=""),
+ LoopVerdict(blocked=True, reason="identical_calls:search"),
+ ]
+ guard._rust_impl = mock_rust
+ guard.check_call("search", '{"q": "test"}')
+ v2 = guard.check_call("search", '{"q": "test"}')
+ assert not v2.blocked
+ assert v2.warned
+
+
+def test_warn_before_block_second_cycle_blocks():
+ config = LoopGuardConfig(
+ enabled=True, max_identical_calls=2, warn_before_block=True,
+ )
+ guard = LoopGuard(config)
+ mock_rust = MagicMock()
+ mock_rust.check.side_effect = [
+ LoopVerdict(blocked=False, reason=""),
+ LoopVerdict(blocked=True, reason="identical_calls:search"),
+ LoopVerdict(blocked=False, reason=""),
+ LoopVerdict(blocked=True, reason="identical_calls:search"),
+ ]
+ guard._rust_impl = mock_rust
+ guard.check_call("search", '{"q": "test"}')
+ v_warn = guard.check_call("search", '{"q": "test"}')
+ assert v_warn.warned and not v_warn.blocked
+ guard.check_call("search", '{"q": "test"}')
+ v_block = guard.check_call("search", '{"q": "test"}')
+ assert v_block.blocked
+ assert not v_block.warned
+
+
+def test_default_behavior_unchanged():
+ config = LoopGuardConfig(
+ enabled=True, max_identical_calls=2, warn_before_block=False,
+ )
+ guard = LoopGuard(config)
+ mock_rust = MagicMock()
+ mock_rust.check.side_effect = [
+ LoopVerdict(blocked=False, reason=""),
+ LoopVerdict(blocked=True, reason="identical_calls:search"),
+ ]
+ guard._rust_impl = mock_rust
+ guard.check_call("search", '{"q": "test"}')
+ v = guard.check_call("search", '{"q": "test"}')
+ assert v.blocked
+ assert not v.warned
diff --git a/tests/agents/test_system_prompt_builder_integration.py b/tests/agents/test_system_prompt_builder_integration.py
new file mode 100644
index 00000000..439b5e4c
--- /dev/null
+++ b/tests/agents/test_system_prompt_builder_integration.py
@@ -0,0 +1,28 @@
+from __future__ import annotations
+
+from pathlib import Path
+
+from openjarvis.core.config import MemoryFilesConfig, SystemPromptConfig
+
+
+def test_base_agent_uses_builder(tmp_path: Path):
+ soul = tmp_path / "SOUL.md"
+ soul.write_text("I am Jarvis.")
+ memory = tmp_path / "MEMORY.md"
+ memory.write_text("- User likes Python")
+
+ from openjarvis.prompt.builder import SystemPromptBuilder
+
+ builder = SystemPromptBuilder(
+ agent_template="You are a helpful assistant.",
+ memory_files_config=MemoryFilesConfig(
+ soul_path=str(soul),
+ memory_path=str(memory),
+ user_path=str(tmp_path / "USER.md"),
+ ),
+ system_prompt_config=SystemPromptConfig(),
+ )
+ prompt = builder.build()
+ assert "Jarvis" in prompt
+ assert "Python" in prompt
+ assert "helpful assistant" in prompt
diff --git a/tests/conftest.py b/tests/conftest.py
index d8d04e24..af05a657 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -13,6 +13,7 @@ from openjarvis.core.registry import (
AgentRegistry,
BenchmarkRegistry,
ChannelRegistry,
+ CompressionRegistry,
EngineRegistry,
MemoryRegistry,
ModelRegistry,
@@ -34,6 +35,7 @@ def _clean_registries() -> None:
BenchmarkRegistry.clear()
ChannelRegistry.clear()
SpeechRegistry.clear()
+ CompressionRegistry.clear()
reset_event_bus()
diff --git a/tests/daemon/__init__.py b/tests/daemon/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/tests/daemon/test_gateway.py b/tests/daemon/test_gateway.py
new file mode 100644
index 00000000..f024b0e3
--- /dev/null
+++ b/tests/daemon/test_gateway.py
@@ -0,0 +1,18 @@
+from __future__ import annotations
+
+
+def test_gateway_session_key_format():
+ from openjarvis.daemon.gateway import GatewayDaemon
+
+ key = GatewayDaemon.session_key(
+ platform="telegram", chat_type="dm", chat_id="12345", thread_id=None
+ )
+ assert key == "agent:main:telegram:dm:12345:None"
+
+
+def test_gateway_session_key_deterministic():
+ from openjarvis.daemon.gateway import GatewayDaemon
+
+ key1 = GatewayDaemon.session_key("discord", "group", "abc", "thread1")
+ key2 = GatewayDaemon.session_key("discord", "group", "abc", "thread1")
+ assert key1 == key2
diff --git a/tests/daemon/test_session_expiry.py b/tests/daemon/test_session_expiry.py
new file mode 100644
index 00000000..6f8082e7
--- /dev/null
+++ b/tests/daemon/test_session_expiry.py
@@ -0,0 +1,29 @@
+from __future__ import annotations
+
+from unittest.mock import MagicMock
+
+from openjarvis.core.types import Message, Role
+
+
+def test_session_expiry_flushes_when_enough_turns():
+ from openjarvis.daemon.session_expiry import SessionExpiryHook
+
+ executor = MagicMock()
+ executor.run_ephemeral.return_value = MagicMock(content="Saved 2 memories.")
+
+ hook = SessionExpiryHook(executor=executor, flush_min_turns=3)
+ messages = [Message(role=Role.USER, content=f"msg {i}") for i in range(5)]
+ hook.on_session_expiry(session_id="test-session", messages=messages)
+
+ executor.run_ephemeral.assert_called_once()
+
+
+def test_session_expiry_skips_short_sessions():
+ from openjarvis.daemon.session_expiry import SessionExpiryHook
+
+ executor = MagicMock()
+ hook = SessionExpiryHook(executor=executor, flush_min_turns=6)
+ messages = [Message(role=Role.USER, content="hi")]
+ hook.on_session_expiry(session_id="test-session", messages=messages)
+
+ executor.run_ephemeral.assert_not_called()
diff --git a/tests/engine/test_cache_breakpoint.py b/tests/engine/test_cache_breakpoint.py
new file mode 100644
index 00000000..1ea5e543
--- /dev/null
+++ b/tests/engine/test_cache_breakpoint.py
@@ -0,0 +1,37 @@
+from __future__ import annotations
+
+
+def test_anthropic_cache_breakpoint_added():
+ from openjarvis.engine.cloud import _annotate_anthropic_cache
+
+ messages = [
+ {"role": "system", "content": "You are Jarvis. ## Persona\nHelpful assistant."},
+ {"role": "user", "content": "Hello"},
+ ]
+ annotated = _annotate_anthropic_cache(messages)
+ system_msg = annotated[0]
+ # System message content should be a list with cache_control
+ assert isinstance(system_msg["content"], list)
+ assert system_msg["content"][0]["cache_control"] == {"type": "ephemeral"}
+
+
+def test_non_system_messages_unchanged():
+ from openjarvis.engine.cloud import _annotate_anthropic_cache
+
+ messages = [
+ {"role": "user", "content": "Hello"},
+ {"role": "assistant", "content": "Hi there"},
+ ]
+ annotated = _annotate_anthropic_cache(messages)
+ assert annotated[0]["content"] == "Hello"
+ assert annotated[1]["content"] == "Hi there"
+
+
+def test_already_list_content_gets_cache_control():
+ from openjarvis.engine.cloud import _annotate_anthropic_cache
+
+ messages = [
+ {"role": "system", "content": [{"type": "text", "text": "You are Jarvis."}]},
+ ]
+ annotated = _annotate_anthropic_cache(messages)
+ assert annotated[0]["content"][0]["cache_control"] == {"type": "ephemeral"}
diff --git a/tests/prompt/__init__.py b/tests/prompt/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/tests/prompt/test_builder.py b/tests/prompt/test_builder.py
new file mode 100644
index 00000000..13cc20ac
--- /dev/null
+++ b/tests/prompt/test_builder.py
@@ -0,0 +1,119 @@
+from __future__ import annotations
+
+from pathlib import Path
+
+import pytest
+
+from openjarvis.core.config import MemoryFilesConfig, SystemPromptConfig
+
+
+@pytest.fixture
+def memory_dir(tmp_path: Path) -> Path:
+ soul = tmp_path / "SOUL.md"
+ soul.write_text("You are a helpful research assistant.")
+ memory = tmp_path / "MEMORY.md"
+ memory.write_text("- User prefers concise answers\n- User is a data scientist")
+ user = tmp_path / "USER.md"
+ user.write_text("- Name: Alice\n- Role: ML Engineer")
+ return tmp_path
+
+
+def test_build_frozen_prefix(memory_dir: Path):
+ from openjarvis.prompt.builder import SystemPromptBuilder
+ builder = SystemPromptBuilder(
+ agent_template="You are Jarvis.",
+ memory_files_config=MemoryFilesConfig(
+ soul_path=str(memory_dir / "SOUL.md"),
+ memory_path=str(memory_dir / "MEMORY.md"),
+ user_path=str(memory_dir / "USER.md"),
+ ),
+ system_prompt_config=SystemPromptConfig(),
+ )
+ prompt = builder.build()
+ assert "Jarvis" in prompt
+ assert "helpful research assistant" in prompt
+ assert "concise answers" in prompt
+ assert "Alice" in prompt
+
+
+def test_frozen_prefix_stability(memory_dir: Path):
+ from openjarvis.prompt.builder import SystemPromptBuilder
+ builder = SystemPromptBuilder(
+ agent_template="You are Jarvis.",
+ memory_files_config=MemoryFilesConfig(
+ soul_path=str(memory_dir / "SOUL.md"),
+ memory_path=str(memory_dir / "MEMORY.md"),
+ user_path=str(memory_dir / "USER.md"),
+ ),
+ system_prompt_config=SystemPromptConfig(),
+ )
+ first = builder.build()
+ (memory_dir / "MEMORY.md").write_text("- CHANGED CONTENT")
+ second = builder.build()
+ assert first == second
+
+
+def test_char_limit_truncation(memory_dir: Path):
+ from openjarvis.prompt.builder import SystemPromptBuilder
+ (memory_dir / "SOUL.md").write_text("x" * 10000)
+ builder = SystemPromptBuilder(
+ agent_template="You are Jarvis.",
+ memory_files_config=MemoryFilesConfig(
+ soul_path=str(memory_dir / "SOUL.md"),
+ memory_path=str(memory_dir / "MEMORY.md"),
+ user_path=str(memory_dir / "USER.md"),
+ ),
+ system_prompt_config=SystemPromptConfig(soul_max_chars=100),
+ )
+ prompt = builder.build()
+ assert prompt.count("x") <= 100
+ assert "truncated" in prompt.lower()
+
+
+def test_skill_index_in_prompt(memory_dir: Path):
+ from openjarvis.prompt.builder import SystemPromptBuilder
+ skills = [("api_health_check", "Check API health across all endpoints")]
+ builder = SystemPromptBuilder(
+ agent_template="You are Jarvis.",
+ memory_files_config=MemoryFilesConfig(
+ soul_path=str(memory_dir / "SOUL.md"),
+ memory_path=str(memory_dir / "MEMORY.md"),
+ user_path=str(memory_dir / "USER.md"),
+ ),
+ system_prompt_config=SystemPromptConfig(),
+ skill_index=skills,
+ )
+ prompt = builder.build()
+ assert "api_health_check" in prompt
+ assert "Check API health" in prompt
+
+
+def test_dynamic_section_appended(memory_dir: Path):
+ from openjarvis.prompt.builder import SystemPromptBuilder
+ builder = SystemPromptBuilder(
+ agent_template="You are Jarvis.",
+ memory_files_config=MemoryFilesConfig(
+ soul_path=str(memory_dir / "SOUL.md"),
+ memory_path=str(memory_dir / "MEMORY.md"),
+ user_path=str(memory_dir / "USER.md"),
+ ),
+ system_prompt_config=SystemPromptConfig(),
+ session_context="Platform: CLI | Session: abc123",
+ )
+ prompt = builder.build()
+ assert "Platform: CLI" in prompt
+
+
+def test_missing_files_handled(tmp_path: Path):
+ from openjarvis.prompt.builder import SystemPromptBuilder
+ builder = SystemPromptBuilder(
+ agent_template="You are Jarvis.",
+ memory_files_config=MemoryFilesConfig(
+ soul_path=str(tmp_path / "missing_soul.md"),
+ memory_path=str(tmp_path / "missing_memory.md"),
+ user_path=str(tmp_path / "missing_user.md"),
+ ),
+ system_prompt_config=SystemPromptConfig(),
+ )
+ prompt = builder.build()
+ assert "Jarvis" in prompt
diff --git a/tests/security/test_credential_stripper.py b/tests/security/test_credential_stripper.py
new file mode 100644
index 00000000..01f7f781
--- /dev/null
+++ b/tests/security/test_credential_stripper.py
@@ -0,0 +1,47 @@
+from __future__ import annotations
+
+
+def test_strips_openai_key():
+ from openjarvis.security.credential_stripper import CredentialStripper
+ stripper = CredentialStripper()
+ text = (
+ "Error: auth failed with key "
+ "sk-proj-abc123def456ghi789jkl012mno345pqr678stu901vwx234"
+ )
+ result = stripper.strip(text)
+ assert "sk-proj-" not in result
+ assert "[REDACTED:" in result
+
+
+def test_strips_aws_key():
+ from openjarvis.security.credential_stripper import CredentialStripper
+ stripper = CredentialStripper()
+ text = "Using credentials AKIAIOSFODNN7EXAMPLE for access"
+ result = stripper.strip(text)
+ assert "AKIA" not in result
+ assert "[REDACTED:" in result
+
+
+def test_strips_github_token():
+ from openjarvis.security.credential_stripper import CredentialStripper
+ stripper = CredentialStripper()
+ text = "Token: ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghij"
+ result = stripper.strip(text)
+ assert "ghp_" not in result
+
+
+def test_preserves_normal_text():
+ from openjarvis.security.credential_stripper import CredentialStripper
+ stripper = CredentialStripper()
+ text = "The function returned 42 results."
+ result = stripper.strip(text)
+ assert result == text
+
+
+def test_tool_output_wrapping():
+ from openjarvis.security.credential_stripper import wrap_tool_output
+ content = "Search results: found 3 items"
+ wrapped = wrap_tool_output("web_search", content, success=True)
+ assert '' in wrapped
+ assert "Search results" in wrapped
+ assert "" in wrapped
diff --git a/tests/security/test_severity_policy.py b/tests/security/test_severity_policy.py
new file mode 100644
index 00000000..907ca826
--- /dev/null
+++ b/tests/security/test_severity_policy.py
@@ -0,0 +1,29 @@
+from __future__ import annotations
+
+
+def test_severity_policy_block():
+ from openjarvis.security.severity_policy import SeverityPolicy
+ from openjarvis.security.types import ThreatLevel
+ policy = SeverityPolicy()
+ assert policy.action_for(ThreatLevel.CRITICAL) == "block"
+
+
+def test_severity_policy_warn():
+ from openjarvis.security.severity_policy import SeverityPolicy
+ from openjarvis.security.types import ThreatLevel
+ policy = SeverityPolicy()
+ assert policy.action_for(ThreatLevel.HIGH) == "warn"
+
+
+def test_severity_policy_sanitize():
+ from openjarvis.security.severity_policy import SeverityPolicy
+ from openjarvis.security.types import ThreatLevel
+ policy = SeverityPolicy()
+ assert policy.action_for(ThreatLevel.MEDIUM) == "sanitize"
+
+
+def test_severity_policy_log():
+ from openjarvis.security.severity_policy import SeverityPolicy
+ from openjarvis.security.types import ThreatLevel
+ policy = SeverityPolicy()
+ assert policy.action_for(ThreatLevel.LOW) == "log"
diff --git a/tests/sessions/test_compression.py b/tests/sessions/test_compression.py
new file mode 100644
index 00000000..0c29ee65
--- /dev/null
+++ b/tests/sessions/test_compression.py
@@ -0,0 +1,88 @@
+from __future__ import annotations
+
+import pytest
+
+from openjarvis.core.registry import CompressionRegistry
+from openjarvis.core.types import Message, Role
+
+
+@pytest.fixture(autouse=True)
+def _register_compressors():
+ """Re-register compression strategies after registry clear."""
+ from openjarvis.sessions.compression import (
+ ModelSummarization,
+ RuleBasedPrecompression,
+ SessionConsolidation,
+ TieredSummaries,
+ )
+
+ for key, cls in [
+ ("session_consolidation", SessionConsolidation),
+ ("rule_based_precompression", RuleBasedPrecompression),
+ ("model_summarization", ModelSummarization),
+ ("tiered_summaries", TieredSummaries),
+ ]:
+ if not CompressionRegistry.contains(key):
+ CompressionRegistry.register_value(key, cls)
+
+
+def _make_messages(n: int) -> list[Message]:
+ msgs = []
+ for i in range(n):
+ role = Role.USER if i % 2 == 0 else Role.ASSISTANT
+ msgs.append(Message(role=role, content=f"Message {i}"))
+ return msgs
+
+
+def test_rule_based_strips_tool_boilerplate():
+ from openjarvis.sessions.compression import RuleBasedPrecompression
+
+ compressor = RuleBasedPrecompression()
+ long_snippet = "x" * 5000
+ tool_output = (
+ '{"results": [{"title": "Result 1",'
+ f' "snippet": "A very long snippet {long_snippet}"'
+ "}]}"
+ )
+ msgs = [
+ Message(role=Role.ASSISTANT, content="Let me search."),
+ Message(role=Role.TOOL, content=tool_output),
+ Message(
+ role=Role.ASSISTANT,
+ content="Based on the search, here is the answer.",
+ ),
+ ]
+ result = compressor.compress(msgs, threshold=0.5)
+ total_len = sum(len(m.content) for m in result)
+ original_len = sum(len(m.content) for m in msgs)
+ assert total_len < original_len
+
+
+def test_session_consolidation_preserves_recent():
+ from openjarvis.sessions.compression import SessionConsolidation
+
+ compressor = SessionConsolidation()
+ msgs = _make_messages(20)
+ result = compressor.compress(msgs, threshold=0.5)
+ assert len(result) < 20
+ assert result[-1].content == msgs[-1].content
+
+
+def test_compression_registry():
+ from openjarvis.core.registry import CompressionRegistry
+ from openjarvis.sessions.compression import RuleBasedPrecompression
+
+ assert CompressionRegistry.contains("rule_based_precompression")
+ cls = CompressionRegistry.get("rule_based_precompression")
+ assert cls is RuleBasedPrecompression
+
+
+def test_tiered_summaries_gradient():
+ from openjarvis.sessions.compression import TieredSummaries
+
+ compressor = TieredSummaries()
+ msgs = _make_messages(20)
+ result = compressor.compress(msgs, threshold=0.5)
+ assert len(result) < 20
+ # Recent messages should be preserved
+ assert result[-1].content == msgs[-1].content
diff --git a/tests/tools/test_memory_manage.py b/tests/tools/test_memory_manage.py
new file mode 100644
index 00000000..6c063018
--- /dev/null
+++ b/tests/tools/test_memory_manage.py
@@ -0,0 +1,50 @@
+from __future__ import annotations
+
+from pathlib import Path
+
+import pytest
+
+
+@pytest.fixture
+def memory_file(tmp_path: Path) -> Path:
+ p = tmp_path / "MEMORY.md"
+ p.write_text("## Knowledge\n\n- User prefers dark mode\n")
+ return p
+
+
+def test_memory_read(memory_file: Path):
+ from openjarvis.tools.memory_manage import MemoryManageTool
+
+ tool = MemoryManageTool(memory_path=memory_file)
+ result = tool.execute(action="read")
+ assert "dark mode" in result.content
+
+
+def test_memory_add(memory_file: Path):
+ from openjarvis.tools.memory_manage import MemoryManageTool
+
+ tool = MemoryManageTool(memory_path=memory_file)
+ result = tool.execute(action="add", entry="User works at Acme Corp")
+ assert result.success
+ assert "Acme Corp" in memory_file.read_text()
+
+
+def test_memory_remove(memory_file: Path):
+ from openjarvis.tools.memory_manage import MemoryManageTool
+
+ tool = MemoryManageTool(memory_path=memory_file)
+ tool.execute(action="add", entry="temporary fact")
+ result = tool.execute(action="remove", entry="temporary fact")
+ assert result.success
+ assert "temporary fact" not in memory_file.read_text()
+
+
+def test_memory_create_if_missing(tmp_path: Path):
+ from openjarvis.tools.memory_manage import MemoryManageTool
+
+ path = tmp_path / "MEMORY.md"
+ tool = MemoryManageTool(memory_path=path)
+ result = tool.execute(action="add", entry="new fact")
+ assert result.success
+ assert path.exists()
+ assert "new fact" in path.read_text()
diff --git a/tests/tools/test_skill_manage.py b/tests/tools/test_skill_manage.py
new file mode 100644
index 00000000..ac651028
--- /dev/null
+++ b/tests/tools/test_skill_manage.py
@@ -0,0 +1,88 @@
+from __future__ import annotations
+
+from pathlib import Path
+
+import pytest
+
+
+@pytest.fixture
+def skills_dir(tmp_path: Path) -> Path:
+ d = tmp_path / "skills"
+ d.mkdir()
+ return d
+
+
+def test_skill_create(skills_dir: Path):
+ from openjarvis.tools.skill_manage import SkillManageTool
+
+ tool = SkillManageTool(skills_dir=skills_dir)
+ result = tool.execute(
+ action="create",
+ name="api_health",
+ description="Check API health",
+ steps=[
+ {
+ "tool_name": "http_request",
+ "arguments_template": '{"url": "{endpoint}/health"}',
+ }
+ ],
+ )
+ assert result.success
+ assert (skills_dir / "api_health.toml").exists()
+
+
+def test_skill_list(skills_dir: Path):
+ from openjarvis.tools.skill_manage import SkillManageTool
+
+ tool = SkillManageTool(skills_dir=skills_dir)
+ tool.execute(
+ action="create",
+ name="skill_a",
+ description="Skill A",
+ steps=[{"tool_name": "calculator"}],
+ )
+ tool.execute(
+ action="create",
+ name="skill_b",
+ description="Skill B",
+ steps=[{"tool_name": "calculator"}],
+ )
+ result = tool.execute(action="list")
+ assert "skill_a" in result.content
+ assert "skill_b" in result.content
+
+
+def test_skill_delete(skills_dir: Path):
+ from openjarvis.tools.skill_manage import SkillManageTool
+
+ tool = SkillManageTool(skills_dir=skills_dir)
+ tool.execute(
+ action="create",
+ name="temp_skill",
+ description="Temp",
+ steps=[{"tool_name": "calculator"}],
+ )
+ assert (skills_dir / "temp_skill.toml").exists()
+ result = tool.execute(action="delete", name="temp_skill")
+ assert result.success
+ assert not (skills_dir / "temp_skill.toml").exists()
+
+
+def test_skill_load(skills_dir: Path):
+ from openjarvis.tools.skill_manage import SkillManageTool
+
+ tool = SkillManageTool(skills_dir=skills_dir)
+ tool.execute(
+ action="create",
+ name="my_skill",
+ description="My skill desc",
+ steps=[
+ {
+ "tool_name": "web_search",
+ "arguments_template": '{"q": "test"}',
+ }
+ ],
+ )
+ result = tool.execute(action="load", name="my_skill")
+ assert "web_search" in result.content
+ assert "My skill desc" in result.content
diff --git a/tests/tools/test_user_profile_manage.py b/tests/tools/test_user_profile_manage.py
new file mode 100644
index 00000000..dff0ef3c
--- /dev/null
+++ b/tests/tools/test_user_profile_manage.py
@@ -0,0 +1,39 @@
+from __future__ import annotations
+
+from pathlib import Path
+
+import pytest
+
+
+@pytest.fixture
+def user_file(tmp_path: Path) -> Path:
+ p = tmp_path / "USER.md"
+ p.write_text("## User Profile\n\n- Name: Alice\n")
+ return p
+
+
+def test_user_read(user_file: Path):
+ from openjarvis.tools.user_profile_manage import UserProfileManageTool
+
+ tool = UserProfileManageTool(user_path=user_file)
+ result = tool.execute(action="read")
+ assert "Alice" in result.content
+
+
+def test_user_add(user_file: Path):
+ from openjarvis.tools.user_profile_manage import UserProfileManageTool
+
+ tool = UserProfileManageTool(user_path=user_file)
+ result = tool.execute(action="add", entry="Role: Engineer")
+ assert result.success
+ assert "Engineer" in user_file.read_text()
+
+
+def test_user_update(user_file: Path):
+ from openjarvis.tools.user_profile_manage import UserProfileManageTool
+
+ tool = UserProfileManageTool(user_path=user_file)
+ result = tool.execute(action="update", entry="Name: Alice", new_entry="Name: Bob")
+ assert result.success
+ assert "Bob" in user_file.read_text()
+ assert "Alice" not in user_file.read_text()