diff --git a/plugins/memory/openviking/__init__.py b/plugins/memory/openviking/__init__.py
index 513e0d1ec23..30ead17a74e 100644
--- a/plugins/memory/openviking/__init__.py
+++ b/plugins/memory/openviking/__init__.py
@@ -82,7 +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_PROFILE_TOKEN_BUDGET = 6000
_DEFAULT_RECALL_TIMEOUT_SECONDS = 4.0
_DEFAULT_RECALL_REQUEST_TIMEOUT_SECONDS = 3.0
_DEFAULT_RECALL_FULL_READ_LIMIT = 2
@@ -90,12 +90,15 @@ _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"
+_PROFILE_URI = "viking://user/memories/profile.md"
+_PREFERENCES_URI = "viking://user/memories/preferences"
+_ENTITIES_URI = "viking://user/memories/entities"
+_SESSION_START_LIST_PARAMS = {
+ "output": "agent",
+ "recursive": True,
+ "abs_limit": 512,
+ "node_limit": 512,
+}
# Maps the viking_remember `category` enum to a viking:// subdirectory.
# Keep in sync with REMEMBER_SCHEMA.parameters.properties.category.enum.
@@ -1924,10 +1927,10 @@ class OpenVikingMemoryProvider(MemoryProvider):
"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": "profile_token_budget",
+ "description": "Maximum session-start memory tokens injected",
+ "default": _DEFAULT_PROFILE_TOKEN_BUDGET,
+ "env_var": "OPENVIKING_PROFILE_TOKEN_BUDGET",
},
{
"key": "recall_timeout_seconds",
@@ -2969,11 +2972,11 @@ class OpenVikingMemoryProvider(MemoryProvider):
"resources": self._env_bool("OPENVIKING_RECALL_RESOURCES", False),
}
- def _profile_max_chars(self) -> int:
+ def _profile_token_budget(self) -> int:
return self._env_int(
- "OPENVIKING_PROFILE_MAX_CHARS",
- _DEFAULT_PROFILE_MAX_CHARS,
- minimum=200,
+ "OPENVIKING_PROFILE_TOKEN_BUDGET",
+ _DEFAULT_PROFILE_TOKEN_BUDGET,
+ minimum=500,
maximum=50000,
)
@@ -2987,215 +2990,274 @@ class OpenVikingMemoryProvider(MemoryProvider):
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]",
- ))
+ def _extract_memory_listing(resp: Any) -> List[Dict[str, str]]:
+ result = OpenVikingMemoryProvider._unwrap_result(resp)
+ if not isinstance(result, list):
+ return []
+
+ entries: List[Dict[str, str]] = []
+ for raw in result:
+ if not isinstance(raw, dict) or raw.get("isDir"):
+ continue
+ name = str(raw.get("rel_path") or raw.get("name") or "").strip()
+ if not name.endswith(".md"):
+ continue
+ abstract = " ".join(str(raw.get("abstract") or "").split())[:200]
+ entries.append({"name": name, "abstract": abstract})
+ entries.sort(key=lambda entry: entry["name"])
+ return entries
@staticmethod
- def _weighted_context_len(content: str) -> int:
- total = 0
- for ch in content:
- total += 2 if ord(ch) >= 0x3000 else 1
- return total
+ def _token_units(content: str) -> int:
+ """Return quarter-token units using the shared OpenViking estimator."""
+ return sum(6 if ord(ch) >= 0x3000 else 1 for ch in content)
@classmethod
- def _take_weighted_prefix(cls, content: str, max_chars: int) -> str:
- if max_chars <= 0:
+ def _estimate_tokens(cls, content: str) -> int:
+ units = cls._token_units(content)
+ return (units + 3) // 4
+
+ @classmethod
+ def _take_token_prefix(cls, content: str, max_units: int) -> str:
+ if max_units <= 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]
+ for index, ch in enumerate(content):
+ used += 6 if ord(ch) >= 0x3000 else 1
+ if used > max_units:
+ return content[:index]
return content
@classmethod
- def _take_weighted_suffix(cls, content: str, max_chars: int) -> str:
- if max_chars <= 0:
+ def _take_token_suffix(cls, content: str, max_units: int) -> str:
+ if max_units <= 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:
+ used += 6 if ord(ch) >= 0x3000 else 1
+ if used > max_units:
return content[start:]
start = idx
return content
@classmethod
- def _truncate_text_content(cls, content: str, max_chars: int) -> str:
+ def _truncate_profile_content(cls, content: str, max_units: int) -> str:
content = content.strip()
- if cls._weighted_context_len(content) <= max_chars:
+ if cls._token_units(content) <= max_units:
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 _head_only() -> str:
+ marker = "\n... [profile truncated]"
+ marker_units = cls._token_units(marker)
+ if marker_units >= max_units:
+ return cls._take_token_prefix(content, max_units)
+ head = cls._take_token_prefix(content, max_units - marker_units).rstrip()
+ return f"{head}{marker}" if head else cls._take_token_prefix(content, max_units)
- def _read_session_start_text(
+ lines = content.split("\n")
+ head_line_count = 8
+ if len(lines) <= head_line_count + 4:
+ return _head_only()
+
+ marker = "\n... [profile middle elided] ...\n"
+ remaining = max_units - cls._token_units(marker)
+ if remaining <= 0:
+ return _head_only()
+
+ head = cls._take_token_prefix(
+ "\n".join(lines[:head_line_count]),
+ remaining // 2,
+ ).rstrip()
+ tail = cls._take_token_suffix(
+ "\n".join(lines[head_line_count:]),
+ remaining - cls._token_units(head),
+ ).lstrip()
+ return f"{head}{marker}{tail}" if tail else _head_only()
+
+ def _read_session_start_profile(
self,
client: _VikingClient,
- endpoint: str,
- uri: str,
*,
- skip_placeholder: bool = False,
- timeout: Optional[float] = None,
+ deadline: float,
+ request_timeout: float,
) -> Optional[str]:
try:
- kwargs = {"timeout": timeout} if timeout is not None else {}
- resp = client.get(endpoint, params={"uri": uri}, **kwargs)
+ timeout = self._remaining_recall_timeout(deadline, request_timeout)
+ resp = client.get(
+ "/api/v1/content/read",
+ params={"uri": _PROFILE_URI},
+ timeout=timeout,
+ )
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
+ return self._extract_text_content(resp)
+
+ def _list_session_start_memories(
+ self,
+ client: _VikingClient,
+ uri: str,
+ *,
+ deadline: float,
+ request_timeout: float,
+ ) -> List[Dict[str, str]]:
+ try:
+ timeout = self._remaining_recall_timeout(deadline, request_timeout)
+ resp = client.get(
+ "/api/v1/fs/ls",
+ params={"uri": uri, **_SESSION_START_LIST_PARAMS},
+ timeout=timeout,
+ )
+ except Exception:
+ return []
+ return self._extract_memory_listing(resp)
def _read_session_start_memory_parts(
self,
*,
client: Optional[_VikingClient] = None,
- request_timeout: Optional[float] = None,
- ) -> Dict[str, Optional[str]]:
+ deadline: float,
+ request_timeout: float,
+ ) -> Dict[str, Any]:
active_client = client or self._client
if not active_client:
return {}
- profile_uri, _level = _PROFILE_READS[0]
+ profile = self._read_session_start_profile(
+ active_client,
+ deadline=deadline,
+ request_timeout=request_timeout,
+ )
+ if profile is None:
+ return {"profile": None, "preferences": [], "entities": []}
return {
- "profile": self._read_session_start_text(
+ "profile": profile,
+ "preferences": self._list_session_start_memories(
active_client,
- "/api/v1/content/read",
- profile_uri,
- timeout=request_timeout,
+ _PREFERENCES_URI,
+ deadline=deadline,
+ request_timeout=request_timeout,
),
- "preferences": self._read_session_start_text(
+ "entities": self._list_session_start_memories(
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,
+ _ENTITIES_URI,
+ deadline=deadline,
+ request_timeout=request_timeout,
),
}
@staticmethod
- def _format_session_start_memory_block(
- *,
- profile: str = "",
- preferences: str = "",
- entities: str = "",
+ def _assemble_session_start_memory_block(
+ profile: str,
+ preference_lines: List[str],
+ entity_lines: List[str],
) -> str:
- lines: List[str] = ["## Session Memory"]
+ lines: List[str] = []
if profile:
lines.extend([
- ' ',
+ f'',
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 ""
+ if preference_lines or entity_lines:
+ lines.append("")
+ lines.extend(preference_lines)
+ lines.extend(entity_lines)
+ lines.append("")
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}
+ @classmethod
+ def _format_memory_listing(
+ cls,
+ uri: str,
+ entries: List[Dict[str, str]],
+ max_units: int,
+ ) -> tuple[List[str], int]:
+ if not entries or max_units <= 0:
+ return [], 0
+
+ header = f" {uri}/"
+ header_units = cls._token_units(header)
+ if header_units > max_units:
+ stub = f" {uri}/ ({len(entries)} entries; use `viking_search`)"
+ stub_units = cls._token_units(stub)
+ return ([stub], stub_units) if stub_units <= max_units else ([], 0)
+
+ lines = [header]
+ used = header_units
+ newline_units = cls._token_units("\n")
+ for index, entry in enumerate(entries):
+ abstract = entry.get("abstract", "")
+ description = f" — {abstract}" if abstract else ""
+ line = f" - {entry['name']}{description}"
+ line_units = newline_units + cls._token_units(line)
+ if used + line_units > max_units:
+ remaining = len(entries) - index
+ tail = f" ... +{remaining} more, use `viking_search`"
+ tail_units = newline_units + cls._token_units(tail)
+ if used + tail_units <= max_units:
+ lines.append(tail)
+ used += tail_units
+ break
+ lines.append(line)
+ used += line_units
+ return lines, used
+
+ @classmethod
+ def _build_session_start_memory_block(
+ cls,
+ *,
+ profile: str,
+ preferences: List[Dict[str, str]],
+ entities: List[Dict[str, str]],
+ token_budget: int,
+ ) -> str:
+ profile = profile.strip()
+ if not profile and not preferences and not entities:
+ return ""
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 "",
+ scaffold = cls._assemble_session_start_memory_block(
+ placeholder if profile else "",
+ [placeholder] if preferences else [],
+ [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": ""}
+ placeholder_count = int(bool(profile)) + int(bool(preferences)) + int(bool(entities))
+ overhead_units = cls._token_units(scaffold) - placeholder_count
+ available_units = max(0, (token_budget * 4) - overhead_units)
- 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"])
+ profile_text = ""
+ if profile and available_units > 0:
+ profile_units = min(available_units, token_budget * 2)
+ profile_text = cls._truncate_profile_content(profile, profile_units)
+ available_units -= cls._token_units(profile_text)
- 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
+ preference_lines: List[str] = []
+ entity_lines: List[str] = []
+ if preferences and entities:
+ preference_budget = available_units // 2
+ else:
+ preference_budget = available_units
+ preference_lines, preference_units = cls._format_memory_listing(
+ _PREFERENCES_URI,
+ preferences,
+ preference_budget,
+ )
+ available_units -= preference_units
+ entity_lines, _ = cls._format_memory_listing(
+ _ENTITIES_URI,
+ entities,
+ available_units,
+ )
+
+ return cls._assemble_session_start_memory_block(
+ profile_text,
+ preference_lines,
+ entity_lines,
+ )
def _session_start_memory_context(self, session_id: str) -> str:
session_key = session_id or self._session_id or "__openviking_default_session__"
@@ -3203,27 +3265,24 @@ class OpenVikingMemoryProvider(MemoryProvider):
return ""
try:
cfg = self._recall_config()
+ deadline = time.monotonic() + cfg["timeout_seconds"]
raw_parts = self._read_session_start_memory_parts(
+ deadline=deadline,
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)
+ profile = raw_parts.get("profile")
+ if profile is None:
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
+ self._profile_prefetched_sessions.add(session_key)
+ return self._build_session_start_memory_block(
+ profile=profile,
+ preferences=raw_parts.get("preferences") or [],
+ entities=raw_parts.get("entities") or [],
+ token_budget=self._profile_token_budget(),
+ )
@staticmethod
def _clamp_score(value: Any) -> float:
diff --git a/tests/openviking_plugin/test_openviking.py b/tests/openviking_plugin/test_openviking.py
index f5a4f84ed7f..a2e7de62ea8 100644
--- a/tests/openviking_plugin/test_openviking.py
+++ b/tests/openviking_plugin/test_openviking.py
@@ -106,7 +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",
+ "OPENVIKING_PROFILE_TOKEN_BUDGET",
):
monkeypatch.delenv(key, raising=False)
for key, value in env.items():
@@ -966,7 +966,7 @@ class TestOpenVikingRead:
class TestOpenVikingAutoRecallPrefetch:
def test_prefetch_e2e_sends_limit_and_reads_l2_content(self, monkeypatch):
- records = {"searches": [], "reads": [], "headers": []}
+ records = {"searches": [], "reads": [], "listings": [], "headers": []}
class Handler(BaseHTTPRequestHandler):
def _send_json(self, payload):
@@ -988,12 +988,41 @@ 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)
+ if uri == "viking://user/memories/profile.md":
+ self._send_json({"result": "E2E user profile."})
return
records["reads"].append(uri)
self._send_json({"result": {"content": "E2E full L2 memory content."}})
return
+ if parsed.path == "/api/v1/fs/ls":
+ query = {key: values[0] for key, values in parse_qs(parsed.query).items()}
+ records["listings"].append(query)
+ uri = query.get("uri")
+ if uri == "viking://user/memories/preferences":
+ self._send_json({
+ "result": [
+ {"isDir": True, "rel_path": "owner", "abstract": "ignored"},
+ {
+ "isDir": False,
+ "rel_path": "owner/answers.md",
+ "abstract": "Prefers source-backed answers.",
+ },
+ ]
+ })
+ return
+ if uri == "viking://user/memories/entities":
+ self._send_json({
+ "result": [
+ {
+ "isDir": False,
+ "rel_path": "people/ada.md",
+ "abstract": "Ada is the project owner.",
+ }
+ ]
+ })
+ return
+ self.send_error(404)
+ return
self.send_error(404)
def do_POST(self):
@@ -1033,7 +1062,7 @@ class TestOpenVikingAutoRecallPrefetch:
"OPENVIKING_RECALL_MAX_INJECTED_CHARS",
"OPENVIKING_RECALL_PREFER_ABSTRACT",
"OPENVIKING_RECALL_RESOURCES",
- "OPENVIKING_PROFILE_MAX_CHARS",
+ "OPENVIKING_PROFILE_TOKEN_BUDGET",
"OPENVIKING_API_KEY",
):
monkeypatch.delenv(key, raising=False)
@@ -1053,9 +1082,20 @@ class TestOpenVikingAutoRecallPrefetch:
thread.join(timeout=3.0)
assert block.startswith("## OpenViking Context\n")
+ assert "E2E user profile." in block
+ assert "owner/answers.md — Prefers source-backed answers." in block
+ assert "people/ada.md — Ada is the project owner." in block
assert "E2E full L2 memory content." in block
assert "E2E abstract should not be injected." not in block
assert records["reads"] == ["viking://user/peers/hermes/memories/e2e-full.md"]
+ assert [listing["uri"] for listing in records["listings"]] == [
+ "viking://user/memories/preferences",
+ "viking://user/memories/entities",
+ ]
+ assert all(listing["output"] == "agent" for listing in records["listings"])
+ assert all(listing["recursive"].lower() == "true" for listing in records["listings"])
+ assert all(listing["abs_limit"] == "512" for listing in records["listings"])
+ assert all(listing["node_limit"] == "512" for listing in records["listings"])
assert len(records["searches"]) == 1
assert records["searches"][0]["context_type"] == "memory"
assert records["searches"][0]["session_id"] == "e2e-session"
diff --git a/tests/plugins/memory/test_openviking_provider.py b/tests/plugins/memory/test_openviking_provider.py
index 69a5e8619aa..df2e5632be8 100644
--- a/tests/plugins/memory/test_openviking_provider.py
+++ b/tests/plugins/memory/test_openviking_provider.py
@@ -35,7 +35,7 @@ def _clear_openviking_env(monkeypatch):
"OPENVIKING_USER",
"OPENVIKING_AGENT",
"OPENVIKING_CLI_CONFIG_FILE",
- "OPENVIKING_PROFILE_MAX_CHARS",
+ "OPENVIKING_PROFILE_TOKEN_BUDGET",
):
monkeypatch.delenv(key, raising=False)
@@ -3580,12 +3580,28 @@ def _make_prefetch_provider() -> OpenVikingMemoryProvider:
return provider
-def _mock_session_start_reads(provider: OpenVikingMemoryProvider, responses: dict[tuple[str, str], object]):
+_SESSION_START_LIST_PARAMS = {
+ "output": "agent",
+ "recursive": True,
+ "abs_limit": 512,
+ "node_limit": 512,
+}
+
+
+def _memory_listing(*entries):
+ return list(entries)
+
+
+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))
+ request_params = dict(params or {})
+ uri = request_params.get("uri", "")
+ calls.append((path, request_params, kwargs.get("timeout")))
response = responses.get((path, uri), "")
if isinstance(response, Exception):
raise response
@@ -3595,6 +3611,15 @@ def _mock_session_start_reads(provider: OpenVikingMemoryProvider, responses: dic
return calls
+def test_session_start_token_estimator_matches_shared_openviking_contract():
+ provider = OpenVikingMemoryProvider
+
+ assert provider._estimate_tokens("abcd") == 1
+ assert provider._estimate_tokens("设") == 2
+ assert provider._estimate_tokens("设置") == 3
+ assert provider._estimate_tokens("设置ab") == 4
+
+
def test_prefetch_prepends_session_start_memory_context_once_per_session():
provider = _make_prefetch_provider()
calls = _mock_session_start_reads(
@@ -3603,11 +3628,26 @@ def test_prefetch_prepends_session_start_memory_context_once_per_session():
("/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/fs/ls", "viking://user/memories/preferences"): _memory_listing(
+ {"isDir": True, "rel_path": "owner"},
+ {
+ "isDir": False,
+ "rel_path": "owner/z-last.md",
+ "abstract": " Keep replies compact. ",
+ },
+ {
+ "isDir": False,
+ "rel_path": "owner/a-first.md",
+ "abstract": "Verify source before editing.",
+ },
+ {"isDir": False, "rel_path": "owner/ignored.txt", "abstract": "ignore"},
),
- ("/api/v1/content/overview", "viking://user/memories/entities/"): (
- "# Entities\n- Ada Lovelace: collaborator."
+ ("/api/v1/fs/ls", "viking://user/memories/entities"): _memory_listing(
+ {
+ "isDir": False,
+ "rel_path": "people/ada.md",
+ "abstract": "Ada Lovelace is a collaborator.",
+ },
),
},
)
@@ -3616,20 +3656,30 @@ def test_prefetch_prepends_session_start_memory_context_once_per_session():
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 "" in first
+ assert "viking://user/memories/preferences/" in first
+ assert "owner/z-last.md — Keep replies compact." in first
+ assert first.index("owner/a-first.md") < first.index("owner/z-last.md")
+ assert "viking://user/memories/entities/" in first
+ assert "people/ada.md — Ada Lovelace is a collaborator." in first
+ assert "owner/ignored.txt" not in first
+ 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
+ assert "viking://user/memories/preferences/" in context
def test_session_start_memory_context_respects_total_budget_and_preserves_profile_tail(monkeypatch):
- monkeypatch.setenv("OPENVIKING_PROFILE_MAX_CHARS", "700")
+ monkeypatch.setenv("OPENVIKING_PROFILE_TOKEN_BUDGET", "6000")
provider = _make_prefetch_provider()
long_profile = "\n".join(
["Profile head: user is Ada."]
- + [f"profile middle {i}: {'x' * 30}" for i in range(40)]
+ + [f"profile middle {i}: {'设' * 30}" for i in range(300)]
+ ["Profile tail: recent work is OpenViking."]
)
+ listing = _memory_listing(*[
+ {
+ "isDir": False,
+ "rel_path": f"owner/topic-{index:02d}.md",
+ "abstract": "偏好经过源码验证的答案" * 6,
+ }
+ for index in range(700)
+ ])
_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)
- ),
+ ("/api/v1/fs/ls", "viking://user/memories/preferences"): listing,
+ ("/api/v1/fs/ls", "viking://user/memories/entities"): listing,
},
)
provider._search_prefetch_context = MagicMock(return_value="should not run")
@@ -3877,10 +3915,11 @@ def test_session_start_memory_context_respects_total_budget_and_preserves_profil
context = provider.prefetch("hi", session_id="sid-budget")
block = context.removeprefix("## OpenViking Context\n")
- assert len(block) <= 700
+ assert provider._estimate_tokens(block) <= 6000
assert "Profile head: user is Ada." in block
assert "Profile tail: recent work is OpenViking." in block
- assert "[...] truncated" in block
+ assert "[profile middle elided]" in block
+ assert "" in block
assert "viking_profile" not in block
@@ -3889,21 +3928,19 @@ def test_prefetch_does_not_auto_inject_memory_overview_when_profile_missing():
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/"): "",
+ ("/api/v1/content/read", "viking://user/memories/profile.md"): RuntimeError(
+ "transient profile failure"
+ ),
},
)
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 "