OpenSpace/openspace/services/memory/extract.py
2026-07-17 11:43:42 +08:00

1211 lines
43 KiB
Python

"""Background auto-memory extraction.
OpenSpace runs a small isolated tool loop through
``services.side_query.run_side_query`` to extract durable memory in the
background.
Implemented behavior:
- cursor based incremental extraction
- skip when the main agent already wrote auto memory
- per-N-turn throttling
- coalesced trailing run while extraction is in progress
- restricted tool gate for memory-related reads, searches, and writes
- best-effort fire-and-forget execution with drain support
"""
from __future__ import annotations
import asyncio
import hashlib
import inspect
import json
import logging
import os
import time
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Awaitable, Callable, Iterable, Mapping, Sequence
from openspace.grounding.core.tool.base import BaseTool
from openspace.services.memory.memdir import (
ENTRYPOINT_NAME,
ensure_memory_dir_exists,
get_auto_mem_path,
is_auto_mem_path,
is_auto_memory_enabled,
is_extract_mode_active,
)
from openspace.services.memory.daily_log import (
MEMORY_LOG_TOOL_NAME,
MemoryLogTool,
build_extract_daily_log_prompt,
extract_logged_entries,
get_memory_mode,
)
from openspace.services.memory.memory_scan import format_memory_manifest, scan_memory_files
from openspace.services.memory.memory_types import (
MEMORY_FRONTMATTER_EXAMPLE,
TYPES_SECTION_COMBINED,
TYPES_SECTION_INDIVIDUAL,
WHAT_NOT_TO_SAVE_SECTION,
)
from openspace.services.memory.task_scope import maybe_memory_task_scope_key
from openspace.services.conversation.side_query import run_side_query
from openspace.services.tooling.context import ToolUseContext
logger = logging.getLogger(__name__)
FILE_READ_TOOL_NAME = "read"
GREP_TOOL_NAME = "grep"
GLOB_TOOL_NAME = "glob"
BASH_TOOL_NAME = "bash"
FILE_EDIT_TOOL_NAME = "edit"
FILE_WRITE_TOOL_NAME = "write"
MEMORY_READ_TOOL_NAME = "memory_read"
MEMORY_WRITE_TOOL_NAME = "memory_write"
OPENSPACE_EXTRACT_MEMORIES_ENABLED_ENV = "OPENSPACE_EXTRACT_MEMORIES_ENABLED"
OPENSPACE_DISABLE_EXTRACT_MEMORIES_ENV = "OPENSPACE_DISABLE_EXTRACT_MEMORIES"
OPENSPACE_EXTRACT_MEMORIES_EVERY_N_TURNS_ENV = "OPENSPACE_EXTRACT_MEMORIES_EVERY_N_TURNS"
OPENSPACE_EXTRACT_MEMORIES_SKIP_INDEX_ENV = "OPENSPACE_EXTRACT_MEMORIES_SKIP_INDEX"
OPENSPACE_MEMORY_EXTRACT_MODEL_ENV = "OPENSPACE_MEMORY_EXTRACT_MODEL"
OPENSPACE_EXTRACT_MEMORIES_ALLOW_NON_INTERACTIVE_ENV = (
"OPENSPACE_EXTRACT_MEMORIES_ALLOW_NON_INTERACTIVE"
)
DEFAULT_MAX_EXTRACT_TURNS = 5
AppendSystemMessageFn = Callable[[dict[str, Any]], Awaitable[None] | None]
AutoMemCanUseToolFn = Callable[
[BaseTool | None, Mapping[str, Any]],
Awaitable[dict[str, Any]],
]
@dataclass(frozen=True)
class MemoryCursor:
"""Cursor for the last parent message processed by extraction.
OpenSpace stores a UUID. OpenSpace messages do not always carry UUIDs, so the
cursor stores a stable fingerprint plus its occurrence number. If the
fingerprint cannot be found later, we fall back to "all messages", matching
OpenSpace's compaction fallback.
"""
fingerprint: str
occurrence: int
@dataclass(slots=True)
class MemoryExtractionResult:
"""Result object for tests and observability.
OpenSpace's public API resolves ``void``. Returning this value in OS does not
affect the fire-and-forget call path, but makes the behavior auditable.
"""
ran: bool = False
skipped_reason: str | None = None
new_message_count: int = 0
written_paths: list[str] = field(default_factory=list)
memory_paths: list[str] = field(default_factory=list)
turn_count: int = 0
error: str | None = None
duration_ms: float = 0.0
@dataclass(slots=True)
class MemoryExtractorRuntimeState:
last_cursor: MemoryCursor | None = None
in_progress: bool = False
turns_since_last_extraction: int = 0
pending_context: tuple[ToolUseContext, AppendSystemMessageFn | None] | None = None
def _env_truthy(value: str | None) -> bool:
return value is not None and value.lower() in {"1", "true", "yes", "on"}
def _env_defined_falsy(value: str | None) -> bool:
return value is not None and value.lower() in {"0", "false", "no", "off", ""}
def _extract_feature_enabled() -> bool:
if _env_truthy(os.environ.get(OPENSPACE_DISABLE_EXTRACT_MEMORIES_ENV)):
return False
enabled = os.environ.get(OPENSPACE_EXTRACT_MEMORIES_ENABLED_ENV)
# OpenSpace gates this behind GrowthBook with default ``false``. OpenSpace has
# no GrowthBook layer, so an explicit env/settings opt-in is the equivalent
# feature decision.
return _env_truthy(enabled)
def _extract_every_n_turns(default: int = 1) -> int:
raw = os.environ.get(OPENSPACE_EXTRACT_MEMORIES_EVERY_N_TURNS_ENV)
if not raw:
return default
try:
parsed = int(raw)
except ValueError:
return default
return max(1, parsed)
def _extract_skip_index() -> bool:
return _env_truthy(os.environ.get(OPENSPACE_EXTRACT_MEMORIES_SKIP_INDEX_ENV))
def _extract_allow_non_interactive() -> bool:
return _env_truthy(
os.environ.get(OPENSPACE_EXTRACT_MEMORIES_ALLOW_NON_INTERACTIVE_ENV)
)
def _opener(new_message_count: int, existing_memories: str) -> str:
manifest = (
"\n\n## Existing memory files\n\n"
+ existing_memories
+ "\n\nCheck this list before writing - update an existing file rather than creating a duplicate."
if existing_memories
else ""
)
return "\n".join(
[
(
"You are now acting as the memory extraction subagent. "
f"Analyze the most recent ~{new_message_count} messages above and use them to update your persistent memory systems."
),
"",
(
f"Available tools: {FILE_READ_TOOL_NAME}, {GREP_TOOL_NAME}, {GLOB_TOOL_NAME}, "
f"read-only {BASH_TOOL_NAME} (ls/find/cat/stat/wc/head/tail and similar), "
f"and {FILE_EDIT_TOOL_NAME}/{FILE_WRITE_TOOL_NAME} for paths inside the memory directory only. "
f"{MEMORY_READ_TOOL_NAME}/{MEMORY_WRITE_TOOL_NAME} are also available in OpenSpace and are already scoped to the memory directory. "
f"{BASH_TOOL_NAME} rm is not permitted. All other tools - MCP, Agent, write-capable {BASH_TOOL_NAME}, etc - will be denied."
),
"",
(
f"You have a limited turn budget. {FILE_EDIT_TOOL_NAME} requires a prior "
f"{FILE_READ_TOOL_NAME} of the same file, so the efficient strategy is: "
f"turn 1 - issue all {FILE_READ_TOOL_NAME} calls in parallel for every file you might update; "
f"turn 2 - issue all {FILE_WRITE_TOOL_NAME}/{FILE_EDIT_TOOL_NAME} or {MEMORY_WRITE_TOOL_NAME} calls in parallel. "
"Do not interleave reads and writes across multiple turns."
),
"",
(
f"You MUST only use content from the last ~{new_message_count} messages to update your persistent memories. "
"Do not waste any turns attempting to investigate or verify that content further - no grepping source files, "
"no reading code to confirm a pattern exists, no git commands."
+ manifest
),
]
)
def build_extract_auto_only_prompt(
new_message_count: int,
existing_memories: str,
skip_index: bool = False,
) -> str:
"""Build OpenSpace's auto-only extraction prompt, adapted to OS tool names."""
if skip_index:
how_to_save = [
"## How to save memories",
"",
"Write each memory to its own file (e.g., `user_role.md`, `feedback_testing.md`) using this frontmatter format:",
"",
*MEMORY_FRONTMATTER_EXAMPLE,
"",
"- Organize memory semantically by topic, not chronologically",
"- Update or remove memories that turn out to be wrong or outdated",
"- Do not write duplicate memories. First check if there is an existing memory you can update before writing a new one.",
]
else:
how_to_save = [
"## How to save memories",
"",
"Saving a memory is a two-step process:",
"",
"**Step 1** - write the memory to its own file (e.g., `user_role.md`, `feedback_testing.md`) using this frontmatter format:",
"",
*MEMORY_FRONTMATTER_EXAMPLE,
"",
(
f"**Step 2** - add a pointer to that file in `{ENTRYPOINT_NAME}`. "
f"`{ENTRYPOINT_NAME}` is an index, not a memory - each entry should be one line, under ~150 characters: "
"`- [Title](file.md) - one-line hook`. It has no frontmatter. Never write memory content directly into "
f"`{ENTRYPOINT_NAME}`."
),
"",
f"- `{ENTRYPOINT_NAME}` is always loaded into your system prompt - lines after 200 will be truncated, so keep the index concise",
"- Organize memory semantically by topic, not chronologically",
"- Update or remove memories that turn out to be wrong or outdated",
"- Do not write duplicate memories. First check if there is an existing memory you can update before writing a new one.",
]
return "\n".join(
[
_opener(new_message_count, existing_memories),
"",
"If the user explicitly asks you to remember something, save it immediately as whichever type fits best. If they ask you to forget something, find and remove the relevant entry.",
"",
*TYPES_SECTION_INDIVIDUAL,
*WHAT_NOT_TO_SAVE_SECTION,
"",
*how_to_save,
]
)
def build_extract_combined_prompt(
new_message_count: int,
existing_memories: str,
skip_index: bool = False,
*,
team_memory_enabled: bool = False,
) -> str:
"""Build OpenSpace's combined auto+team prompt.
Team memory is intentionally disabled in OpenSpace because it needs a
backend/API surface (DEC-025). The function keeps the OpenSpace return structure
so later TeamMem work has a single place to connect.
"""
if not team_memory_enabled:
return build_extract_auto_only_prompt(
new_message_count,
existing_memories,
skip_index,
)
if skip_index:
how_to_save = [
"## How to save memories",
"",
"Write each memory to its own file in the chosen directory (private or team, per the type's scope guidance) using this frontmatter format:",
"",
*MEMORY_FRONTMATTER_EXAMPLE,
"",
"- Organize memory semantically by topic, not chronologically",
"- Update or remove memories that turn out to be wrong or outdated",
"- Do not write duplicate memories. First check if there is an existing memory you can update before writing a new one.",
]
else:
how_to_save = [
"## How to save memories",
"",
"Saving a memory is a two-step process:",
"",
"**Step 1** - write the memory to its own file in the chosen directory (private or team, per the type's scope guidance) using this frontmatter format:",
"",
*MEMORY_FRONTMATTER_EXAMPLE,
"",
"**Step 2** - add a pointer to that file in the same directory's `MEMORY.md`. Each directory (private and team) has its own `MEMORY.md` index - each entry should be one line, under ~150 characters: `- [Title](file.md) - one-line hook`. They have no frontmatter. Never write memory content directly into a `MEMORY.md`.",
"",
"- Both `MEMORY.md` indexes are loaded into your system prompt - lines after 200 will be truncated, so keep them concise",
"- Organize memory semantically by topic, not chronologically",
"- Update or remove memories that turn out to be wrong or outdated",
"- Do not write duplicate memories. First check if there is an existing memory you can update before writing a new one.",
]
return "\n".join(
[
_opener(new_message_count, existing_memories),
"",
"If the user explicitly asks you to remember something, save it immediately as whichever type fits best. If they ask you to forget something, find and remove the relevant entry.",
"",
*TYPES_SECTION_COMBINED,
*WHAT_NOT_TO_SAVE_SECTION,
"- You MUST avoid saving sensitive data within shared team memories. For example, never save API keys or user credentials.",
"",
*how_to_save,
]
)
def create_auto_mem_can_use_tool(memory_dir: str | Path) -> AutoMemCanUseToolFn:
"""Return the tool gate for auto-memory background agents."""
root = Path(memory_dir).expanduser().resolve()
async def can_use_tool(
tool: BaseTool | None,
input: Mapping[str, Any],
) -> dict[str, Any]:
if tool is None:
return _deny_auto_mem_tool(
"unknown",
"Tool is not available to the auto-memory extraction agent.",
)
name = tool.name
data = dict(input)
if name in {MEMORY_READ_TOOL_NAME, MEMORY_WRITE_TOOL_NAME}:
return {"behavior": "allow", "updated_input": data}
if name == MEMORY_LOG_TOOL_NAME:
return {"behavior": "allow", "updated_input": data}
if name in {FILE_READ_TOOL_NAME, GREP_TOOL_NAME, GLOB_TOOL_NAME}:
return {"behavior": "allow", "updated_input": data}
if name == BASH_TOOL_NAME:
try:
if tool.is_read_only(data):
return {"behavior": "allow", "updated_input": data}
except Exception:
pass
return _deny_auto_mem_tool(
name,
(
"Only read-only shell commands are permitted in this context "
"(ls, find, grep, cat, stat, wc, head, tail, and similar)."
),
)
if name in {FILE_EDIT_TOOL_NAME, FILE_WRITE_TOOL_NAME}:
file_path = data.get("file_path")
if isinstance(file_path, str) and _path_is_inside(file_path, root):
return {"behavior": "allow", "updated_input": data}
return _deny_auto_mem_tool(
name,
f"{name} is only permitted for paths inside {root}.",
)
return _deny_auto_mem_tool(
name,
(
f"Only {FILE_READ_TOOL_NAME}, {GREP_TOOL_NAME}, {GLOB_TOOL_NAME}, "
f"read-only {BASH_TOOL_NAME}, {FILE_EDIT_TOOL_NAME}/{FILE_WRITE_TOOL_NAME} within {root}, "
f"{MEMORY_READ_TOOL_NAME}/{MEMORY_WRITE_TOOL_NAME}, and {MEMORY_LOG_TOOL_NAME} are allowed."
),
)
return can_use_tool
def _deny_auto_mem_tool(tool_name: str, reason: str) -> dict[str, Any]:
logger.debug("[autoMem] denied %s: %s", tool_name, reason)
return {
"behavior": "deny",
"message": reason,
"decision_reason": {"type": "other", "reason": reason},
}
def _path_is_inside(file_path: str, root: Path) -> bool:
try:
candidate = Path(file_path).expanduser()
if not candidate.is_absolute():
candidate = root / candidate
candidate.resolve().relative_to(root)
return True
except (OSError, ValueError):
return False
class MemoryExtractor:
"""Stateful background memory extractor.
A single extractor instance mirrors OpenSpace's closure-scoped state created by
``initExtractMemories``.
"""
def __init__(
self,
*,
max_turns: int = DEFAULT_MAX_EXTRACT_TURNS,
throttle_turns: int | None = None,
) -> None:
self.max_turns = max(1, int(max_turns))
self.throttle_turns = throttle_turns
self._states: dict[str, MemoryExtractorRuntimeState] = {}
self._default_state = MemoryExtractorRuntimeState()
self._in_flight: set[asyncio.Task[MemoryExtractionResult]] = set()
self._task_scope_keys: dict[asyncio.Task[MemoryExtractionResult], str | None] = {}
@property
def last_cursor(self) -> MemoryCursor | None:
return self._default_state.last_cursor
def _runtime_state(self, context: ToolUseContext) -> MemoryExtractorRuntimeState:
scope_key = maybe_memory_task_scope_key(context)
if scope_key is None:
return self._default_state
state = self._states.get(scope_key)
if state is None:
state = MemoryExtractorRuntimeState()
self._states[scope_key] = state
return state
async def execute(
self,
context: ToolUseContext,
append_system_message: AppendSystemMessageFn | None = None,
) -> MemoryExtractionResult:
"""Run extraction once, including trailing-run coalescing."""
task = asyncio.current_task()
if task is not None:
self._in_flight.add(task) # type: ignore[arg-type]
self._task_scope_keys[task] = maybe_memory_task_scope_key(context) # type: ignore[index]
try:
return await self._execute_impl(context, append_system_message)
finally:
if task is not None:
self._in_flight.discard(task) # type: ignore[arg-type]
self._task_scope_keys.pop(task, None)
def submit(
self,
context: ToolUseContext,
append_system_message: AppendSystemMessageFn | None = None,
) -> asyncio.Task[MemoryExtractionResult]:
"""Schedule fire-and-forget extraction and track it for draining."""
task = asyncio.create_task(self.execute(context, append_system_message))
self._in_flight.add(task)
self._task_scope_keys[task] = maybe_memory_task_scope_key(context)
def _done(done: asyncio.Task[MemoryExtractionResult]) -> None:
self._in_flight.discard(done)
self._task_scope_keys.pop(done, None)
try:
done.result()
except asyncio.CancelledError:
pass
except Exception:
logger.debug("Background memory extraction task failed", exc_info=True)
task.add_done_callback(_done)
return task
async def drain(
self,
timeout_s: float = 60.0,
*,
context: Any | None = None,
scope_key: str | None = None,
) -> int:
"""Wait for in-flight extractions with a soft timeout."""
scope_key = scope_key or maybe_memory_task_scope_key(context)
tasks = [
task
for task in self._in_flight
if scope_key is None or self._task_scope_keys.get(task) == scope_key
]
if not tasks:
return 0
done, _pending = await asyncio.wait(
tasks,
timeout=max(0.0, timeout_s),
)
for task in done:
try:
task.result()
except asyncio.CancelledError:
pass
except Exception:
logger.debug("Pending memory extraction failed during drain", exc_info=True)
return len(_pending)
async def _execute_impl(
self,
context: ToolUseContext,
append_system_message: AppendSystemMessageFn | None,
) -> MemoryExtractionResult:
if _should_skip_context(context):
await _emit_memory_extraction_skipped(context, "subagent")
return MemoryExtractionResult(skipped_reason="subagent")
feature_enabled = _extract_feature_enabled()
non_interactive = not bool(getattr(context, "tui_available", False))
if not is_extract_mode_active(
feature_enabled=feature_enabled,
non_interactive=non_interactive,
allow_non_interactive=_extract_allow_non_interactive(),
):
await _emit_memory_extraction_skipped(context, "disabled")
return MemoryExtractionResult(skipped_reason="disabled")
if not is_auto_memory_enabled():
await _emit_memory_extraction_skipped(context, "auto_memory_disabled")
return MemoryExtractionResult(skipped_reason="auto_memory_disabled")
state = self._runtime_state(context)
if state.in_progress:
state.pending_context = (context, append_system_message)
await context.emit_event("memory_extraction_coalesced", {})
await _emit_memory_extraction_skipped(context, "coalesced")
return MemoryExtractionResult(skipped_reason="coalesced")
return await self._run_extraction_chain(context, append_system_message, state)
async def _run_extraction_chain(
self,
context: ToolUseContext,
append_system_message: AppendSystemMessageFn | None,
state: MemoryExtractorRuntimeState,
*,
is_trailing_run: bool = False,
) -> MemoryExtractionResult:
state.in_progress = True
try:
result = await self._run_extraction(
context,
append_system_message,
state,
is_trailing_run=is_trailing_run,
)
finally:
state.in_progress = False
trailing = state.pending_context
state.pending_context = None
if trailing is not None:
await context.emit_event("memory_extraction_trailing_start", {})
await self._run_extraction_chain(
trailing[0],
trailing[1],
state,
is_trailing_run=True,
)
return result
async def _run_extraction(
self,
context: ToolUseContext,
append_system_message: AppendSystemMessageFn | None,
state: MemoryExtractorRuntimeState,
*,
is_trailing_run: bool,
) -> MemoryExtractionResult:
start_time = time.time()
messages = list(context.messages or [])
memory_dir = get_auto_mem_path(cwd=context.cwd)
new_message_count = count_model_visible_messages_since(
messages,
state.last_cursor,
)
if new_message_count <= 0:
await _emit_memory_extraction_skipped(
context,
"no_new_messages",
memory_dir=str(memory_dir),
message_count=new_message_count,
)
return MemoryExtractionResult(
skipped_reason="no_new_messages",
new_message_count=0,
)
if has_memory_writes_since(messages, state.last_cursor, memory_dir=memory_dir):
self._advance_cursor(messages, state)
await context.emit_event(
"memory_extraction_skipped",
{
"task_id": _background_task_id(context, "extract_memories"),
"reason": "main_agent_memory_write",
"message_count": new_message_count,
},
)
return MemoryExtractionResult(
skipped_reason="main_agent_memory_write",
new_message_count=new_message_count,
)
if not is_trailing_run:
state.turns_since_last_extraction += 1
required_turns = self.throttle_turns or _extract_every_n_turns()
if state.turns_since_last_extraction < required_turns:
await _emit_memory_extraction_skipped(
context,
"throttled",
memory_dir=str(memory_dir),
message_count=new_message_count,
required_turns=required_turns,
turns_since_last_extraction=state.turns_since_last_extraction,
)
return MemoryExtractionResult(
skipped_reason="throttled",
new_message_count=new_message_count,
)
state.turns_since_last_extraction = 0
llm_client = context.llm_client
if llm_client is None or not hasattr(llm_client, "call_model"):
await _emit_memory_extraction_skipped(
context,
"missing_llm_client",
memory_dir=str(memory_dir),
message_count=new_message_count,
)
return MemoryExtractionResult(
skipped_reason="missing_llm_client",
new_message_count=new_message_count,
)
ensure_memory_dir_exists(memory_dir)
existing_memories = format_memory_manifest(scan_memory_files(memory_dir))
memory_mode = get_memory_mode(getattr(context, "memory_mode", None))
daily_log_mode = memory_mode == "daily_log"
if daily_log_mode:
prompt = build_extract_daily_log_prompt(
new_message_count,
existing_memories,
)
else:
prompt = build_extract_auto_only_prompt(
new_message_count,
existing_memories,
skip_index=_extract_skip_index(),
)
tools = _select_auto_mem_tools(
context,
memory_dir,
daily_log_mode=daily_log_mode,
)
if not tools:
await _emit_memory_extraction_skipped(
context,
"missing_tools",
memory_dir=str(memory_dir),
message_count=new_message_count,
memory_mode=memory_mode,
)
return MemoryExtractionResult(
skipped_reason="missing_tools",
new_message_count=new_message_count,
)
can_use_tool = create_auto_mem_can_use_tool(memory_dir)
task_id = _background_task_id(context, "extract_memories")
await context.emit_event(
"memory_extraction_start",
{
"task_id": task_id,
"message_count": new_message_count,
"memory_mode": memory_mode,
"memory_dir": str(memory_dir),
"is_trailing_run": is_trailing_run,
},
)
try:
model_override = os.environ.get(OPENSPACE_MEMORY_EXTRACT_MODEL_ENV) or None
side_result = await run_side_query(
prompt,
tools=tools,
model=model_override,
parent_context=context,
llm_client=llm_client,
messages=messages,
max_turns=self.max_turns,
can_use_tool=can_use_tool,
query_source="extract_memories",
fork_label="extract_memories",
agent_type="extract_memories",
denied_result_type="auto_mem_tool_denied",
tui_available=False,
is_async_agent=True,
)
result_messages = side_result.messages
total_usage = side_result.total_usage
turn_count = side_result.turn_count
self._advance_cursor(messages, state)
written_paths = extract_written_paths(result_messages, memory_dir=memory_dir)
logged_entry_ids, logged_paths = extract_logged_entries(result_messages)
if daily_log_mode:
written_paths = logged_paths
memory_paths = [
path for path in written_paths if Path(path).name != ENTRYPOINT_NAME
]
if daily_log_mode:
memory_paths = []
duration_ms = (time.time() - start_time) * 1000
extraction_result = MemoryExtractionResult(
ran=True,
new_message_count=new_message_count,
written_paths=written_paths,
memory_paths=memory_paths,
turn_count=turn_count,
duration_ms=duration_ms,
)
await context.emit_event(
"memory_extraction_complete",
{
"task_id": task_id,
"message_count": new_message_count,
"turn_count": turn_count,
"files_written": len(written_paths),
"memories_saved": len(memory_paths),
"input_tokens": total_usage.input_tokens,
"output_tokens": total_usage.output_tokens,
"duration_ms": duration_ms,
"memory_mode": memory_mode,
"written_paths": written_paths,
"memory_paths": memory_paths,
"log_paths": logged_paths,
"logged_entry_ids": logged_entry_ids,
"logs_written": len(logged_paths),
},
)
if memory_paths:
await _append_memory_saved_message(
context,
memory_paths,
append_system_message,
)
return extraction_result
except Exception as exc:
duration_ms = (time.time() - start_time) * 1000
logger.debug("[extractMemories] error: %s", exc, exc_info=True)
await context.emit_event(
"memory_extraction_error",
{
"task_id": task_id,
"memory_dir": str(memory_dir),
"duration_ms": duration_ms,
"error": str(exc),
},
)
return MemoryExtractionResult(
ran=True,
new_message_count=new_message_count,
turn_count=turn_count,
duration_ms=duration_ms,
error=str(exc),
)
def _advance_cursor(
self,
messages: Sequence[Mapping[str, Any]],
state: MemoryExtractorRuntimeState,
) -> None:
if not messages:
return
state.last_cursor = _cursor_for_index(messages, len(messages) - 1)
def _should_skip_context(context: ToolUseContext) -> bool:
if getattr(context, "is_async_agent", False):
return True
if getattr(context, "parent_task_id", None):
return True
if getattr(context, "agent_type", None) in {"extract_memories", "auto_dream"}:
return True
return False
def _background_task_id(context: ToolUseContext, key: str) -> str | None:
task_ids = getattr(context, "background_task_ids", None)
if isinstance(task_ids, dict):
value = task_ids.get(key)
return str(value) if value else None
return None
async def _emit_memory_extraction_skipped(
context: ToolUseContext,
reason: str,
**extra: Any,
) -> None:
await context.emit_event(
"memory_extraction_skipped",
{
"task_id": _background_task_id(context, "extract_memories"),
"reason": reason,
**extra,
},
)
def should_schedule_extract_memories(context: ToolUseContext) -> bool:
"""Cheap gate used by stop hooks before creating a background task."""
if _should_skip_context(context):
return False
if not is_auto_memory_enabled():
return False
feature_enabled = _extract_feature_enabled()
non_interactive = not bool(getattr(context, "tui_available", False))
return is_extract_mode_active(
feature_enabled=feature_enabled,
non_interactive=non_interactive,
allow_non_interactive=_extract_allow_non_interactive(),
)
def _select_auto_mem_tools(
context: ToolUseContext,
memory_dir: str | Path,
*,
daily_log_mode: bool = False,
) -> list[BaseTool]:
if daily_log_mode:
allowed = {
FILE_READ_TOOL_NAME,
GREP_TOOL_NAME,
GLOB_TOOL_NAME,
BASH_TOOL_NAME,
MEMORY_READ_TOOL_NAME,
MEMORY_LOG_TOOL_NAME,
}
else:
allowed = {
FILE_READ_TOOL_NAME,
GREP_TOOL_NAME,
GLOB_TOOL_NAME,
BASH_TOOL_NAME,
FILE_EDIT_TOOL_NAME,
FILE_WRITE_TOOL_NAME,
MEMORY_READ_TOOL_NAME,
MEMORY_WRITE_TOOL_NAME,
}
selected: list[BaseTool] = []
seen: set[str] = set()
for tool in [*(context.all_tools or []), *(context.tools or [])]:
name = getattr(tool, "name", "")
if name in allowed and name not in seen:
selected.append(tool)
seen.add(name)
# OpenSpace-specific safe default: if the main tool set omitted the memory
# helpers, use them as a scoped fallback. OpenSpace lacks these dedicated tools;
# OS added them in 15.5 to avoid fragile hand-written frontmatter/index
# edits while preserving the same memdir storage shape.
try:
if daily_log_mode and MEMORY_LOG_TOOL_NAME not in seen:
selected.append(MemoryLogTool())
seen.add(MEMORY_LOG_TOOL_NAME)
if not daily_log_mode and MEMORY_WRITE_TOOL_NAME not in seen:
from openspace.tools.memory_tools import MemoryWriteTool
selected.append(MemoryWriteTool())
seen.add(MEMORY_WRITE_TOOL_NAME)
if MEMORY_READ_TOOL_NAME not in seen:
from openspace.tools.memory_tools import MemoryReadTool
selected.append(MemoryReadTool())
seen.add(MEMORY_READ_TOOL_NAME)
except Exception:
pass
return selected
def _tool_call_name(tool_call: Mapping[str, Any]) -> str:
function = tool_call.get("function")
if isinstance(function, Mapping):
name = function.get("name")
if isinstance(name, str):
return name
name = tool_call.get("name")
return name if isinstance(name, str) else ""
def _tool_call_input(tool_call: Mapping[str, Any]) -> dict[str, Any]:
function = tool_call.get("function")
raw: Any = None
if isinstance(function, Mapping):
raw = function.get("arguments")
elif "input" in tool_call:
raw = tool_call.get("input")
if isinstance(raw, dict):
return dict(raw)
if isinstance(raw, str):
try:
parsed = json.loads(raw)
except (json.JSONDecodeError, TypeError):
return {}
return parsed if isinstance(parsed, dict) else {}
return {}
def _message_fingerprint(message: Mapping[str, Any]) -> str:
meta = message.get("_meta")
if isinstance(meta, Mapping):
for key in ("uuid", "message_uuid", "id"):
value = meta.get(key)
if isinstance(value, str) and value:
return f"meta:{key}:{value}"
for key in ("uuid", "id"):
value = message.get(key)
if isinstance(value, str) and value:
return f"{key}:{value}"
payload = {
"role": message.get("role"),
"content": message.get("content"),
"tool_calls": message.get("tool_calls"),
"tool_call_id": message.get("tool_call_id"),
"name": message.get("name"),
}
raw = json.dumps(payload, sort_keys=True, default=str, ensure_ascii=False)
return "sha256:" + hashlib.sha256(raw.encode("utf-8")).hexdigest()
def _cursor_for_index(
messages: Sequence[Mapping[str, Any]],
index: int,
) -> MemoryCursor:
fingerprint = _message_fingerprint(messages[index])
occurrence = 0
for item in messages[: index + 1]:
if _message_fingerprint(item) == fingerprint:
occurrence += 1
return MemoryCursor(fingerprint=fingerprint, occurrence=occurrence)
def _find_cursor_index(
messages: Sequence[Mapping[str, Any]],
cursor: MemoryCursor | None,
) -> int:
if cursor is None:
return -1
seen = 0
for index, message in enumerate(messages):
if _message_fingerprint(message) == cursor.fingerprint:
seen += 1
if seen == cursor.occurrence:
return index
return -1
def _is_model_visible_message(message: Mapping[str, Any]) -> bool:
return message.get("role") in {"user", "assistant"}
def count_model_visible_messages_since(
messages: Sequence[Mapping[str, Any]],
cursor: MemoryCursor | None,
) -> int:
start = _find_cursor_index(messages, cursor)
return sum(1 for message in messages[start + 1 :] if _is_model_visible_message(message))
def has_memory_writes_since(
messages: Sequence[Mapping[str, Any]],
cursor: MemoryCursor | None,
*,
memory_dir: str | Path | None = None,
) -> bool:
start = _find_cursor_index(messages, cursor)
for message in messages[start + 1 :]:
if _message_contains_auto_memory_write(message, memory_dir=memory_dir):
return True
return False
def _message_contains_auto_memory_write(
message: Mapping[str, Any],
*,
memory_dir: str | Path | None,
) -> bool:
meta = message.get("_meta")
if isinstance(meta, Mapping):
if meta.get("tool_name") in {MEMORY_WRITE_TOOL_NAME, MEMORY_LOG_TOOL_NAME}:
return True
result_meta = meta.get("tool_result_metadata")
if isinstance(result_meta, Mapping):
if result_meta.get("type") in {"memory_write", "memory_log"}:
return True
path = result_meta.get("file_path") or result_meta.get("entrypoint_path")
if isinstance(path, str) and _is_auto_memory_path(path, memory_dir):
return True
if message.get("role") == "assistant":
for tool_call in _iter_assistant_tool_calls(message):
tool_name = _tool_call_name(tool_call)
if tool_name in {MEMORY_WRITE_TOOL_NAME, MEMORY_LOG_TOOL_NAME}:
return True
if tool_name in {FILE_EDIT_TOOL_NAME, FILE_WRITE_TOOL_NAME}:
path = _tool_call_input(tool_call).get("file_path")
if isinstance(path, str) and _is_auto_memory_path(path, memory_dir):
return True
return False
def _iter_assistant_tool_calls(message: Mapping[str, Any]) -> Iterable[Mapping[str, Any]]:
tool_calls = message.get("tool_calls")
if isinstance(tool_calls, list):
for call in tool_calls:
if isinstance(call, Mapping):
yield call
content = message.get("content")
if isinstance(content, list):
for block in content:
if isinstance(block, Mapping) and block.get("type") == "tool_use":
yield block
def _is_auto_memory_path(path: str, memory_dir: str | Path | None) -> bool:
if memory_dir is not None:
return _path_is_inside(path, Path(memory_dir).expanduser().resolve())
return is_auto_mem_path(path)
def extract_written_paths(
agent_messages: Sequence[Mapping[str, Any]],
*,
memory_dir: str | Path | None = None,
) -> list[str]:
paths: list[str] = []
for message in agent_messages:
meta = message.get("_meta")
if isinstance(meta, Mapping):
result_meta = meta.get("tool_result_metadata")
if isinstance(result_meta, Mapping):
for key in ("file_path", "entrypoint_path"):
path = result_meta.get(key)
if isinstance(path, str) and _is_auto_memory_path(path, memory_dir):
paths.append(str(Path(path).expanduser().resolve()))
if message.get("role") == "assistant":
for tool_call in _iter_assistant_tool_calls(message):
tool_name = _tool_call_name(tool_call)
tool_input = _tool_call_input(tool_call)
if tool_name in {FILE_EDIT_TOOL_NAME, FILE_WRITE_TOOL_NAME}:
path = tool_input.get("file_path")
if isinstance(path, str) and _is_auto_memory_path(path, memory_dir):
paths.append(str(Path(path).expanduser().resolve()))
elif tool_name == MEMORY_WRITE_TOOL_NAME and memory_dir is not None:
filename = tool_input.get("filename")
title = tool_input.get("title") or "memory"
if isinstance(filename, str) and filename.strip():
candidate = Path(memory_dir) / filename
else:
slug = "".join(
ch.lower() if ch.isalnum() else "_"
for ch in str(title).strip()
).strip("_")[:80] or "memory"
candidate = Path(memory_dir) / f"{slug}.md"
paths.append(str(candidate.expanduser().resolve()))
return _uniq(paths)
def _uniq(items: Iterable[str]) -> list[str]:
seen: set[str] = set()
out: list[str] = []
for item in items:
if item not in seen:
out.append(item)
seen.add(item)
return out
def create_memory_saved_message(memory_paths: Sequence[str]) -> dict[str, Any]:
"""Build a user-visible system message equivalent to OpenSpace memory saved note."""
paths = [str(path) for path in memory_paths]
if len(paths) == 1:
content = f"Memory saved: {paths[0]}"
else:
rendered = "\n".join(f"- {path}" for path in paths)
content = f"Memories saved:\n{rendered}"
return {
"role": "system",
"content": content,
"_meta": {
"type": "memory_saved",
"memory_paths": paths,
"timestamp": time.time(),
},
}
async def _append_memory_saved_message(
context: ToolUseContext,
memory_paths: Sequence[str],
append_system_message: AppendSystemMessageFn | None,
) -> None:
message = create_memory_saved_message(memory_paths)
if append_system_message is not None:
result = append_system_message(message)
if inspect.isawaitable(result):
await result
else:
context.messages.append(message)
await context.emit_event(
"memory_saved",
{"memory_paths": list(memory_paths), "message": message},
)
_default_extractor: MemoryExtractor = MemoryExtractor()
def init_extract_memories(extractor: MemoryExtractor | None = None) -> MemoryExtractor:
"""Initialize global extraction state, matching OpenSpace ``initExtractMemories``."""
global _default_extractor
_default_extractor = extractor or MemoryExtractor()
return _default_extractor
def get_memory_extractor() -> MemoryExtractor:
return _default_extractor
async def execute_extract_memories(
context: ToolUseContext,
append_system_message: AppendSystemMessageFn | None = None,
) -> MemoryExtractionResult:
"""Run memory extraction.
This no-ops until gates pass and is safe to schedule fire-and-forget.
"""
return await _default_extractor.execute(context, append_system_message)
def submit_extract_memories(
context: ToolUseContext,
append_system_message: AppendSystemMessageFn | None = None,
) -> asyncio.Task[MemoryExtractionResult]:
"""Schedule background extraction and track it for draining."""
return _default_extractor.submit(context, append_system_message)
async def drain_pending_extraction(
timeout_s: float = 60.0,
*,
context: Any | None = None,
scope_key: str | None = None,
) -> int:
"""Await all in-flight extraction tasks with a soft timeout."""
return await _default_extractor.drain(
timeout_s,
context=context,
scope_key=scope_key,
)
__all__ = [
"AutoMemCanUseToolFn",
"MemoryCursor",
"MemoryExtractionResult",
"MemoryExtractor",
"build_extract_auto_only_prompt",
"build_extract_combined_prompt",
"count_model_visible_messages_since",
"create_auto_mem_can_use_tool",
"create_memory_saved_message",
"drain_pending_extraction",
"execute_extract_memories",
"extract_written_paths",
"get_memory_extractor",
"has_memory_writes_since",
"init_extract_memories",
"should_schedule_extract_memories",
"submit_extract_memories",
]