Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,12 @@ a decision with visible consequences.
`user` or `owner_statement`) is no longer taken out of recall because of one that is not: the
conflict is still recorded, and only the other memory goes to review.

- **A write is not lost to a memory under review.** `POST /memory/write` took a memory with the
same text as its duplicate whatever its status, so a write equal to a memory under review or
expired answered ok with `duplicate_of` and nothing became active; it also looked only at the
newest 100. Now only an active memory of the agent counts, however old, and two writes of one
text are serialised (Postgres).

- **"Don't remember" keeps no words.** A conversation message the owner asked not to keep
("don't remember ...", "не запоминай ...") used to be stored word for word as evidence (only
the memory was skipped). Its evidence now holds no text (`normalized_payload` is
Expand Down
33 changes: 17 additions & 16 deletions services/api/app/routes/memory.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
from app.services.agent_config_service import get_or_create_config
from app.services.classifier import CURRENT_MEMORY_TYPES, classify_memory, normalize_memory_type
from app.services.lexical_retrieval import escape_like, memory_candidates, prepare_query
from app.services.memory_text_lock import lock_memory_text
from app.services.memory_truth import add_revision, detect_conflicts
from app.services.qdrant_store import (
INDEX_UNREACHABLE,
Expand Down Expand Up @@ -245,23 +246,23 @@ def write_memory(payload: MemoryWriteRequest, header_agent_id: str = Depends(get
final_do_not_generalize = payload.do_not_generalize if payload.do_not_generalize is not None else False
final_review_by = _default_review_by(final_memory_type, parse_timestamp(payload.review_by, "review_by"))

normalized_text = payload.text.strip().lower()
existing = db.execute(
# Exact duplicate: the same text as an active memory of this agent, however old. A memory
# under review or expired is not a duplicate (the write would be lost); two writes of
# one text are serialised until this transaction commits.
lock_memory_text(db, agent_id, payload.text)
duplicate = db.execute(
select(Memory)
.where(Memory.agent_id == agent_id)
.order_by(Memory.created_at.desc())
.limit(100)
).scalars().all()

# exact duplicate
for row in existing:
if row.text.strip().lower() == normalized_text:
return _upgrade_existing_memory(
db, row, payload,
final_memory_type,
final_confidence,
final_summary,
)
.where(Memory.agent_id == agent_id, Memory.status == "active",
func.lower(func.trim(Memory.text)) == payload.text.strip().lower())
.order_by(Memory.created_at.asc())
).scalars().first()
if duplicate is not None:
return _upgrade_existing_memory(
db, duplicate, payload,
final_memory_type,
final_confidence,
final_summary,
)

# Novelty check via vector similarity, scoped to this agent. Without a
# working vector path there is no near-duplicate signal at all, so the
Expand Down
33 changes: 33 additions & 0 deletions services/api/tests/test_write_dedup_active.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
"""A write matches only an active memory as its duplicate, however old that memory is."""

from datetime import UTC, datetime, timedelta

from app.models.memory import Memory
from memory_stack import stack as stack

TEXT = "I prefer the release checklist printed on paper"


def test_a_write_equal_to_a_memory_under_review_makes_an_active_memory(stack):
with stack.sessions() as db:
db.add(Memory(id="m_review", agent_id="owner", text=TEXT, status="needs_review"))
db.commit()

written = stack.write(TEXT)

assert written.get("duplicate_of") is None and written["id"] != "m_review"
with stack.sessions() as db:
assert db.get(Memory, written["id"]).status == "active"


def test_an_old_active_memory_is_still_found_as_the_duplicate(stack):
first = stack.write(TEXT)["id"]
with stack.sessions() as db:
later = datetime.now(UTC) + timedelta(days=1)
db.add_all([Memory(id=f"m{n:03d}", agent_id="owner", text=f"Filler memory number {n} about lunch",
created_at=later + timedelta(seconds=n)) for n in range(120)])
db.commit()

again = stack.write(" " + TEXT.upper() + " ")

assert again["duplicate_of"] == first
Loading