feat(memory): add Claude-style memory runtime (#267)

* Add Claude-style memory runtime

* fix(memory): trim relevance EOF whitespace
This commit is contained in:
Jiabin Tang
2026-05-17 15:31:35 +08:00
committed by GitHub
parent 6243793ec0
commit 09bc641aad
17 changed files with 1475 additions and 35 deletions
+224 -6
View File
@@ -4,6 +4,7 @@ from __future__ import annotations
import importlib.metadata
import json
import os
import re
import shutil
import subprocess
@@ -38,8 +39,28 @@ from openharness.memory import (
list_memory_files,
migrate_memory,
remove_memory_entry,
scan_memory_files,
)
from openharness.memory.agent import (
ensure_agent_memory_vault,
get_agent_memory_entrypoint,
initialize_agent_memory_from_snapshot,
)
from openharness.memory.schema import (
DEFAULT_MEMORY_SCOPE,
DEFAULT_MEMORY_TYPE,
MEMORY_TYPES,
is_disabled_metadata,
is_memory_expired,
parse_memory_scope,
parse_memory_type,
split_memory_file,
)
from openharness.memory.team import (
check_team_memory_secrets,
ensure_team_memory_vault,
get_team_memory_dir,
)
from openharness.memory.schema import is_disabled_metadata, is_memory_expired, split_memory_file
from openharness.output_styles import load_output_styles
from openharness.permissions import PermissionChecker, PermissionMode
from openharness.plugins import load_plugins
@@ -60,6 +81,12 @@ from openharness.services.autodream import (
restore_memory_backup,
start_dream_now,
)
from openharness.services.memory_extract import extract_memories_from_turn
from openharness.services.session_memory import (
get_session_memory_content,
get_session_memory_path,
update_session_memory_file,
)
from openharness.services.session_backend import DEFAULT_SESSION_BACKEND, SessionBackend
from openharness.skills import load_skill_registry
from openharness.skills.types import SkillDefinition
@@ -654,7 +681,10 @@ def create_default_command_registry(
message=(
f"Memory store: {backend.label}\n"
f"Memory directory: {backend.get_memory_dir()}\n"
f"Entrypoint: {backend.get_entrypoint()}"
f"Entrypoint: {backend.get_entrypoint()}\n"
"Commands: list, show NAME, add TITLE :: CONTENT, remove NAME, "
"edit [NAME], validate, extract, session, team, agent, "
"migrate --dry-run, migrate --apply"
)
)
action = tokens[0]
@@ -710,19 +740,55 @@ def create_default_command_registry(
return CommandResult(message=f"Memory entry not found: {rest}")
return CommandResult(message=content)
if action == "add" and rest:
title, separator, content = rest.partition("::")
memory_type, scope, cleaned_rest = _parse_memory_add_flags(rest)
title, separator, content = cleaned_rest.partition("::")
if not separator or not title.strip() or not content.strip():
return CommandResult(message="Usage: /memory add TITLE :: CONTENT")
path = backend.add_entry(title.strip(), content.strip())
return CommandResult(message="Usage: /memory add [--type TYPE] [--scope SCOPE] TITLE :: CONTENT")
if context.memory_backend is None:
path = add_memory_entry(
context.cwd,
title.strip(),
content.strip(),
memory_type=memory_type,
scope=scope,
)
else:
path = backend.add_entry(title.strip(), content.strip())
return CommandResult(message=f"Added memory entry {path.name}")
if action == "remove" and rest:
if backend.remove_entry(rest.strip()):
return CommandResult(message=f"Removed memory entry {rest.strip()}")
return CommandResult(message=f"Memory entry not found: {rest.strip()}")
if action == "edit":
return _handle_memory_edit_command(rest, context, backend)
if action == "validate":
return _handle_memory_validate_command(context)
if action == "extract":
if context.memory_backend is not None:
return CommandResult(message="Memory extraction is only supported for OpenHarness project memory.")
result = await extract_memories_from_turn(
cwd=context.cwd,
api_client=context.engine.api_client,
model=context.engine.model,
messages=context.engine.messages,
max_records=load_settings().memory.auto_extract_max_records,
)
if result.skipped:
return CommandResult(message=f"Memory extraction skipped: {result.reason}")
return CommandResult(
message="Memory extraction wrote:\n" + "\n".join(f"- {path}" for path in result.written_paths)
)
if action == "session":
return _handle_memory_session_command(rest, context)
if action == "team":
return _handle_memory_team_command(rest, context)
if action == "agent":
return _handle_memory_agent_command(rest, context)
return CommandResult(
message=(
"Usage: /memory "
"[list|show NAME|add TITLE :: CONTENT|remove NAME|"
"[list|show NAME|add TITLE :: CONTENT|remove NAME|edit [NAME]|"
"validate|extract|session|team|agent|"
"migrate --dry-run|migrate --apply]"
)
)
@@ -2478,6 +2544,158 @@ def create_default_command_registry(
return registry
def _handle_memory_edit_command(
args: str,
context: CommandContext,
backend: MemoryCommandBackend,
) -> CommandResult:
memory_dir = backend.get_memory_dir()
target = backend.get_entrypoint()
if args.strip():
path, invalid = _resolve_memory_entry_path(memory_dir, args.strip())
if invalid:
return CommandResult(message="Memory entry path must stay within the configured memory directory.")
if path is None:
return CommandResult(message=f"Memory entry not found: {args.strip()}")
target = path
target.parent.mkdir(parents=True, exist_ok=True)
target.touch(exist_ok=True)
editor = os.environ.get("VISUAL") or os.environ.get("EDITOR")
if not editor:
return CommandResult(message=f"Memory file ready: {target}\nSet $VISUAL or $EDITOR to open it from /memory edit.")
result = subprocess.run([editor, str(target)], cwd=context.cwd, check=False)
if result.returncode != 0:
return CommandResult(message=f"Editor exited with status {result.returncode}: {editor}")
return CommandResult(message=f"Edited memory file: {target}")
def _parse_memory_add_flags(args: str):
"""Parse optional ``/memory add`` type/scope flags."""
memory_type = DEFAULT_MEMORY_TYPE
scope = DEFAULT_MEMORY_SCOPE
rest = args.strip()
changed = True
while changed:
changed = False
if rest.startswith("--type "):
_, _, tail = rest.partition(" ")
raw, _, rest = tail.partition(" ")
parsed = parse_memory_type(raw, default=DEFAULT_MEMORY_TYPE)
if parsed is not None:
memory_type = parsed
changed = True
if rest.startswith("--scope "):
_, _, tail = rest.partition(" ")
raw, _, rest = tail.partition(" ")
parsed_scope = parse_memory_scope(raw, default=DEFAULT_MEMORY_SCOPE)
if parsed_scope is not None:
scope = parsed_scope
changed = True
return memory_type, scope, rest
def _handle_memory_validate_command(context: CommandContext) -> CommandResult:
memory_dir = get_project_memory_dir(context.cwd)
headers = scan_memory_files(context.cwd, max_files=500)
issues: list[str] = []
for header in headers:
raw_type = header.frontmatter.get("type") or header.frontmatter.get("memory_type")
if parse_memory_type(raw_type) is None:
issues.append(
f"- {header.relative_path}: invalid or missing type {raw_type!r}; expected {', '.join(MEMORY_TYPES)}"
)
if "team" in Path(header.relative_path).parts:
try:
text = header.path.read_text(encoding="utf-8", errors="replace")
except OSError:
text = ""
secret_error = check_team_memory_secrets(text)
if secret_error:
issues.append(f"- {header.relative_path}: {secret_error}")
if not issues:
return CommandResult(
message=(
"Memory validation passed.\n"
f"- files: {len(headers)}\n"
f"- memory_dir: {memory_dir}"
)
)
return CommandResult(message="Memory validation issues:\n" + "\n".join(issues))
def _handle_memory_session_command(args: str, context: CommandContext) -> CommandResult:
action = args.split(maxsplit=1)[0] if args.strip() else "status"
path = get_session_memory_path(context.cwd, context.session_id or "default")
if action == "update":
path = update_session_memory_file(
context.cwd,
context.engine.messages,
tool_metadata=context.engine.tool_metadata,
session_id=context.session_id or "default",
)
return CommandResult(message=f"Updated session memory: {path}")
if action == "show":
content = get_session_memory_content(path)
return CommandResult(message=content or f"No session memory at {path}")
return CommandResult(
message=(
"Session memory:\n"
f"- path: {path}\n"
f"- exists: {path.exists()}\n"
"Commands: /memory session [status|show|update]"
)
)
def _handle_memory_team_command(args: str, context: CommandContext) -> CommandResult:
action = args.split(maxsplit=1)[0] if args.strip() else "status"
team_dir = ensure_team_memory_vault(context.cwd)
if action == "list":
files = sorted(path for path in team_dir.rglob("*.md") if path.name != "MEMORY.md")
return CommandResult(message="\n".join(str(path.relative_to(team_dir)) for path in files) or "No team memory files.")
if action == "validate":
issues: list[str] = []
for path in sorted(team_dir.rglob("*.md")):
if path.name == "MEMORY.md":
continue
text = path.read_text(encoding="utf-8", errors="replace")
secret_error = check_team_memory_secrets(text)
if secret_error:
issues.append(f"- {path.relative_to(team_dir)}: {secret_error}")
return CommandResult(message="Team memory validation passed." if not issues else "\n".join(issues))
return CommandResult(
message=(
"Team memory:\n"
f"- directory: {get_team_memory_dir(context.cwd)}\n"
f"- exists: {team_dir.exists()}\n"
"Commands: /memory team [status|list|validate]"
)
)
def _handle_memory_agent_command(args: str, context: CommandContext) -> CommandResult:
parts = args.split()
action = parts[0] if parts else "status"
agent_type = parts[1] if len(parts) > 1 else "default"
scope = parts[2] if len(parts) > 2 else "project"
if scope not in {"user", "project", "local"}:
return CommandResult(message="Agent memory scope must be one of: user, project, local")
if action == "snapshot":
target = initialize_agent_memory_from_snapshot(context.cwd, agent_type, scope) # type: ignore[arg-type]
return CommandResult(message=f"Initialized agent memory from snapshot: {target}" if target else "No snapshot found.")
vault = ensure_agent_memory_vault(context.cwd, agent_type, scope) # type: ignore[arg-type]
return CommandResult(
message=(
"Agent memory:\n"
f"- agent_type: {agent_type}\n"
f"- scope: {scope}\n"
f"- directory: {vault}\n"
f"- entrypoint: {get_agent_memory_entrypoint(context.cwd, agent_type, scope)}"
)
)
def _resolve_memory_entry_path(memory_dir: Path, candidate: str) -> tuple[Path | None, bool]:
"""Resolve a memory entry path while enforcing containment under ``memory_dir``."""
+4
View File
@@ -63,8 +63,12 @@ class MemorySettings(BaseModel):
enabled: bool = True
max_files: int = 5
max_entrypoint_lines: int = 200
max_entrypoint_bytes: int = 25_000
context_window_tokens: int | None = None
auto_compact_threshold_tokens: int | None = None
auto_extract_enabled: bool = False
auto_extract_max_records: int = 3
session_memory_enabled: bool = True
auto_dream_enabled: bool = False
auto_dream_min_hours: float = 24.0
auto_dream_min_sessions: int = 5
+63
View File
@@ -152,6 +152,63 @@ class QueryEngine:
**kwargs,
)
def _prepare_session_memory(self) -> None:
"""Expose file-backed session memory to compaction when enabled."""
if self._settings is None or not self._settings.memory.session_memory_enabled:
return
if not self._settings.memory.enabled:
return
from openharness.services.session_memory import prepare_session_memory_metadata
prepare_session_memory_metadata(
self._cwd,
self._tool_metadata,
session_id=str(self._tool_metadata.get("session_id") or "default"),
)
async def _update_session_memory(self) -> None:
"""Persist a session checkpoint after a user turn."""
if self._settings is None or not self._settings.memory.session_memory_enabled:
return
if not self._settings.memory.enabled:
return
from openharness.services.session_memory import update_session_memory_file
update_session_memory_file(
self._cwd,
list(self._messages),
tool_metadata=self._tool_metadata,
session_id=str(self._tool_metadata.get("session_id") or "default"),
)
async def _extract_durable_memories(self) -> None:
"""Run the optional durable memory extraction pass."""
if self._settings is None or not self._settings.memory.auto_extract_enabled:
return
if not self._settings.memory.enabled:
return
from openharness.services.memory_extract import extract_memories_from_turn
try:
result = await extract_memories_from_turn(
cwd=self._cwd,
api_client=self._api_client,
model=self._model,
messages=list(self._messages),
max_records=self._settings.memory.auto_extract_max_records,
)
except Exception as exc:
self._tool_metadata["memory_extract_last_error"] = str(exc)
return
self._tool_metadata["memory_extract_last"] = {
"skipped": result.skipped,
"reason": result.reason,
"written_paths": [str(path) for path in result.written_paths],
}
def has_pending_continuation(self) -> bool:
"""Return True when the conversation ends with tool results awaiting a follow-up model turn."""
if not self._messages:
@@ -176,6 +233,7 @@ class QueryEngine:
)
if user_message.text.strip() and not self._tool_metadata.pop("_suppress_next_user_goal", False):
remember_user_goal(self._tool_metadata, user_message.text)
self._prepare_session_memory()
self._messages = sanitize_conversation_messages(self._messages)
self._messages.append(user_message)
if self._hook_executor is not None:
@@ -215,10 +273,13 @@ class QueryEngine:
self._cost_tracker.add(usage)
yield event
finally:
await self._update_session_memory()
await self._extract_durable_memories()
self._schedule_auto_dream()
async def continue_pending(self, *, max_turns: int | None = None) -> AsyncIterator[StreamEvent]:
"""Continue an interrupted tool loop without appending a new user message."""
self._prepare_session_memory()
self._messages = sanitize_conversation_messages(self._messages)
context = QueryContext(
api_client=self._api_client,
@@ -241,3 +302,5 @@ class QueryEngine:
if usage is not None:
self._cost_tracker.add(usage)
yield event
await self._update_session_memory()
await self._extract_durable_memories()
+3
View File
@@ -7,10 +7,12 @@ from openharness.memory.paths import get_memory_entrypoint, get_project_memory_d
from openharness.memory.scan import scan_memory_files
from openharness.memory.search import find_relevant_memories
from openharness.memory.usage import mark_memory_used
from openharness.memory.relevance import format_relevant_memories, select_relevant_memories
__all__ = [
"add_memory_entry",
"find_relevant_memories",
"format_relevant_memories",
"get_memory_entrypoint",
"get_project_memory_dir",
"list_memory_files",
@@ -19,4 +21,5 @@ __all__ = [
"migrate_memory",
"remove_memory_entry",
"scan_memory_files",
"select_relevant_memories",
]
+91
View File
@@ -0,0 +1,91 @@
"""Agent-scoped memory paths and snapshots."""
from __future__ import annotations
import re
import shutil
from pathlib import Path
from typing import Literal
from openharness.config.paths import get_data_dir
from openharness.memory.paths import get_project_memory_dir
AgentMemoryScope = Literal["user", "project", "local"]
MEMORY_INDEX = "MEMORY.md"
SNAPSHOT_DIR_NAME = "agent-memory-snapshots"
def sanitize_agent_type(agent_type: str) -> str:
"""Return a path-safe agent type."""
return re.sub(r"[^a-zA-Z0-9_.-]+", "_", agent_type.strip()).strip("._") or "default"
def get_agent_memory_dir(cwd: str | Path, agent_type: str, scope: AgentMemoryScope) -> Path:
"""Return an agent memory vault for the requested scope."""
safe = sanitize_agent_type(agent_type)
if scope == "project":
return get_project_memory_dir(cwd) / "agent" / safe
if scope == "local":
return Path(cwd).resolve() / ".openharness" / "agent-memory-local" / safe
return get_data_dir() / "agent-memory" / safe
def ensure_agent_memory_vault(cwd: str | Path, agent_type: str, scope: AgentMemoryScope) -> Path:
"""Create and return an agent-scoped memory vault."""
memory_dir = get_agent_memory_dir(cwd, agent_type, scope)
memory_dir.mkdir(parents=True, exist_ok=True)
entrypoint = memory_dir / MEMORY_INDEX
if not entrypoint.exists():
entrypoint.write_text("# Memory Index\n", encoding="utf-8")
return memory_dir
def get_agent_memory_entrypoint(cwd: str | Path, agent_type: str, scope: AgentMemoryScope) -> Path:
"""Return an agent memory ``MEMORY.md`` path."""
return ensure_agent_memory_vault(cwd, agent_type, scope) / MEMORY_INDEX
def get_agent_snapshot_dir(cwd: str | Path, agent_type: str) -> Path:
"""Return the project snapshot directory for an agent type."""
return Path(cwd).resolve() / ".openharness" / SNAPSHOT_DIR_NAME / sanitize_agent_type(agent_type)
def initialize_agent_memory_from_snapshot(
cwd: str | Path,
agent_type: str,
scope: AgentMemoryScope,
*,
replace: bool = False,
) -> Path | None:
"""Initialize local agent memory from a project snapshot if present."""
snapshot_dir = get_agent_snapshot_dir(cwd, agent_type)
if not snapshot_dir.exists():
return None
target = ensure_agent_memory_vault(cwd, agent_type, scope)
if replace and target.exists():
shutil.rmtree(target)
target.mkdir(parents=True, exist_ok=True)
for src in snapshot_dir.rglob("*.md"):
rel = src.relative_to(snapshot_dir)
dest = target / rel
dest.parent.mkdir(parents=True, exist_ok=True)
if replace or not dest.exists() or _is_default_agent_index(dest):
shutil.copy2(src, dest)
return target
def _is_default_agent_index(path: Path) -> bool:
if path.name != MEMORY_INDEX or not path.exists():
return False
try:
text = path.read_text(encoding="utf-8", errors="replace")
except OSError:
return False
return text.startswith("# Memory Index\n")
+29 -6
View File
@@ -8,6 +8,10 @@ from re import sub
from openharness.memory.paths import get_memory_entrypoint, get_project_memory_dir
from openharness.memory.scan import scan_memory_files
from openharness.memory.schema import (
DEFAULT_MEMORY_SCOPE,
DEFAULT_MEMORY_TYPE,
MemoryScope,
MemoryType,
SCHEMA_VERSION,
compute_memory_signature,
first_content_line,
@@ -32,12 +36,28 @@ def list_memory_files(cwd: str | Path) -> list[Path]:
return sorted(header.path for header in scan_memory_files(cwd, max_files=None))
def add_memory_entry(cwd: str | Path, title: str, content: str) -> Path:
def add_memory_entry(
cwd: str | Path,
title: str,
content: str,
*,
memory_type: MemoryType = DEFAULT_MEMORY_TYPE,
scope: MemoryScope = DEFAULT_MEMORY_SCOPE,
description: str = "",
tags: tuple[str, ...] = (),
) -> Path:
"""Create or refresh a memory file and append it to MEMORY.md."""
memory_dir = get_project_memory_dir(cwd)
if scope == "team":
from openharness.memory.team import check_team_memory_secrets, ensure_team_memory_vault
secret_error = check_team_memory_secrets(content)
if secret_error:
raise ValueError(secret_error)
memory_dir = ensure_team_memory_vault(cwd)
else:
memory_dir = get_project_memory_dir(cwd)
slug = sub(r"[^a-zA-Z0-9]+", "_", title.strip().lower()).strip("_") or "memory"
with exclusive_file_lock(_memory_lock_path(cwd)):
memory_type = "project"
with exclusive_file_lock(memory_dir / ".memory.lock"):
category = "knowledge"
body = content.strip() + "\n"
signature = compute_memory_signature(body, memory_type, category)
@@ -46,6 +66,7 @@ def add_memory_entry(cwd: str | Path, title: str, content: str) -> Path:
max_files=None,
include_disabled=True,
include_expired=True,
memory_dir=memory_dir,
)
duplicate = next(
(header for header in existing if _effective_signature(header.path, header.signature) == signature),
@@ -75,8 +96,9 @@ def add_memory_entry(cwd: str | Path, title: str, content: str) -> Path:
"schema_version": SCHEMA_VERSION,
"id": memory_id,
"name": title.strip(),
"description": first_content_line(body) or title.strip(),
"description": description.strip() or first_content_line(body) or title.strip(),
"type": str(metadata.get("type") or memory_type),
"scope": str(metadata.get("scope") or scope),
"category": str(metadata.get("category") or category),
"importance": max(coerce_int(metadata.get("importance"), default=0), 1),
"source": "manual",
@@ -86,11 +108,12 @@ def add_memory_entry(cwd: str | Path, title: str, content: str) -> Path:
"ttl_days": metadata.get("ttl_days"),
"disabled": False,
"supersedes": metadata.get("supersedes") or [],
"tags": list(dict.fromkeys(str(tag).strip() for tag in tags if str(tag).strip())),
}
)
atomic_write_text(path, render_memory_file(metadata, body))
entrypoint = get_memory_entrypoint(cwd)
entrypoint = memory_dir / "MEMORY.md"
index_text = entrypoint.read_text(encoding="utf-8") if entrypoint.exists() else "# Memory Index\n"
if path.name not in index_text:
index_text = index_text.rstrip() + f"\n- [{title}]({path.name})\n"
+22 -4
View File
@@ -5,9 +5,19 @@ from __future__ import annotations
from pathlib import Path
from openharness.memory.paths import get_memory_entrypoint, get_project_memory_dir
from openharness.memory.schema import (
MAX_ENTRYPOINT_BYTES,
MEMORY_POLICY_LINES,
truncate_entrypoint_content,
)
def load_memory_prompt(cwd: str | Path, *, max_entrypoint_lines: int = 200) -> str | None:
def load_memory_prompt(
cwd: str | Path,
*,
max_entrypoint_lines: int = 200,
max_entrypoint_bytes: int = MAX_ENTRYPOINT_BYTES,
) -> str | None:
"""Return the memory prompt section for the current project."""
memory_dir = get_project_memory_dir(cwd)
entrypoint = get_memory_entrypoint(cwd)
@@ -16,12 +26,20 @@ def load_memory_prompt(cwd: str | Path, *, max_entrypoint_lines: int = 200) -> s
f"- Persistent memory directory: {memory_dir}",
"- Use this directory to store durable project and repository context that should survive future sessions.",
"- Prefer concise topic files plus an index entry in MEMORY.md.",
"",
*MEMORY_POLICY_LINES,
]
if entrypoint.exists():
content_lines = entrypoint.read_text(encoding="utf-8").splitlines()[:max_entrypoint_lines]
if content_lines:
lines.extend(["", "## MEMORY.md", "```md", *content_lines, "```"])
raw = entrypoint.read_text(encoding="utf-8", errors="replace")
view = truncate_entrypoint_content(
raw,
max_lines=max_entrypoint_lines,
max_bytes=max_entrypoint_bytes,
)
content = view.content.strip()
if content:
lines.extend(["", "## MEMORY.md", "```md", content, "```"])
else:
lines.extend(
[
+145
View File
@@ -0,0 +1,145 @@
"""Relevant memory selection and formatting."""
from __future__ import annotations
import json
from dataclasses import dataclass
from pathlib import Path
from typing import Callable, Iterable
from openharness.memory.scan import scan_memory_files
from openharness.memory.schema import memory_age_label, memory_freshness_text
from openharness.memory.search import find_relevant_memories
from openharness.memory.types import MemoryHeader
@dataclass(frozen=True)
class RelevantMemory:
"""A memory selected for prompt injection."""
header: MemoryHeader
freshness: str = ""
MemorySelector = Callable[[str, list[MemoryHeader]], list[str]]
def build_memory_manifest(headers: Iterable[MemoryHeader]) -> str:
"""Render a compact manifest for selector prompts and diagnostics."""
lines: list[str] = []
for header in headers:
prefix = f"[{header.memory_type or 'memory'}]"
bits = [
prefix,
header.relative_path or header.path.name,
f"({memory_age_label(header.modified_at)})",
]
if header.description:
bits.append(f"- {header.description}")
lines.append(" ".join(bits))
return "\n".join(lines)
def select_relevant_memories(
query: str,
cwd: str | Path,
*,
max_results: int = 5,
already_surfaced: set[str] | None = None,
selector: MemorySelector | None = None,
) -> list[RelevantMemory]:
"""Return relevant memories with duplicate and freshness handling.
``selector`` is an optional side-query style reranker. It receives the query
and a heuristic shortlist, and returns relative paths in desired order.
"""
surfaced = already_surfaced or set()
heuristic = [
header
for header in find_relevant_memories(query, cwd, max_results=max(10, max_results * 3))
if (header.relative_path or str(header.path)) not in surfaced
]
selected = _apply_selector(query, heuristic, selector=selector, max_results=max_results)
result: list[RelevantMemory] = []
for header in selected[:max_results]:
result.append(RelevantMemory(header=header, freshness=memory_freshness_text(header.modified_at)))
return result
def select_manifest_memories(
query: str,
cwd: str | Path,
*,
max_results: int = 5,
selector: MemorySelector | None = None,
) -> list[RelevantMemory]:
"""Select from the full manifest instead of heuristic matches only."""
headers = scan_memory_files(cwd, max_files=200)
selected = _apply_selector(query, headers, selector=selector, max_results=max_results)
return [
RelevantMemory(header=header, freshness=memory_freshness_text(header.modified_at))
for header in selected[:max_results]
]
def format_relevant_memories(memories: Iterable[RelevantMemory], *, max_chars: int = 8000) -> str:
"""Render selected memories for prompt context."""
lines = ["# Relevant Memories"]
for item in memories:
header = item.header
content = header.path.read_text(encoding="utf-8", errors="replace").strip()
if item.freshness:
lines.extend(["", f"## {header.relative_path or header.path.name}", f"> {item.freshness}"])
else:
lines.extend(["", f"## {header.relative_path or header.path.name}"])
lines.extend(["```md", content[:max_chars], "```"])
return "\n".join(lines)
def json_selector_from_text(text: str) -> list[str]:
"""Parse a selector response as either JSON list or newline paths."""
stripped = text.strip()
if not stripped:
return []
try:
payload = json.loads(stripped)
except json.JSONDecodeError:
return [line.strip("- ").strip() for line in stripped.splitlines() if line.strip()]
if isinstance(payload, list):
return [str(item).strip() for item in payload if str(item).strip()]
if isinstance(payload, dict) and isinstance(payload.get("paths"), list):
return [str(item).strip() for item in payload["paths"] if str(item).strip()]
return []
def _apply_selector(
query: str,
headers: list[MemoryHeader],
*,
selector: MemorySelector | None,
max_results: int,
) -> list[MemoryHeader]:
if not headers or selector is None:
return headers[:max_results]
requested = selector(query, headers)
by_path = {header.relative_path or header.path.name: header for header in headers}
selected: list[MemoryHeader] = []
seen: set[str] = set()
for path in requested:
header = by_path.get(path)
if header is None or path in seen:
continue
selected.append(header)
seen.add(path)
if len(selected) < max_results:
selected.extend(
header
for header in headers
if (header.relative_path or header.path.name) not in seen
)
return selected[:max_results]
+3
View File
@@ -102,6 +102,9 @@ def _parse_memory_file(path: Path, content: str) -> MemoryHeader:
ttl_days=coerce_optional_int(metadata.get("ttl_days")),
disabled=coerce_bool(metadata.get("disabled"), default=False),
supersedes=coerce_str_list(metadata.get("supersedes")),
relative_path=path.name,
tags=coerce_str_list(metadata.get("tags")),
frontmatter=metadata,
)
+141 -1
View File
@@ -7,20 +7,35 @@ import json
import re
import secrets
import string
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any
from typing import Any, Literal
import yaml
SCHEMA_VERSION = 1
MemoryType = Literal["user", "feedback", "project", "reference"]
MemoryScope = Literal["private", "project", "team"]
MEMORY_TYPES: tuple[MemoryType, ...] = ("user", "feedback", "project", "reference")
MEMORY_SCOPES: tuple[MemoryScope, ...] = ("private", "project", "team")
DEFAULT_MEMORY_TYPE: MemoryType = "project"
DEFAULT_MEMORY_SCOPE: MemoryScope = "project"
MAX_ENTRYPOINT_LINES = 200
MAX_ENTRYPOINT_BYTES = 25_000
MAX_MANIFEST_FILES = 200
FRONTMATTER_FIELDS = (
"schema_version",
"id",
"name",
"description",
"type",
"scope",
"category",
"importance",
"source",
@@ -30,9 +45,19 @@ FRONTMATTER_FIELDS = (
"ttl_days",
"disabled",
"supersedes",
"tags",
)
@dataclass(frozen=True)
class EntrypointView:
"""A bounded view of ``MEMORY.md`` plus truncation diagnostics."""
content: str
was_truncated: bool
reason: str = ""
def utc_now() -> datetime:
"""Return the current UTC time without sub-second noise."""
@@ -83,6 +108,121 @@ def compute_memory_signature(content: str, memory_type: str, category: str) -> s
return hashlib.sha256(payload.encode("utf-8")).hexdigest()
def parse_memory_type(raw: Any, *, default: MemoryType | None = None) -> MemoryType | None:
"""Parse a frontmatter ``type`` value into the canonical runtime taxonomy."""
if isinstance(raw, str):
value = raw.strip().lower()
if value in MEMORY_TYPES:
return value # type: ignore[return-value]
if value in {"note", "memory", "core", "knowledge"}:
return default
return default
def parse_memory_scope(raw: Any, *, default: MemoryScope | None = None) -> MemoryScope | None:
"""Parse a frontmatter ``scope`` value into the canonical scope taxonomy."""
if isinstance(raw, str):
value = raw.strip().lower()
if value in MEMORY_SCOPES:
return value # type: ignore[return-value]
if value in {"personal", "user"}:
return "private"
if value in {"shared"}:
return "team"
return default
def truncate_entrypoint_content(
raw: str,
*,
max_lines: int = MAX_ENTRYPOINT_LINES,
max_bytes: int = MAX_ENTRYPOINT_BYTES,
) -> EntrypointView:
"""Bound ``MEMORY.md`` by line count and UTF-8 byte count."""
lines = raw.splitlines()
was_line_truncated = len(lines) > max_lines
text = "\n".join(lines[:max_lines])
encoded = text.encode("utf-8")
was_byte_truncated = len(encoded) > max_bytes
if was_byte_truncated:
encoded = encoded[:max_bytes]
text = encoded.decode("utf-8", errors="ignore")
cut_at = text.rfind("\n")
if cut_at > 0:
text = text[:cut_at]
if raw.endswith("\n") and not text.endswith("\n"):
text += "\n"
if not was_line_truncated and not was_byte_truncated:
return EntrypointView(content=text, was_truncated=False)
reason = (
f"{len(raw.encode('utf-8'))} bytes (limit: {max_bytes})"
if was_byte_truncated
else f"{len(lines)} lines (limit: {max_lines})"
)
warning = (
f"\n\n> WARNING: MEMORY.md is {reason}. Only part of it was loaded. "
"Keep index entries one line and move detail into topic notes.\n"
)
return EntrypointView(content=text.rstrip() + warning, was_truncated=True, reason=reason)
def memory_age_days(mtime: float, *, now: float | None = None) -> int:
"""Return floor-rounded days elapsed since a file modification time."""
import time
current = time.time() if now is None else now
return max(0, int((current - mtime) // 86_400))
def memory_age_label(mtime: float, *, now: float | None = None) -> str:
"""Return a model-friendly age label."""
days = memory_age_days(mtime, now=now)
if days == 0:
return "today"
if days == 1:
return "yesterday"
return f"{days} days ago"
def memory_freshness_text(mtime: float, *, now: float | None = None) -> str:
"""Return a staleness warning for older memories."""
days = memory_age_days(mtime, now=now)
if days <= 1:
return ""
return (
f"This memory is {days} days old. Memories are point-in-time observations; "
"verify claims against the current project state before treating them as facts."
)
def path_is_relative_to(path: str | Path, root: str | Path) -> bool:
"""Compatibility helper for containment checks."""
try:
Path(path).resolve().relative_to(Path(root).resolve())
except ValueError:
return False
return True
MEMORY_POLICY_LINES: tuple[str, ...] = (
"## Durable memory policy",
"- Store durable memory only when the information is not cheaply derivable from current files, docs, git history, or tool output.",
"- Use `type: user|feedback|project|reference` and optional `scope: private|project|team` frontmatter.",
"- `MEMORY.md` is an index, not a memory body. Keep each pointer one line.",
"- Update or remove stale contradictions instead of duplicating notes.",
"- If the user says to ignore memory, proceed as if no memory was loaded and do not cite, apply, or mention memory contents.",
"- Memory can be stale. Verify remembered project/code state against current files before acting on it.",
"- Do not save secrets, credentials, private personal context in team memory, or temporary task chatter.",
)
def generate_memory_id(now: datetime | None = None) -> str:
"""Generate a stable-looking memory id for a new memory file."""
+90
View File
@@ -0,0 +1,90 @@
"""Local team-memory vault helpers and safety guards."""
from __future__ import annotations
import re
from dataclasses import dataclass
from pathlib import Path
from openharness.memory.paths import get_project_memory_dir
from openharness.memory.schema import path_is_relative_to
TEAM_DIR_NAME = "team"
MEMORY_INDEX = "MEMORY.md"
@dataclass(frozen=True)
class SecretMatch:
"""A possible secret found in shared memory content."""
rule_id: str
label: str
SECRET_RULES: tuple[tuple[str, str, re.Pattern[str]], ...] = (
("private-key", "private key", re.compile(r"-----BEGIN [A-Z ]*PRIVATE KEY-----")),
("aws-access-key", "AWS access key", re.compile(r"\bAKIA[0-9A-Z]{16}\b")),
("github-token", "GitHub token", re.compile(r"\bgh[pousr]_[A-Za-z0-9_]{20,}\b")),
("openai-key", "OpenAI API key", re.compile(r"\bsk-[A-Za-z0-9_-]{20,}\b")),
("anthropic-key", "Anthropic API key", re.compile(r"\bsk-ant-[A-Za-z0-9_-]{20,}\b")),
("generic-secret", "secret assignment", re.compile(r"(?i)\b(secret|token|api[_-]?key|password)\s*[:=]\s*['\"]?[^'\"\s]{12,}")),
)
def get_team_memory_dir(cwd: str | Path) -> Path:
"""Return the project-local shared team memory vault."""
return get_project_memory_dir(cwd) / TEAM_DIR_NAME
def ensure_team_memory_vault(cwd: str | Path) -> Path:
"""Create and return the team memory vault."""
team_dir = get_team_memory_dir(cwd)
team_dir.mkdir(parents=True, exist_ok=True)
entrypoint = team_dir / MEMORY_INDEX
if not entrypoint.exists():
entrypoint.write_text("# Memory Index\n", encoding="utf-8")
return team_dir
def validate_team_memory_write_path(cwd: str | Path, candidate: str | Path) -> tuple[Path | None, str | None]:
"""Validate a write target against traversal and symlink escape."""
team_dir = ensure_team_memory_vault(cwd).resolve()
path = Path(candidate).expanduser()
if not path.is_absolute():
path = team_dir / path
resolved = path.resolve()
if not path_is_relative_to(resolved, team_dir):
return None, f"Path escapes team memory directory: {candidate}"
parent = resolved.parent
deepest = parent
while not deepest.exists() and deepest != deepest.parent:
deepest = deepest.parent
if deepest.exists() and not path_is_relative_to(deepest.resolve(), team_dir):
return None, f"Path escapes team memory directory via symlink: {candidate}"
return resolved, None
def scan_for_secrets(content: str) -> list[SecretMatch]:
"""Return possible secrets in content without exposing matched values."""
matches: list[SecretMatch] = []
for rule_id, label, pattern in SECRET_RULES:
if pattern.search(content):
matches.append(SecretMatch(rule_id=rule_id, label=label))
return matches
def check_team_memory_secrets(content: str) -> str | None:
"""Return an error message when shared memory content appears sensitive."""
matches = scan_for_secrets(content)
if not matches:
return None
labels = ", ".join(match.label for match in matches)
return (
f"Content contains potential secrets ({labels}) and cannot be written to team memory. "
"Team memory is shared with project collaborators."
)
+5 -1
View File
@@ -2,8 +2,9 @@
from __future__ import annotations
from dataclasses import dataclass
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
@dataclass(frozen=True)
@@ -27,3 +28,6 @@ class MemoryHeader:
ttl_days: int | None = None
disabled: bool = False
supersedes: tuple[str, ...] = ()
relative_path: str = ""
tags: tuple[str, ...] = field(default_factory=tuple)
frontmatter: dict[str, Any] = field(default_factory=dict)
+7 -16
View File
@@ -12,7 +12,8 @@ from openharness.config.paths import (
)
from openharness.config.settings import Settings
from openharness.coordinator.coordinator_mode import get_coordinator_system_prompt, is_coordinator_mode
from openharness.memory import find_relevant_memories, load_memory_prompt
from openharness.memory import load_memory_prompt
from openharness.memory.relevance import format_relevant_memories, select_relevant_memories
from openharness.memory.usage import mark_memory_used
from openharness.personalization.rules import load_local_rules
from openharness.prompts.claudemd import load_claude_md_prompt
@@ -139,33 +140,23 @@ def build_runtime_system_prompt(
memory_section = load_memory_prompt(
cwd,
max_entrypoint_lines=settings.memory.max_entrypoint_lines,
max_entrypoint_bytes=settings.memory.max_entrypoint_bytes,
)
if memory_section:
sections.append(memory_section)
if latest_user_prompt:
relevant = find_relevant_memories(
relevant = select_relevant_memories(
latest_user_prompt,
cwd,
max_results=settings.memory.max_files,
)
if relevant:
try:
mark_memory_used(cwd, relevant, memory_dir=relevant[0].path.parent)
headers = [item.header for item in relevant]
mark_memory_used(cwd, headers, memory_dir=headers[0].path.parent)
except OSError:
pass
lines = ["# Relevant Memories"]
for header in relevant:
content = header.path.read_text(encoding="utf-8", errors="replace").strip()
lines.extend(
[
"",
f"## {header.path.name}",
"```md",
content[:8000],
"```",
]
)
sections.append("\n".join(lines))
sections.append(format_relevant_memories(relevant))
return "\n\n".join(section for section in sections if section.strip())
+25 -1
View File
@@ -890,6 +890,28 @@ def _build_session_memory_message(messages: list[ConversationMessage]) -> Conver
)
def _build_file_session_memory_message(metadata: dict[str, Any] | None) -> ConversationMessage | None:
"""Build a compaction message from the persisted session-memory file."""
if not metadata:
return None
path = metadata.get("session_memory_path")
if not path:
return None
try:
from openharness.services.session_memory import (
get_session_memory_content,
session_memory_to_compact_text,
)
text = session_memory_to_compact_text(get_session_memory_content(str(path)))
except Exception:
return None
if not text.strip():
return None
return ConversationMessage.from_user_text(text)
def try_session_memory_compaction(
messages: list[ConversationMessage],
*,
@@ -901,7 +923,8 @@ def try_session_memory_compaction(
if len(messages) <= preserve_recent + 4:
return None
older, newer = _split_preserving_tool_pairs(messages, preserve_recent=preserve_recent)
summary_message = _build_session_memory_message(older)
file_summary_message = _build_file_session_memory_message(metadata)
summary_message = file_summary_message or _build_session_memory_message(older)
if summary_message is None:
return None
provisional = [summary_message, *newer]
@@ -917,6 +940,7 @@ def try_session_memory_compaction(
"pre_compact_token_count": estimate_message_tokens(messages),
"preserve_recent": preserve_recent,
"used_session_memory": True,
"used_file_session_memory": file_summary_message is not None,
"pre_compact_discovered_tools": _extract_discovered_tools(older),
"attachments": _extract_attachment_paths(older),
}
@@ -0,0 +1,261 @@
"""Durable memory extraction from completed turns."""
from __future__ import annotations
import json
import logging
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from openharness.api.client import ApiMessageCompleteEvent, ApiMessageRequest, SupportsStreamingMessages
from openharness.engine.messages import ConversationMessage, ToolUseBlock
from openharness.memory.manager import add_memory_entry
from openharness.memory.paths import get_project_memory_dir
from openharness.memory.relevance import build_memory_manifest
from openharness.memory.scan import scan_memory_files
from openharness.memory.schema import (
DEFAULT_MEMORY_SCOPE,
DEFAULT_MEMORY_TYPE,
MemoryScope,
MemoryType,
parse_memory_scope,
parse_memory_type,
)
from openharness.memory.team import check_team_memory_secrets, validate_team_memory_write_path
log = logging.getLogger(__name__)
MEMORY_WRITE_TOOLS = {"write_file", "edit_file"}
@dataclass(frozen=True)
class ExtractionRecord:
"""Structured memory record proposed by the extraction pass."""
title: str
body: str
memory_type: MemoryType = DEFAULT_MEMORY_TYPE
scope: MemoryScope = DEFAULT_MEMORY_SCOPE
description: str = ""
tags: tuple[str, ...] = ()
@dataclass(frozen=True)
class ExtractionResult:
"""Outcome of a durable memory extraction run."""
skipped: bool
reason: str = ""
records: tuple[ExtractionRecord, ...] = ()
written_paths: tuple[Path, ...] = ()
def has_memory_writes_since(
messages: list[ConversationMessage],
memory_dir: str | Path,
*,
cwd: str | Path | None = None,
) -> bool:
"""Return whether the visible turn already wrote memory files."""
root = Path(memory_dir).expanduser().resolve()
write_base = Path(cwd).expanduser().resolve() if cwd is not None else root
for message in messages:
for block in message.content:
if not isinstance(block, ToolUseBlock):
continue
if block.name not in MEMORY_WRITE_TOOLS:
continue
raw_path = block.input.get("path") or block.input.get("file_path")
if not raw_path:
continue
path = Path(str(raw_path)).expanduser()
if not path.is_absolute():
path = write_base / path
try:
path.resolve().relative_to(root)
except ValueError:
continue
return True
return False
async def extract_memories_from_turn(
*,
cwd: str | Path,
api_client: SupportsStreamingMessages,
model: str,
messages: list[ConversationMessage],
max_records: int = 3,
) -> ExtractionResult:
"""Ask the model for durable memory candidates and apply them."""
memory_dir = get_project_memory_dir(cwd)
if len(messages) < 2:
return ExtractionResult(skipped=True, reason="not enough messages")
if has_memory_writes_since(messages, memory_dir, cwd=cwd):
return ExtractionResult(skipped=True, reason="main conversation already wrote memory")
prompt = build_extraction_prompt(cwd, messages, max_records=max_records)
final_text = ""
async for event in api_client.stream_message(
ApiMessageRequest(
model=model,
messages=[ConversationMessage.from_user_text(prompt)],
system_prompt=EXTRACTION_SYSTEM_PROMPT,
max_tokens=2048,
tools=[],
)
):
if isinstance(event, ApiMessageCompleteEvent):
final_text = event.message.text
break
records = parse_extraction_records(final_text, max_records=max_records)
if not records:
return ExtractionResult(skipped=True, reason="no durable memories proposed")
return apply_extraction_records(cwd, records)
def build_extraction_prompt(cwd: str | Path, messages: list[ConversationMessage], *, max_records: int) -> str:
"""Build the extraction request from recent messages and manifest."""
manifest = build_memory_manifest(scan_memory_files(cwd, max_files=80))
transcript = "\n".join(_summarize_message(message) for message in messages[-12:])
return (
"Extract only durable memories from the recent conversation.\n"
f"Return JSON with at most {max_records} records. Existing memory manifest:\n"
f"{manifest or '(empty)'}\n\n"
"Recent conversation:\n"
f"{transcript}\n\n"
"JSON schema: {\"memories\":[{\"title\":\"...\",\"type\":\"user|feedback|project|reference\","
"\"scope\":\"private|project|team\",\"description\":\"...\",\"body\":\"...\",\"tags\":[\"...\"]}]}"
)
EXTRACTION_SYSTEM_PROMPT = """You maintain OpenHarness durable memory.
Save only stable, future-useful facts that are not derivable from current files,
git history, or documentation. Prefer updating existing memories conceptually
over duplicating them. Do not save secrets. If nothing is worth saving, return
{"memories": []}.
"""
def parse_extraction_records(text: str, *, max_records: int = 3) -> tuple[ExtractionRecord, ...]:
"""Parse JSON memory extraction output."""
try:
payload = json.loads(_extract_json_object(text))
except json.JSONDecodeError:
return ()
raw_records = payload.get("memories") if isinstance(payload, dict) else None
if not isinstance(raw_records, list):
return ()
records: list[ExtractionRecord] = []
for item in raw_records[:max_records]:
if not isinstance(item, dict):
continue
title = str(item.get("title") or "").strip()
body = str(item.get("body") or "").strip()
if not title or not body:
continue
memory_type = parse_memory_type(item.get("type"), default=DEFAULT_MEMORY_TYPE) or DEFAULT_MEMORY_TYPE
scope = parse_memory_scope(item.get("scope"), default=DEFAULT_MEMORY_SCOPE) or DEFAULT_MEMORY_SCOPE
tags_raw = item.get("tags") or ()
tags = tuple(str(tag).strip() for tag in tags_raw if str(tag).strip()) if isinstance(tags_raw, list) else ()
records.append(
ExtractionRecord(
title=title,
body=body,
memory_type=memory_type,
scope=scope,
description=str(item.get("description") or "").strip(),
tags=tags,
)
)
return tuple(records)
def apply_extraction_records(cwd: str | Path, records: tuple[ExtractionRecord, ...]) -> ExtractionResult:
"""Write accepted records to durable memory."""
written: list[Path] = []
for record in records:
if record.scope == "team":
secret_error = check_team_memory_secrets(record.body)
if secret_error:
log.warning("memory extraction skipped team record %r: %s", record.title, secret_error)
continue
path, error = validate_team_memory_write_path(cwd, f"{record.title}.md")
if error or path is None:
log.warning("memory extraction skipped team record %r: %s", record.title, error)
continue
written.append(
add_memory_entry(
cwd,
record.title,
record.body,
memory_type=record.memory_type,
scope=record.scope,
description=record.description,
tags=record.tags,
)
)
return ExtractionResult(skipped=not bool(written), reason="" if written else "all records rejected", records=records, written_paths=tuple(written))
def validate_extraction_tool_request(tool_name: str, tool_input: dict[str, Any], memory_dir: str | Path) -> tuple[bool, str]:
"""Permission guard for extraction-like agents."""
if tool_name in {"read_file", "grep", "glob"}:
return True, ""
if tool_name == "bash":
command = str(tool_input.get("command") or "")
if _is_read_only_shell(command):
return True, ""
return False, "memory extraction may only run read-only shell commands"
if tool_name in {"write_file", "edit_file"}:
raw_path = str(tool_input.get("path") or tool_input.get("file_path") or "")
if not raw_path:
return False, "memory extraction write requires a path"
root = Path(memory_dir).expanduser().resolve()
path = Path(raw_path).expanduser()
if not path.is_absolute():
path = root / path
try:
path.resolve().relative_to(root)
except ValueError:
return False, f"memory extraction writes must stay within {root}"
return True, ""
return False, f"memory extraction cannot use tool {tool_name}"
def _extract_json_object(text: str) -> str:
stripped = text.strip()
if stripped.startswith("{") and stripped.endswith("}"):
return stripped
start = stripped.find("{")
end = stripped.rfind("}")
if start >= 0 and end > start:
return stripped[start : end + 1]
return stripped
def _summarize_message(message: ConversationMessage) -> str:
text = " ".join(message.text.split())
if text:
return f"{message.role}: {text[:1200]}"
if message.tool_uses:
return f"{message.role}: tool calls -> {', '.join(block.name for block in message.tool_uses)}"
return f"{message.role}: [non-text content]"
def _is_read_only_shell(command: str) -> bool:
lowered = command.strip().lower()
if not lowered:
return False
denied = (" > ", ">>", " rm ", " mv ", " cp ", " sed -i", " tee ", "python -c", "python3 -c")
if any(marker in f" {lowered} " for marker in denied):
return False
first = lowered.split(maxsplit=1)[0]
return first in {"ls", "pwd", "cat", "head", "tail", "rg", "grep", "find", "git", "wc", "sed", "awk", "stat"}
@@ -0,0 +1,139 @@
"""File-backed session memory for compact continuity."""
from __future__ import annotations
from hashlib import sha1
from pathlib import Path
from openharness.config.paths import get_data_dir
from openharness.engine.messages import ConversationMessage, ToolResultBlock
from openharness.services.token_estimation import estimate_tokens
from openharness.utils.fs import atomic_write_text
MAX_SESSION_MEMORY_CHARS = 12_000
MAX_RECENT_LINES = 80
def get_session_memory_dir(cwd: str | Path) -> Path:
"""Return the project session-memory directory."""
root = Path(cwd).resolve()
digest = sha1(str(root).encode("utf-8")).hexdigest()[:12]
path = get_data_dir() / "session-memory" / f"{root.name}-{digest}"
path.mkdir(parents=True, exist_ok=True)
return path
def get_session_memory_path(cwd: str | Path, session_id: str | None = None) -> Path:
"""Return the markdown session-memory path."""
safe_session = "".join(ch if ch.isalnum() or ch in "._-" else "_" for ch in (session_id or "default"))
return get_session_memory_dir(cwd) / f"{safe_session or 'default'}.md"
def prepare_session_memory_metadata(
cwd: str | Path,
tool_metadata: dict[str, object],
*,
session_id: str | None = None,
) -> Path:
"""Ensure metadata points compaction to the session-memory file."""
sid = session_id or str(tool_metadata.get("session_id") or "default")
path = get_session_memory_path(cwd, sid)
tool_metadata["session_memory_path"] = str(path)
return path
def get_session_memory_content(path: str | Path | None) -> str:
"""Read session memory content if available."""
if not path:
return ""
candidate = Path(path).expanduser()
if not candidate.exists():
return ""
try:
return candidate.read_text(encoding="utf-8", errors="replace")
except OSError:
return ""
def update_session_memory_file(
cwd: str | Path,
messages: list[ConversationMessage],
*,
tool_metadata: dict[str, object] | None = None,
session_id: str | None = None,
) -> Path:
"""Update the deterministic session-memory checkpoint."""
path = prepare_session_memory_metadata(cwd, tool_metadata or {}, session_id=session_id)
body = build_session_memory_document(messages, tool_metadata=tool_metadata)
atomic_write_text(path, body)
return path
def build_session_memory_document(
messages: list[ConversationMessage],
*,
tool_metadata: dict[str, object] | None = None,
) -> str:
"""Build a compact markdown checkpoint for the current session."""
state = tool_metadata.get("task_focus_state") if isinstance(tool_metadata, dict) else None
goal = ""
next_step = ""
verified: list[str] = []
artifacts: list[str] = []
if isinstance(state, dict):
goal = str(state.get("goal") or "").strip()
next_step = str(state.get("next_step") or "").strip()
verified = [str(item).strip() for item in state.get("verified_state", []) if str(item).strip()]
artifacts = [str(item).strip() for item in state.get("active_artifacts", []) if str(item).strip()]
lines = ["# Session Memory", ""]
lines.extend(["## Current State", goal or "(no current goal recorded)", ""])
if next_step:
lines.extend(["## Next Step", next_step, ""])
if verified:
lines.extend(["## Verified Work", *[f"- {item}" for item in verified[-10:]], ""])
if artifacts:
lines.extend(["## Active Artifacts", *[f"- {item}" for item in artifacts[-10:]], ""])
lines.extend(["## Recent Conversation", *_recent_message_lines(messages), ""])
text = "\n".join(lines).strip() + "\n"
if len(text) > MAX_SESSION_MEMORY_CHARS:
text = text[:MAX_SESSION_MEMORY_CHARS].rsplit("\n", 1)[0]
text += "\n\n> Session memory was truncated to stay within budget.\n"
return text
def session_memory_to_compact_text(content: str) -> str:
"""Prepare persisted session memory for insertion across compact boundaries."""
stripped = content.strip()
if not stripped:
return ""
if estimate_tokens(stripped) > 4_000:
stripped = stripped[:MAX_SESSION_MEMORY_CHARS].rsplit("\n", 1)[0]
return "Session memory checkpoint from earlier in this conversation:\n" + stripped
def _recent_message_lines(messages: list[ConversationMessage]) -> list[str]:
lines: list[str] = []
for message in messages[-MAX_RECENT_LINES:]:
line = _summarize_message(message)
if line:
lines.append(f"- {line}")
return lines or ["- (no recent messages)"]
def _summarize_message(message: ConversationMessage) -> str:
text = " ".join(message.text.split())
if text:
return f"{message.role}: {text[:220]}"
if message.tool_uses:
return f"{message.role}: tool calls -> {', '.join(block.name for block in message.tool_uses[:6])}"
if any(isinstance(block, ToolResultBlock) for block in message.content):
return f"{message.role}: tool results returned"
return f"{message.role}: [non-text content]"
@@ -0,0 +1,223 @@
"""Tests for Claude Code-inspired memory runtime behavior."""
from __future__ import annotations
import os
import time
from pathlib import Path
import pytest
from openharness.api.client import ApiMessageCompleteEvent
from openharness.api.usage import UsageSnapshot
from openharness.commands.registry import CommandContext, create_default_command_registry
from openharness.config.settings import Settings
from openharness.engine.messages import ConversationMessage, ToolUseBlock
from openharness.engine.query_engine import QueryEngine
from openharness.memory import add_memory_entry, get_project_memory_dir
from openharness.memory.agent import (
ensure_agent_memory_vault,
get_agent_memory_entrypoint,
initialize_agent_memory_from_snapshot,
)
from openharness.memory.relevance import format_relevant_memories, select_relevant_memories
from openharness.memory.schema import (
memory_freshness_text,
parse_memory_scope,
parse_memory_type,
truncate_entrypoint_content,
)
from openharness.memory.team import (
check_team_memory_secrets,
ensure_team_memory_vault,
validate_team_memory_write_path,
)
from openharness.permissions import PermissionChecker
from openharness.services.memory_extract import (
extract_memories_from_turn,
has_memory_writes_since,
parse_extraction_records,
validate_extraction_tool_request,
)
from openharness.services.session_memory import (
get_session_memory_content,
get_session_memory_path,
update_session_memory_file,
)
from openharness.tools import create_default_tool_registry
class _FakeApiClient:
def __init__(self, text: str = "done") -> None:
self.text = text
self.requests = []
async def stream_message(self, request):
self.requests.append(request)
yield ApiMessageCompleteEvent(
message=ConversationMessage.from_user_text(self.text).model_copy(update={"role": "assistant"}),
usage=UsageSnapshot(input_tokens=1, output_tokens=1),
stop_reason="end_turn",
)
def test_schema_truncation_and_freshness() -> None:
raw = "\n".join(f"- item {idx}" for idx in range(240))
view = truncate_entrypoint_content(raw, max_lines=10, max_bytes=1_000)
assert view.was_truncated is True
assert "WARNING" in view.content
assert parse_memory_type("note", default="project") == "project"
assert parse_memory_scope("personal") == "private"
assert "2 days old" in memory_freshness_text(time.time() - 2 * 86_400)
def test_relevance_formats_staleness(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("OPENHARNESS_DATA_DIR", str(tmp_path / "data"))
project = tmp_path / "repo"
project.mkdir()
path = add_memory_entry(project, "Redis Rule", "Redis cache decisions matter.", memory_type="project")
old = time.time() - 3 * 86_400
os.utime(path, (old, old))
selected = select_relevant_memories("redis cache", project)
rendered = format_relevant_memories(selected)
assert selected
assert "3 days old" in rendered
assert "Redis cache decisions" in rendered
@pytest.mark.asyncio
async def test_memory_extraction_writes_typed_note(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("OPENHARNESS_DATA_DIR", str(tmp_path / "data"))
project = tmp_path / "repo"
project.mkdir()
api = _FakeApiClient(
'{"memories":[{"title":"Testing Policy","type":"feedback","scope":"project",'
'"description":"real database tests","body":"Use real database tests for migrations.",'
'"tags":["testing"]}]}'
)
messages = [
ConversationMessage.from_user_text("do not mock database migrations"),
ConversationMessage.from_user_text("noted").model_copy(update={"role": "assistant"}),
]
result = await extract_memories_from_turn(cwd=project, api_client=api, model="claude-test", messages=messages)
assert result.skipped is False
assert len(result.written_paths) == 1
text = result.written_paths[0].read_text(encoding="utf-8")
assert 'type: "feedback"' in text
assert 'scope: "project"' in text
assert "Use real database tests" in text
def test_memory_extraction_parser_and_tool_guard(tmp_path: Path) -> None:
records = parse_extraction_records(
'{"memories":[{"title":"User Role","type":"user","scope":"private","body":"User knows Go."}]}'
)
assert records[0].memory_type == "user"
assert records[0].scope == "private"
ok, _ = validate_extraction_tool_request("bash", {"command": "rg memory src"}, tmp_path)
denied, reason = validate_extraction_tool_request("write_file", {"path": "../x", "content": "x"}, tmp_path)
assert ok is True
assert denied is False
assert "within" in reason
def test_memory_write_detection_resolves_relative_paths_from_cwd(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setenv("OPENHARNESS_DATA_DIR", str(tmp_path / "data"))
project = tmp_path / "repo"
project.mkdir()
memory_dir = get_project_memory_dir(project)
normal_relative_write = ConversationMessage(
role="assistant",
content=[ToolUseBlock(name="write_file", input={"path": "REPORT.md", "content": "ok"})],
)
memory_absolute_write = ConversationMessage(
role="assistant",
content=[ToolUseBlock(name="write_file", input={"path": str(memory_dir / "report.md")})],
)
assert has_memory_writes_since([normal_relative_write], memory_dir, cwd=project) is False
assert has_memory_writes_since([memory_absolute_write], memory_dir, cwd=project) is True
def test_session_memory_file_round_trip(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("OPENHARNESS_DATA_DIR", str(tmp_path / "data"))
project = tmp_path / "repo"
project.mkdir()
messages = [ConversationMessage.from_user_text("current task: finish memory runtime")]
path = update_session_memory_file(project, messages, session_id="abc")
assert path == get_session_memory_path(project, "abc")
assert "finish memory runtime" in get_session_memory_content(path)
def test_team_memory_guards_and_agent_memory(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("OPENHARNESS_DATA_DIR", str(tmp_path / "data"))
project = tmp_path / "repo"
project.mkdir()
team_dir = ensure_team_memory_vault(project)
valid_path, error = validate_team_memory_write_path(project, "notes/shared.md")
escaped_path, escaped_error = validate_team_memory_write_path(project, "../outside.md")
agent_dir = ensure_agent_memory_vault(project, "reviewer/bot", "project")
assert team_dir.exists()
assert valid_path is not None and error is None
assert escaped_path is None and escaped_error
assert check_team_memory_secrets("OPENAI_API_KEY=sk-12345678901234567890")
assert agent_dir.exists()
assert get_agent_memory_entrypoint(project, "reviewer/bot", "project").exists()
def test_agent_memory_snapshot_initializes(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("OPENHARNESS_DATA_DIR", str(tmp_path / "data"))
project = tmp_path / "repo"
project.mkdir()
snapshot = project / ".openharness" / "agent-memory-snapshots" / "reviewer"
snapshot.mkdir(parents=True)
(snapshot / "MEMORY.md").write_text("# Snapshot\n", encoding="utf-8")
target = initialize_agent_memory_from_snapshot(project, "reviewer", "project")
assert target is not None
assert (target / "MEMORY.md").read_text(encoding="utf-8") == "# Snapshot\n"
@pytest.mark.asyncio
async def test_memory_commands_expose_session_team_and_agent(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("OPENHARNESS_DATA_DIR", str(tmp_path / "data"))
project = tmp_path / "repo"
project.mkdir()
engine = QueryEngine(
api_client=_FakeApiClient(),
tool_registry=create_default_tool_registry(),
permission_checker=PermissionChecker(Settings().permission),
cwd=project,
model="claude-test",
system_prompt="system",
)
context = CommandContext(engine=engine, cwd=str(project), session_id="s1")
registry = create_default_command_registry()
for raw in ("/memory session", "/memory team", "/memory agent status reviewer project"):
command, args = registry.lookup(raw)
assert command is not None
result = await command.handler(args, context)
assert result.message
command, args = registry.lookup("/memory add --type feedback --scope private Style :: Keep answers concise.")
assert command is not None
result = await command.handler(args, context)
assert "Added memory entry" in (result.message or "")
assert 'type: "feedback"' in (get_project_memory_dir(project) / "style.md").read_text(encoding="utf-8")