chatterdome 0.3.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. chatterdome/__init__.py +5 -0
  2. chatterdome/__main__.py +146 -0
  3. chatterdome/debatte_ablauf.py +436 -0
  4. chatterdome/debatte_cli.py +226 -0
  5. chatterdome/demo_ablauf.py +196 -0
  6. chatterdome/haftung.py +68 -0
  7. chatterdome/i18n.py +100 -0
  8. chatterdome/kern/__init__.py +1 -0
  9. chatterdome/kern/absturz.py +129 -0
  10. chatterdome/kern/busansicht.py +158 -0
  11. chatterdome/kern/debatte.py +732 -0
  12. chatterdome/kern/demo_daten.py +294 -0
  13. chatterdome/kern/demo_quelle.py +228 -0
  14. chatterdome/kern/demo_texte.py +582 -0
  15. chatterdome/kern/diskussionsarchiv.py +224 -0
  16. chatterdome/kern/einstellungen.py +127 -0
  17. chatterdome/kern/fenster.py +193 -0
  18. chatterdome/kern/gedaechtnis.py +586 -0
  19. chatterdome/kern/lokale_quelle.py +509 -0
  20. chatterdome/kern/modelle.py +353 -0
  21. chatterdome/kern/protokolle.py +98 -0
  22. chatterdome/kern/prozessbaum.py +126 -0
  23. chatterdome/kern/statistik.py +545 -0
  24. chatterdome/kern/suche.py +346 -0
  25. chatterdome/kern/terminals.py +252 -0
  26. chatterdome/kern/transkripte.py +569 -0
  27. chatterdome/kern/umgebung.py +116 -0
  28. chatterdome/kern/warten.py +87 -0
  29. chatterdome/locale/de.json +614 -0
  30. chatterdome/locale/en.json +614 -0
  31. chatterdome/tui/__init__.py +1 -0
  32. chatterdome/tui/app.py +1613 -0
  33. chatterdome/tui/app.tcss +292 -0
  34. chatterdome/tui/keymap.py +229 -0
  35. chatterdome/tui/schutz.py +35 -0
  36. chatterdome/tui/screens/__init__.py +1 -0
  37. chatterdome/tui/screens/bestaetigung_screen.py +75 -0
  38. chatterdome/tui/screens/bild_screen.py +92 -0
  39. chatterdome/tui/screens/dateiwahl_screen.py +120 -0
  40. chatterdome/tui/screens/detail_screen.py +196 -0
  41. chatterdome/tui/screens/einstellungen_screen.py +354 -0
  42. chatterdome/tui/screens/hilfe_screen.py +175 -0
  43. chatterdome/tui/screens/rundruf_screen.py +153 -0
  44. chatterdome/tui/starter.py +202 -0
  45. chatterdome/tui/widgets/__init__.py +1 -0
  46. chatterdome/tui/widgets/agenten_tabelle.py +534 -0
  47. chatterdome/tui/widgets/balken.py +30 -0
  48. chatterdome/tui/widgets/bus_detail.py +210 -0
  49. chatterdome/tui/widgets/bus_tabelle.py +285 -0
  50. chatterdome/tui/widgets/diskussion_panel.py +1233 -0
  51. chatterdome/tui/widgets/gedaechtnis_detail.py +368 -0
  52. chatterdome/tui/widgets/kopf_panel.py +153 -0
  53. chatterdome/tui/widgets/notizen_tabelle.py +317 -0
  54. chatterdome/tui/widgets/statistik_dashboard.py +285 -0
  55. chatterdome/tui/widgets/status_zeile.py +110 -0
  56. chatterdome/tui/widgets/such_panel.py +160 -0
  57. chatterdome/tui/widgets/verlauf_panel.py +266 -0
  58. chatterdome-0.3.1.dist-info/METADATA +522 -0
  59. chatterdome-0.3.1.dist-info/RECORD +63 -0
  60. chatterdome-0.3.1.dist-info/WHEEL +5 -0
  61. chatterdome-0.3.1.dist-info/entry_points.txt +2 -0
  62. chatterdome-0.3.1.dist-info/licenses/LICENSE +191 -0
  63. chatterdome-0.3.1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,5 @@
1
+ """Chatterdome - Zentrale fuer mehrere gleichzeitig laufende Claude-Code-Instanzen."""
2
+
3
+ __version__ = "0.3.1"
4
+ __author__ = "Michael Blaess"
5
+ __year__ = "2026"
@@ -0,0 +1,146 @@
1
+ """Einstiegspunkt der Oberflaeche."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import sys
7
+
8
+ from chatterdome import __version__
9
+ from chatterdome.i18n import DEFAULT_LANGUAGE, SUPPORTED_LANGUAGES, load_locale
10
+ from chatterdome.kern import absturz, umgebung
11
+ from chatterdome.kern.einstellungen import Einstellungen, alten_ordner_uebernehmen
12
+
13
+
14
+ def soll_grafik_wecken(bild_modus: str, protokoll: str | None) -> bool:
15
+ """Sagt, ob das Grafik-Backend vor dem Start geweckt werden soll.
16
+
17
+ Ohne erkanntes Protokoll fragt das Wecken ein Terminal nach seiner
18
+ Zellgroesse, das gar keine Grafik beherrscht. Die Abfrage laeuft in einen
19
+ Timeout, und textual-image meldet das mit logger.warning(exc_info=...) -
20
+ also mit vollem Traceback auf der Standardfehlerausgabe. Sichtbar wird der
21
+ erst beim Beenden, weil Textual bis dahin den zweiten Bildschirmpuffer
22
+ haelt, und sieht dort nach einem Absturz aus. Belegt am 14.08.2026 auf
23
+ senza (gnome-terminal, TERM=xterm-256color, kein Sixel).
24
+
25
+ :param bild_modus:
26
+ Einstellung "bild_modus": auto, graphics oder halfblock.
27
+ :param protokoll:
28
+ Ergebnis von erkenne_protokoll(), also tgp, sixel oder None.
29
+ :returns:
30
+ True, wenn vorab_initialisieren() sinnvoll ist.
31
+ """
32
+ if bild_modus == "halfblock":
33
+ return False
34
+ # Auch bei erzwungenem "graphics" bringt das Wecken nichts, solange kein
35
+ # Protokoll erkannt ist - die Widget-Klasse kaeme ohnehin nicht zustande.
36
+ return protokoll is not None
37
+
38
+
39
+ def main() -> None:
40
+ """Startet die Zentrale."""
41
+ # Der Demo-Modus MUSS vor jedem Lesen von Einstellungen stehen - sonst
42
+ # stammen Sprache und Thema schon aus dem echten Zuhause. Deshalb der
43
+ # Blick in sys.argv, bevor argparse ueberhaupt gebaut ist.
44
+ demo = "--demo" in sys.argv[1:]
45
+ zuhause = umgebung.demo_aktivieren() if demo else None
46
+ if not demo:
47
+ alten_ordner_uebernehmen()
48
+ einstellungen = Einstellungen()
49
+ werte = einstellungen.laden()
50
+ gespeicherte_sprache = str(werte.get("language", DEFAULT_LANGUAGE))
51
+
52
+ parser = argparse.ArgumentParser(
53
+ prog="chatterdome-tui",
54
+ # Englisch, weil die Hilfe vor dem Laden der Sprache entsteht und der
55
+ # Einzeiler aus dem README als Erstes hierher fuehrt.
56
+ description="A control room for several Claude Code sessions running at the same time",
57
+ )
58
+ parser.add_argument(
59
+ "--lang",
60
+ default=gespeicherte_sprache,
61
+ choices=SUPPORTED_LANGUAGES,
62
+ help="Language of the interface",
63
+ )
64
+ parser.add_argument("--version", action="version", version=f"chatterdome {__version__}")
65
+ parser.add_argument(
66
+ "--demo",
67
+ action="store_true",
68
+ help="Made-up agents and data instead of the real ones, e.g. for screenshots",
69
+ )
70
+ parser.add_argument(
71
+ "--pool",
72
+ default="",
73
+ metavar="NAME",
74
+ help="With --demo: name the made-up agents after another name pool, "
75
+ "e.g. schauspieler, heilige or saenger",
76
+ )
77
+ args = parser.parse_args()
78
+ if args.pool and not demo:
79
+ parser.error("--pool only works together with --demo")
80
+
81
+ # Sprache VOR dem Import der App laden - sonst sind die Beschriftungen
82
+ # der Bindings leer, die zur Klassendefinitionszeit entstehen.
83
+ load_locale(args.lang)
84
+ if args.lang != gespeicherte_sprache:
85
+ einstellungen.speichern({"language": args.lang})
86
+
87
+ from textual_widgets import (
88
+ erkenne_protokoll,
89
+ reset_terminal_title,
90
+ set_terminal_title,
91
+ vorab_initialisieren,
92
+ )
93
+
94
+ from chatterdome.kern.protokolle import Quelle
95
+ from chatterdome.tui.app import ChatterdomeApp
96
+
97
+ quelle: Quelle | None = None
98
+ if zuhause is not None:
99
+ from chatterdome.kern.demo_daten import erzeugen, motiv_waehlen
100
+ from chatterdome.kern.demo_quelle import DemoQuelle
101
+
102
+ if args.pool:
103
+ try:
104
+ motiv_waehlen(args.pool)
105
+ except ValueError as fehler:
106
+ parser.error(str(fehler))
107
+ erzeugen(zuhause, args.lang)
108
+ quelle = DemoQuelle(args.lang)
109
+
110
+ # MUSS vor App.run() stehen: textual-image fragt beim ersten Import die
111
+ # Zellgroesse am Terminal ab. Passiert das erst waehrend der App, landet
112
+ # die Antwort des Terminals als Zeichenmuell im Eingabefeld.
113
+ #
114
+ # Aber nur, wenn das Terminal ueberhaupt Grafik kann - sonst wartet die
115
+ # Abfrage vergeblich und hinterlaesst einen Traceback, siehe
116
+ # soll_grafik_wecken().
117
+ if soll_grafik_wecken(str(werte.get("bild_modus", "auto")), erkenne_protokoll()):
118
+ vorab_initialisieren()
119
+
120
+ set_terminal_title(f"chatterdome v{__version__}{' (Demo)' if demo else ''}")
121
+ # Die Klammer MUSS hier stehen und nicht in der App: nur so umschliesst
122
+ # sie auch das Aufraeumen unten. Bleibt die Endzeile im Protokoll aus,
123
+ # ist genau dieses finally nicht mehr gelaufen - dann war es kein
124
+ # Python-Fehler, sondern ein harter Abbruch von aussen.
125
+ absturz.beobachte(__version__)
126
+ try:
127
+ ChatterdomeApp(quelle=quelle).run()
128
+ finally:
129
+ reset_terminal_title()
130
+ _maus_tracking_aus()
131
+
132
+
133
+ def _maus_tracking_aus() -> None:
134
+ """Schaltet die Maus-Meldungen ab.
135
+
136
+ Unter Windows laesst Textual die Modi gelegentlich aktiv - danach landet
137
+ bei jeder Mausbewegung Steuerzeichen-Muell in der Shell.
138
+ """
139
+ if not sys.stdout.isatty():
140
+ return
141
+ sys.stdout.write("\x1b[?1000l\x1b[?1002l\x1b[?1003l\x1b[?1006l\x1b[?1015l")
142
+ sys.stdout.flush()
143
+
144
+
145
+ if __name__ == "__main__":
146
+ main()
@@ -0,0 +1,436 @@
1
+ """Der Ablauf einer Diskussion rund um den Moderator.
2
+
3
+ Frische Agenten starten und auf ihre Namen warten, moderieren, die selbst
4
+ gestarteten Fenster wieder schliessen, zusammenfassen und das Protokoll
5
+ ablegen. Kurzbefehl und Oberflaeche benutzen denselben Ablauf - frueher stand
6
+ er nur im Kurzbefehl.
7
+
8
+ Bewusst NICHT in ``kern``: der Start frischer Agenten braucht den Starter der
9
+ Oberflaeche (Terminalwahl, Startdatei), und der Kern kennt keine Oberflaeche.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ import os
16
+ import platform
17
+ import shutil
18
+ import sqlite3
19
+ import subprocess
20
+ import threading
21
+ import time
22
+ from collections.abc import Callable
23
+ from dataclasses import dataclass
24
+ from datetime import datetime
25
+ from pathlib import Path
26
+
27
+ from chatterdome.kern.debatte import (
28
+ Beitrag,
29
+ BusKanal,
30
+ Diskussion,
31
+ Teilnehmer,
32
+ Verbrauch,
33
+ als_markdown,
34
+ endegrund,
35
+ moderieren,
36
+ zusammenfassen,
37
+ )
38
+ from chatterdome.kern.diskussionsarchiv import Diskussionsarchiv
39
+ from chatterdome.kern.lokale_quelle import LokaleQuelle
40
+ from chatterdome.kern.prozessbaum import fenster_beenden
41
+ from chatterdome.kern.transkripte import claude_anfragen_ab, claude_transkript
42
+ from chatterdome.tui.starter import saubere_umgebung, starte_lokal
43
+
44
+ STARTFRIST = 120.0
45
+ """Sekunden, bis eine frisch gestartete Sitzung im Bestand auftauchen muss."""
46
+
47
+ ANLAUF = 15.0
48
+ """Nach dem Auftauchen: Zeit fuer SessionStart-Hook und Inbox-Socket."""
49
+
50
+ ZUSAMMENFASSUNG_FRIST = 240.0
51
+ """Sekunden fuer den einmaligen Aufruf, der die Diskussion zusammenfasst."""
52
+
53
+ ZUSAMMENFASSUNG_LAEUFT = "Zusammenfassung wird erstellt ..."
54
+ """Meldung waehrend der Zusammenfassung. Der Ablauf ist sprachneutral, die Oberflaeche
55
+ erkennt die Meldung an dieser Konstante und zeigt ihren eigenen Text."""
56
+
57
+
58
+ @dataclass
59
+ class Ergebnis:
60
+ """Was nach dem Ablauf bleibt."""
61
+
62
+ diskussion: Diskussion
63
+ protokoll: Path | None = None
64
+ """Die abgelegte Markdown-Datei, None wenn der Ablauf vor dem Start scheiterte."""
65
+ fehler: str = ""
66
+ """Warum der Ablauf gar nicht erst in die Diskussion kam."""
67
+
68
+
69
+ def rechner() -> str:
70
+ """Der eigene Rechnername wie im Bus."""
71
+ # COMPUTERNAME zuerst, weil node() Suffixe wie .local liefern kann.
72
+ return (os.environ.get("COMPUTERNAME") or platform.node().split(".")[0]).upper()
73
+
74
+
75
+ def ablage() -> Path:
76
+ """Arbeitsordner der Diskussionen und Ablage der Protokolle.
77
+
78
+ Immer derselbe Ordner: Claude Code fragt nur beim ersten Mal, ob man ihm
79
+ vertraut. Ein neuer Ordner je Diskussion haette die Abfrage jedes Mal.
80
+ """
81
+ ordner = Path.home() / ".claude" / "bus" / rechner() / "diskussionen"
82
+ ordner.mkdir(parents=True, exist_ok=True)
83
+ return ordner
84
+
85
+
86
+ def modelle(quelle: LokaleQuelle) -> dict[str, str]:
87
+ """Kleingeschriebener Agentenname auf das Modell, mit dem er laeuft, soweit bekannt."""
88
+ return {a.name.lower(): a.modell for a in quelle.bestand().agenten if a.modell}
89
+
90
+
91
+ def laufende(quelle: LokaleQuelle) -> dict[str, str]:
92
+ """Laufende Agenten als Sitzungskennung auf Namen."""
93
+ return {a.session_id: a.name for a in quelle.bestand().agenten if a.session_id}
94
+
95
+
96
+ def warte_auf_neue(quelle: LokaleQuelle, vorher: set[str], anzahl: int) -> list[str]:
97
+ """Wartet, bis ``anzahl`` neue Sitzungen im Bestand stehen. Liefert deren Namen.
98
+
99
+ Die Namen vergibt der SessionStart-Hook. Welche Sitzung neu ist, verraet
100
+ nur der Vergleich mit dem Stand vor dem Start.
101
+ """
102
+ ende = time.monotonic() + STARTFRIST
103
+ neue: dict[str, str] = {}
104
+ while len(neue) < anzahl and time.monotonic() < ende:
105
+ neue = {s: n for s, n in laufende(quelle).items() if s not in vorher}
106
+ if len(neue) < anzahl:
107
+ time.sleep(3)
108
+ return list(neue.values())
109
+
110
+
111
+ def ohne_kontext(ordner: Path, *, hooks: bool = True) -> tuple[list[str], dict[str, str]]:
112
+ """Claude-Argumente und Umgebung fuer eine Sitzung ohne CLAUDE.md und Memory.
113
+
114
+ Nicht ``--bare``: das laesst auch alle Hooks weg (kein Name, kein Socket,
115
+ also kein Bus) und liest kein OAuth. Stattdessen die drei dokumentierten
116
+ Stellschrauben aus code.claude.com/docs/en/memory.md. Am 28.09.2026 mit
117
+ einer Kontrollsitzung gegengeprueft. Die Einstellungen gehen als Datei
118
+ hinein, weil JSON mit Anfuehrungszeichen den Weg durch die .cmd-Startdatei
119
+ nicht heil uebersteht.
120
+ """
121
+ global_md = Path.home() / ".claude" / "CLAUDE.md"
122
+ einstellungen: dict[str, object] = {
123
+ "autoMemoryEnabled": False,
124
+ # Beide Schreibweisen, weil die Doku von Glob-Mustern auf absolute
125
+ # Pfade spricht und offen laesst, wie Windows-Trenner verglichen werden.
126
+ "claudeMdExcludes": [str(global_md), global_md.as_posix()],
127
+ }
128
+ if not hooks:
129
+ # Nur fuer Einmal-Aufrufe. Eine Diskussionssitzung braucht ihre Hooks,
130
+ # sonst hat sie weder Namen noch Socket.
131
+ einstellungen["disableAllHooks"] = True
132
+ datei = ordner / ("ohne-kontext.json" if hooks else "ohne-kontext-ohne-hooks.json")
133
+ datei.write_text(json.dumps(einstellungen, indent=2) + "\n", encoding="utf-8", newline="\n")
134
+ return ["--settings", str(datei)], {"CLAUDE_CODE_DISABLE_AUTO_MEMORY": "1"}
135
+
136
+
137
+ def _json_antwort(ausgabe: str) -> tuple[str, Verbrauch]:
138
+ """Zerlegt die Ausgabe von ``claude -p --output-format json`` in Text und Verbrauch.
139
+
140
+ Aufbau am 28.09.2026 mit einem echten Aufruf geprueft: ``result`` traegt
141
+ den Text, ``usage`` die vier Token-Arten wie im Transkript. Ist die Ausgabe
142
+ kein JSON, gilt sie als reiner Text - dann eben ohne Verbrauch.
143
+ """
144
+ try:
145
+ daten = json.loads(ausgabe)
146
+ except ValueError:
147
+ return ausgabe.strip(), Verbrauch()
148
+ if not isinstance(daten, dict):
149
+ return ausgabe.strip(), Verbrauch()
150
+ nutzung = daten.get("usage")
151
+ verbrauch = Verbrauch()
152
+ if isinstance(nutzung, dict):
153
+ def zahl(schluessel: str) -> int:
154
+ wert = nutzung.get(schluessel)
155
+ return wert if isinstance(wert, int) else 0
156
+ verbrauch = Verbrauch(
157
+ neu=zahl("input_tokens") + zahl("cache_creation_input_tokens"),
158
+ cache=zahl("cache_read_input_tokens"),
159
+ aus=zahl("output_tokens"),
160
+ )
161
+ return str(daten.get("result") or "").strip(), verbrauch
162
+
163
+
164
+ def claude_einmal(auftrag: str, ordner: Path, modell: str = "") -> tuple[str, str, Verbrauch]:
165
+ """Ein einmaliger ``claude -p``-Aufruf ohne Fenster, ohne Bus und ohne eigenen Kontext.
166
+
167
+ Der Auftrag geht ueber stdin, nicht als Argument: der Wrapper claude.cmd
168
+ wuerde ihn sonst am ersten Zeilenumbruch abschneiden, wie es bei
169
+ chatterdome.CMD geschehen ist. Hooks sind abgeschaltet, sonst vergaebe der
170
+ Namens-Hook dem Einmal-Aufruf einen Namen aus dem Pool.
171
+
172
+ :returns: ``(text, "", verbrauch)`` bei Erfolg, sonst ``("", grund, Verbrauch())``.
173
+ """
174
+ claude = os.environ.get("CLAUDE_CODE_EXECPATH") or shutil.which("claude") or "claude"
175
+ argumente, umgebung = ohne_kontext(ordner, hooks=False)
176
+ befehl = [claude, "-p", *argumente, "--output-format", "json",
177
+ "--disallowedTools", "WebSearch", "WebFetch", "Bash", "Read", "Write", "Edit"]
178
+ if modell:
179
+ befehl += ["--model", modell]
180
+ try:
181
+ lauf = subprocess.run( # fester Befehl, keine Shell
182
+ befehl,
183
+ input=auftrag,
184
+ capture_output=True,
185
+ text=True,
186
+ encoding="utf-8",
187
+ errors="replace",
188
+ env={**saubere_umgebung(), **umgebung},
189
+ cwd=str(ordner),
190
+ timeout=ZUSAMMENFASSUNG_FRIST,
191
+ check=False,
192
+ )
193
+ except (OSError, subprocess.TimeoutExpired) as fehler:
194
+ return "", str(fehler), Verbrauch()
195
+ if lauf.returncode != 0:
196
+ grund = (lauf.stderr or lauf.stdout).strip()[:500] or f"Exit-Code {lauf.returncode}"
197
+ return "", grund, Verbrauch()
198
+ text, verbrauch = _json_antwort(lauf.stdout)
199
+ return text, "", verbrauch
200
+
201
+
202
+ class Verbrauchszaehler:
203
+ """Zaehlt die Tokens der Teilnehmer seit Beginn der Diskussion.
204
+
205
+ Gemerkt wird je Sitzung die Groesse ihres Transkripts beim Eintritt,
206
+ gezaehlt wird nur, was danach dazukam. Eine frisch gestartete Sitzung
207
+ zaehlt ab Byte 0, ihr Anlauf gehoert ja zur Diskussion. Tut ein laufender
208
+ Agent nebenher anderes, zaehlt das mit - die Zahl ist also eher zu hoch
209
+ als zu niedrig.
210
+ """
211
+
212
+ def __init__(self, wurzel: Path | None = None) -> None:
213
+ self._wurzel = wurzel
214
+ self._versatz: dict[str, int] = {}
215
+
216
+ def merken(self, sitzung: str, *, frisch: bool = False) -> None:
217
+ if not sitzung or sitzung in self._versatz:
218
+ return
219
+ datei = claude_transkript(sitzung, self._wurzel)
220
+ groesse = 0
221
+ if datei is not None and not frisch:
222
+ try:
223
+ groesse = datei.stat().st_size
224
+ except OSError:
225
+ groesse = 0
226
+ self._versatz[sitzung] = groesse
227
+
228
+ def stand(self) -> Verbrauch:
229
+ summe = Verbrauch()
230
+ for sitzung, versatz in self._versatz.items():
231
+ datei = claude_transkript(sitzung, self._wurzel)
232
+ if datei is None:
233
+ continue
234
+ for a in claude_anfragen_ab(datei, versatz):
235
+ summe = summe + Verbrauch(a.frisch + a.cache_neu, a.cache_gelesen, a.aus)
236
+ return summe
237
+
238
+
239
+ def namen_zuordnen(wunsch: list[str], vergeben: list[str]) -> list[str]:
240
+ """Ordnet den gestarteten Sitzungen ihre Plaetze zu.
241
+
242
+ :param wunsch: je Start der gewuenschte Name, leer fuer "aus dem Pool".
243
+ :param vergeben: die Namen, die tatsaechlich im Bestand auftauchten.
244
+ :returns: je Start der vergebene Name, in der Reihenfolge von ``wunsch``.
245
+
246
+ Wer seinen Wunschnamen bekommen hat, behaelt ihn. Die uebrigen Namen gehen
247
+ der Reihe nach an die uebrigen Plaetze - auch an einen Wunsch, den der
248
+ Pool nicht erfuellt hat, dann uebernimmt eben ein anderer Name die Rolle.
249
+ """
250
+ frei = list(vergeben)
251
+ ergebnis = [""] * len(wunsch)
252
+ for i, name in enumerate(wunsch):
253
+ treffer = next((v for v in frei if name and v.lower() == name.lower()), None)
254
+ if treffer is not None:
255
+ ergebnis[i] = treffer
256
+ frei.remove(treffer)
257
+ for i in range(len(wunsch)):
258
+ if not ergebnis[i] and frei:
259
+ ergebnis[i] = frei.pop(0)
260
+ return ergebnis
261
+
262
+
263
+ def aufraeumen(quelle: LokaleQuelle, namen: list[str]) -> list[str]:
264
+ """Beendet die selbst gestarteten Sitzungen samt ihrem Fenster.
265
+
266
+ Erst das ganze Fenster, weil ``stop`` allein unter Windows die
267
+ Mausverfolgung eingeschaltet laesst, siehe ``kern.prozessbaum``. Findet
268
+ sich die Startdatei nicht, bleibt ``stop`` als Rueckfall.
269
+
270
+ :returns: je Sitzung, die sich nicht beenden liess, eine Meldung.
271
+ """
272
+ if not namen:
273
+ return []
274
+ meldungen: list[str] = []
275
+ pids = {a.name.lower(): a.pid for a in quelle.bestand().agenten if a.pid}
276
+ for name in namen:
277
+ pid = pids.get(name.lower())
278
+ grund = fenster_beenden(pid) if pid else "PID unbekannt"
279
+ if not grund:
280
+ continue
281
+ fehler = quelle.stoppen(name)
282
+ if fehler:
283
+ meldungen.append(f"{name}: {grund}; {fehler}")
284
+ return meldungen
285
+
286
+
287
+ def ausfuehren(
288
+ diskussion: Diskussion,
289
+ neu: list[Teilnehmer],
290
+ *,
291
+ ohne_eigenen_kontext: bool = False,
292
+ wiederbeleben: list[Teilnehmer] | None = None,
293
+ melden: Callable[[str], None] = lambda _text: None,
294
+ beim_beitrag: Callable[[Beitrag], None] | None = None,
295
+ beim_wort: Callable[[Teilnehmer, int], None] | None = None,
296
+ beim_start: Callable[[Diskussion], None] | None = None,
297
+ beim_verbrauch: Callable[[Verbrauch], None] | None = None,
298
+ beim_vorbereiten: Callable[[list[Teilnehmer]], None] | None = None,
299
+ stopp: threading.Event | None = None,
300
+ quelle: LokaleQuelle | None = None,
301
+ archiv: Diskussionsarchiv | None = None,
302
+ protokoll: Path | None = None,
303
+ zaehler: Verbrauchszaehler | None = None,
304
+ ) -> Ergebnis:
305
+ """Fuehrt eine Diskussion aus, neu oder fortgesetzt. Blockiert, gehoert in einen Thread.
306
+
307
+ :param diskussion: mit den laufenden Agenten als Teilnehmer, die frischen fehlen noch.
308
+ Beim Fortsetzen steht das bisherige Protokoll schon darin und ``runden`` ist
309
+ die neue Gesamtzahl.
310
+ :param neu: je frische Sitzung ein Teilnehmer, dessen Name ein Platzhalter ist.
311
+ Seite und Rolle werden auf die tatsaechlich vergebenen Namen uebertragen.
312
+ :param ohne_eigenen_kontext: frische Sitzungen ohne eigene CLAUDE.md und Memory.
313
+ :param wiederbeleben: Teilnehmer aus ``diskussion``, deren Sitzung nicht mehr
314
+ laeuft. Sie werden unter ihrem Namen neu gestartet.
315
+ :param melden: Fortschrittsmeldungen ausserhalb der Beitraege.
316
+ :param beim_wort: siehe ``moderieren``.
317
+ :param beim_start: bekommt die Diskussion, sobald alle Teilnehmer ihre Namen haben.
318
+ :param beim_verbrauch: bekommt nach jedem Beitrag den Verbrauch der ganzen Diskussion.
319
+ :param archiv: wohin gespeichert wird, None fuer das Archiv im Einstellungsordner.
320
+ :param protokoll: die Markdown-Datei einer fortgesetzten Diskussion, sonst eine neue.
321
+ """
322
+ quelle = quelle or LokaleQuelle()
323
+ stopp = stopp or threading.Event()
324
+ archiv = archiv or Diskussionsarchiv()
325
+ zaehler = zaehler or Verbrauchszaehler()
326
+ wiederbeleben = wiederbeleben or []
327
+ ordner = ablage()
328
+ gestartet: list[str] = []
329
+ basis = diskussion.verbrauch
330
+ if not diskussion.beginn:
331
+ diskussion.beginn = datetime.now().isoformat(timespec="seconds")
332
+ diskussion.ohne_kontext = ohne_eigenen_kontext
333
+
334
+ def sichern(protokoll_pfad: str = "") -> None:
335
+ # Das Archiv darf die Diskussion nie aufhalten - ein Fehler wird gemeldet.
336
+ try:
337
+ archiv.speichern(diskussion, protokoll_pfad)
338
+ except (sqlite3.Error, OSError) as fehler:
339
+ melden(f"Archiv: {fehler}")
340
+
341
+ def nach_beitrag(beitrag: Beitrag) -> None:
342
+ diskussion.verbrauch = basis + zaehler.stand()
343
+ sichern()
344
+ if beim_beitrag is not None:
345
+ beim_beitrag(beitrag)
346
+ if beim_verbrauch is not None:
347
+ beim_verbrauch(diskussion.verbrauch)
348
+
349
+ def modelle_merken() -> None:
350
+ bekannt = modelle(quelle)
351
+ for teilnehmer in diskussion.teilnehmer:
352
+ teilnehmer.modell = bekannt.get(teilnehmer.name.lower(), teilnehmer.modell)
353
+
354
+ try:
355
+ wunsch = [t.name for t in wiederbeleben] + [""] * len(neu)
356
+ if wunsch:
357
+ vorher = set(laufende(quelle))
358
+ # Ohne Vorbereitung braucht niemand die Websuche - beim Fortsetzen
359
+ # steht sie schon im Protokoll.
360
+ ohne_web = not diskussion.recherche or diskussion.gespielte_runden > 0
361
+ for name in wunsch:
362
+ argumente, umgebung = ohne_kontext(ordner) if ohne_eigenen_kontext else ([], {})
363
+ if diskussion.modell:
364
+ argumente = [*argumente, "--model", diskussion.modell]
365
+ if ohne_web:
366
+ # Bei frischen Sitzungen hart gesperrt, bei laufenden Agenten
367
+ # bleibt es bei der Bitte in der Anweisung.
368
+ argumente = [*argumente, "--disallowedTools", "WebSearch", "WebFetch"]
369
+ fehler = starte_lokal(
370
+ name, verzeichnis=str(ordner), claude_argumente=argumente, umgebung=umgebung
371
+ )
372
+ if fehler:
373
+ return Ergebnis(diskussion, fehler=f"Start fehlgeschlagen: {fehler}")
374
+ melden(f"{len(wunsch)} Sitzung(en) gestartet, warte auf die Namen ...")
375
+ gestartet = warte_auf_neue(quelle, vorher, len(wunsch))
376
+ if len(gestartet) < len(wunsch):
377
+ return Ergebnis(diskussion, fehler=(
378
+ f"Nach {STARTFRIST:.0f} s erst {len(gestartet)} von {len(wunsch)} "
379
+ "Sitzungen im Bestand."
380
+ ))
381
+ # Kurz warten, damit Hook und Inbox-Socket stehen - aber abbrechbar.
382
+ if stopp.wait(ANLAUF):
383
+ diskussion.ende = endegrund("discussion.end_stopped", "von Hand gestoppt")
384
+ return Ergebnis(diskussion, fehler="Vor dem Start gestoppt.")
385
+ zugeordnet = namen_zuordnen(wunsch, gestartet)
386
+ for teilnehmer, name in zip(wiederbeleben, zugeordnet, strict=False):
387
+ if name.lower() != teilnehmer.name.lower():
388
+ melden(f"{name} übernimmt für {teilnehmer.name}")
389
+ teilnehmer.name = name
390
+ diskussion.teilnehmer = diskussion.teilnehmer + [
391
+ Teilnehmer(name, t.rolle, t.seite)
392
+ for name, t in zip(zugeordnet[len(wiederbeleben):], neu, strict=True)
393
+ ]
394
+
395
+ modelle_merken()
396
+ sitzungen = {n.lower(): s for s, n in laufende(quelle).items()}
397
+ frisch = {n.lower() for n in gestartet}
398
+ for teilnehmer in diskussion.teilnehmer:
399
+ teilnehmer.sitzung = sitzungen.get(teilnehmer.name.lower(), teilnehmer.sitzung)
400
+ zaehler.merken(teilnehmer.sitzung, frisch=teilnehmer.name.lower() in frisch)
401
+
402
+ namen = ", ".join(t.name for t in diskussion.teilnehmer)
403
+ melden(f'Diskussion: "{diskussion.thema}" mit {namen}')
404
+ sichern()
405
+ if beim_start is not None:
406
+ beim_start(diskussion)
407
+ moderieren(diskussion, BusKanal(quelle, rechner()), beim_beitrag=nach_beitrag,
408
+ beim_wort=beim_wort, beim_vorbereiten=beim_vorbereiten, stopp=stopp)
409
+ finally:
410
+ # Vor dem Aufraeumen: eine frische Sitzung nennt ihr Modell erst nach
411
+ # der ersten Antwort, und danach ist sie weg.
412
+ modelle_merken()
413
+ for meldung in aufraeumen(quelle, gestartet):
414
+ melden(f"Nicht beendet: {meldung}")
415
+
416
+ diskussion.verbrauch = basis + zaehler.stand()
417
+ if any(b.art in ("beitrag", "schlusswort") for b in diskussion.beitraege):
418
+ melden(ZUSAMMENFASSUNG_LAEUFT)
419
+ extra = Verbrauch()
420
+
421
+ def modell(auftrag: str) -> tuple[str, str]:
422
+ nonlocal extra
423
+ text, fehler, extra = claude_einmal(auftrag, ordner, diskussion.modell)
424
+ return text, fehler
425
+
426
+ grund = zusammenfassen(diskussion, modell)
427
+ diskussion.verbrauch = diskussion.verbrauch + extra
428
+ if grund:
429
+ melden(f"Keine Zusammenfassung: {grund}")
430
+ if beim_verbrauch is not None:
431
+ beim_verbrauch(diskussion.verbrauch)
432
+
433
+ datei = protokoll or ordner / f"{datetime.now():%Y%m%d-%H%M%S}.md"
434
+ datei.write_text(als_markdown(diskussion), encoding="utf-8", newline="\n")
435
+ sichern(str(datei))
436
+ return Ergebnis(diskussion, protokoll=datei)