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:
|
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."""
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
@@ -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."""
|
||||||
|
|||||||
@@ -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
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
Reference in New Issue
Block a user