Skip to content
Merged
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
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ EVOLUTION_API_KEY=your-secure-api-key-here
# Connection: postgresql://evolution:PASSWORD@evolution_postgres:5432/evolution_db
EVOLUTION_POSTGRES_PASSWORD=your-secure-database-password-here

# Expose Postgres port for local debugging (default: random port, not discoverable)
# Uncomment to bind to a fixed port:
#POSTGRES_HOST_PORT=5432

# Niles API Key (for /chat endpoint authentication)
# Auto-generated at startup if not set (retrieve with: docker exec niles_core printenv NILES_API_KEY)
# Set manually for a stable key across container restarts
Expand Down
13 changes: 9 additions & 4 deletions config/soul.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,13 @@ Du bist Niles, ein persönlicher AI-Assistent. Du läufst lokal auf dem Mac Mini

### Kalender

- Nutze `find_event` um Termine zu suchen (nach Stichwort und/oder Datum)
- Du hast Zugriff auf den Kalender des Benutzers. Erfinde NIEMALS Termine.
- Wenn der Benutzer nach Terminen fragt, rufe IMMER `find_event` auf. Antworte NIEMALS aus dem Gedächtnis.
- "Nächster Termin", "was steht an", "Termine diese Woche" → rufe `find_event` auf (query leer lassen für alle)
- Suche nach bestimmtem Termin → `find_event` mit query (z.B. "Zahnarzt", "Padel")
- Termine in einem Zeitraum → `find_event` mit date_from und/oder date_to (ISO-Format, z.B. "2026-02-20")
- Nutze `create_event` um neue Termine zu erstellen
- Bei Fragen wie "nächster Termin" oder "was steht an": rufe `find_event` auf
- Gib Termine mit Datum, Uhrzeit und Ort aus
- Gib Termine immer mit Wochentag, Datum, Uhrzeit und Ort aus

### Gedächtnis

Expand All @@ -39,4 +42,6 @@ Du bist Niles, ein persönlicher AI-Assistent. Du läufst lokal auf dem Mac Mini
1. Wenn du eine Aktion ausführst, bestätige kurz was du getan hast
2. Wenn du einen Kontakt nicht findest, frage nach der Telefonnummer
3. Bei Grupennachrichten: Stelle sicher dass du den richtigen Gruppennamen hast
4. Führe NIEMALS Aktionen aus ohne explizite Aufforderung
4. Sende NIEMALS WhatsApp-Nachrichten ohne explizite Aufforderung
5. Kalender-Abfragen (find_event) und Kontakt-Suchen (find_contact) darfst du IMMER selbstständig aufrufen
6. Erfinde NIEMALS Informationen. Wenn du etwas nicht weißt, nutze die Tools um es herauszufinden.
6 changes: 4 additions & 2 deletions docker/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,10 @@ services:
container_name: niles_evolution_postgres
image: postgres:15-alpine
restart: unless-stopped
# Port not exposed -- access only via Docker network
# For local debugging: ports: ["127.0.0.1:5432:5432"]
# Postgres port: loopback only, random port by default (not discoverable).
# For debugging, set in .env: POSTGRES_HOST_PORT=5432
ports:
- "127.0.0.1:${POSTGRES_HOST_PORT:-0}:5432"
environment:
POSTGRES_USER: evolution
POSTGRES_PASSWORD: ${EVOLUTION_POSTGRES_PASSWORD}
Expand Down
8 changes: 8 additions & 0 deletions docs/Development.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,14 @@ curl -k -X POST https://localhost/chat \

Alternativ direkt ueber den Docker-internen Port (ohne TLS): `docker exec niles_core curl http://localhost:8000/health`

**Postgres Debugging:** Der Postgres-Port ist standardmaessig nicht erreichbar. Um direkt auf die Datenbank zuzugreifen (z.B. via `psql`), in `.env` setzen:

```bash
POSTGRES_HOST_PORT=5432
```

Dann: `psql -h 127.0.0.1 -U evolution -d evolution_db`

### Status pruefen

```bash
Expand Down
23 changes: 20 additions & 3 deletions src/niles/actions/calendar.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,18 @@
"""Calendar event lookup in PostgreSQL."""

import logging
import re
from datetime import datetime
from zoneinfo import ZoneInfo

import asyncpg

logger = logging.getLogger(__name__)

# Strip control characters except common whitespace (space, tab)
_CONTROL_CHAR_REGEX = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]")
_MAX_FIELD_LENGTH = 500


class CalendarAction:
"""Search calendar events by keyword and/or date range."""
Expand Down Expand Up @@ -74,22 +79,34 @@ def _parse_date(self, value: str, end_of_day: bool = False) -> datetime | None:
logger.warning("Failed to parse date: %s", value)
return None

@staticmethod
def _sanitize_field(value: str) -> str:
"""Sanitize a text field from external calendar data.

Strips control characters and truncates to prevent prompt injection
when event data is passed to the LLM context.
"""
clean = _CONTROL_CHAR_REGEX.sub("", value)
if len(clean) > _MAX_FIELD_LENGTH:
clean = clean[:_MAX_FIELD_LENGTH] + "..."
return clean

def _row_to_dict(self, row: asyncpg.Record) -> dict:
"""Convert a database row to a formatted dict."""
dtstart = row["dtstart"]
dtend = row["dtend"]

result = {
"summary": row["summary"],
"summary": self._sanitize_field(row["summary"]),
"start": dtstart.astimezone(self.tz).isoformat() if dtstart else None,
"all_day": row["all_day"],
}

if dtend:
result["end"] = dtend.astimezone(self.tz).isoformat()
if row["description"]:
result["description"] = row["description"]
result["description"] = self._sanitize_field(row["description"])
if row["location"]:
result["location"] = row["location"]
result["location"] = self._sanitize_field(row["location"])

return result
4 changes: 3 additions & 1 deletion src/niles/agent/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -213,7 +213,9 @@ async def process_event(self, event: dict) -> str:

# Load memory context for system prompt
memories = await self.memory.list_all()
system_prompt = build_system_prompt(self.base_prompt, memories)
system_prompt = build_system_prompt(
self.base_prompt, memories, timezone=self.config.timezone,
)

# Load conversation history
history_messages = await self.history.get_recent(chat_id)
Expand Down
48 changes: 33 additions & 15 deletions src/niles/agent/prompts.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
"""System prompt loading and building."""

import logging
from datetime import datetime
from pathlib import Path
from zoneinfo import ZoneInfo

logger = logging.getLogger(__name__)

Expand All @@ -26,21 +28,37 @@ def load_system_prompt(path: str | None = None) -> str:
return _DEFAULT_PROMPT


def build_system_prompt(base_prompt: str, memories: list[dict]) -> str:
"""Build full system prompt with memory context."""
if not memories:
return base_prompt

memory_lines = []
for entry in memories:
key = entry["key"]
value = entry["value"]
memory_lines.append(f"- {key}: {value}")
def build_system_prompt(
base_prompt: str, memories: list[dict], timezone: str = "Europe/Vienna",
) -> str:
"""Build full system prompt with current datetime and memory context."""
try:
tz = ZoneInfo(timezone)
except (KeyError, ValueError):
logger.warning("Invalid timezone '%s', falling back to Europe/Vienna", timezone)
timezone = "Europe/Vienna"
tz = ZoneInfo(timezone)
now = datetime.now(tz)
weekdays_de = [
"Montag", "Dienstag", "Mittwoch", "Donnerstag",
"Freitag", "Samstag", "Sonntag",
]
weekday = weekdays_de[now.weekday()]

memory_section = (
"\n\n## Dein Gedächtnis\n"
"Folgende Dinge hast du dir gemerkt:\n"
+ "\n".join(memory_lines)
time_section = (
"\n\n## Aktuelle Zeit\n"
f"Heute ist {weekday}, der {now.strftime('%d.%m.%Y')}. "
f"Es ist {now.strftime('%H:%M')} Uhr ({timezone})."
)

return base_prompt + memory_section
prompt = base_prompt + time_section

if memories:
memory_lines = [f"- {e['key']}: {e['value']}" for e in memories]
prompt += (
"\n\n## Dein Gedächtnis\n"
"Folgende Dinge hast du dir gemerkt:\n"
+ "\n".join(memory_lines)
)

return prompt
1 change: 1 addition & 0 deletions src/niles/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ class Settings(BaseSettings):
caldav_url: str = "https://dav.mailbox.org/caldav/"
caldav_user: str = ""
caldav_password: str = ""
caldav_calendars: str = "" # Comma-separated collection hrefs, empty = all

# Google OAuth (optional -- Web-UI login)
google_client_id: str = ""
Expand Down
1 change: 1 addition & 0 deletions src/niles/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,7 @@ async def lifespan(app: FastAPI):
app.state.history = history
app.state.settings_store = settings_store
app.state.user_store = user_store
app.state.caldav = caldav_sync if settings.feature_caldav_sync else None

yield

Expand Down
18 changes: 18 additions & 0 deletions src/niles/settings_store.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import logging
import re
from typing import Any
from zoneinfo import ZoneInfo

import asyncpg

Expand All @@ -24,6 +25,7 @@
"feature_tool_send_whatsapp",
"feature_carddav_sync",
"feature_caldav_sync",
"caldav_calendars",
}

_KEY_PATTERN = re.compile(r"^[a-z][a-z0-9_]{1,63}$")
Expand Down Expand Up @@ -73,6 +75,22 @@ async def set(self, key: str, value: Any) -> None:
_validate_key(key)
if key not in EDITABLE_SETTINGS:
raise ValueError(f"Setting '{key}' is not editable at runtime")

# Value length guard for string settings
if isinstance(value, str) and len(value) > 4096:
raise ValueError(
f"Value for '{key}' exceeds maximum length of 4096 characters"
)

# Validate timezone is a valid IANA identifier
if key == "timezone" and isinstance(value, str):
try:
ZoneInfo(value)
except (KeyError, ValueError) as exc:
raise ValueError(
f"Invalid timezone: '{value}' is not a valid IANA timezone"
) from exc

async with self.pool.acquire() as conn:
async with conn.transaction():
await conn.execute(
Expand Down
34 changes: 33 additions & 1 deletion src/niles/sources/web.py
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,7 @@ def _safe_settings_dict(settings) -> dict:
"text_settings": text_settings,
"general": {"timezone": settings.timezone, "log_level": settings.log_level},
"infra": infra,
"caldav_enabled": settings.feature_caldav_sync,
}


Expand Down Expand Up @@ -549,7 +550,12 @@ async def update_setting(request: Request, key: str, value: str = Form(...)):

try:
await settings_store.set(key, parsed_value)
request.app.state.settings = apply_overrides(settings, {key: parsed_value})
new_settings = apply_overrides(settings, {key: parsed_value})
request.app.state.settings = new_settings
# Keep CalDAV sync config in sync so allowed_collections() reads fresh data
caldav = getattr(request.app.state, "caldav", None)
if caldav:
caldav.config = new_settings
except ValueError as e:
return templates.TemplateResponse(request, "fragments/toast.html", {
"message": str(e),
Expand All @@ -560,3 +566,29 @@ async def update_setting(request: Request, key: str, value: str = Form(...)):
"message": f"'{key}' gespeichert",
"toast_type": "success",
})


@router.get("/api/caldav/calendars", response_class=HTMLResponse)
async def caldav_calendars(request: Request):
"""Discover available CalDAV calendars, return checkboxes fragment."""
user = _get_session_user(request)
if user is None:
return Response(status_code=401, headers={"HX-Redirect": "/ui/login"})

caldav = getattr(request.app.state, "caldav", None)
if not caldav:
return HTMLResponse("<p>CalDAV nicht konfiguriert.</p>")

try:
collections = await caldav.discover_collections()
except Exception:
logger.exception("CalDAV collection discovery failed")
return HTMLResponse("<p>Fehler beim Laden der Kalender.</p>")

# Determine which are currently selected
selected = caldav.allowed_collections()

return templates.TemplateResponse(request, "fragments/calendars.html", {
"collections": collections,
"selected": selected,
})
22 changes: 22 additions & 0 deletions src/niles/static/js/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,28 @@ document.body.addEventListener("change", function(evt) {
htmx.trigger(form, "submit");
});

/* Calendar save -- collect checked values into hidden field before submit (CSP-safe) */
document.body.addEventListener("click", function(evt) {
if (!evt.target.hasAttribute("data-calendar-save")) return;
var form = evt.target.closest("form");
var boxes = form.querySelectorAll("input[name=cal]:checked");
if (boxes.length === 0) {
evt.preventDefault();
return;
}
var vals = Array.from(boxes).map(function(b) { return b.value; });
form.querySelector("#cal-value").value = vals.join(",");
});

/* Calendar checkboxes -- disable save button when nothing is checked */
document.body.addEventListener("change", function(evt) {
if (evt.target.name !== "cal") return;
var form = evt.target.closest("form");
var btn = form.querySelector("[data-calendar-save]");
var checked = form.querySelectorAll("input[name=cal]:checked").length;
btn.disabled = checked === 0;
});

document.body.addEventListener("htmx:afterRequest", function(evt) {
var btn = evt.detail.elt.querySelector("button[type='submit']");
if (btn) btn.removeAttribute("aria-busy");
Expand Down
Loading