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
wirenote/net/peer.py ADDED
@@ -0,0 +1,101 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later
2
+ # Copyright (c) 2026 Hrishikesh Jadhav
3
+ """Who owns a loopback TCP socket, from /proc/net/tcp and tcp6.
4
+
5
+ The web UI and Ollama listen on loopback, which every account on the machine
6
+ can reach. These lookups let us refuse other users' connections, and refuse
7
+ to talk to a port that another user has taken. A socket on [::] also takes
8
+ IPv4 connections (dual stack), so IPv6 listeners count for 127.0.0.1 too.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import ipaddress
14
+ import os
15
+ import re
16
+ from pathlib import Path
17
+
18
+ PROC_TCP = Path("/proc/net/tcp")
19
+ PROC_TCP6 = Path("/proc/net/tcp6")
20
+ LOGIN_DEFS = Path("/etc/login.defs")
21
+ LISTEN = "0A"
22
+ V4_ANY = ipaddress.ip_address("0.0.0.0")
23
+ V6_ANY = ipaddress.ip_address("::")
24
+
25
+ Address = ipaddress.IPv4Address | ipaddress.IPv6Address
26
+
27
+
28
+ def _ip(hex_ip: str) -> Address:
29
+ """/proc/net/tcp* write addresses as 32-bit words in host (little-endian) order."""
30
+ raw = bytes.fromhex(hex_ip)
31
+ return ipaddress.ip_address(b"".join(raw[i:i + 4][::-1] for i in range(0, len(raw), 4)))
32
+
33
+
34
+ def _rows() -> list[tuple[Address, int, Address, int, str, int]]:
35
+ """(local_ip, local_port, remote_ip, remote_port, state, uid) per socket."""
36
+ rows = []
37
+ for path in (PROC_TCP, PROC_TCP6):
38
+ try:
39
+ lines = path.read_text().splitlines()[1:]
40
+ except OSError:
41
+ continue
42
+ for line in lines:
43
+ f = line.split()
44
+ if len(f) < 8:
45
+ continue
46
+ try:
47
+ (lip, lport), (rip, rport) = f[1].split(":"), f[2].split(":")
48
+ rows.append((_ip(lip), int(lport, 16), _ip(rip), int(rport, 16), f[3], int(f[7])))
49
+ except ValueError:
50
+ continue
51
+ return rows
52
+
53
+
54
+ def _same(a: Address, b: Address) -> bool:
55
+ """Equal, counting ::ffff:127.0.0.1 (an IPv4 peer of a dual-stack socket) as 127.0.0.1."""
56
+ a = getattr(a, "ipv4_mapped", None) or a
57
+ b = getattr(b, "ipv4_mapped", None) or b
58
+ return a == b
59
+
60
+
61
+ def tcp_owner(local_port: int, remote_port: int, host: str = "127.0.0.1") -> int | None:
62
+ """UID of the client side of a connection to our host:local_port that comes
63
+ from host:remote_port; None if there is no such socket."""
64
+ ip = ipaddress.ip_address(host)
65
+ for lip, lport, rip, rport, _state, uid in _rows():
66
+ if lport == remote_port and rport == local_port and _same(lip, ip) and _same(rip, ip):
67
+ return uid
68
+ return None
69
+
70
+
71
+ def listener_uid(port: int, host: str = "127.0.0.1") -> int | None:
72
+ """UID of the socket that a connection to host:port would reach (the exact
73
+ address first, then a wildcard); None if nothing listens there."""
74
+ ip = ipaddress.ip_address(host)
75
+ if ip.version == 4:
76
+ ranks = [lambda a: a == ip, lambda a: a == V4_ANY,
77
+ lambda a: a.version == 6 and a.ipv4_mapped == ip, lambda a: a == V6_ANY]
78
+ else:
79
+ ranks = [lambda a: a == ip, lambda a: a == V6_ANY]
80
+ listening = [(lip, uid) for lip, lport, _r, _rp, state, uid in _rows()
81
+ if state == LISTEN and lport == port]
82
+ for match in ranks:
83
+ for lip, uid in listening:
84
+ if match(lip):
85
+ return uid
86
+ return None
87
+
88
+
89
+ def uid_min() -> int:
90
+ """The first UID for people (UID_MIN in /etc/login.defs); below it are system accounts."""
91
+ try:
92
+ m = re.search(r"^\s*UID_MIN\s+(\d+)", LOGIN_DEFS.read_text(), re.MULTILINE)
93
+ except OSError:
94
+ m = None
95
+ return int(m.group(1)) if m else 1000
96
+
97
+
98
+ def trusted_owner(uid: int | None) -> bool:
99
+ """A socket we may hand private data to: ours, root's, or a system account's
100
+ (Ollama's installer runs it as the system user "ollama"). Never another person's."""
101
+ return uid is not None and (uid == os.getuid() or uid == 0 or 0 < uid < uid_min())
@@ -0,0 +1,2 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later
2
+ # Copyright (c) 2026 Hrishikesh Jadhav
@@ -0,0 +1,43 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later
2
+ # Copyright (c) 2026 Hrishikesh Jadhav
3
+ """Copy text to the clipboard on any desktop: Klipper (KDE) over D-Bus, else
4
+ wl-copy (Wayland), xclip or xsel (X11). The text goes in on stdin, never on the
5
+ command line, where other users could read it in the process list."""
6
+
7
+ from __future__ import annotations
8
+
9
+ import asyncio
10
+ import os
11
+ import shutil
12
+ import subprocess
13
+ from collections.abc import Awaitable, Callable
14
+
15
+ # (name, argv, the environment variable that must be set for it to work)
16
+ TOOLS = (("wl-copy", ["wl-copy"], "WAYLAND_DISPLAY"),
17
+ ("xclip", ["xclip", "-selection", "clipboard"], "DISPLAY"),
18
+ ("xsel", ["xsel", "-b", "-i"], "DISPLAY"))
19
+
20
+
21
+ def available_tools() -> list[str]:
22
+ return [name for name, argv, _env in TOOLS if shutil.which(argv[0])]
23
+
24
+
25
+ async def copy(text: str, klipper: Callable[[str], Awaitable[None]] | None = None) -> str | None:
26
+ """The name of the tool that copied the text, or None if nothing could."""
27
+ if klipper is not None:
28
+ try:
29
+ await klipper(text)
30
+ return "klipper"
31
+ except Exception: # noqa: BLE001 - not KDE, or Klipper not running
32
+ pass
33
+ for name, argv, env in TOOLS:
34
+ if not os.environ.get(env) or not shutil.which(argv[0]):
35
+ continue
36
+ try:
37
+ await asyncio.to_thread(subprocess.run, argv, input=text.encode(), timeout=5,
38
+ check=True, stdout=subprocess.DEVNULL,
39
+ stderr=subprocess.DEVNULL)
40
+ return name
41
+ except (OSError, subprocess.SubprocessError):
42
+ continue
43
+ return None
@@ -0,0 +1,89 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later
2
+ # Copyright (c) 2026 Hrishikesh Jadhav
3
+ """Desktop notifications (org.freedesktop.Notifications) and logind sleep signals."""
4
+
5
+ from __future__ import annotations
6
+
7
+ import logging
8
+ from collections.abc import Callable
9
+
10
+ from dbus_fast import BusType, Variant
11
+ from dbus_fast.aio import MessageBus
12
+
13
+ NOTIFY_NAME = "org.freedesktop.Notifications"
14
+ NOTIFY_PATH = "/org/freedesktop/Notifications"
15
+ KLIPPER_NAME = "org.kde.klipper" # Plasma's clipboard, used for the consent line
16
+ KLIPPER_PATH = "/klipper"
17
+ KLIPPER_IFACE = "org.kde.klipper.klipper"
18
+ LOGIND_NAME = "org.freedesktop.login1"
19
+ LOGIND_PATH = "/org/freedesktop/login1"
20
+
21
+ # NotificationClosed reasons (Desktop Notifications spec)
22
+ CLOSED_EXPIRED, CLOSED_DISMISSED, CLOSED_BY_CALL, CLOSED_UNDEFINED = 1, 2, 3, 4
23
+
24
+ log = logging.getLogger(__name__)
25
+
26
+
27
+ def escape_markup(text: str) -> str:
28
+ """Page titles and calendar titles reach the notification; servers that render
29
+ markup (Plasma: body-markup, body-hyperlinks) must show them as text."""
30
+ return text.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
31
+
32
+
33
+ class Notifier:
34
+ """Thin wrapper over the notification server.
35
+
36
+ on_action(nid, action_key) and on_closed(nid, reason) are called from the
37
+ event loop when the server reports a click or a close.
38
+ """
39
+
40
+ def __init__(self, app_name: str = "Wirenote") -> None:
41
+ self.app_name = app_name
42
+ self.caps: set[str] = set()
43
+ self.server: str = "?"
44
+ self.on_action: Callable[[int, str], None] = lambda nid, key: None
45
+ self.on_closed: Callable[[int, int], None] = lambda nid, reason: None
46
+ self._iface = None
47
+ self._bus = None
48
+
49
+ async def connect(self) -> None:
50
+ bus = self._bus = await MessageBus().connect()
51
+ intro = await bus.introspect(NOTIFY_NAME, NOTIFY_PATH)
52
+ self._iface = bus.get_proxy_object(NOTIFY_NAME, NOTIFY_PATH, intro).get_interface(NOTIFY_NAME)
53
+ self.caps = set(await self._iface.call_get_capabilities())
54
+ name, vendor, version, _spec = await self._iface.call_get_server_information()
55
+ self.server = f"{vendor} {name} {version}"
56
+ self._iface.on_action_invoked(lambda nid, key: self.on_action(nid, key))
57
+ self._iface.on_notification_closed(lambda nid, reason: self.on_closed(nid, reason))
58
+
59
+ @property
60
+ def has_actions(self) -> bool:
61
+ return "actions" in self.caps
62
+
63
+ async def notify(self, summary: str, body: str = "", actions: list[tuple[str, str]] = (),
64
+ timeout_ms: int = 0, urgency: int = 1) -> int:
65
+ """Show a notification; timeout 0 means it never expires on its own."""
66
+ flat = [x for key, label in actions for x in (key, label)] if self.has_actions else []
67
+ hints = {"urgency": Variant("y", urgency)}
68
+ return await self._iface.call_notify(self.app_name, 0, "audio-input-microphone",
69
+ escape_markup(summary), escape_markup(body), flat,
70
+ hints, timeout_ms)
71
+
72
+ async def close(self, nid: int) -> None:
73
+ await self._iface.call_close_notification(nid)
74
+
75
+ async def copy_to_clipboard(self, text: str) -> None:
76
+ """Through Klipper on the session bus; raises if Klipper is not running."""
77
+ intro = await self._bus.introspect(KLIPPER_NAME, KLIPPER_PATH)
78
+ klipper = self._bus.get_proxy_object(KLIPPER_NAME, KLIPPER_PATH, intro).get_interface(
79
+ KLIPPER_IFACE)
80
+ await klipper.call_set_clipboard_contents(text)
81
+
82
+
83
+ async def watch_sleep(on_sleep: Callable[[bool], None]) -> None:
84
+ """Call on_sleep(True) before suspend and on_sleep(False) after resume."""
85
+ bus = await MessageBus(bus_type=BusType.SYSTEM).connect()
86
+ intro = await bus.introspect(LOGIND_NAME, LOGIND_PATH)
87
+ mgr = bus.get_proxy_object(LOGIND_NAME, LOGIND_PATH, intro).get_interface(
88
+ "org.freedesktop.login1.Manager")
89
+ mgr.on_prepare_for_sleep(on_sleep)
@@ -0,0 +1,2 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later
2
+ # Copyright (c) 2026 Hrishikesh Jadhav
@@ -0,0 +1,122 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later
2
+ # Copyright (c) 2026 Hrishikesh Jadhav
3
+ """Processing jobs in SQLite (docs/dev/PLAN.md › Processing: a crash never loses a meeting).
4
+
5
+ One row per recording. States: queued -> transcribing -> summarizing -> done,
6
+ or failed after MAX_ATTEMPTS. The daemon runs one worker process at a time;
7
+ the worker updates the row as it goes.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import datetime as dt
13
+ import sqlite3
14
+ from dataclasses import dataclass
15
+ from pathlib import Path
16
+
17
+ MAX_ATTEMPTS = 3
18
+ ACTIVE = ("transcribing", "summarizing")
19
+
20
+ SCHEMA = """
21
+ CREATE TABLE IF NOT EXISTS jobs (
22
+ id INTEGER PRIMARY KEY,
23
+ recording TEXT NOT NULL UNIQUE,
24
+ state TEXT NOT NULL,
25
+ attempts INTEGER NOT NULL DEFAULT 0,
26
+ summary_only INTEGER NOT NULL DEFAULT 0,
27
+ error TEXT,
28
+ note TEXT,
29
+ created TEXT NOT NULL,
30
+ updated TEXT NOT NULL
31
+ )
32
+ """
33
+
34
+
35
+ def _now() -> str:
36
+ return dt.datetime.now().astimezone().isoformat(timespec="seconds")
37
+
38
+
39
+ @dataclass(frozen=True)
40
+ class Job:
41
+ id: int
42
+ recording: Path
43
+ state: str
44
+ attempts: int
45
+ summary_only: bool
46
+ error: str | None
47
+ note: str | None
48
+ created: str
49
+ updated: str
50
+
51
+
52
+ class Jobs:
53
+ def __init__(self, path: Path) -> None:
54
+ path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
55
+ # The UI server shares one connection across threads behind its own lock.
56
+ self.db = sqlite3.connect(path, timeout=10, isolation_level=None, check_same_thread=False)
57
+ self.db.execute("PRAGMA journal_mode=WAL")
58
+ self.db.execute(SCHEMA)
59
+
60
+ def close(self) -> None:
61
+ self.db.close()
62
+
63
+ def add(self, recording: Path, summary_only: bool = False) -> int:
64
+ """Queue a recording; re-queues it (attempts reset) if it already has a row."""
65
+ now = _now()
66
+ self.db.execute(
67
+ "INSERT INTO jobs (recording, state, summary_only, created, updated) "
68
+ "VALUES (?, 'queued', ?, ?, ?) ON CONFLICT(recording) DO UPDATE SET "
69
+ "state='queued', attempts=0, error=NULL, summary_only=excluded.summary_only, "
70
+ "updated=excluded.updated",
71
+ (str(recording), int(summary_only), now, now))
72
+ return self.by_recording(recording).id
73
+
74
+ def get(self, job_id: int) -> Job | None:
75
+ row = self.db.execute("SELECT * FROM jobs WHERE id=?", (job_id,)).fetchone()
76
+ return _job(row)
77
+
78
+ def by_recording(self, recording: Path) -> Job | None:
79
+ row = self.db.execute("SELECT * FROM jobs WHERE recording=?",
80
+ (str(recording),)).fetchone()
81
+ return _job(row)
82
+
83
+ def next_queued(self) -> Job | None:
84
+ row = self.db.execute("SELECT * FROM jobs WHERE state='queued' "
85
+ "ORDER BY id LIMIT 1").fetchone()
86
+ return _job(row)
87
+
88
+ def recent(self, n: int = 10) -> list[Job]:
89
+ return [_job(r) for r in self.db.execute(
90
+ "SELECT * FROM jobs ORDER BY id DESC LIMIT ?", (n,))]
91
+
92
+ def counts(self) -> dict[str, int]:
93
+ return dict(self.db.execute("SELECT state, COUNT(*) FROM jobs GROUP BY state"))
94
+
95
+ def set_state(self, job_id: int, state: str, note: str | None = None) -> None:
96
+ self.db.execute("UPDATE jobs SET state=?, note=COALESCE(?, note), error=NULL, "
97
+ "updated=? WHERE id=?", (state, note, _now(), job_id))
98
+
99
+ def fail(self, job_id: int, error: str) -> Job:
100
+ """Count a failed attempt; back to queued unless attempts are used up."""
101
+ job = self.get(job_id)
102
+ attempts = job.attempts + 1
103
+ state = "failed" if attempts >= MAX_ATTEMPTS else "queued"
104
+ self.db.execute("UPDATE jobs SET state=?, attempts=?, error=?, updated=? WHERE id=?",
105
+ (state, attempts, error[-2000:], _now(), job_id))
106
+ return self.get(job_id)
107
+
108
+ def delete(self, recording: Path) -> None:
109
+ self.db.execute("DELETE FROM jobs WHERE recording=?", (str(recording),))
110
+
111
+ def requeue_interrupted(self) -> list[Job]:
112
+ """Jobs a dead worker left mid-way count one failed attempt."""
113
+ stuck = [_job(r) for r in self.db.execute(
114
+ f"SELECT * FROM jobs WHERE state IN {ACTIVE}")]
115
+ return [self.fail(j.id, f"interrupted while {j.state}") for j in stuck]
116
+
117
+
118
+ def _job(row) -> Job | None:
119
+ if row is None:
120
+ return None
121
+ id_, rec, state, attempts, summary_only, error, note, created, updated = row
122
+ return Job(id_, Path(rec), state, attempts, bool(summary_only), error, note, created, updated)
@@ -0,0 +1,87 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later
2
+ # Copyright (c) 2026 Hrishikesh Jadhav
3
+ """Merge both tracks into one transcript (docs/dev/PLAN.md › Processing, step 2).
4
+
5
+ Mic lines are "Me"; system-audio lines carry a speaker label (S1, S2, ...,
6
+ process/speakers.py) or "Others" when speakers were not separated. Echo dedupe (docs/dev/m2-m4-design.md › M3): a Me
7
+ line that overlaps an Others line and says nearly the same thing is the far
8
+ side leaking into the mic, so it is dropped.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import re
14
+ from dataclasses import asdict, dataclass
15
+ from difflib import SequenceMatcher
16
+
17
+ _WORD = re.compile(r"\w+", re.UNICODE)
18
+
19
+
20
+ @dataclass(frozen=True)
21
+ class Line:
22
+ speaker: str
23
+ start: float # seconds from the start of the recording
24
+ end: float
25
+ text: str
26
+
27
+ def as_dict(self) -> dict:
28
+ return asdict(self)
29
+
30
+
31
+ def _norm(text: str) -> str:
32
+ return " ".join(_WORD.findall(text.lower()))
33
+
34
+
35
+ def dedupe_echo(lines: list[Line], min_overlap: float, min_similarity: float
36
+ ) -> tuple[list[Line], list[Line]]:
37
+ """Drop Me lines that echo an overlapping Others line. Returns (kept, dropped)."""
38
+ others = [l for l in lines if l.speaker != "Me"]
39
+ kept, dropped = [], []
40
+ for line in lines:
41
+ if line.speaker == "Me" and _echoes(line, others, min_overlap, min_similarity):
42
+ dropped.append(line)
43
+ else:
44
+ kept.append(line)
45
+ return kept, dropped
46
+
47
+
48
+ def _echoes(me: Line, others: list[Line], min_overlap: float, min_similarity: float) -> bool:
49
+ length = max(me.end - me.start, 1e-6)
50
+ mine = _norm(me.text)
51
+ if not mine:
52
+ return False
53
+ for o in others:
54
+ overlap = min(me.end, o.end) - max(me.start, o.start)
55
+ if overlap / length < min_overlap:
56
+ continue
57
+ if SequenceMatcher(None, mine, _norm(o.text)).ratio() >= min_similarity:
58
+ return True
59
+ return False
60
+
61
+
62
+ def paragraphs(lines: list[Line], max_gap: float) -> list[Line]:
63
+ """Sort by time and join consecutive lines of one speaker closer than max_gap."""
64
+ out: list[Line] = []
65
+ for line in sorted(lines, key=lambda l: (l.start, l.speaker)):
66
+ text = line.text.strip()
67
+ if not text:
68
+ continue
69
+ prev = out[-1] if out else None
70
+ if prev and prev.speaker == line.speaker and line.start - prev.end < max_gap:
71
+ out[-1] = Line(prev.speaker, prev.start, max(prev.end, line.end),
72
+ f"{prev.text} {text}")
73
+ else:
74
+ out.append(Line(line.speaker, line.start, line.end, text))
75
+ return out
76
+
77
+
78
+ def stamp(seconds: float) -> str:
79
+ s = int(seconds)
80
+ return f"{s // 3600:02d}:{s % 3600 // 60:02d}:{s % 60:02d}"
81
+
82
+
83
+ def as_text(lines: list[Line], names: dict[str, str] | None = None) -> str:
84
+ """Plain transcript for the summary backend; names maps labels to display names."""
85
+ names = names or {}
86
+ return "\n".join(f"[{stamp(l.start)}] {names.get(l.speaker, l.speaker)}: {l.text}"
87
+ for l in lines)
@@ -0,0 +1,57 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later
2
+ # Copyright (c) 2026 Hrishikesh Jadhav
3
+ """Model files from Hugging Face: pinned revisions, and offline once
4
+ they are cached, so processing a meeting does not contact huggingface.co."""
5
+
6
+ from __future__ import annotations
7
+
8
+ from pathlib import Path
9
+
10
+ from huggingface_hub import hf_hub_download
11
+ from huggingface_hub.errors import LocalEntryNotFoundError
12
+
13
+ SEG_REPO, SEG_FILE = "csukuangfj/sherpa-onnx-pyannote-segmentation-3-0", "model.onnx"
14
+ EMB_REPO = "csukuangfj/speaker-embedding-models"
15
+ # Commit hashes checked on 2026-10-26. A model not listed (another whisper_model)
16
+ # uses the repo's current revision, still offline after its first download.
17
+ PINS = {
18
+ "mobiuslabsgmbh/faster-whisper-large-v3-turbo": "0a363e9161cbc7ed1431c9597a8ceaf0c4f78fcf",
19
+ SEG_REPO: "9403a6902bb58e3d5ae8c7e77c3422de279db2e0",
20
+ EMB_REPO: "0743f301363dec56491a490f6d6cbc9d67f9a3bf",
21
+ }
22
+
23
+
24
+ def whisper_repo(name: str) -> str:
25
+ from faster_whisper.utils import _MODELS
26
+
27
+ return name if "/" in name else _MODELS.get(name, name)
28
+
29
+
30
+ def _snapshot(name: str, *, revision: str | None, local_files_only: bool) -> str:
31
+ from faster_whisper.utils import download_model
32
+
33
+ return download_model(name, revision=revision, local_files_only=local_files_only)
34
+
35
+
36
+ def hub_file(repo: str, filename: str, local_only: bool = False) -> str:
37
+ """Path of one file of a model repo; downloads it only if it is not cached."""
38
+ try:
39
+ return hf_hub_download(repo, filename, revision=PINS.get(repo), local_files_only=True)
40
+ except LocalEntryNotFoundError:
41
+ if local_only:
42
+ raise
43
+ return hf_hub_download(repo, filename, revision=PINS.get(repo))
44
+
45
+
46
+ def whisper_path(name: str, local_only: bool = False) -> str:
47
+ """Folder of a faster-whisper model (a size like "large-v3-turbo", a repo id, or a
48
+ local folder); downloads it only if it is not cached."""
49
+ if Path(name).expanduser().is_dir():
50
+ return name
51
+ revision = PINS.get(whisper_repo(name))
52
+ try:
53
+ return _snapshot(name, revision=revision, local_files_only=True)
54
+ except LocalEntryNotFoundError:
55
+ if local_only:
56
+ raise
57
+ return _snapshot(name, revision=revision, local_files_only=False)
@@ -0,0 +1,124 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later
2
+ # Copyright (c) 2026 Hrishikesh Jadhav
3
+ """The Markdown note (docs/dev/PLAN.md › Processing, step 4): frontmatter, summary, transcript."""
4
+
5
+ from __future__ import annotations
6
+
7
+ import datetime as dt
8
+ import json
9
+ import re
10
+ from pathlib import Path
11
+
12
+ from .merge import Line, stamp
13
+ from .summarize import Summary
14
+
15
+
16
+ # Calendar titles (anyone can send an invite) and LLM output (a transcript can carry
17
+ # prompt injection) are not trusted Markdown: no HTML, no images, no links, since
18
+ # note viewers like Obsidian load and follow them. Escaping every
19
+ # bracket (and the backslash, so it cannot undo that) leaves no link syntax at all,
20
+ # where matching link patterns always misses a variant.
21
+ _MD_ESCAPES = str.maketrans({"\\": "\\\\", "[": "\\[", "]": "\\]", "<": "&lt;"})
22
+
23
+
24
+ def md_safe(text: str) -> str:
25
+ return text.translate(_MD_ESCAPES)
26
+
27
+
28
+ def action_line(owner: str, task: str, due: str, done: bool) -> str:
29
+ """The one place an action item's Markdown line is built (render, tick, rewrite)."""
30
+ due = f" (due: {md_safe(due)})" if due else ""
31
+ return f"- [{'x' if done else ' '}] **{md_safe(owner)}**: {md_safe(task)}{due}"
32
+
33
+
34
+ def inside(path: Path, *roots: Path) -> bool:
35
+ """path is under one of roots (after resolving symlinks and ..)."""
36
+ p = Path(path).expanduser().resolve()
37
+ return any(p.is_relative_to(Path(r).expanduser().resolve()) for r in roots)
38
+
39
+
40
+ def own_note(manifest: dict, notes_root: Path) -> Path | None:
41
+ """The manifest's note, only if it lies in the notes folder: a manifest is a file
42
+ on disk, and Wirenote never opens or deletes anything else on its word."""
43
+ note = manifest.get("note")
44
+ return Path(note) if note and inside(Path(note), notes_root) else None
45
+
46
+
47
+ def segment_file(rec: Path, seg: dict) -> Path | None:
48
+ """A segment's audio file, only if it is a plain file name in the recording folder."""
49
+ name = str(seg.get("file") or "")
50
+ return rec / name if name and Path(name).name == name and name not in (".", "..") else None
51
+
52
+
53
+ def title_for(manifest: dict, summary: Summary | None) -> str:
54
+ cal = manifest.get("calendar") or {}
55
+ if cal.get("title"):
56
+ return cal["title"]
57
+ if summary and summary.title:
58
+ return summary.title
59
+ if manifest.get("service") and manifest.get("room"):
60
+ return f"{manifest['service']} {manifest['room']}"
61
+ if manifest.get("app") == "manual":
62
+ return manifest.get("name") or "Meeting"
63
+ return f"{manifest.get('name') or manifest.get('app', 'Call')} call"
64
+
65
+
66
+ def note_path(notes_root: Path, started: dt.datetime, title: str) -> Path:
67
+ safe = re.sub(r"[^\w\- ]+", "", title, flags=re.UNICODE).strip()[:60] or "Call"
68
+ return notes_root / f"{started:%Y}" / f"{started:%m}" / f"{started:%Y-%m-%d %H%M} {safe}.md"
69
+
70
+
71
+ def _yaml(value) -> str:
72
+ # JSON scalars and flow lists are valid YAML.
73
+ return json.dumps(value, ensure_ascii=False)
74
+
75
+
76
+ def render(manifest: dict, lines: list[Line], summary: Summary | None, *, title: str,
77
+ duration_min: float, audio: list[str], transcript_model: str,
78
+ summary_backend: str | None, summary_error: str | None = None,
79
+ names: dict[str, str] | None = None, my_notes: str = "") -> str:
80
+ names = names or {}
81
+ cal = manifest.get("calendar") or {}
82
+ front = {
83
+ "title": title,
84
+ "date": manifest["started"],
85
+ "duration_min": round(duration_min, 1),
86
+ "app": manifest.get("app"),
87
+ "service": manifest.get("service"),
88
+ "room": manifest.get("room"),
89
+ "calendar": cal.get("title"),
90
+ "attendees": cal.get("attendees") or None,
91
+ "speakers": sorted({names.get(l.speaker, l.speaker) for l in lines} - {"Me"}) or None,
92
+ "audio": audio or None,
93
+ "transcript_model": transcript_model,
94
+ "summary_backend": summary_backend if summary else None,
95
+ "recording_id": manifest["id"],
96
+ }
97
+ out = ["---"]
98
+ out += [f"{k}: {_yaml(v)}" for k, v in front.items() if v is not None]
99
+ out += ["---", "", f"# {md_safe(title)}", ""]
100
+
101
+ if summary:
102
+ out += ["## Summary", ""] + [f"- {md_safe(p)}" for p in summary.summary] + [""]
103
+ if summary.decisions:
104
+ out += ["## Decisions", ""] + [f"- {md_safe(d)}" for d in summary.decisions] + [""]
105
+ if summary.action_items:
106
+ out += ["## Action items", ""]
107
+ out += [action_line(a.owner, a.task, a.due, False) for a in summary.action_items]
108
+ out.append("")
109
+ if summary.open_questions:
110
+ out += ["## Open questions", ""] + [f"- {md_safe(q)}" for q in summary.open_questions] + [""]
111
+ elif summary_error:
112
+ out += [f"> Summary not available: {md_safe(summary_error)}",
113
+ f"> Run `wirenote reprocess {manifest['id']} --summary` to try again.", ""]
114
+
115
+ if my_notes:
116
+ out += ["## My notes", "", my_notes.strip(), ""]
117
+
118
+ out += ["## Transcript", ""]
119
+ if not lines:
120
+ out += ["_No speech was recognized._", ""]
121
+ for line in lines:
122
+ out += [f"**{md_safe(names.get(line.speaker, line.speaker))}** [{stamp(line.start)}] "
123
+ f"{md_safe(line.text)}", ""]
124
+ return "\n".join(out).rstrip() + "\n"