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.
- chatterdome/__init__.py +5 -0
- chatterdome/__main__.py +146 -0
- chatterdome/debatte_ablauf.py +436 -0
- chatterdome/debatte_cli.py +226 -0
- chatterdome/demo_ablauf.py +196 -0
- chatterdome/haftung.py +68 -0
- chatterdome/i18n.py +100 -0
- chatterdome/kern/__init__.py +1 -0
- chatterdome/kern/absturz.py +129 -0
- chatterdome/kern/busansicht.py +158 -0
- chatterdome/kern/debatte.py +732 -0
- chatterdome/kern/demo_daten.py +294 -0
- chatterdome/kern/demo_quelle.py +228 -0
- chatterdome/kern/demo_texte.py +582 -0
- chatterdome/kern/diskussionsarchiv.py +224 -0
- chatterdome/kern/einstellungen.py +127 -0
- chatterdome/kern/fenster.py +193 -0
- chatterdome/kern/gedaechtnis.py +586 -0
- chatterdome/kern/lokale_quelle.py +509 -0
- chatterdome/kern/modelle.py +353 -0
- chatterdome/kern/protokolle.py +98 -0
- chatterdome/kern/prozessbaum.py +126 -0
- chatterdome/kern/statistik.py +545 -0
- chatterdome/kern/suche.py +346 -0
- chatterdome/kern/terminals.py +252 -0
- chatterdome/kern/transkripte.py +569 -0
- chatterdome/kern/umgebung.py +116 -0
- chatterdome/kern/warten.py +87 -0
- chatterdome/locale/de.json +614 -0
- chatterdome/locale/en.json +614 -0
- chatterdome/tui/__init__.py +1 -0
- chatterdome/tui/app.py +1613 -0
- chatterdome/tui/app.tcss +292 -0
- chatterdome/tui/keymap.py +229 -0
- chatterdome/tui/schutz.py +35 -0
- chatterdome/tui/screens/__init__.py +1 -0
- chatterdome/tui/screens/bestaetigung_screen.py +75 -0
- chatterdome/tui/screens/bild_screen.py +92 -0
- chatterdome/tui/screens/dateiwahl_screen.py +120 -0
- chatterdome/tui/screens/detail_screen.py +196 -0
- chatterdome/tui/screens/einstellungen_screen.py +354 -0
- chatterdome/tui/screens/hilfe_screen.py +175 -0
- chatterdome/tui/screens/rundruf_screen.py +153 -0
- chatterdome/tui/starter.py +202 -0
- chatterdome/tui/widgets/__init__.py +1 -0
- chatterdome/tui/widgets/agenten_tabelle.py +534 -0
- chatterdome/tui/widgets/balken.py +30 -0
- chatterdome/tui/widgets/bus_detail.py +210 -0
- chatterdome/tui/widgets/bus_tabelle.py +285 -0
- chatterdome/tui/widgets/diskussion_panel.py +1233 -0
- chatterdome/tui/widgets/gedaechtnis_detail.py +368 -0
- chatterdome/tui/widgets/kopf_panel.py +153 -0
- chatterdome/tui/widgets/notizen_tabelle.py +317 -0
- chatterdome/tui/widgets/statistik_dashboard.py +285 -0
- chatterdome/tui/widgets/status_zeile.py +110 -0
- chatterdome/tui/widgets/such_panel.py +160 -0
- chatterdome/tui/widgets/verlauf_panel.py +266 -0
- chatterdome-0.3.1.dist-info/METADATA +522 -0
- chatterdome-0.3.1.dist-info/RECORD +63 -0
- chatterdome-0.3.1.dist-info/WHEEL +5 -0
- chatterdome-0.3.1.dist-info/entry_points.txt +2 -0
- chatterdome-0.3.1.dist-info/licenses/LICENSE +191 -0
- chatterdome-0.3.1.dist-info/top_level.txt +1 -0
chatterdome/__init__.py
ADDED
chatterdome/__main__.py
ADDED
|
@@ -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)
|