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:
"""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."""

View File

@@ -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:

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:
"""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

View File

@@ -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()

View File

@@ -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."""

View File

@@ -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
# ---------------------------------------------------------------------------