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/ui/data.py ADDED
@@ -0,0 +1,783 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later
2
+ # Copyright (c) 2026 Hrishikesh Jadhav
3
+ """Read and change past calls for the UI (docs/dev/ui-design.md, docs/dev/features-2-design.md).
4
+
5
+ Everything comes from what the daemon and worker already write: each
6
+ recording's manifest.json, transcript.json and summary.json, the jobs
7
+ database and status.json. Nothing here touches a running recording, except
8
+ my_notes.md, which the person writes during the call.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import dataclasses
14
+ import datetime as dt
15
+ import hashlib
16
+ import json
17
+ import math
18
+ import os
19
+ import re
20
+ import shutil
21
+ import subprocess
22
+ import threading
23
+ import time
24
+ from pathlib import Path
25
+
26
+ from ..capture.recorder import read_manifest, write_manifest
27
+ from ..config.secrets import (calendar_sources, load_secrets, save_secrets, with_config_value,
28
+ write_config_atomic)
29
+ from ..config.settings import DEFAULT_CONFIG_TOML, Settings, config_dir, parse_settings
30
+ from ..config.vocabulary import load_vocabulary, save_vocabulary
31
+ from ..process.jobs import Jobs
32
+ from ..process.merge import Line, as_text, paragraphs, stamp
33
+ from ..process.note import action_line, own_note, segment_file, title_for
34
+ from ..process.speakers import Voices, display_name
35
+ from ..process.summarize import Summary, SummaryError, make_backend
36
+ from ..process.worker import MY_NOTES, SUMMARY, TRANSCRIPT, read_my_notes, rewrite_note
37
+
38
+ REC_ID = re.compile(r"^\d{8}-\d{6}-[A-Za-z0-9_.-]{1,64}$")
39
+ LABEL = re.compile(r"^(S\d{1,3}|Others)$")
40
+ MIX = "mix.ogg"
41
+ ASKS = "asks.json"
42
+ FOLLOWUP = "followup.json"
43
+ _WORD = re.compile(r"\w+", re.UNICODE)
44
+ # Ask: how much transcript a local model gets (its context is 8k tokens).
45
+ OLLAMA_ASK_CHARS = 18000
46
+ ALL_CALLS_CHARS_API = 120000
47
+
48
+ ASK_SYSTEM = """You answer questions about one call, using only its transcript.
49
+ "Me" is the person asking you; other speakers appear by name or as "Speaker N".
50
+ If the transcript does not contain the answer, say so plainly.
51
+ Answer briefly, in the language of the question.
52
+ citations: the [HH:MM:SS] timestamps of the transcript lines your answer rests on, \
53
+ at most 5, as "HH:MM:SS"."""
54
+
55
+ ASK_SCHEMA = {"type": "object",
56
+ "properties": {"answer": {"type": "string"},
57
+ "citations": {"type": "array", "items": {"type": "string"}}},
58
+ "required": ["answer", "citations"], "additionalProperties": False}
59
+
60
+ ASK_ALL_SYSTEM = """You answer questions about the person's past calls, using only the \
61
+ excerpts given. Each call has an id like C3; each excerpt line starts with [C3 HH:MM:SS].
62
+ "Me" is the person asking you; other speakers appear by name or as "Speaker N".
63
+ If the excerpts do not contain the answer, say so plainly. Mention which call (title, \
64
+ date) an answer comes from. Answer briefly, in the language of the question.
65
+ citations: the labels of the lines your answer rests on, at most 6, as "C3 HH:MM:SS"."""
66
+
67
+ FOLLOWUP_SYSTEM = """You write a short follow-up email after a call, from its notes.
68
+ Write it in the language of the notes, friendly and plain, as the person who \
69
+ recorded the call ("Me" in the notes means the writer, so write "I").
70
+ Structure: greeting; one or two sentences on what the call covered; decisions; \
71
+ action items with owner and due date where known; a short closing line such as "Best,".
72
+ Do not add a name or "Me" after the closing. Do not invent anything that is not in \
73
+ the notes, in particular no deadlines that are not written there.
74
+ subject: a short subject line."""
75
+
76
+ # Settings the UI may change; everything else stays
77
+ # in config.toml.
78
+ EDITABLE = ("consent_line", "summary_backend", "ollama_model", "anthropic_model",
79
+ "whisper_model", "whisper_language", "summary_language")
80
+ BACKENDS = ("ollama", "anthropic", "none")
81
+ MAX_SETTING = 500
82
+ CALENDAR_LINK = re.compile(r"^(https|webcal)://\S{1,2000}$", re.IGNORECASE)
83
+
84
+ CLOUD_DISCLOSURE = ("With Claude on, these go to Anthropic: the transcript of each call (in "
85
+ "parts), the calendar title and invitees, your notes, your vocabulary, "
86
+ "questions you ask with the transcript, and for Ask across meetings up to "
87
+ "120,000 characters from all your calls.")
88
+
89
+ FOLLOWUP_SCHEMA = {"type": "object",
90
+ "properties": {"subject": {"type": "string"}, "body": {"type": "string"}},
91
+ "required": ["subject", "body"], "additionalProperties": False}
92
+
93
+
94
+ class NotFound(Exception):
95
+ pass
96
+
97
+
98
+ class Invalid(Exception):
99
+ pass
100
+
101
+
102
+ def _read_json(path: Path, default=None):
103
+ try:
104
+ return json.loads(path.read_text())
105
+ except (OSError, ValueError):
106
+ return default
107
+
108
+
109
+ def _write_json(path: Path, data) -> None:
110
+ tmp = path.with_suffix(path.suffix + ".tmp")
111
+ tmp.write_text(json.dumps(data, indent=2, ensure_ascii=False))
112
+ tmp.replace(path)
113
+
114
+
115
+ def _words(text: str) -> set[str]:
116
+ return {w for w in _WORD.findall(text.lower()) if len(w) > 2}
117
+
118
+
119
+ def _stems(text: str) -> set[str]:
120
+ """Words cut to 5 letters, so German inflections (Release/Releases) still match."""
121
+ return {w[:5] for w in _words(text)}
122
+
123
+
124
+ def poke_daemon(command: str) -> None:
125
+ """Tell the daemon something changed; it is fine if it is not running."""
126
+ from ..tools.setup import _poke_daemon
127
+
128
+ _poke_daemon(command)
129
+
130
+
131
+ def _calendar_id(source: str) -> str:
132
+ return hashlib.sha256(source.encode()).hexdigest()[:12]
133
+
134
+
135
+ def _secrets() -> dict:
136
+ try:
137
+ return load_secrets()
138
+ except ValueError as e:
139
+ raise Invalid("secrets.toml cannot be read; fix or delete it") from e
140
+
141
+
142
+ def _now() -> str:
143
+ return dt.datetime.now().astimezone().isoformat(timespec="seconds")
144
+
145
+
146
+ class Store:
147
+ def __init__(self, settings: Settings, state_dir: Path, voices: Voices | None = None) -> None:
148
+ self.settings = settings
149
+ self.root = settings.recordings_path()
150
+ self.state_dir = state_dir
151
+ self.jobs = Jobs(state_dir / "jobs.db")
152
+ self.voices = voices or Voices()
153
+ self._jobs_lock = threading.Lock()
154
+ self._mix_locks: dict[str, threading.Lock] = {}
155
+ self._upcoming: tuple[float, list] = (0.0, [])
156
+
157
+ # Paths -----------------------------------------------------------------
158
+
159
+ def rec_dir(self, rec_id: str) -> Path:
160
+ if not REC_ID.match(rec_id or ""):
161
+ raise NotFound(rec_id)
162
+ d = self.root / rec_id
163
+ if not (d / "manifest.json").is_file():
164
+ raise NotFound(rec_id)
165
+ return d
166
+
167
+ def _note(self, manifest: dict) -> Path | None:
168
+ return own_note(manifest, self.settings.notes_path())
169
+
170
+ def _rec_dirs(self) -> list[Path]:
171
+ if not self.root.is_dir():
172
+ return []
173
+ return [d for d in sorted(self.root.iterdir(), reverse=True)
174
+ if (d / "manifest.json").is_file()]
175
+
176
+ # Status ----------------------------------------------------------------
177
+
178
+ def status(self) -> dict:
179
+ st = _read_json(self.state_dir / "status.json", {}) or {}
180
+ pid = st.get("pid")
181
+ st["alive"] = bool(st.get("running") and pid and Path(f"/proc/{pid}").exists())
182
+ return st
183
+
184
+ # Listing ---------------------------------------------------------------
185
+
186
+ def _job_state(self, d: Path) -> tuple[str | None, str | None]:
187
+ with self._jobs_lock:
188
+ job = self.jobs.by_recording(d)
189
+ if job is None:
190
+ return None, None
191
+ return job.state, (job.error.strip().splitlines()[-1] if job.error else None)
192
+
193
+ def _summary(self, d: Path) -> dict | None:
194
+ return _read_json(d / SUMMARY)
195
+
196
+ def _transcript(self, d: Path) -> dict:
197
+ return _read_json(d / TRANSCRIPT, {}) or {}
198
+
199
+ def _title(self, m: dict, summary: dict | None) -> str:
200
+ if m.get("title_override"):
201
+ return m["title_override"]
202
+ return title_for(m, Summary(title=(summary or {}).get("title", "")))
203
+
204
+ def _brief(self, d: Path, recording_id: str | None) -> dict:
205
+ m = read_manifest(d)
206
+ s = self._summary(d)
207
+ state, error = self._job_state(d)
208
+ if recording_id == m["id"]:
209
+ state = "recording"
210
+ segs = m.get("segments", [])
211
+ duration = max((x.get("end_offset_s") or 0.0 for x in segs), default=0.0)
212
+ actions = (s or {}).get("action_items", [])
213
+ cal = m.get("calendar") or {}
214
+ speakers = self._transcript(d).get("speakers", {})
215
+ return {"id": m["id"], "title": self._title(m, s), "started": m["started"],
216
+ "duration_s": round(duration), "app": m.get("app"), "name": m.get("name"),
217
+ "service": m.get("service"), "room": m.get("room"),
218
+ "calendar": cal.get("title"), "attendees": cal.get("attendees") or [],
219
+ "emails": cal.get("emails") or [],
220
+ "speakers": [display_name(l, speakers) for l in sorted(speakers)],
221
+ "state": state, "error": error, "has_summary": s is not None,
222
+ "has_transcript": (d / TRANSCRIPT).exists(),
223
+ "summary_error": m.get("summary_error"),
224
+ "summary_backend": m.get("summary_backend"),
225
+ "actions": len(actions),
226
+ "open_actions": sum(1 for a in actions if not a.get("done")),
227
+ "my_open_actions": sum(1 for a in actions
228
+ if not a.get("done") and a.get("owner") == "Me"),
229
+ "preview": ((s or {}).get("summary") or [""])[0]}
230
+
231
+ def calls(self, query: str = "") -> list[dict]:
232
+ recording_id = self.status().get("recording_id")
233
+ q = query.strip().lower()
234
+ out = []
235
+ for d in self._rec_dirs():
236
+ try:
237
+ brief = self._brief(d, recording_id)
238
+ except (OSError, ValueError, KeyError):
239
+ continue
240
+ if q:
241
+ hit = self._search(d, brief, q)
242
+ if hit is None:
243
+ continue
244
+ brief["match"] = hit
245
+ out.append(brief)
246
+ return out
247
+
248
+ def _search(self, d: Path, brief: dict, q: str) -> dict | None:
249
+ if q in brief["title"].lower():
250
+ return {"where": "title", "text": brief["title"]}
251
+ for name in brief["speakers"] + brief["attendees"]:
252
+ if q in name.lower():
253
+ return {"where": "people", "text": name}
254
+ s = self._summary(d) or {}
255
+ for key in ("summary", "decisions", "open_questions"):
256
+ for item in s.get(key, []):
257
+ if q in item.lower():
258
+ return {"where": key, "text": item}
259
+ for a in s.get("action_items", []):
260
+ if q in a.get("task", "").lower():
261
+ return {"where": "action_items", "text": a["task"]}
262
+ if q in read_my_notes(d).lower():
263
+ return {"where": "my_notes", "text": read_my_notes(d)[:140]}
264
+ for line in self._transcript(d).get("lines", []):
265
+ text = line.get("text", "")
266
+ i = text.lower().find(q)
267
+ if i >= 0:
268
+ lo, hi = max(0, i - 60), min(len(text), i + len(q) + 80)
269
+ snippet = ("…" if lo else "") + text[lo:hi] + ("…" if hi < len(text) else "")
270
+ return {"where": "transcript", "text": snippet, "start": line.get("start"),
271
+ "speaker": line.get("speaker")}
272
+ return None
273
+
274
+ # One call --------------------------------------------------------------
275
+
276
+ def call(self, rec_id: str) -> dict:
277
+ d = self.rec_dir(rec_id)
278
+ brief = self._brief(d, self.status().get("recording_id"))
279
+ m = read_manifest(d)
280
+ tr = self._transcript(d)
281
+ speakers = tr.get("speakers", {})
282
+ note = self._note(m)
283
+ has_audio = any(d.glob("*.wav"))
284
+ lines = tr.get("lines", [])
285
+ talk: dict[str, float] = {}
286
+ for l in lines:
287
+ talk[l["speaker"]] = talk.get(l["speaker"], 0.0) + max(0.0, l["end"] - l["start"])
288
+ people = [{"label": label, "name": display_name(label, speakers),
289
+ "named": bool((speakers.get(label) or {}).get("name")),
290
+ "source": (speakers.get(label) or {}).get("source"),
291
+ "evidence": (speakers.get(label) or {}).get("evidence", ""),
292
+ "seconds": round(talk.get(label, 0.0)),
293
+ "can_remember": bool((speakers.get(label) or {}).get("embedding"))}
294
+ for label in sorted(talk, key=lambda l: (l != "Me", -talk[l]))]
295
+ return {**brief,
296
+ "summary": self._summary(d),
297
+ "lines": lines,
298
+ "people": people,
299
+ "names": {p["label"]: p["name"] for p in people},
300
+ "runs": tr.get("runs", []),
301
+ "echo_dropped": len(tr.get("dropped_echo", [])),
302
+ "transcript_model": tr.get("model"),
303
+ "note": str(note) if note and note.exists() else None,
304
+ "has_audio": has_audio,
305
+ "audio_deleted": m.get("audio_deleted"),
306
+ "end_reason": m.get("end_reason"),
307
+ "my_notes": read_my_notes(d),
308
+ "followup": _read_json(d / FOLLOWUP),
309
+ "asks": _read_json(d / ASKS, []) or [],
310
+ "known_voices": [v["name"] for v in self.voices.names()]}
311
+
312
+ def rename(self, rec_id: str, title: str) -> None:
313
+ d = self.rec_dir(rec_id)
314
+ title = title.strip()[:120]
315
+ m = read_manifest(d)
316
+ if title:
317
+ m["title_override"] = title
318
+ else:
319
+ m.pop("title_override", None)
320
+ write_manifest(d, m)
321
+
322
+ def set_action_done(self, rec_id: str, index: int, done: bool) -> None:
323
+ d = self.rec_dir(rec_id)
324
+ s = self._summary(d)
325
+ if not s or not 0 <= index < len(s.get("action_items", [])):
326
+ raise Invalid("no such action item")
327
+ item = s["action_items"][index]
328
+ item["done"] = bool(done)
329
+ _write_json(d / SUMMARY, s)
330
+ note = self._note(read_manifest(d))
331
+ if note and note.exists():
332
+ _tick_in_note(note, item, done)
333
+
334
+ def save_my_notes(self, rec_id: str, text: str) -> None:
335
+ d = self.rec_dir(rec_id)
336
+ text = text[:20000]
337
+ (d / MY_NOTES).write_text(text)
338
+ state, _ = self._job_state(d)
339
+ if state == "done" and read_manifest(d).get("note"):
340
+ rewrite_note(d, self.settings)
341
+
342
+ def rename_speaker(self, rec_id: str, label: str, name: str = "", remember: bool = False,
343
+ merge_into: str = "") -> None:
344
+ """Name a speaker (and optionally remember the voice), or merge two that are one person."""
345
+ d = self.rec_dir(rec_id)
346
+ if not LABEL.match(label or ""):
347
+ raise Invalid("bad speaker")
348
+ tr = self._transcript(d)
349
+ if not tr:
350
+ raise Invalid("no transcript yet")
351
+ speakers = tr.setdefault("speakers", {})
352
+ lines = [Line(**l) for l in tr.get("lines", [])]
353
+ if merge_into:
354
+ if not LABEL.match(merge_into) or merge_into == label:
355
+ raise Invalid("bad merge target")
356
+ lines = paragraphs([Line(merge_into if l.speaker == label else l.speaker,
357
+ l.start, l.end, l.text) for l in lines],
358
+ self.settings.merge_gap_s)
359
+ gone = speakers.pop(label, None) or {}
360
+ target = speakers.setdefault(merge_into, {"name": None, "source": None})
361
+ target["seconds"] = round((target.get("seconds") or 0) + (gone.get("seconds") or 0), 1)
362
+ if not target.get("name") and gone.get("name"):
363
+ target.update(name=gone["name"], source=gone.get("source"))
364
+ tr["lines"] = [l.as_dict() for l in lines]
365
+ else:
366
+ name = name.strip()[:80]
367
+ info = speakers.setdefault(label, {})
368
+ for other, o in speakers.items():
369
+ if other != label and name and (o.get("name") or "").lower() == name.lower():
370
+ raise Invalid(f"{name} is already {display_name(other, speakers)}; "
371
+ "merge the two instead")
372
+ info.update(name=name or None, source="you" if name else None, evidence="")
373
+ if remember and name and info.get("embedding"):
374
+ self.voices.remember(name, info["embedding"])
375
+ _write_json(d / TRANSCRIPT, tr)
376
+ if read_manifest(d).get("note"):
377
+ rewrite_note(d, self.settings)
378
+
379
+ # Processing ------------------------------------------------------------
380
+
381
+ def requeue(self, rec_id: str, summary_only: bool) -> int:
382
+ d = self.rec_dir(rec_id)
383
+ if not summary_only and not any(d.glob("*.wav")):
384
+ raise Invalid("the audio was deleted; only the summary can be redone")
385
+ if summary_only and not (d / TRANSCRIPT).exists():
386
+ raise Invalid("no transcript yet")
387
+ with self._jobs_lock:
388
+ return self.jobs.add(d, summary_only=summary_only)
389
+
390
+ def _backend(self):
391
+ try:
392
+ backend = make_backend(self.settings)
393
+ except SummaryError as e:
394
+ raise Invalid(str(e)) from e
395
+ if backend is None:
396
+ raise Invalid('no AI backend (summary_backend = "none")')
397
+ return backend
398
+
399
+ def _complete(self, backend, system: str, user: str, schema: dict) -> dict:
400
+ try:
401
+ return backend.complete_json(system, user, schema)
402
+ except SummaryError as e:
403
+ raise Invalid(str(e)) from e
404
+ except (ValueError, KeyError) as e:
405
+ raise Invalid(f"{backend.name} gave an unusable answer: {e}") from e
406
+
407
+ def ask(self, rec_id: str, question: str) -> dict:
408
+ question = question.strip()
409
+ if not question:
410
+ raise Invalid("empty question")
411
+ d = self.rec_dir(rec_id)
412
+ tr = self._transcript(d)
413
+ lines = [Line(**l) for l in tr.get("lines", [])]
414
+ if not lines:
415
+ raise Invalid("no transcript yet")
416
+ backend = self._backend()
417
+ names = {l: display_name(l, tr.get("speakers", {})) for l in tr.get("speakers", {})}
418
+ limit = OLLAMA_ASK_CHARS if backend.name.startswith("ollama:") else None
419
+ excerpt, partial = _relevant(lines, question, limit, names)
420
+ head = "Transcript excerpts (most relevant parts):" if partial else "Transcript:"
421
+ data = self._complete(backend, ASK_SYSTEM, f"{head}\n{excerpt}\n\nQuestion: {question}",
422
+ ASK_SCHEMA)
423
+ cites = [c for c in data.get("citations", []) if re.match(r"^\d\d:\d\d:\d\d$", str(c))]
424
+ entry = {"t": _now(), "question": question, "answer": str(data.get("answer", "")).strip(),
425
+ "citations": [_seconds(c) for c in cites][:5], "backend": backend.name,
426
+ "partial": partial}
427
+ asks = _read_json(d / ASKS, []) or []
428
+ asks.append(entry)
429
+ _write_json(d / ASKS, asks[-50:])
430
+ return entry
431
+
432
+ def followup(self, rec_id: str) -> dict:
433
+ d = self.rec_dir(rec_id)
434
+ s = self._summary(d)
435
+ if not s:
436
+ raise Invalid("no summary yet; the follow-up is written from it")
437
+ m = read_manifest(d)
438
+ backend = self._backend()
439
+ notes = [f"Call: {self._title(m, s)} ({m['started'][:16].replace('T', ' ')})"]
440
+ cal = m.get("calendar") or {}
441
+ if cal.get("attendees"):
442
+ notes.append("Invited: " + ", ".join(cal["attendees"]))
443
+ notes.append("Summary:\n" + "\n".join(f"- {x}" for x in s.get("summary", [])))
444
+ if s.get("decisions"):
445
+ notes.append("Decisions:\n" + "\n".join(f"- {x}" for x in s["decisions"]))
446
+ if s.get("action_items"):
447
+ notes.append("Action items:\n" + "\n".join(
448
+ f"- {a['owner']}: {a['task']}"
449
+ + (f" (due {a['due']})" if a.get("due") else " (no deadline given)")
450
+ for a in s["action_items"]))
451
+ if s.get("open_questions"):
452
+ notes.append("Open questions:\n" + "\n".join(f"- {x}" for x in s["open_questions"]))
453
+ if read_my_notes(d):
454
+ notes.append("My own notes:\n" + read_my_notes(d)[:3000])
455
+ data = self._complete(backend, FOLLOWUP_SYSTEM, "\n\n".join(notes), FOLLOWUP_SCHEMA)
456
+ body = re.sub(r"\n+\s*(Me|Ich|I)\s*$", "", str(data.get("body", "")).strip())
457
+ entry = {"t": _now(), "subject": str(data.get("subject", "")).strip(),
458
+ "body": body, "to": cal.get("emails") or [],
459
+ "backend": backend.name}
460
+ _write_json(d / FOLLOWUP, entry)
461
+ return entry
462
+
463
+ # Across calls ----------------------------------------------------------
464
+
465
+ def action_items(self) -> list[dict]:
466
+ out = []
467
+ for d in self._rec_dirs():
468
+ s = self._summary(d)
469
+ if not s:
470
+ continue
471
+ m = read_manifest(d)
472
+ title = self._title(m, s)
473
+ for i, a in enumerate(s.get("action_items", [])):
474
+ out.append({"call": m["id"], "title": title, "started": m["started"], "index": i,
475
+ "owner": a.get("owner", ""), "task": a.get("task", ""),
476
+ "due": a.get("due", ""), "done": bool(a.get("done"))})
477
+ return out
478
+
479
+ def ask_all(self, question: str) -> dict:
480
+ question = question.strip()
481
+ if not question:
482
+ raise Invalid("empty question")
483
+ backend = self._backend()
484
+ budget = OLLAMA_ASK_CHARS if backend.name.startswith("ollama:") else ALL_CALLS_CHARS_API
485
+ q = _stems(question)
486
+ calls, scored = [], []
487
+ for d in self._rec_dirs():
488
+ tr = self._transcript(d)
489
+ if not tr.get("lines"):
490
+ continue
491
+ m = read_manifest(d)
492
+ s = self._summary(d)
493
+ idx = len(calls)
494
+ names = {l: display_name(l, tr.get("speakers", {})) for l in tr.get("speakers", {})}
495
+ lines = [Line(**l) for l in tr["lines"]]
496
+ calls.append({"id": m["id"], "title": self._title(m, s), "started": m["started"],
497
+ "lines": lines, "names": names})
498
+ topic = _stems(self._title(m, s) + " " + " ".join((s or {}).get("summary", [])))
499
+ bonus = 0.5 * len(q & topic)
500
+ for i, l in enumerate(lines):
501
+ hits = len(q & _stems(l.text))
502
+ if hits:
503
+ scored.append(((hits + bonus) / math.sqrt(len(_words(l.text)) + 4), idx, i))
504
+ if not calls:
505
+ raise Invalid("no transcripts yet")
506
+ scored.sort(reverse=True)
507
+ keep: dict[int, set[int]] = {}
508
+ size = 0
509
+ overview = "\n".join(f"C{i + 1} = {c['title']} ({c['started'][:16].replace('T', ' ')})"
510
+ for i, c in enumerate(calls[:60]))
511
+ size += len(overview)
512
+ for _, ci, li in scored:
513
+ for j in (li - 1, li, li + 1):
514
+ if 0 <= j < len(calls[ci]["lines"]) and j not in keep.get(ci, set()):
515
+ cost = len(calls[ci]["lines"][j].text) + 40
516
+ if size + cost > budget:
517
+ break
518
+ keep.setdefault(ci, set()).add(j)
519
+ size += cost
520
+ if size >= budget * 0.95:
521
+ break
522
+ parts = [f"Calls:\n{overview}"]
523
+ for ci in sorted(keep):
524
+ c = calls[ci]
525
+ body = "\n".join(
526
+ f"[C{ci + 1} {stamp(c['lines'][j].start)}] "
527
+ f"{c['names'].get(c['lines'][j].speaker, c['lines'][j].speaker)}: {c['lines'][j].text}"
528
+ for j in sorted(keep[ci]))
529
+ parts.append(f"C{ci + 1} {c['title']}:\n{body}")
530
+ if not keep:
531
+ parts.append("(No transcript line matched the question's words.)")
532
+ data = self._complete(backend, ASK_ALL_SYSTEM,
533
+ "\n\n".join(parts) + f"\n\nQuestion: {question}", ASK_SCHEMA)
534
+ cites = []
535
+ for c in data.get("citations", []):
536
+ mt = re.match(r"^C(\d+)\s+(\d\d:\d\d:\d\d)$", str(c).strip())
537
+ if mt and 1 <= int(mt.group(1)) <= len(calls):
538
+ call = calls[int(mt.group(1)) - 1]
539
+ cites.append({"call": call["id"], "title": call["title"],
540
+ "t": _seconds(mt.group(2))})
541
+ entry = {"t": _now(), "question": question, "answer": str(data.get("answer", "")).strip(),
542
+ "citations": cites[:6], "backend": backend.name,
543
+ "searched": len(calls), "used": len(keep)}
544
+ path = self.state_dir / ASKS
545
+ asks = _read_json(path, []) or []
546
+ asks.append(entry)
547
+ _write_json(path, asks[-100:])
548
+ return entry
549
+
550
+ def asks(self) -> list[dict]:
551
+ return _read_json(self.state_dir / ASKS, []) or []
552
+
553
+ # Settings --------------------------------------------------------------
554
+
555
+ def settings_info(self) -> dict:
556
+ s = self.settings
557
+ try:
558
+ key_set = bool(load_secrets().get("anthropic_api_key"))
559
+ except ValueError:
560
+ key_set = False
561
+ return {"summary_backend": s.summary_backend,
562
+ "model": s.ollama_model if s.summary_backend == "ollama" else s.anthropic_model,
563
+ "whisper_model": s.whisper_model, "diarize": s.diarize,
564
+ "audio_retention_days": s.audio_retention_days,
565
+ "notes_dir": str(s.notes_path()),
566
+ "vocabulary": load_vocabulary(), "voices": self.voices.names(),
567
+ "calendars": self.calendars(),
568
+ "editable": {k: getattr(s, k) for k in EDITABLE},
569
+ "anthropic_key_set": key_set or bool(os.environ.get("ANTHROPIC_API_KEY")),
570
+ "cloud_disclosure": CLOUD_DISCLOSURE}
571
+
572
+ def update_settings(self, changes: dict, confirm_cloud: bool) -> dict:
573
+ """Change some of EDITABLE in config.toml; the file is untouched unless all are valid."""
574
+ if not isinstance(changes, dict) or not changes:
575
+ raise Invalid("nothing to change")
576
+ unknown = sorted(set(changes) - set(EDITABLE))
577
+ if unknown:
578
+ raise Invalid(f"cannot change {', '.join(unknown)} here")
579
+ for k, v in changes.items():
580
+ if not isinstance(v, str) or len(v) > MAX_SETTING:
581
+ raise Invalid(f"{k}: expected text of at most {MAX_SETTING} characters")
582
+ if k != "consent_line" and not v.isprintable():
583
+ raise Invalid(f"{k}: one line of plain text")
584
+ backend = changes.get("summary_backend")
585
+ if backend is not None and backend not in BACKENDS:
586
+ raise Invalid(f"summary_backend: one of {', '.join(BACKENDS)}")
587
+ if backend == "anthropic" and self.settings.summary_backend != "anthropic" \
588
+ and not confirm_cloud:
589
+ raise Invalid("confirm what is sent to Claude first")
590
+ path = config_dir() / "config.toml"
591
+ text = path.read_text() if path.exists() else DEFAULT_CONFIG_TOML
592
+ for k, v in changes.items():
593
+ text = with_config_value(text, k, json.dumps(v, ensure_ascii=False))
594
+ try:
595
+ new = parse_settings(text)
596
+ except ValueError as e:
597
+ raise Invalid(f"config.toml would not be valid: {e}") from e
598
+ write_config_atomic(path, text)
599
+ self.settings = dataclasses.replace(self.settings,
600
+ **{k: getattr(new, k) for k in changes})
601
+ poke_daemon("reload-settings")
602
+ return self.settings_info()
603
+
604
+ def set_api_key(self, key: str) -> dict:
605
+ """Store the Claude API key in secrets.toml; it is never sent back to the page."""
606
+ key = str(key).strip()
607
+ if not 20 <= len(key) <= 300 or not key.isascii() or not key.isprintable() \
608
+ or any(c.isspace() for c in key):
609
+ raise Invalid("that does not look like an API key")
610
+ secrets = _secrets()
611
+ secrets["anthropic_api_key"] = key
612
+ save_secrets(secrets)
613
+ return {"set": True}
614
+
615
+ def calendars(self) -> list[dict]:
616
+ """Calendars by id and host or file name; links are secret and never shown."""
617
+ from ..calendar import ics
618
+
619
+ try:
620
+ links = set(load_secrets().get("calendar_urls", []))
621
+ except ValueError:
622
+ links = set()
623
+ return [{"id": _calendar_id(src), "label": ics.source_label(src),
624
+ "kind": "link" if src in links else "file"}
625
+ for src in calendar_sources(self.settings.calendar_urls)]
626
+
627
+ def add_calendar(self, url: str) -> list[dict]:
628
+ from ..calendar import ics
629
+
630
+ url = str(url).strip()
631
+ if not CALENDAR_LINK.match(url):
632
+ raise Invalid("paste the calendar's https:// or webcal:// ICS link")
633
+ try:
634
+ ics.parse(ics.fetch(url), dt.datetime.now().astimezone())
635
+ except Exception as e: # noqa: BLE001 - say what failed, without the link
636
+ raise Invalid(f"could not read that calendar: {ics.describe_error(url, e)}") from e
637
+ secrets = _secrets()
638
+ secrets["calendar_urls"] = list(dict.fromkeys([*secrets.get("calendar_urls", []), url]))
639
+ save_secrets(secrets)
640
+ self._upcoming = (0.0, [])
641
+ poke_daemon("refresh-calendar")
642
+ return self.calendars()
643
+
644
+ def remove_calendar(self, cal_id: str) -> list[dict]:
645
+ secrets = _secrets()
646
+ links = secrets.get("calendar_urls", [])
647
+ keep = [u for u in links if _calendar_id(u) != cal_id]
648
+ if len(keep) == len(links):
649
+ if any(_calendar_id(u) == cal_id for u in self.settings.calendar_urls):
650
+ raise Invalid("this calendar is set in config.toml; remove it there")
651
+ raise NotFound("calendar")
652
+ secrets["calendar_urls"] = keep
653
+ save_secrets(secrets)
654
+ self._upcoming = (0.0, [])
655
+ poke_daemon("refresh-calendar")
656
+ return self.calendars()
657
+
658
+ def doctor(self) -> dict:
659
+ from .. import __version__
660
+ from ..tools import setup
661
+
662
+ return {"checks": [dataclasses.asdict(c) for c in setup.checks(self.settings)],
663
+ "version": __version__}
664
+
665
+ def set_vocabulary(self, terms: list[str]) -> list[str]:
666
+ return save_vocabulary([str(t) for t in terms])
667
+
668
+ def forget_voice(self, name: str) -> None:
669
+ if not self.voices.forget(name):
670
+ raise NotFound(name)
671
+
672
+ # Files -----------------------------------------------------------------
673
+
674
+ def mixed_audio(self, rec_id: str) -> Path:
675
+ """Both tracks mixed into one Opus file, made on first use and cached."""
676
+ d = self.rec_dir(rec_id)
677
+ m = read_manifest(d)
678
+ segs = [s for s in m.get("segments", [])
679
+ if (f := segment_file(d, s)) and f.exists() and f.stat().st_size > 44]
680
+ if not segs:
681
+ raise NotFound("no audio")
682
+ out = d / MIX
683
+ lock = self._mix_locks.setdefault(rec_id, threading.Lock())
684
+ with lock:
685
+ newest = max((d / s["file"]).stat().st_mtime for s in segs)
686
+ if out.exists() and out.stat().st_mtime >= newest:
687
+ return out
688
+ cmd = ["ffmpeg", "-nostdin", "-v", "error", "-y"]
689
+ for s in segs:
690
+ cmd += ["-i", str(d / s["file"])]
691
+ chains = [f"[{i}:a]adelay={int(s['start_offset_s'] * 1000)}:all=1[a{i}]"
692
+ for i, s in enumerate(segs)]
693
+ mix = "".join(f"[a{i}]" for i in range(len(segs)))
694
+ graph = ";".join(chains) + f";{mix}amix=inputs={len(segs)}:normalize=0[out]"
695
+ tmp = d / (MIX + ".tmp.ogg")
696
+ cmd += ["-filter_complex", graph, "-map", "[out]", "-ac", "1",
697
+ "-c:a", "libopus", "-b:a", "32k", str(tmp)]
698
+ subprocess.run(cmd, check=True, timeout=600)
699
+ tmp.replace(out)
700
+ return out
701
+
702
+ def delete_audio(self, rec_id: str) -> None:
703
+ d = self.rec_dir(rec_id)
704
+ if self.status().get("recording_id") == rec_id:
705
+ raise Invalid("still recording")
706
+ for f in [*d.glob("*.wav"), d / MIX]:
707
+ f.unlink(missing_ok=True)
708
+ m = read_manifest(d)
709
+ m["audio_deleted"] = _now()
710
+ write_manifest(d, m)
711
+
712
+ def delete_call(self, rec_id: str) -> None:
713
+ d = self.rec_dir(rec_id)
714
+ if self.status().get("recording_id") == rec_id:
715
+ raise Invalid("still recording")
716
+ state, _ = self._job_state(d)
717
+ if state in ("transcribing", "summarizing"):
718
+ raise Invalid("a note is being written for this call; try again when it is done")
719
+ note = self._note(read_manifest(d))
720
+ if note:
721
+ note.unlink(missing_ok=True)
722
+ shutil.rmtree(d)
723
+ with self._jobs_lock:
724
+ self.jobs.delete(d)
725
+
726
+ def note_path(self, rec_id: str) -> Path:
727
+ note = self._note(read_manifest(self.rec_dir(rec_id)))
728
+ if not note or not note.exists():
729
+ raise NotFound("no note")
730
+ return note
731
+
732
+ # Calendar --------------------------------------------------------------
733
+
734
+ def upcoming(self) -> list[dict]:
735
+ from ..calendar import ics
736
+
737
+ fetched, events = self._upcoming
738
+ if not fetched or time.monotonic() - fetched >= self.settings.calendar_refresh_s:
739
+ sources = calendar_sources(self.settings.calendar_urls)
740
+ events = ics.load(sources, dt.datetime.now().astimezone())[0] if sources else []
741
+ self._upcoming = (time.monotonic(), events)
742
+ now = dt.datetime.now().astimezone()
743
+ soon = [e for e in events if e.end > now and e.start < now + dt.timedelta(hours=18)]
744
+ return [{"title": e.title, "start": e.start.isoformat(), "end": e.end.isoformat(),
745
+ "attendees": list(e.attendees), "call": bool(e.service_links(None))}
746
+ for e in sorted(soon, key=lambda e: e.start)[:8]]
747
+
748
+
749
+ def _seconds(hhmmss: str) -> float:
750
+ h, m, s = (int(x) for x in hhmmss.split(":"))
751
+ return float(h * 3600 + m * 60 + s)
752
+
753
+
754
+ def _relevant(lines: list[Line], question: str, limit: int | None,
755
+ names: dict[str, str] | None = None) -> tuple[str, bool]:
756
+ """The whole transcript if it fits, else the paragraphs that share the most
757
+ words with the question (with their neighbours), in time order."""
758
+ full = as_text(lines, names)
759
+ if limit is None or len(full) <= limit:
760
+ return full, False
761
+ q = _stems(question)
762
+ ranked = sorted(range(len(lines)), key=lambda i: -len(q & _stems(lines[i].text)))
763
+ keep: set[int] = set()
764
+ size = 0
765
+ for i in ranked:
766
+ for j in (i - 1, i, i + 1):
767
+ if 0 <= j < len(lines) and j not in keep:
768
+ cost = len(lines[j].text) + 30
769
+ if size + cost > limit:
770
+ break
771
+ keep.add(j)
772
+ size += cost
773
+ if size >= limit * 0.95:
774
+ break
775
+ return as_text([lines[i] for i in sorted(keep)], names), True
776
+
777
+
778
+ def _tick_in_note(note: Path, item: dict, done: bool) -> None:
779
+ args = (item["owner"], item["task"], item.get("due", ""))
780
+ old, new = action_line(*args, not done), action_line(*args, done)
781
+ text = note.read_text()
782
+ if old in text:
783
+ note.write_text(text.replace(old, new, 1))