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:
+244
-8
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user