Merge pull request 'feat: tick prompt arg + fix name extraction' (#4) from claude/suspicious-poincare into main

Reviewed-on: http://localhost:3000/rockachopa/Timmy-time-dashboard/pulls/4
This commit is contained in:
rockachopa
2026-03-11 21:18:05 -04:00
6 changed files with 117 additions and 47 deletions

View File

@@ -310,41 +310,15 @@ class TimmyWithMemory:
def _extract_and_store_facts(self, message: str) -> None: def _extract_and_store_facts(self, message: str) -> None:
"""Extract user facts from message and store in memory.""" """Extract user facts from message and store in memory."""
message_lower = message.lower() try:
from timmy.conversation import conversation_manager
# Extract name name = conversation_manager.extract_user_name(message)
name_patterns = [ if name:
("my name is ", 11), self.memory.update_user_fact("Name", name)
("i'm ", 4), self.memory.record_decision(f"Learned user's name: {name}")
("i am ", 5), except Exception:
("call me ", 8), pass # Best-effort extraction
]
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
def end_session(self, summary: str = "Session completed") -> None: def end_session(self, summary: str = "Session completed") -> None:
"""End session and write handoff.""" """End session and write handoff."""

View File

@@ -23,13 +23,17 @@ _MODEL_SIZE_OPTION = typer.Option(
@app.command() @app.command()
def tick(): def tick(
"""Run one autonomous thinking cycle (used by systemd timer).""" 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 import asyncio
from timmy.thinking import thinking_engine from timmy.thinking import thinking_engine
thought = asyncio.run(thinking_engine.think_once()) thought = asyncio.run(thinking_engine.think_once(prompt=prompt))
if thought: if thought:
typer.echo(f"[{thought.seed_type}] {thought.content}") typer.echo(f"[{thought.seed_type}] {thought.content}")
else: else:

View File

@@ -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: 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() message_lower = message.lower()
# Common patterns # Common patterns
@@ -148,15 +156,21 @@ class ConversationManager:
remainder = message[idx:].strip() remainder = message[idx:].strip()
if not remainder: if not remainder:
continue continue
# Take first word as name # Take first word as name (from original message for case info)
name = remainder.split()[0].strip(".,!?;:") raw_name = remainder.split()[0].strip(".,!?;:")
if not name: 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 continue
# Reject common verbs, adjectives, and UI-state words # Reject common verbs, adjectives, and UI-state words
if name.lower() in self._NAME_BLOCKLIST: if raw_name.lower() in self._NAME_BLOCKLIST:
continue continue
# Capitalize first letter # Reject words with verb/adjective suffixes
return name.capitalize() if any(raw_name.lower().endswith(s) for s in self._NON_NAME_SUFFIXES):
continue
return raw_name.capitalize()
return None return None

View File

@@ -176,10 +176,15 @@ class ThinkingEngine:
except Exception: except Exception:
pass # Fresh start if DB doesn't exist yet 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. """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 2. Build a prompt with continuity from recent thoughts
3. Call the agent 3. Call the agent
4. Store the thought 4. Store the thought
@@ -188,7 +193,11 @@ class ThinkingEngine:
if not settings.thinking_enabled: if not settings.thinking_enabled:
return None 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() continuity = self._build_continuity_context()
memory_context = self._load_memory_context() memory_context = self._load_memory_context()

View File

@@ -91,6 +91,27 @@ class TestExtractUserName:
mgr = ConversationManager() mgr = ConversationManager()
assert mgr.extract_user_name("My name is Eve.") == "Eve" 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: class TestShouldUseTools:
"""Test tool usage detection.""" """Test tool usage detection."""

View File

@@ -524,6 +524,54 @@ async def test_think_once_memory_update_graceful_on_failure(tmp_path):
assert engine.count_thoughts() == 1 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 # Dashboard route
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------