kimal3 0.4.0 → 0.6.0
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.
- package/README.md +8 -0
- package/coach/de/kimal3-coach.md +2 -2
- package/coach/en/kimal3-coach.md +2 -2
- package/dist/befehle/einrichten.js +1 -1
- package/dist/befehle/login.js +6 -2
- package/dist/befehle/plan-hoch.js +114 -0
- package/dist/befehle/plan.js +110 -0
- package/dist/befehle/skill-install.js +4 -1
- package/dist/befehle/zeigen-einrichten.js +104 -0
- package/dist/befehle/zeigen-start.js +143 -0
- package/dist/befehle/zeigen.js +288 -0
- package/dist/befehle-katalog-en.js +41 -0
- package/dist/befehle-katalog.js +45 -0
- package/dist/befehlswoerter.js +16 -0
- package/dist/kimal3.js +15 -0
- package/dist/logo-ablegen.js +26 -6
- package/dist/plan-datei.js +172 -0
- package/dist/slash-befehle.js +2 -0
- package/dist/texte/einrichten.js +10 -3
- package/dist/texte/plan.js +93 -0
- package/dist/texte/zeigen.js +189 -0
- package/dist/zeigen-rechnung.js +226 -0
- package/dist/zeigen-umgebung.js +190 -0
- package/package.json +30 -29
- package/skill/SKILL.md +4 -2
- package/skill/commands/k3.md +5 -3
- package/skill/commands/k3plan.md +347 -0
- package/skill/commands/k3start.md +1 -1
- package/skill/commands/k3zeigen.md +303 -0
- package/skill/en/SKILL.md +5 -3
- package/skill/en/commands/k3.md +5 -3
- package/skill/en/commands/k3plan.md +342 -0
- package/skill/en/commands/k3show.md +302 -0
- package/skill/en/commands/k3start.md +1 -1
- package/zeigen/gemeinsam.py +82 -0
- package/zeigen/starter.py +163 -0
- package/zeigen/werkzeug.py +250 -0
- package/zeigen/zeitleiste.py +393 -0
|
@@ -24,7 +24,7 @@ say so briefly and skip it — no filler.
|
|
|
24
24
|
- Is there a status file (`NEXT-STEPS.md` or `ROADMAP.md`) — a small file in which the
|
|
25
25
|
latest status and the next step are written down? Then read it.
|
|
26
26
|
- **Where the status file lives and what it is called.** The project rule applies **only inside a workplace**: if a folder `projects/` or `projekte/` sits directly in a folder `workbench-…` or `werkbank-…` (both spellings occur, also mixed) and the session is in or under a folder in it, that **project folder** is the place. Name there: under `projects/` it is `NEXT-STEPS.md`, under `projekte/` `WEITER-HIER.md`. **Everywhere else** (starter package, your own repos, even a folder `~/projects/my-repo`) it stays as before: the file lives in the folder you started in; if one exists, use it; if one has to be created, it is called `NEXT-STEPS.md` — or `WEITER-HIER.md` if the folder was created in German. If both exist, ask which one applies and write to neither until you have the answer. **Which one applies (in a workplace):** if the project has one, the project's applies. Otherwise an existing workbench note (`workbench-…/NEXT-STEPS.md` or `werkbank-…/WEITER-HIER.md` from the old template) applies — you keep reading and maintaining it. If neither exists, it is created **in the project**. **Never create a new status file in the workbench.**
|
|
27
|
-
- **Are you in the workbench itself (`workbench-…` or `werkbank-…`), not inside a project?** If its `CLAUDE.md` has the section “First session” (German: “Erste Sitzung”), follow **it** — it takes priority over everything below. If it isn't there (any more), show the projects in `projects/` or `projekte/` (without `example-capture-invoices` and `beispiel-rechnungen-erfassen`) with the **first status line** of each one's status file, or “no status file yet” if there is none; an existing workbench note (`NEXT-STEPS.md` or `WEITER-HIER.md`) goes above them. Recommend **one** of them and ask **one** question about it.
|
|
27
|
+
- **Are you in the workbench itself (`workbench-…` or `werkbank-…`), not inside a project?** If its `CLAUDE.md` has the section “First session” (German: “Erste Sitzung”), follow **it** — it takes priority over everything below. If it isn't there (any more), show the projects in `projects/` or `projekte/` (without `example-capture-invoices` and `beispiel-rechnungen-erfassen`) with the **first status line** of each one's status file, or “no status file yet” if there is none; an existing workbench note (`NEXT-STEPS.md` or `WEITER-HIER.md`) goes above them. Recommend **one** of them and ask **one** question about it. If the person picks a project from the list or names a new task (then you first create the folder and status file in the project, never in the workbench), bring the session into the project folder: with `change_directory` (if it only appears as a name in your tool list, load it via the tool search) — and say first: “The Claude app will ask whether I may change the folder, and then whether you trust it — both are fine, click Allow each time. If you land in an empty conversation, type `/k3start` there; you will find this conversation again in the list on the left.” Without the tool, or if the person declines: give the exact project folder (not the workbench above it) — in the app “Choose folder” and pick that folder, in the terminal `/exit`, `cd '‹path›'`, `claude` — and then `/k3start`. For a new project, read `projects/CLAUDE.md` (German: `projekte/CLAUDE.md`) first: from now on it applies.
|
|
28
28
|
- No status file? **First check whether there is a `.git` folder where the status file would live** (in a workplace the project folder, otherwise the folder you started in). If there is, this
|
|
29
29
|
project keeps a version history, and `git log --oneline -10` plus `git status` show you the
|
|
30
30
|
latest state.
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# Gemeinsame Teile von werkzeug.py und starter.py: Dateinamen, Umgebung des Aufnehmers, Musterliste der Fehlergruende,
|
|
2
|
+
# JSON lesen/schreiben, Stopp einer Sitzung. Kein eigener Einstieg; laeuft mit dem Python des Aufnehmers.
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import re
|
|
6
|
+
import time
|
|
7
|
+
|
|
8
|
+
# Der Aufnehmer bekommt einen eigenen Unterordner, in den sonst niemand schreibt: Beim Abschliessen versiegelt er JEDE
|
|
9
|
+
# Datei seines Ordners und prueft das Siegel danach nach. Lag unser Herzschlag (starter.json) mit darin, brach es
|
|
10
|
+
# (`capture_seal_failed`, Lauf 1 am 21.10.); dasselbe droht jeder Datei, die wir dort schreiben. Bericht, Protokoll,
|
|
11
|
+
# Abbruch-Datei, Zeitleiste und Bilder liegen deshalb eine Ebene hoeher.
|
|
12
|
+
ROH = "roh"
|
|
13
|
+
STARTER = "starter.json"
|
|
14
|
+
PROTOKOLL = "aufnahme.log"
|
|
15
|
+
ABBRECHEN = "abbrechen"
|
|
16
|
+
ZUSTAND_AUFNEHMER = "capture-state.json"
|
|
17
|
+
UMGEBUNG = {
|
|
18
|
+
"STOP_SEQUENCES": '[["o","a",".","s","t","o","p"]]', # die Strg-Folge des Aufnehmers beendet sonst bei Strg+C (B1)
|
|
19
|
+
"RECORD_AUDIO_RETAIN_WAVEFORM": "true",
|
|
20
|
+
"PYTHONUNBUFFERED": "1",
|
|
21
|
+
"PYTHONUTF8": "1",
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
# Zwilling von GRUENDE in cli/src/zeigen-rechnung.ts -- gleiche Reihenfolge, gleiche Schalter (der Test vergleicht beide).
|
|
25
|
+
GRUENDE = [
|
|
26
|
+
(re.compile(r"FFmpegUnavailableError|ffmpeg.*not found", re.IGNORECASE), "ffmpeg-fehlt"),
|
|
27
|
+
(re.compile(r"returned success without a non-empty output"), "pfad-zu-lang"),
|
|
28
|
+
(re.compile(r"PortAudio|No Default Input Device|sounddevice"), "kein-mikrofon"),
|
|
29
|
+
(re.compile(r"DesktopCaptureError|screen layout"), "bildschirm-gesperrt"),
|
|
30
|
+
(re.compile(r"did not become ready|did not start"), "startet-nicht"),
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def fehler_grund(protokoll: str) -> str:
|
|
35
|
+
return next((code for muster, code in GRUENDE if muster.search(protokoll)), "unbekannt")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def schreibe_json_atomar(pfad: str, daten: dict) -> None:
|
|
39
|
+
tmp = pfad + ".tmp"
|
|
40
|
+
with open(tmp, "w", encoding="utf-8") as f:
|
|
41
|
+
json.dump(daten, f)
|
|
42
|
+
for versuch in range(20):
|
|
43
|
+
try:
|
|
44
|
+
os.replace(tmp, pfad)
|
|
45
|
+
return
|
|
46
|
+
except PermissionError: # Windows: ein Leser haelt die Datei gerade offen
|
|
47
|
+
if versuch == 19:
|
|
48
|
+
raise
|
|
49
|
+
time.sleep(0.05)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def lies_json(pfad: str) -> dict | None:
|
|
53
|
+
try:
|
|
54
|
+
with open(pfad, encoding="utf-8") as f:
|
|
55
|
+
daten = json.load(f)
|
|
56
|
+
except (OSError, ValueError):
|
|
57
|
+
return None
|
|
58
|
+
return daten if isinstance(daten, dict) else None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def lies_protokoll(ordner: str) -> str:
|
|
62
|
+
try:
|
|
63
|
+
with open(os.path.join(ordner, PROTOKOLL), encoding="utf-8", errors="replace") as f:
|
|
64
|
+
return f.read()
|
|
65
|
+
except OSError:
|
|
66
|
+
return ""
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def sauber(ordner: str) -> bool:
|
|
70
|
+
zustand = lies_json(os.path.join(ordner, ROH, ZUSTAND_AUFNEHMER)) or {}
|
|
71
|
+
return zustand.get("integrity_verified") is True and zustand.get("phase") == "complete"
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _stoppe_sitzung(sitzung: str, timeout: float) -> None:
|
|
75
|
+
"""Stoppt genau diese Sitzung und kehrt erst nach dem geprueften Abschluss zurueck; sonst eine Ausnahme. Laut
|
|
76
|
+
Aufnehmer (control.stop_recording) ist ein Stopp idempotent: ein zweiter, gleichzeitiger wartet auf denselben
|
|
77
|
+
Abschluss. Nach Ablauf von `timeout` schliesst der Aufnehmer trotzdem weiter ab -- nur die Antwort fehlt."""
|
|
78
|
+
if not sitzung: # ohne Sitzung stoppt stop_recording die EINE laufende Aufnahme -- womoeglich eine fremde
|
|
79
|
+
raise ValueError("keine Sitzung")
|
|
80
|
+
from openadapt_capture.control import stop_recording # erst nach chdir: der Aufnehmer liest beim Import eine .env
|
|
81
|
+
|
|
82
|
+
stop_recording(sitzung, timeout=timeout)
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
# Starter hinter `werkzeug.py starten <konfig.json>`: haelt den Aufnehmer als eigenes Kind (versteckte Konsole), zieht die
|
|
2
|
+
# Bremse nach der Frist, beendet ihn bei `abbrechen` und schreibt laufend starter.json mit Herzschlag `zuletzt`.
|
|
3
|
+
# Von Node mit pythonw abgehaengt gestartet: keine Ausgabe. Einstieg bleibt werkzeug.py (Ruling R-12: eigene Datei).
|
|
4
|
+
import os
|
|
5
|
+
import re
|
|
6
|
+
import subprocess
|
|
7
|
+
import sys
|
|
8
|
+
import threading
|
|
9
|
+
import time
|
|
10
|
+
|
|
11
|
+
from gemeinsam import (ABBRECHEN, PROTOKOLL, ROH, STARTER, UMGEBUNG, _stoppe_sitzung, fehler_grund, lies_protokoll,
|
|
12
|
+
sauber, schreibe_json_atomar)
|
|
13
|
+
|
|
14
|
+
TAKT_SEK = 2.0 # so oft schaut der Starter nach Sitzung, Abbruch und Frist (Wanduhr, nicht ein Zeitgeber ueber 15 Minuten)
|
|
15
|
+
NACHTOETEN_SEK = 0.5 # Pause zwischen den ersten Versuchen, einen Prozessbaum zu beenden
|
|
16
|
+
NACHTOETEN_VERSUCHE = 3 # danach meldet der Starter "nicht-beendet" und versucht es jeden Takt weiter
|
|
17
|
+
# Video-Abschluss und eingebaute Spracherkennung nach 15 Minuten brauchen mehr als die Vorgabe 60 s. Der Starter
|
|
18
|
+
# laeuft ohne Claudes 10-Minuten-Grenze; `kimal3 zeigen stopp` wartet kuerzer (werkzeug.STOPP_SEK) und meldet dann
|
|
19
|
+
# "wird noch abgeschlossen".
|
|
20
|
+
BREMSE_STOPP_SEK = 600
|
|
21
|
+
AUFNEHMEN = "import sys; from openadapt_capture.cli import record; record(sys.argv[1], audio=True)"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _starte_kind(befehl: list, ordner: str, protokoll) -> subprocess.Popen:
|
|
25
|
+
# Eigene, VERSTECKTE Konsole: Sonst oeffnen python.exe, sein multiprocessing-Kind und FFmpeg je ein Fenster
|
|
26
|
+
# (gemessen 18.10.: detached + windowsHide allein reicht nicht; starter-probe.py).
|
|
27
|
+
si = subprocess.STARTUPINFO()
|
|
28
|
+
si.dwFlags |= subprocess.STARTF_USESHOWWINDOW
|
|
29
|
+
si.wShowWindow = 0 # SW_HIDE
|
|
30
|
+
return subprocess.Popen(befehl, cwd=ordner, env=dict(os.environ, **UMGEBUNG), stdout=protokoll,
|
|
31
|
+
stderr=subprocess.STDOUT, startupinfo=si, creationflags=subprocess.CREATE_NEW_CONSOLE)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _beende_baum(kind: subprocess.Popen) -> bool:
|
|
35
|
+
"""Beendet Kind samt Enkeln (multiprocessing, FFmpeg) und prueft, dass keiner mehr lebt."""
|
|
36
|
+
import psutil
|
|
37
|
+
|
|
38
|
+
try:
|
|
39
|
+
baum = [psutil.Process(kind.pid)]
|
|
40
|
+
baum += baum[0].children(recursive=True)
|
|
41
|
+
except psutil.NoSuchProcess:
|
|
42
|
+
baum = []
|
|
43
|
+
subprocess.run(["taskkill", "/PID", str(kind.pid), "/T", "/F"], capture_output=True,
|
|
44
|
+
creationflags=subprocess.CREATE_NO_WINDOW)
|
|
45
|
+
_, lebend = psutil.wait_procs(baum, timeout=10)
|
|
46
|
+
for p in lebend:
|
|
47
|
+
try:
|
|
48
|
+
p.kill()
|
|
49
|
+
except psutil.NoSuchProcess:
|
|
50
|
+
pass
|
|
51
|
+
_, lebend = psutil.wait_procs(lebend, timeout=5)
|
|
52
|
+
try:
|
|
53
|
+
kind.wait(timeout=5)
|
|
54
|
+
except subprocess.TimeoutExpired:
|
|
55
|
+
return False
|
|
56
|
+
return not lebend
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _mit_herzschlag(arbeit, melde):
|
|
60
|
+
"""Fuehrt eine blockierende Arbeit (Stopp, Prozessbaum beenden) in einem Faden aus und schreibt waehrenddessen jeden
|
|
61
|
+
Takt `zuletzt` -- sonst haelt Node einen gesunden Starter, der gerade abschliesst, nach 10 s fuer tot."""
|
|
62
|
+
ergebnis = {}
|
|
63
|
+
|
|
64
|
+
def lauf():
|
|
65
|
+
try:
|
|
66
|
+
ergebnis["wert"] = arbeit()
|
|
67
|
+
except BaseException as fehler:
|
|
68
|
+
ergebnis["fehler"] = fehler
|
|
69
|
+
|
|
70
|
+
faden = threading.Thread(target=lauf, daemon=True)
|
|
71
|
+
faden.start()
|
|
72
|
+
while True:
|
|
73
|
+
faden.join(TAKT_SEK)
|
|
74
|
+
if not faden.is_alive():
|
|
75
|
+
break
|
|
76
|
+
melde()
|
|
77
|
+
if "fehler" in ergebnis:
|
|
78
|
+
raise ergebnis["fehler"]
|
|
79
|
+
return ergebnis.get("wert")
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _warte_auf_kind(kind: subprocess.Popen, sekunden: float, melde) -> bool:
|
|
83
|
+
"""Wartet in Takt-Scheiben mit Herzschlag; True, wenn das Kind in der Zeit geendet hat."""
|
|
84
|
+
ende = time.time() + sekunden
|
|
85
|
+
while kind.poll() is None and time.time() < ende:
|
|
86
|
+
try:
|
|
87
|
+
kind.wait(timeout=TAKT_SEK)
|
|
88
|
+
except subprocess.TimeoutExpired:
|
|
89
|
+
melde()
|
|
90
|
+
return kind.poll() is not None
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _raeume_weg(kind: subprocess.Popen, melde) -> None:
|
|
94
|
+
"""Kehrt erst zurueck, wenn der Baum nachweislich weg ist. Solange er lebt, bleibt der Starter da (Bremse bleibt
|
|
95
|
+
gezogen) und meldet `grund: nicht-beendet` -- nie einen Endzustand, waehrend noch etwas aufnimmt."""
|
|
96
|
+
versuch = 0
|
|
97
|
+
while not _mit_herzschlag(lambda: _beende_baum(kind), melde):
|
|
98
|
+
versuch += 1
|
|
99
|
+
if versuch < NACHTOETEN_VERSUCHE:
|
|
100
|
+
time.sleep(NACHTOETEN_SEK)
|
|
101
|
+
continue
|
|
102
|
+
melde(grund="nicht-beendet")
|
|
103
|
+
time.sleep(TAKT_SEK)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def starte(konfig: dict, befehl: list | None = None) -> None:
|
|
107
|
+
ordner = konfig["ordner"]
|
|
108
|
+
gestartet = time.time()
|
|
109
|
+
bericht = {"zustand": "startet", "sitzung": None, "gestartet": gestartet,
|
|
110
|
+
"frist": gestartet + float(konfig["hoechstens_sek"]), "grund": None, "zuletzt": gestartet}
|
|
111
|
+
pfad = os.path.join(ordner, STARTER)
|
|
112
|
+
|
|
113
|
+
def melde(**felder):
|
|
114
|
+
# `zuletzt` ist der Herzschlag: Node haelt einen Starter, der vor <= 10 s schrieb, fuer lebendig (Ruling R-3).
|
|
115
|
+
bericht.update(felder, zuletzt=time.time())
|
|
116
|
+
schreibe_json_atomar(pfad, bericht)
|
|
117
|
+
|
|
118
|
+
melde()
|
|
119
|
+
kind = None
|
|
120
|
+
try:
|
|
121
|
+
python = os.path.join(os.path.dirname(sys.executable), "python.exe")
|
|
122
|
+
roh = os.path.join(ordner, ROH) # nur der Aufnehmer schreibt dort (gemeinsam.ROH)
|
|
123
|
+
os.makedirs(roh, exist_ok=True)
|
|
124
|
+
with open(os.path.join(ordner, PROTOKOLL), "w", encoding="utf-8") as protokoll:
|
|
125
|
+
kind = _starte_kind(befehl or [python, "-c", AUFNEHMEN, roh], roh, protokoll)
|
|
126
|
+
while True:
|
|
127
|
+
try:
|
|
128
|
+
kind.wait(timeout=TAKT_SEK)
|
|
129
|
+
except subprocess.TimeoutExpired:
|
|
130
|
+
pass
|
|
131
|
+
treffer = None if bericht["sitzung"] else re.search(r"Capture session: ([0-9a-f-]{36})", lies_protokoll(ordner))
|
|
132
|
+
melde(**({"zustand": "laeuft", "sitzung": treffer.group(1)} if treffer else {}))
|
|
133
|
+
if kind.poll() is not None:
|
|
134
|
+
return melde(**_ende(ordner, bericht["sitzung"], "gestoppt"))
|
|
135
|
+
if os.path.exists(os.path.join(ordner, ABBRECHEN)):
|
|
136
|
+
_raeume_weg(kind, melde)
|
|
137
|
+
return melde(zustand="abgebrochen", grund=None)
|
|
138
|
+
if time.time() >= bericht["frist"]:
|
|
139
|
+
if bericht["sitzung"] is None:
|
|
140
|
+
_raeume_weg(kind, melde)
|
|
141
|
+
return melde(zustand="fehlgeschlagen", grund=fehler_grund(lies_protokoll(ordner)))
|
|
142
|
+
try:
|
|
143
|
+
_mit_herzschlag(lambda: _stoppe_sitzung(bericht.get("sitzung"), BREMSE_STOPP_SEK), melde)
|
|
144
|
+
except Exception:
|
|
145
|
+
pass # ob die Aufnahme sauber zu ist, sagt danach capture-state.json (_ende)
|
|
146
|
+
if not _warte_auf_kind(kind, 120, melde):
|
|
147
|
+
_raeume_weg(kind, melde)
|
|
148
|
+
return melde(**_ende(ordner, bericht["sitzung"], "selbst-gestoppt"))
|
|
149
|
+
except Exception:
|
|
150
|
+
grund = "unbekannt"
|
|
151
|
+
if kind is not None and kind.poll() is None:
|
|
152
|
+
try:
|
|
153
|
+
_raeume_weg(kind, melde)
|
|
154
|
+
except Exception:
|
|
155
|
+
grund = "nicht-beendet" # nicht nachgewiesen weg: Node sagt "kann ich nicht feststellen", nie "beendet"
|
|
156
|
+
melde(zustand="fehlgeschlagen", grund=grund)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _ende(ordner: str, sitzung: str | None, art: str) -> dict:
|
|
160
|
+
"""Das Kind ist weg: sauber abgeschlossen -> `art`; mit Sitzung, aber nicht sauber -> abgebrochen; ohne -> fehlgeschlagen."""
|
|
161
|
+
if sitzung is not None and sauber(ordner):
|
|
162
|
+
return {"zustand": art, "grund": None}
|
|
163
|
+
return {"zustand": "abgebrochen" if sitzung else "fehlgeschlagen", "grund": fehler_grund(lies_protokoll(ordner))}
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
# Werkzeug hinter `kimal3 zeigen`: spricht mit dem Aufnehmer (openadapt-capture 1.3.0) ueber dessen Python-Funktionen,
|
|
2
|
+
# nie ueber dessen Befehlszeile (Python Fire zerlegt dort Werte, Spec US-4). Laeuft mit dem Python des Aufnehmers.
|
|
3
|
+
#
|
|
4
|
+
# werkzeug.py starten <konfig.json> Starter mit Bremse (von Node mit pythonw abgehaengt gestartet, keine Ausgabe)
|
|
5
|
+
# werkzeug.py stoppen <ordner> -> eine JSON-Zeile: Ergebnis und Zahlen
|
|
6
|
+
# werkzeug.py zustand <ordner|-> -> eine JSON-Zeile: aktive Sitzungen des Aufnehmers, Bericht des Starters
|
|
7
|
+
# werkzeug.py teile -> eine JSON-Zeile: was eingerichtet ist
|
|
8
|
+
# werkzeug.py zeitleiste <ordner> <de|en> [--ohne-getipptes] -> schreibt zeitleiste.md, eine JSON-Zeile mit Zahlen
|
|
9
|
+
# werkzeug.py bild <ordner> <sekunden> -> bilder/<m-ss>.png, eine JSON-Zeile
|
|
10
|
+
# werkzeug.py modell-laden -> eine JSON-Zeile (laedt large-v3-turbo, Netz)
|
|
11
|
+
# Die drei letzten wohnen in zeitleiste.py (Ruling R-6); hier steht nur der Aufruf.
|
|
12
|
+
# Der Starter wohnt in starter.py (Ruling R-12), was beide brauchen in gemeinsam.py; Einstieg bleibt diese Datei.
|
|
13
|
+
#
|
|
14
|
+
# 🔴 Keine Ausgabe enthaelt Inhalte aus der Aufnahme: Das Protokoll des Aufnehmers traegt Fenstertitel. Es wird nur nach
|
|
15
|
+
# festen Mustern durchsucht (fehler_grund), nie zurueckgegeben -- auch nicht auf stderr, auch nicht im Fehlerfall.
|
|
16
|
+
# Gedeckt von `npm run check:zeigen-python` (scripts/zeigen/test_werkzeug.py). Spec 2026-10-18-k3zeigen-design.md.
|
|
17
|
+
import argparse
|
|
18
|
+
import json
|
|
19
|
+
import os
|
|
20
|
+
import sqlite3
|
|
21
|
+
import sys
|
|
22
|
+
import time
|
|
23
|
+
|
|
24
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) # Nachbardateien auch unter -I/-P, die den Ordner weglassen
|
|
25
|
+
sys.dont_write_bytecode = True # kein __pycache__ neben den Dateien: der Ordner geht so, wie er ist, ins npm-Paket
|
|
26
|
+
from gemeinsam import (ABBRECHEN, ROH, STARTER, UMGEBUNG, ZUSTAND_AUFNEHMER, _stoppe_sitzung, # noqa: E402
|
|
27
|
+
fehler_grund, lies_json, lies_protokoll, sauber)
|
|
28
|
+
from starter import starte # noqa: E402
|
|
29
|
+
|
|
30
|
+
WARTEN_SEK = 15.0 # so lange wartet `stoppen` nach dem Stopp auf einen Starter ohne frischen Herzschlag
|
|
31
|
+
# Claude Code bricht einen Werkzeugaufruf nach 10 Minuten ab -- dann kaeme gar kein Satz. Deshalb endet `stoppen`
|
|
32
|
+
# spaetestens nach STOPP_SEK + WARTEN_SEK (495 s) mit einer Antwort; schliesst der Aufnehmer dann noch ab, heisst sie
|
|
33
|
+
# "wird-abgeschlossen". Node gibt dem Aufruf STOPPEN_MS (zeigen-umgebung.ts) als letztes Netz.
|
|
34
|
+
STOPP_SEK = 480.0
|
|
35
|
+
LEBENSZEICHEN_SEK = 10 # Zwilling von LEBENSZEICHEN_SEK in zeigen-rechnung.ts: so alt darf `zuletzt` hoechstens sein
|
|
36
|
+
MODELL_REPO = "mobiuslabsgmbh/faster-whisper-large-v3-turbo" # faster_whisper.utils._MODELS["large-v3-turbo"]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def aufnahme_zahlen(ordner: str, ende: float | None = None) -> dict | None:
|
|
40
|
+
"""Zahlen aus recording.db; None, wenn sie nicht lesbar ist. `ende` = Moment der Bremse (sonst letztes Ereignis)."""
|
|
41
|
+
pfad = os.path.join(ordner, ROH, "recording.db")
|
|
42
|
+
if not os.path.isfile(pfad):
|
|
43
|
+
return None
|
|
44
|
+
try:
|
|
45
|
+
db = sqlite3.connect(f"file:{pfad}?mode=ro", uri=True)
|
|
46
|
+
try:
|
|
47
|
+
start = float(db.execute("select min(timestamp) from recording").fetchone()[0])
|
|
48
|
+
klicks = db.execute("select count(*) from action_event where name = 'click'").fetchone()[0]
|
|
49
|
+
tasten = db.execute("select count(*) from action_event where name = 'press'").fetchone()[0]
|
|
50
|
+
ton = db.execute("select count(*) from audio_info where length(flac_data) > 0").fetchone()[0] > 0
|
|
51
|
+
letzte = db.execute("select max(timestamp) from action_event").fetchone()[0]
|
|
52
|
+
finally:
|
|
53
|
+
db.close()
|
|
54
|
+
except (sqlite3.Error, TypeError, ValueError):
|
|
55
|
+
return None
|
|
56
|
+
if ende is None and letzte is not None:
|
|
57
|
+
ende = float(letzte)
|
|
58
|
+
if ende is None:
|
|
59
|
+
ende = (lies_json(os.path.join(ordner, ROH, ZUSTAND_AUFNEHMER)) or {}).get("finalized_at")
|
|
60
|
+
dauer = max(0.0, float(ende) - start) if isinstance(ende, (int, float)) else 0.0
|
|
61
|
+
return {"dauer_sek": round(dauer, 2), "klicks": klicks, "tasten": tasten, "ton": ton}
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
# --- Stoppen, Zustand, Teile -----------------------------------------------------------------------------------
|
|
65
|
+
|
|
66
|
+
def _frisch(bericht: dict) -> bool:
|
|
67
|
+
zuletzt = bericht.get("zuletzt")
|
|
68
|
+
return isinstance(zuletzt, (int, float)) and time.time() - zuletzt <= LEBENSZEICHEN_SEK
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _warte_auf_starter(ordner: str, solange: str, bis: float) -> dict:
|
|
72
|
+
"""Wartet, solange der Starter `solange` meldet und lebt -- hoechstens bis `bis`. Ein Starter ohne frischen
|
|
73
|
+
Herzschlag schreibt nie mehr etwas und bekommt nur WARTEN_SEK; einer, der `nicht-beendet` meldet, gar keine."""
|
|
74
|
+
gnade = time.time() + WARTEN_SEK
|
|
75
|
+
bericht = lies_json(os.path.join(ordner, STARTER)) or {}
|
|
76
|
+
while (bericht.get("zustand") == solange and bericht.get("grund") != "nicht-beendet"
|
|
77
|
+
and time.time() < (bis if _frisch(bericht) else min(bis, gnade))):
|
|
78
|
+
time.sleep(0.25)
|
|
79
|
+
bericht = lies_json(os.path.join(ordner, STARTER)) or bericht
|
|
80
|
+
return bericht
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def stoppen(ordner: str) -> dict:
|
|
84
|
+
def antwort(ergebnis, grund=None, zahlen=None):
|
|
85
|
+
z = zahlen or {"dauer_sek": 0.0, "klicks": 0, "tasten": 0, "ton": False}
|
|
86
|
+
return {"ergebnis": ergebnis, **z, "grund": grund}
|
|
87
|
+
|
|
88
|
+
bis = time.time() + STOPP_SEK + WARTEN_SEK
|
|
89
|
+
bericht = lies_json(os.path.join(ordner, STARTER))
|
|
90
|
+
if bericht is None:
|
|
91
|
+
return antwort("keine")
|
|
92
|
+
if bericht.get("zustand") == "startet": # noch keine Sitzung, die man stoppen koennte: Bremse ziehen
|
|
93
|
+
open(os.path.join(ordner, ABBRECHEN), "w").close()
|
|
94
|
+
bericht = _warte_auf_starter(ordner, "startet", bis)
|
|
95
|
+
if bericht.get("zustand") == "laeuft":
|
|
96
|
+
# Nach der Frist stoppt ein lebender Starter schon selbst (die Bremse): dann nur auf ihn warten. Ein zweiter
|
|
97
|
+
# Stopp fuehrte zum selben Abschluss, liefe aber gegen die eigene Uhr -- und meldete "nicht sauber" (Review).
|
|
98
|
+
# Der Stopp bekommt nur, was von `bis` uebrig ist: Hat das Warten auf "startet" schon Zeit verbraucht, kaeme
|
|
99
|
+
# `stoppen` sonst erst nach bis zu 495 + 480 s zurueck -- Node braeche bei 540 s ab, und es gaebe keinen Satz.
|
|
100
|
+
# Ist nichts mehr uebrig, wird nicht gestoppt: Die Bremse (`abbrechen`) liegt dann schon.
|
|
101
|
+
rest = min(STOPP_SEK, bis - time.time())
|
|
102
|
+
if rest > 0 and not (time.time() >= (bericht.get("frist") or float("inf")) and _frisch(bericht)):
|
|
103
|
+
try:
|
|
104
|
+
_stoppe_sitzung(bericht.get("sitzung"), rest)
|
|
105
|
+
except Exception:
|
|
106
|
+
pass # ob die Aufnahme zu ist, sagen danach der Starter und capture-state.json
|
|
107
|
+
bericht = _warte_auf_starter(ordner, "laeuft", bis)
|
|
108
|
+
zustand = bericht.get("zustand")
|
|
109
|
+
if zustand in ("startet", "laeuft"):
|
|
110
|
+
# Der Starter hat kein Ende gemeldet. Lebt er und laeuft die Aufnahme, schliesst er noch ab. Steht er trotz
|
|
111
|
+
# Bremse noch in "startet" (oder meldet `nicht-beendet`), schliesst er nichts ab, sondern wird sie nicht los:
|
|
112
|
+
# dann "unklar". Ist er still, entscheidet die Liste des Aufnehmers -- im Zweifel nie ein Ergebnis, das wie ein
|
|
113
|
+
# Ende aussieht.
|
|
114
|
+
if _frisch(bericht):
|
|
115
|
+
abschluss = zustand == "laeuft" and bericht.get("grund") != "nicht-beendet"
|
|
116
|
+
return antwort("wird-abgeschlossen" if abschluss else "unklar")
|
|
117
|
+
aktive = _aktive_sitzungen()
|
|
118
|
+
if aktive is None or (bericht.get("sitzung") in aktive if zustand == "laeuft" else aktive):
|
|
119
|
+
return antwort("unklar")
|
|
120
|
+
if zustand == "startet": # ohne Sitzung gibt es keine Aufnahme, die man abschliessen koennte
|
|
121
|
+
return antwort("abgebrochen", fehler_grund(lies_protokoll(ordner)), aufnahme_zahlen(ordner))
|
|
122
|
+
if zustand in ("abgebrochen", "fehlgeschlagen"):
|
|
123
|
+
return antwort("abgebrochen", bericht.get("grund") or fehler_grund(lies_protokoll(ordner)), aufnahme_zahlen(ordner))
|
|
124
|
+
if zustand not in ("gestoppt", "selbst-gestoppt", "laeuft"): # "laeuft" = Starter still, Sitzung nicht mehr aktiv
|
|
125
|
+
return antwort("nicht-sauber", fehler_grund(lies_protokoll(ordner)), aufnahme_zahlen(ordner))
|
|
126
|
+
ergebnis = "selbst-gestoppt" if zustand == "selbst-gestoppt" else "gestoppt"
|
|
127
|
+
zahlen = aufnahme_zahlen(ordner, bericht.get("frist") if ergebnis == "selbst-gestoppt" else None)
|
|
128
|
+
if zahlen is None or not sauber(ordner):
|
|
129
|
+
return antwort("nicht-sauber", fehler_grund(lies_protokoll(ordner)), zahlen)
|
|
130
|
+
return antwort(ergebnis, None, zahlen)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _aktive_sitzungen() -> list | None:
|
|
134
|
+
try:
|
|
135
|
+
from openadapt_capture.control import discover_recorders # nur lebende Prozesse, Steuerdateien des Aufnehmers
|
|
136
|
+
|
|
137
|
+
return [str(s) for s in discover_recorders()]
|
|
138
|
+
except Exception:
|
|
139
|
+
return None
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def zustand(ordner: str | None) -> dict:
|
|
143
|
+
return {"aktive_sitzungen": _aktive_sitzungen(),
|
|
144
|
+
"starter": lies_json(os.path.join(ordner, STARTER)) if ordner else None}
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def modell_da(cache: str) -> bool:
|
|
148
|
+
"""large-v3-turbo vollstaendig im Hugging-Face-Zwischenspeicher? Ohne Netz: Groessen gegen den Dateibaum."""
|
|
149
|
+
repo = os.path.join(cache, "models--" + MODELL_REPO.replace("/", "--"))
|
|
150
|
+
try:
|
|
151
|
+
with open(os.path.join(repo, "refs", "main"), encoding="utf-8") as f:
|
|
152
|
+
stand = f.read().strip()
|
|
153
|
+
except OSError:
|
|
154
|
+
return False
|
|
155
|
+
snapshot = os.path.join(repo, "snapshots", stand)
|
|
156
|
+
baum = (lies_json(os.path.join(repo, "trees", stand + ".json")) or {}).get("files", {})
|
|
157
|
+
for name in ("config.json", "preprocessor_config.json", "model.bin", "tokenizer.json", "vocabulary.json"):
|
|
158
|
+
datei = os.path.join(snapshot, name)
|
|
159
|
+
if not os.path.isfile(datei):
|
|
160
|
+
return False
|
|
161
|
+
soll = (baum.get(name) or {}).get("size")
|
|
162
|
+
ist = os.path.getsize(datei)
|
|
163
|
+
if (isinstance(soll, int) and ist != soll) or (name == "model.bin" and ist == 0):
|
|
164
|
+
return False
|
|
165
|
+
return True
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _hf_cache() -> str:
|
|
169
|
+
try:
|
|
170
|
+
from huggingface_hub import constants
|
|
171
|
+
|
|
172
|
+
return constants.HF_HUB_CACHE
|
|
173
|
+
except Exception:
|
|
174
|
+
return os.path.join(os.path.expanduser("~"), ".cache", "huggingface", "hub")
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _ffmpeg_da() -> bool:
|
|
178
|
+
try:
|
|
179
|
+
from openadapt_capture.video import resolve_ffmpeg # genau die Suche des Aufnehmers
|
|
180
|
+
|
|
181
|
+
resolve_ffmpeg()
|
|
182
|
+
return True
|
|
183
|
+
except Exception:
|
|
184
|
+
return False
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def teile() -> dict:
|
|
188
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
189
|
+
|
|
190
|
+
try:
|
|
191
|
+
aufnehmer = version("openadapt-capture")
|
|
192
|
+
except PackageNotFoundError:
|
|
193
|
+
aufnehmer = None
|
|
194
|
+
return {"aufnehmer": aufnehmer, "ffmpeg": _ffmpeg_da(), "modell": modell_da(_hf_cache())}
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def main(argv: list) -> int:
|
|
198
|
+
parser = argparse.ArgumentParser(prog="werkzeug.py")
|
|
199
|
+
befehle = parser.add_subparsers(dest="befehl", required=True)
|
|
200
|
+
befehle.add_parser("starten").add_argument("konfig")
|
|
201
|
+
befehle.add_parser("stoppen").add_argument("ordner")
|
|
202
|
+
befehle.add_parser("zustand").add_argument("ordner")
|
|
203
|
+
befehle.add_parser("teile")
|
|
204
|
+
zl = befehle.add_parser("zeitleiste")
|
|
205
|
+
zl.add_argument("ordner")
|
|
206
|
+
zl.add_argument("sprache", choices=["de", "en"])
|
|
207
|
+
zl.add_argument("--ohne-getipptes", action="store_true")
|
|
208
|
+
bi = befehle.add_parser("bild")
|
|
209
|
+
bi.add_argument("ordner")
|
|
210
|
+
bi.add_argument("sekunden", type=float)
|
|
211
|
+
befehle.add_parser("modell-laden")
|
|
212
|
+
args = parser.parse_args(argv)
|
|
213
|
+
os.environ.update(UMGEBUNG)
|
|
214
|
+
try:
|
|
215
|
+
if args.befehl == "starten":
|
|
216
|
+
konfig = lies_json(args.konfig)
|
|
217
|
+
konfig["ordner"] = os.path.abspath(konfig["ordner"]) # vor chdir: ein relativer Pfad zeigte sonst ins Leere
|
|
218
|
+
os.chdir(konfig["ordner"]) # Arbeitsordner = Aufnahmeordner: nie eine fremde .env (Spec US-4)
|
|
219
|
+
starte(konfig)
|
|
220
|
+
return 0
|
|
221
|
+
ordner = getattr(args, "ordner", "-")
|
|
222
|
+
ordner = ordner if ordner == "-" else os.path.abspath(ordner)
|
|
223
|
+
fehlt = args.befehl in ("zeitleiste", "bild") and not os.path.isdir(ordner) # zeitleiste.py meldet ordner-fehlt
|
|
224
|
+
os.chdir(os.path.dirname(os.path.abspath(__file__)) if ordner == "-" or fehlt else ordner)
|
|
225
|
+
if args.befehl == "stoppen":
|
|
226
|
+
daten = stoppen(ordner)
|
|
227
|
+
elif args.befehl == "zustand":
|
|
228
|
+
daten = zustand(None if ordner == "-" else ordner)
|
|
229
|
+
elif args.befehl in ("zeitleiste", "bild", "modell-laden"):
|
|
230
|
+
import zeitleiste
|
|
231
|
+
|
|
232
|
+
if args.befehl == "zeitleiste":
|
|
233
|
+
daten = zeitleiste.auswerten(ordner, args.sprache, args.ohne_getipptes, lambda: modell_da(_hf_cache()))
|
|
234
|
+
elif args.befehl == "bild":
|
|
235
|
+
daten = zeitleiste.bild(ordner, args.sekunden)
|
|
236
|
+
else:
|
|
237
|
+
daten = zeitleiste.modell_laden(lambda: modell_da(_hf_cache()))
|
|
238
|
+
else:
|
|
239
|
+
daten = teile()
|
|
240
|
+
except Exception as fehler:
|
|
241
|
+
# Fester Code statt Meldung: eine Meldung koennte einen Pfad oder eine Zeile des Aufnehmers tragen.
|
|
242
|
+
if sys.stderr is not None: # unter pythonw (Starter) gibt es keinen stderr
|
|
243
|
+
sys.stderr.write(f"werkzeug-fehler:{type(fehler).__name__}\n")
|
|
244
|
+
return 1
|
|
245
|
+
sys.stdout.write(json.dumps(daten) + "\n")
|
|
246
|
+
return 0
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
if __name__ == "__main__":
|
|
250
|
+
sys.exit(main(sys.argv[1:]))
|