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,102 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later
2
+ # Copyright (c) 2026 Hrishikesh Jadhav
3
+ """In-memory mirror of the PipeWire object graph, fed by `pw-dump --monitor`.
4
+
5
+ Output format (docs/dev/m0-findings.md §2): pretty-printed JSON arrays written
6
+ back to back, each ending with "]" alone at column 0. The first array is the
7
+ full graph; later arrays are update batches. Updates carry the whole object,
8
+ so they replace; a removal is {"id": N, "info": null} with no "type".
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ from collections.abc import Iterable, Iterator
15
+
16
+
17
+ class BatchFramer:
18
+ """Collects pw-dump output lines; feed() returns a batch once one is complete."""
19
+
20
+ def __init__(self) -> None:
21
+ self._buf: list[str] = []
22
+
23
+ def feed(self, line: str) -> list[dict] | None:
24
+ self._buf.append(line)
25
+ if line.rstrip("\n") != "]":
26
+ return None
27
+ try:
28
+ batch = json.loads("".join(self._buf))
29
+ except json.JSONDecodeError:
30
+ return None # a nested "]" at column 0; keep reading
31
+ self._buf.clear()
32
+ return batch
33
+
34
+
35
+ def iter_batches(lines: Iterable[str]) -> Iterator[list[dict]]:
36
+ """Yield each top-level JSON array from pw-dump output, line by line."""
37
+ framer = BatchFramer()
38
+ for line in lines:
39
+ batch = framer.feed(line)
40
+ if batch is not None:
41
+ yield batch
42
+
43
+
44
+ def is_removal(obj: dict) -> bool:
45
+ return obj.get("info") is None and "type" not in obj
46
+
47
+
48
+ class Graph:
49
+ """Objects keyed by id. Ids are reused by PipeWire; object.serial is not."""
50
+
51
+ def __init__(self) -> None:
52
+ self.objects: dict[int, dict] = {}
53
+
54
+ def apply(self, batch: list[dict]) -> None:
55
+ for obj in batch:
56
+ if is_removal(obj):
57
+ self.objects.pop(obj["id"], None)
58
+ else:
59
+ self.objects[obj["id"]] = obj
60
+
61
+ def clear(self) -> None:
62
+ self.objects.clear()
63
+
64
+ def props(self, oid: int | None) -> dict:
65
+ obj = self.objects.get(oid) if oid is not None else None
66
+ return ((obj or {}).get("info") or {}).get("props") or {}
67
+
68
+ def of_type(self, suffix: str) -> Iterator[dict]:
69
+ for obj in self.objects.values():
70
+ if obj.get("type", "").endswith(suffix):
71
+ yield obj
72
+
73
+ def nodes(self) -> Iterator[dict]:
74
+ return self.of_type(":Node")
75
+
76
+ def links(self) -> Iterator[dict]:
77
+ return self.of_type(":Link")
78
+
79
+ def default_node(self, key: str = "default.audio.source") -> str | None:
80
+ """node.name from the "default" Metadata object. Metadata keeps its props
81
+ at the top level, not under info."""
82
+ for obj in self.of_type(":Metadata"):
83
+ if (obj.get("props") or {}).get("metadata.name") != "default":
84
+ continue
85
+ for entry in obj.get("metadata") or []:
86
+ if entry.get("key") == key:
87
+ value = entry.get("value")
88
+ if isinstance(value, str):
89
+ try:
90
+ value = json.loads(value)
91
+ except json.JSONDecodeError:
92
+ return value or None
93
+ return (value or {}).get("name") if isinstance(value, dict) else None
94
+ return None
95
+
96
+
97
+ def info(obj: dict | None) -> dict:
98
+ return (obj or {}).get("info") or {}
99
+
100
+
101
+ def props(obj: dict | None) -> dict:
102
+ return info(obj).get("props") or {}
@@ -0,0 +1,205 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later
2
+ # Copyright (c) 2026 Hrishikesh Jadhav
3
+ """Which app owns a mic stream (docs/dev/PLAN.md › Identifying the app).
4
+
5
+ PID rule (docs/dev/m0-findings.md §3): for pulse-protocol clients
6
+ (client.api == "pipewire-pulse") pipewire.sec.pid is the pipewire-pulse
7
+ server, so the self-reported application.process.id is used; for native
8
+ clients pipewire.sec.pid comes from the socket and is used. The chosen PID
9
+ must pass a check: its exe basename or comm equals the reported
10
+ application.process.binary. If it fails (say a Flatpak sandbox PID that
11
+ names an unrelated host process), the PID-derived steps are skipped.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import os
17
+ import re
18
+ from dataclasses import dataclass, field
19
+ from typing import Protocol
20
+
21
+ from ..config.apps import AppRule, AppRules
22
+
23
+ PARENT_DEPTH = 5
24
+ COMM_LEN = 15 # the kernel truncates /proc/<pid>/comm to 15 characters
25
+
26
+
27
+ class ProcReader(Protocol):
28
+ def read(self, pid: int) -> dict | None:
29
+ """exe_basename, comm, cmdline0, flatpak_id, snap_name, unit, ppid; None if gone."""
30
+
31
+
32
+ @dataclass(frozen=True)
33
+ class Evidence:
34
+ pid: int | None = None
35
+ pid_source: str | None = None
36
+ pid_ok: bool = False
37
+ flatpak_id: str | None = None
38
+ snap_name: str | None = None
39
+ unit: str | None = None
40
+ portal_app_id: str | None = None
41
+ exe: str | None = None
42
+ cmdline0: str | None = None
43
+ parents: tuple[str, ...] = ()
44
+ binary: str | None = None
45
+ app_name: str | None = None
46
+
47
+ def as_dict(self) -> dict:
48
+ return {k: v for k, v in self.__dict__.items() if v not in (None, ())}
49
+
50
+
51
+ @dataclass(frozen=True)
52
+ class Identity:
53
+ rule: AppRule | None
54
+ matched_by: str | None
55
+ evidence: Evidence = field(default_factory=Evidence)
56
+
57
+ @property
58
+ def key(self) -> str | None:
59
+ return self.rule.key if self.rule else None
60
+
61
+
62
+ def gather_evidence(client_props: dict, node_props: dict, proc: ProcReader) -> Evidence:
63
+ def prop(name):
64
+ return client_props.get(name) or node_props.get(name)
65
+
66
+ binary = prop("application.process.binary")
67
+ if client_props.get("client.api") == "pipewire-pulse":
68
+ pid_source = "application.process.id"
69
+ pid = _as_int(prop("application.process.id"))
70
+ else:
71
+ pid_source = "pipewire.sec.pid"
72
+ pid = _as_int(client_props.get("pipewire.sec.pid"))
73
+ if pid is None:
74
+ pid_source = "application.process.id"
75
+ pid = _as_int(prop("application.process.id"))
76
+
77
+ base = dict(
78
+ pid=pid,
79
+ pid_source=pid_source if pid is not None else None,
80
+ portal_app_id=client_props.get("pipewire.access.portal.app_id"),
81
+ binary=binary,
82
+ app_name=prop("application.name"),
83
+ )
84
+ p = proc.read(pid) if pid is not None else None
85
+ if not p:
86
+ return Evidence(**base)
87
+
88
+ names = {p.get("exe_basename"), p.get("comm")}
89
+ pid_ok = binary is None or binary in names or binary[:COMM_LEN] in names
90
+ if not pid_ok:
91
+ return Evidence(**base)
92
+
93
+ parents = []
94
+ ppid = p.get("ppid")
95
+ while ppid and ppid > 1 and len(parents) < PARENT_DEPTH:
96
+ pp = proc.read(ppid)
97
+ if not pp:
98
+ break
99
+ parents.append(pp.get("exe_basename") or pp.get("comm") or "?")
100
+ ppid = pp.get("ppid")
101
+
102
+ return Evidence(
103
+ **base,
104
+ pid_ok=True,
105
+ flatpak_id=p.get("flatpak_id"),
106
+ snap_name=p.get("snap_name"),
107
+ unit=p.get("unit"),
108
+ exe=p.get("exe_basename"),
109
+ cmdline0=p.get("cmdline0"),
110
+ parents=tuple(parents),
111
+ )
112
+
113
+
114
+ def resolve(ev: Evidence, rules: AppRules) -> Identity:
115
+ """Walk the lookup order and stop at the first exact rule match."""
116
+ steps: list[tuple[str, str, str | None]] = []
117
+ if ev.pid_ok:
118
+ steps += [("cgroup.flatpak", "flatpak", ev.flatpak_id),
119
+ ("cgroup.snap", "snap", ev.snap_name)]
120
+ steps.append(("portal_app_id", "flatpak", ev.portal_app_id))
121
+ if ev.pid_ok:
122
+ steps += [("exe", "exe", ev.exe), ("cmdline0", "exe", ev.cmdline0)]
123
+ steps += [(f"parent{i + 1}", "exe", name) for i, name in enumerate(ev.parents)]
124
+ steps += [("binary", "binary", ev.binary), ("binary", "exe", ev.binary),
125
+ ("app_name", "app_name", ev.app_name)]
126
+ for label, fld, value in steps:
127
+ rule = rules.lookup(fld, value)
128
+ if rule:
129
+ return Identity(rule, label, ev)
130
+ return Identity(None, None, ev)
131
+
132
+
133
+ class LiveProc:
134
+ """ProcReader over /proc."""
135
+
136
+ def read(self, pid: int) -> dict | None:
137
+ base = f"/proc/{pid}"
138
+ try:
139
+ exe = os.readlink(f"{base}/exe").removesuffix(" (deleted)")
140
+ except OSError:
141
+ exe = None
142
+ comm = _read(f"{base}/comm")
143
+ if comm is None and exe is None:
144
+ return None
145
+ cmdline = _read(f"{base}/cmdline") or ""
146
+ argv = [a for a in cmdline.split("\0") if a]
147
+ stat = _read(f"{base}/stat") or ""
148
+ rest = stat.rsplit(")", 1)[-1].split()
149
+ out = {
150
+ "exe_basename": os.path.basename(exe) if exe else None,
151
+ "comm": comm.strip() if comm else None,
152
+ "cmdline0": cmdline0(argv),
153
+ "ppid": int(rest[1]) if len(rest) > 1 else None,
154
+ }
155
+ out.update(parse_cgroup(_read(f"{base}/cgroup")))
156
+ return out
157
+
158
+
159
+ def cmdline0(argv: list[str]) -> str | None:
160
+ if not argv:
161
+ return None
162
+ # Chrome and Electron rewrite argv into one space-joined string.
163
+ first = argv[0].split(" ", 1)[0] if len(argv) == 1 else argv[0]
164
+ return os.path.basename(first)
165
+
166
+
167
+ def parse_cgroup(text: str | None) -> dict:
168
+ out = {"flatpak_id": None, "snap_name": None, "unit": None}
169
+ path = None
170
+ for line in (text or "").splitlines():
171
+ parts = line.split(":", 2)
172
+ if len(parts) == 3 and (parts[0] == "0" or parts[1] == "name=systemd"):
173
+ path = parts[2]
174
+ if parts[0] == "0":
175
+ break
176
+ if not path:
177
+ return out
178
+ segs = path.split("/")
179
+ units = [s for s in segs if s.endswith((".scope", ".service"))]
180
+ out["unit"] = units[-1] if units else None
181
+ for seg in reversed(segs):
182
+ m = re.match(r"app-flatpak-(.+)-\d+\.scope$", seg)
183
+ if m:
184
+ out["flatpak_id"] = m.group(1)
185
+ break
186
+ m = re.match(r"snap\.([^.]+)\.", seg)
187
+ if m:
188
+ out["snap_name"] = m.group(1)
189
+ break
190
+ return out
191
+
192
+
193
+ def _read(path: str) -> str | None:
194
+ try:
195
+ with open(path, "rb") as f:
196
+ return f.read().decode("utf-8", "replace")
197
+ except OSError:
198
+ return None
199
+
200
+
201
+ def _as_int(v) -> int | None:
202
+ try:
203
+ return int(v)
204
+ except (TypeError, ValueError):
205
+ return None
@@ -0,0 +1,69 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later
2
+ # Copyright (c) 2026 Hrishikesh Jadhav
3
+ """Turn the graph mirror into the set of running microphone streams.
4
+
5
+ A mic stream (docs/dev/PLAN.md › What counts as a mic stream): a Stream/Input/Audio
6
+ node in state "running", linked from at least one Audio/Source* node, and not
7
+ owned by this daemon.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from dataclasses import dataclass
13
+
14
+ from .graph import Graph, info, props
15
+
16
+ CAPTURE_CLASS = "Stream/Input/Audio"
17
+
18
+
19
+ @dataclass(frozen=True)
20
+ class MicStream:
21
+ serial: int # object.serial; node ids get reused
22
+ node_id: int
23
+ client_serial: int | None # identity is cached per client
24
+ media_name: str | None
25
+ node_props: dict
26
+ client_props: dict
27
+ source_names: tuple[str, ...]
28
+
29
+
30
+ def mic_streams(graph: Graph, own_pids: set[int], own_prefix: str) -> list[MicStream]:
31
+ sources_by_node: dict[int, list[str]] = {}
32
+ for link in graph.links():
33
+ li = info(link)
34
+ src = graph.props(li.get("output-node-id"))
35
+ if src.get("media.class", "").startswith("Audio/Source"):
36
+ sources_by_node.setdefault(li.get("input-node-id"), []).append(
37
+ src.get("node.name", "?"))
38
+
39
+ out = []
40
+ for node in graph.nodes():
41
+ np = props(node)
42
+ if np.get("media.class") != CAPTURE_CLASS or info(node).get("state") != "running":
43
+ continue
44
+ if node["id"] not in sources_by_node:
45
+ continue # fed only from a sink monitor, or not linked yet
46
+ if str(np.get("node.name", "")).startswith(own_prefix):
47
+ continue
48
+ cp = graph.props(np.get("client.id"))
49
+ claimed = {cp.get("application.process.id"), np.get("application.process.id"),
50
+ cp.get("pipewire.sec.pid")}
51
+ if any(_as_int(p) in own_pids for p in claimed):
52
+ continue
53
+ out.append(MicStream(
54
+ serial=np.get("object.serial"),
55
+ node_id=node["id"],
56
+ client_serial=cp.get("object.serial"),
57
+ media_name=np.get("media.name"),
58
+ node_props=np,
59
+ client_props=cp,
60
+ source_names=tuple(sorted(set(sources_by_node[node["id"]]))),
61
+ ))
62
+ return out
63
+
64
+
65
+ def _as_int(v) -> int | None:
66
+ try:
67
+ return int(v)
68
+ except (TypeError, ValueError):
69
+ return None
@@ -0,0 +1,304 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later
2
+ # Copyright (c) 2026 Hrishikesh Jadhav
3
+ """Per-app state machine (docs/dev/PLAN.md › State machine, Mermaid diagram).
4
+
5
+ Pure: no I/O, no clock of its own. The daemon calls update() after every
6
+ PipeWire batch and on the 1 s reconcile tick with the apps currently on the
7
+ mic; user clicks come in through record(), ignore() and stop(). Every call
8
+ returns a list of actions for the daemon to carry out and log.
9
+
10
+ Two refinements of the diagram, both to avoid surprising prompts:
11
+ - StopPending stays put while a stop guard (recording < 30 s, app runtime
12
+ < 20 s) blocks, and re-checks on every update, instead of bouncing back to
13
+ Recording each tick. It returns to Recording only when the app comes back.
14
+ - Manual Stop and the 4 h cap go to Suppressed, not Idle, so a call that is
15
+ still running does not prompt again right away.
16
+ Prompted also waits out the grace before withdrawing, so a mic reopen (Chrome
17
+ closes and reopens it at Join, docs/dev/m0-findings.md) does not flash the prompt.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ from dataclasses import dataclass
23
+ from enum import StrEnum
24
+
25
+ from ..config.settings import Settings
26
+
27
+
28
+ MANUAL_KEY = "manual"
29
+
30
+
31
+ class State(StrEnum):
32
+ IDLE = "idle"
33
+ CANDIDATE = "candidate"
34
+ PROMPTED = "prompted"
35
+ RECORDING = "recording"
36
+ STOP_PENDING = "stop_pending"
37
+ SUPPRESSED = "suppressed"
38
+
39
+
40
+ @dataclass(frozen=True)
41
+ class Observation:
42
+ """One app on the mic right now: all of its running mic streams."""
43
+ key: str
44
+ name: str
45
+ kind: str # "meeting" or "browser", after title rules
46
+ streams: frozenset[int] # object.serial of each running stream
47
+ service: str | None = None # e.g. "Jitsi", from a title rule
48
+ room: str | None = None
49
+
50
+
51
+ # Actions -------------------------------------------------------------------
52
+
53
+ @dataclass(frozen=True)
54
+ class Transition:
55
+ key: str
56
+ old: State
57
+ new: State
58
+ reason: str
59
+
60
+
61
+ @dataclass(frozen=True)
62
+ class Note:
63
+ key: str
64
+ message: str
65
+
66
+
67
+ @dataclass(frozen=True)
68
+ class ShowPrompt:
69
+ key: str
70
+ name: str
71
+ kind: str
72
+ service: str | None
73
+ room: str | None
74
+
75
+
76
+ @dataclass(frozen=True)
77
+ class WithdrawPrompt:
78
+ key: str
79
+
80
+
81
+ @dataclass(frozen=True)
82
+ class StartRecording:
83
+ key: str
84
+ name: str
85
+
86
+
87
+ @dataclass(frozen=True)
88
+ class StopRecording:
89
+ key: str
90
+ reason: str
91
+ duration_s: float
92
+
93
+
94
+ @dataclass(frozen=True)
95
+ class ShowNudge:
96
+ key: str
97
+ name: str
98
+ dismisses: int
99
+
100
+
101
+ Action = Transition | Note | ShowPrompt | WithdrawPrompt | StartRecording | StopRecording | ShowNudge
102
+
103
+
104
+ @dataclass
105
+ class AppTrack:
106
+ key: str
107
+ state: State = State.IDLE
108
+ obs: Observation | None = None
109
+ session_start: float | None = None # first mic use of this session
110
+ gone_since: float | None = None # set while the app is off the mic
111
+ candidate_since: float | None = None
112
+ recording_since: float | None = None
113
+ note: str | None = None # last Note, so it is emitted once
114
+ manual: bool = False # started with "Record a meeting": no app to leave
115
+
116
+
117
+ class Tracker:
118
+ def __init__(self, settings: Settings, dismisses: dict[str, int] | None = None) -> None:
119
+ self.s = settings
120
+ self.apps: dict[str, AppTrack] = {}
121
+ self.dismisses: dict[str, int] = dict(dismisses or {})
122
+ self.asleep = False
123
+
124
+ # Queries ---------------------------------------------------------------
125
+
126
+ @property
127
+ def recording_key(self) -> str | None:
128
+ return next((t.key for t in self.apps.values()
129
+ if t.state in (State.RECORDING, State.STOP_PENDING)), None)
130
+
131
+ def snapshot(self, now: float) -> dict:
132
+ out = {}
133
+ for t in self.apps.values():
134
+ entry = {"state": str(t.state), "name": t.obs.name if t.obs else t.key}
135
+ if t.session_start is not None:
136
+ entry["on_mic_for_s"] = round(now - t.session_start, 1)
137
+ if t.recording_since is not None:
138
+ entry["recording_for_s"] = round(now - t.recording_since, 1)
139
+ if t.gone_since is not None:
140
+ entry["gone_for_s"] = round(now - t.gone_since, 1)
141
+ out[t.key] = entry
142
+ return out
143
+
144
+ # Inputs ----------------------------------------------------------------
145
+
146
+ def update(self, now: float, observations: dict[str, Observation]) -> list[Action]:
147
+ if self.asleep:
148
+ return []
149
+ actions: list[Action] = []
150
+ for key in sorted(set(self.apps) | set(observations)):
151
+ t = self.apps.setdefault(key, AppTrack(key))
152
+ obs = observations.get(key)
153
+ if obs is not None:
154
+ t.obs = obs
155
+ t.gone_since = None
156
+ elif t.gone_since is None:
157
+ t.gone_since = now
158
+ self._step(t, now, obs is not None, actions)
159
+ if t.state is State.IDLE:
160
+ del self.apps[key]
161
+ return actions
162
+
163
+ def record(self, key: str, now: float) -> list[Action]:
164
+ t = self.apps.get(key)
165
+ if t is None or t.state is not State.PROMPTED:
166
+ return [Note(key, "record ignored: no prompt showing")]
167
+ if self.recording_key is not None:
168
+ return [Note(key, f"record refused: {self.recording_key} is recording")]
169
+ actions: list[Action] = []
170
+ t.recording_since = now
171
+ self._go(t, State.RECORDING, "user clicked Record", actions)
172
+ actions.append(StartRecording(key, t.obs.name if t.obs else key))
173
+ return actions
174
+
175
+ def start_manual(self, now: float, key: str = MANUAL_KEY,
176
+ name: str = "In-person meeting") -> list[Action]:
177
+ """Record without a call app (docs/dev/features-2-design.md › 2). Only Stop,
178
+ the 4 h cap and sleep end it."""
179
+ if self.recording_key is not None:
180
+ return [Note(key, f"record refused: {self.recording_key} is recording")]
181
+ actions: list[Action] = []
182
+ t = self.apps[key] = AppTrack(key, obs=Observation(key, name, "meeting", frozenset()),
183
+ session_start=now, recording_since=now, manual=True)
184
+ self._go(t, State.RECORDING, "user started a recording", actions)
185
+ actions.append(StartRecording(key, name))
186
+ return actions
187
+
188
+ def ignore(self, key: str, now: float) -> list[Action]:
189
+ t = self.apps.get(key)
190
+ if t is None or t.state is not State.PROMPTED:
191
+ return [Note(key, "ignore ignored: no prompt showing")]
192
+ actions: list[Action] = []
193
+ self._go(t, State.SUPPRESSED, "user ignored the prompt", actions)
194
+ n = self.dismisses[key] = self.dismisses.get(key, 0) + 1
195
+ if n == self.s.nudge_after_dismisses:
196
+ actions.append(ShowNudge(key, t.obs.name if t.obs else key, n))
197
+ return actions
198
+
199
+ def stop(self, now: float, reason: str = "manual stop") -> list[Action]:
200
+ key = self.recording_key
201
+ if key is None:
202
+ return []
203
+ actions: list[Action] = []
204
+ self._stop(self.apps[key], now, State.SUPPRESSED, reason, actions)
205
+ return actions
206
+
207
+ def sleep(self, now: float) -> list[Action]:
208
+ actions: list[Action] = []
209
+ for t in list(self.apps.values()):
210
+ if t.state is State.PROMPTED:
211
+ actions.append(WithdrawPrompt(t.key))
212
+ if t.state in (State.RECORDING, State.STOP_PENDING):
213
+ self._stop(t, now, State.IDLE, "system sleep", actions)
214
+ elif t.state is not State.IDLE:
215
+ self._go(t, State.IDLE, "system sleep", actions)
216
+ self.apps.clear()
217
+ self.asleep = True
218
+ return actions
219
+
220
+ def resume(self, now: float, observations: dict[str, Observation]) -> list[Action]:
221
+ """Apps already on the mic after wake count as seen: no prompt."""
222
+ self.asleep = False
223
+ actions: list[Action] = []
224
+ for key, obs in sorted(observations.items()):
225
+ t = self.apps.setdefault(key, AppTrack(key, obs=obs, session_start=now))
226
+ self._go(t, State.SUPPRESSED, "on the mic at resume", actions)
227
+ return actions
228
+
229
+ # Internals -------------------------------------------------------------
230
+
231
+ def _step(self, t: AppTrack, now: float, present: bool, actions: list[Action]) -> None:
232
+ s = self.s
233
+ gone_for = now - t.gone_since if t.gone_since is not None else 0.0
234
+
235
+ if t.state is State.IDLE:
236
+ if present:
237
+ t.session_start = t.candidate_since = now
238
+ self._go(t, State.CANDIDATE, "app opened the mic", actions)
239
+
240
+ elif t.state is State.CANDIDATE:
241
+ if not present:
242
+ self._go(t, State.IDLE, "mic closed before dwell", actions)
243
+ return
244
+ other = self.recording_key
245
+ if other is not None:
246
+ self._note(t, f"prompt held: {other} is recording", actions)
247
+ return
248
+ dwell = s.dwell_meeting_s if t.obs.kind == "meeting" else s.dwell_browser_s
249
+ if now - t.candidate_since >= dwell:
250
+ self._go(t, State.PROMPTED, f"on the mic for {dwell:g} s", actions)
251
+ o = t.obs
252
+ actions.append(ShowPrompt(t.key, o.name, o.kind, o.service, o.room))
253
+
254
+ elif t.state is State.PROMPTED:
255
+ if not present and gone_for >= s.grace_s:
256
+ self._go(t, State.IDLE, "app left the mic (prompt withdrawn)", actions)
257
+ actions.append(WithdrawPrompt(t.key))
258
+
259
+ elif t.state is State.SUPPRESSED:
260
+ if not present and gone_for >= max(s.cooldown_s, s.grace_s):
261
+ self._go(t, State.IDLE, f"stream ended {s.cooldown_s:g} s ago", actions)
262
+
263
+ elif t.state is State.RECORDING:
264
+ if now - t.recording_since >= s.max_recording_s:
265
+ self._stop(t, now, State.SUPPRESSED, "4 h cap", actions)
266
+ elif t.manual:
267
+ pass
268
+ elif not present and gone_for >= s.grace_s:
269
+ self._go(t, State.STOP_PENDING, f"app gone > {s.grace_s:g} s", actions)
270
+ self._check_stop(t, now, actions)
271
+
272
+ elif t.state is State.STOP_PENDING:
273
+ if present:
274
+ self._go(t, State.RECORDING, "app back on the mic", actions)
275
+ elif now - t.recording_since >= s.max_recording_s:
276
+ self._stop(t, now, State.SUPPRESSED, "4 h cap", actions)
277
+ else:
278
+ self._check_stop(t, now, actions)
279
+
280
+ def _check_stop(self, t: AppTrack, now: float, actions: list[Action]) -> None:
281
+ s = self.s
282
+ if now - t.recording_since < s.min_recording_age_s:
283
+ self._note(t, f"stop held: recording younger than {s.min_recording_age_s:g} s", actions)
284
+ elif now - t.session_start < s.min_app_runtime_s:
285
+ self._note(t, f"stop held: app on the mic less than {s.min_app_runtime_s:g} s", actions)
286
+ else:
287
+ self._stop(t, now, State.IDLE, "app left the mic", actions)
288
+
289
+ def _stop(self, t: AppTrack, now: float, new: State, reason: str,
290
+ actions: list[Action]) -> None:
291
+ duration = now - t.recording_since if t.recording_since is not None else 0.0
292
+ self._go(t, new, reason, actions)
293
+ actions.append(StopRecording(t.key, reason, round(duration, 1)))
294
+ t.recording_since = None
295
+
296
+ def _go(self, t: AppTrack, new: State, reason: str, actions: list[Action]) -> None:
297
+ actions.append(Transition(t.key, t.state, new, reason))
298
+ t.state = new
299
+ t.note = None
300
+
301
+ def _note(self, t: AppTrack, message: str, actions: list[Action]) -> None:
302
+ if t.note != message:
303
+ t.note = message
304
+ actions.append(Note(t.key, message))
@@ -0,0 +1,2 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later
2
+ # Copyright (c) 2026 Hrishikesh Jadhav