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)
+38 -10
View File
@@ -32,6 +32,7 @@ class Turn:
content: str
ts: str = field(default_factory=lambda: datetime.now(timezone.utc).isoformat())
source: str = "" # "app" / "diagnostic" / "stt" — optional
project_id: str = "" # leer = Hauptthread; sonst projects.py-ID
class Conversation:
@@ -73,7 +74,8 @@ class Conversation:
if role in ("user", "assistant") and isinstance(content, str):
loaded.append(Turn(role=role, content=content,
ts=obj.get("ts", ""),
source=obj.get("source", "")))
source=obj.get("source", ""),
project_id=obj.get("project_id", "")))
self.turns = loaded
logger.info("Konversation geladen: %d Turns aus %s", len(self.turns), CONVERSATION_FILE)
@@ -85,17 +87,40 @@ class Conversation:
except Exception as exc:
logger.warning("Konversation persist fehlgeschlagen: %s", exc)
def add(self, role: str, content: str, source: str = "") -> Turn:
t = Turn(role=role, content=content, source=source)
def add(self, role: str, content: str, source: str = "",
project_id: str = "") -> Turn:
t = Turn(role=role, content=content, source=source, project_id=project_id)
self.turns.append(t)
self._append_to_file({
record = {
"ts": t.ts, "role": t.role, "content": t.content, "source": t.source,
})
}
if t.project_id:
record["project_id"] = t.project_id
self._append_to_file(record)
return t
def window(self) -> List[Turn]:
"""Die letzten max_window Turns — gehen in den LLM-Prompt."""
return self.turns[-self.max_window:]
def window(self, project_id: Optional[str] = None) -> List[Turn]:
"""Die letzten max_window Turns — gehen in den LLM-Prompt.
Wenn project_id gesetzt: nur Turns aus diesem Projekt + die letzten
~5 Hauptthread-Turns als Kontext. Wenn project_id leer/None und
explizit uebergeben → nur Hauptthread."""
if project_id is None:
return self.turns[-self.max_window:]
if project_id == "":
# Hauptthread-Modus: alle Turns, aber project-getaggte rausfiltern
main_turns = [t for t in self.turns if not t.project_id]
return main_turns[-self.max_window:]
# In-Projekt: alle Turns des Projekts + Tail des Hauptthreads als Kontext
project_turns = [t for t in self.turns if t.project_id == project_id]
return project_turns[-self.max_window:]
def window_recent_per_project(self) -> dict:
"""Returns {project_id: [last N turns]} — fuer „hol mich ab"-Summary."""
groups: dict[str, List[Turn]] = {}
for t in self.turns:
pid = t.project_id or ""
groups.setdefault(pid, []).append(t)
return groups
def needs_distill(self) -> bool:
return len(self.turns) > self.distill_threshold
@@ -131,10 +156,13 @@ class Conversation:
tmp = CONVERSATION_FILE.with_suffix(".jsonl.tmp")
with tmp.open("w", encoding="utf-8") as f:
for t in self.turns:
f.write(json.dumps({
rec = {
"ts": t.ts, "role": t.role,
"content": t.content, "source": t.source,
}, ensure_ascii=False) + "\n")
}
if t.project_id:
rec["project_id"] = t.project_id
f.write(json.dumps(rec, ensure_ascii=False) + "\n")
tmp.replace(CONVERSATION_FILE)
except Exception as exc:
logger.warning("Konversation rewrite fehlgeschlagen: %s", exc)
+71
View File
@@ -38,6 +38,7 @@ import watcher as watcher_mod
import background as background_mod
import oauth as oauth_mod
import seed_rules as seed_rules_mod
import projects as projects_mod
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(name)s: %(message)s")
logger = logging.getLogger("aria-brain")
@@ -639,6 +640,76 @@ def chat(body: ChatIn, background: BackgroundTasks):
)
# ── Projekte ────────────────────────────────────────────────────────
@app.get("/projects/status")
def projects_status():
"""Komplett-Status: aktives Projekt + Liste aller (nicht-archivierten)."""
return projects_mod.status()
@app.get("/projects/list")
def projects_list(include_archived: bool = False):
return {"projects": projects_mod.list_projects(include_archived=include_archived)}
class ProjectCreateBody(BaseModel):
name: str
description: str = ""
@app.post("/projects/create")
def projects_create(body: ProjectCreateBody):
try:
p = projects_mod.create_project(body.name, body.description)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc))
return p
class ProjectSwitchBody(BaseModel):
project_id: str = ""
@app.post("/projects/switch")
def projects_switch(body: ProjectSwitchBody):
"""Aktive Projekt-ID setzen. Leerer String → Hauptthread."""
if body.project_id:
p = projects_mod.get_project(body.project_id)
if not p:
raise HTTPException(status_code=404, detail=f"Projekt {body.project_id} nicht gefunden")
projects_mod.set_active(body.project_id)
return projects_mod.status()
@app.post("/projects/{project_id}/end")
def projects_end(project_id: str):
if not projects_mod.end_project(project_id):
raise HTTPException(status_code=404, detail=f"Projekt {project_id} nicht gefunden")
return projects_mod.get_project(project_id) or {"id": project_id, "status": "ended"}
@app.post("/projects/{project_id}/archive")
def projects_archive(project_id: str):
if not projects_mod.archive_project(project_id):
raise HTTPException(status_code=404, detail=f"Projekt {project_id} nicht gefunden")
return {"id": project_id, "status": "archived"}
class ProjectUpdateBody(BaseModel):
name: Optional[str] = None
description: Optional[str] = None
@app.patch("/projects/{project_id}")
def projects_update(project_id: str, body: ProjectUpdateBody):
patch = body.dict(exclude_unset=True)
p = projects_mod.update_project(project_id, patch)
if p is None:
raise HTTPException(status_code=404, detail=f"Projekt {project_id} nicht gefunden")
return p
@app.get("/conversation/stats")
def conversation_stats():
return conversation().stats()
+219
View File
@@ -0,0 +1,219 @@
"""
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),
}