Files
ARIA-AGENT/aria-brain/projects.py
T
duffyduck fc0f91d1e6 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>
2026-06-13 13:51:26 +02:00

220 lines
6.6 KiB
Python

"""
Projekt-Verwaltung — Stefans Idee fuer „Threads im Hauptchat verankert".
Ein Projekt ist ein benanntes Thema-Bündel. Zwei Modi:
- Hauptthread (kein aktives Projekt): klassischer rollender Chat.
- In-Projekt: alle neuen Turns werden mit project_id getaggt. Die App
zeigt sie als zusammenhängenden Block, einklappbar.
Voice-Pattern (vom LLM via Meta-Tools getriggert):
- „neues Projekt 'Aria-Wakeword'" → project_create
- „steig in Projekt Spotify-Setup ein" → project_enter (Fuzzy-Match)
- „Projekt Ende" → project_exit (zurueck zu Hauptthread)
- „welche Projekte gibt's?" → project_list
- „hol mich ab — was war zuletzt bei Projekt X?" → project_summary
Persistenz: JSON-Liste in /shared/config/projects.json + aktive ID
in /shared/config/active_project.txt. Single-User, single-active —
keine Concurrency-Probleme.
"""
from __future__ import annotations
import json
import logging
import os
import re
import time
import uuid
from difflib import SequenceMatcher
from pathlib import Path
from typing import Optional
logger = logging.getLogger(__name__)
PROJECTS_DIR = Path(os.environ.get("PROJECTS_DIR", "/shared/config"))
PROJECTS_FILE = PROJECTS_DIR / "projects.json"
ACTIVE_PROJECT_FILE = PROJECTS_DIR / "active_project.txt"
def _now() -> int:
return int(time.time())
def _load_all() -> list[dict]:
if not PROJECTS_FILE.exists():
return []
try:
data = json.loads(PROJECTS_FILE.read_text(encoding="utf-8"))
return data if isinstance(data, list) else []
except Exception as exc:
logger.warning("[projects] load failed: %s", exc)
return []
def _save_all(projects: list[dict]) -> None:
PROJECTS_DIR.mkdir(parents=True, exist_ok=True)
PROJECTS_FILE.write_text(
json.dumps(projects, indent=2, ensure_ascii=False), encoding="utf-8")
def _slug(name: str) -> str:
"""Stabile ID aus Namen — fuer Voice-Matches. Lowercase, only a-z 0-9 _."""
s = name.strip().lower()
s = re.sub(r"[^a-z0-9]+", "_", s)
s = s.strip("_")
return s or f"project_{_now()}"
def list_projects(include_archived: bool = False) -> list[dict]:
projects = _load_all()
if not include_archived:
projects = [p for p in projects if p.get("status") != "archived"]
projects.sort(key=lambda p: p.get("last_activity_at", 0), reverse=True)
return projects
def get_project(project_id: str) -> Optional[dict]:
if not project_id:
return None
for p in _load_all():
if p.get("id") == project_id:
return p
return None
def find_project(query: str) -> Optional[dict]:
"""Fuzzy-Match auf Projekt-Namen — fuer Voice-Commands.
Trifft auf: exact slug, prefix, substring, oder hoechste similarity > 0.6."""
q = (query or "").strip().lower()
if not q:
return None
projects = _load_all()
# 1. Exact ID-Match
for p in projects:
if p.get("id") == q:
return p
# 2. Exact / Prefix / Substring auf Slug + Name
q_slug = _slug(q)
for p in projects:
if p.get("id") == q_slug:
return p
name_low = (p.get("name", "")).lower()
if name_low == q or name_low.startswith(q) or q in name_low:
return p
# 3. Fuzzy
best, best_score = None, 0.0
for p in projects:
s = SequenceMatcher(None, q, p.get("name", "").lower()).ratio()
if s > best_score:
best, best_score = p, s
if best and best_score >= 0.6:
return best
return None
def create_project(name: str, description: str = "") -> dict:
name = (name or "").strip()
if not name:
raise ValueError("Projektname darf nicht leer sein")
base_id = _slug(name)
projects = _load_all()
# Dedup by id with suffix
used_ids = {p["id"] for p in projects}
pid = base_id
counter = 2
while pid in used_ids:
pid = f"{base_id}_{counter}"
counter += 1
now = _now()
project = {
"id": pid,
"name": name,
"description": description.strip(),
"status": "active", # active | ended | archived
"created_at": now,
"updated_at": now,
"last_activity_at": now,
"turn_count": 0,
}
projects.append(project)
_save_all(projects)
set_active(pid)
logger.info("[projects] created %r (id=%s)", name, pid)
return project
def update_project(project_id: str, patch: dict) -> Optional[dict]:
projects = _load_all()
for p in projects:
if p["id"] == project_id:
for k in ("name", "description", "status"):
if k in patch and patch[k] is not None:
p[k] = patch[k]
p["updated_at"] = _now()
_save_all(projects)
return p
return None
def archive_project(project_id: str) -> bool:
if update_project(project_id, {"status": "archived"}) is not None:
if get_active() == project_id:
set_active("")
return True
return False
def end_project(project_id: str) -> bool:
"""Markiert als beendet, aktive-Projekt-Pointer raus."""
if update_project(project_id, {"status": "ended"}) is not None:
if get_active() == project_id:
set_active("")
return True
return False
def touch_project(project_id: str) -> None:
"""Bei jedem Turn im Projekt: last_activity + turn_count erhoehen."""
if not project_id:
return
projects = _load_all()
changed = False
for p in projects:
if p["id"] == project_id:
p["last_activity_at"] = _now()
p["turn_count"] = int(p.get("turn_count", 0)) + 1
changed = True
break
if changed:
_save_all(projects)
# ── Active-Project-Pointer ─────────────────────────────────────────
def get_active() -> str:
"""Returns die aktive Projekt-ID oder leer (= Hauptthread)."""
try:
if ACTIVE_PROJECT_FILE.exists():
return ACTIVE_PROJECT_FILE.read_text(encoding="utf-8").strip()
except Exception:
pass
return ""
def set_active(project_id: str) -> None:
PROJECTS_DIR.mkdir(parents=True, exist_ok=True)
ACTIVE_PROJECT_FILE.write_text(project_id or "", encoding="utf-8")
logger.info("[projects] active project: %r", project_id or "(main)")
def status() -> dict:
"""Status-Snapshot fuer App/Diagnostic."""
active_id = get_active()
active = get_project(active_id) if active_id else None
return {
"active_id": active_id,
"active": active,
"projects": list_projects(include_archived=False),
}