From f8dadeec59022c836792683a90b63b4b37d02cb8 Mon Sep 17 00:00:00 2001 From: Trip T Date: Wed, 11 Mar 2026 21:11:53 -0400 Subject: [PATCH] feat: tick prompt arg + fix name extraction learning verbs as names Add optional prompt argument to `timmy tick` so custom journal prompts can be passed from the CLI (seed_type="prompted"). Fix extract_user_name() learning verbs as names (e.g. "Serving"). Now requires the candidate word to start with a capital letter in the original message, rejects common verb suffixes (-ing, -tion, etc.), and deduplicates the naive regex in TimmyWithMemory to use the fixed ConversationManager.extract_user_name() instead. Co-Authored-By: Claude Opus 4.6 --- src/timmy/agent.py | 42 ++++++---------------------- src/timmy/cli.py | 10 +++++-- src/timmy/conversation.py | 28 ++++++++++++++----- src/timmy/thinking.py | 15 ++++++++-- tests/timmy/test_conversation.py | 21 ++++++++++++++ tests/timmy/test_thinking.py | 48 ++++++++++++++++++++++++++++++++ 6 files changed, 117 insertions(+), 47 deletions(-) diff --git a/src/timmy/agent.py b/src/timmy/agent.py index 90475b7..20f7b27 100644 --- a/src/timmy/agent.py +++ b/src/timmy/agent.py @@ -310,41 +310,15 @@ class TimmyWithMemory: def _extract_and_store_facts(self, message: str) -> None: """Extract user facts from message and store in memory.""" - message_lower = message.lower() + try: + from timmy.conversation import conversation_manager - # Extract name - name_patterns = [ - ("my name is ", 11), - ("i'm ", 4), - ("i am ", 5), - ("call me ", 8), - ] - - for pattern, offset in name_patterns: - if pattern in message_lower: - idx = message_lower.find(pattern) + offset - name = message[idx:].strip().split()[0].strip(".,!?;:()\"'").capitalize() - if name and len(name) > 1 and name.lower() not in ("the", "a", "an"): - self.memory.update_user_fact("Name", name) - self.memory.record_decision(f"Learned user's name: {name}") - break - - # Extract preferences - pref_patterns = [ - ("i like ", "Likes"), - ("i love ", "Loves"), - ("i prefer ", "Prefers"), - ("i don't like ", "Dislikes"), - ("i hate ", "Dislikes"), - ] - - for pattern, category in pref_patterns: - if pattern in message_lower: - idx = message_lower.find(pattern) + len(pattern) - pref = message[idx:].strip().split(".")[0].strip() - if pref and len(pref) > 3: - self.memory.record_open_item(f"User {category.lower()}: {pref}") - break + name = conversation_manager.extract_user_name(message) + if name: + self.memory.update_user_fact("Name", name) + self.memory.record_decision(f"Learned user's name: {name}") + except Exception: + pass # Best-effort extraction def end_session(self, summary: str = "Session completed") -> None: """End session and write handoff.""" diff --git a/src/timmy/cli.py b/src/timmy/cli.py index a9e9e0a..ae07ea0 100644 --- a/src/timmy/cli.py +++ b/src/timmy/cli.py @@ -23,13 +23,17 @@ _MODEL_SIZE_OPTION = typer.Option( @app.command() -def tick(): - """Run one autonomous thinking cycle (used by systemd timer).""" +def tick( + prompt: str | None = typer.Argument( + None, help="Optional journal prompt for Timmy to reflect on" + ), +): + """Run one thinking cycle. Pass a prompt to ask Timmy a specific question.""" import asyncio from timmy.thinking import thinking_engine - thought = asyncio.run(thinking_engine.think_once()) + thought = asyncio.run(thinking_engine.think_once(prompt=prompt)) if thought: typer.echo(f"[{thought.seed_type}] {thought.content}") else: diff --git a/src/timmy/conversation.py b/src/timmy/conversation.py index 4827784..bc16a99 100644 --- a/src/timmy/conversation.py +++ b/src/timmy/conversation.py @@ -130,8 +130,16 @@ class ConversationManager: } ) + # Verb/adjective suffixes that never appear on real names + _NON_NAME_SUFFIXES = ("ing", "tion", "sion", "ness", "ment", "ful", "less", "ous", "ive", "ble") + def extract_user_name(self, message: str) -> str | None: - """Try to extract user's name from message.""" + """Try to extract user's name from message. + + Requires the candidate word to be capitalized in the original + message (real names are written with a capital letter). Also + rejects words in the blocklist and common verb/adjective suffixes. + """ message_lower = message.lower() # Common patterns @@ -148,15 +156,21 @@ class ConversationManager: remainder = message[idx:].strip() if not remainder: continue - # Take first word as name - name = remainder.split()[0].strip(".,!?;:") - if not name: + # Take first word as name (from original message for case info) + raw_name = remainder.split()[0].strip(".,!?;:") + if not raw_name or len(raw_name) < 2: + continue + # Require first letter to be uppercase in the original text + # (names are capitalized; "I am serving..." is not a name) + if not raw_name[0].isupper(): continue # Reject common verbs, adjectives, and UI-state words - if name.lower() in self._NAME_BLOCKLIST: + if raw_name.lower() in self._NAME_BLOCKLIST: continue - # Capitalize first letter - return name.capitalize() + # Reject words with verb/adjective suffixes + if any(raw_name.lower().endswith(s) for s in self._NON_NAME_SUFFIXES): + continue + return raw_name.capitalize() return None diff --git a/src/timmy/thinking.py b/src/timmy/thinking.py index 55470da..d8de233 100644 --- a/src/timmy/thinking.py +++ b/src/timmy/thinking.py @@ -176,10 +176,15 @@ class ThinkingEngine: except Exception: pass # Fresh start if DB doesn't exist yet - async def think_once(self) -> Thought | None: + async def think_once(self, prompt: str | None = None) -> Thought | None: """Execute one thinking cycle. - 1. Gather a seed context + Args: + prompt: Optional custom seed prompt. When provided, overrides + the random seed selection and uses "prompted" as the + seed type — useful for journal prompts from the CLI. + + 1. Gather a seed context (or use the custom prompt) 2. Build a prompt with continuity from recent thoughts 3. Call the agent 4. Store the thought @@ -188,7 +193,11 @@ class ThinkingEngine: if not settings.thinking_enabled: return None - seed_type, seed_context = self._gather_seed() + if prompt: + seed_type = "prompted" + seed_context = f"Journal prompt: {prompt}" + else: + seed_type, seed_context = self._gather_seed() continuity = self._build_continuity_context() memory_context = self._load_memory_context() diff --git a/tests/timmy/test_conversation.py b/tests/timmy/test_conversation.py index 6ec015e..2d3f292 100644 --- a/tests/timmy/test_conversation.py +++ b/tests/timmy/test_conversation.py @@ -91,6 +91,27 @@ class TestExtractUserName: mgr = ConversationManager() assert mgr.extract_user_name("My name is Eve.") == "Eve" + def test_rejects_serving(self): + mgr = ConversationManager() + assert mgr.extract_user_name("I am serving one person deeply") is None + + def test_rejects_common_verbs(self): + mgr = ConversationManager() + assert mgr.extract_user_name("I am building a dashboard") is None + assert mgr.extract_user_name("I'm testing the system") is None + assert mgr.extract_user_name("I am having a great day") is None + + def test_rejects_lowercase_after_pattern(self): + """Real names start with a capital letter in the original message.""" + mgr = ConversationManager() + assert mgr.extract_user_name("I am really confused") is None + assert mgr.extract_user_name("I'm so tired today") is None + + def test_accepts_capitalized_name(self): + mgr = ConversationManager() + assert mgr.extract_user_name("I am Alexander") == "Alexander" + assert mgr.extract_user_name("My name is Timmy") == "Timmy" + class TestShouldUseTools: """Test tool usage detection.""" diff --git a/tests/timmy/test_thinking.py b/tests/timmy/test_thinking.py index 131fb6d..3826269 100644 --- a/tests/timmy/test_thinking.py +++ b/tests/timmy/test_thinking.py @@ -524,6 +524,54 @@ async def test_think_once_memory_update_graceful_on_failure(tmp_path): assert engine.count_thoughts() == 1 +# --------------------------------------------------------------------------- +# Custom prompt override +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_think_once_with_custom_prompt(tmp_path): + """think_once(prompt=...) should use the custom prompt as the seed context.""" + engine = _make_engine(tmp_path) + + captured_prompts = [] + + def capture_agent(prompt): + captured_prompts.append(prompt) + return "Alexander values sovereignty above all." + + with ( + patch.object(engine, "_call_agent", side_effect=capture_agent), + patch.object(engine, "_log_event"), + patch.object(engine, "_update_memory"), + patch.object(engine, "_broadcast", new_callable=AsyncMock), + ): + thought = await engine.think_once(prompt="What does Alexander care about most?") + + assert thought is not None + assert thought.seed_type == "prompted" + assert "What does Alexander care about most?" in captured_prompts[0] + + +@pytest.mark.asyncio +async def test_think_once_custom_prompt_stored_in_journal(tmp_path): + """A prompted thought should be stored and journaled like any other.""" + engine = _make_engine(tmp_path) + + with ( + patch.object(engine, "_call_agent", return_value="Deep answer."), + patch.object(engine, "_log_event"), + patch.object(engine, "_update_memory"), + patch.object(engine, "_broadcast", new_callable=AsyncMock), + ): + thought = await engine.think_once(prompt="Reflect on memory.") + + assert thought is not None + assert engine.count_thoughts() == 1 + stored = engine.get_thought(thought.id) + assert stored.seed_type == "prompted" + + # --------------------------------------------------------------------------- # Dashboard route # ---------------------------------------------------------------------------