feat: refine private and team memory protocols

This commit is contained in:
Anish Sarkar 2026-05-20 02:02:10 +05:30
parent ceedd02353
commit 5247dc7097
16 changed files with 232 additions and 264 deletions

View file

@ -6,4 +6,10 @@ standing instructions?
If yes, call `update_memory` **alongside** your normal response — don't If yes, call `update_memory` **alongside** your normal response — don't
defer it to a later turn. Skip ephemeral chat noise (one-off Q/A, greetings, defer it to a later turn. Skip ephemeral chat noise (one-off Q/A, greetings,
session logistics). Stay within the budget shown in `<user_memory>`. session logistics). Stay within the budget shown in `<user_memory>`.
Memory is heading-based markdown. New entries should be under `##` headings
such as `## Facts`, `## Preferences`, or `## Instructions`, with bullets like
`- YYYY-MM-DD: text`. If existing memory contains legacy
`(YYYY-MM-DD) [fact|pref|instr]` markers, preserve the information but write
new saves in the heading-based format.
</memory_protocol> </memory_protocol>

View file

@ -6,4 +6,12 @@ key facts?
If yes, call `update_memory` **alongside** your normal response — don't If yes, call `update_memory` **alongside** your normal response — don't
defer it to a later turn. Skip ephemeral chat noise (one-off Q/A, greetings, defer it to a later turn. Skip ephemeral chat noise (one-off Q/A, greetings,
session logistics). Stay within the budget shown in `<team_memory>`. session logistics). Stay within the budget shown in `<team_memory>`.
Team memory is heading-based markdown. New entries should be under `##`
headings such as `## Product Decisions`, `## Engineering Conventions`,
`## Project Facts`, or `## Open Questions`, with bullets like
`- YYYY-MM-DD: text`. If existing memory contains legacy `(YYYY-MM-DD) [fact]`
markers, preserve the information but write new saves in the heading-based
format. Do not create personal headings such as `## Preferences` or
`## Instructions`.
</memory_protocol> </memory_protocol>

View file

@ -9,7 +9,9 @@
- Skip ephemeral chat noise (one-off Q/A, greetings, session logistics). - Skip ephemeral chat noise (one-off Q/A, greetings, session logistics).
- Args: `updated_memory` — FULL replacement markdown (merge and curate, - Args: `updated_memory` — FULL replacement markdown (merge and curate,
don't only append). don't only append).
- Formatting: bullets `- (YYYY-MM-DD) [marker] text` with markers `[fact]`, - Formatting: heading-based markdown with entries under `##` headings.
`[pref]`, `[instr]` (priority when trimming: `instr > pref > fact`). Recommended headings are `## Facts`, `## Preferences`, `## Instructions`,
Group bullets under short `##` headings; stay under the limit shown in though clearer natural headings are allowed. New bullets should look like
`<user_memory>`. `- YYYY-MM-DD: text`; stay under the limit shown in `<user_memory>`.
- If existing memory uses legacy `(YYYY-MM-DD) [fact|pref|instr]` markers,
preserve the information but write the updated document in the new format.

View file

@ -1,28 +1,28 @@
<example> <example>
<user_name>Alex</user_name>, <user_memory> is empty. <user_name>Alex</user_name>, <user_memory> is empty.
user: "I'm a space enthusiast, explain astrophage to me" user: "I'm a space enthusiast, explain astrophage to me"
→ update_memory(updated_memory="## Interests & background\n- (2025-03-15) [fact] Alex is a space enthusiast\n") → update_memory(updated_memory="## Facts\n- 2025-03-15: Alex is a space enthusiast\n")
(Casual durable fact; use first name, neutral heading.) (Casual durable fact; use first name, neutral heading.)
</example> </example>
<example> <example>
user: "Remember that I prefer concise answers over detailed explanations" user: "Remember that I prefer concise answers over detailed explanations"
→ update_memory(updated_memory="## Interests & background\n- (2025-03-15) [fact] Alex is a space enthusiast\n\n## Response style\n- (2025-03-15) [pref] Alex prefers concise answers over detailed explanations\n") → update_memory(updated_memory="## Facts\n- 2025-03-15: Alex is a space enthusiast\n\n## Preferences\n- 2025-03-15: Alex prefers concise answers over detailed explanations\n")
(Durable preference; merge with existing memory.) (Durable preference; merge with existing memory.)
</example> </example>
<example> <example>
user: "I actually moved to Tokyo last month" user: "I actually moved to Tokyo last month"
→ update_memory(updated_memory="...\n\n## Personal context\n- (2025-03-15) [fact] Alex lives in Tokyo (previously London)\n...") → update_memory(updated_memory="...\n\n## Facts\n- 2025-03-15: Alex lives in Tokyo (previously London)\n...")
(Updated fact; date reflects when recorded.) (Updated fact; date reflects when recorded.)
</example> </example>
<example> <example>
user: "I'm a freelance photographer working on a nature documentary" user: "I'm a freelance photographer working on a nature documentary"
→ update_memory(updated_memory="...\n\n## Current focus\n- (2025-03-15) [fact] Alex is a freelance photographer\n- (2025-03-15) [fact] Alex is working on a nature documentary\n") → update_memory(updated_memory="...\n\n## Current Focus\n- 2025-03-15: Alex is a freelance photographer\n- 2025-03-15: Alex is working on a nature documentary\n")
</example> </example>
<example> <example>
user: "Always respond in bullet points" user: "Always respond in bullet points"
→ update_memory(updated_memory="...\n\n## Response style\n- (2025-03-15) [instr] Always respond to Alex in bullet points\n") → update_memory(updated_memory="...\n\n## Instructions\n- 2025-03-15: Always respond to Alex in bullet points\n")
</example> </example>

View file

@ -9,8 +9,14 @@
- Skip ephemeral chat noise (one-off Q/A, greetings, session logistics). - Skip ephemeral chat noise (one-off Q/A, greetings, session logistics).
- Args: `updated_memory` — FULL replacement markdown (merge and curate, - Args: `updated_memory` — FULL replacement markdown (merge and curate,
don't only append). don't only append).
- Formatting: bullets `- (YYYY-MM-DD) [fact] text`. Team memory uses ONLY - Formatting: heading-based markdown with entries under `##` headings.
the `[fact]` marker (never `[pref]` or `[instr]`). Group bullets under Recommended headings are `## Product Decisions`,
short `##` headings (2-3 words each); stay under the limit shown in `## Engineering Conventions`, `## Project Facts`, and `## Open Questions`.
`<team_memory>`. When trimming, prioritise: decisions/conventions > key New bullets should look like `- YYYY-MM-DD: text`; stay under the limit
facts > current priorities. shown in `<team_memory>`.
- If existing memory uses legacy `(YYYY-MM-DD) [fact]` markers, preserve the
information but write the updated document in the new format.
- Do not create personal headings such as `## Preferences`,
`## Instructions`, `## Personal Notes`, or `## Personal Instructions`.
When trimming, prioritise: decisions/conventions > key facts > current
priorities.

View file

@ -1,9 +1,9 @@
<example> <example>
user: "Let's remember that we decided to do weekly standup meetings on Mondays" user: "Let's remember that we decided to do weekly standup meetings on Mondays"
→ update_memory(updated_memory="...\n\n## Team rituals\n- (2025-03-15) [fact] Weekly standup meetings on Mondays\n...") → update_memory(updated_memory="...\n\n## Product Decisions\n- 2025-03-15: Weekly standup meetings happen on Mondays\n...")
</example> </example>
<example> <example>
user: "Our office is in downtown Seattle, 5th floor" user: "Our office is in downtown Seattle, 5th floor"
→ update_memory(updated_memory="...\n\n## Workspace\n- (2025-03-15) [fact] Office location: downtown Seattle, 5th floor\n...") → update_memory(updated_memory="...\n\n## Project Facts\n- 2025-03-15: Office location is downtown Seattle, 5th floor\n...")
</example> </example>

View file

@ -18,6 +18,10 @@ Persist durable preferences/facts/instructions with `update_memory` while avoidi
- Do not store transient chatter. - Do not store transient chatter.
- Do not store secrets unless explicitly instructed. - Do not store secrets unless explicitly instructed.
- If memory intent is unclear, return `status=blocked` with the missing intent signal. - If memory intent is unclear, return `status=blocked` with the missing intent signal.
- Persisted memory is heading-based markdown. New saved bullets should look like
`- YYYY-MM-DD: text` under `##` headings. If existing memory has legacy
`(YYYY-MM-DD) [fact|pref|instr]` markers, preserve the information but write
the updated document in the heading-based format.
</tool_policy> </tool_policy>
<out_of_scope> <out_of_scope>
@ -53,4 +57,7 @@ Rules:
- `status=success` -> `next_step=null`, `missing_fields=null`. - `status=success` -> `next_step=null`, `missing_fields=null`.
- `status=partial|blocked|error` -> `next_step` must be non-null. - `status=partial|blocked|error` -> `next_step` must be non-null.
- `status=blocked` due to missing required inputs -> `missing_fields` must be non-null. - `status=blocked` due to missing required inputs -> `missing_fields` must be non-null.
- `evidence.memory_category` is a semantic classification for supervisor logs
only. It is not the persisted storage format and must not force inline
`[fact|preference|instruction]` markers into saved memory.
</output_contract> </output_contract>

View file

@ -17,8 +17,8 @@ from langgraph.runtime import Runtime
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.agents.new_chat.tools.update_memory import MEMORY_HARD_LIMIT, MEMORY_SOFT_LIMIT
from app.db import ChatVisibility, SearchSpace, User, shielded_async_session from app.db import ChatVisibility, SearchSpace, User, shielded_async_session
from app.services.memory import MEMORY_HARD_LIMIT, MEMORY_SOFT_LIMIT
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)

View file

@ -3,4 +3,10 @@ IMPORTANT — After understanding each user message, ALWAYS check: does this mes
reveal durable facts about the user (role, interests, preferences, projects, reveal durable facts about the user (role, interests, preferences, projects,
background, or standing instructions)? If yes, you MUST call update_memory background, or standing instructions)? If yes, you MUST call update_memory
alongside your normal response — do not defer this to a later turn. alongside your normal response — do not defer this to a later turn.
Memory is stored as a heading-based markdown document. New entries should be
under `##` headings such as `## Facts`, `## Preferences`, or `## Instructions`
with bullets like `- YYYY-MM-DD: text`. If existing memory contains legacy
`(YYYY-MM-DD) [fact|pref|instr]` markers, preserve the information but write
new saves in the heading-based format.
</memory_protocol> </memory_protocol>

View file

@ -3,4 +3,12 @@ IMPORTANT — After understanding each user message, ALWAYS check: does this mes
reveal durable facts about the team (decisions, conventions, architecture, processes, reveal durable facts about the team (decisions, conventions, architecture, processes,
or key facts)? If yes, you MUST call update_memory alongside your normal response — or key facts)? If yes, you MUST call update_memory alongside your normal response —
do not defer this to a later turn. do not defer this to a later turn.
Team memory is stored as a heading-based markdown document. New entries should
be under `##` headings such as `## Product Decisions`,
`## Engineering Conventions`, `## Project Facts`, or `## Open Questions` with
bullets like `- YYYY-MM-DD: text`. If existing memory contains legacy
`(YYYY-MM-DD) [fact]` markers, preserve the information but write new saves in
the heading-based format. Do not create personal headings such as
`## Preferences` or `## Instructions`.
</memory_protocol> </memory_protocol>

View file

@ -1,16 +1,16 @@
- <user_name>Alex</user_name>, <user_memory> is empty. User: "I'm a space enthusiast, explain astrophage to me" - <user_name>Alex</user_name>, <user_memory> is empty. User: "I'm a space enthusiast, explain astrophage to me"
- The user casually shared a durable fact. Use their first name in the entry, short neutral heading: - The user casually shared a durable fact:
update_memory(updated_memory="## Interests & background\n- (2025-03-15) [fact] Alex is a space enthusiast\n") update_memory(updated_memory="## Facts\n- 2025-03-15: Alex is a space enthusiast\n")
- User: "Remember that I prefer concise answers over detailed explanations" - User: "Remember that I prefer concise answers over detailed explanations"
- Durable preference. Merge with existing memory, add a new heading: - Durable preference. Merge with existing memory:
update_memory(updated_memory="## Interests & background\n- (2025-03-15) [fact] Alex is a space enthusiast\n\n## Response style\n- (2025-03-15) [pref] Alex prefers concise answers over detailed explanations\n") update_memory(updated_memory="## Facts\n- 2025-03-15: Alex is a space enthusiast\n\n## Preferences\n- 2025-03-15: Alex prefers concise answers over detailed explanations\n")
- User: "I actually moved to Tokyo last month" - User: "I actually moved to Tokyo last month"
- Updated fact, date prefix reflects when recorded: - Updated fact, date prefix reflects when recorded:
update_memory(updated_memory="## Interests & background\n...\n\n## Personal context\n- (2025-03-15) [fact] Alex lives in Tokyo (previously London)\n...") update_memory(updated_memory="## Facts\n- 2025-03-15: Alex lives in Tokyo (previously London)\n...")
- User: "I'm a freelance photographer working on a nature documentary" - User: "I'm a freelance photographer working on a nature documentary"
- Durable background info under a fitting heading: - Durable background info under a fitting heading:
update_memory(updated_memory="...\n\n## Current focus\n- (2025-03-15) [fact] Alex is a freelance photographer\n- (2025-03-15) [fact] Alex is working on a nature documentary\n") update_memory(updated_memory="...\n\n## Current Focus\n- 2025-03-15: Alex is a freelance photographer\n- 2025-03-15: Alex is working on a nature documentary\n")
- User: "Always respond in bullet points" - User: "Always respond in bullet points"
- Standing instruction: - Standing instruction:
update_memory(updated_memory="...\n\n## Response style\n- (2025-03-15) [instr] Always respond to Alex in bullet points\n") update_memory(updated_memory="...\n\n## Instructions\n- 2025-03-15: Always respond to Alex in bullet points\n")

View file

@ -1,7 +1,7 @@
- User: "Let's remember that we decided to do weekly standup meetings on Mondays" - User: "Let's remember that we decided to do weekly standup meetings on Mondays"
- Durable team decision: - Durable team decision:
update_memory(updated_memory="- (2025-03-15) [fact] Weekly standup meetings on Mondays\n...") update_memory(updated_memory="## Product Decisions\n- 2025-03-15: Weekly standup meetings happen on Mondays\n...")
- User: "Our office is in downtown Seattle, 5th floor" - User: "Our office is in downtown Seattle, 5th floor"
- Durable team fact: - Durable team fact:
update_memory(updated_memory="- (2025-03-15) [fact] Office location: downtown Seattle, 5th floor\n...") update_memory(updated_memory="## Project Facts\n- 2025-03-15: Office location is downtown Seattle, 5th floor\n...")

View file

@ -1,31 +1,26 @@
- update_memory: Update your personal memory document about the user. - update_memory: Update your personal memory document about the user.
- Your current memory is already in <user_memory> in your context. The `chars` and - Your current memory is already in <user_memory> in your context. The `chars`
`limit` attributes show your current usage and the maximum allowed size. and `limit` attributes show current usage and the maximum allowed size.
- This is your curated long-term memory — the distilled essence of what you know about - This is curated long-term memory, not raw conversation logs.
the user, not raw conversation logs. - Call update_memory when the user explicitly asks to remember/forget
- Call update_memory when: something or shares durable facts, preferences, or standing instructions.
* The user explicitly asks to remember or forget something - The user's first name is provided in <user_name>. Use it in entries instead
* The user shares durable facts or preferences that will matter in future conversations of "the user" when helpful. Do not store the name alone as a memory entry.
- The user's first name is provided in <user_name>. Use it in memory entries - Do not store short-lived info: one-off questions, greetings, session
instead of "the user" (e.g. "{name} works at..." not "The user works at..."). logistics, or things that only matter for the current task.
Do not store the name itself as a separate memory entry.
- Do not store short-lived or ephemeral info: one-off questions, greetings,
session logistics, or things that only matter for the current task.
- Args: - Args:
- updated_memory: The FULL updated markdown document (not a diff). - updated_memory: The FULL updated markdown document, not a diff. Merge new
Merge new facts with existing ones, update contradictions, remove outdated entries. facts with existing ones, update contradictions, remove outdated entries,
Treat every update as a curation pass — consolidate, don't just append. and consolidate instead of only appending.
- Every bullet MUST use this format: - (YYYY-MM-DD) [marker] text - Use heading-based Markdown:
Markers: * Every entry must be under a `##` heading.
[fact] — durable facts (role, background, projects, tools, expertise) * Recommended headings: `## Facts`, `## Preferences`, `## Instructions`.
[pref] — preferences (response style, languages, formats, tools) Specific natural headings are allowed when clearer.
[instr] — standing instructions (always/never do, response rules) * New bullets should use `- YYYY-MM-DD: text`.
- Keep it concise and well under the character limit shown in <user_memory>. * Each entry should be one concise but descriptive bullet.
- Every entry MUST be under a `##` heading. Keep heading names short (2-3 words) and - If existing memory uses legacy `(YYYY-MM-DD) [fact|pref|instr]` markers,
natural. Do NOT include the user's name in headings. Organize by context — e.g. preserve the information but write the updated document in the new
who they are, what they're focused on, how they prefer things. Create, split, or heading-based format.
merge headings freely as the memory grows. - During consolidation, prioritize durable instructions and preferences before
- Each entry MUST be a single bullet point. Be descriptive but concise — include relevant generic facts.
details and context rather than just a few words.
- During consolidation, prioritize keeping: [instr] > [pref] > [fact].

View file

@ -1,26 +1,28 @@
- update_memory: Update the team's shared memory document for this search space. - update_memory: Update the team's shared memory document for this search space.
- Your current team memory is already in <team_memory> in your context. The `chars` - Your current team memory is already in <team_memory> in your context. The
and `limit` attributes show current usage and the maximum allowed size. `chars` and `limit` attributes show current usage and the maximum allowed size.
- This is the team's curated long-term memory — decisions, conventions, key facts. - This is curated long-term team memory: decisions, conventions, architecture,
- NEVER store personal memory in team memory (e.g. personal bio, individual processes, and key shared facts.
preferences, or user-only standing instructions). - NEVER store personal memory in team memory: individual bios, personal
- Call update_memory when: preferences, or user-only standing instructions.
* A team member explicitly asks to remember or forget something - Call update_memory when a team member asks to remember/forget something, or
* The conversation surfaces durable team decisions, conventions, or facts when the conversation surfaces durable team context that matters later.
that will matter in future conversations - Do not store short-lived info: one-off questions, greetings, session
- Do not store short-lived or ephemeral info: one-off questions, greetings, logistics, or things that only matter for the current task.
session logistics, or things that only matter for the current task.
- Args: - Args:
- updated_memory: The FULL updated markdown document (not a diff). - updated_memory: The FULL updated markdown document, not a diff. Merge new
Merge new facts with existing ones, update contradictions, remove outdated entries. facts with existing ones, update contradictions, remove outdated entries,
Treat every update as a curation pass — consolidate, don't just append. and consolidate instead of only appending.
- Every bullet MUST use this format: - (YYYY-MM-DD) [fact] text - Use heading-based Markdown:
Team memory uses ONLY the [fact] marker. Never use [pref] or [instr] in team memory. * Every entry must be under a `##` heading.
- Keep it concise and well under the character limit shown in <team_memory>. * Recommended headings: `## Product Decisions`, `## Engineering Conventions`,
- Every entry MUST be under a `##` heading. Keep heading names short (2-3 words) and `## Project Facts`, `## Open Questions`.
natural. Organize by context — e.g. what the team decided, current architecture, * New bullets should use `- YYYY-MM-DD: text`.
active processes. Create, split, or merge headings freely as the memory grows. * Each entry should be one concise but descriptive bullet.
- Each entry MUST be a single bullet point. Be descriptive but concise — include relevant - If existing memory uses legacy `(YYYY-MM-DD) [fact]` markers, preserve the
details and context rather than just a few words. information but write the updated document in the new heading-based format.
- During consolidation, prioritize keeping: decisions/conventions > key facts > current priorities. - Do not create personal headings such as `## Preferences`, `## Instructions`,
`## Personal Notes`, or `## Personal Instructions`.
- During consolidation, prioritize decisions/conventions, then key facts, then
current priorities.

View file

@ -2,28 +2,12 @@
import pytest import pytest
from app.agents.new_chat.tools.update_memory import _save_memory from app.services.memory import MemoryScope, save_memory
from app.utils.content_utils import extract_text_content from app.utils.content_utils import extract_text_content
pytestmark = pytest.mark.unit pytestmark = pytest.mark.unit
class _Recorder:
def __init__(self) -> None:
self.applied_content: str | None = None
self.commit_calls = 0
self.rollback_calls = 0
def apply(self, content: str) -> None:
self.applied_content = content
async def commit(self) -> None:
self.commit_calls += 1
async def rollback(self) -> None:
self.rollback_calls += 1
def test_extract_text_content_keeps_no_update_bare_string_from_content_blocks() -> None: def test_extract_text_content_keeps_no_update_bare_string_from_content_blocks() -> None:
content = [ content = [
{"type": "thinking", "thinking": "No"}, {"type": "thinking", "thinking": "No"},
@ -69,21 +53,12 @@ def test_extract_text_content_preserves_plain_string_responses() -> None:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_save_memory_rejects_non_string_payload_before_commit() -> None: async def test_save_memory_rejects_non_string_payload_before_commit() -> None:
recorder = _Recorder() result = await save_memory(
scope=MemoryScope.USER,
result = await _save_memory( target_id="00000000-0000-0000-0000-000000000000",
updated_memory=["NO_UPDATE"], # type: ignore[arg-type] content=["NO_UPDATE"], # type: ignore[arg-type]
old_memory=None, session=None, # type: ignore[arg-type]
llm=None,
apply_fn=recorder.apply,
commit_fn=recorder.commit,
rollback_fn=recorder.rollback,
label="memory",
scope="user",
) )
assert result["status"] == "error" assert result.status == "error"
assert "must be a string" in result["message"] assert "must be a string" in result.message
assert recorder.applied_content is None
assert recorder.commit_calls == 0
assert recorder.rollback_calls == 0

View file

@ -1,24 +1,24 @@
"""Unit tests for memory scope validation and bullet format validation.""" """Unit tests for heading-based memory validation."""
import pytest import pytest
from app.agents.new_chat.tools.update_memory import ( from app.services.memory import MemoryScope, save_memory
_save_memory, from app.services.memory.validation import (
_validate_bullet_format, validate_bullet_format,
_validate_memory_scope, validate_memory_scope,
) )
pytestmark = pytest.mark.unit pytestmark = pytest.mark.unit
class _Recorder: class _FakeSession:
def __init__(self) -> None: def __init__(self) -> None:
self.applied_content: str | None = None self.added = []
self.commit_calls = 0 self.commit_calls = 0
self.rollback_calls = 0 self.rollback_calls = 0
def apply(self, content: str) -> None: def add(self, obj) -> None:
self.applied_content = content self.added.append(obj)
async def commit(self) -> None: async def commit(self) -> None:
self.commit_calls += 1 self.commit_calls += 1
@ -27,172 +27,125 @@ class _Recorder:
self.rollback_calls += 1 self.rollback_calls += 1
# --------------------------------------------------------------------------- def test_validate_memory_scope_rejects_new_personal_heading_in_team() -> None:
# _validate_memory_scope — marker-based content = "## Preferences\n- 2026-04-10: Prefers dark mode\n"
# --------------------------------------------------------------------------- result, _warnings = validate_memory_scope(content, "team")
def test_validate_memory_scope_rejects_pref_marker_in_team_scope() -> None:
content = "- (2026-04-10) [pref] Prefers dark mode\n"
result = _validate_memory_scope(content, "team")
assert result is not None assert result is not None
assert result["status"] == "error" assert result["status"] == "error"
assert "[pref]" in result["message"] assert "preferences" in result["message"]
def test_validate_memory_scope_rejects_instr_marker_in_team_scope() -> None: def test_validate_memory_scope_allows_old_marker_payload_in_team_scope() -> None:
content = "- (2026-04-10) [instr] Always respond in Spanish\n" content = "- (2026-04-10) [pref] Legacy personal marker remains readable\n"
result = _validate_memory_scope(content, "team") result, _warnings = validate_memory_scope(content, "team")
assert result is not None assert result is None
assert result["status"] == "error"
assert "[instr]" in result["message"]
def test_validate_memory_scope_rejects_both_personal_markers_in_team() -> None: def test_validate_memory_scope_allows_team_headings() -> None:
content = "## Engineering Conventions\n- 2026-04-10: Uses PostgreSQL\n"
result, _warnings = validate_memory_scope(content, "team")
assert result is None
def test_validate_bullet_format_accepts_new_and_legacy_bullets() -> None:
content = ( content = (
"- (2026-04-10) [pref] Prefers dark mode\n" "## Facts\n"
"- (2026-04-10) [instr] Always respond in Spanish\n" "- 2026-04-10: Senior Python developer\n"
"- (2026-04-10) [fact] Legacy fact is preserved\n"
) )
result = _validate_memory_scope(content, "team") warnings = validate_bullet_format(content)
assert result is not None
assert result["status"] == "error"
assert "[instr]" in result["message"]
assert "[pref]" in result["message"]
def test_validate_memory_scope_allows_fact_in_team_scope() -> None:
content = "- (2026-04-10) [fact] Office is in downtown Seattle\n"
result = _validate_memory_scope(content, "team")
assert result is None
def test_validate_memory_scope_allows_all_markers_in_user_scope() -> None:
content = (
"- (2026-04-10) [fact] Python developer\n"
"- (2026-04-10) [pref] Prefers concise answers\n"
"- (2026-04-10) [instr] Always use bullet points\n"
)
result = _validate_memory_scope(content, "user")
assert result is None
def test_validate_memory_scope_allows_any_heading_in_team() -> None:
content = "## Architecture\n- (2026-04-10) [fact] Uses PostgreSQL for persistence\n"
result = _validate_memory_scope(content, "team")
assert result is None
def test_validate_memory_scope_allows_any_heading_in_user() -> None:
content = "## My Projects\n- (2026-04-10) [fact] Working on SurfSense\n"
result = _validate_memory_scope(content, "user")
assert result is None
# ---------------------------------------------------------------------------
# _validate_bullet_format
# ---------------------------------------------------------------------------
def test_validate_bullet_format_passes_valid_bullets() -> None:
content = (
"## Work\n"
"- (2026-04-10) [fact] Senior Python developer\n"
"- (2026-04-10) [pref] Prefers dark mode\n"
"- (2026-04-10) [instr] Always respond in bullet points\n"
)
warnings = _validate_bullet_format(content)
assert warnings == [] assert warnings == []
def test_validate_bullet_format_warns_on_missing_marker() -> None: def test_validate_bullet_format_warns_on_nonstandard_bullet() -> None:
content = "- (2026-04-10) Senior Python developer\n" content = "## Facts\n- Senior Python developer\n"
warnings = _validate_bullet_format(content) warnings = validate_bullet_format(content)
assert len(warnings) == 1 assert len(warnings) == 1
assert "Malformed bullet" in warnings[0] assert "Non-standard memory bullet" in warnings[0]
def test_validate_bullet_format_warns_on_missing_date() -> None:
content = "- [fact] Senior Python developer\n"
warnings = _validate_bullet_format(content)
assert len(warnings) == 1
assert "Malformed bullet" in warnings[0]
def test_validate_bullet_format_warns_on_unknown_marker() -> None:
content = "- (2026-04-10) [context] Working on project X\n"
warnings = _validate_bullet_format(content)
assert len(warnings) == 1
assert "Malformed bullet" in warnings[0]
def test_validate_bullet_format_ignores_non_bullet_lines() -> None:
content = "## Some Heading\nSome paragraph text\n"
warnings = _validate_bullet_format(content)
assert warnings == []
def test_validate_bullet_format_warns_on_old_format_without_marker() -> None:
content = "## About the user\n- (2026-04-10) Likes cats\n"
warnings = _validate_bullet_format(content)
assert len(warnings) == 1
# ---------------------------------------------------------------------------
# _save_memory — end-to-end with marker scope check
# ---------------------------------------------------------------------------
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_save_memory_blocks_pref_in_team_before_commit() -> None: async def test_save_memory_blocks_new_personal_heading_in_team_before_commit(
recorder = _Recorder() monkeypatch,
result = await _save_memory( ) -> None:
updated_memory="- (2026-04-10) [pref] Prefers dark mode\n", target = type("Target", (), {"shared_memory_md": ""})()
old_memory=None, session = _FakeSession()
llm=None,
apply_fn=recorder.apply, async def fake_load_target(**_kwargs):
commit_fn=recorder.commit, return target
rollback_fn=recorder.rollback,
label="team memory", monkeypatch.setattr("app.services.memory.service._load_target", fake_load_target)
scope="team",
result = await save_memory(
scope=MemoryScope.TEAM,
target_id=1,
content="## Preferences\n- 2026-04-10: Prefers dark mode\n",
session=session,
) )
assert result["status"] == "error" assert result.status == "error"
assert recorder.commit_calls == 0 assert session.commit_calls == 0
assert recorder.applied_content is None assert target.shared_memory_md == ""
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_save_memory_allows_fact_in_team_and_commits() -> None: async def test_save_memory_allows_grandfathered_personal_heading_in_team(monkeypatch) -> None:
recorder = _Recorder() content = "## Preferences\n- 2026-04-10: Prefers dark mode\n"
content = "- (2026-04-10) [fact] Weekly standup on Mondays\n" target = type("Target", (), {"shared_memory_md": content})()
result = await _save_memory( session = _FakeSession()
updated_memory=content,
old_memory=None, async def fake_load_target(**_kwargs):
llm=None, return target
apply_fn=recorder.apply,
commit_fn=recorder.commit, monkeypatch.setattr("app.services.memory.service._load_target", fake_load_target)
rollback_fn=recorder.rollback,
label="team memory", result = await save_memory(
scope="team", scope=MemoryScope.TEAM,
target_id=1,
content=content,
session=session,
) )
assert result["status"] == "saved" assert result.status == "saved"
assert recorder.commit_calls == 1 assert session.commit_calls == 1
assert recorder.applied_content == content assert target.shared_memory_md == content.strip()
assert result.warnings
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_save_memory_includes_format_warnings() -> None: async def test_save_memory_strips_preamble_before_heading(monkeypatch) -> None:
recorder = _Recorder() target = type("Target", (), {"memory_md": ""})()
content = "- (2026-04-10) Missing marker text\n" session = _FakeSession()
result = await _save_memory(
updated_memory=content, async def fake_load_target(**_kwargs):
old_memory=None, return target
llm=None,
apply_fn=recorder.apply, monkeypatch.setattr("app.services.memory.service._load_target", fake_load_target)
commit_fn=recorder.commit,
rollback_fn=recorder.rollback, result = await save_memory(
label="memory", scope=MemoryScope.USER,
scope="user", target_id="00000000-0000-0000-0000-000000000000",
content="Sure, here is the update:\n\n## Facts\n- 2026-04-10: Likes cats\n",
session=session,
) )
assert result["status"] == "saved" assert result.status == "saved"
assert "format_warnings" in result assert target.memory_md == "## Facts\n- 2026-04-10: Likes cats"
assert len(result["format_warnings"]) == 1
@pytest.mark.asyncio
async def test_save_memory_rejects_long_no_heading_payload(monkeypatch) -> None:
target = type("Target", (), {"memory_md": ""})()
session = _FakeSession()
async def fake_load_target(**_kwargs):
return target
monkeypatch.setattr("app.services.memory.service._load_target", fake_load_target)
result = await save_memory(
scope=MemoryScope.USER,
target_id="00000000-0000-0000-0000-000000000000",
content="NO_UPDATE because there is nothing durable to remember.",
session=session,
)
assert result.status == "error"
assert "## heading" in result.message
assert session.commit_calls == 0