fix(mem0-agent): non-blocking stdin, clean session_state, lint

Found by running the CLI live against a scratch project:
- hook_input() blocked forever on an open stdin pipe with no data. A real hook
  writes JSON and closes the pipe, but any manual invocation hung; now polled
  with select() and never blocks.
- the open-thread snapshot was filling with progress lines and file-modification
  lists -- v1's exact failure -- so it now filters turns through the same
  hard-drop rules the capture gate uses.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Deshraj Yadav
2026-07-28 02:12:23 -07:00
parent 4d5f2e653c
commit ba1de5f87b
6 changed files with 76 additions and 9 deletions
+24 -4
View File
@@ -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 {}
@@ -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
@@ -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 []
@@ -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 ""
@@ -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) == ""
@@ -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