diff --git a/surfsense_backend/app/agents/new_chat/memory_extraction.py b/surfsense_backend/app/agents/new_chat/memory_extraction.py index 3799f78fa..e42e76813 100644 --- a/surfsense_backend/app/agents/new_chat/memory_extraction.py +++ b/surfsense_backend/app/agents/new_chat/memory_extraction.py @@ -34,13 +34,12 @@ info, things that only matter for the current task. If the message contains memorizable information, output the FULL updated \ memory document with the new facts merged into the existing content. Follow \ these rules: -- Use the same ## section structure as the existing memory. +- Preserve any existing ## headings; create new ones if useful. - Keep entries as single concise bullet points (under 120 chars each). -- Every bullet MUST start with a (YYYY-MM-DD) date prefix. +- Every bullet MUST use format: - (YYYY-MM-DD) [fact|pref|instr] text + [fact] = durable facts, [pref] = preferences, [instr] = standing instructions. - If a new fact contradicts an existing entry, update the existing entry. - Do not duplicate information that is already present. -- Standard sections: \ -"## About the user", "## Preferences", "## Instructions" If nothing is worth remembering, output exactly: NO_UPDATE @@ -77,16 +76,13 @@ NOT worth remembering: If the message contains memorizable team information, output the FULL updated \ team memory document with new facts merged into existing content. Follow rules: -- Use the same ## section structure as the existing memory. +- Preserve any existing ## headings; create new ones if useful. - Keep entries as single concise bullet points (under 120 chars each). -- Every bullet MUST start with a (YYYY-MM-DD) date prefix. +- Every bullet MUST use format: - (YYYY-MM-DD) [fact] text + Team memory uses ONLY the [fact] marker. Never use [pref] or [instr]. - If a new fact contradicts an existing entry, update the existing entry. - Do not duplicate existing information. -- NEVER use personal sections like "## About the user", "## Preferences", \ - or "## Instructions". - Preserve neutral team phrasing; avoid person-specific memory unless role-anchored. -- Standard sections: "## Team decisions", "## Team conventions", \ -"## Key facts", "## Current priorities" If nothing is worth remembering, output exactly: NO_UPDATE diff --git a/surfsense_backend/app/agents/new_chat/system_prompt.py b/surfsense_backend/app/agents/new_chat/system_prompt.py index e310d02eb..0fdaaddb4 100644 --- a/surfsense_backend/app/agents/new_chat/system_prompt.py +++ b/surfsense_backend/app/agents/new_chat/system_prompt.py @@ -281,18 +281,16 @@ _MEMORY_TOOL_INSTRUCTIONS: dict[str, dict[str, str]] = { - updated_memory: The FULL updated markdown document (not a diff). Merge new facts with existing ones, update contradictions, remove outdated entries. Treat every update as a curation pass — consolidate, don't just append. - - Every bullet MUST start with a (YYYY-MM-DD) date prefix indicating when it was recorded or last updated. + - Every bullet MUST use this format: - (YYYY-MM-DD) [marker] text + Markers: + [fact] — durable facts (role, background, projects, tools, expertise) + [pref] — preferences (response style, languages, formats, tools) + [instr] — standing instructions (always/never do, response rules) - Keep it concise and well under the character limit shown in . - - You MUST organize memory using these standard sections (add new `##` sections only if none of the standard ones fit): - ## About the user - ## Preferences - ## Instructions - - Section guidance: - * About the user: role, background, company, durable identity context - * Preferences: languages, tools, frameworks, response style preferences - * Instructions: standing instructions, things to always/never do + - Use any `##` heading that fits. Headings are optional and freeform — organize + however makes sense for the content (e.g. ## Work, ## Research, ## Personal). - Each entry MUST be a single bullet point. Keep entries concise (aim for under 120 chars each). - - During consolidation, prioritize keeping: identity/instructions > preferences. + - During consolidation, prioritize keeping: [instr] > [pref] > [fact]. """, "shared": """ - update_memory: Update the team's shared memory document for this search space. @@ -311,18 +309,11 @@ _MEMORY_TOOL_INSTRUCTIONS: dict[str, dict[str, str]] = { - updated_memory: The FULL updated markdown document (not a diff). Merge new facts with existing ones, update contradictions, remove outdated entries. Treat every update as a curation pass — consolidate, don't just append. - - Every bullet MUST start with a (YYYY-MM-DD) date prefix indicating when it was recorded or last updated. + - Every bullet MUST use this format: - (YYYY-MM-DD) [fact] text + Team memory uses ONLY the [fact] marker. Never use [pref] or [instr] in team memory. - Keep it concise and well under the character limit shown in . - - You MUST organize memory using these standard sections (add new `##` sections only if none of the standard ones fit): - ## Team decisions - ## Conventions - ## Key facts - ## Current priorities - - Section guidance: - * Team decisions: agreed choices and durable technical/product decisions - * Conventions: coding standards, tools, processes, naming patterns - * Key facts: stable facts about org/team/system setup - * Current priorities: active projects, near-term goals, important blockers + - Use any `##` heading that fits. Headings are optional and freeform — organize + however makes sense for the content (e.g. ## Decisions, ## Architecture, ## Process). - Each entry MUST be a single bullet point. Keep entries concise (aim for under 120 chars each). - During consolidation, prioritize keeping: decisions/conventions > key facts > current priorities. """, @@ -334,24 +325,27 @@ _MEMORY_TOOL_EXAMPLES: dict[str, dict[str, str]] = { "private": """ - is empty. User: "I'm a space enthusiast, explain astrophage to me" - The user casually shared a durable fact about themselves. Save it: - update_memory(updated_memory="## About the user\\n- (2025-03-15) Space enthusiast\\n") + update_memory(updated_memory="- (2025-03-15) [fact] Space enthusiast\\n") - User: "Remember that I prefer concise answers over detailed explanations" - - Durable preference. You see the current and merge: - update_memory(updated_memory="## About the user\\n- (2025-03-15) Space enthusiast\\n\\n## Preferences\\n- (2025-03-15) Prefers concise answers over detailed explanations\\n...") + - Durable preference. Merge with existing memory: + update_memory(updated_memory="- (2025-03-15) [fact] Space enthusiast\\n- (2025-03-15) [pref] Prefers concise answers over detailed explanations\\n") - User: "I actually moved to Tokyo last month" - Updated fact, date prefix reflects when recorded: - update_memory(updated_memory="## About the user\\n- (2025-03-15) Lives in Tokyo (previously London)\\n...") + update_memory(updated_memory="- (2025-03-15) [fact] Lives in Tokyo (previously London)\\n...") - User: "I'm a freelance photographer working on a nature documentary" - - Durable background info. Save it under About the user: - update_memory(updated_memory="## About the user\\n- (2025-03-15) Freelance photographer\\n- (2025-03-15) Working on a nature documentary\\n") + - Durable background info: + update_memory(updated_memory="- (2025-03-15) [fact] Freelance photographer\\n- (2025-03-15) [fact] Working on a nature documentary\\n") +- User: "Always respond in bullet points" + - Standing instruction: + update_memory(updated_memory="...\\n- (2025-03-15) [instr] Always respond in bullet points\\n") """, "shared": """ - User: "Let's remember that we decided to do weekly standup meetings on Mondays" - Durable team decision: - update_memory(updated_memory="## Team decisions\\n- (2025-03-15) Weekly standup meetings on Mondays\\n...") + update_memory(updated_memory="- (2025-03-15) [fact] Weekly standup meetings on Mondays\\n...") - User: "Our office is in downtown Seattle, 5th floor" - Durable team fact: - update_memory(updated_memory="## Key facts\\n- (2025-03-15) Office location: downtown Seattle, 5th floor\\n...") + update_memory(updated_memory="- (2025-03-15) [fact] Office location: downtown Seattle, 5th floor\\n...") """, }, } diff --git a/surfsense_backend/app/agents/new_chat/tools/update_memory.py b/surfsense_backend/app/agents/new_chat/tools/update_memory.py index 24577e975..39af0317c 100644 --- a/surfsense_backend/app/agents/new_chat/tools/update_memory.py +++ b/surfsense_backend/app/agents/new_chat/tools/update_memory.py @@ -36,13 +36,11 @@ MEMORY_HARD_LIMIT = 25_000 _SECTION_HEADING_RE = re.compile(r"^##\s+(.+)$", re.MULTILINE) _HEADING_NORMALIZE_RE = re.compile(r"\s+") -_USER_ONLY_HEADINGS = {"about the user", "preferences", "instructions"} -_TEAM_ONLY_HEADINGS = { - "team decisions", - "conventions", - "key facts", - "current priorities", -} +_MARKER_RE = re.compile(r"\[(fact|pref|instr)\]") +_BULLET_FORMAT_RE = re.compile( + r"^- \(\d{4}-\d{2}-\d{2}\) \[(fact|pref|instr)\] .+$" +) +_PERSONAL_ONLY_MARKERS = {"pref", "instr"} # --------------------------------------------------------------------------- @@ -63,37 +61,40 @@ def _normalize_heading(heading: str) -> str: def _validate_memory_scope( content: str, scope: Literal["user", "team"] ) -> dict[str, Any] | None: - """Reject cross-scope headings (user sections in team memory and vice versa).""" - headings = {_normalize_heading(h) for h in _extract_headings(content)} - if not headings: + """Reject personal-only markers ([pref], [instr]) in team memory.""" + if scope != "team": return None - if scope == "team": - leaked = sorted(headings & _USER_ONLY_HEADINGS) - if leaked: - return { - "status": "error", - "message": ( - "Team memory cannot include personal sections: " - + ", ".join(leaked) - + ". Use team sections only." - ), - } - return None - - leaked = sorted(headings & _TEAM_ONLY_HEADINGS) + markers = set(_MARKER_RE.findall(content)) + leaked = sorted(markers & _PERSONAL_ONLY_MARKERS) if leaked: + tags = ", ".join(f"[{m}]" for m in leaked) return { "status": "error", "message": ( - "User memory cannot include team sections: " - + ", ".join(leaked) - + ". Use personal sections only." + f"Team memory cannot include personal markers: {tags}. " + "Use [fact] only in team memory." ), } return None +def _validate_bullet_format(content: str) -> list[str]: + """Return warnings for bullet lines that don't match the required format. + + Expected: ``- (YYYY-MM-DD) [fact|pref|instr] text`` + """ + warnings: list[str] = [] + for line in content.splitlines(): + stripped = line.strip() + if not stripped.startswith("- "): + continue + if not _BULLET_FORMAT_RE.match(stripped): + short = stripped[:80] + ("..." if len(stripped) > 80 else "") + warnings.append(f"Malformed bullet: {short}") + return warnings + + def _validate_diff(old_memory: str | None, new_memory: str) -> list[str]: """Return a list of warning strings about suspicious changes.""" if not old_memory: @@ -163,13 +164,11 @@ limit and must be shortened. RULES: 1. Rewrite the document to be under {target} characters. -2. Preserve all ## section headings. -3. Priority for keeping content: identity/instructions > preferences > \ - current context. +2. Preserve any existing ## headings. +3. Priority for keeping content: [instr] > [pref] > [fact]. 4. Merge duplicate entries, remove outdated entries, shorten verbose descriptions. -5. Each entry must be a single bullet point. -6. Every bullet MUST keep its (YYYY-MM-DD) date prefix. -7. Output ONLY the consolidated markdown — no explanations, no wrapping. +5. Every bullet MUST have format: - (YYYY-MM-DD) [fact|pref|instr] text +6. Output ONLY the consolidated markdown — no explanations, no wrapping. {content} @@ -275,6 +274,10 @@ async def _save_memory( if diff_warnings: resp["diff_warnings"] = diff_warnings + format_warnings = _validate_bullet_format(content) + if format_warnings: + resp["format_warnings"] = format_warnings + warning = _soft_warning(content) if warning: resp["warning"] = warning diff --git a/surfsense_backend/app/routes/memory_routes.py b/surfsense_backend/app/routes/memory_routes.py index 565656f60..e45eb7a23 100644 --- a/surfsense_backend/app/routes/memory_routes.py +++ b/surfsense_backend/app/routes/memory_routes.py @@ -41,12 +41,13 @@ Apply the user's instruction to the existing memory document and output the \ FULL updated document. RULES: -1. If the instruction asks to add something, add it in the appropriate \ -## section with a (YYYY-MM-DD) date prefix using today's date. +1. If the instruction asks to add something, add it with format: \ +- (YYYY-MM-DD) [fact|pref|instr] text, under an existing or new ## heading. 2. If the instruction asks to remove something, remove the matching entry. 3. If the instruction asks to change something, update the matching entry. -4. Preserve the existing ## section structure and all other entries. -5. Output ONLY the updated markdown — no explanations, no wrapping. +4. Preserve existing ## headings and all other entries. +5. Every bullet must include a marker: [fact], [pref], or [instr]. +6. Output ONLY the updated markdown — no explanations, no wrapping. {current_memory} diff --git a/surfsense_backend/app/routes/search_spaces_routes.py b/surfsense_backend/app/routes/search_spaces_routes.py index 6c608c532..7fe14c3e9 100644 --- a/surfsense_backend/app/routes/search_spaces_routes.py +++ b/surfsense_backend/app/routes/search_spaces_routes.py @@ -51,13 +51,12 @@ team's shared memory document. Apply the user's instruction to the existing \ memory document and output the FULL updated document. RULES: -1. If the instruction asks to add something, add it in the appropriate \ -## section with a (YYYY-MM-DD) date prefix using today's date. +1. If the instruction asks to add something, add it with format: \ +- (YYYY-MM-DD) [fact] text, under an existing or new ## heading. 2. If the instruction asks to remove something, remove the matching entry. 3. If the instruction asks to change something, update the matching entry. -4. Preserve the existing ## section structure and all other entries. -5. NEVER use personal sections like "## About the user", "## Preferences", or - "## Instructions". Team memory must stay team-scoped. +4. Preserve existing ## headings and all other entries. +5. NEVER use [pref] or [instr] markers. Team memory uses [fact] only. 6. Output ONLY the updated markdown — no explanations, no wrapping. diff --git a/surfsense_backend/tests/unit/agents/new_chat/tools/test_update_memory_scope.py b/surfsense_backend/tests/unit/agents/new_chat/tools/test_update_memory_scope.py index caef29623..df824be42 100644 --- a/surfsense_backend/tests/unit/agents/new_chat/tools/test_update_memory_scope.py +++ b/surfsense_backend/tests/unit/agents/new_chat/tools/test_update_memory_scope.py @@ -1,8 +1,12 @@ -"""Unit tests for memory scope validation.""" +"""Unit tests for memory scope validation and bullet format validation.""" import pytest -from app.agents.new_chat.tools.update_memory import _save_memory, _validate_memory_scope +from app.agents.new_chat.tools.update_memory import ( + _save_memory, + _validate_bullet_format, + _validate_memory_scope, +) pytestmark = pytest.mark.unit @@ -23,34 +27,132 @@ class _Recorder: self.rollback_calls += 1 -def test_validate_memory_scope_rejects_user_sections_in_team_scope() -> None: - content = "## About the user\n- (2026-04-10) Student studying DSA\n" +# --------------------------------------------------------------------------- +# _validate_memory_scope — marker-based +# --------------------------------------------------------------------------- + + +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["status"] == "error" - assert "personal sections" in result["message"] + assert "[pref]" in result["message"] -def test_validate_memory_scope_rejects_team_sections_in_user_scope() -> None: - content = "## Team decisions\n- (2026-04-10) Python-first backend policy\n" +def test_validate_memory_scope_rejects_instr_marker_in_team_scope() -> None: + content = "- (2026-04-10) [instr] Always respond in Spanish\n" + result = _validate_memory_scope(content, "team") + assert result is not None + assert result["status"] == "error" + assert "[instr]" in result["message"] + + +def test_validate_memory_scope_rejects_both_personal_markers_in_team() -> None: + content = ( + "- (2026-04-10) [pref] Prefers dark mode\n" + "- (2026-04-10) [instr] Always respond in Spanish\n" + ) + result = _validate_memory_scope(content, "team") + 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 not None - assert result["status"] == "error" - assert "team sections" in result["message"] + assert result is None -def test_validate_memory_scope_normalizes_heading_case_and_spacing() -> None: - content = "## About The User \n- (2026-04-10) Student\n" +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 not None - assert result["status"] == "error" + 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 == [] + + +def test_validate_bullet_format_warns_on_missing_marker() -> None: + content = "- (2026-04-10) 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_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 -async def test_save_memory_blocks_cross_scope_write_before_commit() -> None: +async def test_save_memory_blocks_pref_in_team_before_commit() -> None: recorder = _Recorder() result = await _save_memory( - updated_memory="## About the user\n- (2026-04-10) Student\n", + updated_memory="- (2026-04-10) [pref] Prefers dark mode\n", old_memory=None, llm=None, apply_fn=recorder.apply, @@ -65,9 +167,9 @@ async def test_save_memory_blocks_cross_scope_write_before_commit() -> None: @pytest.mark.asyncio -async def test_save_memory_allows_valid_scope_and_commits() -> None: +async def test_save_memory_allows_fact_in_team_and_commits() -> None: recorder = _Recorder() - content = "## Team decisions\n- (2026-04-10) Python-first backend policy\n" + content = "- (2026-04-10) [fact] Weekly standup on Mondays\n" result = await _save_memory( updated_memory=content, old_memory=None, @@ -81,3 +183,22 @@ async def test_save_memory_allows_valid_scope_and_commits() -> None: assert result["status"] == "saved" assert recorder.commit_calls == 1 assert recorder.applied_content == content + + +@pytest.mark.asyncio +async def test_save_memory_includes_format_warnings() -> None: + recorder = _Recorder() + content = "- (2026-04-10) Missing marker text\n" + result = await _save_memory( + updated_memory=content, + old_memory=None, + llm=None, + apply_fn=recorder.apply, + commit_fn=recorder.commit, + rollback_fn=recorder.rollback, + label="memory", + scope="user", + ) + assert result["status"] == "saved" + assert "format_warnings" in result + assert len(result["format_warnings"]) == 1 diff --git a/surfsense_web/app/dashboard/[search_space_id]/user-settings/components/MemoryContent.tsx b/surfsense_web/app/dashboard/[search_space_id]/user-settings/components/MemoryContent.tsx index 0eb385791..19b4c3e5b 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/user-settings/components/MemoryContent.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/user-settings/components/MemoryContent.tsx @@ -102,7 +102,7 @@ export function MemoryContent() { } }; - const displayMemory = memory.replace(/\(\d{4}-\d{2}-\d{2}\)\s*/g, ""); + const displayMemory = memory.replace(/\(\d{4}-\d{2}-\d{2}\)\s*\[(fact|pref|instr)\]\s*/g, ""); const charCount = memory.length; const getCounterColor = () => { diff --git a/surfsense_web/components/settings/team-memory-manager.tsx b/surfsense_web/components/settings/team-memory-manager.tsx index 4989e606e..525f3320d 100644 --- a/surfsense_web/components/settings/team-memory-manager.tsx +++ b/surfsense_web/components/settings/team-memory-manager.tsx @@ -107,7 +107,7 @@ export function TeamMemoryManager({ searchSpaceId }: TeamMemoryManagerProps) { } }; - const displayMemory = memory.replace(/\(\d{4}-\d{2}-\d{2}\)\s*/g, ""); + const displayMemory = memory.replace(/\(\d{4}-\d{2}-\d{2}\)\s*\[(fact|pref|instr)\]\s*/g, ""); const charCount = memory.length; const getCounterColor = () => {