Skip to content

Commit 09bc641

Browse files
authored
feat(memory): add Claude-style memory runtime (HKUDS#267)
* Add Claude-style memory runtime * fix(memory): trim relevance EOF whitespace
1 parent 6243793 commit 09bc641

17 files changed

Lines changed: 1475 additions & 35 deletions

File tree

src/openharness/commands/registry.py

Lines changed: 224 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
import importlib.metadata
66
import json
7+
import os
78
import re
89
import shutil
910
import subprocess
@@ -38,8 +39,28 @@
3839
list_memory_files,
3940
migrate_memory,
4041
remove_memory_entry,
42+
scan_memory_files,
43+
)
44+
from openharness.memory.agent import (
45+
ensure_agent_memory_vault,
46+
get_agent_memory_entrypoint,
47+
initialize_agent_memory_from_snapshot,
48+
)
49+
from openharness.memory.schema import (
50+
DEFAULT_MEMORY_SCOPE,
51+
DEFAULT_MEMORY_TYPE,
52+
MEMORY_TYPES,
53+
is_disabled_metadata,
54+
is_memory_expired,
55+
parse_memory_scope,
56+
parse_memory_type,
57+
split_memory_file,
58+
)
59+
from openharness.memory.team import (
60+
check_team_memory_secrets,
61+
ensure_team_memory_vault,
62+
get_team_memory_dir,
4163
)
42-
from openharness.memory.schema import is_disabled_metadata, is_memory_expired, split_memory_file
4364
from openharness.output_styles import load_output_styles
4465
from openharness.permissions import PermissionChecker, PermissionMode
4566
from openharness.plugins import load_plugins
@@ -60,6 +81,12 @@
6081
restore_memory_backup,
6182
start_dream_now,
6283
)
84+
from openharness.services.memory_extract import extract_memories_from_turn
85+
from openharness.services.session_memory import (
86+
get_session_memory_content,
87+
get_session_memory_path,
88+
update_session_memory_file,
89+
)
6390
from openharness.services.session_backend import DEFAULT_SESSION_BACKEND, SessionBackend
6491
from openharness.skills import load_skill_registry
6592
from openharness.skills.types import SkillDefinition
@@ -654,7 +681,10 @@ async def _memory_handler(args: str, context: CommandContext) -> CommandResult:
654681
message=(
655682
f"Memory store: {backend.label}\n"
656683
f"Memory directory: {backend.get_memory_dir()}\n"
657-
f"Entrypoint: {backend.get_entrypoint()}"
684+
f"Entrypoint: {backend.get_entrypoint()}\n"
685+
"Commands: list, show NAME, add TITLE :: CONTENT, remove NAME, "
686+
"edit [NAME], validate, extract, session, team, agent, "
687+
"migrate --dry-run, migrate --apply"
658688
)
659689
)
660690
action = tokens[0]
@@ -710,19 +740,55 @@ async def _memory_handler(args: str, context: CommandContext) -> CommandResult:
710740
return CommandResult(message=f"Memory entry not found: {rest}")
711741
return CommandResult(message=content)
712742
if action == "add" and rest:
713-
title, separator, content = rest.partition("::")
743+
memory_type, scope, cleaned_rest = _parse_memory_add_flags(rest)
744+
title, separator, content = cleaned_rest.partition("::")
714745
if not separator or not title.strip() or not content.strip():
715-
return CommandResult(message="Usage: /memory add TITLE :: CONTENT")
716-
path = backend.add_entry(title.strip(), content.strip())
746+
return CommandResult(message="Usage: /memory add [--type TYPE] [--scope SCOPE] TITLE :: CONTENT")
747+
if context.memory_backend is None:
748+
path = add_memory_entry(
749+
context.cwd,
750+
title.strip(),
751+
content.strip(),
752+
memory_type=memory_type,
753+
scope=scope,
754+
)
755+
else:
756+
path = backend.add_entry(title.strip(), content.strip())
717757
return CommandResult(message=f"Added memory entry {path.name}")
718758
if action == "remove" and rest:
719759
if backend.remove_entry(rest.strip()):
720760
return CommandResult(message=f"Removed memory entry {rest.strip()}")
721761
return CommandResult(message=f"Memory entry not found: {rest.strip()}")
762+
if action == "edit":
763+
return _handle_memory_edit_command(rest, context, backend)
764+
if action == "validate":
765+
return _handle_memory_validate_command(context)
766+
if action == "extract":
767+
if context.memory_backend is not None:
768+
return CommandResult(message="Memory extraction is only supported for OpenHarness project memory.")
769+
result = await extract_memories_from_turn(
770+
cwd=context.cwd,
771+
api_client=context.engine.api_client,
772+
model=context.engine.model,
773+
messages=context.engine.messages,
774+
max_records=load_settings().memory.auto_extract_max_records,
775+
)
776+
if result.skipped:
777+
return CommandResult(message=f"Memory extraction skipped: {result.reason}")
778+
return CommandResult(
779+
message="Memory extraction wrote:\n" + "\n".join(f"- {path}" for path in result.written_paths)
780+
)
781+
if action == "session":
782+
return _handle_memory_session_command(rest, context)
783+
if action == "team":
784+
return _handle_memory_team_command(rest, context)
785+
if action == "agent":
786+
return _handle_memory_agent_command(rest, context)
722787
return CommandResult(
723788
message=(
724789
"Usage: /memory "
725-
"[list|show NAME|add TITLE :: CONTENT|remove NAME|"
790+
"[list|show NAME|add TITLE :: CONTENT|remove NAME|edit [NAME]|"
791+
"validate|extract|session|team|agent|"
726792
"migrate --dry-run|migrate --apply]"
727793
)
728794
)
@@ -2478,6 +2544,158 @@ async def _plugin_command_handler(
24782544
return registry
24792545

24802546

2547+
def _handle_memory_edit_command(
2548+
args: str,
2549+
context: CommandContext,
2550+
backend: MemoryCommandBackend,
2551+
) -> CommandResult:
2552+
memory_dir = backend.get_memory_dir()
2553+
target = backend.get_entrypoint()
2554+
if args.strip():
2555+
path, invalid = _resolve_memory_entry_path(memory_dir, args.strip())
2556+
if invalid:
2557+
return CommandResult(message="Memory entry path must stay within the configured memory directory.")
2558+
if path is None:
2559+
return CommandResult(message=f"Memory entry not found: {args.strip()}")
2560+
target = path
2561+
target.parent.mkdir(parents=True, exist_ok=True)
2562+
target.touch(exist_ok=True)
2563+
editor = os.environ.get("VISUAL") or os.environ.get("EDITOR")
2564+
if not editor:
2565+
return CommandResult(message=f"Memory file ready: {target}\nSet $VISUAL or $EDITOR to open it from /memory edit.")
2566+
result = subprocess.run([editor, str(target)], cwd=context.cwd, check=False)
2567+
if result.returncode != 0:
2568+
return CommandResult(message=f"Editor exited with status {result.returncode}: {editor}")
2569+
return CommandResult(message=f"Edited memory file: {target}")
2570+
2571+
2572+
def _parse_memory_add_flags(args: str):
2573+
"""Parse optional ``/memory add`` type/scope flags."""
2574+
2575+
memory_type = DEFAULT_MEMORY_TYPE
2576+
scope = DEFAULT_MEMORY_SCOPE
2577+
rest = args.strip()
2578+
changed = True
2579+
while changed:
2580+
changed = False
2581+
if rest.startswith("--type "):
2582+
_, _, tail = rest.partition(" ")
2583+
raw, _, rest = tail.partition(" ")
2584+
parsed = parse_memory_type(raw, default=DEFAULT_MEMORY_TYPE)
2585+
if parsed is not None:
2586+
memory_type = parsed
2587+
changed = True
2588+
if rest.startswith("--scope "):
2589+
_, _, tail = rest.partition(" ")
2590+
raw, _, rest = tail.partition(" ")
2591+
parsed_scope = parse_memory_scope(raw, default=DEFAULT_MEMORY_SCOPE)
2592+
if parsed_scope is not None:
2593+
scope = parsed_scope
2594+
changed = True
2595+
return memory_type, scope, rest
2596+
2597+
2598+
def _handle_memory_validate_command(context: CommandContext) -> CommandResult:
2599+
memory_dir = get_project_memory_dir(context.cwd)
2600+
headers = scan_memory_files(context.cwd, max_files=500)
2601+
issues: list[str] = []
2602+
for header in headers:
2603+
raw_type = header.frontmatter.get("type") or header.frontmatter.get("memory_type")
2604+
if parse_memory_type(raw_type) is None:
2605+
issues.append(
2606+
f"- {header.relative_path}: invalid or missing type {raw_type!r}; expected {', '.join(MEMORY_TYPES)}"
2607+
)
2608+
if "team" in Path(header.relative_path).parts:
2609+
try:
2610+
text = header.path.read_text(encoding="utf-8", errors="replace")
2611+
except OSError:
2612+
text = ""
2613+
secret_error = check_team_memory_secrets(text)
2614+
if secret_error:
2615+
issues.append(f"- {header.relative_path}: {secret_error}")
2616+
if not issues:
2617+
return CommandResult(
2618+
message=(
2619+
"Memory validation passed.\n"
2620+
f"- files: {len(headers)}\n"
2621+
f"- memory_dir: {memory_dir}"
2622+
)
2623+
)
2624+
return CommandResult(message="Memory validation issues:\n" + "\n".join(issues))
2625+
2626+
2627+
def _handle_memory_session_command(args: str, context: CommandContext) -> CommandResult:
2628+
action = args.split(maxsplit=1)[0] if args.strip() else "status"
2629+
path = get_session_memory_path(context.cwd, context.session_id or "default")
2630+
if action == "update":
2631+
path = update_session_memory_file(
2632+
context.cwd,
2633+
context.engine.messages,
2634+
tool_metadata=context.engine.tool_metadata,
2635+
session_id=context.session_id or "default",
2636+
)
2637+
return CommandResult(message=f"Updated session memory: {path}")
2638+
if action == "show":
2639+
content = get_session_memory_content(path)
2640+
return CommandResult(message=content or f"No session memory at {path}")
2641+
return CommandResult(
2642+
message=(
2643+
"Session memory:\n"
2644+
f"- path: {path}\n"
2645+
f"- exists: {path.exists()}\n"
2646+
"Commands: /memory session [status|show|update]"
2647+
)
2648+
)
2649+
2650+
2651+
def _handle_memory_team_command(args: str, context: CommandContext) -> CommandResult:
2652+
action = args.split(maxsplit=1)[0] if args.strip() else "status"
2653+
team_dir = ensure_team_memory_vault(context.cwd)
2654+
if action == "list":
2655+
files = sorted(path for path in team_dir.rglob("*.md") if path.name != "MEMORY.md")
2656+
return CommandResult(message="\n".join(str(path.relative_to(team_dir)) for path in files) or "No team memory files.")
2657+
if action == "validate":
2658+
issues: list[str] = []
2659+
for path in sorted(team_dir.rglob("*.md")):
2660+
if path.name == "MEMORY.md":
2661+
continue
2662+
text = path.read_text(encoding="utf-8", errors="replace")
2663+
secret_error = check_team_memory_secrets(text)
2664+
if secret_error:
2665+
issues.append(f"- {path.relative_to(team_dir)}: {secret_error}")
2666+
return CommandResult(message="Team memory validation passed." if not issues else "\n".join(issues))
2667+
return CommandResult(
2668+
message=(
2669+
"Team memory:\n"
2670+
f"- directory: {get_team_memory_dir(context.cwd)}\n"
2671+
f"- exists: {team_dir.exists()}\n"
2672+
"Commands: /memory team [status|list|validate]"
2673+
)
2674+
)
2675+
2676+
2677+
def _handle_memory_agent_command(args: str, context: CommandContext) -> CommandResult:
2678+
parts = args.split()
2679+
action = parts[0] if parts else "status"
2680+
agent_type = parts[1] if len(parts) > 1 else "default"
2681+
scope = parts[2] if len(parts) > 2 else "project"
2682+
if scope not in {"user", "project", "local"}:
2683+
return CommandResult(message="Agent memory scope must be one of: user, project, local")
2684+
if action == "snapshot":
2685+
target = initialize_agent_memory_from_snapshot(context.cwd, agent_type, scope) # type: ignore[arg-type]
2686+
return CommandResult(message=f"Initialized agent memory from snapshot: {target}" if target else "No snapshot found.")
2687+
vault = ensure_agent_memory_vault(context.cwd, agent_type, scope) # type: ignore[arg-type]
2688+
return CommandResult(
2689+
message=(
2690+
"Agent memory:\n"
2691+
f"- agent_type: {agent_type}\n"
2692+
f"- scope: {scope}\n"
2693+
f"- directory: {vault}\n"
2694+
f"- entrypoint: {get_agent_memory_entrypoint(context.cwd, agent_type, scope)}"
2695+
)
2696+
)
2697+
2698+
24812699
def _resolve_memory_entry_path(memory_dir: Path, candidate: str) -> tuple[Path | None, bool]:
24822700
"""Resolve a memory entry path while enforcing containment under ``memory_dir``."""
24832701

src/openharness/config/settings.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,8 +63,12 @@ class MemorySettings(BaseModel):
6363
enabled: bool = True
6464
max_files: int = 5
6565
max_entrypoint_lines: int = 200
66+
max_entrypoint_bytes: int = 25_000
6667
context_window_tokens: int | None = None
6768
auto_compact_threshold_tokens: int | None = None
69+
auto_extract_enabled: bool = False
70+
auto_extract_max_records: int = 3
71+
session_memory_enabled: bool = True
6872
auto_dream_enabled: bool = False
6973
auto_dream_min_hours: float = 24.0
7074
auto_dream_min_sessions: int = 5

src/openharness/engine/query_engine.py

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,63 @@ def _schedule_auto_dream(self) -> None:
152152
**kwargs,
153153
)
154154

155+
def _prepare_session_memory(self) -> None:
156+
"""Expose file-backed session memory to compaction when enabled."""
157+
158+
if self._settings is None or not self._settings.memory.session_memory_enabled:
159+
return
160+
if not self._settings.memory.enabled:
161+
return
162+
from openharness.services.session_memory import prepare_session_memory_metadata
163+
164+
prepare_session_memory_metadata(
165+
self._cwd,
166+
self._tool_metadata,
167+
session_id=str(self._tool_metadata.get("session_id") or "default"),
168+
)
169+
170+
async def _update_session_memory(self) -> None:
171+
"""Persist a session checkpoint after a user turn."""
172+
173+
if self._settings is None or not self._settings.memory.session_memory_enabled:
174+
return
175+
if not self._settings.memory.enabled:
176+
return
177+
from openharness.services.session_memory import update_session_memory_file
178+
179+
update_session_memory_file(
180+
self._cwd,
181+
list(self._messages),
182+
tool_metadata=self._tool_metadata,
183+
session_id=str(self._tool_metadata.get("session_id") or "default"),
184+
)
185+
186+
async def _extract_durable_memories(self) -> None:
187+
"""Run the optional durable memory extraction pass."""
188+
189+
if self._settings is None or not self._settings.memory.auto_extract_enabled:
190+
return
191+
if not self._settings.memory.enabled:
192+
return
193+
from openharness.services.memory_extract import extract_memories_from_turn
194+
195+
try:
196+
result = await extract_memories_from_turn(
197+
cwd=self._cwd,
198+
api_client=self._api_client,
199+
model=self._model,
200+
messages=list(self._messages),
201+
max_records=self._settings.memory.auto_extract_max_records,
202+
)
203+
except Exception as exc:
204+
self._tool_metadata["memory_extract_last_error"] = str(exc)
205+
return
206+
self._tool_metadata["memory_extract_last"] = {
207+
"skipped": result.skipped,
208+
"reason": result.reason,
209+
"written_paths": [str(path) for path in result.written_paths],
210+
}
211+
155212
def has_pending_continuation(self) -> bool:
156213
"""Return True when the conversation ends with tool results awaiting a follow-up model turn."""
157214
if not self._messages:
@@ -176,6 +233,7 @@ async def submit_message(self, prompt: str | ConversationMessage) -> AsyncIterat
176233
)
177234
if user_message.text.strip() and not self._tool_metadata.pop("_suppress_next_user_goal", False):
178235
remember_user_goal(self._tool_metadata, user_message.text)
236+
self._prepare_session_memory()
179237
self._messages = sanitize_conversation_messages(self._messages)
180238
self._messages.append(user_message)
181239
if self._hook_executor is not None:
@@ -215,10 +273,13 @@ async def submit_message(self, prompt: str | ConversationMessage) -> AsyncIterat
215273
self._cost_tracker.add(usage)
216274
yield event
217275
finally:
276+
await self._update_session_memory()
277+
await self._extract_durable_memories()
218278
self._schedule_auto_dream()
219279

220280
async def continue_pending(self, *, max_turns: int | None = None) -> AsyncIterator[StreamEvent]:
221281
"""Continue an interrupted tool loop without appending a new user message."""
282+
self._prepare_session_memory()
222283
self._messages = sanitize_conversation_messages(self._messages)
223284
context = QueryContext(
224285
api_client=self._api_client,
@@ -241,3 +302,5 @@ async def continue_pending(self, *, max_turns: int | None = None) -> AsyncIterat
241302
if usage is not None:
242303
self._cost_tracker.add(usage)
243304
yield event
305+
await self._update_session_memory()
306+
await self._extract_durable_memories()

0 commit comments

Comments
 (0)