From 9d3e178e7fc9b944bf0428d6cbe863721fbdcfb4 Mon Sep 17 00:00:00 2001 From: Asish Kumar Date: Tue, 10 Nov 2026 16:26:21 +0000 Subject: [PATCH 1/2] fix(memory): keep the user's name out of the persona in a turn without memory Onboarding renders the user's name into SOUL.md ("You are Sentient, Maya's chief of staff"). A turn without memory (/v1 with openai_api.allow_memory off, or an incognito chat with incognito_uses_memory off) still put SOUL.md in the prompt as the persona, so "what's my name?" could be answered from it. The prompt now replaces the configured user name in SOUL.md with "the user" (whole word, any case) when memory is off, and keeps the rest of the personality. Normal chats are unchanged. Fixes #552 Signed-off-by: Asish Kumar --- changes/552.fixed.md | 3 +++ sentient/agent/loop.py | 9 ++++++--- sentient/agent/prompt.py | 10 ++++++++++ tests/test_memory_off_turns.py | 33 +++++++++++++++++++++++++++++++++ 4 files changed, 52 insertions(+), 3 deletions(-) create mode 100644 changes/552.fixed.md diff --git a/changes/552.fixed.md b/changes/552.fixed.md new file mode 100644 index 000000000..0212b6cc6 --- /dev/null +++ b/changes/552.fixed.md @@ -0,0 +1,3 @@ +**Connected apps and memory-off chats no longer see your name in Sentient's personality.** The name you gave at setup is +written into the personality file, so a chat with memory off (a connected app, or an incognito chat) could still reveal +it. Now that chat reads "the user" there, and keeps the rest of the personality. Normal chats still use your name. diff --git a/sentient/agent/loop.py b/sentient/agent/loop.py index 8dfc39011..2a735468b 100644 --- a/sentient/agent/loop.py +++ b/sentient/agent/loop.py @@ -56,7 +56,7 @@ from sentient.agent import undo as undo_rules from sentient.agent.approvals import ApprovalBroker from sentient.agent.chat_rules import WAIT_S, pending_asks, standing_text -from sentient.agent.prompt import build_system_prompt, frame_api_request, mark_outside +from sentient.agent.prompt import build_system_prompt, frame_api_request, mark_outside, without_name from sentient.agent.sources import AnswerSources from sentient.agent.toolselect import ToolSelector, is_local_model from sentient.app_names import clean_app_name, framed_app_name @@ -794,8 +794,11 @@ async def _system_prompt_with_sources( sources.add_facts(facts) sources.add_insights(insights) snapshot = self.workspace.snapshot() - if not reads_memory: # only the persona: no profile, memory file or daily notes - snapshot = {k: (v if k == "soul" else "") for k, v in snapshot.items()} + if not reads_memory: # only the persona, without the user's name: no profile, memory file or daily notes (#552) + snapshot = { + k: (without_name(v, self.config.assistant.user_name) if k == "soul" else "") + for k, v in snapshot.items() + } elif not personal: # MEMORY.md, daily notes and USER.md's learned facts come from personal memories (#155) snapshot = { **snapshot, "memory": "", "today": "", "yesterday": "", diff --git a/sentient/agent/prompt.py b/sentient/agent/prompt.py index c8c99ba51..9c338edfd 100644 --- a/sentient/agent/prompt.py +++ b/sentient/agent/prompt.py @@ -27,6 +27,16 @@ - Keep replies short. Plain language. Match the user's tone. """ + +def without_name(text: str, name: str) -> str: + """``text`` with the user's ``name`` replaced by "the user" (#552), for a turn that must not reveal who they are: + SOUL.md is written with the name in it ("You are Sentient, Maya's assistant..."). Whole-word, any case.""" + name = (name or "").strip() + if not name: + return text + return re.sub(rf"(? Date: Tue, 10 Nov 2026 22:14:39 +0530 Subject: [PATCH 2/2] fix(memory): carry a changed name into the persona Settings can change assistant.user_name without rewriting SOUL.md, so the old name stayed in the persona and a turn without memory could still reveal it. save_config now replaces the previous name in SOUL.md with the new one, or with "the user" when the name is cleared. Only that name changes; the rest of the file is kept. --- sentient/agent/prompt.py | 13 +++++++++---- sentient/app.py | 13 +++++++++++++ tests/test_memory_off_turns.py | 14 ++++++++++++++ 3 files changed, 36 insertions(+), 4 deletions(-) diff --git a/sentient/agent/prompt.py b/sentient/agent/prompt.py index 9c338edfd..27e1a82aa 100644 --- a/sentient/agent/prompt.py +++ b/sentient/agent/prompt.py @@ -28,13 +28,18 @@ """ -def without_name(text: str, name: str) -> str: - """``text`` with the user's ``name`` replaced by "the user" (#552), for a turn that must not reveal who they are: - SOUL.md is written with the name in it ("You are Sentient, Maya's assistant..."). Whole-word, any case.""" +def replace_name(text: str, name: str, replacement: str) -> str: + """``text`` with the user's ``name`` replaced by ``replacement``. Whole-word, any case.""" name = (name or "").strip() if not name: return text - return re.sub(rf"(? str: + """``text`` with the user's ``name`` replaced by "the user" (#552), for a turn that must not reveal who they are: + SOUL.md is written with the name in it ("You are Sentient, Maya's assistant...").""" + return replace_name(text, name, "the user") # ---------------------------------------------------------------------- outside content (spotlighting, ADR 0023) diff --git a/sentient/app.py b/sentient/app.py index 33fcf7a86..7a59ca048 100644 --- a/sentient/app.py +++ b/sentient/app.py @@ -32,6 +32,7 @@ from sentient.agent.grants import GrantStore from sentient.agent.incognito import IncognitoSweeper from sentient.agent.loop import Agent +from sentient.agent.prompt import replace_name from sentient.agent.subagents import SubagentManager from sentient.agent.undo import UndoBroker from sentient.backup import BackupService @@ -184,10 +185,12 @@ async def user_seen(self, surface: str) -> None: def save_config(self, config: SentientConfig | None = None) -> None: """Persist and hot-apply configuration.""" + previous_name = self.config.assistant.user_name if config is not None: self.config = config moved = self._claude_code_switched() save_config(self.config) + self._carry_name_into_persona(previous_name, self.config.assistant.user_name) if self.config.integrations.mcp_server_enabled: # turning the MCP server on issues its own narrow token in the keychain (#443, ADR 0034); # a keychain problem must never break saving settings, the status endpoint reports it @@ -210,6 +213,16 @@ def save_config(self, config: SentientConfig | None = None) -> None: if moved: self._note_claude_code(moved) + def _carry_name_into_persona(self, previous: str, current: str) -> None: + """SOUL.md is written with the user's name (#552): when the name changes in settings, the persona follows it, + so a turn without memory never shows the old name. Only that name is replaced; the rest of the file stays.""" + if not previous.strip() or previous.strip() == current.strip() or not self.workspace.soul.exists(): + return + text = self.workspace.soul.read_text(encoding="utf-8") + updated = replace_name(text, previous, current.strip() or "the user") + if updated != text: + self.workspace.write("soul", updated) + def _claude_code_switched(self) -> list[dict]: """Claude Code doing everything (#444, ADR 0032) was just turned off: move the roles still on it that refuse it now to another model (in place, before saving). Returns what moved, or [] when nothing changed.""" diff --git a/tests/test_memory_off_turns.py b/tests/test_memory_off_turns.py index 25847f168..e0e8be948 100644 --- a/tests/test_memory_off_turns.py +++ b/tests/test_memory_off_turns.py @@ -238,6 +238,20 @@ async def test_the_v1_api_without_memory_does_not_reveal_the_name_from_soul(app) assert "Maya" not in prompt +async def test_renaming_the_user_in_settings_moves_the_persona_to_the_new_name(app): + """The settings routes change the name without rewriting SOUL.md, so the old name must not stay behind (#552).""" + from sentient.memory.personas import render_persona + + app.workspace.write("soul", render_persona("professional", name="Sentient", user="Maya")) + cfg = app.config.model_copy(deep=True) + cfg.assistant.user_name = "Sam" + app.save_config(cfg) + assert "Maya" not in app.workspace.snapshot()["soul"] + assert "Sam's chief of staff" in app.workspace.snapshot()["soul"] + app.config.openai_api.allow_memory = False + assert "Maya" not in await app.agent.system_prompt("what's my name?", "api") + + def test_without_name_replaces_whole_words_in_any_case(): from sentient.agent.prompt import without_name