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.
Files changed (60) hide show
  1. wirenote/__init__.py +3 -0
  2. wirenote/calendar/__init__.py +2 -0
  3. wirenote/calendar/ics.py +236 -0
  4. wirenote/capture/__init__.py +2 -0
  5. wirenote/capture/recorder.py +304 -0
  6. wirenote/capture/wav.py +58 -0
  7. wirenote/cli.py +262 -0
  8. wirenote/config/__init__.py +2 -0
  9. wirenote/config/apps.py +158 -0
  10. wirenote/config/migrate.py +130 -0
  11. wirenote/config/perms.py +61 -0
  12. wirenote/config/secrets.py +98 -0
  13. wirenote/config/settings.py +176 -0
  14. wirenote/config/vocabulary.py +32 -0
  15. wirenote/data/wirenote-ui.service +15 -0
  16. wirenote/data/wirenote.desktop +10 -0
  17. wirenote/data/wirenote.service +15 -0
  18. wirenote/detector/__init__.py +2 -0
  19. wirenote/detector/core.py +115 -0
  20. wirenote/detector/daemon.py +743 -0
  21. wirenote/detector/graph.py +102 -0
  22. wirenote/detector/identity.py +205 -0
  23. wirenote/detector/streams.py +69 -0
  24. wirenote/detector/tracker.py +304 -0
  25. wirenote/net/__init__.py +2 -0
  26. wirenote/net/peer.py +101 -0
  27. wirenote/notify/__init__.py +2 -0
  28. wirenote/notify/clipboard.py +43 -0
  29. wirenote/notify/dbus.py +89 -0
  30. wirenote/process/__init__.py +2 -0
  31. wirenote/process/jobs.py +122 -0
  32. wirenote/process/merge.py +87 -0
  33. wirenote/process/models.py +57 -0
  34. wirenote/process/note.py +124 -0
  35. wirenote/process/speakers.py +295 -0
  36. wirenote/process/summarize.py +287 -0
  37. wirenote/process/transcribe.py +97 -0
  38. wirenote/process/worker.py +229 -0
  39. wirenote/tools/__init__.py +2 -0
  40. wirenote/tools/dump_streams.py +595 -0
  41. wirenote/tools/install.py +140 -0
  42. wirenote/tools/setup.py +338 -0
  43. wirenote/ui/__init__.py +2 -0
  44. wirenote/ui/data.py +783 -0
  45. wirenote/ui/launch.py +88 -0
  46. wirenote/ui/server.py +423 -0
  47. wirenote/ui/static/app.css +907 -0
  48. wirenote/ui/static/app.js +1267 -0
  49. wirenote/ui/static/fonts/OFL.txt +93 -0
  50. wirenote/ui/static/fonts/inter-latin-ext.woff2 +0 -0
  51. wirenote/ui/static/fonts/inter-latin.woff2 +0 -0
  52. wirenote/ui/static/icon.svg +1 -0
  53. wirenote/ui/static/icons.svg +31 -0
  54. wirenote/ui/static/index.html +57 -0
  55. wirenote/ui/static/login.html +19 -0
  56. wirenote-0.1.0.dist-info/METADATA +106 -0
  57. wirenote-0.1.0.dist-info/RECORD +60 -0
  58. wirenote-0.1.0.dist-info/WHEEL +4 -0
  59. wirenote-0.1.0.dist-info/entry_points.txt +2 -0
  60. 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,2 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later
2
+ # Copyright (c) 2026 Hrishikesh Jadhav
@@ -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