wirenote 0.1.0__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.
- wirenote/__init__.py +3 -0
- wirenote/calendar/__init__.py +2 -0
- wirenote/calendar/ics.py +236 -0
- wirenote/capture/__init__.py +2 -0
- wirenote/capture/recorder.py +304 -0
- wirenote/capture/wav.py +58 -0
- wirenote/cli.py +262 -0
- wirenote/config/__init__.py +2 -0
- wirenote/config/apps.py +158 -0
- wirenote/config/migrate.py +130 -0
- wirenote/config/perms.py +61 -0
- wirenote/config/secrets.py +98 -0
- wirenote/config/settings.py +176 -0
- wirenote/config/vocabulary.py +32 -0
- wirenote/data/wirenote-ui.service +15 -0
- wirenote/data/wirenote.desktop +10 -0
- wirenote/data/wirenote.service +15 -0
- wirenote/detector/__init__.py +2 -0
- wirenote/detector/core.py +115 -0
- wirenote/detector/daemon.py +743 -0
- wirenote/detector/graph.py +102 -0
- wirenote/detector/identity.py +205 -0
- wirenote/detector/streams.py +69 -0
- wirenote/detector/tracker.py +304 -0
- wirenote/net/__init__.py +2 -0
- wirenote/net/peer.py +101 -0
- wirenote/notify/__init__.py +2 -0
- wirenote/notify/clipboard.py +43 -0
- wirenote/notify/dbus.py +89 -0
- wirenote/process/__init__.py +2 -0
- wirenote/process/jobs.py +122 -0
- wirenote/process/merge.py +87 -0
- wirenote/process/models.py +57 -0
- wirenote/process/note.py +124 -0
- wirenote/process/speakers.py +295 -0
- wirenote/process/summarize.py +287 -0
- wirenote/process/transcribe.py +97 -0
- wirenote/process/worker.py +229 -0
- wirenote/tools/__init__.py +2 -0
- wirenote/tools/dump_streams.py +595 -0
- wirenote/tools/install.py +140 -0
- wirenote/tools/setup.py +338 -0
- wirenote/ui/__init__.py +2 -0
- wirenote/ui/data.py +783 -0
- wirenote/ui/launch.py +88 -0
- wirenote/ui/server.py +423 -0
- wirenote/ui/static/app.css +907 -0
- wirenote/ui/static/app.js +1267 -0
- wirenote/ui/static/fonts/OFL.txt +93 -0
- wirenote/ui/static/fonts/inter-latin-ext.woff2 +0 -0
- wirenote/ui/static/fonts/inter-latin.woff2 +0 -0
- wirenote/ui/static/icon.svg +1 -0
- wirenote/ui/static/icons.svg +31 -0
- wirenote/ui/static/index.html +57 -0
- wirenote/ui/static/login.html +19 -0
- wirenote-0.1.0.dist-info/METADATA +106 -0
- wirenote-0.1.0.dist-info/RECORD +60 -0
- wirenote-0.1.0.dist-info/WHEEL +4 -0
- wirenote-0.1.0.dist-info/entry_points.txt +2 -0
- wirenote-0.1.0.dist-info/licenses/LICENSE +661 -0
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
# Copyright (c) 2026 Hrishikesh Jadhav
|
|
3
|
+
"""secrets.toml (mode 600): the Claude API key and calendar links.
|
|
4
|
+
|
|
5
|
+
Calendar links are secret too: anyone with an ICS link can read the calendar.
|
|
6
|
+
Kept out of config.toml so that file can be shared or committed.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import re
|
|
14
|
+
import tomllib
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
from .settings import config_dir
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def secrets_path() -> Path:
|
|
21
|
+
return config_dir() / "secrets.toml"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def load_secrets() -> dict:
|
|
25
|
+
try:
|
|
26
|
+
return tomllib.loads(secrets_path().read_text())
|
|
27
|
+
except FileNotFoundError:
|
|
28
|
+
return {}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def save_secrets(data: dict) -> None:
|
|
32
|
+
"""Write strings and string lists; JSON strings are valid TOML basic strings."""
|
|
33
|
+
lines = ["# wirenote secrets: keep private (mode 600)."]
|
|
34
|
+
for key, value in data.items():
|
|
35
|
+
if isinstance(value, str):
|
|
36
|
+
lines.append(f"{key} = {json.dumps(value, ensure_ascii=False)}")
|
|
37
|
+
elif isinstance(value, list):
|
|
38
|
+
lines.append(f"{key} = [")
|
|
39
|
+
lines += [f" {json.dumps(str(v), ensure_ascii=False)}," for v in value]
|
|
40
|
+
lines.append("]")
|
|
41
|
+
path = secrets_path()
|
|
42
|
+
path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
43
|
+
tmp = path.with_suffix(".tmp")
|
|
44
|
+
fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
45
|
+
with os.fdopen(fd, "w") as f:
|
|
46
|
+
f.write("\n".join(lines) + "\n")
|
|
47
|
+
tmp.replace(path)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def calendar_sources(configured: tuple[str, ...]) -> tuple[str, ...]:
|
|
51
|
+
"""Calendar links from config.toml plus those added with `wirenote calendar add`."""
|
|
52
|
+
extra = tuple(load_secrets().get("calendar_urls", []))
|
|
53
|
+
return tuple(dict.fromkeys(configured + extra))
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def with_config_value(text: str, key: str, toml_value: str) -> str:
|
|
57
|
+
"""config.toml text with one top-level key set. Replaces its live line, with
|
|
58
|
+
every line of a value that spans several (a list, a multi-line string), else
|
|
59
|
+
its commented-out line, else appends it."""
|
|
60
|
+
line = f"{key} = {toml_value}\n"
|
|
61
|
+
lines = text.splitlines(keepends=True)
|
|
62
|
+
live = re.compile(rf"^[ \t]*{re.escape(key)}[ \t]*=")
|
|
63
|
+
commented = re.compile(rf"^[ \t]*#[ \t]*{re.escape(key)}[ \t]*=")
|
|
64
|
+
for i, current in enumerate(lines):
|
|
65
|
+
if live.match(current):
|
|
66
|
+
return "".join(lines[:i]) + line + "".join(lines[_value_end(lines, i):])
|
|
67
|
+
for i, current in enumerate(lines):
|
|
68
|
+
if commented.match(current):
|
|
69
|
+
return "".join(lines[:i]) + line + "".join(lines[i + 1:])
|
|
70
|
+
return text + f"\n{line}"
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _value_end(lines: list[str], i: int) -> int:
|
|
74
|
+
"""Index after the last line of the key = value that starts on line i."""
|
|
75
|
+
for k in range(i + 1, len(lines) + 1):
|
|
76
|
+
try:
|
|
77
|
+
tomllib.loads("".join(lines[i:k]))
|
|
78
|
+
return k
|
|
79
|
+
except tomllib.TOMLDecodeError:
|
|
80
|
+
continue
|
|
81
|
+
return i + 1
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def write_config_atomic(path: Path, text: str) -> None:
|
|
85
|
+
"""Replace path with text in one step, private to this user (mode 600)."""
|
|
86
|
+
path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
87
|
+
tmp = path.with_suffix(".tmp")
|
|
88
|
+
fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
89
|
+
with os.fdopen(fd, "w") as f:
|
|
90
|
+
f.write(text)
|
|
91
|
+
os.chmod(tmp, 0o600)
|
|
92
|
+
tmp.replace(path)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def set_config_value(path: Path, key: str, toml_value: str) -> None:
|
|
96
|
+
"""Set one top-level key in config.toml, replacing its (commented) line if present."""
|
|
97
|
+
text = path.read_text() if path.exists() else ""
|
|
98
|
+
write_config_atomic(path, with_config_value(text, key, toml_value))
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
# Copyright (c) 2026 Hrishikesh Jadhav
|
|
3
|
+
"""config.toml: every default comes from docs/dev/PLAN.md › Defaults."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import os
|
|
8
|
+
import tomllib
|
|
9
|
+
from dataclasses import dataclass, fields, replace
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
APP = "wirenote"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def config_dir() -> Path:
|
|
16
|
+
return Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) / APP
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def state_dir() -> Path:
|
|
20
|
+
return Path(os.environ.get("XDG_STATE_HOME", Path.home() / ".local/state")) / APP
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def data_dir() -> Path:
|
|
24
|
+
return Path(os.environ.get("XDG_DATA_HOME", Path.home() / ".local/share")) / APP
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True)
|
|
28
|
+
class Settings:
|
|
29
|
+
# docs/dev/PLAN.md › Defaults › Change detection: PipeWire events + 1 s reconcile
|
|
30
|
+
reconcile_s: float = 1.0
|
|
31
|
+
# › Disappearance grace
|
|
32
|
+
grace_s: float = 4.0
|
|
33
|
+
# › Dwell before prompt, meeting app
|
|
34
|
+
dwell_meeting_s: float = 2.0
|
|
35
|
+
# › Dwell before prompt, browser
|
|
36
|
+
dwell_browser_s: float = 5.0
|
|
37
|
+
# › After dismiss: silent until the app's stream ends, then 60 s
|
|
38
|
+
cooldown_s: float = 60.0
|
|
39
|
+
# › Min app runtime before auto-stop (a guess, tune from M1 logs)
|
|
40
|
+
min_app_runtime_s: float = 20.0
|
|
41
|
+
# › Min recording age before auto-stop
|
|
42
|
+
min_recording_age_s: float = 30.0
|
|
43
|
+
# › Max recording length
|
|
44
|
+
max_recording_s: float = 4 * 3600.0
|
|
45
|
+
# › Opt-out nudge: dismisses of one app before offering "ignore"
|
|
46
|
+
nudge_after_dismisses: int = 5
|
|
47
|
+
# docs/dev/PLAN.md › Capture: our own streams carry this node.name prefix
|
|
48
|
+
own_node_prefix: str = "wirenote-"
|
|
49
|
+
|
|
50
|
+
# Capture (M2) -----------------------------------------------------------
|
|
51
|
+
# docs/dev/PLAN.md › Defaults › Capture heartbeat: 5 s per track
|
|
52
|
+
heartbeat_s: float = 5.0
|
|
53
|
+
# "" means ~/.local/share/wirenote/recordings
|
|
54
|
+
recordings_dir: str = ""
|
|
55
|
+
# Record this output's monitor instead of following the default sink ("" = default)
|
|
56
|
+
system_target: str = ""
|
|
57
|
+
# Copied to the clipboard on Record when set (docs/dev/PLAN.md › Prompt: consent line)
|
|
58
|
+
consent_line: str = ""
|
|
59
|
+
|
|
60
|
+
# Transcript (M3) --------------------------------------------------------
|
|
61
|
+
# docs/dev/PLAN.md › Processing: faster-whisper; model chosen in docs/dev/m2-m4-design.md
|
|
62
|
+
whisper_model: str = "large-v3-turbo"
|
|
63
|
+
whisper_language: str = "auto" # or "de", "en", ...
|
|
64
|
+
# Consecutive lines of one speaker closer than this join into a paragraph
|
|
65
|
+
merge_gap_s: float = 2.0
|
|
66
|
+
# Echo dedupe (docs/dev/m2-m4-design.md › M3): overlap share and text similarity
|
|
67
|
+
echo_overlap: float = 0.5
|
|
68
|
+
echo_similarity: float = 0.8
|
|
69
|
+
# Who spoke when on the Others track (docs/dev/features-2-design.md › Speakers).
|
|
70
|
+
# Model and threshold picked on a 3-speaker test: 3 found, 87% of speech right.
|
|
71
|
+
diarize: bool = True
|
|
72
|
+
diarize_model: str = "nemo_en_titanet_small.onnx"
|
|
73
|
+
diarize_threshold: float = 0.9
|
|
74
|
+
# Cosine similarity at which a speaker counts as a remembered voice
|
|
75
|
+
# (same voice measured 0.66-0.88, different voices <= 0.32).
|
|
76
|
+
voice_match: float = 0.55
|
|
77
|
+
notes_dir: str = "~/Meetings"
|
|
78
|
+
# docs/dev/PLAN.md › Processing: delete audio after 30 days
|
|
79
|
+
audio_retention_days: int = 30
|
|
80
|
+
|
|
81
|
+
# Summary (M4) -----------------------------------------------------------
|
|
82
|
+
summary_backend: str = "ollama" # ollama | anthropic | none
|
|
83
|
+
ollama_url: str = "http://127.0.0.1:11434"
|
|
84
|
+
ollama_model: str = "qwen3:4b"
|
|
85
|
+
anthropic_model: str = "claude-opus-5-5"
|
|
86
|
+
# docs/dev/PLAN.md › Processing: long calls are summarized in chunks of about 15 min
|
|
87
|
+
summary_chunk_s: float = 900.0
|
|
88
|
+
summary_language: str = "auto" # "auto": the transcript's language
|
|
89
|
+
|
|
90
|
+
# Calendar (M4) ----------------------------------------------------------
|
|
91
|
+
calendar_urls: tuple = () # ICS URLs or file paths
|
|
92
|
+
# docs/dev/PLAN.md › Defaults › Calendar refresh: every 5 min
|
|
93
|
+
calendar_refresh_s: float = 300.0
|
|
94
|
+
# › Calendar window: starts in <=10 min, started <=5 min ago, or in progress
|
|
95
|
+
calendar_before_s: float = 600.0
|
|
96
|
+
calendar_after_start_s: float = 300.0
|
|
97
|
+
|
|
98
|
+
# UI (docs/dev/ui-design.md): local web app on 127.0.0.1
|
|
99
|
+
ui_port: int = 8765
|
|
100
|
+
|
|
101
|
+
def recordings_path(self) -> Path:
|
|
102
|
+
return Path(self.recordings_dir).expanduser() if self.recordings_dir \
|
|
103
|
+
else data_dir() / "recordings"
|
|
104
|
+
|
|
105
|
+
def notes_path(self) -> Path:
|
|
106
|
+
return Path(self.notes_dir).expanduser()
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
DEFAULT_CONFIG_TOML = """# wirenote settings. Defaults come from docs/dev/PLAN.md › Defaults; uncomment to change.
|
|
110
|
+
# reconcile_s = 1.0 # 1 s reconcile next to PipeWire events
|
|
111
|
+
# grace_s = 4.0 # app may drop the mic this long without counting as gone
|
|
112
|
+
# dwell_meeting_s = 2.0 # mic use before prompting, meeting apps
|
|
113
|
+
# dwell_browser_s = 5.0 # mic use before prompting, browsers
|
|
114
|
+
# cooldown_s = 60.0 # after Ignore: silent until the stream ends, then this long
|
|
115
|
+
# min_app_runtime_s = 20.0 # no auto-stop before the app has used the mic this long
|
|
116
|
+
# min_recording_age_s = 30.0 # no auto-stop for younger recordings
|
|
117
|
+
# max_recording_s = 14400.0 # hard stop after 4 h
|
|
118
|
+
# nudge_after_dismisses = 5 # offer "always ignore" after this many dismisses
|
|
119
|
+
|
|
120
|
+
# Capture
|
|
121
|
+
# heartbeat_s = 5.0 # a track whose file stops growing this long is reported
|
|
122
|
+
# recordings_dir = "" # default ~/.local/share/wirenote/recordings
|
|
123
|
+
# system_target = "" # record this output instead of the default one
|
|
124
|
+
# consent_line = "" # copied to the clipboard on Record, e.g.
|
|
125
|
+
# # "Heads-up: I'm recording this call for my notes. OK for everyone?"
|
|
126
|
+
|
|
127
|
+
# Transcript
|
|
128
|
+
# whisper_model = "large-v3-turbo" # or "small", "medium", ...
|
|
129
|
+
# whisper_language = "auto" # or "de", "en"
|
|
130
|
+
# diarize = true # tell the other speakers apart
|
|
131
|
+
# diarize_threshold = 0.9 # higher: fewer speakers; lower: more
|
|
132
|
+
# voice_match = 0.55 # how close a voice must be to a remembered one
|
|
133
|
+
# merge_gap_s = 2.0
|
|
134
|
+
# echo_overlap = 0.5
|
|
135
|
+
# echo_similarity = 0.8
|
|
136
|
+
# notes_dir = "~/Meetings"
|
|
137
|
+
# audio_retention_days = 30 # audio is deleted this long after the note
|
|
138
|
+
|
|
139
|
+
# Summary
|
|
140
|
+
# summary_backend = "ollama" # ollama | anthropic | none
|
|
141
|
+
# ollama_url = "http://127.0.0.1:11434"
|
|
142
|
+
# ollama_model = "qwen3:4b"
|
|
143
|
+
# anthropic_model = "claude-opus-5-5" # API key: ~/.config/wirenote/secrets.toml
|
|
144
|
+
# summary_chunk_s = 900.0
|
|
145
|
+
# summary_language = "auto" # or "German", "English"
|
|
146
|
+
|
|
147
|
+
# Calendar
|
|
148
|
+
# calendar_urls = [] # ICS URLs or files; none means no calendar matching
|
|
149
|
+
# calendar_refresh_s = 300.0
|
|
150
|
+
# calendar_before_s = 600.0
|
|
151
|
+
# calendar_after_start_s = 300.0
|
|
152
|
+
|
|
153
|
+
# UI
|
|
154
|
+
# ui_port = 8765 # wirenote ui serves http://127.0.0.1:<port>/
|
|
155
|
+
"""
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def parse_settings(text: str) -> Settings:
|
|
159
|
+
data = tomllib.loads(text)
|
|
160
|
+
known = {f.name: f.type for f in fields(Settings)}
|
|
161
|
+
unknown = set(data) - set(known)
|
|
162
|
+
if unknown:
|
|
163
|
+
raise ValueError(f"config.toml: unknown setting(s) {sorted(unknown)}")
|
|
164
|
+
base = Settings()
|
|
165
|
+
coerced = {}
|
|
166
|
+
for k, v in data.items():
|
|
167
|
+
default = getattr(base, k)
|
|
168
|
+
coerced[k] = v if isinstance(default, bool) else type(default)(v)
|
|
169
|
+
return replace(base, **coerced)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def load_settings(path: Path) -> Settings:
|
|
173
|
+
if not path.exists():
|
|
174
|
+
path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
175
|
+
path.write_text(DEFAULT_CONFIG_TOML)
|
|
176
|
+
return parse_settings(path.read_text())
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
# Copyright (c) 2026 Hrishikesh Jadhav
|
|
3
|
+
"""vocabulary.txt: names and terms Whisper should spell right (one per line, # comments)."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from .settings import config_dir
|
|
10
|
+
|
|
11
|
+
MAX_TERMS = 200
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def vocabulary_path() -> Path:
|
|
15
|
+
return config_dir() / "vocabulary.txt"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def load_vocabulary() -> list[str]:
|
|
19
|
+
try:
|
|
20
|
+
lines = vocabulary_path().read_text().splitlines()
|
|
21
|
+
except FileNotFoundError:
|
|
22
|
+
return []
|
|
23
|
+
terms = [l.strip() for l in lines if l.strip() and not l.lstrip().startswith("#")]
|
|
24
|
+
return list(dict.fromkeys(terms))[:MAX_TERMS]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def save_vocabulary(terms: list[str]) -> list[str]:
|
|
28
|
+
clean = list(dict.fromkeys(t.strip() for t in terms if t.strip()))[:MAX_TERMS]
|
|
29
|
+
path = vocabulary_path()
|
|
30
|
+
path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
31
|
+
path.write_text("# Names and terms for the transcript, one per line.\n" + "\n".join(clean) + "\n")
|
|
32
|
+
return clean
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# Written by `wirenote setup install`; remove with `wirenote setup uninstall`.
|
|
2
|
+
# The meetings web app on 127.0.0.1 (this user only); open it with `wirenote open`.
|
|
3
|
+
[Unit]
|
|
4
|
+
Description=Wirenote web app (past calls, transcripts, summaries)
|
|
5
|
+
After=graphical-session.target
|
|
6
|
+
PartOf=graphical-session.target
|
|
7
|
+
|
|
8
|
+
[Service]
|
|
9
|
+
ExecStart=@EXEC@ ui
|
|
10
|
+
UMask=0077
|
|
11
|
+
Restart=on-failure
|
|
12
|
+
RestartSec=5
|
|
13
|
+
|
|
14
|
+
[Install]
|
|
15
|
+
WantedBy=graphical-session.target
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
[Desktop Entry]
|
|
2
|
+
Type=Application
|
|
3
|
+
Name=Wirenote
|
|
4
|
+
GenericName=Meeting notes
|
|
5
|
+
Comment=Past calls: transcripts, summaries and action items
|
|
6
|
+
Exec=@EXEC@ open
|
|
7
|
+
Icon=wirenote
|
|
8
|
+
Terminal=false
|
|
9
|
+
Categories=Office;AudioVideo;
|
|
10
|
+
Keywords=meeting;notes;transcript;call;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# Written by `wirenote setup install`; remove with `wirenote setup uninstall`.
|
|
2
|
+
# Logs: journalctl --user -u wirenote -f (events: ~/.local/state/wirenote/detector.jsonl)
|
|
3
|
+
[Unit]
|
|
4
|
+
Description=Wirenote meeting detector
|
|
5
|
+
After=pipewire.service pipewire-pulse.service graphical-session.target
|
|
6
|
+
PartOf=graphical-session.target
|
|
7
|
+
|
|
8
|
+
[Service]
|
|
9
|
+
ExecStart=@EXEC@ run
|
|
10
|
+
UMask=0077
|
|
11
|
+
Restart=on-failure
|
|
12
|
+
RestartSec=5
|
|
13
|
+
|
|
14
|
+
[Install]
|
|
15
|
+
WantedBy=graphical-session.target
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
# Copyright (c) 2026 Hrishikesh Jadhav
|
|
3
|
+
"""Synchronous detector: graph mirror -> mic streams -> app identity -> tracker.
|
|
4
|
+
|
|
5
|
+
No I/O besides the injected ProcReader, so tests replay recorded batches
|
|
6
|
+
against a fake clock. The asyncio daemon wraps this.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from collections.abc import Callable
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
|
|
14
|
+
from ..config.apps import AppRules
|
|
15
|
+
from ..config.settings import Settings
|
|
16
|
+
from .graph import Graph
|
|
17
|
+
from .identity import Identity, ProcReader, gather_evidence, resolve
|
|
18
|
+
from .streams import MicStream, mic_streams
|
|
19
|
+
from .tracker import Action, Observation, Tracker
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True)
|
|
23
|
+
class Identified:
|
|
24
|
+
"""First sight of a client on the mic, with the evidence behind its identity."""
|
|
25
|
+
client_serial: int | None
|
|
26
|
+
key: str | None # None: no rule matched, so it never prompts
|
|
27
|
+
kind: str | None
|
|
28
|
+
matched_by: str | None
|
|
29
|
+
evidence: dict
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
DetectorEvent = Action | Identified
|
|
33
|
+
|
|
34
|
+
IDENTITY_CACHE_MAX = 256
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class Detector:
|
|
38
|
+
def __init__(self, settings: Settings, rules: AppRules, proc: ProcReader,
|
|
39
|
+
own_pids: set[int], ignored: set[str] | None = None,
|
|
40
|
+
dismisses: dict[str, int] | None = None) -> None:
|
|
41
|
+
self.settings = settings
|
|
42
|
+
self.rules = rules
|
|
43
|
+
self.proc = proc
|
|
44
|
+
self.own_pids = own_pids
|
|
45
|
+
self.ignored: set[str] = set(ignored or ())
|
|
46
|
+
self.graph = Graph()
|
|
47
|
+
self.tracker = Tracker(settings, dismisses)
|
|
48
|
+
self._identities: dict[int | None, Identity] = {}
|
|
49
|
+
# Capture sources each app on the mic is linked to (node.name), for the recorder.
|
|
50
|
+
self.app_sources: dict[str, tuple[str, ...]] = {}
|
|
51
|
+
# True while a calendar event with a call link is in its window: browsers
|
|
52
|
+
# then get the meeting dwell (docs/dev/PLAN.md › Browsers).
|
|
53
|
+
self.calendar_hint: Callable[[], bool] = lambda: False
|
|
54
|
+
|
|
55
|
+
def apply_batch(self, batch: list[dict], now: float) -> list[DetectorEvent]:
|
|
56
|
+
self.graph.apply(batch)
|
|
57
|
+
return self.tick(now)
|
|
58
|
+
|
|
59
|
+
def reset_graph(self) -> None:
|
|
60
|
+
"""pw-dump restarted; its first batch will be the full graph again."""
|
|
61
|
+
self.graph.clear()
|
|
62
|
+
|
|
63
|
+
def tick(self, now: float) -> list[DetectorEvent]:
|
|
64
|
+
events: list[DetectorEvent] = []
|
|
65
|
+
obs = self.observations(events)
|
|
66
|
+
events += self.tracker.update(now, obs)
|
|
67
|
+
if len(self._identities) > IDENTITY_CACHE_MAX:
|
|
68
|
+
self._prune_identities()
|
|
69
|
+
return events
|
|
70
|
+
|
|
71
|
+
def _prune_identities(self) -> None:
|
|
72
|
+
"""Forget clients that left the graph; the daemon runs for weeks."""
|
|
73
|
+
live = {self.graph.props(c["id"]).get("object.serial")
|
|
74
|
+
for c in self.graph.of_type(":Client")}
|
|
75
|
+
self._identities = {k: v for k, v in self._identities.items() if k in live}
|
|
76
|
+
|
|
77
|
+
def observations(self, events: list[DetectorEvent] | None = None) -> dict[str, Observation]:
|
|
78
|
+
by_app: dict[str, list[MicStream]] = {}
|
|
79
|
+
for stream in mic_streams(self.graph, self.own_pids, self.settings.own_node_prefix):
|
|
80
|
+
ident = self._identify(stream, events)
|
|
81
|
+
if ident.rule is None or ident.rule.kind == "ignore" or ident.key in self.ignored:
|
|
82
|
+
continue
|
|
83
|
+
by_app.setdefault(ident.key, []).append(stream)
|
|
84
|
+
|
|
85
|
+
out = {}
|
|
86
|
+
hint = None
|
|
87
|
+
for key, streams in by_app.items():
|
|
88
|
+
rule = self.rules.get(key)
|
|
89
|
+
kind, service, room = rule.kind, None, None
|
|
90
|
+
for s in streams:
|
|
91
|
+
hit = rule.title_match(s.media_name)
|
|
92
|
+
if hit:
|
|
93
|
+
kind, (service, room) = "meeting", hit
|
|
94
|
+
break
|
|
95
|
+
if kind == "browser":
|
|
96
|
+
hint = self.calendar_hint() if hint is None else hint
|
|
97
|
+
if hint:
|
|
98
|
+
kind = "meeting"
|
|
99
|
+
out[key] = Observation(key, rule.name, kind,
|
|
100
|
+
frozenset(s.serial for s in streams), service, room)
|
|
101
|
+
self.app_sources = {key: tuple(sorted({n for s in streams for n in s.source_names}))
|
|
102
|
+
for key, streams in by_app.items()}
|
|
103
|
+
return out
|
|
104
|
+
|
|
105
|
+
def _identify(self, stream: MicStream, events: list[DetectorEvent] | None) -> Identity:
|
|
106
|
+
cache_key = stream.client_serial if stream.client_serial is not None else -stream.serial
|
|
107
|
+
ident = self._identities.get(cache_key)
|
|
108
|
+
if ident is None:
|
|
109
|
+
ev = gather_evidence(stream.client_props, stream.node_props, self.proc)
|
|
110
|
+
ident = self._identities[cache_key] = resolve(ev, self.rules)
|
|
111
|
+
if events is not None:
|
|
112
|
+
events.append(Identified(stream.client_serial, ident.key,
|
|
113
|
+
ident.rule.kind if ident.rule else None,
|
|
114
|
+
ident.matched_by, ev.as_dict()))
|
|
115
|
+
return ident
|