Skip to content

Commit e676046

Browse files
committed
feat: add welcome tool, enhance event_bus, whatsapp, TUI
- New welcome tool for dynamic landing UI with .welcome/.hushlogin support - Enhanced event_bus with context injection - Expanded whatsapp tool capabilities - TUI improvements - Telegram tool refinements - Landing page updates
1 parent 4799d82 commit e676046

8 files changed

Lines changed: 674 additions & 40 deletions

File tree

devduck/__init__.py

Lines changed: 32 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1926,10 +1926,10 @@ def __init__(
19261926
# Append to default tools if any server tools are needed
19271927
if server_tools_needed:
19281928
server_tools_str = ",".join(server_tools_needed)
1929-
default_tools = f"devduck.tools:system_prompt,use_github,listen,speech_to_speech,telegram,whatsapp,use_computer,browse,fetch_github_tool,manage_tools,manage_messages,service,tunnel,tasks,scheduler,websocket,zenoh_peer,zcm_peer,ambient_mode,notify,identity,openapi,inspect,{server_tools_str};strands_tools:shell"
1929+
default_tools = f"devduck.tools:welcome,system_prompt,use_github,listen,speech_to_speech,telegram,whatsapp,use_computer,browse,fetch_github_tool,manage_tools,manage_messages,service,tunnel,tasks,scheduler,websocket,zenoh_peer,zcm_peer,ambient_mode,notify,identity,{server_tools_str};strands_tools:shell"
19301930
logger.info(f"Auto-added server tools: {server_tools_str}")
19311931
else:
1932-
default_tools = "devduck.tools:system_prompt,browse,fetch_github_tool,manage_tools,manage_messages,service,tunnel,scheduler,websocket,zenoh_peer,zcm_peer,ambient_mode,notify,identity,openapi,inspect;strands_tools:shell"
1932+
default_tools = "devduck.tools:welcome,system_prompt,browse,fetch_github_tool,manage_tools,manage_messages,service,tunnel,scheduler,websocket,zenoh_peer,ambient_mode,notify,identity,openapi;strands_tools:shell"
19331933

19341934
tools_config = os.getenv("DEVDUCK_TOOLS", default_tools)
19351935
logger.info(f"Loading tools from config: {tools_config}")
@@ -3441,23 +3441,41 @@ def interactive():
34413441
from prompt_toolkit.completion import WordCompleter
34423442
from prompt_toolkit.history import FileHistory
34433443

3444-
# 🦆 Render beautiful landing UI
3444+
# 🦆 Render beautiful landing UI (honors .hushlogin + $CWD/.welcome)
3445+
# There is NO static welcome — content comes from devduck.tools.welcome
3446+
try:
3447+
from devduck.tools.welcome import is_hushed as _is_hushed
3448+
except Exception:
3449+
_is_hushed = lambda: False
3450+
34453451
try:
34463452
from devduck.landing import render_landing
34473453
render_landing(devduck)
34483454
except Exception as e:
3449-
# Fallback to plain text if rich UI fails
3455+
# Fallback — still honor hushlogin
34503456
logger.warning(f"Landing UI failed, using fallback: {e}")
3451-
print("🦆 DevDuck")
3452-
print(f"📝 Logs: {LOG_DIR}")
3453-
if devduck.ambient:
3454-
print(f"🌙 Ambient mode: ON (idle: {devduck.ambient.idle_threshold}s)")
3455-
recorder = get_session_recorder()
3456-
if recorder and recorder.recording:
3457-
print(f"🎬 Recording: ON ({recorder.session_id})")
3458-
print("Type 'exit', 'quit', or 'q' to quit.")
3459-
print("Commands: 'record' (toggle recording), 'ambient' (toggle), '!' (shell)")
3460-
print()
3457+
if not _is_hushed():
3458+
# Minimal dynamic welcome (avoids static message)
3459+
try:
3460+
from devduck.tools.welcome import get_welcome_text
3461+
wt = get_welcome_text()
3462+
if wt:
3463+
print(wt)
3464+
print()
3465+
except Exception:
3466+
pass
3467+
print("🦆 DevDuck")
3468+
print(f"📝 Logs: {LOG_DIR}")
3469+
if devduck.ambient:
3470+
print(f"🌙 Ambient mode: ON (idle: {devduck.ambient.idle_threshold}s)")
3471+
recorder = get_session_recorder()
3472+
if recorder and recorder.recording:
3473+
print(f"🎬 Recording: ON ({recorder.session_id})")
3474+
print("Type 'exit', 'quit', or 'q' to quit.")
3475+
print("Commands: 'record' (toggle recording), 'ambient' (toggle), '!' (shell)")
3476+
print()
3477+
else:
3478+
print(f"🦆 DevDuck ready · {getattr(devduck, 'model', '?')} · type 'exit' to quit")
34613479

34623480
logger.info("Interactive mode started")
34633481

devduck/landing.py

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,20 @@
1616
from rich.columns import Columns
1717
from rich.text import Text
1818
from rich.align import Align
19+
from rich.markdown import Markdown
1920
from rich import box
2021

22+
# Dynamic welcome — NOT static! Honors $CWD/.welcome + .hushlogin
23+
try:
24+
from devduck.tools.welcome import get_welcome_text, is_hushed, has_custom_welcome
25+
except Exception:
26+
def get_welcome_text() -> str:
27+
return ""
28+
def is_hushed() -> bool:
29+
return False
30+
def has_custom_welcome() -> bool:
31+
return False
32+
2133

2234
console = Console()
2335

@@ -58,9 +70,26 @@ def _status_dot(ok: bool) -> str:
5870

5971

6072
def render_landing(devduck_instance):
61-
"""Render the full landing dashboard."""
73+
"""Render the full landing dashboard.
74+
75+
Honors `.hushlogin` (Unix convention): if `$CWD/.hushlogin` or
76+
`$HOME/.hushlogin` exists, or `DEVDUCK_HUSHLOGIN=true`, the landing
77+
screen is suppressed entirely — only a single-line status is printed.
78+
79+
The welcome content itself is dynamic — sourced from `$CWD/.welcome`
80+
via `devduck.tools.welcome.get_welcome_text()`. There is no static
81+
welcome message anywhere.
82+
"""
6283
from devduck import LOG_DIR, get_session_recorder
6384

85+
# 🤫 Hushlogin → minimal output, skip the big dashboard
86+
if is_hushed():
87+
console.print(
88+
f"[dim]🦆 DevDuck ready · [/][bright_yellow]{getattr(devduck_instance, 'model', '?')}[/]"
89+
f"[dim] · {len(getattr(devduck_instance, 'tools', []))} tools · type 'exit' to quit[/]"
90+
)
91+
return
92+
6493
console.clear()
6594

6695
# ── Header ──────────────────────────────────────────────────
@@ -300,6 +329,27 @@ def render_landing(devduck_instance):
300329
padding=(0, 0),
301330
))
302331

332+
# ── Dynamic Welcome (from $CWD/.welcome or default) ────────
333+
try:
334+
welcome_text = get_welcome_text()
335+
if welcome_text:
336+
try:
337+
welcome_body = Markdown(welcome_text)
338+
except Exception:
339+
welcome_body = Text(welcome_text)
340+
341+
subtitle = "[dim italic]custom · $CWD/.welcome[/]" if has_custom_welcome() else "[dim italic]default · welcome(action='edit', content=...)[/]"
342+
console.print(Panel(
343+
welcome_body,
344+
title="[bold]📜 Welcome[/]",
345+
subtitle=subtitle,
346+
border_style="bright_magenta",
347+
box=box.ROUNDED,
348+
padding=(1, 2),
349+
))
350+
except Exception:
351+
pass
352+
303353
# ── Footer ──────────────────────────────────────────────────
304354
now = datetime.now()
305355
footer = Text()

devduck/tools/event_bus.py

Lines changed: 119 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121
from collections import deque
2222
from datetime import datetime
2323
from typing import Any, Callable, Dict, List, Optional
24-
24+
from strands import tool
2525

2626
# ── Event Types ─────────────────────────────────────────────────
2727
# Standardized event type constants for consistency
@@ -254,3 +254,121 @@ def emit(event_type: str, source: str, summary: str, detail: str = "",
254254
metadata: Optional[Dict[str, Any]] = None) -> Event:
255255
"""Shorthand: push an event to the global bus."""
256256
return bus.emit(event_type, source, summary, detail, metadata)
257+
258+
@tool
259+
def event_bus(
260+
action: str = "recent",
261+
event_type: str = None,
262+
source: str = "agent",
263+
summary: str = "",
264+
detail: str = "",
265+
count: int = 30,
266+
seconds: float = 300,
267+
max_age_seconds: float = 300,
268+
) -> Dict[str, Any]:
269+
"""🔔 Inspect and control the unified DevDuck event bus.
270+
271+
All background tools (telegram, whatsapp, scheduler, tasks, listen,
272+
notify, zenoh, speech) push events here. The TUI sidebar and agent
273+
context reads from it.
274+
275+
Actions:
276+
- "recent": List last N events (count=30)
277+
- "by_type": Filter by event_type (e.g. "telegram.in")
278+
- "since": Events from last N seconds
279+
- "context": Formatted context block (as injected to agent)
280+
- "emit": Push a custom event (requires event_type+summary)
281+
- "clear": Wipe the buffer
282+
- "stats": Counter, size, event type distribution
283+
284+
Args:
285+
action: One of the actions above
286+
event_type: For "by_type" filter or "emit" (e.g. "custom.note")
287+
source: Source label when emitting (default: "agent")
288+
summary: Short one-liner (required for emit)
289+
detail: Longer detail text (optional for emit)
290+
count: How many events to return (recent/by_type)
291+
seconds: Time window for "since"
292+
max_age_seconds: Time window for "context"
293+
294+
Returns:
295+
Dict with status and content.
296+
"""
297+
try:
298+
if action == "recent":
299+
events = bus.recent(count=count)
300+
if not events:
301+
return {"status": "success", "content": [{"text": "No events in bus."}]}
302+
lines = [f"📋 {len(events)} recent events (total emitted: {bus.count}):"]
303+
for e in events:
304+
detail_preview = f" — {e.detail[:100]}" if e.detail else ""
305+
lines.append(f"[{e.time_str}] {e.icon} {e.source} | {e.event_type}: {e.summary}{detail_preview}")
306+
return {"status": "success", "content": [{"text": "\n".join(lines)}]}
307+
308+
elif action == "by_type":
309+
if not event_type:
310+
return {"status": "error", "content": [{"text": "event_type required for by_type"}]}
311+
events = bus.recent_by_type(event_type, count=count)
312+
if not events:
313+
return {"status": "success", "content": [{"text": f"No events of type '{event_type}'."}]}
314+
lines = [f"📋 {len(events)} events of type '{event_type}':"]
315+
for e in events:
316+
lines.append(f"[{e.time_str}] {e.icon} {e.source}: {e.summary}")
317+
return {"status": "success", "content": [{"text": "\n".join(lines)}]}
318+
319+
elif action == "since":
320+
events = bus.recent_since(seconds)
321+
if not events:
322+
return {"status": "success", "content": [{"text": f"No events in last {seconds}s."}]}
323+
lines = [f"📋 {len(events)} events in last {seconds}s:"]
324+
for e in events:
325+
lines.append(f"[{e.time_str}] {e.icon} {e.source} | {e.event_type}: {e.summary}")
326+
return {"status": "success", "content": [{"text": "\n".join(lines)}]}
327+
328+
elif action == "context":
329+
ctx = bus.get_context_string(max_events=count, max_age_seconds=max_age_seconds)
330+
if not ctx:
331+
return {"status": "success", "content": [{"text": "No recent events for context."}]}
332+
return {"status": "success", "content": [{"text": ctx}]}
333+
334+
elif action == "emit":
335+
if not event_type or not summary:
336+
return {"status": "error", "content": [{"text": "emit requires event_type + summary"}]}
337+
e = bus.emit(event_type, source, summary, detail)
338+
return {"status": "success", "content": [{"text": f"✅ Emitted [{e.time_str}] {e.icon} {source} | {event_type}: {summary}"}]}
339+
340+
elif action == "clear":
341+
prev = bus.size
342+
bus.clear()
343+
return {"status": "success", "content": [{"text": f"🗑 Cleared {prev} events."}]}
344+
345+
elif action == "stats":
346+
events = bus.recent(count=bus.size)
347+
type_counts: Dict[str, int] = {}
348+
source_counts: Dict[str, int] = {}
349+
for e in events:
350+
type_counts[e.event_type] = type_counts.get(e.event_type, 0) + 1
351+
source_counts[e.source] = source_counts.get(e.source, 0) + 1
352+
lines = [
353+
f"📊 Event Bus Stats",
354+
f" Total emitted: {bus.count}",
355+
f" Currently buffered: {bus.size}",
356+
f" Subscribers: {len(bus._subscribers)}",
357+
"",
358+
"By type:",
359+
]
360+
for t, c in sorted(type_counts.items(), key=lambda x: -x[1]):
361+
lines.append(f" {c:4d} {t}")
362+
lines.append("\nBy source:")
363+
for s, c in sorted(source_counts.items(), key=lambda x: -x[1]):
364+
lines.append(f" {c:4d} {s}")
365+
return {"status": "success", "content": [{"text": "\n".join(lines)}]}
366+
367+
else:
368+
return {
369+
"status": "error",
370+
"content": [{"text": f"Unknown action '{action}'. Valid: recent, by_type, since, context, emit, clear, stats"}],
371+
}
372+
373+
except Exception as e:
374+
return {"status": "error", "content": [{"text": f"event_bus error: {e}"}]}

devduck/tools/telegram.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,7 @@ def _store_event(event_data: Dict):
105105
logger.error(f"Error storing telegram event: {e}")
106106

107107

108-
def _get_recent_events(count: int = 20) -> List[Dict]:
108+
def _get_recent_events(count: int = 100) -> List[Dict]:
109109
"""Read last N events from JSONL."""
110110
if not EVENTS_FILE.exists():
111111
return []
@@ -268,10 +268,10 @@ def _process_message(message: Dict, bot_token: str):
268268
invoker = connection_devduck
269269

270270
# Build context with recent events
271-
event_count = int(os.getenv("TELEGRAM_DEFAULT_EVENT_COUNT", "20"))
271+
event_count = int(os.getenv("TELEGRAM_DEFAULT_EVENT_COUNT", "100"))
272272
recent = _get_recent_events(event_count)
273273
event_ctx = (
274-
f"\nRecent Telegram Events:\n{json.dumps(recent[-5:], indent=2)}"
274+
f"\nRecent Telegram Events:\n{json.dumps(recent[-100:], indent=2)}"
275275
if recent
276276
else ""
277277
)

0 commit comments

Comments
 (0)