Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,14 @@ TIMEZONE=America/Los_Angeles
# Display name spoken by CAAL (optional, defaults to "Pacific Time")
TIMEZONE_DISPLAY=Pacific Time

# =============================================================================
# Chat API (Headless Mode)
# =============================================================================
# System prompt for the /api/chat endpoint. Resolves to prompt/{language}/{CHAT_PROMPT}.md
# "headless" uses text-optimized formatting (numbers, dates, markdown).
# Leave unset to use the default voice prompt.
#CHAT_PROMPT=headless

# =============================================================================
# Apple Silicon Setup (M1/M2/M3/M4)
# =============================================================================
Expand Down
119 changes: 119 additions & 0 deletions integrations/openwebui/caal_offload.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
"""
title: CAAL Tool Offload
author: AbdulShahzeb
version: 0.2
required_open_webui_version: 0.3.9
"""

from pydantic import BaseModel, Field
from typing import Optional
import requests


class Filter:
class Valves(BaseModel):
caal_url: str = Field(
default="http://172.17.0.1:8889",
description="CAAL server URL (host:port, no trailing slash)",
)
timeout: int = Field(
default=120,
description="Request timeout in seconds",
)
tool_keywords: str = Field(
default="hey caal",
description="Comma-separated phrases that trigger CAAL (case-insensitive)",
)
reload_keywords: str = Field(
default="reload caal,caal reload",
description="Comma-separated phrases that trigger CAAL reload (case-insensitive)",
)

def __init__(self):
self.valves = self.Valves()

def _should_route(self, message: str) -> bool:
message_lower = message.lower()
keywords = [k.strip().lower() for k in self.valves.tool_keywords.split(",")]
return any(kw in message_lower for kw in keywords if kw)

def _should_reload(self, message: str) -> bool:
message_lower = message.lower().strip()
keywords = [k.strip().lower() for k in self.valves.reload_keywords.split(",")]
return any(kw == message_lower for kw in keywords if kw)

def _reload_caal(self) -> str:
try:
resp = requests.post(
f"{self.valves.caal_url}/api/chat/reload",
timeout=30,
)
if resp.status_code == 200:
data = resp.json()
return (
f"CAAL reloaded. Provider: {data.get('llm_provider', '?')}, "
f"model: {data.get('llm_model', '?')}, "
f"tools: {data.get('tools_loaded', '?')}, "
f"sessions cleared: {data.get('sessions_cleared', '?')}"
)
else:
return f"[CAAL reload error: HTTP {resp.status_code}]"
except requests.exceptions.ConnectionError:
return f"[CAAL reload error: cannot reach {self.valves.caal_url}]"
except Exception as e:
return f"[CAAL reload error: {e}]"

def _call_caal(self, message: str) -> str:
try:
resp = requests.post(
f"{self.valves.caal_url}/api/chat",
json={
"text": message,
"reuse_session": True,
},
timeout=self.valves.timeout,
)

if resp.status_code == 200:
data = resp.json()
return data.get("response", "")
else:
return f"[CAAL error: HTTP {resp.status_code}]"

except requests.exceptions.Timeout:
return "[CAAL error: request timed out]"
except requests.exceptions.ConnectionError:
return f"[CAAL error: cannot reach {self.valves.caal_url}]"
except Exception as e:
return f"[CAAL error: {e}]"

def inlet(self, body: dict, __user__: Optional[dict] = None) -> dict:
messages = body.get("messages", [])
if not messages:
return body

last = messages[-1]
if last.get("role") != "user":
return body

user_text = last.get("content", "")

if self._should_reload(user_text):
print("[CAAL Filter] reload keyword detected, reloading CAAL")
caal_response = self._reload_caal()
elif self._should_route(user_text):
print("[CAAL Filter] keyword detected, routing to CAAL")
caal_response = self._call_caal(user_text)
else:
return body

last["content"] = (
"OUTPUT ONLY THE FOLLOWING TEXT EXACTLY AS WRITTEN. "
"DO NOT ADD ANYTHING. DO NOT REMOVE ANYTHING. "
"DO NOT PARAPHRASE. COPY THIS EXACTLY:\n\n"
f"{caal_response}"
)
return body

def outlet(self, body: dict, __user__: Optional[dict] = None) -> dict:
return body
65 changes: 65 additions & 0 deletions prompt/en/headless.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
# Assistant

You are an ACTION-ORIENTED assistant. {{CURRENT_DATE_CONTEXT}}

When asked to do something:
1. If you have a tool → CALL IT immediately
2. If no tool exists → Say so and offer to create one
3. NEVER say "I'll do that" or "Would you like me to..." - just DO IT

# Tool Priority

Answer questions in this order:

1. **Tools** - Device control, workflows, environment queries
2. **Web search** - Current events, news, prices, hours, scores, anything time-sensitive
3. **General knowledge** - Only for static facts that never change

Your training data is outdated. If the answer could change over time, use a tool or web_search.

# Home Control (hass)

Control devices or check status with: `hass(action, target, value)`
- **action**: status, turn_on, turn_off, volume_up, volume_down, set_volume, mute, unmute, pause, play, next, previous
- **target**: Device name like "office lamp" or "apple tv" (optional for status)
- **value**: Only for set_volume (0-100)

Examples:
- "turn on the office lamp" → `hass(action="turn_on", target="office lamp")`
- "set apple tv volume to 50" → `hass(action="set_volume", target="apple tv", value=50)`
- "is the garage door open?" → `hass(action="status", target="garage door")`

Act immediately - don't ask for confirmation. Confirm AFTER the action completes.

# Tool Response Handling

CRITICAL: When a tool returns JSON with a `message` field, relay ONLY that message.
Do NOT read or summarize any other fields (players, books, games, etc.).
Those arrays are for follow-up questions only - never dump them unprompted.

# Text Output

Responses are displayed as text. Use markdown where it improves readability.

- Numbers: "72°" not "seventy-two degrees"
- Dates: "31 Jan" or "31/1" not "January thirty-first"
- Times: "4:30 PM" not "four thirty PM"
- Currency: "$12.50" not "twelve dollars and fifty cents"
- Keep responses concise

# Tool Capabilities

- If you lack a tool for a request, say: "I don't have a tool for that. Want me to create one?"
- You can create new tools using n8n_create_caal_tool
- Don't list your capabilities unprompted

# Rules

- If an action requires data you don't have (email address, user ID, tweet ID), look it up first with the appropriate tool before acting
- CALL tools for actions - never pretend or describe what you would do
- Speaking about an action is not the same as performing it
- If corrected, retry the tool immediately with fixed input
- Ask for clarification only when truly ambiguous (e.g., multiple devices with similar names)
- No filler phrases like "Let me check..." or "Would you like me to..."
- Don't suggest further actions - just respond to what was asked
- It's okay to provide your opinion when asked.
15 changes: 13 additions & 2 deletions src/caal/chat/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@
class ChatRequest(BaseModel):
text: str
session_id: str | None = None
reuse_session: bool = False
dry_run: bool = False # Reserved for v2
verbose: bool = False

Expand Down Expand Up @@ -218,13 +219,16 @@ async def _ensure_initialized() -> None:
f"({runtime.get('ollama_model', '')})"
)

# Load system prompt with date/time context (same as voice path)
# Load system prompt with date/time context
# CHAT_PROMPT selects a named prompt file (e.g. "headless" → prompt/en/headless.md)
timezone_id = os.getenv("TIMEZONE", "America/Los_Angeles")
timezone_display = os.getenv("TIMEZONE_DISPLAY", "Pacific Time")
chat_prompt_name = os.getenv("CHAT_PROMPT") or None
_prompt = settings_module.load_prompt_with_context(
timezone_id=timezone_id,
timezone_display=timezone_display,
language=runtime.get("language", "en"),
prompt_name=chat_prompt_name,
)

# Short-term memory (shared singleton, reload for cross-process sync)
Expand Down Expand Up @@ -348,8 +352,15 @@ async def chat(req: ChatRequest) -> ChatResponse:
assert _llm is not None
assert _prompt is not None

# Resolve session: explicit id > reuse latest > create new
sid = req.session_id
if sid is None and req.reuse_session:
latest = _session_manager.get_latest_session()
if latest is not None:
sid = latest.session_id

session = _session_manager.get_or_create(
session_id=req.session_id, max_turns=_max_turns
session_id=sid, max_turns=_max_turns
)

# Add user message to session history
Expand Down
7 changes: 7 additions & 0 deletions src/caal/chat/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,13 @@ def delete(self, session_id: str) -> bool:
return True
return False

def get_latest_session(self) -> ChatSession | None:
"""Return the most recently active non-expired session, or None."""
active = [s for s in self._sessions.values() if not s.is_expired]
if not active:
return None
return max(active, key=lambda s: s.last_activity)

def list_sessions(self) -> list[dict]:
"""List active sessions with metadata."""
return [
Expand Down
3 changes: 2 additions & 1 deletion src/caal/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -428,6 +428,7 @@ def load_prompt_with_context(
timezone_id: str = "America/Los_Angeles",
timezone_display: str = "Pacific Time",
language: str = "en",
prompt_name: str | None = None,
) -> str:
"""Load prompt and populate with date/time context.

Expand All @@ -447,7 +448,7 @@ def load_prompt_with_context(
format_time_speech_friendly,
)

template = load_prompt_content(language=language)
template = load_prompt_content(prompt_name=prompt_name, language=language)

now = datetime.now(ZoneInfo(timezone_id))

Expand Down