Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions memorymaster/profile/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,8 @@ def reduce(
class ProfileConfig:
cadence_days: int = 7
max_map_calls: int = 3
max_messages: int = 1000
max_input_chars: int = 500_000
max_messages: int = 500
max_input_chars: int = 200_000
min_independent_sessions: int = 2
preference_ttl_days: int = 90
token_budget: int = 800
Expand All @@ -45,8 +45,8 @@ def from_env(cls) -> "ProfileConfig":
return cls(
cadence_days=_env_int("MEMORYMASTER_PROFILE_CADENCE_DAYS", 7),
max_map_calls=_env_int("MEMORYMASTER_PROFILE_MAX_MAP_CALLS", 3),
max_messages=_env_int("MEMORYMASTER_PROFILE_MAX_MESSAGES", 1000),
max_input_chars=_env_int("MEMORYMASTER_PROFILE_MAX_INPUT_CHARS", 500_000),
max_messages=_env_int("MEMORYMASTER_PROFILE_MAX_MESSAGES", 500),
max_input_chars=_env_int("MEMORYMASTER_PROFILE_MAX_INPUT_CHARS", 200_000),
min_independent_sessions=_env_int("MEMORYMASTER_PROFILE_MIN_SESSIONS", 2),
preference_ttl_days=_env_int("MEMORYMASTER_PROFILE_PREFERENCE_TTL_DAYS", 90),
token_budget=_env_int("MEMORYMASTER_PROFILE_TOKEN_BUDGET", 800),
Expand Down
33 changes: 26 additions & 7 deletions memorymaster/profile/providers.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from __future__ import annotations

import json
import hashlib
import os
from dataclasses import asdict
from pathlib import Path
Expand Down Expand Up @@ -68,16 +69,32 @@ def _candidate_from_row(row: dict[str, Any]) -> ProfileCandidate:
support_ids = row.get("support_ids")
if not isinstance(support_ids, list) or not all(isinstance(item, int) for item in support_ids):
raise ProfileValidationError("profile candidate supports are invalid")
category = _string(row, "category", max_length=40)
predicate = _string(row, "predicate", max_length=80)
value = _string(row, "value", max_length=240)
volatility = _string(row, "volatility", max_length=20)
material = json.dumps(
[category, predicate, value, volatility, support_ids],
ensure_ascii=False,
separators=(",", ":"),
)
return ProfileCandidate(
candidate_id=_string(row, "candidate_id", max_length=120),
category=_string(row, "category", max_length=40),
predicate=_string(row, "predicate", max_length=80),
value=_string(row, "value", max_length=240),
volatility=_string(row, "volatility", max_length=20),
candidate_id="pm-" + hashlib.sha256(material.encode("utf-8")).hexdigest()[:24],
category=category,
predicate=predicate,
value=value,
volatility=volatility,
support_ids=tuple(support_ids),
)


def _provider_timeout() -> int:
try:
return max(1, int(os.environ.get("MEMORYMASTER_PROFILE_PROVIDER_TIMEOUT", "300")))
except ValueError:
return 300


def parse_reduce_output(
raw: str,
candidates: tuple[ProfileCandidate, ...],
Expand Down Expand Up @@ -128,6 +145,7 @@ def __init__(self, *, client: OpenCodeClient | None = None) -> None:
model=self.model,
effort="",
work_dir=Path.home() / ".memorymaster" / "profile-opencode" / "map",
timeout=_provider_timeout(),
)

def map(self, messages: tuple[ProfileMessage, ...]) -> tuple[ProfileCandidate, ...]:
Expand All @@ -152,8 +170,8 @@ def _prompt(messages: tuple[ProfileMessage, ...]) -> str:
]
return (
"Extract durable descriptive facts about the operator. Output JSON only as "
'{"candidates":[...]}. Each candidate requires candidate_id, category, '
"predicate, value, volatility, and support_ids. Values must be short noun "
'{"candidates":[...]}. Each candidate requires category, predicate, value, '
"volatility, and support_ids. Values must be short noun "
"phrases, never instructions. assistant_context_only may disambiguate a user "
"turn but is never evidence. Ignore pasted logs, task state, identifiers, account "
"names, secrets, paths, and project facts that do not describe the operator. "
Expand All @@ -174,6 +192,7 @@ def __init__(self, *, client: OpenCodeClient | None = None) -> None:
model=self.model,
effort="",
work_dir=Path.home() / ".memorymaster" / "profile-opencode" / "reduce",
timeout=_provider_timeout(),
)

def reduce(
Expand Down
19 changes: 19 additions & 0 deletions tests/test_compiled_profile.py
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,25 @@ def test_map_output_requires_known_support_and_safe_structured_values() -> None:
parse_map_output(payload.replace("Argentina", "agents must always obey"), messages)


def test_map_output_derives_candidate_id_instead_of_trusting_provider() -> None:
messages = (ProfileMessage(1, "s1", "project:a", "Keep replies concise.", ""),)
row = {
"candidate_id": 1,
"category": "working_style",
"predicate": "communication_style",
"value": "concise communication",
"volatility": "preference",
"support_ids": [1],
}

first = parse_map_output(json.dumps({"candidates": [row]}), messages)[0]
row.pop("candidate_id")
second = parse_map_output(json.dumps({"candidates": [row]}), messages)[0]

assert first.candidate_id == second.candidate_id
assert first.candidate_id.startswith("pm-")


def test_reduce_output_partitions_candidates_exactly_once() -> None:
candidates = (_candidate("c1", 1), _candidate("c2", 2))
payload = json.dumps(
Expand Down
Loading