Compare commits

...
3 Commits
Author SHA1 Message Date
duffyduck c92e042e91 release: bump version to 0.2.1.7 2026-07-14 23:20:57 +02:00
duffyduckandClaude Opus 4.8 019b17ff97 fix(app): Queue friert nach Verbindungsabbruch nicht mehr ein (Issue 1)
Zwei Ursachen:
- pending_queue-Bubbles haben (noch) keine clientMsgId → der Reconnect-
  History-Sync erkannte sie nicht als lokal-only und verwarf sie, waehrend
  projectQueues den Eintrag behielt → 'N in Warteschlange' fror ein ohne
  sichtbare Nachricht. Jetzt bleiben pending_queue-Bubbles beim Sync erhalten.
- Watchdog: haengt ein Kontext >15s auf 'running', obwohl der Brain ihn NICHT
  als busy meldet (Antwort beim Abbruch verloren), schaltet die Queue jetzt
  selbst weiter (dequeue oder idle) statt fuer immer zu blockieren.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 23:18:06 +02:00
duffyduckandClaude Opus 4.8 55938c3173 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>
2026-07-14 23:12:02 +02:00
6 changed files with 108 additions and 31 deletions
+2 -2
View File
@@ -79,8 +79,8 @@ android {
applicationId "com.ariacockpit"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 20106
versionName "0.2.1.6"
versionCode 20107
versionName "0.2.1.7"
// Fallback fuer Libraries mit Product Flavors
missingDimensionStrategy 'react-native-camera', 'general'
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "aria-cockpit",
"version": "0.2.1.6",
"version": "0.2.1.7",
"private": true,
"scripts": {
"android": "react-native run-android",
+29 -2
View File
@@ -318,6 +318,9 @@ const ChatScreen: React.FC = () => {
const [projectQueues, setProjectQueues] = useState<Record<string, QueuedItem[]>>({});
const projectStatesRef = useRef<Record<string, CtxState>>({});
const projectQueuesRef = useRef<Record<string, QueuedItem[]>>({});
// Wann ist ein Kontext in 'running' gegangen? Fuer den Watchdog, der eine
// haengende Queue (z.B. Antwort waehrend Verbindungsabbruch verloren) loest.
const ctxRunningSinceRef = useRef<Record<string, number>>({});
// Pro-Projekt-Textfeld-Entwuerfe (noch nicht gesendeter Feldinhalt). Key = pid.
const projectDraftsRef = useRef<Record<string, string>>({});
const prevFocusedPidRef = useRef<string>('');
@@ -583,8 +586,24 @@ const ChatScreen: React.FC = () => {
try {
const s = await brainApi.getProjectQueueStatus();
if (cancelled) return;
setQueueStatus(s.contexts || {});
queueStatusRef.current = s.contexts || {};
const ctxs = s.contexts || {};
setQueueStatus(ctxs);
queueStatusRef.current = ctxs;
// Watchdog: haengt ein Kontext seit >15s auf 'running', obwohl der Brain
// ihn NICHT als busy meldet, ist die Antwort verloren gegangen (z.B.
// Verbindungsabbruch) — sonst friert die Queue ein. Dann weiterschalten.
const api = queueApiRef.current;
if (api) {
const now = Date.now();
for (const [pid, since] of Object.entries(ctxRunningSinceRef.current)) {
if (api.getCtxState(pid) !== 'running') continue;
const busy = !!ctxs[pid || '__main__']?.busy;
if (!busy && now - since > 15000) {
console.log('[Chat] Queue-Watchdog: Kontext %s haengt (running, brain idle) → weiterschalten', pid || '(main)');
api.advanceQueue(pid);
}
}
}
} catch {}
};
poll();
@@ -918,6 +937,11 @@ const ChatScreen: React.FC = () => {
const localOnly = prev.filter(m => {
if (m.skillCreated || m.triggerCreated || m.memorySaved) return true;
if (m.audioRequestId && (!m.text || m.text === '🎙 Aufnahme...' || m.text === 'Aufnahme...')) return true;
// Wartende Queue-Bubbles (noch nicht gesendet → kein clientMsgId, nicht
// auf dem Server) MUESSEN erhalten bleiben — sonst verschwindet die
// Bubble beim Reconnect-Sync, waehrend projectQueues den Eintrag behaelt
// → "N in Warteschlange" friert ein ohne sichtbare Nachricht.
if (m.sender === 'user' && m.deliveryStatus === 'pending_queue') return true;
if (m.sender === 'user' && m.clientMsgId && !serverCmids.has(m.clientMsgId)) {
// Text-Match-Fallback: wenn der Server irgendwo eine textgleiche
// User-Bubble hat, ist es dieselbe Nachricht (vor cmid-Aera, ts
@@ -2050,6 +2074,9 @@ const ChatScreen: React.FC = () => {
const setCtxState = useCallback((pid: string, s: CtxState) => {
projectStatesRef.current = { ...projectStatesRef.current, [pid]: s };
// Watchdog-Zeitstempel: nur 'running' bekommt einen Start, sonst raus.
if (s === 'running') ctxRunningSinceRef.current[pid] = Date.now();
else delete ctxRunningSinceRef.current[pid];
setProjectStates(prev => ({ ...prev, [pid]: s }));
}, []);
+29 -19
View File
@@ -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()
+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
(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)
+27 -3
View File
@@ -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 = {