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