diff --git a/plugins/memory/openviking/__init__.py b/plugins/memory/openviking/__init__.py
index 937def6b9ad4..513e0d1ec23d 100644
--- a/plugins/memory/openviking/__init__.py
+++ b/plugins/memory/openviking/__init__.py
@@ -82,6 +82,7 @@ _SYNC_TRACE_ENV = "HERMES_OPENVIKING_SYNC_TRACE"
_DEFAULT_RECALL_LIMIT = 6
_DEFAULT_RECALL_SCORE_THRESHOLD = 0.15
_DEFAULT_RECALL_MAX_INJECTED_CHARS = 4000
+_DEFAULT_PROFILE_MAX_CHARS = 4000
_DEFAULT_RECALL_TIMEOUT_SECONDS = 4.0
_DEFAULT_RECALL_REQUEST_TIMEOUT_SECONDS = 3.0
_DEFAULT_RECALL_FULL_READ_LIMIT = 2
@@ -89,6 +90,12 @@ _RECALL_QUERY_MIN_CHARS = 5
_RECALL_MIN_TIMEOUT_SECONDS = 0.05
_READ_BATCH_LIMIT = 3
_READ_BATCH_FULL_LIMIT = 2500
+_PROFILE_READS = (
+ ("viking://user/memories/profile.md", "full"),
+)
+_PREFERENCES_OVERVIEW_URI = "viking://user/memories/preferences/"
+_ENTITIES_OVERVIEW_URI = "viking://user/memories/entities/"
+_SESSION_START_TRUNCATION_SUFFIX = "[...] truncated"
# Maps the viking_remember `category` enum to a viking:// subdirectory.
# Keep in sync with REMEMBER_SCHEMA.parameters.properties.category.enum.
@@ -1842,6 +1849,7 @@ class OpenVikingMemoryProvider(MemoryProvider):
self._runtime_start_thread: Optional[threading.Thread] = None
self._memory_write_lock = threading.Lock()
self._memory_write_threads: Set[threading.Thread] = set()
+ self._profile_prefetched_sessions: Set[str] = set()
# Set on shutdown so deferred-commit / writer finalizers stop issuing
# network writes against a torn-down provider.
self._shutting_down = False
@@ -1915,6 +1923,12 @@ class OpenVikingMemoryProvider(MemoryProvider):
"default": _DEFAULT_RECALL_MAX_INJECTED_CHARS,
"env_var": "OPENVIKING_RECALL_MAX_INJECTED_CHARS",
},
+ {
+ "key": "profile_max_chars",
+ "description": "Maximum session-start memory characters injected",
+ "default": _DEFAULT_PROFILE_MAX_CHARS,
+ "env_var": "OPENVIKING_PROFILE_MAX_CHARS",
+ },
{
"key": "recall_timeout_seconds",
"description": "Total timeout for recall (seconds)",
@@ -2170,6 +2184,7 @@ class OpenVikingMemoryProvider(MemoryProvider):
hermes_home = str(Path.home() / ".hermes")
self._hermes_home = hermes_home
self._acquire_run_lock()
+ self._profile_prefetched_sessions.clear()
warning_callback = (
kwargs.get("warning_callback")
if kwargs.get("platform") == "cli"
@@ -2250,7 +2265,8 @@ class OpenVikingMemoryProvider(MemoryProvider):
"# OpenViking Knowledge Base\n"
f"Active. Endpoint: {self._endpoint}\n"
"Use viking_search, viking_read, viking_browse, "
- "viking_remember, viking_forget, viking_add_resource. "
+ "viking_remember, viking_forget, "
+ "viking_add_resource. "
"If repeated searches "
"return the same evidence or no stronger evidence, answer "
"from available evidence and state uncertainty if needed."
@@ -2259,17 +2275,24 @@ class OpenVikingMemoryProvider(MemoryProvider):
def prefetch(self, query: str, *, session_id: str = "") -> str:
"""Return recall context for this query/session."""
query_text = _derive_openviking_user_text(query).strip()
- if not self._client or len(query_text) < _RECALL_QUERY_MIN_CHARS:
+ if not self._client:
return ""
effective_session_id = str(session_id or self._session_id or "").strip()
- result = self._search_prefetch_context(
- query_text,
- session_id=effective_session_id,
- )
- if not result:
+ parts: List[str] = []
+ session_memory = self._session_start_memory_context(effective_session_id)
+ if session_memory:
+ parts.append(session_memory)
+ if len(query_text) >= _RECALL_QUERY_MIN_CHARS:
+ result = self._search_prefetch_context(
+ query_text,
+ session_id=effective_session_id,
+ )
+ if result:
+ parts.append(result)
+ if not parts:
return ""
- return f"## OpenViking Context\n{result}"
+ return "## OpenViking Context\n" + "\n\n".join(parts)
@staticmethod
def _remaining_recall_timeout(deadline: float, per_request_timeout: float) -> float:
@@ -2946,6 +2969,262 @@ class OpenVikingMemoryProvider(MemoryProvider):
"resources": self._env_bool("OPENVIKING_RECALL_RESOURCES", False),
}
+ def _profile_max_chars(self) -> int:
+ return self._env_int(
+ "OPENVIKING_PROFILE_MAX_CHARS",
+ _DEFAULT_PROFILE_MAX_CHARS,
+ minimum=200,
+ maximum=50000,
+ )
+
+ @staticmethod
+ def _extract_text_content(resp: Any) -> str:
+ result = OpenVikingMemoryProvider._unwrap_result(resp)
+ if isinstance(result, str):
+ return result.strip()
+ if isinstance(result, dict):
+ return str(result.get("content") or result.get("text") or "").strip()
+ return ""
+
+ @staticmethod
+ def _is_placeholder_overview(content: str) -> bool:
+ normalized = " ".join((content or "").strip().lower().split())
+ return normalized in {
+ "[directory overview is not ready]",
+ "[directory overview is not generated]",
+ "[directory abstract is not ready]",
+ "[directory abstract is not generated]",
+ } or normalized.endswith((
+ "[directory overview is not ready]",
+ "[directory overview is not generated]",
+ "[directory abstract is not ready]",
+ "[directory abstract is not generated]",
+ ))
+
+ @staticmethod
+ def _weighted_context_len(content: str) -> int:
+ total = 0
+ for ch in content:
+ total += 2 if ord(ch) >= 0x3000 else 1
+ return total
+
+ @classmethod
+ def _take_weighted_prefix(cls, content: str, max_chars: int) -> str:
+ if max_chars <= 0:
+ return ""
+ used = 0
+ end = 0
+ for end, ch in enumerate(content):
+ used += 2 if ord(ch) >= 0x3000 else 1
+ if used > max_chars:
+ return content[:end]
+ return content
+
+ @classmethod
+ def _take_weighted_suffix(cls, content: str, max_chars: int) -> str:
+ if max_chars <= 0:
+ return ""
+ used = 0
+ start = len(content)
+ for idx in range(len(content) - 1, -1, -1):
+ ch = content[idx]
+ used += 2 if ord(ch) >= 0x3000 else 1
+ if used > max_chars:
+ return content[start:]
+ start = idx
+ return content
+
+ @classmethod
+ def _truncate_text_content(cls, content: str, max_chars: int) -> str:
+ content = content.strip()
+ if cls._weighted_context_len(content) <= max_chars:
+ return content
+ suffix = f"\n\n{_SESSION_START_TRUNCATION_SUFFIX}"
+ suffix_len = cls._weighted_context_len(suffix)
+ if max_chars <= suffix_len:
+ return cls._take_weighted_prefix(content, max_chars)
+ return cls._take_weighted_prefix(content, max_chars - suffix_len).rstrip() + suffix
+
+ @classmethod
+ def _truncate_profile_content(cls, content: str, max_chars: int) -> str:
+ content = content.strip()
+ if cls._weighted_context_len(content) <= max_chars:
+ return content
+ marker = f"\n\n{_SESSION_START_TRUNCATION_SUFFIX}\n\n"
+ marker_len = cls._weighted_context_len(marker)
+ if max_chars <= marker_len + 40:
+ return cls._truncate_text_content(content, max_chars)
+ remaining = max_chars - marker_len
+ head_budget = max(1, remaining // 2)
+ tail_budget = max(1, remaining - head_budget)
+ head = cls._take_weighted_prefix(content, head_budget).rstrip()
+ tail = cls._take_weighted_suffix(content, tail_budget).lstrip()
+ if not head or not tail:
+ return cls._truncate_text_content(content, max_chars)
+ return f"{head}{marker}{tail}"
+
+ def _read_session_start_text(
+ self,
+ client: _VikingClient,
+ endpoint: str,
+ uri: str,
+ *,
+ skip_placeholder: bool = False,
+ timeout: Optional[float] = None,
+ ) -> Optional[str]:
+ try:
+ kwargs = {"timeout": timeout} if timeout is not None else {}
+ resp = client.get(endpoint, params={"uri": uri}, **kwargs)
+ except Exception as e:
+ if _status_code_from_error(e) in {404, 410}:
+ return ""
+ return None
+ content = self._extract_text_content(resp)
+ if skip_placeholder and self._is_placeholder_overview(content):
+ return ""
+ return content
+
+ def _read_session_start_memory_parts(
+ self,
+ *,
+ client: Optional[_VikingClient] = None,
+ request_timeout: Optional[float] = None,
+ ) -> Dict[str, Optional[str]]:
+ active_client = client or self._client
+ if not active_client:
+ return {}
+
+ profile_uri, _level = _PROFILE_READS[0]
+ return {
+ "profile": self._read_session_start_text(
+ active_client,
+ "/api/v1/content/read",
+ profile_uri,
+ timeout=request_timeout,
+ ),
+ "preferences": self._read_session_start_text(
+ active_client,
+ "/api/v1/content/overview",
+ _PREFERENCES_OVERVIEW_URI,
+ skip_placeholder=True,
+ timeout=request_timeout,
+ ),
+ "entities": self._read_session_start_text(
+ active_client,
+ "/api/v1/content/overview",
+ _ENTITIES_OVERVIEW_URI,
+ skip_placeholder=True,
+ timeout=request_timeout,
+ ),
+ }
+
+ @staticmethod
+ def _format_session_start_memory_block(
+ *,
+ profile: str = "",
+ preferences: str = "",
+ entities: str = "",
+ ) -> str:
+ lines: List[str] = ["## Session Memory"]
+ if profile:
+ lines.extend([
+ ' ',
+ profile,
+ " ",
+ ])
+ if preferences or entities:
+ lines.append(" ")
+ if preferences:
+ lines.extend([
+ f' ',
+ preferences,
+ " ",
+ ])
+ if entities:
+ lines.extend([
+ f' ',
+ entities,
+ " ",
+ ])
+ lines.append(" ")
+ if len(lines) == 1:
+ return ""
+ return "\n".join(lines)
+
+ def _budget_session_start_memory_parts(self, parts: Dict[str, str], max_chars: int) -> Dict[str, str]:
+ profile = parts.get("profile", "").strip()
+ preferences = parts.get("preferences", "").strip()
+ entities = parts.get("entities", "").strip()
+ full = self._format_session_start_memory_block(
+ profile=profile,
+ preferences=preferences,
+ entities=entities,
+ )
+ if not full or self._weighted_context_len(full) <= max_chars:
+ return {"profile": profile, "preferences": preferences, "entities": entities}
+
+ placeholder = "\0"
+ placeholder_block = self._format_session_start_memory_block(
+ profile=placeholder if profile else "",
+ preferences=placeholder if preferences else "",
+ entities=placeholder if entities else "",
+ )
+ present_count = sum(1 for value in (profile, preferences, entities) if value)
+ overhead = self._weighted_context_len(placeholder_block) - present_count
+ content_budget = max_chars - overhead
+ if content_budget <= 0:
+ return {"profile": "", "preferences": "", "entities": ""}
+
+ has_listings = bool(preferences or entities)
+ result = {"profile": "", "preferences": "", "entities": ""}
+ remaining = content_budget
+ if profile:
+ profile_budget = remaining if not has_listings else min(
+ self._weighted_context_len(profile),
+ max(0, int(content_budget * 0.6)),
+ )
+ result["profile"] = self._truncate_profile_content(profile, profile_budget)
+ remaining -= self._weighted_context_len(result["profile"])
+
+ listing_values = {"preferences": preferences, "entities": entities}
+ listing_keys = [key for key, value in listing_values.items() if value]
+ for idx, key in enumerate(listing_keys):
+ if remaining <= 0:
+ break
+ value = listing_values[key]
+ budget = remaining if idx == len(listing_keys) - 1 else max(0, remaining // 2)
+ result[key] = self._truncate_text_content(value, budget)
+ remaining -= self._weighted_context_len(result[key])
+ return result
+
+ def _session_start_memory_context(self, session_id: str) -> str:
+ session_key = session_id or self._session_id or "__openviking_default_session__"
+ if session_key in self._profile_prefetched_sessions:
+ return ""
+ try:
+ cfg = self._recall_config()
+ raw_parts = self._read_session_start_memory_parts(
+ request_timeout=cfg["request_timeout_seconds"],
+ )
+ except Exception as e:
+ logger.debug("OpenViking session-start memory prefetch failed: %s", e)
+ return ""
+ profile_failed = raw_parts.get("profile") is None
+ parts = {key: value or "" for key, value in raw_parts.items()}
+ if not any(value.strip() for value in parts.values()):
+ if not profile_failed:
+ self._profile_prefetched_sessions.add(session_key)
+ return ""
+ budgeted = self._budget_session_start_memory_parts(parts, self._profile_max_chars())
+ block = self._format_session_start_memory_block(**budgeted)
+ if not block:
+ if not profile_failed:
+ self._profile_prefetched_sessions.add(session_key)
+ return ""
+ if not profile_failed:
+ self._profile_prefetched_sessions.add(session_key)
+ return block
+
@staticmethod
def _clamp_score(value: Any) -> float:
try:
@@ -3580,6 +3859,7 @@ class OpenVikingMemoryProvider(MemoryProvider):
return
rewound = bool(kwargs.get("rewound"))
+ compression = kwargs.get("reason") == "compression"
# Rotate cached session state synchronously (cheap, in-memory) and
# snapshot the old session under the lock so a concurrent sync_turn
@@ -3596,6 +3876,9 @@ class OpenVikingMemoryProvider(MemoryProvider):
self._session_id = new_id
self._turn_count = 0
+ if compression:
+ self._profile_prefetched_sessions.discard(old_session_id or new_id)
+
if not rotate:
# Same-session rewind (/undo) or no-op rotation: no commit and no
# counter reset.
diff --git a/tests/openviking_plugin/test_openviking.py b/tests/openviking_plugin/test_openviking.py
index 70f65fa62cf8..f5a4f84ed7fe 100644
--- a/tests/openviking_plugin/test_openviking.py
+++ b/tests/openviking_plugin/test_openviking.py
@@ -106,6 +106,7 @@ def make_prefetch_provider(monkeypatch, responses, **env):
"OPENVIKING_RECALL_FULL_READ_LIMIT",
"OPENVIKING_RECALL_PREFER_ABSTRACT",
"OPENVIKING_RECALL_RESOURCES",
+ "OPENVIKING_PROFILE_MAX_CHARS",
):
monkeypatch.delenv(key, raising=False)
for key, value in env.items():
@@ -987,6 +988,9 @@ class TestOpenVikingAutoRecallPrefetch:
if parsed.path == "/api/v1/content/read":
query = parse_qs(parsed.query)
uri = query.get("uri", [""])[0]
+ if uri.startswith("viking://user/memories/"):
+ self.send_error(404)
+ return
records["reads"].append(uri)
self._send_json({"result": {"content": "E2E full L2 memory content."}})
return
@@ -1029,6 +1033,7 @@ class TestOpenVikingAutoRecallPrefetch:
"OPENVIKING_RECALL_MAX_INJECTED_CHARS",
"OPENVIKING_RECALL_PREFER_ABSTRACT",
"OPENVIKING_RECALL_RESOURCES",
+ "OPENVIKING_PROFILE_MAX_CHARS",
"OPENVIKING_API_KEY",
):
monkeypatch.delenv(key, raising=False)
diff --git a/tests/plugins/memory/test_openviking_provider.py b/tests/plugins/memory/test_openviking_provider.py
index 9d8e39924835..69a5e8619aa7 100644
--- a/tests/plugins/memory/test_openviking_provider.py
+++ b/tests/plugins/memory/test_openviking_provider.py
@@ -35,6 +35,7 @@ def _clear_openviking_env(monkeypatch):
"OPENVIKING_USER",
"OPENVIKING_AGENT",
"OPENVIKING_CLI_CONFIG_FILE",
+ "OPENVIKING_PROFILE_MAX_CHARS",
):
monkeypatch.delenv(key, raising=False)
@@ -1521,14 +1522,36 @@ def test_tool_add_resource_sends_git_remote_sources_as_path(url):
})
-def test_get_tool_schemas_includes_narrow_forget_tool():
+def test_get_tool_schemas_omits_profile_and_keeps_narrow_forget_tools():
provider = OpenVikingMemoryProvider()
names = [schema["name"] for schema in provider.get_tool_schemas()]
+ assert "viking_profile" not in names
assert "viking_forget" in names
+def test_handle_tool_call_profile_returns_unknown_tool():
+ provider = OpenVikingMemoryProvider()
+ provider._client = MagicMock()
+
+ result = json.loads(provider.handle_tool_call("viking_profile", {}))
+
+ assert result["error"] == "Unknown tool: viking_profile"
+ provider._client.get.assert_not_called()
+
+
+def test_system_prompt_block_omits_removed_profile_tool_guidance():
+ provider = OpenVikingMemoryProvider()
+ provider._client = MagicMock()
+ provider._client.get.return_value = {"result": [{"name": "memories"}]}
+
+ prompt = provider.system_prompt_block()
+
+ assert "viking_profile" not in prompt
+ assert "viking_search" in prompt
+
+
def test_handle_tool_call_forget_deletes_exact_memory_file_uri():
uri = "viking://user/peers/hermes/memories/preferences/mem_abc123.md"
provider = OpenVikingMemoryProvider()
@@ -3557,6 +3580,337 @@ def _make_prefetch_provider() -> OpenVikingMemoryProvider:
return provider
+def _mock_session_start_reads(provider: OpenVikingMemoryProvider, responses: dict[tuple[str, str], object]):
+ calls = []
+
+ def fake_get(path, params=None, **kwargs):
+ uri = (params or {}).get("uri", "")
+ calls.append((path, uri))
+ response = responses.get((path, uri), "")
+ if isinstance(response, Exception):
+ raise response
+ return {"result": response}
+
+ provider._client.get.side_effect = fake_get
+ return calls
+
+
+def test_prefetch_prepends_session_start_memory_context_once_per_session():
+ provider = _make_prefetch_provider()
+ calls = _mock_session_start_reads(
+ provider,
+ {
+ ("/api/v1/content/read", "viking://user/memories/profile.md"): (
+ "User prefers concise answers."
+ ),
+ ("/api/v1/content/overview", "viking://user/memories/preferences/"): (
+ "# Preferences\n- Keep replies compact."
+ ),
+ ("/api/v1/content/overview", "viking://user/memories/entities/"): (
+ "# Entities\n- Ada Lovelace: collaborator."
+ ),
+ },
+ )
+ provider._search_prefetch_context = MagicMock(return_value="- [events]\n recalled context")
+
+ first = provider.prefetch("What should we recall?", session_id="sid-123")
+ second = provider.prefetch("What should we recall?", session_id="sid-123")
+
+ assert "## Session Memory" in first
+ assert '' in first
+ assert "User prefers concise answers." in first
+ assert '' in first
+ assert "Keep replies compact." in first
+ assert '' in first
+ assert "Ada Lovelace: collaborator." in first
+ assert "recalled context" in first
+ assert "## Session Memory" not in second
+ assert "recalled context" in second
+ assert calls == [
+ ("/api/v1/content/read", "viking://user/memories/profile.md"),
+ ("/api/v1/content/overview", "viking://user/memories/preferences/"),
+ ("/api/v1/content/overview", "viking://user/memories/entities/"),
+ ]
+ assert provider._search_prefetch_context.call_count == 2
+
+
+def test_prefetch_can_return_session_start_memory_for_short_query():
+ provider = _make_prefetch_provider()
+ _mock_session_start_reads(
+ provider,
+ {
+ ("/api/v1/content/read", "viking://user/memories/profile.md"): (
+ "User profile is Ada."
+ ),
+ ("/api/v1/content/overview", "viking://user/memories/preferences/"): "",
+ ("/api/v1/content/overview", "viking://user/memories/entities/"): "",
+ },
+ )
+ provider._search_prefetch_context = MagicMock(return_value="should not run")
+
+ context = provider.prefetch("hi", session_id="sid-123")
+
+ assert "## OpenViking Context" in context
+ assert "## Session Memory" in context
+ assert "User profile is Ada." in context
+ provider._search_prefetch_context.assert_not_called()
+
+
+def test_prefetch_session_start_memory_reads_use_bounded_timeout(monkeypatch):
+ monkeypatch.setenv("OPENVIKING_RECALL_REQUEST_TIMEOUT_SECONDS", "0.25")
+ provider = _make_prefetch_provider()
+ calls = []
+
+ def fake_get(path, params=None, **kwargs):
+ calls.append((path, (params or {}).get("uri", ""), kwargs.get("timeout")))
+ if (params or {}).get("uri") == "viking://user/memories/profile.md":
+ return {"result": "User profile is Ada."}
+ return {"result": ""}
+
+ provider._client.get.side_effect = fake_get
+ provider._search_prefetch_context = MagicMock(return_value="should not run")
+
+ context = provider.prefetch("hi", session_id="sid-123")
+
+ assert "User profile is Ada." in context
+ assert calls == [
+ ("/api/v1/content/read", "viking://user/memories/profile.md", 0.25),
+ ("/api/v1/content/overview", "viking://user/memories/preferences/", 0.25),
+ ("/api/v1/content/overview", "viking://user/memories/entities/", 0.25),
+ ]
+
+
+def test_prefetch_retries_session_start_memory_after_empty_failed_attempt():
+ provider = _make_prefetch_provider()
+ profile_attempts = 0
+
+ def fake_get(path, params=None, **kwargs):
+ nonlocal profile_attempts
+ uri = (params or {}).get("uri", "")
+ if uri == "viking://user/memories/profile.md":
+ profile_attempts += 1
+ if profile_attempts == 1:
+ raise RuntimeError("transient profile read failure")
+ return {"result": "Recovered user profile."}
+ return {"result": ""}
+
+ provider._client.get.side_effect = fake_get
+ provider._search_prefetch_context = MagicMock(return_value="should not run")
+
+ first = provider.prefetch("hi", session_id="sid-123")
+ provider._turn_count = 1
+ second = provider.prefetch("hi", session_id="sid-123")
+
+ assert first == ""
+ assert "Recovered user profile." in second
+ assert profile_attempts == 2
+
+
+def test_prefetch_marks_successful_empty_session_start_memory_as_checked():
+ provider = _make_prefetch_provider()
+ calls = _mock_session_start_reads(
+ provider,
+ {
+ ("/api/v1/content/read", "viking://user/memories/profile.md"): "",
+ ("/api/v1/content/overview", "viking://user/memories/preferences/"): "",
+ ("/api/v1/content/overview", "viking://user/memories/entities/"): "",
+ },
+ )
+ provider._search_prefetch_context = MagicMock(return_value="should not run")
+
+ first = provider.prefetch("hi", session_id="sid-123")
+ second = provider.prefetch("hi", session_id="sid-123")
+
+ assert first == ""
+ assert second == ""
+ assert calls == [
+ ("/api/v1/content/read", "viking://user/memories/profile.md"),
+ ("/api/v1/content/overview", "viking://user/memories/preferences/"),
+ ("/api/v1/content/overview", "viking://user/memories/entities/"),
+ ]
+ provider._search_prefetch_context.assert_not_called()
+
+
+def test_prefetch_marks_checked_when_secondary_session_memory_read_fails():
+ provider = _make_prefetch_provider()
+ calls = []
+
+ def fake_get(path, params=None, **kwargs):
+ uri = (params or {}).get("uri", "")
+ calls.append((path, uri))
+ if uri == "viking://user/memories/profile.md":
+ return {"result": "User profile is Ada."}
+ if uri == "viking://user/memories/entities/":
+ raise RuntimeError("transient entities overview failure")
+ return {"result": ""}
+
+ provider._client.get.side_effect = fake_get
+ provider._search_prefetch_context = MagicMock(return_value="should not run")
+
+ first = provider.prefetch("hi", session_id="sid-123")
+ provider._turn_count = 1
+ second = provider.prefetch("hi", session_id="sid-123")
+
+ assert "User profile is Ada." in first
+ assert second == ""
+ assert calls == [
+ ("/api/v1/content/read", "viking://user/memories/profile.md"),
+ ("/api/v1/content/overview", "viking://user/memories/preferences/"),
+ ("/api/v1/content/overview", "viking://user/memories/entities/"),
+ ]
+ provider._search_prefetch_context.assert_not_called()
+
+
+def test_prefetch_reinjects_after_in_place_compression_same_session():
+ provider = _make_prefetch_provider()
+ provider._session_id = "sid-123"
+ profiles = iter(["Profile before compression.", "Profile after compression."])
+
+ def fake_get(path, params=None, **kwargs):
+ uri = (params or {}).get("uri", "")
+ if uri == "viking://user/memories/profile.md":
+ return {"result": next(profiles)}
+ return {"result": ""}
+
+ provider._client.get.side_effect = fake_get
+ provider._search_prefetch_context = MagicMock(return_value="should not run")
+
+ first = provider.prefetch("hi", session_id="sid-123")
+ provider._turn_count = 3
+ provider.on_session_switch("sid-123", reason="compression")
+ second = provider.prefetch("hi", session_id="sid-123")
+
+ assert "Profile before compression." in first
+ assert "Profile after compression." in second
+
+
+def test_prefetch_reinjects_for_new_session_id():
+ provider = _make_prefetch_provider()
+ profiles = iter(["Session A profile.", "Session B profile."])
+
+ def fake_get(path, params=None, **kwargs):
+ uri = (params or {}).get("uri", "")
+ if uri == "viking://user/memories/profile.md":
+ return {"result": next(profiles)}
+ return {"result": ""}
+
+ provider._client.get.side_effect = fake_get
+ provider._search_prefetch_context = MagicMock(return_value="should not run")
+
+ first = provider.prefetch("hi", session_id="sid-a")
+ second = provider.prefetch("hi", session_id="sid-b")
+
+ assert "Session A profile." in first
+ assert "Session B profile." in second
+
+
+def test_prefetch_degrades_cleanly_when_some_session_memory_parts_are_missing():
+ provider = _make_prefetch_provider()
+ _mock_session_start_reads(
+ provider,
+ {
+ ("/api/v1/content/read", "viking://user/memories/profile.md"): RuntimeError("missing"),
+ ("/api/v1/content/overview", "viking://user/memories/preferences/"): (
+ "# Preferences\n- Likes source-backed answers."
+ ),
+ ("/api/v1/content/overview", "viking://user/memories/entities/"): "",
+ },
+ )
+ provider._search_prefetch_context = MagicMock(return_value="should not run")
+
+ context = provider.prefetch("hi", session_id="sid-123")
+
+ assert "Likes source-backed answers." in context
+ assert "' in context
+
+
+def test_prefetch_omits_placeholder_directory_overviews_from_session_memory():
+ provider = _make_prefetch_provider()
+ _mock_session_start_reads(
+ provider,
+ {
+ ("/api/v1/content/read", "viking://user/memories/profile.md"): (
+ "User profile is Ada."
+ ),
+ ("/api/v1/content/overview", "viking://user/memories/preferences/"): (
+ "# viking://user/memories/preferences/\n\n[Directory abstract is not ready]"
+ ),
+ ("/api/v1/content/overview", "viking://user/memories/entities/"): (
+ "# viking://user/memories/entities/\n\n[Directory overview is not generated]"
+ ),
+ },
+ )
+ provider._search_prefetch_context = MagicMock(return_value="should not run")
+
+ context = provider.prefetch("hi", session_id="sid-123")
+
+ assert "User profile is Ada." in context
+ assert "" not in context
+ assert "Directory abstract is not ready" not in context
+ assert "Directory overview is not generated" not in context
+
+
+def test_session_start_memory_context_respects_total_budget_and_preserves_profile_tail(monkeypatch):
+ monkeypatch.setenv("OPENVIKING_PROFILE_MAX_CHARS", "700")
+ provider = _make_prefetch_provider()
+ long_profile = "\n".join(
+ ["Profile head: user is Ada."]
+ + [f"profile middle {i}: {'x' * 30}" for i in range(40)]
+ + ["Profile tail: recent work is OpenViking."]
+ )
+ _mock_session_start_reads(
+ provider,
+ {
+ ("/api/v1/content/read", "viking://user/memories/profile.md"): long_profile,
+ ("/api/v1/content/overview", "viking://user/memories/preferences/"): (
+ "Preferences overview " + ("p" * 500)
+ ),
+ ("/api/v1/content/overview", "viking://user/memories/entities/"): (
+ "Entities overview " + ("e" * 500)
+ ),
+ },
+ )
+ provider._search_prefetch_context = MagicMock(return_value="should not run")
+
+ context = provider.prefetch("hi", session_id="sid-budget")
+ block = context.removeprefix("## OpenViking Context\n")
+
+ assert len(block) <= 700
+ assert "Profile head: user is Ada." in block
+ assert "Profile tail: recent work is OpenViking." in block
+ assert "[...] truncated" in block
+ assert "viking_profile" not in block
+
+
+def test_prefetch_does_not_auto_inject_memory_overview_when_profile_missing():
+ provider = _make_prefetch_provider()
+ calls = _mock_session_start_reads(
+ provider,
+ {
+ ("/api/v1/content/read", "viking://user/memories/profile.md"): RuntimeError("missing"),
+ ("/api/v1/content/overview", "viking://user/memories/preferences/"): "",
+ ("/api/v1/content/overview", "viking://user/memories/entities/"): "",
+ },
+ )
+ provider._search_prefetch_context = MagicMock(return_value="- [events]\n recalled context")
+
+ context = provider.prefetch("What should we recall?", session_id="sid-123")
+
+ assert "## Session Memory" not in context
+ assert "recalled context" in context
+ assert calls == [
+ ("/api/v1/content/read", "viking://user/memories/profile.md"),
+ ("/api/v1/content/overview", "viking://user/memories/preferences/"),
+ ("/api/v1/content/overview", "viking://user/memories/entities/"),
+ ]
+ provider._search_prefetch_context.assert_called_once_with(
+ "What should we recall?",
+ session_id="sid-123",
+ )
+
+
def test_queue_prefetch_is_noop_for_openviking_recall(monkeypatch):
provider = _make_prefetch_provider()
constructed_clients = []