feat(projects): Threads im Hauptchat verankert (Stefan-Konzept)

Projekte sind benannte Thema-Bündel die voice-gesteuert via Brain-Tools
geöffnet/verlassen werden. Default-Mode bleibt der Hauptthread — Projekte
sind eine optionale Bühne. Anchored-not-replaced: App-Open landet immer
im Hauptchat, Projekte sind nur sichtbar wenn aktiv betreten.

Brain:
- projects.py: CRUD + Fuzzy-Find + Active-State-Pointer
  (/shared/config/projects.json + active_project.txt).
- conversation.py: Turn.project_id-Feld + window(project_id) Filter.
- agent.py: 6 Meta-Tools — project_create / _enter / _exit / _list /
  _summary / _end. chat() liest aktive Projekt-ID, taggt User+Assistant-
  Turns damit, filtert das LLM-Window auf Projekt-Kontext und ergaenzt
  den System-Prompt um den aktiven Projekt-Hinweis. touch_project pflegt
  last_activity_at + turn_count.
- main.py: REST-Endpoints /projects/{status,list,create,switch,
  {id}/end,{id}/archive, PATCH /{id}}.

Bridge + RVS:
- aria_bridge.py: project_changed Event-Propagation Brain → RVS-Broadcast
  damit App + Diagnostic ihre Banner refreshen.
- rvs/server.js: project_changed in ALLOWED_TYPES.

App:
- brainApi.ts: Project-Type + 6 API-Methoden.
- ProjectsBrowser.tsx (neue Komponente, ~340 Zeilen): Status-Header,
  Hauptchat als Erster-Eintrag, Projekt-Liste mit Aktiv-Marker, Long-Press
  zum Editieren, Modals fuer Neu/Edit/End/Archiv.
- ChatScreen.tsx: Banner unterhalb des Status-Bars zeigt aktives Projekt
  oder „Hauptchat" — Tap öffnet ProjectsBrowser als Modal. Aktive Projekt-
  Info wird bei Mount + bei project_changed-Events refreshed.
- SettingsScreen.tsx: Neue Section 📁 „Projekte" zeigt ProjectsBrowser inline.

Diagnostic:
- Neue Sektion im Brain-Tab mit Liste, Aktiv-Marker, Beenden/Archivieren
  pro Zeile, Modal fuer Neu. Lädt automatisch bei Brain-Tab + bei
  project_changed-Event-Broadcast.

Was bewusst NICHT drin ist (Folgeschritte):
- Per-Message Filter im Chat-Verlauf (zeigt aktuell alle Bubbles, Banner
  zeigt Kontext) — App müsste Chat-History per project_id filtern.
- Files-by-Project Tagging.
- Inline-Collapse-Bloecke im Chat-Verlauf.
- Sub-Projekte (Stefan-Entscheidung: weglassen, „Mama-tauglich").

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-06-13 13:51:26 +02:00
parent f714cfc336
commit fc0f91d1e6
11 changed files with 1239 additions and 19 deletions
+244 -8
View File
@@ -32,6 +32,7 @@ import skills as skills_mod
import triggers as triggers_mod
import watcher as watcher_mod
import oauth as oauth_mod
import projects as projects_mod
BRIDGE_URL = os.environ.get("BRIDGE_URL", "http://aria-bridge:8090")
# FLUX-Render kann bis ~90s dauern, beim ersten Render nach Container-Start
@@ -808,6 +809,104 @@ META_TOOLS = [
},
},
},
# ── Projekte (Stefan-Konzept: Threads im Hauptchat verankert) ──
{
"type": "function",
"function": {
"name": "project_create",
"description": (
"Legt ein neues Projekt an und macht es ZUR AKTIVEN Bühne. "
"Nutze das wenn Stefan sagt 'lass uns ein Projekt für X anlegen' "
"oder ein Thema klar als zusammenhängend bezeichnet. NICHT für "
"Ad-hoc-Fragen — Projekte sind für wiederkehrende, mehrere Tage "
"spannende Themen (Spotify-Setup, Renovierung, Reise-Planung)."
),
"parameters": {
"type": "object",
"properties": {
"name": {"type": "string", "description": "Kurzer Name, wie ein Buchtitel ('Aria-Wakeword', 'Frankreich-Urlaub')."},
"description": {"type": "string", "description": "1-Satz worum's geht. Hilft beim Wiedererkennen."},
},
"required": ["name"],
},
},
},
{
"type": "function",
"function": {
"name": "project_enter",
"description": (
"Wechselt in ein bestehendes Projekt. Fuzzy-Match auf Namen — "
"'Spotify' findet das Projekt 'Spotify-Setup'. Nach dem Eintritt "
"tagged jeder neue Turn die project_id. Bei sehr alten Projekten: "
"vorher project_summary aufrufen damit Du Stefan abholst."
),
"parameters": {
"type": "object",
"properties": {
"name": {"type": "string", "description": "Projekt-Name oder Teil davon."},
},
"required": ["name"],
},
},
},
{
"type": "function",
"function": {
"name": "project_exit",
"description": (
"Verlässt das aktuelle Projekt — zurück zum Hauptthread. Nutze "
"wenn Stefan sagt 'Projekt Ende', 'zurück zum Hauptchat' o.ä."
),
"parameters": {"type": "object", "properties": {}},
},
},
{
"type": "function",
"function": {
"name": "project_list",
"description": "Listet alle Projekte mit Status und letzter Aktivität. Bevor Du ein neues anlegst: hier prüfen ob's schon eins gibt.",
"parameters": {"type": "object", "properties": {}},
},
},
{
"type": "function",
"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."
),
"parameters": {
"type": "object",
"properties": {
"name": {"type": "string", "description": "Projekt-Name (Fuzzy-Match)."},
},
"required": ["name"],
},
},
},
{
"type": "function",
"function": {
"name": "project_end",
"description": (
"Markiert ein Projekt als beendet — bleibt in der Liste sichtbar "
"(z.B. archiviert/grau), kann aber nicht mehr neu betreten werden "
"außer mit explizitem project_enter. Nutze wenn Stefan sagt 'Projekt "
"abgeschlossen' o.ä."
),
"parameters": {
"type": "object",
"properties": {
"name": {"type": "string", "description": "Projekt-Name."},
},
"required": ["name"],
},
},
},
]
@@ -960,17 +1059,28 @@ class Agent:
# Events vom letzten Turn weglassen
self._pending_events = []
# Aktives Projekt (leer = Hauptthread) — bestimmt das Tagging der
# neuen Turns + das Conversation-Window-Filter fuer den LLM-Prompt.
active_project_id = projects_mod.get_active()
active_project = projects_mod.get_project(active_project_id) if active_project_id else None
# Fast-Path: einfache "reines Steuern"-Commands ueberspringen Claude komplett.
# Jeder Skill kann in seinem Manifest fast_patterns deklarieren — das Brain
# iteriert hier ueber alle aktiven Skills und matched. Spart 5-10s Latenz.
fast_reply = self._try_skill_fast_path(user_message)
if fast_reply is not None:
self.conversation.add("user", user_message, source=source)
self.conversation.add("assistant", fast_reply)
self.conversation.add("user", user_message, source=source,
project_id=active_project_id)
self.conversation.add("assistant", fast_reply, project_id=active_project_id)
if active_project_id:
projects_mod.touch_project(active_project_id)
return fast_reply
# 1. User-Turn an die Konversation
self.conversation.add("user", user_message, source=source)
self.conversation.add("user", user_message, source=source,
project_id=active_project_id)
if active_project_id:
projects_mod.touch_project(active_project_id)
# 2. Hot Memory (alle pinned Punkte)
hot = self.store.list_pinned()
@@ -1017,13 +1127,38 @@ class Agent:
oauth_callback_host=oauth_host,
oauth_callback_port=oauth_port,
oauth_callback_tls=oauth_tls)
# Aktuelle Projekt-Bühne als System-Hinweis ergaenzen, damit Claude
# weiss in welchem Kontext sie spricht und ihre project_* Tools korrekt
# einsetzt (z.B. bei „Projekt Ende" project_exit aufruft).
if active_project:
system_prompt += (
f"\n\n## AKTUELLES PROJEKT\n"
f"Stefan befindet sich gerade IN dem Projekt '{active_project['name']}' "
f"(id={active_project['id']}). Beschreibung: "
f"{active_project.get('description', '(keine)')}. "
f"Alle Antworten in diesem Turn gelten fuer dieses Projekt. "
f"Wenn er rauswill, ruf project_exit auf."
)
else:
project_count = len(projects_mod.list_projects())
if project_count > 0:
system_prompt += (
f"\n\n## PROJEKTE\n"
f"Hauptthread aktiv. {project_count} Projekte verfuegbar — wenn "
f"Stefan sagt 'in Projekt X' oder 'lass uns das Spotify-Thema "
f"weiterfuehren': project_enter aufrufen."
)
messages = [ProxyMessage(role="system", content=system_prompt)]
for t in self.conversation.window():
# Conversation-Window auf das aktive Projekt filtern: in einem Projekt
# sieht der LLM nur die Projekt-Turns (sauberer Kontext); im Hauptthread
# nur die nicht-getaggten Turns.
window = self.conversation.window(project_id=active_project_id)
for t in window:
messages.append(ProxyMessage(role=t.role, content=t.content))
logger.info("chat: pinned=%d cold=%d skills=%d/%d window=%d prompt_chars=%d",
logger.info("chat: pinned=%d cold=%d skills=%d/%d window=%d project=%r prompt_chars=%d",
len(hot), len(cold), len(active_skills), len(all_skills),
len(self.conversation.window()), len(system_prompt))
len(window), active_project_id or "(main)", len(system_prompt))
# 6. Tool-Use-Loop. Bei Exception (z.B. Proxy-Timeout) muss ein
# Assistant-Turn als Error-Marker geschrieben werden — der User-Turn
@@ -1082,13 +1217,19 @@ class Agent:
err_text = f"[Fehler: {exc}]"
logger.error("chat() Exception — schreibe Error-Marker als Assistant-Turn: %s", exc)
try:
self.conversation.add("assistant", err_text)
# Aktive Projekt-ID NEU lesen — kann sich waehrend des Tool-Loops
# geaendert haben (project_enter/exit als Tool-Call).
self.conversation.add("assistant", err_text,
project_id=projects_mod.get_active())
except Exception as add_exc:
logger.warning("Konnte Error-Marker nicht persistieren: %s", add_exc)
raise
# 7. Assistant-Turn (final reply) in die Conversation
self.conversation.add("assistant", final_reply)
# NEU lesen — wenn der LLM project_enter/exit gerufen hat, ist der
# Final-Reply schon im neuen Projekt-Kontext.
self.conversation.add("assistant", final_reply,
project_id=projects_mod.get_active())
return final_reply
# ── Tool-Dispatcher ───────────────────────────────────────
@@ -1648,6 +1789,101 @@ class Agent:
except Exception as e:
logger.exception("memory_save fehlgeschlagen")
return f"FEHLER beim Speichern: {e}"
# ── Projekte ────────────────────────────────────────
if name == "project_create":
pname = (arguments.get("name") or "").strip()
desc = (arguments.get("description") or "").strip()
if not pname:
return "FEHLER: name ist Pflicht."
try:
p = projects_mod.create_project(pname, desc)
except ValueError as e:
return f"FEHLER: {e}"
self._pending_events.append({
"type": "project_changed",
"project": p,
"action": "created",
})
return f"OK — Projekt '{p['name']}' angelegt (id={p['id']}) und aktiv. Alle weiteren Turns gehen jetzt da rein bis Du project_exit oder project_enter aufrufst."
if name == "project_enter":
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. Nutze project_list zum Aufzaehlen oder project_create wenn's neu sein soll."
projects_mod.set_active(p["id"])
self._pending_events.append({
"type": "project_changed",
"project": p,
"action": "entered",
})
turn_count = p.get("turn_count", 0)
hint = ""
if turn_count > 0:
hint = " Wenn Stefan nach dem Stand fragt: project_summary aufrufen."
return f"OK — in Projekt '{p['name']}' eingestiegen (id={p['id']}, {turn_count} bisherige Turns).{hint}"
if name == "project_exit":
active_id = projects_mod.get_active()
if not active_id:
return "Es ist gerade kein Projekt aktiv — bereits im Hauptthread."
p = projects_mod.get_project(active_id)
projects_mod.set_active("")
self._pending_events.append({
"type": "project_changed",
"project": p,
"action": "exited",
})
return f"OK — Projekt '{p['name'] if p else active_id}' verlassen. Zurueck im Hauptthread."
if name == "project_list":
items = projects_mod.list_projects()
if not items:
return "(keine Projekte angelegt)"
active_id = projects_mod.get_active()
lines = []
for p in items:
marker = " ← AKTIV" if p["id"] == active_id else ""
status_lbl = p.get("status", "active")
lines.append(
f"- {p['name']} (id={p['id']}, {p.get('turn_count', 0)} Turns, "
f"status={status_lbl}){marker}"
)
return "Projekte:\n" + "\n".join(lines)
if name == "project_summary":
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"]]
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)')}")
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")
return preamble + "\n".join(summary_lines)
if name == "project_end":
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."
projects_mod.end_project(p["id"])
self._pending_events.append({
"type": "project_changed",
"project": projects_mod.get_project(p["id"]),
"action": "ended",
})
return f"OK — Projekt '{p['name']}' beendet (id={p['id']}). Bleibt in der Liste, aktiv ist jetzt der Hauptthread."
return f"Unbekanntes Tool: {name}"
except Exception as exc:
logger.exception("Tool '%s' fehlgeschlagen", name)