fc0f91d1e6
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>
208 lines
8.4 KiB
Python
208 lines
8.4 KiB
Python
"""
|
|
Conversation-State — ein einziger Rolling-Window-State fuer ARIAs
|
|
laufendes Gespraech mit Stefan.
|
|
|
|
Stefan-Entscheidung: KEINE Sessions, KEIN Multi-Thread. EIN Strang,
|
|
intern rollend. Was rausfaellt, wird ggf. destilliert und landet
|
|
als type=fact Memory in der Vector-DB.
|
|
|
|
Persistenz: append-only JSONL unter /data/conversation.jsonl.
|
|
Bei Restart wird die letzte N gelesen (komplett vermeidet Memory-
|
|
Overhead bei sehr langen Verlaeufen).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import logging
|
|
import os
|
|
from dataclasses import dataclass, field, asdict
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
from typing import List, Optional
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
CONVERSATION_FILE = Path(os.environ.get("CONVERSATION_FILE", "/data/conversation.jsonl"))
|
|
|
|
|
|
@dataclass
|
|
class Turn:
|
|
role: str # "user" | "assistant"
|
|
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:
|
|
"""In-Memory Rolling Window, mit JSONL-Persistenz."""
|
|
|
|
def __init__(self, max_window: int = 50, distill_threshold: int = 60,
|
|
distill_count: int = 30):
|
|
self.max_window = max_window
|
|
self.distill_threshold = distill_threshold
|
|
self.distill_count = distill_count
|
|
self.turns: List[Turn] = []
|
|
self._load()
|
|
|
|
def _load(self):
|
|
if not CONVERSATION_FILE.exists():
|
|
return
|
|
try:
|
|
lines = CONVERSATION_FILE.read_text(encoding="utf-8").splitlines()
|
|
except Exception as exc:
|
|
logger.warning("Konversation laden fehlgeschlagen: %s", exc)
|
|
return
|
|
loaded: List[Turn] = []
|
|
for line in lines:
|
|
line = line.strip()
|
|
if not line:
|
|
continue
|
|
try:
|
|
obj = json.loads(line)
|
|
except Exception:
|
|
continue
|
|
if obj.get("op") == "distill":
|
|
# Marker: bis hierhin wurde alles destilliert
|
|
drop_until_ts = obj.get("ts", "")
|
|
if drop_until_ts:
|
|
loaded = [t for t in loaded if t.ts > drop_until_ts]
|
|
continue
|
|
role = obj.get("role")
|
|
content = obj.get("content")
|
|
if role in ("user", "assistant") and isinstance(content, str):
|
|
loaded.append(Turn(role=role, content=content,
|
|
ts=obj.get("ts", ""),
|
|
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)
|
|
|
|
def _append_to_file(self, record: dict):
|
|
try:
|
|
CONVERSATION_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
with CONVERSATION_FILE.open("a", encoding="utf-8") as f:
|
|
f.write(json.dumps(record, ensure_ascii=False) + "\n")
|
|
except Exception as exc:
|
|
logger.warning("Konversation persist fehlgeschlagen: %s", exc)
|
|
|
|
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)
|
|
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, 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
|
|
|
|
def take_oldest_for_distill(self) -> List[Turn]:
|
|
"""Gibt die N aeltesten Turns zurueck — fuer den Destillat-Call.
|
|
Entfernt sie NICHT — das macht commit_distill nach erfolgreichem Call."""
|
|
return self.turns[: self.distill_count]
|
|
|
|
def commit_distill(self, last_distilled_ts: str):
|
|
"""Schreibt einen Distill-Marker, entfernt aus dem In-Memory-Window."""
|
|
self._append_to_file({"op": "distill", "ts": last_distilled_ts})
|
|
self.turns = [t for t in self.turns if t.ts > last_distilled_ts]
|
|
logger.info("Distill commit bei ts=%s — Window jetzt %d Turns", last_distilled_ts, len(self.turns))
|
|
|
|
def reset(self):
|
|
"""Hardes Reset — verwende vorsichtig (Diagnostic-Button)."""
|
|
try:
|
|
if CONVERSATION_FILE.exists():
|
|
CONVERSATION_FILE.unlink()
|
|
except Exception:
|
|
pass
|
|
self.turns = []
|
|
logger.warning("Konversation komplett zurueckgesetzt")
|
|
|
|
def _rewrite_file(self) -> None:
|
|
"""Datei komplett aus In-Memory-State neu schreiben.
|
|
Wird nach Mutationen (Loeschen) genutzt. Alte distill-Marker
|
|
gehen dabei verloren — das ist OK weil der In-Memory-State
|
|
bereits post-distill ist."""
|
|
try:
|
|
CONVERSATION_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
tmp = CONVERSATION_FILE.with_suffix(".jsonl.tmp")
|
|
with tmp.open("w", encoding="utf-8") as f:
|
|
for t in self.turns:
|
|
rec = {
|
|
"ts": t.ts, "role": t.role,
|
|
"content": t.content, "source": t.source,
|
|
}
|
|
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)
|
|
|
|
def remove_by_match(self, role: str, content: str,
|
|
ts_iso_hint: Optional[str] = None) -> bool:
|
|
"""Entfernt EINEN Turn mit passendem role + content.
|
|
|
|
Bei Mehrfach-Match (z.B. zwei identische 'ja'-Turns) waehlt
|
|
den naehesten zum ts_iso_hint, sonst den juengsten.
|
|
|
|
Returns True wenn was entfernt wurde.
|
|
"""
|
|
candidates = [(i, t) for i, t in enumerate(self.turns)
|
|
if t.role == role and t.content == content]
|
|
if not candidates:
|
|
logger.info("[conv] remove_by_match: kein Match fuer role=%s content[:40]=%r",
|
|
role, content[:40])
|
|
return False
|
|
if len(candidates) > 1 and ts_iso_hint:
|
|
def _diff(item):
|
|
_, turn = item
|
|
try:
|
|
return abs((datetime.fromisoformat(turn.ts.replace("Z", "+00:00"))
|
|
- datetime.fromisoformat(ts_iso_hint.replace("Z", "+00:00"))).total_seconds())
|
|
except Exception:
|
|
return 1e9
|
|
candidates.sort(key=_diff)
|
|
idx, turn = candidates[0] if not ts_iso_hint else candidates[0]
|
|
self.turns.pop(idx)
|
|
self._rewrite_file()
|
|
logger.info("[conv] Turn entfernt: role=%s ts=%s content[:40]=%r",
|
|
turn.role, turn.ts, turn.content[:40])
|
|
return True
|
|
|
|
def stats(self) -> dict:
|
|
return {
|
|
"turns": len(self.turns),
|
|
"max_window": self.max_window,
|
|
"distill_threshold": self.distill_threshold,
|
|
"needs_distill": self.needs_distill(),
|
|
}
|