forked from Rockachopa/Timmy-time-dashboard
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:
@@ -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."""
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user