# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. # ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= # Licensed under the Apache License, Version 2.0 (the "License"); """Bounded, failure-independent incremental Memory maintenance.""" from __future__ import annotations import hashlib import logging import re import threading from concurrent.futures import Future, ThreadPoolExecutor from dataclasses import dataclass from typing import Protocol from app.lightweight_memory.service import ( HistoryQueryResult, LightweightMemoryService, count_tokens, format_project_cursor, parse_project_cursor, ) from app.run_journal import MemoryScopeStateRecord logger = logging.getLogger("lightweight_memory") _EXECUTOR = ThreadPoolExecutor(max_workers=2, thread_name_prefix="memory-v2") _FUTURES: set[Future] = set() _SCHEDULE_LOCK = threading.Lock() _PROJECT_SCHEDULES: dict[str, _ProjectMaintenanceSchedule] = {} _MAX_FAILURE_RETRIES = 5 _MAX_RETRY_DELAY_SECONDS = 60.0 _CONTINUATION_DELAY_SECONDS = 0.5 _EXPLICIT_MEMORY_PATTERNS = ( re.compile(r"\b(?:please\s+)?remember(?:\s+that)?\s+(.+)", re.I | re.S), re.compile(r"\bI\s+prefer\s+(.+)", re.I | re.S), re.compile(r"\bmy\s+(?:preference|default)\s+is\s+(.+)", re.I | re.S), re.compile( r"\b(?:for|in)\s+(?:this|the\s+current)\s+" r"(?:project|space|workspace)\s*[:,]?\s*(.+)", re.I | re.S, ), re.compile( r"\b(?:for|across)\s+all\s+(?:projects|spaces)\s*[:,]?\s*(.+)", re.I | re.S, ), re.compile(r"(?:请)?记住[:,:]?\s*(.+)", re.S), re.compile( r"(?:这个|当前)(?:项目|空间|工作区)(?:中|里)?[:,:,]?\s*(.+)", re.S, ), re.compile(r"(?:所有项目|所有空间)(?:中|里)?[:,:,]?\s*(.+)", re.S), re.compile(r"我(?:更)?(?:偏好|喜欢)[:,:,]?\s*(.+)", re.S), ) _USER_SCOPE_MARKERS = ( "across all projects", "across all spaces", "for all projects", "for all spaces", "always remember", "my preference", "my default", "my personal preference", "所有项目", "所有空间", "所有 space", "以后都", "永远记住", "我的偏好", "个人偏好", "我偏好", "我喜欢", ) _SPACE_SCOPE_MARKERS = ( "this space", "current space", "this workspace", "our team", "这个空间", "当前空间", "这个 space", "当前 space", "这个工作区", "当前工作区", "团队", ) @dataclass(frozen=True) class ProposedMemoryMutation: kind: str content: str source_trust: str source_event_ids: tuple[str, ...] confidence: float sensitivity: str = "normal" target_scope: str = "project" @dataclass class _ProjectMaintenanceSchedule: future: Future | None = None timer: threading.Timer | None = None failure_attempts: int = 0 class MemoryExtractor(Protocol): version: str def extract( self, *, active_memory: tuple, history_delta: tuple[HistoryQueryResult, ...], target_scope: str = "project", ) -> tuple[ProposedMemoryMutation, ...]: ... class ConservativeMemoryExtractor: """Extract only explicit user requests; Agent CRUD handles inferred notes. This deliberately avoids turning tool/web text or model prose into durable instructions. A model-backed extractor can implement the same protocol, but must retain these trust and mutation bounds. """ version = "memory-v2-explicit-user-v1" def extract( self, *, active_memory: tuple, history_delta: tuple[HistoryQueryResult, ...], target_scope: str = "project", ) -> tuple[ProposedMemoryMutation, ...]: known = {entry.content.casefold().strip() for entry in active_memory} proposals: list[ProposedMemoryMutation] = [] for item in history_delta: if item.event_type != "user.message": continue raw = item.content.get("content") if not isinstance(raw, str): continue for pattern in _EXPLICIT_MEMORY_PATTERNS: match = pattern.search(raw.strip()) if match is None: continue proposal_scope = self._target_scope(raw, match) if proposal_scope != target_scope: continue content = match.group(1).strip().rstrip() if not content or len(content) > 1000: break normalized = content.casefold() if normalized in known: break matched_text = match.group(0).casefold() kind = ( "preference" if any( marker in matched_text for marker in ("prefer", "preference", "偏好", "喜欢") ) else "fact" ) proposals.append( ProposedMemoryMutation( kind=kind, content=content, source_trust="user_asserted", source_event_ids=(item.event_id,), confidence=1.0, target_scope=proposal_scope, ) ) known.add(normalized) break if len(proposals) == 3: break return tuple(proposals) @staticmethod def _target_scope(raw: str, match: re.Match[str]) -> str: normalized = raw.casefold() if re.match(r"\bi\s+prefer\b", match.group(0), re.I): return "user" if any(marker in normalized for marker in _USER_SCOPE_MARKERS): return "user" if any(marker in normalized for marker in _SPACE_SCOPE_MARKERS): return "space" return "project" class IncrementalMemoryMaintainer: """Advance only after a bounded delta is durably applied. Mutations are deterministic/idempotent. If the process crashes after a mutation but before the watermark CAS, replay returns the original result and cannot duplicate Memory. """ def __init__( self, service: LightweightMemoryService, extractor: MemoryExtractor | None = None, ) -> None: self._service = service self._extractor = extractor or ConservativeMemoryExtractor() def process_project(self, project_id: str) -> MemoryScopeStateRecord: space_id, user_id = self._service.journal.get_memory_project_scopes( project_id ) targets = [("project", project_id)] if space_id: targets.append(("space", space_id)) if user_id: targets.append(("user", user_id)) failures: list[tuple[str, str, Exception]] = [] for scope_type, scope_id in targets: state = self._service.scope(scope_type, scope_id) if not state.capture_enabled: continue try: self._process_target( source_project_id=project_id, scope_type=scope_type, scope_id=scope_id, ) except Exception as exc: self._record_failure( source_project_id=project_id, scope_type=scope_type, scope_id=scope_id, error=exc, ) failures.append((scope_type, scope_id, exc)) if failures: scope_type, scope_id, error = failures[0] raise RuntimeError( f"Memory extraction failed for {scope_type}:{scope_id}: {error}" ) from error return self._service.scope("project", project_id) def _process_target( self, *, source_project_id: str, scope_type: str, scope_id: str, ) -> None: state = self._service.scope(scope_type, scope_id) ratio = state.current_token_count / state.token_limit if ratio >= state.consolidate_threshold and ( state.last_consolidated_at is None or state.updated_at > state.last_consolidated_at ): state = self._service.consolidate_scope( scope_type=scope_type, scope_id=scope_id, reason="automatic bounded Memory consolidation", request_id=( f"memory-auto-consolidate:{scope_type}:{scope_id}:" f"{state.revision}" ), ).scope_state # One terminal trigger may represent a very long Run. Process a # bounded number of pages; the scheduler queues another pass whenever # any enabled target scope remains behind this Project History. for _ in range(10): after = self._target_watermark( source_project_id=source_project_id, scope_type=scope_type, scope_id=scope_id, state=state, ) page = self._service.search_history( project_id=source_project_id, after_cursor=after, limit=100, byte_budget=256 * 1024, token_budget=16384, ) if parse_project_cursor(page.next_cursor) == parse_project_cursor( after ): if page.complete: if scope_type == "project": if state.last_error is not None: state = self._service.journal.record_memory_maintenance_result( scope_type, scope_id, expected_revision=state.revision, processed_through_watermark=page.next_cursor, watermark_kind="journal_cursor", extractor_version=self._extractor.version, ) else: self._service.journal.record_memory_extraction_watermark( target_scope_type=scope_type, target_scope_id=scope_id, source_project_id=source_project_id, processed_through_watermark=page.next_cursor, watermark_kind="journal_cursor", extractor_version=self._extractor.version, ) return raise RuntimeError( "History page exceeded the bounded extraction budget " "before its first event" ) active = self._service.list_entries(scope_type, scope_id) proposals = self._extractor.extract( active_memory=active, history_delta=page.items, target_scope=scope_type, )[:3] projected_tokens = state.current_token_count bounded_proposals: list[ProposedMemoryMutation] = [] for proposal in proposals: proposal_tokens = count_tokens(proposal.content) if ( projected_tokens + proposal_tokens > state.token_limit * 0.9 ): continue bounded_proposals.append(proposal) projected_tokens += proposal_tokens proposals = tuple(bounded_proposals) cursor_from = after or format_project_cursor(0) if not proposals and scope_type == "project": identity = hashlib.sha256( ( f"{self._extractor.version}|{scope_type}|{scope_id}|" f"{source_project_id}|{cursor_from}|" f"{page.next_cursor}|noop" ).encode() ).hexdigest() self._service.journal.apply_memory_mutation( mutation_id=f"mut_{identity[:32]}", idempotency_key=f"memory-extract-noop:{identity}", operation="noop", scope_type=scope_type, scope_id=scope_id, memory_id=None, actor_type="extractor", reason=( "incremental extraction found no durable Memory " f"in {cursor_from}..{page.next_cursor}" ), source_refs=tuple( item.event_id for item in page.items[:32] ), ) for index, proposal in enumerate(proposals): identity = hashlib.sha256( ( f"{self._extractor.version}|{scope_type}|{scope_id}|" f"{source_project_id}|{cursor_from}|" f"{page.next_cursor}|{index}|{proposal.kind}|" f"{proposal.content}" ).encode() ).hexdigest() self._service.create_entry( scope_type=scope_type, scope_id=scope_id, kind=proposal.kind, content=proposal.content, actor_type="extractor", reason=( "incremental extraction " f"{cursor_from}..{page.next_cursor}" ), source_trust=proposal.source_trust, source_refs=proposal.source_event_ids, sensitivity=proposal.sensitivity, request_id=f"memory-extract:{identity}", ) state = self._service.scope(scope_type, scope_id) if scope_type == "project": state = self._service.journal.record_memory_maintenance_result( scope_type, scope_id, expected_revision=state.revision, processed_through_watermark=page.next_cursor, watermark_kind="journal_cursor", extractor_version=self._extractor.version, ) else: self._service.journal.record_memory_extraction_watermark( target_scope_type=scope_type, target_scope_id=scope_id, source_project_id=source_project_id, processed_through_watermark=page.next_cursor, watermark_kind="journal_cursor", extractor_version=self._extractor.version, ) if page.complete: return def _target_watermark( self, *, source_project_id: str, scope_type: str, scope_id: str, state: MemoryScopeStateRecord, ) -> str | None: if scope_type == "project": return state.processed_through_watermark return self._service.journal.get_memory_extraction_watermark( target_scope_type=scope_type, target_scope_id=scope_id, source_project_id=source_project_id, ) def _record_failure( self, *, source_project_id: str, scope_type: str, scope_id: str, error: Exception, ) -> None: try: if scope_type == "project": current = self._service.scope(scope_type, scope_id) self._service.journal.record_memory_maintenance_result( scope_type, scope_id, expected_revision=current.revision, processed_through_watermark=None, watermark_kind=None, extractor_version=self._extractor.version, last_error=str(error), ) else: self._service.journal.record_memory_extraction_watermark( target_scope_type=scope_type, target_scope_id=scope_id, source_project_id=source_project_id, processed_through_watermark=None, watermark_kind=None, extractor_version=self._extractor.version, last_error=str(error), ) except Exception: logger.exception( "Failed to record Memory extraction error", extra={"scope_type": scope_type, "scope_id": scope_id}, ) def _maintenance_retry_delay(failure_attempts: int) -> float | None: if failure_attempts < 1 or failure_attempts > _MAX_FAILURE_RETRIES: return None return min(2 ** (failure_attempts - 1), _MAX_RETRY_DELAY_SECONDS) def _memory_maintenance_is_behind( service: LightweightMemoryService, project_id: str ) -> bool: available = service.journal.get_project_history_cursor(project_id) project_state = service.scope("project", project_id) if project_state.capture_enabled and ( parse_project_cursor(project_state.processed_through_watermark) < available ): return True space_id, user_id = service.journal.get_memory_project_scopes(project_id) for scope_type, scope_id in (("space", space_id), ("user", user_id)): if not scope_id: continue state = service.scope(scope_type, scope_id) if not state.capture_enabled: continue watermark = service.journal.get_memory_extraction_watermark( target_scope_type=scope_type, target_scope_id=scope_id, source_project_id=project_id, ) if parse_project_cursor(watermark) < available: return True return False def _submit_project_memory_maintenance(project_id: str) -> None: """Submit one pass, coalescing all triggers for the same Project.""" from app.lightweight_memory.service import get_lightweight_memory_service with _SCHEDULE_LOCK: state = _PROJECT_SCHEDULES.setdefault( project_id, _ProjectMaintenanceSchedule() ) state.timer = None # A completed Future remains the lane owner until its callback clears # it. Treating future.done() as idle opens a submit/callback race. if state.future is not None: return # Reserve the Project lane while the scheduler lock is held. Without # this, two terminal notifications can both observe an empty lane and # submit concurrent extraction passes for the same durable cursor. future = _EXECUTOR.submit( IncrementalMemoryMaintainer( get_lightweight_memory_service() ).process_project, project_id, ) state.future = future _FUTURES.add(future) def _done(completed: Future) -> None: _FUTURES.discard(completed) with _SCHEDULE_LOCK: current = _PROJECT_SCHEDULES.setdefault( project_id, _ProjectMaintenanceSchedule() ) if current.future is completed: current.future = None try: scope_state = completed.result() if scope_state.last_error is not None: with _SCHEDULE_LOCK: current.failure_attempts += 1 failure_attempts = current.failure_attempts delay = _maintenance_retry_delay(failure_attempts) if delay is not None: logger.warning( "Incremental Memory maintenance returned an error", extra={ "project_id": project_id, "failure_attempts": failure_attempts, "last_error": scope_state.last_error, }, ) _schedule_project_memory_maintenance_after( project_id, delay ) else: with _SCHEDULE_LOCK: _PROJECT_SCHEDULES.pop(project_id, None) logger.error( "Incremental Memory maintenance retry budget exhausted", extra={ "project_id": project_id, "last_error": scope_state.last_error, }, ) return behind = _memory_maintenance_is_behind( get_lightweight_memory_service(), project_id ) if behind: with _SCHEDULE_LOCK: current.failure_attempts = 0 _schedule_project_memory_maintenance_after( project_id, _CONTINUATION_DELAY_SECONDS ) else: with _SCHEDULE_LOCK: _PROJECT_SCHEDULES.pop(project_id, None) except Exception: with _SCHEDULE_LOCK: current.failure_attempts += 1 failure_attempts = current.failure_attempts logger.exception( "Incremental Memory maintenance failed", extra={ "project_id": project_id, "failure_attempts": failure_attempts, }, ) delay = _maintenance_retry_delay(failure_attempts) if delay is not None: _schedule_project_memory_maintenance_after(project_id, delay) else: with _SCHEDULE_LOCK: _PROJECT_SCHEDULES.pop(project_id, None) logger.error( "Incremental Memory maintenance retry budget exhausted", extra={"project_id": project_id}, ) future.add_done_callback(_done) def _schedule_project_memory_maintenance_after( project_id: str, delay_seconds: float ) -> None: with _SCHEDULE_LOCK: state = _PROJECT_SCHEDULES.setdefault( project_id, _ProjectMaintenanceSchedule() ) if state.future is not None: return if state.timer is not None and state.timer.is_alive(): return timer = threading.Timer( delay_seconds, _submit_project_memory_maintenance, (project_id,) ) timer.daemon = True state.timer = timer timer.start() def schedule_project_memory_maintenance(project_id: str) -> None: """Best-effort terminal trigger with bounded retry and continuation pace.""" normalized = project_id.strip() if not normalized: return with _SCHEDULE_LOCK: state = _PROJECT_SCHEDULES.setdefault( normalized, _ProjectMaintenanceSchedule() ) active = state.future is not None or ( state.timer is not None and state.timer.is_alive() ) if active: return # A later terminal Run is a new trigger and gets a fresh bounded retry # budget even when an earlier pass exhausted its own attempts. state.failure_attempts = 0 _submit_project_memory_maintenance(normalized)