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

1360 lines
46 KiB
Python

"""Session memory for the current OpenSpace conversation.
Implementation notes:
- ``services/SessionMemory/sessionMemory.ts`` (495 lines)
- ``services/SessionMemory/sessionMemoryUtils.ts`` (207 lines)
- ``services/SessionMemory/prompts.ts`` (324 lines)
OpenSpace keeps the same state machine and markdown-note structure, but uses a
provider-neutral lightweight tool loop instead of OpenSpace's Anthropic-only
``runForkedAgent`` prompt-cache fork. The subagent is restricted to editing
the single session memory file.
"""
from __future__ import annotations
import asyncio
import hashlib
import inspect
import json
import logging
import os
import re
import time
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Awaitable, Callable, Mapping, Sequence
from openspace.grounding.core.tool.base import BaseTool
from openspace.services.conversation.messages import (
has_tool_calls_in_last_assistant_turn,
)
from openspace.services.memory.paths import get_openspace_config_home_dir
from openspace.services.memory.task_scope import (
maybe_memory_task_scope_key,
resolve_memory_task_scope_key,
)
from openspace.services.conversation.side_query import run_side_query
from openspace.services.tooling.context import ReadFileEntry, ToolUseContext
logger = logging.getLogger(__name__)
SESSION_MEMORY_FILENAME = "session_memory.md"
SESSION_MEMORY_CONFIG_DIR = "session-memory"
SESSION_MEMORY_TEMPLATE_PATH = ("config", "template.md")
SESSION_MEMORY_PROMPT_PATH = ("config", "prompt.md")
OPENSPACE_SESSION_MEMORY_ENABLED_ENV = "OPENSPACE_SESSION_MEMORY_ENABLED"
OPENSPACE_DISABLE_SESSION_MEMORY_ENV = "OPENSPACE_DISABLE_SESSION_MEMORY"
OPENSPACE_SESSION_MEMORY_SESSIONS_DIR_ENV = "OPENSPACE_SESSION_MEMORY_SESSIONS_DIR"
OPENSPACE_MEMORY_SESSION_MODEL_ENV = "OPENSPACE_MEMORY_SESSION_MODEL"
OPENSPACE_REMOTE_ENV = "OPENSPACE_REMOTE"
EXTRACTION_WAIT_TIMEOUT_MS = 15_000
EXTRACTION_STALE_THRESHOLD_MS = 60_000
EXTRACTION_POLL_INTERVAL_MS = 1_000
MAX_SECTION_LENGTH = 2_000
MAX_TOTAL_SESSION_MEMORY_TOKENS = 12_000
DEFAULT_MAX_SESSION_MEMORY_TURNS = 5
FILE_EDIT_TOOL_NAME = "edit"
@dataclass(frozen=True, slots=True)
class SessionMemoryConfig:
"""OpenSpace ``SessionMemoryConfig`` threshold knobs."""
minimum_message_tokens_to_init: int = 10_000
minimum_tokens_between_update: int = 5_000
tool_calls_between_updates: int = 3
DEFAULT_SESSION_MEMORY_CONFIG = SessionMemoryConfig()
@dataclass(frozen=True, slots=True)
class MessageCursor:
"""Stable cursor for an OpenSpace message.
OpenSpace stores UUIDs. OpenSpace messages can lack UUIDs, so we use a stable
fingerprint plus occurrence number to distinguish repeated equal messages.
"""
fingerprint: str
occurrence: int
@dataclass(slots=True)
class SessionMemoryExtractionResult:
"""Auditable result for tests and runtime events."""
ran: bool = False
skipped_reason: str | None = None
memory_path: str | None = None
turn_count: int = 0
edited: bool = False
error: str | None = None
duration_ms: float = 0.0
@dataclass(slots=True)
class SessionMemoryRuntimeState:
"""Per-session runtime cursors and threshold bookkeeping."""
last_memory_message_cursor: MessageCursor | None = None
last_summarized_message_cursor: MessageCursor | None = None
extraction_started_at_ms: float | None = None
tokens_at_last_extraction: int = 0
initialized: bool = False
AppendSystemMessageFn = Callable[[dict[str, Any]], Awaitable[None] | None]
SessionMemoryCanUseToolFn = Callable[
[BaseTool | None, Mapping[str, Any]],
Awaitable[dict[str, Any]],
]
_session_memory_config = DEFAULT_SESSION_MEMORY_CONFIG
_session_memory_states: dict[str, SessionMemoryRuntimeState] = {}
_session_memory_singleton: "SessionMemory | None" = None
DEFAULT_SESSION_MEMORY_TEMPLATE = """
# Session Title
_A short and distinctive 5-10 word descriptive title for the session. Super info dense, no filler_
# Current State
_What is actively being worked on right now? Pending tasks not yet completed. Immediate next steps._
# Task specification
_What did the user ask to build? Any design decisions or other explanatory context_
# Files and Functions
_What are the important files? In short, what do they contain and why are they relevant?_
# Workflow
_What bash commands are usually run and in what order? How to interpret their output if not obvious?_
# Errors & Corrections
_Errors encountered and how they were fixed. What did the user correct? What approaches failed and should not be tried again?_
# Codebase and System Documentation
_What are the important system components? How do they work/fit together?_
# Learnings
_What has worked well? What has not? What to avoid? Do not duplicate items from other sections_
# Key results
_If the user asked a specific output such as an answer to a question, a table, or other document, repeat the exact result here_
# Worklog
_Step by step, what was attempted, done? Very terse summary for each step_
"""
def get_default_update_prompt() -> str:
return f"""IMPORTANT: This message and these instructions are NOT part of the actual user conversation. Do NOT include any references to "note-taking", "session notes extraction", or these update instructions in the notes content.
Based on the user conversation above (EXCLUDING this note-taking instruction message as well as system prompt, OPENSPACE.md entries, or any past session summaries), update the session notes file.
The file {{{{notesPath}}}} has already been read for you. Here are its current contents:
<current_notes_content>
{{{{currentNotes}}}}
</current_notes_content>
Your ONLY task is to use the Edit tool to update the notes file, then stop. You can make multiple edits (update every section as needed) - make all Edit tool calls in parallel in a single message. Do not call any other tools.
CRITICAL RULES FOR EDITING:
- The file must maintain its exact structure with all sections, headers, and italic descriptions intact
-- NEVER modify, delete, or add section headers (the lines starting with '#' like # Task specification)
-- NEVER modify or delete the italic _section description_ lines (these are the lines in italics immediately following each header - they start and end with underscores)
-- The italic _section descriptions_ are TEMPLATE INSTRUCTIONS that must be preserved exactly as-is - they guide what content belongs in each section
-- ONLY update the actual content that appears BELOW the italic _section descriptions_ within each existing section
-- Do NOT add any new sections, summaries, or information outside the existing structure
- Do NOT reference this note-taking process or instructions anywhere in the notes
- It's OK to skip updating a section if there are no substantial new insights to add. Do not add filler content like "No info yet", just leave sections blank/unedited if appropriate.
- Write DETAILED, INFO-DENSE content for each section - include specifics like file paths, function names, error messages, exact commands, technical details, etc.
- For "Key results", include the complete, exact output the user requested (e.g., full table, full answer, etc.)
- Do not include information that's already in the OPENSPACE.md files included in the context
- Keep each section under ~{MAX_SECTION_LENGTH} tokens/words - if a section is approaching this limit, condense it by cycling out less important details while preserving the most critical information
- Focus on actionable, specific information that would help someone understand or recreate the work discussed in the conversation
- IMPORTANT: Always update "Current State" to reflect the most recent work - this is critical for continuity after compaction
Use the Edit tool with file_path: {{{{notesPath}}}}
STRUCTURE PRESERVATION REMINDER:
Each section has TWO parts that must be preserved exactly as they appear in the current file:
1. The section header (line starting with #)
2. The italic description line (the _italicized text_ immediately after the header - this is a template instruction)
You ONLY update the actual content that comes AFTER these two preserved lines. The italic description lines starting and ending with underscores are part of the template structure, NOT content to be edited or removed.
REMEMBER: Use the Edit tool in parallel and stop. Do not continue after the edits. Only include insights from the actual user conversation, never from these note-taking instructions. Do not delete or change section headers or italic _section descriptions_."""
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 is_session_memory_enabled() -> bool:
"""Return whether session-memory extraction is enabled.
Explicit env vars are the local configuration surface. The default is on
because extraction is still guarded by the 10k/5k token thresholds.
"""
if _env_truthy(os.environ.get(OPENSPACE_DISABLE_SESSION_MEMORY_ENV)):
return False
enabled = os.environ.get(OPENSPACE_SESSION_MEMORY_ENABLED_ENV)
if _env_defined_falsy(enabled):
return False
if _env_truthy(enabled):
return True
return True
def get_session_memory_sessions_dir(sessions_dir: str | Path | None = None) -> Path:
raw = sessions_dir or os.environ.get(OPENSPACE_SESSION_MEMORY_SESSIONS_DIR_ENV)
if raw:
return Path(raw).expanduser().resolve()
return get_openspace_config_home_dir() / "sessions"
def _sanitize_session_id(session_id: str | None, *, cwd: str | Path | None = None) -> str:
raw = str(session_id or "").strip()
if raw and re.fullmatch(r"[A-Za-z0-9_.:-]+", raw):
return raw
fallback = raw or str(cwd or os.getcwd())
return hashlib.sha256(fallback.encode("utf-8")).hexdigest()[:32]
def get_session_memory_dir(
session_id: str | None,
*,
cwd: str | Path | None = None,
sessions_dir: str | Path | None = None,
) -> Path:
"""Return ``~/.openspace/sessions/<session_id>/`` for session memory."""
return get_session_memory_sessions_dir(sessions_dir) / _sanitize_session_id(
session_id,
cwd=cwd,
)
def get_session_memory_path(
session_id: str | None,
*,
cwd: str | Path | None = None,
sessions_dir: str | Path | None = None,
) -> Path:
return get_session_memory_dir(
session_id,
cwd=cwd,
sessions_dir=sessions_dir,
) / SESSION_MEMORY_FILENAME
def get_session_memory_path_for_context(context: ToolUseContext) -> Path:
session_dir = getattr(context, "session_dir", None)
if session_dir:
return Path(session_dir).expanduser().resolve() / SESSION_MEMORY_FILENAME
return get_session_memory_path(context.session_id, cwd=context.cwd)
def get_session_transcript_path(
session_id: str | None,
*,
cwd: str | Path | None = None,
sessions_dir: str | Path | None = None,
) -> Path:
"""Return the OS session transcript path when a session id is known."""
sanitized = _sanitize_session_id(session_id, cwd=cwd)
return get_session_memory_sessions_dir(sessions_dir) / f"{sanitized}.messages"
def load_session_memory_template() -> str:
template_path = (
get_openspace_config_home_dir()
/ SESSION_MEMORY_CONFIG_DIR
/ SESSION_MEMORY_TEMPLATE_PATH[0]
/ SESSION_MEMORY_TEMPLATE_PATH[1]
)
try:
return template_path.read_text(encoding="utf-8")
except FileNotFoundError:
return DEFAULT_SESSION_MEMORY_TEMPLATE
except OSError:
logger.debug("Failed to read session memory template", exc_info=True)
return DEFAULT_SESSION_MEMORY_TEMPLATE
def load_session_memory_prompt() -> str:
prompt_path = (
get_openspace_config_home_dir()
/ SESSION_MEMORY_CONFIG_DIR
/ SESSION_MEMORY_PROMPT_PATH[0]
/ SESSION_MEMORY_PROMPT_PATH[1]
)
try:
return prompt_path.read_text(encoding="utf-8")
except FileNotFoundError:
return get_default_update_prompt()
except OSError:
logger.debug("Failed to read session memory prompt", exc_info=True)
return get_default_update_prompt()
def _rough_token_count(text: str) -> int:
return round(len(text) / 4)
def _analyze_section_sizes(content: str) -> dict[str, int]:
sections: dict[str, int] = {}
current_section = ""
current_content: list[str] = []
for line in content.split("\n"):
if line.startswith("# "):
if current_section and current_content:
sections[current_section] = _rough_token_count(
"\n".join(current_content).strip()
)
current_section = line
current_content = []
else:
current_content.append(line)
if current_section and current_content:
sections[current_section] = _rough_token_count(
"\n".join(current_content).strip()
)
return sections
def _generate_section_reminders(
section_sizes: Mapping[str, int],
total_tokens: int,
) -> str:
over_budget = total_tokens > MAX_TOTAL_SESSION_MEMORY_TOKENS
oversized = sorted(
(
(section, tokens)
for section, tokens in section_sizes.items()
if tokens > MAX_SECTION_LENGTH
),
key=lambda item: item[1],
reverse=True,
)
if not oversized and not over_budget:
return ""
parts: list[str] = []
if over_budget:
parts.append(
f"\n\nCRITICAL: The session memory file is currently ~{total_tokens} tokens, "
f"which exceeds the maximum of {MAX_TOTAL_SESSION_MEMORY_TOKENS} tokens. "
'You MUST condense the file to fit within this budget. Aggressively shorten oversized sections by removing less important details, merging related items, and summarizing older entries. Prioritize keeping "Current State" and "Errors & Corrections" accurate and detailed.'
)
if oversized:
rendered = "\n".join(
f'- "{section}" is ~{tokens} tokens (limit: {MAX_SECTION_LENGTH})'
for section, tokens in oversized
)
parts.append(
"\n\n"
+ (
"Oversized sections to condense"
if over_budget
else "IMPORTANT: The following sections exceed the per-section limit and MUST be condensed"
)
+ ":\n"
+ rendered
)
return "".join(parts)
def _substitute_variables(template: str, variables: Mapping[str, str]) -> str:
def repl(match: re.Match[str]) -> str:
key = match.group(1)
return variables[key] if key in variables else match.group(0)
return re.sub(r"\{\{(\w+)\}\}", repl, template)
def build_session_memory_update_prompt(current_notes: str, notes_path: str) -> str:
prompt_template = load_session_memory_prompt()
section_sizes = _analyze_section_sizes(current_notes)
total_tokens = _rough_token_count(current_notes)
section_reminders = _generate_section_reminders(section_sizes, total_tokens)
base_prompt = _substitute_variables(
prompt_template,
{
"currentNotes": current_notes,
"notesPath": notes_path,
},
)
return base_prompt + section_reminders
def is_session_memory_empty(content: str) -> bool:
return content.strip() == load_session_memory_template().strip()
def truncate_session_memory_for_compact(content: str) -> tuple[str, bool]:
lines = content.split("\n")
max_chars_per_section = MAX_SECTION_LENGTH * 4
output_lines: list[str] = []
current_header = ""
current_lines: list[str] = []
was_truncated = False
for line in lines:
if line.startswith("# "):
flushed, truncated = _flush_session_section(
current_header,
current_lines,
max_chars_per_section,
)
output_lines.extend(flushed)
was_truncated = was_truncated or truncated
current_header = line
current_lines = []
else:
current_lines.append(line)
flushed, truncated = _flush_session_section(
current_header,
current_lines,
max_chars_per_section,
)
output_lines.extend(flushed)
was_truncated = was_truncated or truncated
return "\n".join(output_lines), was_truncated
def _flush_session_section(
section_header: str,
section_lines: Sequence[str],
max_chars_per_section: int,
) -> tuple[list[str], bool]:
if not section_header:
return list(section_lines), False
section_content = "\n".join(section_lines)
if len(section_content) <= max_chars_per_section:
return [section_header, *section_lines], False
char_count = 0
kept = [section_header]
for line in section_lines:
if char_count + len(line) + 1 > max_chars_per_section:
break
kept.append(line)
char_count += len(line) + 1
kept.append("\n[... section truncated for length ...]")
return kept, True
def get_session_memory_config() -> SessionMemoryConfig:
return _session_memory_config
def set_session_memory_config(config: SessionMemoryConfig | Mapping[str, int]) -> None:
global _session_memory_config
if isinstance(config, SessionMemoryConfig):
_session_memory_config = config
return
current = _session_memory_config
_session_memory_config = SessionMemoryConfig(
minimum_message_tokens_to_init=int(
config.get(
"minimum_message_tokens_to_init",
config.get("minimumMessageTokensToInit", current.minimum_message_tokens_to_init),
)
),
minimum_tokens_between_update=int(
config.get(
"minimum_tokens_between_update",
config.get("minimumTokensBetweenUpdate", current.minimum_tokens_between_update),
)
),
tool_calls_between_updates=int(
config.get(
"tool_calls_between_updates",
config.get("toolCallsBetweenUpdates", current.tool_calls_between_updates),
)
),
)
def resolve_session_memory_state_key(context: Any) -> str:
"""Return the stable key used for this session's memory runtime state."""
return resolve_memory_task_scope_key(context)
def get_session_memory_runtime_state(context: Any) -> SessionMemoryRuntimeState:
key = resolve_session_memory_state_key(context)
state = _session_memory_states.get(key)
if state is None:
state = SessionMemoryRuntimeState()
_session_memory_states[key] = state
return state
def mark_extraction_started(context: Any) -> None:
get_session_memory_runtime_state(context).extraction_started_at_ms = (
time.time() * 1000
)
def mark_extraction_completed(
context: Any,
token_count: int | None = None,
) -> None:
state = get_session_memory_runtime_state(context)
state.extraction_started_at_ms = None
if token_count is not None:
state.tokens_at_last_extraction = int(token_count)
def _active_extraction_starts(context: Any | None) -> list[float]:
if context is not None:
started = get_session_memory_runtime_state(context).extraction_started_at_ms
return [] if started is None else [started]
return [
started
for state in _session_memory_states.values()
if (started := state.extraction_started_at_ms) is not None
]
async def wait_for_session_memory_extraction(context: Any | None = None) -> None:
"""Wait for in-flight extraction, with OpenSpace's timeout and stale caps."""
started = time.time() * 1000
while starts := _active_extraction_starts(context):
age = time.time() * 1000 - min(starts)
if age > EXTRACTION_STALE_THRESHOLD_MS:
return
if time.time() * 1000 - started > EXTRACTION_WAIT_TIMEOUT_MS:
return
await asyncio.sleep(EXTRACTION_POLL_INTERVAL_MS / 1000)
def read_session_memory(
session_id: str | None,
*,
cwd: str | Path | None = None,
sessions_dir: str | Path | None = None,
) -> str | None:
path = get_session_memory_path(session_id, cwd=cwd, sessions_dir=sessions_dir)
try:
content = path.read_text(encoding="utf-8")
except FileNotFoundError:
return None
except OSError:
logger.debug("Failed to read session memory", exc_info=True)
return None
return content
def write_session_memory(
session_id: str | None,
content: str,
*,
cwd: str | Path | None = None,
sessions_dir: str | Path | None = None,
) -> Path:
path = get_session_memory_path(session_id, cwd=cwd, sessions_dir=sessions_dir)
path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
path.write_text(content, encoding="utf-8")
try:
os.chmod(path, 0o600)
except OSError:
pass
return path
def setup_session_memory_file(context: ToolUseContext) -> tuple[Path, str]:
path = get_session_memory_path_for_context(context)
path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
if not path.exists():
path.write_text(load_session_memory_template(), encoding="utf-8")
try:
os.chmod(path, 0o600)
except OSError:
pass
current = path.read_text(encoding="utf-8")
try:
stat = path.stat()
timestamp = stat.st_mtime
except OSError:
timestamp = time.time()
context.read_file_state[str(path.resolve())] = ReadFileEntry(
content=current,
timestamp=timestamp,
)
return path, current
def has_met_initialization_threshold(current_token_count: int) -> bool:
return (
current_token_count
>= _session_memory_config.minimum_message_tokens_to_init
)
def has_met_update_threshold(context: Any, current_token_count: int) -> bool:
state = get_session_memory_runtime_state(context)
return (
current_token_count - state.tokens_at_last_extraction
>= _session_memory_config.minimum_tokens_between_update
)
def get_tool_calls_between_updates() -> int:
return _session_memory_config.tool_calls_between_updates
def get_last_summarized_message_id(context: Any) -> MessageCursor | None:
return get_session_memory_runtime_state(context).last_summarized_message_cursor
def set_last_summarized_message_id(
context: Any,
cursor: MessageCursor | None,
) -> None:
get_session_memory_runtime_state(context).last_summarized_message_cursor = cursor
def reset_session_memory_state() -> None:
global _session_memory_config
_session_memory_config = DEFAULT_SESSION_MEMORY_CONFIG
_session_memory_states.clear()
if _session_memory_singleton is not None:
_session_memory_singleton._reset_runtime_state()
def message_cursor_for_index(
messages: Sequence[Mapping[str, Any]],
index: int,
) -> MessageCursor:
fingerprint = _message_fingerprint(messages[index])
occurrence = 0
for item in messages[: index + 1]:
if _message_fingerprint(item) == fingerprint:
occurrence += 1
return MessageCursor(fingerprint=fingerprint, occurrence=occurrence)
def find_message_cursor_index(
messages: Sequence[Mapping[str, Any]],
cursor: MessageCursor | 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 _message_fingerprint(message: Mapping[str, Any]) -> str:
meta = message.get("_meta")
if isinstance(meta, Mapping):
for key in ("uuid", "message_uuid", "id", "response_id"):
value = meta.get(key)
if isinstance(value, str) and value:
return f"meta:{key}:{value}"
for key in ("uuid", "id", "tool_call_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, ensure_ascii=False, default=str)
return "sha256:" + hashlib.sha256(raw.encode("utf-8")).hexdigest()
def count_tool_calls_since(
messages: Sequence[Mapping[str, Any]],
since_cursor: MessageCursor | None,
) -> int:
start_index = find_message_cursor_index(messages, since_cursor)
count = 0
for message in messages[start_index + 1 :]:
if message.get("role") != "assistant":
continue
tool_calls = message.get("tool_calls")
if isinstance(tool_calls, Sequence) and not isinstance(
tool_calls,
(str, bytes, bytearray),
):
count += len(tool_calls)
content = message.get("content")
if isinstance(content, Sequence) and not isinstance(
content,
(str, bytes, bytearray),
):
count += sum(
1
for block in content
if isinstance(block, Mapping) and block.get("type") == "tool_use"
)
return count
def should_extract_memory(
messages: Sequence[Mapping[str, Any]],
context: Any,
) -> bool:
from openspace.services.conversation.compact import token_count_with_estimation
state = get_session_memory_runtime_state(context)
current_token_count = token_count_with_estimation(messages)
if not state.initialized:
if not has_met_initialization_threshold(current_token_count):
return False
state.initialized = True
has_token_threshold = (
current_token_count - state.tokens_at_last_extraction
>= _session_memory_config.minimum_tokens_between_update
)
tool_calls_since = count_tool_calls_since(
messages,
state.last_memory_message_cursor,
)
has_tool_threshold = tool_calls_since >= get_tool_calls_between_updates()
has_tool_calls_in_last_turn = has_tool_calls_in_last_assistant_turn(messages)
should_extract = (has_token_threshold and has_tool_threshold) or (
has_token_threshold and not has_tool_calls_in_last_turn
)
if should_extract and messages:
state.last_memory_message_cursor = message_cursor_for_index(
messages,
len(messages) - 1,
)
return True
return False
def should_schedule_session_memory(context: ToolUseContext) -> bool:
if _should_skip_context(context):
return False
if not getattr(context, "session_id", None):
return False
if _env_truthy(os.environ.get(OPENSPACE_REMOTE_ENV)):
return False
if not is_session_memory_enabled():
return False
try:
from openspace.services.conversation.compact import is_auto_compact_enabled
if not is_auto_compact_enabled():
return False
except Exception:
pass
return True
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",
"session_memory",
}:
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_session_memory_skipped(
context: ToolUseContext,
reason: str,
**extra: Any,
) -> None:
await context.emit_event(
"session_memory_extraction_skipped",
{
"task_id": _background_task_id(context, "session_memory"),
"reason": reason,
**extra,
},
)
class SessionMemory:
"""Stateful session memory extractor.
One singleton coordinates background tasks; runtime cursors are session-scoped.
"""
def __init__(self, *, max_turns: int = DEFAULT_MAX_SESSION_MEMORY_TURNS) -> None:
self.max_turns = max(1, int(max_turns))
self._in_flight: set[asyncio.Task[SessionMemoryExtractionResult]] = set()
self._task_scope_keys: dict[
asyncio.Task[SessionMemoryExtractionResult],
str | None,
] = {}
self._in_progress_session_keys: set[str] = set()
self._pending_contexts: dict[
str,
tuple[
ToolUseContext,
AppendSystemMessageFn | None,
bool,
],
] = {}
def _reset_runtime_state(self) -> None:
self._in_progress_session_keys.clear()
self._pending_contexts.clear()
self._task_scope_keys.clear()
async def extract(
self,
context: ToolUseContext,
append_system_message: AppendSystemMessageFn | None = None,
*,
force: bool = False,
) -> SessionMemoryExtractionResult:
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._extract_impl(context, append_system_message, force=force)
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,
*,
force: bool = False,
) -> asyncio.Task[SessionMemoryExtractionResult]:
task = asyncio.create_task(
self.extract(context, append_system_message, force=force)
)
self._in_flight.add(task)
self._task_scope_keys[task] = maybe_memory_task_scope_key(context)
def _done(done: asyncio.Task[SessionMemoryExtractionResult]) -> 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 session memory extraction 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:
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("Session memory extraction failed during drain", exc_info=True)
return len(_pending)
async def _extract_impl(
self,
context: ToolUseContext,
append_system_message: AppendSystemMessageFn | None,
*,
force: bool,
) -> SessionMemoryExtractionResult:
if not should_schedule_session_memory(context):
await _emit_session_memory_skipped(context, "disabled")
return SessionMemoryExtractionResult(skipped_reason="disabled")
if not force and not should_extract_memory(
list(context.messages or []),
context,
):
await _emit_session_memory_skipped(context, "threshold")
return SessionMemoryExtractionResult(skipped_reason="threshold")
session_key = resolve_session_memory_state_key(context)
if session_key in self._in_progress_session_keys:
self._pending_contexts[session_key] = (
context,
append_system_message,
force,
)
await context.emit_event("session_memory_extraction_coalesced", {})
await _emit_session_memory_skipped(context, "coalesced")
return SessionMemoryExtractionResult(skipped_reason="coalesced")
return await self._run_extraction_chain(
context,
append_system_message,
force=force,
session_key=session_key,
)
async def _run_extraction_chain(
self,
context: ToolUseContext,
append_system_message: AppendSystemMessageFn | None,
*,
force: bool,
session_key: str,
) -> SessionMemoryExtractionResult:
self._in_progress_session_keys.add(session_key)
try:
result = await self._run_extraction(context, append_system_message)
while trailing := self._pending_contexts.pop(session_key, None):
await context.emit_event("session_memory_extraction_trailing_start", {})
await self._run_extraction(trailing[0], trailing[1])
return result
finally:
self._in_progress_session_keys.discard(session_key)
async def _run_extraction(
self,
context: ToolUseContext,
append_system_message: AppendSystemMessageFn | None,
) -> SessionMemoryExtractionResult:
start = time.time()
mark_extraction_started(context)
memory_path: Path | None = None
turn_count = 0
completed_token_count: int | None = None
try:
llm_client = context.llm_client
if llm_client is None or not hasattr(llm_client, "call_model"):
await _emit_session_memory_skipped(context, "missing_llm_client")
return SessionMemoryExtractionResult(skipped_reason="missing_llm_client")
setup_context = _build_session_memory_context(
parent=context,
tools=[],
messages=list(context.messages or []),
)
memory_path, current_memory = setup_session_memory_file(setup_context)
prompt = build_session_memory_update_prompt(
current_memory,
str(memory_path),
)
tools = _select_session_memory_tools(context)
if not tools:
await _emit_session_memory_skipped(
context,
"missing_tools",
memory_path=str(memory_path),
)
return SessionMemoryExtractionResult(
skipped_reason="missing_tools",
memory_path=str(memory_path),
)
gate = create_memory_file_can_use_tool(memory_path)
read_file_state = dict(context.read_file_state)
read_file_state[str(memory_path.resolve())] = (
setup_context.read_file_state[str(memory_path.resolve())]
)
model_override = os.environ.get(OPENSPACE_MEMORY_SESSION_MODEL_ENV) or None
side_result = await run_side_query(
prompt,
tools=tools,
model=model_override,
parent_context=context,
llm_client=llm_client,
messages=list(context.messages or []),
max_turns=self.max_turns,
can_use_tool=gate,
query_source="session_memory",
fork_label="session_memory",
agent_type="session_memory",
denied_result_type="session_memory_tool_denied",
read_file_state=read_file_state,
tui_available=False,
is_async_agent=True,
)
turn_count = side_result.turn_count
total_usage = side_result.total_usage
result_messages = side_result.messages
current_tokens = _token_count_with_estimation_safe(context.messages or [])
completed_token_count = current_tokens
_update_last_summarized_message_id_if_safe(context, context.messages or [])
edited = _session_memory_was_edited(result_messages, memory_path)
duration_ms = (time.time() - start) * 1000
await context.emit_event(
"session_memory_extraction_complete",
{
"task_id": _background_task_id(context, "session_memory"),
"memory_path": str(memory_path),
"turn_count": turn_count,
"edited": edited,
"input_tokens": total_usage.input_tokens,
"output_tokens": total_usage.output_tokens,
"duration_ms": duration_ms,
},
)
await _append_session_memory_message(
context,
memory_path,
append_system_message,
edited=edited,
)
return SessionMemoryExtractionResult(
ran=True,
memory_path=str(memory_path),
turn_count=turn_count,
edited=edited,
duration_ms=duration_ms,
)
except Exception as exc:
duration_ms = (time.time() - start) * 1000
logger.debug("Session memory extraction error: %s", exc, exc_info=True)
await context.emit_event(
"session_memory_extraction_error",
{
"task_id": _background_task_id(context, "session_memory"),
"memory_path": str(memory_path) if memory_path else None,
"error": str(exc),
"duration_ms": duration_ms,
},
)
return SessionMemoryExtractionResult(
ran=True,
memory_path=str(memory_path) if memory_path else None,
turn_count=turn_count,
duration_ms=duration_ms,
error=str(exc),
)
finally:
mark_extraction_completed(context, completed_token_count)
def get_session_memory() -> SessionMemory:
global _session_memory_singleton
if _session_memory_singleton is None:
_session_memory_singleton = SessionMemory()
return _session_memory_singleton
def init_session_memory() -> SessionMemory:
"""Return the singleton for parity with OpenSpace's synchronous initializer."""
return get_session_memory()
async def extract_session_memory(
context: ToolUseContext,
append_system_message: AppendSystemMessageFn | None = None,
*,
force: bool = False,
) -> SessionMemoryExtractionResult:
return await get_session_memory().extract(
context,
append_system_message,
force=force,
)
def submit_session_memory_extraction(
context: ToolUseContext,
append_system_message: AppendSystemMessageFn | None = None,
*,
force: bool = False,
) -> asyncio.Task[SessionMemoryExtractionResult]:
return get_session_memory().submit(
context,
append_system_message,
force=force,
)
async def drain_pending_session_memory(
timeout_s: float = 60.0,
*,
context: Any | None = None,
scope_key: str | None = None,
) -> int:
return await get_session_memory().drain(
timeout_s,
context=context,
scope_key=scope_key,
)
async def manually_extract_session_memory(
messages: Sequence[Mapping[str, Any]],
context: ToolUseContext,
) -> SessionMemoryExtractionResult:
context.replace_messages([dict(message) for message in messages])
return await extract_session_memory(context, force=True)
def create_memory_file_can_use_tool(
memory_path: str | Path,
) -> SessionMemoryCanUseToolFn:
allowed_path = Path(memory_path).expanduser().resolve()
async def can_use_tool(
tool: BaseTool | None,
input: Mapping[str, Any],
) -> dict[str, Any]:
if tool is None:
return _deny_session_memory_tool(
"unknown",
"Tool is not available to the session-memory extraction agent.",
)
name = getattr(tool, "name", "")
data = dict(input)
if name != FILE_EDIT_TOOL_NAME:
return _deny_session_memory_tool(
name or "unknown",
f"only {FILE_EDIT_TOOL_NAME} on {allowed_path} is allowed",
)
file_path = data.get("file_path")
if isinstance(file_path, str):
try:
candidate = Path(file_path).expanduser()
if not candidate.is_absolute():
candidate = allowed_path.parent / candidate
if candidate.resolve() == allowed_path:
return {"behavior": "allow", "updated_input": data}
except OSError:
pass
return _deny_session_memory_tool(
name,
f"only {FILE_EDIT_TOOL_NAME} on {allowed_path} is allowed",
)
return can_use_tool
def _deny_session_memory_tool(tool_name: str, reason: str) -> dict[str, Any]:
logger.debug("[sessionMemory] denied %s: %s", tool_name, reason)
return {
"behavior": "deny",
"message": reason,
"decision_reason": {"type": "other", "reason": reason},
}
def _select_session_memory_tools(context: ToolUseContext) -> list[BaseTool]:
selected: list[BaseTool] = []
seen: set[str] = set()
for tool in [*(context.all_tools or []), *(context.tools or [])]:
name = getattr(tool, "name", "")
if name == FILE_EDIT_TOOL_NAME and name not in seen:
selected.append(tool)
seen.add(name)
if FILE_EDIT_TOOL_NAME not in seen:
try:
from openspace.grounding.backends.shell.file_tools import FileEditTool
selected.append(FileEditTool())
except Exception:
logger.debug("Could not instantiate FileEditTool for session memory", exc_info=True)
return selected
def _build_session_memory_context(
*,
parent: ToolUseContext,
tools: list[BaseTool],
messages: list[dict[str, Any]],
) -> ToolUseContext:
return ToolUseContext(
tools=list(tools),
all_tools=list(tools),
model=parent.model,
llm_client=parent.llm_client,
cwd=parent.cwd,
original_cwd=parent.original_cwd,
agent_id=f"{parent.agent_id}:session_memory",
agent_type="session_memory",
max_result_size_chars=parent.max_result_size_chars,
abort_event=parent.abort_event,
messages=messages,
read_file_state=dict(parent.read_file_state),
tool_results_token_count=0,
permission_engine=parent.permission_engine,
permission_mode=parent.permission_mode,
permission_context=parent.permission_context,
hook_registry=parent.hook_registry,
tui_available=False,
is_async_agent=True,
event_sink=parent.event_sink,
recording_manager=None,
quality_manager=None,
parent_task_id=parent.parent_task_id,
task_description=parent.task_description,
current_iteration=0,
max_iterations=DEFAULT_MAX_SESSION_MEMORY_TURNS,
session_id=parent.session_id,
session_dir=parent.session_dir,
tool_results_dir=parent.tool_results_dir,
)
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 _token_count_with_estimation_safe(messages: Sequence[Mapping[str, Any]]) -> int:
try:
from openspace.services.conversation.compact import token_count_with_estimation
return token_count_with_estimation(messages)
except Exception:
return sum(_rough_token_count(str(message.get("content", ""))) for message in messages)
def _update_last_summarized_message_id_if_safe(
context: Any,
messages: Sequence[Mapping[str, Any]],
) -> None:
if not messages:
return
if has_tool_calls_in_last_assistant_turn(messages):
return
set_last_summarized_message_id(
context,
message_cursor_for_index(messages, len(messages) - 1)
)
def _session_memory_was_edited(
messages: Sequence[Mapping[str, Any]],
memory_path: Path | None,
) -> bool:
if memory_path is None:
return False
target = str(memory_path.expanduser().resolve())
for message in messages:
if message.get("role") != "assistant":
continue
for call in message.get("tool_calls") or []:
if _tool_call_name(call) != FILE_EDIT_TOOL_NAME:
continue
file_path = _tool_call_input(call).get("file_path")
if isinstance(file_path, str):
try:
if str(Path(file_path).expanduser().resolve()) == target:
return True
except OSError:
continue
return False
async def _append_session_memory_message(
context: ToolUseContext,
memory_path: Path,
append_system_message: AppendSystemMessageFn | None,
*,
edited: bool,
) -> None:
message = {
"role": "system",
"content": (
f"Session memory updated: {memory_path}"
if edited
else f"Session memory checked: {memory_path}"
),
"_meta": {
"type": "session_memory_updated" if edited else "session_memory_checked",
"memory_path": str(memory_path),
"timestamp": time.time(),
},
}
if append_system_message is not None:
result = append_system_message(message)
if inspect.isawaitable(result):
await result
else:
await context.emit_event(
"session_memory_updated" if edited else "session_memory_checked",
{"memory_path": str(memory_path), "edited": edited},
)