diff --git a/src/openjarvis/cli/ask.py b/src/openjarvis/cli/ask.py index 86f675f7..ff9f6c15 100644 --- a/src/openjarvis/cli/ask.py +++ b/src/openjarvis/cli/ask.py @@ -151,9 +151,7 @@ def _run_research( trace.print(f" [dim]↳ Found {n} {label}[/dim]") elif etype == "clarify_call": q = event.get("question", "") or "" - trace.print( - f" [dim]↳ Clarifying:[/dim] [dim italic]{q}[/dim italic]" - ) + trace.print(f" [dim]↳ Clarifying:[/dim] [dim italic]{q}[/dim italic]") # final_answer and clarify_response are handled outside the loop. agent = ResearchAgent( @@ -326,6 +324,7 @@ def _run_agent( temperature: float, max_tokens: int, capability_policy=None, + memory_files_config=None, ): """Instantiate and run an agent, returning the AgentResult.""" # Import agents to trigger registration @@ -374,7 +373,7 @@ def _run_agent( agent_kwargs["prompt_builder"] = SystemPromptBuilder( agent_template=config.agent.default_system_prompt or "", - memory_files_config=config.memory_files, + memory_files_config=memory_files_config or config.memory_files, system_prompt_config=config.system_prompt, ) @@ -592,6 +591,15 @@ def _print_profile( "(default: ~/.openjarvis/knowledge.db)." ), ) +@click.option( + "--persona", + "persona_name", + default=None, + help=( + "Named persona dir under ~/.openjarvis/personas// " + "(overrides config). Pass 'none' to disable all persona files." + ), +) @click.pass_context def ask( ctx: click.Context, @@ -608,6 +616,7 @@ def ask( enable_profile: bool, research_mode: bool, knowledge_db: str | None, + persona_name: str | None, ) -> None: """Ask Jarvis a question.""" quiet = (ctx.obj or {}).get("quiet", False) or output_json @@ -620,6 +629,15 @@ def ask( # Load config config = load_config() + # Resolve effective MemoryFilesConfig with --persona override + import dataclasses as _dc + + effective_mf = ( + _dc.replace(config.memory_files, persona_name=persona_name) + if persona_name is not None + else config.memory_files + ) + # Honor `agent.default_agent` from config when --agent was not explicitly # passed. Pass `--agent ""` to opt out and use direct-to-engine mode. # Without this fallback, `[agent].default_system_prompt` and the @@ -773,6 +791,7 @@ def ask( temperature, max_tokens, capability_policy=sec.capability_policy, + memory_files_config=effective_mf, ) except EngineConnectionError as exc: console.print(f"[red]Engine error:[/red] {exc}") diff --git a/src/openjarvis/core/config.py b/src/openjarvis/core/config.py index 07effe4c..40c1f016 100644 --- a/src/openjarvis/core/config.py +++ b/src/openjarvis/core/config.py @@ -1428,6 +1428,7 @@ class MemoryFilesConfig: memory_path: str = "~/.openjarvis/MEMORY.md" user_path: str = "~/.openjarvis/USER.md" nudge_interval: int = 10 + persona_name: str = "" # named persona dir under ~/.openjarvis/personas// @dataclass(slots=True) diff --git a/src/openjarvis/prompt/builder.py b/src/openjarvis/prompt/builder.py index e812a528..3114559d 100644 --- a/src/openjarvis/prompt/builder.py +++ b/src/openjarvis/prompt/builder.py @@ -35,7 +35,8 @@ class SystemPromptBuilder: skill_few_shot_examples: Optional[List[str]] = None, ) -> None: self._agent_template = agent_template - self._mf_config = memory_files_config or MemoryFilesConfig() + _mf = memory_files_config or MemoryFilesConfig() + self._mf_config = self._resolve_persona(_mf) self._sp_config = system_prompt_config or SystemPromptConfig() self._skill_index = skill_index or [] self._session_context = session_context @@ -250,3 +251,34 @@ class SystemPromptBuilder: + text[-tail_size:] ) return text[:max_chars] + "\n[...truncated...]" + + @staticmethod + def _resolve_persona(mf: MemoryFilesConfig) -> MemoryFilesConfig: + """Resolve persona_name to effective file paths. + - "" (empty) -> use mf's existing paths (global default, unchanged) + - "none" -> empty paths (opt-out, no persona injected) + - "" -> ~/.openjarvis/personas//{SOUL,MEMORY,USER}.md + """ + if not mf.persona_name: + return mf + if mf.persona_name == "none": + return MemoryFilesConfig( + soul_path="", + memory_path="", + user_path="", + nudge_interval=mf.nudge_interval, + ) + name = mf.persona_name + if ".." in name or "/" in name or "\\" in name or name.startswith("/"): + raise ValueError( + f"Invalid persona name {name!r}: must be a simple " + "identifier (no path separators or '..')." + ) + base = Path.home() / ".openjarvis" / "personas" / name + return MemoryFilesConfig( + soul_path=str(base / "SOUL.md"), + memory_path=str(base / "MEMORY.md"), + user_path=str(base / "USER.md"), + nudge_interval=mf.nudge_interval, + persona_name=name, + ) diff --git a/tests/prompt/test_persona_scope.py b/tests/prompt/test_persona_scope.py new file mode 100644 index 00000000..97c461f5 --- /dev/null +++ b/tests/prompt/test_persona_scope.py @@ -0,0 +1,33 @@ +"""Tests for #380 per-invocation persona scope (_resolve_persona).""" + +from pathlib import Path + +import pytest + +from openjarvis.core.config import MemoryFilesConfig +from openjarvis.prompt.builder import SystemPromptBuilder + + +def test_empty_persona_passes_through_global_defaults(): + mf = MemoryFilesConfig() + out = SystemPromptBuilder._resolve_persona(mf) + assert out.soul_path == mf.soul_path # unchanged = backward compatible + + +def test_none_persona_disables_all_files(): + out = SystemPromptBuilder._resolve_persona(MemoryFilesConfig(persona_name="none")) + assert out.soul_path == "" and out.memory_path == "" and out.user_path == "" + + +def test_named_persona_resolves_to_personas_dir(): + out = SystemPromptBuilder._resolve_persona(MemoryFilesConfig(persona_name="coder")) + base = str(Path.home() / ".openjarvis" / "personas" / "coder") + assert out.soul_path == f"{base}/SOUL.md" + assert out.memory_path == f"{base}/MEMORY.md" + assert out.user_path == f"{base}/USER.md" + + +@pytest.mark.parametrize("bad", ["../etc", "a/b", "..\\win", "/abs", "x/../y"]) +def test_path_traversal_rejected(bad): + with pytest.raises(ValueError): + SystemPromptBuilder._resolve_persona(MemoryFilesConfig(persona_name=bad))