diff --git a/integrations/mem0-agent/src/mem0_agent/cli.py b/integrations/mem0-agent/src/mem0_agent/cli.py index ca7e9d924..8723107dd 100644 --- a/integrations/mem0-agent/src/mem0_agent/cli.py +++ b/integrations/mem0-agent/src/mem0_agent/cli.py @@ -24,12 +24,32 @@ from .settings import CAPTURE_LEVELS, MEMORY_MODES, RETRIEVAL_LEVELS, Settings # -------------------------------------------------------------------------- # hook plumbing # -------------------------------------------------------------------------- -def hook_input() -> dict: - """Editors hand hooks a JSON payload on stdin. Absent or malformed is fine.""" - if sys.stdin is None or sys.stdin.isatty(): +def hook_input(timeout: float = 0.25) -> dict: + """Editors hand hooks a JSON payload on stdin. Absent or malformed is fine. + + Never blocks. A hook's stdin is written and closed, but a manual invocation inherits + an open pipe with nothing in it -- reading that would hang the command forever. + """ + stream = sys.stdin + if stream is None or not hasattr(stream, "read"): return {} try: - raw = sys.stdin.read() + if stream.isatty(): + return {} + except Exception: + return {} + + # StringIO and friends (tests) are readable immediately; real pipes get a poll. + if hasattr(stream, "fileno"): + try: + import select + + if not select.select([stream], [], [], timeout)[0]: + return {} + except Exception: + pass # not selectable (e.g. StringIO) -- fall through and read + try: + raw = stream.read() return json.loads(raw) if raw.strip() else {} except Exception: return {} diff --git a/integrations/mem0-agent/src/mem0_agent/maintain.py b/integrations/mem0-agent/src/mem0_agent/maintain.py index 4ca9df35c..9bcb5ee47 100644 --- a/integrations/mem0-agent/src/mem0_agent/maintain.py +++ b/integrations/mem0-agent/src/mem0_agent/maintain.py @@ -12,7 +12,7 @@ from __future__ import annotations import re from dataclasses import dataclass, field from itertools import combinations -from typing import Any, Iterable +from typing import Iterable from .api import expiry_date, results_of from .config import filters as F diff --git a/integrations/mem0-agent/src/mem0_agent/settings.py b/integrations/mem0-agent/src/mem0_agent/settings.py index 9678ff0a8..cf2e8d005 100644 --- a/integrations/mem0-agent/src/mem0_agent/settings.py +++ b/integrations/mem0-agent/src/mem0_agent/settings.py @@ -231,7 +231,7 @@ class SessionState: def read_lines(self, name: str) -> list[dict]: try: - return [json.loads(l) for l in self._p(name).read_text().splitlines() if l.strip()] + return [json.loads(line) for line in self._p(name).read_text().splitlines() if line.strip()] except Exception: return [] diff --git a/integrations/mem0-agent/src/mem0_agent/transcript.py b/integrations/mem0-agent/src/mem0_agent/transcript.py index f31373c2a..e17bca68e 100644 --- a/integrations/mem0-agent/src/mem0_agent/transcript.py +++ b/integrations/mem0-agent/src/mem0_agent/transcript.py @@ -86,10 +86,35 @@ def windows_since(turns: list[dict], processed: int, size: int = 4) -> list[list return [fresh[i:i + size] for i in range(0, len(fresh), size) if fresh[i:i + size]] +MIN_THREAD_WORDS = 4 + + +def _signal_turns(turns: list[dict]) -> list[dict]: + """Drop the same mechanical noise the capture gate drops. + + Without this the open-thread snapshot fills up with progress lines and + "I modified VERSION, cli.py, ..." -- the exact content the audit found made v1's + session summaries worthless. + """ + from .triggers import _turn_drop_reason, natural_words + + kept = [] + for t in turns: + if t.get("tool_only") or not t.get("content"): + continue + if _turn_drop_reason([t]): + continue + if natural_words(t["content"]) < MIN_THREAD_WORDS: + continue # "ok", "good catch" -- acknowledgements, not state + kept.append(t) + return kept + + def summarize_open_thread(turns: list[dict], limit: int = 3) -> str: """A plain-language snapshot for session_state: what we were doing, where it stopped.""" - users = [t["content"] for t in turns if t["role"] == "user" and t["content"]] - assistants = [t["content"] for t in turns if t["role"] == "assistant" and t["content"]] + signal = _signal_turns(turns) + users = [t["content"] for t in signal if t["role"] == "user"] + assistants = [t["content"] for t in signal if t["role"] == "assistant"] if not users and not assistants: return "" goal = users[0][:300] if users else "" diff --git a/integrations/mem0-agent/tests/test_integration.py b/integrations/mem0-agent/tests/test_integration.py index cb6202693..e87143552 100644 --- a/integrations/mem0-agent/tests/test_integration.py +++ b/integrations/mem0-agent/tests/test_integration.py @@ -313,3 +313,26 @@ def test_full_session_produces_few_memories(tmp_path): assert summary["sent"] >= 1 for added in ctx.api.added: assert "epoch" not in added["messages"][0]["content"].lower() + + +# -------------------------------------------------------------------------- +# session_state quality +# -------------------------------------------------------------------------- +def test_open_thread_excludes_mechanical_noise(): + """v1's session summaries were file lists. The snapshot must carry intent instead.""" + turns = [ + {"role": "assistant", "content": HEARTBEAT, "tool_only": False}, + {"role": "user", "content": "Let's get the sandbox e2e suite passing today.", "tool_only": False}, + {"role": "user", "content": "ok", "tool_only": False}, + {"role": "assistant", "content": FILE_LIST, "tool_only": False}, + ] + thread = transcript.summarize_open_thread(turns) + assert "sandbox e2e" in thread + assert "VERSION" not in thread and "chat.py" not in thread + assert "epoch" not in thread.lower() + + +def test_open_thread_is_empty_when_there_is_only_noise(): + turns = [{"role": "assistant", "content": HEARTBEAT, "tool_only": False}, + {"role": "assistant", "content": FILE_LIST, "tool_only": False}] + assert transcript.summarize_open_thread(turns) == "" diff --git a/integrations/mem0-agent/tests/test_maintain.py b/integrations/mem0-agent/tests/test_maintain.py index 0940fc947..aee0e1b9f 100644 --- a/integrations/mem0-agent/tests/test_maintain.py +++ b/integrations/mem0-agent/tests/test_maintain.py @@ -1,6 +1,5 @@ """Consolidation must never lose data, and must never touch a pinned memory.""" -import pytest from mem0_agent import maintain from mem0_agent.maintain import jaccard, tokens