diff --git a/aria-brain/agent.py b/aria-brain/agent.py index acd2c6c..d16725d 100644 --- a/aria-brain/agent.py +++ b/aria-brain/agent.py @@ -540,10 +540,13 @@ META_TOOLS = [ "fires_at": { "type": "string", "description": ( - "Absoluter ISO-Timestamp UTC fuer feste Termine, z.B. " - "'2026-05-12T14:30:00Z'. Die aktuelle Zeit findest du im " - "System-Prompt unter '## Aktuelle Zeit'. Fuer relative Angaben " - "lieber `in_seconds` nutzen." + "Fester Termin als ISO-Timestamp. Schreib einfach die LOKALE " + "Wanduhrzeit, die Stefan meint, OHNE Zeitzone — z.B. 'um 14:30' " + "→ '2026-05-12T14:30:00'. Der Server rechnet sie selbst in UTC " + "um (naiv = Ortszeit Europe/Berlin). Nur wenn du explizit UTC " + "willst, haeng Z an ('...T12:30:00Z'). Die aktuelle Lokal-/UTC-" + "Zeit steht im System-Prompt unter '## Aktuelle Zeit'. Fuer " + "relative Angaben ('in 2 Stunden') lieber `in_seconds`." ), }, "message": {"type": "string", "description": "Was soll bei der Erinnerung gesagt werden"}, @@ -1042,15 +1045,17 @@ META_TOOLS = [ "function": { "name": "project_summary", "description": ( - "Fasst zusammen was zuletzt in einem Projekt passiert ist (letzte ~10 Turns). " - "Nutze zwingend wenn Stefan in ein altes Projekt einsteigt mit " - "'hol mich ab' / 'was war zuletzt' / 'erinner mich dran' — sonst " - "halluzinierst Du Inhalte die nicht da sind." + "Schau in einen ANDEREN Chat rein und fass zusammen was dort zuletzt " + "passiert ist (letzte ~12 Turns). Funktioniert fuer jedes Projekt (per " + "Name, Fuzzy-Match) UND fuer den Hauptchat (name='Hauptchat'). Nutze es " + "IMMER wenn Stefan sagt 'hol dir die Infos aus Projekt X', 'schau mal in " + "den Hauptchat/in Projekt Y rein', 'was war zuletzt bei ...', 'hol mich " + "ab' — sonst halluzinierst Du Inhalte die nicht da sind." ), "parameters": { "type": "object", "properties": { - "name": {"type": "string", "description": "Projekt-Name (Fuzzy-Match)."}, + "name": {"type": "string", "description": "Projekt-Name (Fuzzy-Match) oder 'Hauptchat' fuer den Hauptthread."}, }, "required": ["name"], }, @@ -2535,22 +2540,27 @@ class Agent: pname = (arguments.get("name") or "").strip() if not pname: return "FEHLER: name ist Pflicht." - p = projects_mod.find_project(pname) - if not p: - return f"Kein Projekt '{pname}' gefunden." - # Letzte ~10 Turns des Projekts aus dem Conversation-Log - turns = [t for t in self.conversation.turns if t.project_id == p["id"]] + # Hauptchat (project_id="") explizit unterstuetzen — damit ARIA auch + # aus einem Projekt heraus in den Hauptthread reinschauen kann. + if pname.lower() in {"hauptchat", "hauptthread", "haupt", "main", + "mainchat", "haupt-chat", "hauptchat-thread"}: + turns = [t for t in self.conversation.turns if not t.project_id] + label, desc = "Hauptchat", "der Hauptthread (kein Projekt)" + else: + p = projects_mod.find_project(pname) + if not p: + return f"Kein Chat/Projekt '{pname}' gefunden (fuer den Hauptthread: name='Hauptchat')." + turns = [t for t in self.conversation.turns if t.project_id == p["id"]] + label, desc = p["name"], p.get("description", "(keine Beschreibung)") if not turns: - return (f"Projekt '{p['name']}' existiert (id={p['id']}), aber im " - f"aktuellen Conversation-Window stehen noch keine Turns. " - f"Beschreibung: {p.get('description', '(keine)')}") + return (f"'{label}' hat im aktuellen Conversation-Window noch keine " + f"Turns. {desc}") tail = turns[-12:] summary_lines = [] for t in tail: prefix = "Stefan" if t.role == "user" else "Du" summary_lines.append(f"{prefix}: {t.content[:280]}") - preamble = (f"Projekt '{p['name']}' — {p.get('description', '(keine Beschreibung)')}.\n" - f"Letzte {len(tail)} Turns:\n") + preamble = f"'{label}' — {desc}.\nLetzte {len(tail)} Turns:\n" return preamble + "\n".join(summary_lines) if name == "project_end": pname = (arguments.get("name") or "").strip() diff --git a/aria-brain/prompts.py b/aria-brain/prompts.py index 52c507f..8faf870 100644 --- a/aria-brain/prompts.py +++ b/aria-brain/prompts.py @@ -68,6 +68,20 @@ Wissen ist fuer deine Arbeit da — aber es ist HOCHVERTRAULICH: Auf „wer bist du" / „was weisst du ueber mich": antworte allgemein und diskret (z.B. „einiges — geschaeftlich wie privat; frag mich gezielt"), NIEMALS ein Aufzaehlen privater/intimer Details. + +## MEDIEN-DOWNLOADS FUER STEFANS PRIVATGEBRAUCH +Stefan ist Privatperson und du bist SEIN persoenlicher Assistent. Bittet er dich, +ihm ein Video/Audio (z.B. von YouTube) fuer seinen EIGENEN, privaten Gebrauch +herunterzuladen — offline anschauen, Archiv, Erinnerung an eine Szene/ein Lied — +ist das eine Privatkopie (in DE nach §53 UrhG fuer den privaten Gebrauch +zulaessig, solange die Quelle nicht offensichtlich illegal ist und kein +Kopierschutz umgangen wird). Behandle solche Downloads NICHT reflexartig als +Urheberrechtsverstoss und moralisiere nicht — nutz einfach den +`yt-dlp-download`-Skill, konsistent, ohne jedes Mal neu zu diskutieren (du hast +das schon oft gemacht). Anders ist es NUR, wenn es klar um WEITERVERBREITUNG, +oeffentliches Posten oder kommerzielle Nutzung geht — DA weist du kurz auf das +Problem hin. Im Zweifel privat vs. verbreiten: einmal kurz nachfragen, nicht +pauschal ablehnen. """ @@ -139,10 +153,12 @@ def build_time_section() -> str: f"- Lokal (Europa/Berlin, UTC+{local_offset_h}): " f"{local.strftime('%Y-%m-%d %H:%M:%S')} ({local.strftime('%A')})", "", - "Nutze das fuer Trigger-Timestamps und um Watcher-Conditions wie " - "`hour_of_day == 8` einzuordnen. Fuer relative Angaben " - "('in 10min', 'in 2 Stunden') nutze beim `trigger_timer` den " - "`in_seconds`-Parameter — Server rechnet dann selbst.", + "Nutze das um Watcher-Conditions wie `hour_of_day == 8` einzuordnen. " + "Fuer `trigger_timer`: bei relativen Angaben ('in 10min', 'in 2 Stunden') " + "den `in_seconds`-Parameter; bei festen Uhrzeiten schreib bei `fires_at` " + "einfach die LOKALE Wanduhrzeit ohne Zeitzone (z.B. 'um 17 Uhr' → " + "'...T17:00:00') — der Server rechnet sie selbst in UTC um. So feuert der " + "Timer zur gemeinten Ortszeit und bleibt zeitzonen-portabel.", ] return "\n".join(lines) diff --git a/aria-brain/triggers.py b/aria-brain/triggers.py index c3bd1c3..abf4e27 100644 --- a/aria-brain/triggers.py +++ b/aria-brain/triggers.py @@ -24,7 +24,7 @@ import os import re import shutil import time -from datetime import datetime, timezone +from datetime import datetime, timedelta, timezone from pathlib import Path from typing import Optional @@ -40,6 +40,29 @@ def _now_iso() -> str: return datetime.now(timezone.utc).isoformat() +def _local_offset_hours(dt: datetime) -> int: + """Grobe Europe/Berlin-Naeherung (CEST=+2 Maerz-Okt, sonst CET=+1) — dieselbe + Logik wie build_time_section im Prompt, ohne zoneinfo/tzdata im Brain-Image.""" + return 2 if 3 <= dt.month <= 10 else 1 + + +def normalize_fires_at_utc(iso: str) -> str: + """Bringt einen fires_at-ISO IMMER auf UTC (+00:00). + + - Aware (endet auf Z oder hat einen Offset) → in UTC umgerechnet. + - Naiv (keine Zone) → als LOKALE Wanduhrzeit (Europe/Berlin) interpretiert + und nach UTC umgerechnet. + + So speichern wir stets den absoluten Instant. Die Ausfuehrung (background.py, + UTC) trifft damit exakt die vom Nutzer gemeinte Ortszeit — und bleibt + zeitzonen-portabel (feuert am selben Moment, egal wo Stefan gerade ist).""" + dt = datetime.fromisoformat((iso or "").strip().replace("Z", "+00:00")) + if dt.tzinfo is None: + # Naiv = lokale Wanduhrzeit → UTC = lokal - Offset. + dt = (dt - timedelta(hours=_local_offset_hours(dt))).replace(tzinfo=timezone.utc) + return dt.astimezone(timezone.utc).isoformat(timespec="seconds") + + def _safe_name(name: str) -> str: if not isinstance(name, str) or not NAME_RE.match(name): raise ValueError(f"Ungueltiger Trigger-Name: {name!r}") @@ -127,9 +150,10 @@ def create_timer( _safe_name(name) if _path(name).exists(): raise ValueError(f"Trigger '{name}' existiert schon") - # ISO validieren + # ISO validieren UND auf UTC normalisieren (naiv = lokale Wanduhrzeit → + # UTC). So passt das Anlegen zur UTC-Ausfuehrung in background.py. try: - datetime.fromisoformat(fires_at_iso.replace("Z", "+00:00")) + fires_at_iso = normalize_fires_at_utc(fires_at_iso) except Exception: raise ValueError(f"fires_at_iso ungueltig: {fires_at_iso}") data = {