fix(brain): Trigger-Zeit UTC-konsistent, project_summary liest Hauptchat, Privatkopie-Regel

- Trigger-Zeit (Issue 4): fires_at wird beim Anlegen IMMER auf UTC normalisiert
  (naiv = lokale Wanduhrzeit Europe/Berlin → UTC, aware → astimezone). Passt zur
  UTC-Ausfuehrung in background.py → feuert zur gemeinten Ortszeit, zeitzonen-
  portabel. Tool-Desc + Prompt: fires_at = lokale Wanduhrzeit ohne Zone schreiben,
  Server rechnet um. Behebt den 2h-Versatz.
- project_summary (Issue 2): liest jetzt auch den Hauptchat (name='Hauptchat')
  und die Beschreibung sagt ARIA, es fuer 'schau mal in Projekt X / Hauptchat
  rein' / 'hol dir die Infos aus ...' zu nutzen.
- Privatkopie (Issue 3): IDENTITY_ANCHOR-Regel — YouTube-Downloads fuer Stefans
  PRIVATgebrauch sind eine Privatkopie (§53 UrhG), nicht reflexartig ablehnen/
  moralisieren; Urteil bleibt fuer Weiterverbreitung/kommerziell.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-14 23:12:02 +02:00
co-authored by Claude Opus 4.8
parent 761217cb5a
commit 55938c3173
3 changed files with 76 additions and 26 deletions
+29 -19
View File
@@ -540,10 +540,13 @@ META_TOOLS = [
"fires_at": { "fires_at": {
"type": "string", "type": "string",
"description": ( "description": (
"Absoluter ISO-Timestamp UTC fuer feste Termine, z.B. " "Fester Termin als ISO-Timestamp. Schreib einfach die LOKALE "
"'2026-05-12T14:30:00Z'. Die aktuelle Zeit findest du im " "Wanduhrzeit, die Stefan meint, OHNE Zeitzone — z.B. 'um 14:30' "
"System-Prompt unter '## Aktuelle Zeit'. Fuer relative Angaben " "'2026-05-12T14:30:00'. Der Server rechnet sie selbst in UTC "
"lieber `in_seconds` nutzen." "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"}, "message": {"type": "string", "description": "Was soll bei der Erinnerung gesagt werden"},
@@ -1042,15 +1045,17 @@ META_TOOLS = [
"function": { "function": {
"name": "project_summary", "name": "project_summary",
"description": ( "description": (
"Fasst zusammen was zuletzt in einem Projekt passiert ist (letzte ~10 Turns). " "Schau in einen ANDEREN Chat rein und fass zusammen was dort zuletzt "
"Nutze zwingend wenn Stefan in ein altes Projekt einsteigt mit " "passiert ist (letzte ~12 Turns). Funktioniert fuer jedes Projekt (per "
"'hol mich ab' / 'was war zuletzt' / 'erinner mich dran' — sonst " "Name, Fuzzy-Match) UND fuer den Hauptchat (name='Hauptchat'). Nutze es "
"halluzinierst Du Inhalte die nicht da sind." "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": { "parameters": {
"type": "object", "type": "object",
"properties": { "properties": {
"name": {"type": "string", "description": "Projekt-Name (Fuzzy-Match)."}, "name": {"type": "string", "description": "Projekt-Name (Fuzzy-Match) oder 'Hauptchat' fuer den Hauptthread."},
}, },
"required": ["name"], "required": ["name"],
}, },
@@ -2535,22 +2540,27 @@ class Agent:
pname = (arguments.get("name") or "").strip() pname = (arguments.get("name") or "").strip()
if not pname: if not pname:
return "FEHLER: name ist Pflicht." return "FEHLER: name ist Pflicht."
p = projects_mod.find_project(pname) # Hauptchat (project_id="") explizit unterstuetzen — damit ARIA auch
if not p: # aus einem Projekt heraus in den Hauptthread reinschauen kann.
return f"Kein Projekt '{pname}' gefunden." if pname.lower() in {"hauptchat", "hauptthread", "haupt", "main",
# Letzte ~10 Turns des Projekts aus dem Conversation-Log "mainchat", "haupt-chat", "hauptchat-thread"}:
turns = [t for t in self.conversation.turns if t.project_id == p["id"]] 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: if not turns:
return (f"Projekt '{p['name']}' existiert (id={p['id']}), aber im " return (f"'{label}' hat im aktuellen Conversation-Window noch keine "
f"aktuellen Conversation-Window stehen noch keine Turns. " f"Turns. {desc}")
f"Beschreibung: {p.get('description', '(keine)')}")
tail = turns[-12:] tail = turns[-12:]
summary_lines = [] summary_lines = []
for t in tail: for t in tail:
prefix = "Stefan" if t.role == "user" else "Du" prefix = "Stefan" if t.role == "user" else "Du"
summary_lines.append(f"{prefix}: {t.content[:280]}") summary_lines.append(f"{prefix}: {t.content[:280]}")
preamble = (f"Projekt '{p['name']}'{p.get('description', '(keine Beschreibung)')}.\n" preamble = f"'{label}'{desc}.\nLetzte {len(tail)} Turns:\n"
f"Letzte {len(tail)} Turns:\n")
return preamble + "\n".join(summary_lines) return preamble + "\n".join(summary_lines)
if name == "project_end": if name == "project_end":
pname = (arguments.get("name") or "").strip() pname = (arguments.get("name") or "").strip()
+20 -4
View File
@@ -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 Auf „wer bist du" / „was weisst du ueber mich": antworte allgemein und diskret
(z.B. „einiges — geschaeftlich wie privat; frag mich gezielt"), NIEMALS ein (z.B. „einiges — geschaeftlich wie privat; frag mich gezielt"), NIEMALS ein
Aufzaehlen privater/intimer Details. 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"- Lokal (Europa/Berlin, UTC+{local_offset_h}): "
f"{local.strftime('%Y-%m-%d %H:%M:%S')} ({local.strftime('%A')})", f"{local.strftime('%Y-%m-%d %H:%M:%S')} ({local.strftime('%A')})",
"", "",
"Nutze das fuer Trigger-Timestamps und um Watcher-Conditions wie " "Nutze das um Watcher-Conditions wie `hour_of_day == 8` einzuordnen. "
"`hour_of_day == 8` einzuordnen. Fuer relative Angaben " "Fuer `trigger_timer`: bei relativen Angaben ('in 10min', 'in 2 Stunden') "
"('in 10min', 'in 2 Stunden') nutze beim `trigger_timer` den " "den `in_seconds`-Parameter; bei festen Uhrzeiten schreib bei `fires_at` "
"`in_seconds`-Parameter — Server rechnet dann selbst.", "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) return "\n".join(lines)
+27 -3
View File
@@ -24,7 +24,7 @@ import os
import re import re
import shutil import shutil
import time import time
from datetime import datetime, timezone from datetime import datetime, timedelta, timezone
from pathlib import Path from pathlib import Path
from typing import Optional from typing import Optional
@@ -40,6 +40,29 @@ def _now_iso() -> str:
return datetime.now(timezone.utc).isoformat() 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: def _safe_name(name: str) -> str:
if not isinstance(name, str) or not NAME_RE.match(name): if not isinstance(name, str) or not NAME_RE.match(name):
raise ValueError(f"Ungueltiger Trigger-Name: {name!r}") raise ValueError(f"Ungueltiger Trigger-Name: {name!r}")
@@ -127,9 +150,10 @@ def create_timer(
_safe_name(name) _safe_name(name)
if _path(name).exists(): if _path(name).exists():
raise ValueError(f"Trigger '{name}' existiert schon") 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: try:
datetime.fromisoformat(fires_at_iso.replace("Z", "+00:00")) fires_at_iso = normalize_fires_at_utc(fires_at_iso)
except Exception: except Exception:
raise ValueError(f"fires_at_iso ungueltig: {fires_at_iso}") raise ValueError(f"fires_at_iso ungueltig: {fires_at_iso}")
data = { data = {