diff --git a/README.md b/README.md
index 3b678ed..85ac677 100644
--- a/README.md
+++ b/README.md
@@ -31,6 +31,8 @@ Usable end to end: record or import a recording, get a transcript with speakers
agent-drafted summary you approve, tasks on a board, questions answered from the vault with
citations, live translation of whatever is playing, dubbing, notes, calendars, comments, a roster of
agents you edit as files, and encrypted sync between machines through any folder you both can reach.
+Subscribe to a calendar by URL and it stays current, prompts before a meeting starts — it never
+records on its own — and drafts the follow-up email afterwards, which you send yourself.
**Not done:** mobile is scaffolded and has never been compiled, and the hosted sync relay is not
built — sync works today through any shared folder instead.
@@ -39,13 +41,13 @@ The numbers below are measured on this codebase, not estimated. Each is reproduc
command shown; see [`docs/benchmarks.md`](docs/benchmarks.md) and
[`docs/translation.md`](docs/translation.md) for the full method and caveats.
-| Claim | Measured | Source |
-|---|---|---|
-| Vietnamese recognition accuracy | 8.5 % WER, 6.7 % CER (`gipformer-65M`, 100 FLEURS VI clips, 21.3 min; 5.3 % on the 84 clips whose reference contains no digits) | `cargo run --release -p summo-bench --features asr -- asr` |
-| Live pipeline speed | RTF 0.107, roughly 9× faster than realtime (raw mic capture) | `docs/benchmarks.md`, end-to-end pipeline section — two short single-mic captures, not yet WER-scored |
-| Voice activity detection | Silero v5, F1 0.940 (precision 0.925, recall 0.956) | `cargo run --release -p summo-bench --features silero -- vad --sweep` |
-| Finding a meeting without an index | ~30 ms across 1,000 meetings (8-thread scan), which is why there is no database | `cargo run --release -p summo-bench -- vault --sizes 100,1000,5000` |
-| Translating a line | ~244 ms/line, 8 threads, with the default 583 MB `small100` model — in the released binary, with no model server to run | `cargo run -p summo-mt --features local,onnx --example compare` |
+| Claim | Measured | Source |
+| ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- |
+| Vietnamese recognition accuracy | 8.5 % WER, 6.7 % CER (`gipformer-65M`, 100 FLEURS VI clips, 21.3 min; 5.3 % on the 84 clips whose reference contains no digits) | `cargo run --release -p summo-bench --features asr -- asr` |
+| Live pipeline speed | RTF 0.107, roughly 9× faster than realtime (raw mic capture) | `docs/benchmarks.md`, end-to-end pipeline section — two short single-mic captures, not yet WER-scored |
+| Voice activity detection | Silero v5, F1 0.940 (precision 0.925, recall 0.956) | `cargo run --release -p summo-bench --features silero -- vad --sweep` |
+| Finding a meeting without an index | ~30 ms across 1,000 meetings (8-thread scan), which is why there is no database | `cargo run --release -p summo-bench -- vault --sizes 100,1000,5000` |
+| Translating a line | ~244 ms/line, 8 threads, with the default 583 MB `small100` model — in the released binary, with no model server to run | `cargo run -p summo-mt --features local,onnx --example compare` |
## Install and run
@@ -76,6 +78,7 @@ real-time factor, licence — and you can disagree with it. Nothing is recorded
```bash
summo serve --port 8710 # a fixed port, when something else wants to find it
summo serve --no-open # a server, when there is no browser to open
+summo serve --background # run detached; `summo status` and `summo stop` from anywhere
summo import ~/Downloads/zoom-recording.mp4
summo mcp # the vault over MCP, for Claude Code or Cursor
```
@@ -105,7 +108,7 @@ Summo is never the distributor of a licence it cannot redistribute under.
Disconnect the machine from the network, then run `./summo serve` and record a meeting. Recognition,
voice-activity detection and speaker attribution keep working, because they never called out — there
-is no cloud-ASR fallback to fail over to. What you should *not* be able to do offline is get a summary
+is no cloud-ASR fallback to fail over to. What you should _not_ be able to do offline is get a summary
or a translation from a remote model you configured, since that is the one deliberate exception to
"nothing leaves the machine".
diff --git a/README.vi.md b/README.vi.md
index dd051b5..c48ca24 100644
--- a/README.vi.md
+++ b/README.vi.md
@@ -32,7 +32,8 @@ cơ sở dữ liệu của ai khác. Ba nguyên tắc sau đây theo từ đó,
một bản tóm tắt do agent soạn để bạn duyệt, việc cần làm trên bảng kanban, hỏi đáp trả lời từ kho dữ
liệu kèm trích dẫn, dịch trực tiếp nội dung đang phát, lồng tiếng (dubbing), ghi chú, lịch, bình
luận, một dàn agent bạn chỉnh sửa như file, và đồng bộ mã hoá giữa các máy qua bất kỳ thư mục dùng
-chung nào.
+chung nào. Đăng ký lịch bằng URL thì lịch luôn cập nhật, sắp tới giờ họp app hỏi có ghi chú không —
+không bao giờ tự ghi âm — và họp xong thì soạn sẵn thư gửi đi để bạn tự gửi.
**Chưa xong:** phần mobile mới ở dạng khung sườn và chưa từng biên dịch được, và relay đồng bộ trên
cloud (hosted sync relay) chưa được xây — hiện tại đồng bộ vẫn chạy qua một thư mục dùng chung.
@@ -41,13 +42,13 @@ Các con số dưới đây đều đo trên chính codebase này, không phải
bằng đúng lệnh ghi kèm; xem đầy đủ phương pháp và các lưu ý ở
[`docs/benchmarks.md`](docs/benchmarks.md) và [`docs/translation.md`](docs/translation.md).
-| Nhận định | Đo được | Nguồn |
-|---|---|---|
-| Độ chính xác nhận dạng tiếng Việt | 8,5 % WER, 6,7 % CER (`gipformer-65M`, 100 clip FLEURS VI, 21,3 phút; còn 5,3 % nếu bỏ các clip mà bản tham chiếu viết số bằng chữ số) | `cargo run --release -p summo-bench --features asr -- asr` |
-| Tốc độ pipeline chạy live | RTF 0,107, tức nhanh hơn thời gian thực khoảng 9 lần (ghi bằng mic thô) | `docs/benchmarks.md`, mục pipeline đầu-cuối — mới đo trên hai đoạn ghi ngắn từ một mic, chưa tính WER |
-| Voice activity detection (VAD) | Silero v5, F1 0,940 (precision 0,925, recall 0,956) | `cargo run --release -p summo-bench --features silero -- vad --sweep` |
-| Tìm một cuộc họp mà không cần index | ~30 ms trên 1.000 cuộc họp (scan 8 luồng) — đây cũng là lý do không có database | `cargo run --release -p summo-bench -- vault --sizes 100,1000,5000` |
-| Dịch một dòng | ~244 ms/dòng, 8 luồng, với model mặc định `small100` nặng 583 MB — chạy ngay trong binary phát hành, không cần dựng model server | `cargo run -p summo-mt --features local,onnx --example compare` |
+| Nhận định | Đo được | Nguồn |
+| ----------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- |
+| Độ chính xác nhận dạng tiếng Việt | 8,5 % WER, 6,7 % CER (`gipformer-65M`, 100 clip FLEURS VI, 21,3 phút; còn 5,3 % nếu bỏ các clip mà bản tham chiếu viết số bằng chữ số) | `cargo run --release -p summo-bench --features asr -- asr` |
+| Tốc độ pipeline chạy live | RTF 0,107, tức nhanh hơn thời gian thực khoảng 9 lần (ghi bằng mic thô) | `docs/benchmarks.md`, mục pipeline đầu-cuối — mới đo trên hai đoạn ghi ngắn từ một mic, chưa tính WER |
+| Voice activity detection (VAD) | Silero v5, F1 0,940 (precision 0,925, recall 0,956) | `cargo run --release -p summo-bench --features silero -- vad --sweep` |
+| Tìm một cuộc họp mà không cần index | ~30 ms trên 1.000 cuộc họp (scan 8 luồng) — đây cũng là lý do không có database | `cargo run --release -p summo-bench -- vault --sizes 100,1000,5000` |
+| Dịch một dòng | ~244 ms/dòng, 8 luồng, với model mặc định `small100` nặng 583 MB — chạy ngay trong binary phát hành, không cần dựng model server | `cargo run -p summo-mt --features local,onnx --example compare` |
## Cài đặt và chạy
@@ -78,6 +79,7 @@ gì được ghi lại cho tới khi bạn bấm nút ghi.
```bash
summo serve --port 8710 # cố định một port, khi có thứ khác cần tìm tới nó
+summo serve --background # chạy nền; `summo status` để xem, `summo stop` để dừng
summo serve --no-open # chạy server, khi không có trình duyệt nào để mở
summo import ~/Downloads/zoom-recording.mp4
summo mcp # đưa kho dữ liệu ra qua MCP, cho Claude Code hay Cursor
@@ -109,7 +111,7 @@ không có quyền phân phối lại.
Ngắt kết nối mạng của máy, rồi chạy `./summo serve` và ghi một cuộc họp. Nhận dạng giọng nói, VAD và
tách người nói vẫn chạy bình thường, vì chúng chưa bao giờ gọi ra ngoài — không có đường lùi nào sang
-ASR trên cloud để mà thất bại. Điều bạn *sẽ không* làm được khi offline là lấy một bản tóm tắt hay
+ASR trên cloud để mà thất bại. Điều bạn _sẽ không_ làm được khi offline là lấy một bản tóm tắt hay
bản dịch từ model từ xa mà bạn đã cấu hình — đó là ngoại lệ duy nhất, có chủ đích, của lời hứa "không
gì rời khỏi máy".
diff --git a/apps/web/e2e/calendar.mjs b/apps/web/e2e/calendar.mjs
new file mode 100644
index 0000000..ffc42ff
--- /dev/null
+++ b/apps/web/e2e/calendar.mjs
@@ -0,0 +1,109 @@
+/**
+ * Subscribing to a calendar, in the browser, against a real calendar server.
+ *
+ * The unit tests cover which URLs are refused and the daemon test covers the fetch. What neither
+ * can cover is the part that was actually missing for a year: the only way to add a calendar was to
+ * type the *path of a file*, which a browser cannot produce and which stops being true the day
+ * after it is exported. This drives the form a person uses.
+ *
+ * The calendar is served by a two-line HTTP server in this process, so the suite depends on no
+ * network and no account. That is also what makes it able to assert the failure path — the server
+ * is told to answer 404 and the row has to say so, because a subscription that quietly stopped
+ * working looks exactly like a week with no meetings.
+ */
+import { createServer } from "node:http";
+import { chromium } from "playwright";
+
+import { daemon } from "./daemon.mjs";
+
+const problems = [];
+
+// ---- a calendar server ----------------------------------------------------
+const stamp = (epoch) => new Date(epoch * 1000).toISOString().replace(/[-:]|\.\d{3}/g, "");
+const soon = Math.floor(Date.now() / 1000) + 3600;
+const ICS =
+ "BEGIN:VCALENDAR\r\nVERSION:2.0\r\n" +
+ `BEGIN:VEVENT\r\nUID:e2e-1\r\nSUMMARY:Họp chốt giá\r\nDTSTART:${stamp(soon)}\r\n` +
+ `DTEND:${stamp(soon + 1800)}\r\n` +
+ "ATTENDEE:mailto:ngoc@acme.vn\r\nATTENDEE:mailto:binh@acme.vn\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n";
+
+let serving = true;
+const calendars = createServer((request, response) => {
+ if (!serving) {
+ response.writeHead(404).end("gone");
+ return;
+ }
+ response.writeHead(200, { "content-type": "text/calendar" }).end(ICS);
+});
+await new Promise((resolve) => calendars.listen(0, "127.0.0.1", resolve));
+const address = `http://127.0.0.1:${calendars.address().port}/work.ics`;
+
+const engine = await daemon(process.argv, { name: "calendar" });
+const browser = await chromium.launch();
+const context = await browser.newContext({
+ locale: "vi-VN",
+ viewport: { width: 1280, height: 950 },
+});
+const page = await context.newPage();
+page.on("pageerror", (e) => problems.push(`pageerror: ${e.message}`));
+
+await page.goto(`${engine.url}?port=${engine.port}&token=${engine.token}#/agenda`, {
+ waitUntil: "networkidle",
+});
+
+// ---- subscribing ----------------------------------------------------------
+{
+ await page.getByLabel("Địa chỉ lịch (URL)").fill(address);
+ await page.getByLabel("Tên lịch").fill("Lịch công ty");
+ await page.getByRole("button", { name: "Đăng ký", exact: true }).click();
+
+ const row = page.getByTestId("calendar-list").getByText("Lịch công ty");
+ await row.waitFor({ timeout: 15000 }).catch(() => problems.push("the calendar was never listed"));
+
+ // The meeting from that calendar, on the agenda, without a reload.
+ await page
+ .getByText("Họp chốt giá")
+ .first()
+ .waitFor({ timeout: 15000 })
+ .catch(() => problems.push("the subscribed calendar's meeting never reached the agenda"));
+
+ const state = await page.getByTestId("calendar-list").innerText();
+ if (!/1 sự kiện/.test(state)) problems.push(`the row does not say what it holds: "${state}"`);
+}
+
+// ---- a subscription that stops working ------------------------------------
+{
+ serving = false;
+ await page.getByRole("button", { name: /Đồng bộ lại/ }).click();
+
+ const failed = page.getByText(/không tìm thấy lịch/);
+ await failed
+ .waitFor({ timeout: 15000 })
+ .catch(() => problems.push("a broken subscription reports nothing"));
+
+ // And the meetings it already fetched are still there: a laptop that woke up without WiFi should
+ // still show this morning's meetings.
+ if ((await page.getByText("Họp chốt giá").count()) === 0) {
+ problems.push("a failed refresh threw away the calendar it already had");
+ }
+}
+
+// ---- a URL that is not a calendar -----------------------------------------
+{
+ await page.getByLabel("Địa chỉ lịch (URL)").fill("file:///etc/passwd");
+ await page.getByRole("button", { name: "Đăng ký", exact: true }).click();
+ await page
+ .getByText(/phải bắt đầu bằng https/)
+ .waitFor({ timeout: 10000 })
+ .catch(() => problems.push("a file:// URL was not refused in the interface"));
+}
+
+await browser.close();
+await engine.stop();
+calendars.close();
+
+if (problems.length > 0) {
+ console.error(problems.map((p) => ` - ${p}`).join("\n"));
+ process.exit(1);
+}
+console.log("calendar ok");
diff --git a/apps/web/e2e/notes.mjs b/apps/web/e2e/notes.mjs
new file mode 100644
index 0000000..5352c6e
--- /dev/null
+++ b/apps/web/e2e/notes.mjs
@@ -0,0 +1,79 @@
+/**
+ * Starting a note that is already the right shape.
+ *
+ * A blank page is the right default and a poor only option: people were typing the same four sets
+ * of headings by hand — an idea, a decision, a list of things to do, a day's journal — and a note
+ * app that watched them do it and offered nothing is the one they stop opening.
+ *
+ * What this asserts is the part that would break silently: that choosing a kind puts its headings
+ * *in the file*, rather than into some hidden template state that a later edit or an export would
+ * lose.
+ */
+import { chromium } from "playwright";
+
+import { daemon } from "./daemon.mjs";
+
+const problems = [];
+const engine = await daemon(process.argv, { name: "notes" });
+const browser = await chromium.launch();
+const context = await browser.newContext({
+ locale: "vi-VN",
+ viewport: { width: 1280, height: 950 },
+});
+const page = await context.newPage();
+page.on("pageerror", (e) => problems.push(`pageerror: ${e.message}`));
+
+await page.goto(`${engine.url}?port=${engine.port}&token=${engine.token}#/notes`, {
+ waitUntil: "networkidle",
+});
+
+// ---- the kinds on offer ---------------------------------------------------
+await page.getByRole("button", { name: "Mới", exact: true }).click();
+const menu = page.getByTestId("note-kinds");
+await menu.waitFor({ timeout: 10000 }).catch(() => problems.push("no kinds were offered"));
+
+const offered = (await menu.innerText()).split("\n").filter(Boolean);
+if (offered.length < 5)
+ problems.push(`only ${offered.length} kinds offered: ${offered.join(", ")}`);
+if (!/Trống/.test(offered[0] ?? "")) {
+ problems.push(`a blank note should be first, got "${offered[0]}"`);
+}
+
+// ---- a decision note starts with a decision's headings --------------------
+await menu.getByRole("button", { name: "Quyết định" }).click();
+
+const body = page.getByLabel("Nội dung ghi chú");
+await body.waitFor({ timeout: 10000 });
+// The seed has to be in the editor, which is what proves it is in the file rather than a label.
+await page
+ .waitForFunction(
+ () => document.querySelector("textarea")?.value.includes("## Bối cảnh") ?? false,
+ { timeout: 10000 },
+ )
+ .catch(() => problems.push("the decision note did not start with a decision's headings"));
+
+// ---- and it survives a reload, because it was saved -----------------------
+{
+ const before = await body.inputValue();
+ // Typing is what triggers the save; a note created and never touched is allowed to be empty.
+ await body.click();
+ await body.press("End");
+ await body.type(" Ngọc chốt.");
+ await page.waitForTimeout(3000);
+ await page.reload({ waitUntil: "networkidle" });
+
+ const notes = await page.getByText("Quyết định").count();
+ if (notes === 0) problems.push("the note is not in the list after a reload");
+ if (!before.includes("## Quyết định")) {
+ problems.push(`the seed is missing its own heading: ${JSON.stringify(before.slice(0, 40))}`);
+ }
+}
+
+await browser.close();
+await engine.stop();
+
+if (problems.length > 0) {
+ console.error(problems.map((p) => ` - ${p}`).join("\n"));
+ process.exit(1);
+}
+console.log("notes ok");
diff --git a/apps/web/package.json b/apps/web/package.json
index c2c94d7..942dc53 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -9,7 +9,7 @@
"preview": "vite preview",
"test": "vitest run",
"tauri": "tauri",
- "e2e": "node e2e/shell.mjs && node e2e/library.mjs && node e2e/meeting.mjs && node e2e/tasks.mjs && node e2e/draft.mjs && node e2e/nudges.mjs && node e2e/chat.mjs && node e2e/languages.mjs && node e2e/overlap.mjs && node e2e/models.mjs && node e2e/search.mjs && node e2e/assistant.mjs && node e2e/language.mjs && node e2e/permissions.mjs && node e2e/density.mjs",
+ "e2e": "node e2e/shell.mjs && node e2e/library.mjs && node e2e/meeting.mjs && node e2e/tasks.mjs && node e2e/draft.mjs && node e2e/nudges.mjs && node e2e/chat.mjs && node e2e/languages.mjs && node e2e/overlap.mjs && node e2e/models.mjs && node e2e/search.mjs && node e2e/assistant.mjs && node e2e/language.mjs && node e2e/permissions.mjs && node e2e/calendar.mjs && node e2e/notes.mjs && node e2e/density.mjs",
"lint": "eslint . --max-warnings 0",
"format": "prettier --write .",
"format:check": "prettier --check .",
diff --git a/apps/web/src/components/agenda/Calendars.tsx b/apps/web/src/components/agenda/Calendars.tsx
new file mode 100644
index 0000000..f8f5fb3
--- /dev/null
+++ b/apps/web/src/components/agenda/Calendars.tsx
@@ -0,0 +1,249 @@
+import { RefreshCw } from "lucide-react";
+import { useCallback, useMemo, useState } from "react";
+
+import { useI18n } from "../../i18n/context";
+import { useEngine } from "../../lib/engine-context";
+import { useErrorText } from "../../lib/errors";
+import { pickFile } from "../../lib/imports";
+import { AgendaClient, type Calendars as CalendarList } from "../../lib/notes";
+import { useLoad } from "../../lib/use-load";
+import { Button, Input, Labelled } from "../ui";
+
+/**
+ * The calendars this app reads, and where they come from.
+ *
+ * Adding one used to mean typing the path of a `.ics` file, which is a snapshot: the agenda
+ * describes whatever the calendar looked like on the day it was exported, and quietly keeps
+ * describing it. Everything people actually use publishes a URL instead — Google calls it the
+ * *secret address in iCal format*, Apple calls it a *public calendar* — and a URL the daemon can
+ * fetch is a calendar that stays right.
+ *
+ * No account is connected. Signing in with Google would mean shipping a client secret inside an
+ * open-source binary and asking for an account-wide scope so a notes app can learn when the standup
+ * is; a link the user chooses to paste grants one calendar and is revocable from the calendar's own
+ * settings. The instructions for finding it are on this screen, because that is the only hard part.
+ *
+ * Adding a file still works, and such a calendar is listed as what it is: something that will not
+ * refresh.
+ */
+export function CalendarSources({ onChange }: { onChange: () => void }) {
+ const { handshake } = useEngine();
+ const { t, locale } = useI18n();
+ const say = useErrorText();
+ // Memoised because it is a dependency of the load below, and a new client every render would
+ // re-fetch the calendar list on every keystroke in the address field.
+ const client = useMemo(() => new AgendaClient(handshake), [handshake]);
+
+ const [title, setTitle] = useState("");
+ const [address, setAddress] = useState("");
+ const [busy, setBusy] = useState(false);
+ const [error, setError] = useState(null);
+
+ const list = useLoad(
+ useCallback(async () => client.calendars(), [client]),
+ [client],
+ );
+
+ // Both, always: the list of calendars and the agenda drawn from them are two views of the same
+ // fetch, and refreshing one without the other is how a calendar appears with no meetings in it.
+ const reload = () => {
+ list.reload();
+ onChange();
+ };
+
+ const subscribe = async () => {
+ if (!address.trim()) return;
+ setBusy(true);
+ setError(null);
+ try {
+ // The name is optional: a calendar with no name is far likelier than one nobody can identify,
+ // and the host is a better guess than an empty row.
+ await client.subscribe(title.trim() || hostOf(address), address.trim());
+ setTitle("");
+ setAddress("");
+ reload();
+ } catch (e) {
+ setError(say(e));
+ } finally {
+ setBusy(false);
+ }
+ };
+
+ const addFile = async () => {
+ const chosen = await pickFile("iCalendar");
+ if (chosen === null) {
+ setError(t("import.no_dialog"));
+ return;
+ }
+ if (!chosen.trim()) return;
+ setError(null);
+ try {
+ const fallback = (chosen.split(/[/\\]/).pop() ?? "calendar").replace(/\.ics$/i, "");
+ await client.addCalendar(chosen, title.trim() || fallback);
+ setTitle("");
+ reload();
+ } catch (e) {
+ setError(say(e));
+ }
+ };
+
+ const refresh = async (name?: string) => {
+ setBusy(true);
+ setError(null);
+ try {
+ await client.refreshCalendars(name);
+ reload();
+ } catch (e) {
+ setError(say(e));
+ } finally {
+ setBusy(false);
+ }
+ };
+
+ const remove = async (name: string) => {
+ setError(null);
+ try {
+ await client.removeCalendar(name);
+ reload();
+ } catch (e) {
+ setError(say(e));
+ }
+ };
+
+ const calendars: CalendarList = list.data ?? { subscriptions: [], files: [] };
+
+ return (
+
+ );
+}
+
+/** A host to name a calendar after, when the user did not name it. */
+function hostOf(address: string): string {
+ const withoutScheme = address.trim().replace(/^[a-z]+:\/\//i, "");
+ return withoutScheme.split("/")[0] || "calendar";
+}
+
+/** "3 phút trước", from a timestamp, in whatever language the interface is in. */
+function when(epoch: number, locale: string): string {
+ const seconds = Math.round(epoch - Date.now() / 1000);
+ const format = new Intl.RelativeTimeFormat(locale, { numeric: "auto" });
+ const minutes = Math.round(seconds / 60);
+ if (Math.abs(minutes) < 60) return format.format(minutes, "minute");
+ const hours = Math.round(minutes / 60);
+ if (Math.abs(hours) < 24) return format.format(hours, "hour");
+ return format.format(Math.round(hours / 24), "day");
+}
diff --git a/apps/web/src/components/meeting/Compose.tsx b/apps/web/src/components/meeting/Compose.tsx
new file mode 100644
index 0000000..06ec4d9
--- /dev/null
+++ b/apps/web/src/components/meeting/Compose.tsx
@@ -0,0 +1,185 @@
+import { Mail } from "lucide-react";
+import { useState } from "react";
+
+import { useI18n } from "../../i18n/context";
+import {
+ ComposeClient,
+ copy,
+ mailto,
+ type Composed,
+ type Kind,
+ type Tone,
+} from "../../lib/compose";
+import { useEngine } from "../../lib/engine-context";
+import { useErrorText } from "../../lib/errors";
+import { Button, Input, Labelled, SegmentedControl, TextArea } from "../ui";
+
+const KINDS: Kind[] = ["email", "message", "recap", "actions"];
+const TONES: Tone[] = ["neutral", "friendly", "formal"];
+
+/**
+ * Write the follow-up, out of the meeting that is already on screen.
+ *
+ * Four shapes rather than a prompt box, because the shape is the part a model gets wrong: an email
+ * needs a subject and a sign-off, a chat message must fit in a glance, a recap cannot say "as
+ * discussed" to people who were not there, and a list of actions is a list.
+ *
+ * Everything it produces is editable before it goes anywhere, and it goes nowhere by itself: the
+ * buttons are copy, open in your own mail app, and keep as a note. That is deliberate. A model
+ * writing a customer email will occasionally invent a deadline — the prompt makes it mark gaps with
+ * `[…]` instead, but the real defence is that a person reads it and presses send themselves.
+ */
+export function ComposePanel({ meeting, title }: { meeting: string; title: string }) {
+ const { handshake } = useEngine();
+ const { t } = useI18n();
+ const say = useErrorText();
+ const client = new ComposeClient(handshake);
+
+ const [open, setOpen] = useState(false);
+ const [kind, setKind] = useState("email");
+ const [tone, setTone] = useState("neutral");
+ const [audience, setAudience] = useState("");
+ const [busy, setBusy] = useState(false);
+ const [error, setError] = useState(null);
+ const [draft, setDraft] = useState(null);
+ const [subject, setSubject] = useState("");
+ const [body, setBody] = useState("");
+ const [note, setNote] = useState(null);
+ const [copied, setCopied] = useState(false);
+
+ const run = async () => {
+ setBusy(true);
+ setError(null);
+ setNote(null);
+ try {
+ const composed = await client.compose(meeting, {
+ kind,
+ tone,
+ audience: audience.trim() || undefined,
+ });
+ setDraft(composed);
+ setSubject(composed.subject ?? "");
+ setBody(composed.body);
+ } catch (e) {
+ setError(say(e));
+ } finally {
+ setBusy(false);
+ }
+ };
+
+ const keep = async () => {
+ setError(null);
+ try {
+ setNote(await client.save(meeting, subject.trim() || title, body));
+ } catch (e) {
+ setError(say(e));
+ }
+ };
+
+ const wholeMessage = subject.trim() ? `${subject}\n\n${body}` : body;
+
+ return (
+
+
+
+
{t("compose.title")}
+ setOpen((o) => !o)}>
+ {open ? t("common.close") : t("compose.open")}
+
+
+
+ {open && (
+ <>
+ {t("compose.hint")}
+
+
+ ({ value, label: t(`compose.kind_${value}`) }))}
+ label={t("compose.kind")}
+ />
+ ({ value, label: t(`compose.tone_${value}`) }))}
+ label={t("compose.tone")}
+ />
+
+
+
+
+ setAudience(e.target.value)}
+ placeholder={t("compose.audience_placeholder")}
+ />
+
+ void run()} disabled={busy}>
+ {busy ? t("compose.working") : draft ? t("compose.again") : t("compose.write")}
+
+
+
+ {error && (
+
+ {error}
+
+ )}
+
+ {draft && (
+
+ {draft.kind === "email" && (
+
+ setSubject(e.target.value)} />
+
+ )}
+
+
+
+
+
+ void copy(wholeMessage).then((ok) => {
+ setCopied(ok);
+ if (!ok) setError(t("compose.copy_failed"));
+ })
+ }
+ >
+ {copied ? t("compose.copied") : t("compose.copy")}
+
+ {draft.kind === "email" && (
+ // Built from what is on screen rather than from what the model returned: the
+ // point of showing a draft is that it gets edited, and a link made before the
+ // edit opens the mail client with the old text.
+
+ {t("compose.open_mail")}
+
+ )}
+
void keep()}>
+ {t("compose.keep")}
+
+ {note &&
{t("compose.kept")} }
+
+
+ )}
+ >
+ )}
+
+ );
+}
diff --git a/apps/web/src/components/shell/NudgeBar.tsx b/apps/web/src/components/shell/NudgeBar.tsx
index bc29201..43c4035 100644
--- a/apps/web/src/components/shell/NudgeBar.tsx
+++ b/apps/web/src/components/shell/NudgeBar.tsx
@@ -18,7 +18,7 @@ import { NudgeClient, POLL_MS, iconFor, notify, type Nudge } from "../../lib/nud
* an overdue task — is still there, and the daemon will not repeat itself today.
*/
export function NudgeBar() {
- const { handshake, session } = useEngine();
+ const { handshake, session, start } = useEngine();
const client = useMemo(() => new NudgeClient(handshake), [handshake]);
const navigate = useNavigate();
const [queue, setQueue] = useState([]);
@@ -80,15 +80,22 @@ export function NudgeBar() {
{nudge.title}
— {nudge.body}
+ {/* A meeting starting is the one nudge with an obvious next action, and making the
+ user navigate to the record button to take it would waste the minute the prompt
+ exists to save. Recording still begins because a person pressed something. */}
{
- go(nudge.route);
+ if (nudge.reason === "meeting-soon") {
+ void start();
+ } else {
+ go(nudge.route);
+ }
dismiss(nudge.key);
}}
className="text-accent hover:bg-accent/10 text-meta rounded-full px-2.5 py-1 font-medium"
>
- {t("nudge.view")}
+ {nudge.reason === "meeting-soon" ? t("nudge.record") : t("nudge.view")}
{
+ return readJson(
+ await fetch(url(this.handshake, `/meetings/${encodeURIComponent(meeting)}/compose`), {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify(ask),
+ }),
+ );
+ }
+
+ /** Keep it as a note, so it outlives the tab. */
+ async save(meeting: string, title: string, body: string): Promise {
+ const result = await readJson<{ note: string }>(
+ await fetch(url(this.handshake, `/meetings/${encodeURIComponent(meeting)}/compose/save`), {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({ title, body }),
+ }),
+ );
+ return result.note;
+ }
+}
+
+/**
+ * A `mailto:` for text the user has since edited.
+ *
+ * The daemon builds one from what the model wrote, and it is stale the moment anybody fixes a
+ * sentence — which they will, since the whole point of showing the draft is that it is a draft.
+ * Built here from the current contents, so the mail client opens with what is on screen.
+ */
+export function mailto(subject: string | null, body: string): string {
+ const parts = [];
+ if (subject?.trim()) parts.push(`subject=${encodeURIComponent(subject)}`);
+ parts.push(`body=${encodeURIComponent(body)}`);
+ return `mailto:?${parts.join("&")}`;
+}
+
+/**
+ * Put text on the clipboard, and say whether it worked.
+ *
+ * `navigator.clipboard` is unavailable over plain HTTP on some browsers and refused without a user
+ * gesture on others, and a copy button that silently does nothing is worse than one that admits it
+ * — the user walks away believing they have the text.
+ */
+export async function copy(text: string): Promise {
+ try {
+ await navigator.clipboard.writeText(text);
+ return true;
+ } catch {
+ return false;
+ }
+}
diff --git a/apps/web/src/lib/notes.ts b/apps/web/src/lib/notes.ts
index 1556526..9502232 100644
--- a/apps/web/src/lib/notes.ts
+++ b/apps/web/src/lib/notes.ts
@@ -23,7 +23,15 @@ export interface NoteSummary {
export interface Note {
title: string;
+ /** Free text before the first `##`. Kept for callers that want only that part. */
body: string;
+ /**
+ * Everything under the title, headings included — what the editor shows.
+ *
+ * `body` stops at the first `##`, which meant a note written in Obsidian, or started from one of
+ * the shapes in the New menu, opened with most of itself missing.
+ */
+ text: string;
frontmatter: { id: string; date: string; tags?: string[] };
sections: { heading: string; body: string }[];
}
@@ -88,6 +96,24 @@ export interface AgendaEntry {
calendar: string;
}
+/** A calendar the daemon fetches for itself. */
+export interface Subscription {
+ name: string;
+ title: string;
+ url: string;
+ /** Seconds since the epoch, or null if it has never worked. */
+ last_sync: number | null;
+ /** Why the last attempt failed. Shown, because a broken subscription looks like a free week. */
+ last_error: string | null;
+ events: number;
+}
+
+export interface Calendars {
+ subscriptions: Subscription[];
+ /** Calendars copied in as files, which have no URL and never refresh. */
+ files: { name: string; events: number }[];
+}
+
export class AgendaClient {
constructor(private readonly handshake: Handshake) {}
@@ -105,6 +131,39 @@ export class AgendaClient {
);
}
+ /**
+ * Subscribe to a calendar the daemon fetches for itself.
+ *
+ * The difference from `addCalendar` is the whole point: that one copies a file, which is a
+ * snapshot, so the agenda describes last Tuesday until somebody exports again. A subscription is
+ * a URL — Google's "secret address in iCal format", Apple's public calendar link — and the daemon
+ * re-fetches it while it runs.
+ */
+ async subscribe(title: string, url_: string): Promise {
+ return readJson(
+ await fetch(url(this.handshake, "/calendars/subscribe"), {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({ title, url: url_ }),
+ }),
+ );
+ }
+
+ async calendars(): Promise {
+ return readJson(await fetch(url(this.handshake, "/calendars")));
+ }
+
+ /** Fetch now, rather than waiting for the daemon's own timer. */
+ async refreshCalendars(name?: string): Promise {
+ return readJson(
+ await fetch(url(this.handshake, "/calendars/refresh"), {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({ name: name ?? null }),
+ }),
+ );
+ }
+
async removeCalendar(name: string): Promise {
const body = await readJson<{ removed: boolean }>(
await fetch(url(this.handshake, `/calendars/${encodeURIComponent(name)}`), {
diff --git a/apps/web/src/lib/nudges.ts b/apps/web/src/lib/nudges.ts
index e3c158f..98573a3 100644
--- a/apps/web/src/lib/nudges.ts
+++ b/apps/web/src/lib/nudges.ts
@@ -13,7 +13,8 @@ import { url } from "./library";
* delivered once even if two windows are open.
*/
-export type Reason = "daily-report" | "weekly-rollup" | "draft-waiting" | "overdue";
+export type Reason =
+ "daily-report" | "weekly-rollup" | "draft-waiting" | "overdue" | "meeting-soon";
export interface Nudge {
reason: Reason;
@@ -33,11 +34,14 @@ export class NudgeClient {
}
/**
- * How often to ask. Fifteen minutes is a compromise between two failure modes: polling every minute
- * wakes a laptop for nothing, and polling hourly means "your summary is waiting" arrives when the
- * user has moved on.
+ * How often to ask.
+ *
+ * Five minutes. It was fifteen, which was a fine compromise while every nudge was about a day that
+ * had already happened — but "your meeting starts in five minutes" is only worth saying inside a
+ * window about that wide, and a fifteen-minute timer lands inside it barely more often than not.
+ * A request to a daemon on the same machine is not what wakes a laptop.
*/
-export const POLL_MS = 15 * 60 * 1000;
+export const POLL_MS = 5 * 60 * 1000;
/**
* Show a nudge as an OS notification, if the user has allowed it.
@@ -84,6 +88,8 @@ export function iconFor(reason: Reason): string {
return "!";
case "weekly-rollup":
return "◷";
+ case "meeting-soon":
+ return "◎";
default:
return "◔";
}
diff --git a/apps/web/src/screens/AgendaScreen.tsx b/apps/web/src/screens/AgendaScreen.tsx
index 0ce3ca4..c443bf2 100644
--- a/apps/web/src/screens/AgendaScreen.tsx
+++ b/apps/web/src/screens/AgendaScreen.tsx
@@ -2,11 +2,11 @@ import { CalendarDays } from "lucide-react";
import { motion } from "motion/react";
import { useCallback, useMemo, useState } from "react";
-import { Avatar, Button, Empty, Page, PageGlow } from "../components/ui";
+import { Avatar, Empty, Page, PageGlow } from "../components/ui";
+import { CalendarSources } from "../components/agenda/Calendars";
import { useErrorText } from "../lib/errors";
import { useI18n } from "../i18n/context";
import { useEngine } from "../lib/engine-context";
-import { pickFile } from "../lib/imports";
import { GENTLE, listItem } from "../lib/motion";
import { AgendaClient, byDay, clock, length, service, type AgendaEntry } from "../lib/notes";
import { useRefresh } from "../lib/use-load";
@@ -30,8 +30,6 @@ export function AgendaScreen() {
const [entries, setEntries] = useState([]);
const [error, setError] = useState(null);
- const [path, setPath] = useState("");
- const [name, setName] = useState("");
const refresh = useCallback(async () => {
try {
@@ -43,32 +41,6 @@ export function AgendaScreen() {
useRefresh(refresh);
- const add = async (from: string) => {
- const file = from.trim();
- if (!file) return;
- setError(null);
- try {
- // The name defaults to the file's own, so adding one calendar takes one action.
- const fallback = (file.split(/[/\\]/).pop() ?? "calendar").replace(/\.ics$/i, "");
- await client.addCalendar(file, name.trim() || fallback);
- setPath("");
- setName("");
- await refresh();
- } catch (e) {
- setError(say(e));
- }
- };
-
- const browse = async () => {
- const chosen = await pickFile("iCalendar");
- if (chosen === null) {
- setError(t("import.no_dialog"));
- return;
- }
- await add(chosen);
- };
-
- const calendars = [...new Set(entries.map((e) => e.calendar))].sort();
const grouped = byDay(entries).reverse();
return (
@@ -81,52 +53,7 @@ export function AgendaScreen() {
)}
-
- setPath(e.target.value)}
- onKeyDown={(e) => {
- if (e.key === "Enter") void add(path);
- }}
- placeholder={t("agenda.path_placeholder")}
- aria-label={t("agenda.path_label")}
- className="border-line bg-bg-soft focus:border-accent min-w-0 flex-1 rounded-xl border px-3 py-2 text-sm outline-none"
- />
- setName(e.target.value)}
- placeholder={t("agenda.name_placeholder")}
- aria-label={t("agenda.name_label")}
- className="border-line bg-bg-soft focus:border-accent w-36 rounded-xl border px-3 py-2 text-sm outline-none"
- />
- void browse()}>
- {t("import.browse")}
-
- void add(path)} disabled={!path.trim()}>
- {t("agenda.add")}
-
-
-
- {calendars.length > 0 && (
-
- {calendars.map((calendar) => (
-
- {calendar}
- void client.removeCalendar(calendar).then(refresh)}
- className="text-fg-faint hover:text-danger"
- >
- ✕
-
-
- ))}
-
- )}
+ void refresh()} />
{entries.length === 0 ? (
// `full`, so an empty calendar centres itself in what is left of the pane instead of
diff --git a/apps/web/src/screens/MeetingScreen.tsx b/apps/web/src/screens/MeetingScreen.tsx
index c6d7380..95fb60b 100644
--- a/apps/web/src/screens/MeetingScreen.tsx
+++ b/apps/web/src/screens/MeetingScreen.tsx
@@ -5,6 +5,7 @@ import { Comments } from "../components/meeting/Comments";
import { useErrorText } from "../lib/errors";
import { useI18n } from "../i18n/context";
import { DraftPanel } from "../components/meeting/DraftPanel";
+import { ComposePanel } from "../components/meeting/Compose";
import { Player, type PlayerHandle } from "../components/meeting/Player";
import { TranscriptChips } from "../components/meeting/TranscriptChips";
import { Button, Card, CardBody, CardHeader, SegmentedControl } from "../components/ui";
@@ -176,6 +177,11 @@ export function MeetingScreen() {
/>
)}
+ {/* After the summary, not beside it: the follow-up is written *from* what the meeting
+ concluded, and offering to draft an email above the draft of the notes puts the two
+ in the wrong order. */}
+
+
{sections.length === 0 ? (
diff --git a/apps/web/src/screens/NotesScreen.tsx b/apps/web/src/screens/NotesScreen.tsx
index 231bc17..5690138 100644
--- a/apps/web/src/screens/NotesScreen.tsx
+++ b/apps/web/src/screens/NotesScreen.tsx
@@ -28,6 +28,12 @@ import { useRefresh } from "../lib/use-load";
* first time it round-tripped something it did not understand. Tasks work here because
* `- [ ] @ngoc …` is parsed from the file, not from a widget.
*/
+/**
+ * The shapes a note can start in. `blank` first, because most notes are.
+ */
+const KINDS = ["blank", "idea", "decision", "todo", "journal"] as const;
+type NoteKind = (typeof KINDS)[number];
+
export function NotesScreen() {
const { handshake } = useEngine();
const say = useErrorText();
@@ -39,6 +45,7 @@ export function NotesScreen() {
const [text, setText] = useState("");
const [saved, setSaved] = useState(true);
const [error, setError] = useState(null);
+ const [picking, setPicking] = useState(false);
// Held in a ref as well as in state: the debounce fires from a timer that closed over an older
// render, and saving the text from two seconds ago would undo the last two seconds of typing.
@@ -68,7 +75,8 @@ export function NotesScreen() {
const note = await client.read(id);
// The title is the first line, so it is shown as the first line — the file stores them
// apart, and stitching them back together is what makes the editor feel like one document.
- const body = note.body.trim();
+ // `text`, not `body`: the latter stops at the first heading.
+ const body = note.text.trim();
setText(body ? `${note.title}\n\n${body}` : note.title);
} catch (e) {
setError(say(e));
@@ -107,10 +115,24 @@ export function NotesScreen() {
[],
);
- const create = async () => {
+ /**
+ * A new note, optionally with a shape already in it.
+ *
+ * A blank page is the right default and a poor only option: "ý tưởng", "quyết định", "việc cần
+ * làm" and a day's journal are what people were typing headings for by hand, and each one has a
+ * different set of headings. Seeded rather than templated in the daemon — the seed is ordinary
+ * Markdown in the note from the first keystroke, so nothing about it can go stale or need
+ * migrating, and deleting the headings is how you opt out.
+ */
+ const create = async (kind: NoteKind = "blank") => {
setError(null);
+ setPicking(false);
try {
- const { id } = await client.create(t("notes.untitled"));
+ const body = kind === "blank" ? "" : t(`notes.seed_${kind}`);
+ const { id } = await client.create(
+ kind === "blank" ? t("notes.untitled") : t(`notes.kind_${kind}`),
+ body,
+ );
await refresh();
await open(id);
} catch (e) {
@@ -138,9 +160,30 @@ export function NotesScreen() {
{t("notes.title")}
-
void create()}>
- {t("notes.new")}
-
+
+
setPicking((p) => !p)}>
+ {t("notes.new")}
+
+ {picking && (
+
+ {KINDS.map((kind) => (
+
+ void create(kind)}
+ className="hover:bg-bg-soft w-full px-3 py-1.5 text-start text-sm"
+ >
+ {t(`notes.kind_${kind}`)}
+
+
+ ))}
+
+ )}
+
diff --git a/crates/summo-cli/src/daemon.rs b/crates/summo-cli/src/daemon.rs
new file mode 100644
index 0000000..37b63af
--- /dev/null
+++ b/crates/summo-cli/src/daemon.rs
@@ -0,0 +1,253 @@
+//! Running Summo in the background, and talking to the one that is already running.
+//!
+//! `summo serve` holds a terminal open, which is right for a server and wrong for the thing this
+//! is: a notes app that should be listening when the meeting starts, not when somebody remembers to
+//! open a terminal first. The desktop shell has had a tray icon for this since the start; the
+//! command line had nothing, so a Linux user without the bundle, or anyone on a machine they reach
+//! over SSH, had to leave a shell open forever.
+//!
+//! Three commands, and the state they share is one file:
+//!
+//! * `summo serve --background` — start a detached daemon and return the terminal.
+//! * `summo status` — is one running, on what port, doing what.
+//! * `summo stop` — ask it to stop.
+//!
+//! **`engine.json` is the only record.** The daemon already writes it — port, token, pid, version —
+//! so that `summo import` and the desktop shell can find a running instance instead of starting a
+//! second one. Adding a second pidfile would mean two files that can disagree about the same
+//! process.
+//!
+//! Stopping is an HTTP request, not a signal. It carries the token, so anything that can stop the
+//! daemon could already do everything else to it; it lets the daemon refuse while a meeting is
+//! being recorded; and it is the same code on Windows, where the signal would not have been.
+
+use std::path::Path;
+
+use summo_core::{Error, Result, paths::Paths};
+
+/// What the daemon wrote about itself.
+#[derive(Debug, Clone, serde::Deserialize)]
+pub struct Handshake {
+ pub port: u16,
+ pub token: String,
+ pub pid: u32,
+ #[serde(default)]
+ pub version: String,
+}
+
+fn handshake_path(paths: &Paths) -> std::path::PathBuf {
+ paths.root().join("engine.json")
+}
+
+fn read_handshake(paths: &Paths) -> Option
{
+ let text = std::fs::read_to_string(handshake_path(paths)).ok()?;
+ serde_json::from_str(&text).ok()
+}
+
+/// A daemon that answered.
+#[derive(Debug, Clone)]
+pub struct Running {
+ pub handshake: Handshake,
+ /// What `/status` said: `idle`, `recording`, and so on.
+ pub state: String,
+ pub recording: bool,
+}
+
+impl Running {
+ #[must_use]
+ pub fn url(&self) -> String {
+ format!("http://127.0.0.1:{}/", self.handshake.port)
+ }
+}
+
+/// Whether a daemon is running, by asking it rather than by trusting the file.
+///
+/// A stale `engine.json` outlives every crash and every reboot, and the pid in it will eventually
+/// belong to something else entirely. The only honest test is whether something answers on that
+/// port with that token.
+pub async fn running(paths: &Paths) -> Option {
+ let handshake = read_handshake(paths)?;
+ let client = reqwest::Client::builder()
+ .timeout(std::time::Duration::from_secs(3))
+ .build()
+ .ok()?;
+ let response = client
+ .get(format!(
+ "http://127.0.0.1:{}/status?token={}",
+ handshake.port, handshake.token
+ ))
+ .send()
+ .await
+ .ok()?;
+ if !response.status().is_success() {
+ return None;
+ }
+ let body: serde_json::Value = response.json().await.ok()?;
+ let state = body
+ .get("state")
+ .and_then(|s| s.as_str())
+ .unwrap_or("idle")
+ .to_string();
+ Some(Running {
+ handshake,
+ recording: state == "recording",
+ state,
+ })
+}
+
+/// Where a background daemon's output goes.
+///
+/// It has no terminal, so its logs have to land somewhere findable — the first question about a
+/// daemon that will not start is always "what did it say".
+#[must_use]
+pub fn log_path(paths: &Paths) -> std::path::PathBuf {
+ paths.root().join("daemon.log")
+}
+
+/// Start a daemon that outlives this command.
+///
+/// Re-runs this same binary with `serve`, detached from the terminal: its own process group on
+/// Unix, no console on Windows. Without that, closing the terminal — or pressing Ctrl-C in it a
+/// minute later, which sends the signal to the whole foreground group — would take the daemon with
+/// it, and it would look like Summo crashed on its own.
+pub async fn start_background(paths: &Paths, port: u16, dev: bool) -> Result {
+ if let Some(already) = running(paths).await {
+ return Ok(already);
+ }
+
+ let exe = std::env::current_exe()
+ .map_err(|e| Error::msg("daemon.exe", format!("không tìm được chương trình: {e}")))?;
+ let log = log_path(paths);
+ let out = std::fs::File::create(&log).map_err(|e| Error::io(&log, e))?;
+ let err = out.try_clone().map_err(|e| Error::io(&log, e))?;
+
+ let mut command = std::process::Command::new(exe);
+ command
+ .arg("serve")
+ .arg("--port")
+ .arg(port.to_string())
+ .arg("--no-open")
+ .stdin(std::process::Stdio::null())
+ .stdout(out)
+ .stderr(err);
+ if dev {
+ command.arg("--dev");
+ }
+ // The data directory is passed explicitly. `--home` on this command may have pointed somewhere
+ // other than the default, and a background daemon serving a different vault from the one the
+ // user named would be a bewildering thing to debug.
+ command.arg("--home").arg(paths.root());
+ detach(&mut command);
+
+ let child = command
+ .spawn()
+ .map_err(|e| Error::msg("daemon.spawn", format!("không chạy được daemon: {e}")))?;
+ let pid = child.id();
+ // The handle is dropped deliberately. Waiting would defeat the point, and on Unix the child is
+ // reparented to init when this process exits, so nothing is left to reap.
+ drop(child);
+
+ // Wait for it to be answering rather than merely spawned. A command that returns before the
+ // port is open makes the very next `summo status` say nothing is running.
+ for _ in 0..60 {
+ tokio::time::sleep(std::time::Duration::from_millis(250)).await;
+ if let Some(alive) = running(paths).await {
+ return Ok(alive);
+ }
+ }
+
+ Err(Error::msg(
+ "daemon.timeout",
+ format!(
+ "daemon (pid {pid}) không trả lời sau 15 giây — xem {}",
+ log.display()
+ ),
+ ))
+}
+
+#[cfg(unix)]
+fn detach(command: &mut std::process::Command) {
+ use std::os::unix::process::CommandExt;
+ // Its own process group, so Ctrl-C in this terminal never reaches it.
+ command.process_group(0);
+}
+
+#[cfg(windows)]
+fn detach(command: &mut std::process::Command) {
+ use std::os::windows::process::CommandExt;
+ /// `DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP`: no console window flashing up, and no
+ /// Ctrl-C from this one.
+ const FLAGS: u32 = 0x0000_0008 | 0x0000_0200;
+ command.creation_flags(FLAGS);
+}
+
+#[cfg(not(any(unix, windows)))]
+fn detach(_command: &mut std::process::Command) {}
+
+/// Ask a running daemon to stop. `Ok(false)` means there was nothing to stop.
+pub async fn stop(paths: &Paths, force: bool) -> Result {
+ let Some(alive) = running(paths).await else {
+ // A leftover file with nothing behind it is worth clearing: it is what makes every later
+ // command spend three seconds probing a port nothing is listening on.
+ let _ = std::fs::remove_file(handshake_path(paths));
+ return Ok(false);
+ };
+
+ let client = reqwest::Client::builder()
+ .timeout(std::time::Duration::from_secs(10))
+ .build()
+ .map_err(|e| Error::msg("daemon.client", e.to_string()))?;
+ let response = client
+ .post(format!(
+ "http://127.0.0.1:{}/shutdown?token={}",
+ alive.handshake.port, alive.handshake.token
+ ))
+ .json(&serde_json::json!({ "force": force }))
+ .send()
+ .await
+ .map_err(|e| Error::msg("daemon.stop", e.to_string()))?;
+
+ if !response.status().is_success() {
+ let body: serde_json::Value = response.json().await.unwrap_or_default();
+ let message = body
+ .get("error")
+ .and_then(|e| e.get("message"))
+ .and_then(|m| m.as_str())
+ .unwrap_or("daemon từ chối dừng")
+ .to_string();
+ return Err(Error::msg("daemon.stop", message));
+ }
+
+ // Give it a moment to actually go, so `summo stop && summo serve` does not race the port.
+ for _ in 0..20 {
+ tokio::time::sleep(std::time::Duration::from_millis(250)).await;
+ if running(paths).await.is_none() {
+ return Ok(true);
+ }
+ }
+ Err(Error::msg(
+ "daemon.stuck",
+ "daemon nhận lệnh dừng nhưng vẫn đang chạy",
+ ))
+}
+
+/// Remove the handshake file this daemon wrote, on its way out.
+///
+/// Not `running()`'s job: this is the process that knows it is stopping, and leaving the file for
+/// the next command to discover is stale is how a stopped daemon still looks like a running one.
+pub fn forget(paths: &Paths) {
+ let path = handshake_path(paths);
+ if read_handshake(paths).is_some_and(|h| h.pid == std::process::id()) {
+ let _ = std::fs::remove_file(&path);
+ }
+}
+
+/// A human-readable line about a log file that may not exist yet.
+#[must_use]
+pub fn log_hint(path: &Path) -> String {
+ if path.exists() {
+ format!("Nhật ký: {}", path.display())
+ } else {
+ String::new()
+ }
+}
diff --git a/crates/summo-cli/src/main.rs b/crates/summo-cli/src/main.rs
index 0da3923..41802e8 100644
--- a/crates/summo-cli/src/main.rs
+++ b/crates/summo-cli/src/main.rs
@@ -9,6 +9,8 @@ use summo_core::{ModelId, paths::Paths};
use summo_models::{Downloader, Manifest, ModelStore, Registry, RegistrySource, hw::HwProfile};
mod ai;
+#[cfg(feature = "serve")]
+mod daemon;
#[cfg(feature = "dub")]
mod dub;
mod importer;
@@ -84,6 +86,24 @@ enum Command {
/// the one that stops a web page reaching the microphone.
#[arg(long)]
dev: bool,
+ /// Run in the background and give the terminal back.
+ ///
+ /// The daemon keeps running after this shell closes, so the calendar keeps syncing and the
+ /// prompt before a meeting can arrive. `summo stop` ends it.
+ #[arg(long)]
+ background: bool,
+ },
+
+ /// Is a daemon running, and what is it doing.
+ #[cfg(feature = "serve")]
+ Status,
+
+ /// Stop the running daemon.
+ #[cfg(feature = "serve")]
+ Stop {
+ /// Stop even if a meeting is being recorded.
+ #[arg(long)]
+ force: bool,
},
/// Serve the vault to an MCP client — Claude Code, Cursor — over stdio.
@@ -298,7 +318,22 @@ async fn main() -> Result<()> {
detach,
} => import(&paths, &path, dry_run, lang.as_deref(), detach).await,
#[cfg(feature = "serve")]
- Command::Serve { port, no_open, dev } => serve(&paths, port, no_open, dev).await,
+ Command::Serve {
+ port,
+ no_open,
+ dev,
+ background,
+ } => {
+ if background {
+ start_background(&paths, port, dev).await
+ } else {
+ serve(&paths, port, no_open, dev).await
+ }
+ }
+ #[cfg(feature = "serve")]
+ Command::Status => status(&paths).await,
+ #[cfg(feature = "serve")]
+ Command::Stop { force } => stop(&paths, force).await,
#[cfg(feature = "mcp")]
Command::Mcp => mcp(&paths),
#[cfg(feature = "dub")]
@@ -1103,11 +1138,69 @@ async fn serve(paths: &Paths, port: u16, no_open: bool, dev: bool) -> Result<()>
open_browser(&url);
}
- // Ctrl-C rather than running forever: a recording in progress is flushed on its own interval,
- // so stopping here costs seconds of audio at worst.
- tokio::signal::ctrl_c().await.ok();
- println!("\nDừng.");
+ // Ctrl-C, or `summo stop` reaching the daemon over HTTP. Both, because a daemon started with
+ // `--background` has no terminal to press Ctrl-C in, and one started in a terminal should still
+ // answer `summo stop` from another.
+ tokio::select! {
+ _ = tokio::signal::ctrl_c() => println!("\nDừng."),
+ () = server.stop_requested() => println!("Nhận lệnh dừng."),
+ }
server.shutdown();
+ // Written by this process on the way in, removed on the way out: a handshake file left behind
+ // makes every later command spend three seconds asking a port nobody is listening on.
+ daemon::forget(paths);
+ Ok(())
+}
+
+/// Start a daemon that outlives this command, and say where it went.
+#[cfg(feature = "serve")]
+async fn start_background(paths: &Paths, port: u16, dev: bool) -> Result<()> {
+ let running = daemon::start_background(paths, port, dev).await?;
+ println!("Summo đang chạy nền tại {}", running.url());
+ println!("`summo status` để xem, `summo stop` để dừng.");
+ let hint = daemon::log_hint(&daemon::log_path(paths));
+ if !hint.is_empty() {
+ println!("{hint}");
+ }
+ Ok(())
+}
+
+/// What the running daemon is doing, or that there is not one.
+#[cfg(feature = "serve")]
+async fn status(paths: &Paths) -> Result<()> {
+ match daemon::running(paths).await {
+ Some(running) => {
+ println!("Đang chạy tại {}", running.url());
+ println!(
+ "pid {} · phiên bản {} · {}",
+ running.handshake.pid,
+ if running.handshake.version.is_empty() {
+ "?"
+ } else {
+ &running.handshake.version
+ },
+ if running.recording {
+ "đang ghi âm"
+ } else {
+ &running.state
+ }
+ );
+ }
+ // Not an error: "is it running?" answered with "no" is a successful answer, and a non-zero
+ // exit code here would break every script that asks before starting one.
+ None => println!("Không có daemon nào đang chạy."),
+ }
+ Ok(())
+}
+
+/// Stop the running daemon.
+#[cfg(feature = "serve")]
+async fn stop(paths: &Paths, force: bool) -> Result<()> {
+ if daemon::stop(paths, force).await? {
+ println!("Đã dừng.");
+ } else {
+ println!("Không có daemon nào đang chạy.");
+ }
Ok(())
}
diff --git a/crates/summo-engine/Cargo.toml b/crates/summo-engine/Cargo.toml
index 5149bc8..1313fee 100644
--- a/crates/summo-engine/Cargo.toml
+++ b/crates/summo-engine/Cargo.toml
@@ -66,6 +66,9 @@ summo-pipeline.workspace = true
hound.workspace = true
anyhow.workspace = true
+# Fetching calendar subscriptions. Already in the dependency tree through `summo-models`, so this
+# adds a name rather than a download.
+reqwest.workspace = true
axum.workspace = true
clap.workspace = true
futures.workspace = true
diff --git a/crates/summo-engine/src/calsync.rs b/crates/summo-engine/src/calsync.rs
new file mode 100644
index 0000000..1fff34b
--- /dev/null
+++ b/crates/summo-engine/src/calsync.rs
@@ -0,0 +1,368 @@
+//! Subscribing to a calendar, so the agenda keeps up on its own.
+//!
+//! [`crate::agenda`] reads whatever `.ics` files are in `~/.summo/calendars/`, and until now the
+//! only way one got there was [`crate::agenda::install`], which copies a file the user picked. That
+//! is a fine way to look at a calendar once and a bad way to use one: an export is a snapshot, so
+//! the agenda quietly describes last Tuesday until somebody exports again.
+//!
+//! Every calendar people actually use publishes a URL that is always current:
+//!
+//! | | Where the URL comes from |
+//! |---|---|
+//! | Google Calendar | Settings for the calendar → *Secret address in iCal format* |
+//! | Apple Calendar / iCloud | Right-click the calendar → *Share* → *Public Calendar* |
+//! | Outlook / Microsoft 365 | Settings → *Shared calendars* → publish → ICS link |
+//! | Anything else | Almost every calendar server serves iCalendar over HTTP |
+//!
+//! So a subscription is a URL plus a name, and syncing is fetching it and overwriting the file.
+//! That is deliberately the whole design — **no OAuth**. OAuth would mean registering Summo with
+//! Google, shipping a client secret in an open-source binary that anybody can read, and asking for
+//! an account-wide scope so an offline notes app can learn what time the standup is. A secret URL
+//! the user chooses to paste grants exactly one calendar, is revocable from the calendar's own
+//! settings, and needs no server of ours to exist.
+//!
+//! **This still never starts a recording.** It fills the agenda and, with
+//! `Recording::suggest_on_meeting`, lets [`crate::nudge`] ask whether to take notes. Asking is the
+//! strongest thing a calendar is allowed to cause.
+//!
+//! The URL is a secret in its own right — anyone holding it can read the calendar — which is why it
+//! lives in `~/.summo/calendars/subscriptions.json` beside the data it fetches, and why it is
+//! deliberately outside the vault: a synced vault would carry an employer's calendar key to every
+//! other machine.
+
+use serde::{Deserialize, Serialize};
+use summo_core::{Error, Result, paths::Paths};
+
+/// A calendar the app fetches for itself.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct Subscription {
+ /// File stem in `calendars/`, and the id in every route. Slugged, so it is safe as a path.
+ pub name: String,
+ /// What the user called it, which may be Vietnamese and is never used as a filename.
+ pub title: String,
+ pub url: String,
+ /// Seconds since the epoch of the last successful fetch, or `None` if it has never worked.
+ #[serde(default)]
+ pub last_sync: Option,
+ /// Why the last attempt failed, kept until one succeeds.
+ ///
+ /// Shown rather than logged: a subscription that stopped working looks exactly like a week with
+ /// no meetings, and the difference matters.
+ #[serde(default)]
+ pub last_error: Option,
+ /// Events in the file as of the last fetch.
+ #[serde(default)]
+ pub events: usize,
+}
+
+/// A calendar body larger than this is not a calendar.
+///
+/// A year of a busy calendar is a few hundred kilobytes. The cap is here because the response comes
+/// from a URL a user pasted, and a redirect to something enormous should cost a moment rather than
+/// the machine's memory.
+const MAX_BYTES: u64 = 8 * 1024 * 1024;
+
+/// How long to wait for a calendar server.
+const TIMEOUT_S: u64 = 20;
+
+fn file(paths: &Paths) -> std::path::PathBuf {
+ paths.calendars().join("subscriptions.json")
+}
+
+/// Every subscription, in the order they were added.
+pub fn list(paths: &Paths) -> Result> {
+ match std::fs::read_to_string(file(paths)) {
+ Ok(text) => Ok(serde_json::from_str(&text).unwrap_or_default()),
+ Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Vec::new()),
+ Err(e) => Err(Error::io(file(paths), e)),
+ }
+}
+
+fn store(paths: &Paths, subscriptions: &[Subscription]) -> Result<()> {
+ let dir = paths.calendars();
+ std::fs::create_dir_all(&dir).map_err(|e| Error::io(&dir, e))?;
+ summo_vault::write::write_atomically(
+ &file(paths),
+ serde_json::to_vec_pretty(subscriptions)?.as_slice(),
+ )
+}
+
+/// Turn what a user pasted into a URL that can be fetched, or refuse it.
+///
+/// `webcal://` is what Apple and Outlook hand out and what every browser refuses; it is plain HTTPS
+/// with a scheme that means "subscribe to this". Rewriting it here is the difference between the
+/// Apple flow working and the user having to know that.
+///
+/// Everything that is not HTTP is refused rather than attempted. `file:///etc/passwd` is a URL, the
+/// daemon can read it, and "the app fetches whatever string it is handed" is how a local-first app
+/// becomes a file-exfiltration tool.
+pub fn normalise(url: &str) -> Result {
+ let url = url.trim();
+ let rewritten = match url.split_once("://") {
+ Some(("webcal" | "webcals", rest)) => format!("https://{rest}"),
+ Some(("http" | "https", _)) => url.to_string(),
+ _ => {
+ return Err(Error::msg(
+ "calendar.scheme",
+ "địa chỉ lịch phải bắt đầu bằng https:// hoặc webcal://",
+ ));
+ }
+ };
+ if rewritten
+ .split_once("://")
+ .is_none_or(|(_, rest)| rest.is_empty() || rest.starts_with('/'))
+ {
+ return Err(Error::msg("calendar.url", "địa chỉ lịch không hợp lệ"));
+ }
+ Ok(rewritten)
+}
+
+/// Fetch a calendar and write it into `calendars/`, returning how many events it holds.
+///
+/// The file is replaced only when the response parses as a calendar with events in it. Calendar
+/// servers answer expired links with a login page carrying `200 OK`, and writing that over a
+/// working calendar would empty the agenda for a reason nobody could see.
+async fn fetch(paths: &Paths, name: &str, url: &str) -> Result {
+ let client = reqwest::Client::builder()
+ .timeout(std::time::Duration::from_secs(TIMEOUT_S))
+ .build()
+ .map_err(|e| Error::msg("calendar.client", e.to_string()))?;
+
+ let response = client
+ .get(url)
+ .header("Accept", "text/calendar, text/plain;q=0.5")
+ .send()
+ .await
+ .map_err(|e| Error::msg("calendar.fetch", friendly(&e)))?;
+
+ let status = response.status();
+ if !status.is_success() {
+ return Err(Error::msg(
+ "calendar.http",
+ match status.as_u16() {
+ 401 | 403 => "lịch từ chối truy cập — kiểm tra lại địa chỉ bí mật".to_string(),
+ 404 => "không tìm thấy lịch ở địa chỉ này".to_string(),
+ other => format!("máy chủ lịch trả về {other}"),
+ },
+ ));
+ }
+ if response.content_length().is_some_and(|n| n > MAX_BYTES) {
+ return Err(Error::msg("calendar.too_big", "tệp lịch quá lớn"));
+ }
+
+ let text = response
+ .text()
+ .await
+ .map_err(|e| Error::msg("calendar.body", friendly(&e)))?;
+ if text.len() as u64 > MAX_BYTES {
+ return Err(Error::msg("calendar.too_big", "tệp lịch quá lớn"));
+ }
+
+ let events = summo_calendar::ics::parse(&text);
+ if events.is_empty() {
+ return Err(Error::msg(
+ "calendar.no_events",
+ if text.contains("BEGIN:VCALENDAR") {
+ "lịch này chưa có sự kiện nào"
+ } else {
+ "địa chỉ này không trả về lịch"
+ },
+ ));
+ }
+
+ let dir = paths.calendars();
+ std::fs::create_dir_all(&dir).map_err(|e| Error::io(&dir, e))?;
+ summo_vault::write::write_atomically(&dir.join(format!("{name}.ics")), text.as_bytes())?;
+ Ok(events.len())
+}
+
+/// reqwest's own `Display` is a chain of internal types; this is what a person needs to read.
+fn friendly(e: &reqwest::Error) -> String {
+ if e.is_timeout() {
+ "máy chủ lịch không trả lời".to_string()
+ } else if e.is_connect() {
+ "không kết nối được tới máy chủ lịch".to_string()
+ } else {
+ e.to_string()
+ }
+}
+
+/// Add a subscription and fetch it once, so a URL that does not work fails while the user is
+/// looking at the form rather than silently at three in the morning.
+pub async fn subscribe(paths: &Paths, title: &str, url: &str) -> Result {
+ let title = title.trim();
+ if title.is_empty() {
+ return Err(Error::msg("calendar.no_name", "lịch cần có tên"));
+ }
+ let url = normalise(url)?;
+
+ let mut subscriptions = list(paths)?;
+ if let Some(existing) = subscriptions.iter().find(|s| s.url == url) {
+ return Err(Error::msg(
+ "calendar.duplicate",
+ format!("lịch này đã có rồi: {}", existing.title),
+ ));
+ }
+ let name = unique_name(title, &subscriptions);
+
+ // Fetch before recording it. A subscription saved first and fetched second leaves a broken row
+ // in the list when the URL is wrong, and the user has to delete it before trying again.
+ let events = fetch(paths, &name, &url).await?;
+
+ let subscription = Subscription {
+ name,
+ title: title.to_string(),
+ url,
+ last_sync: Some(now()),
+ last_error: None,
+ events,
+ };
+ subscriptions.push(subscription.clone());
+ store(paths, &subscriptions)?;
+ Ok(subscription)
+}
+
+/// Fetch every subscription, or one of them.
+///
+/// One failure does not stop the others, and does not remove anything: a laptop that woke up
+/// without WiFi should still show this morning's meetings from the file it already has.
+pub async fn refresh(paths: &Paths, only: Option<&str>) -> Result> {
+ let mut subscriptions = list(paths)?;
+ for subscription in &mut subscriptions {
+ if only.is_some_and(|name| name != subscription.name) {
+ continue;
+ }
+ match fetch(paths, &subscription.name, &subscription.url).await {
+ Ok(events) => {
+ subscription.events = events;
+ subscription.last_sync = Some(now());
+ subscription.last_error = None;
+ }
+ Err(e) => {
+ tracing::warn!(calendar = %subscription.name, error = %e, "calendar refresh failed");
+ subscription.last_error = Some(e.to_string());
+ }
+ }
+ }
+ store(paths, &subscriptions)?;
+ Ok(subscriptions)
+}
+
+/// Forget a subscription and the file it fetched.
+///
+/// Both, because leaving the `.ics` behind means a calendar the user removed keeps appearing in the
+/// agenda with no row anywhere to remove it a second time.
+pub fn unsubscribe(paths: &Paths, name: &str) -> Result {
+ let mut subscriptions = list(paths)?;
+ let before = subscriptions.len();
+ subscriptions.retain(|s| s.name != name);
+ let removed = subscriptions.len() != before;
+ if removed {
+ store(paths, &subscriptions)?;
+ crate::agenda::forget(paths, name)?;
+ }
+ Ok(removed)
+}
+
+/// A file stem for a title, unique among the ones already taken.
+///
+/// [`crate::agenda::safe_name`] keeps ASCII only, so "Lịch công ty" becomes empty and a Vietnamese
+/// user could not name a calendar in Vietnamese. Slugging handles the accents, and the title is
+/// kept separately for display, so the file is `lich-cong-ty.ics` and the interface still says
+/// "Lịch công ty".
+fn unique_name(title: &str, existing: &[Subscription]) -> String {
+ let base = {
+ let slug = summo_vault::slug::slugify(title);
+ if slug.is_empty() {
+ "calendar".to_string()
+ } else {
+ slug
+ }
+ };
+ let taken = |name: &str| existing.iter().any(|s| s.name == name);
+ if !taken(&base) {
+ return base;
+ }
+ (2..)
+ .map(|n| format!("{base}-{n}"))
+ .find(|name| !taken(name))
+ .unwrap_or(base)
+}
+
+fn now() -> i64 {
+ time::OffsetDateTime::now_utc().unix_timestamp()
+}
+
+/// How often a running daemon re-fetches, in seconds.
+///
+/// Fifteen minutes. A calendar changes on the timescale of somebody moving a meeting, and the point
+/// of syncing at all is that the change is there before the meeting is. Hourly would miss the
+/// common case of a meeting moved that morning; every minute would be a request every minute,
+/// forever, to somebody else's server.
+pub const REFRESH_S: u64 = 900;
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn apple_and_outlook_links_are_rewritten_rather_than_refused() {
+ assert_eq!(
+ normalise("webcal://p1-caldav.icloud.com/published/2/abc").unwrap(),
+ "https://p1-caldav.icloud.com/published/2/abc"
+ );
+ assert_eq!(
+ normalise(" https://calendar.google.com/basic.ics ").unwrap(),
+ "https://calendar.google.com/basic.ics"
+ );
+ }
+
+ /// The daemon fetches this URL with the user's file permissions. A scheme that reads the disk
+ /// or talks to a local socket is not a calendar, whatever it is called.
+ #[test]
+ fn only_http_is_fetched() {
+ for url in [
+ "file:///etc/passwd",
+ "ftp://example.com/cal.ics",
+ "/home/me/cal.ics",
+ "javascript:alert(1)",
+ "https://",
+ "https:///etc/passwd",
+ ] {
+ assert!(normalise(url).is_err(), "{url} should be refused");
+ }
+ }
+
+ #[test]
+ fn a_vietnamese_name_still_produces_a_usable_filename() {
+ let name = unique_name("Lịch công ty", &[]);
+ assert!(!name.is_empty());
+ assert!(
+ name.chars()
+ .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_'),
+ "{name} must be safe as a path"
+ );
+ }
+
+ #[test]
+ fn two_calendars_with_the_same_name_do_not_share_a_file() {
+ let first = Subscription {
+ name: unique_name("Việc", &[]),
+ title: "Việc".into(),
+ url: "https://a".into(),
+ last_sync: None,
+ last_error: None,
+ events: 0,
+ };
+ let second = unique_name("Việc", std::slice::from_ref(&first));
+ assert_ne!(first.name, second);
+ }
+
+ #[test]
+ fn listing_an_empty_installation_is_not_an_error() {
+ let dir = tempfile::tempdir().unwrap();
+ let paths = Paths::at(dir.path());
+ assert!(list(&paths).unwrap().is_empty());
+ assert!(!unsubscribe(&paths, "nothing").unwrap());
+ }
+}
diff --git a/crates/summo-engine/src/compose.rs b/crates/summo-engine/src/compose.rs
new file mode 100644
index 0000000..22c6e8f
--- /dev/null
+++ b/crates/summo-engine/src/compose.rs
@@ -0,0 +1,273 @@
+//! Turning a meeting into something you send.
+//!
+//! Everything up to here ends at a good note. That is where the work actually is for most people:
+//! the meeting produced a decision, and now somebody has to tell the three people who were not in
+//! it — and they do it by scrolling the transcript in one window and typing an email in another.
+//! Summo already holds every fact that email needs.
+//!
+//! So: a follow-up email, a chat message, a recap for people who missed it, or the decisions and
+//! actions as a list. Four shapes rather than a free-text style box, because the shape is what a
+//! model gets wrong; see [`summo_llm::prompt::Compose`].
+//!
+//! Three deliberate limits:
+//!
+//! * **Nothing is sent.** The draft comes back to the screen; the user copies it, or opens their
+//! own mail client through a `mailto:` link with the subject and body already filled in. Summo
+//! holds no mail credentials, sends nothing on anybody's behalf, and cannot send the wrong
+//! version of a message to a customer.
+//! * **The summary is the source, not the transcript.** A summary the user confirmed is the
+//! version of events they agreed with. Falling straight to the transcript would draft from
+//! whatever was said, including the part that was corrected two minutes later.
+//! * **A gap is marked, not filled.** The prompt requires `[…]` where a fact is missing. A model
+//! asked to write a follow-up will otherwise invent a deadline, and a fluent email does not look
+//! like a wrong one.
+
+use serde::{Deserialize, Serialize};
+use summo_core::{Error, MeetingId, Result, paths::Paths};
+use summo_llm::{
+ LlmClient,
+ prompt::{Compose, Tone},
+};
+use summo_vault::meeting::MeetingDoc;
+
+/// A drafted message.
+#[derive(Debug, Clone, PartialEq, Serialize)]
+pub struct Composed {
+ pub kind: Compose,
+ /// Present for an email, absent for everything else.
+ pub subject: Option,
+ pub body: String,
+ /// `mailto:` with the subject and body filled in, for the "open in my mail app" button.
+ ///
+ /// Only for an email. A `mailto:` holding a Slack message would open the wrong application and
+ /// look like a bug in the button rather than a decision.
+ pub mailto: Option,
+}
+
+/// Ask for a draft.
+#[derive(Debug, Clone, Deserialize)]
+pub struct Request {
+ #[serde(default = "default_kind")]
+ pub kind: Compose,
+ #[serde(default)]
+ pub tone: Tone,
+ /// Who is going to read it, in the user's own words: "khách hàng ACME", "team kỹ thuật".
+ #[serde(default)]
+ pub audience: Option,
+ /// Language to write in, or the one configured for summaries.
+ #[serde(default)]
+ pub language: Option,
+}
+
+fn default_kind() -> Compose {
+ Compose::Email
+}
+
+/// How much of a meeting to hand the model when there is no summary.
+///
+/// A transcript is long, the useful part of it is at the start and the end, and a request holding
+/// forty thousand characters is slow, expensive and — for a local model with a small context —
+/// silently truncated at whichever end the runtime chooses.
+const TRANSCRIPT_BUDGET: usize = 6_000;
+
+/// Draft a message from a meeting.
+pub async fn compose(
+ paths: &Paths,
+ client: &LlmClient,
+ meeting: &MeetingId,
+ request: &Request,
+) -> Result {
+ let notes = source(paths, meeting)?;
+ if notes.trim().chars().count() < 80 {
+ return Err(Error::msg(
+ "compose.empty",
+ "buổi này chưa có đủ nội dung để soạn",
+ ));
+ }
+
+ let language = request.language.clone().unwrap_or_else(|| {
+ summo_core::settings::Settings::load(&paths.settings())
+ .ok()
+ .map(|s| s.llm.language)
+ .filter(|l| !l.is_empty())
+ .unwrap_or_else(|| "the language of the notes".into())
+ });
+
+ let messages = summo_llm::prompt::compose(
+ request.kind,
+ request.tone,
+ request.audience.as_deref(),
+ ¬es,
+ &language,
+ );
+ let response = client.complete(&messages).await?;
+ Ok(finish(request.kind, &response))
+}
+
+/// Split what came back and build the link, with no model involved.
+///
+/// Separate from [`compose`] so the parsing is testable without a provider — the subject line is
+/// the part that breaks, and it breaks per language.
+#[must_use]
+pub fn finish(kind: Compose, response: &str) -> Composed {
+ let cleaned = strip_fence(response);
+ let (subject, body) = match kind {
+ Compose::Email => summo_llm::prompt::split_subject(cleaned),
+ _ => (None, cleaned.trim().to_string()),
+ };
+ let mailto = (kind == Compose::Email).then(|| mailto(subject.as_deref(), &body));
+ Composed {
+ kind,
+ subject,
+ body,
+ mailto,
+ }
+}
+
+/// A `mailto:` the operating system will hand to whichever mail application is set up.
+///
+/// No recipient: Summo does not know the address and guessing one from the attendee list is how a
+/// draft goes to the wrong person. The user's mail client asks, which it was going to do anyway.
+fn mailto(subject: Option<&str>, body: &str) -> String {
+ let encoded = |s: &str| {
+ let mut out = String::with_capacity(s.len());
+ for byte in s.as_bytes() {
+ match byte {
+ b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
+ out.push(*byte as char);
+ }
+ other => out.push_str(&format!("%{other:02X}")),
+ }
+ }
+ out
+ };
+ let mut url = String::from("mailto:?");
+ if let Some(subject) = subject {
+ url.push_str(&format!("subject={}&", encoded(subject)));
+ }
+ url.push_str(&format!("body={}", encoded(body)));
+ url
+}
+
+/// Keep the draft, as a note in the vault.
+///
+/// Because the alternative is that it lives in a text box until the tab closes. A note is a file
+/// the user already knows how to find, it is searchable and the assistant can read it, and the
+/// meeting it came from is linked from it.
+pub fn save(paths: &Paths, meeting: &MeetingId, title: &str, body: &str) -> Result {
+ let day = time::OffsetDateTime::now_local()
+ .unwrap_or_else(|_| time::OffsetDateTime::now_utc())
+ .date()
+ .to_string();
+ let body = format!("{}\n\n---\n\nTừ buổi họp `{meeting}`.\n", body.trim());
+ let (id, _) = summo_vault::note::create(paths, title, &day, &body)?;
+ Ok(id)
+}
+
+/// What the model is shown: the confirmed write-up, or the transcript if there is not one.
+fn source(paths: &Paths, meeting: &MeetingId) -> Result {
+ let vault = paths.vault();
+ let index = summo_vault::index::MeetingIndex::scan(&vault)?;
+ let entry = index
+ .entries()
+ .iter()
+ .find(|e| &e.id == meeting)
+ .ok_or_else(|| Error::msg("compose.missing", format!("không có buổi nào id {meeting}")))?;
+ let path = vault.join(&entry.path);
+ let markdown = std::fs::read_to_string(&path).map_err(|e| Error::io(&path, e))?;
+ let doc = MeetingDoc::parse(&markdown)?;
+
+ let mut written = String::new();
+ if !doc.body.trim().is_empty() {
+ written.push_str(doc.body.trim());
+ written.push_str("\n\n");
+ }
+ for section in &doc.sections {
+ written.push_str(&format!(
+ "## {}\n{}\n\n",
+ section.heading,
+ section.body.trim()
+ ));
+ }
+
+ if written.trim().chars().count() >= 200 {
+ return Ok(format!("# {}\n\n{written}", doc.title));
+ }
+
+ // No summary worth the name. The transcript, trimmed from the middle: an opening and an ending
+ // are what a meeting is about, and the middle is where the tangent about lunch lives.
+ let transcript = summo_llm::prompt::render_transcript(&doc.transcript);
+ Ok(format!(
+ "# {}\n\n{}\n\n{written}",
+ doc.title,
+ clip(&transcript)
+ ))
+}
+
+/// Keep the start and the end, and say where the cut is.
+fn clip(text: &str) -> String {
+ if text.chars().count() <= TRANSCRIPT_BUDGET {
+ return text.to_string();
+ }
+ let half = TRANSCRIPT_BUDGET / 2;
+ let head: String = text.chars().take(half).collect();
+ let tail: String = {
+ let all: Vec = text.chars().collect();
+ all[all.len() - half..].iter().collect()
+ };
+ format!("{head}\n\n[…]\n\n{tail}")
+}
+
+fn strip_fence(text: &str) -> &str {
+ let trimmed = text.trim();
+ let Some(rest) = trimmed.strip_prefix("```") else {
+ return trimmed;
+ };
+ let body = rest.split_once('\n').map_or(rest, |(_, body)| body);
+ body.trim_end().strip_suffix("```").unwrap_or(body).trim()
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn an_email_gets_its_subject_split_off_and_a_link_built() {
+ let out = finish(
+ Compose::Email,
+ "Subject: Chốt giá gói doanh nghiệp\n\nChào anh,\n\nNhư đã trao đổi…",
+ );
+ assert_eq!(out.subject.as_deref(), Some("Chốt giá gói doanh nghiệp"));
+ assert!(out.body.starts_with("Chào anh"));
+ let link = out.mailto.unwrap();
+ assert!(link.starts_with("mailto:?subject="));
+ // Percent-encoded, because a raw newline or `&` in a URL truncates the body at the point
+ // it appears — the mail client opens with half a message and nobody notices which half.
+ assert!(!link.contains(' ') && !link.contains('\n'));
+ assert!(link.contains("%C3%A1") || link.contains("%E1%BB"));
+ }
+
+ /// The first line of a chat message is a sentence, not a subject, and promoting it to one
+ /// deletes it from the body.
+ #[test]
+ fn a_chat_message_keeps_its_first_line() {
+ let out = finish(Compose::Message, "Chốt giá 12 triệu.\nBình gửi hợp đồng.");
+ assert!(out.subject.is_none());
+ assert!(out.body.starts_with("Chốt giá"));
+ assert!(out.mailto.is_none(), "a chat message is not an email");
+ }
+
+ #[test]
+ fn a_fenced_answer_is_unwrapped() {
+ let out = finish(Compose::Recap, "```markdown\nHọp đã chốt giá.\n```");
+ assert_eq!(out.body, "Họp đã chốt giá.");
+ }
+
+ #[test]
+ fn a_long_transcript_is_clipped_from_the_middle() {
+ let text = "a".repeat(TRANSCRIPT_BUDGET * 2);
+ let clipped = clip(&text);
+ assert!(clipped.chars().count() < text.chars().count());
+ assert!(clipped.contains("[…]"));
+ }
+}
diff --git a/crates/summo-engine/src/lib.rs b/crates/summo-engine/src/lib.rs
index a49c4b5..d126e02 100644
--- a/crates/summo-engine/src/lib.rs
+++ b/crates/summo-engine/src/lib.rs
@@ -20,7 +20,9 @@ pub mod assets;
pub mod audio_stream;
pub mod auth;
pub mod board;
+pub mod calsync;
pub mod collaborate;
+pub mod compose;
pub mod draft;
pub mod embedded;
pub mod errand;
diff --git a/crates/summo-engine/src/nudge.rs b/crates/summo-engine/src/nudge.rs
index 6fcdef3..2e458b9 100644
--- a/crates/summo-engine/src/nudge.rs
+++ b/crates/summo-engine/src/nudge.rs
@@ -37,6 +37,8 @@ pub enum Reason {
DraftWaiting,
/// A task passed its due date.
Overdue,
+ /// A meeting on the calendar is starting, and nothing is recording it.
+ MeetingSoon,
}
impl Reason {
@@ -47,6 +49,7 @@ impl Reason {
Self::WeeklyRollup => "weekly-rollup",
Self::DraftWaiting => "draft-waiting",
Self::Overdue => "overdue",
+ Self::MeetingSoon => "meeting-soon",
}
}
}
@@ -209,6 +212,65 @@ pub fn due(
Ok(out)
}
+/// How early a meeting is worth mentioning, and how late it is still worth mentioning it.
+///
+/// Five minutes before, because the useful moment is while the person is opening the call, not
+/// while they are already in it. Ten minutes after, because the far more common failure is
+/// remembering to record at minute eight — and a prompt then still saves the rest of the meeting.
+const BEFORE_S: i64 = 300;
+const AFTER_S: i64 = 600;
+
+/// Ask whether to take notes, because a meeting on the calendar is starting.
+///
+/// Separate from [`due`] because it is a different question asked on a different clock: `due` looks
+/// back over a day at what the vault holds, this looks at the next few minutes of somebody else's
+/// calendar. Folding them together would mean either running the calendar check hourly or running
+/// the vault scan every minute.
+///
+/// **It only ever asks.** `suggest` is `Recording::suggest_on_meeting` and this returns nothing when
+/// it is off. Nothing here starts a recording, and nothing here should ever be changed to: a
+/// calendar entry is not consent, and the therapy appointment in a work calendar is the reason.
+pub fn meeting_soon(
+ paths: &Paths,
+ seen: &Seen,
+ today: &str,
+ now_epoch: i64,
+ recording: bool,
+ suggest: bool,
+) -> Vec {
+ if !suggest || recording {
+ return Vec::new();
+ }
+
+ let mut out = Vec::new();
+ for entry in crate::agenda::agenda(paths) {
+ let until = entry.start_epoch - now_epoch;
+ if until > BEFORE_S || -until > AFTER_S {
+ continue;
+ }
+ // Keyed by the occurrence, not by the event: a daily standup is a different meeting every
+ // morning, and one that fired on Monday must still fire on Tuesday.
+ let key = format!("meeting:{}:{}", entry.uid, entry.start_epoch);
+ if seen.already_today(&key, today) {
+ continue;
+ }
+ out.push(Nudge {
+ reason: Reason::MeetingSoon,
+ title: if until > 0 {
+ format!("{} sắp bắt đầu", entry.summary)
+ } else {
+ format!("{} đang diễn ra", entry.summary)
+ },
+ body: "Ghi chú buổi này không?".into(),
+ route: "/".into(),
+ key,
+ });
+ }
+ // One prompt, even when two calendars hold the same meeting or two meetings collide.
+ out.truncate(1);
+ out
+}
+
/// Mark nudges as said, so they do not fire again today.
pub fn record(paths: &Paths, seen: &mut Seen, nudges: &[Nudge], today: &str) -> Result<()> {
for nudge in nudges {
@@ -269,6 +331,84 @@ mod tests {
use super::*;
use tempfile::TempDir;
+ /// A calendar with one meeting in it, starting at `start_epoch`.
+ fn calendar(paths: &Paths, start_epoch: i64) {
+ let dir = paths.calendars();
+ std::fs::create_dir_all(&dir).unwrap();
+ let stamp = |epoch: i64| {
+ time::OffsetDateTime::from_unix_timestamp(epoch)
+ .unwrap()
+ .format(
+ &time::format_description::parse_borrowed::<2>(
+ "[year][month][day]T[hour][minute][second]Z",
+ )
+ .unwrap(),
+ )
+ .unwrap()
+ };
+ std::fs::write(
+ dir.join("work.ics"),
+ format!(
+ "BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\nUID:standup-1\r\nSUMMARY:Họp đầu tuần\r\n\
+ DTSTART:{}\r\nDTEND:{}\r\nATTENDEE:mailto:a@x.vn\r\nATTENDEE:mailto:b@x.vn\r\n\
+ END:VEVENT\r\nEND:VCALENDAR\r\n",
+ stamp(start_epoch),
+ stamp(start_epoch + 1800),
+ ),
+ )
+ .unwrap();
+ }
+
+ #[test]
+ fn a_meeting_about_to_start_is_worth_asking_about() {
+ let (_dir, paths) = vault(&[]);
+ let now = 1_800_000_000;
+ calendar(&paths, now + 120);
+
+ let due = meeting_soon(&paths, &Seen::default(), "2027-01-15", now, false, true);
+ assert_eq!(due.len(), 1);
+ assert_eq!(due[0].reason, Reason::MeetingSoon);
+ assert!(due[0].title.contains("Họp đầu tuần"));
+ }
+
+ /// The switch is `Recording::suggest_on_meeting`, and off means silent — not quieter.
+ #[test]
+ fn nothing_is_said_when_the_suggestion_is_turned_off() {
+ let (_dir, paths) = vault(&[]);
+ let now = 1_800_000_000;
+ calendar(&paths, now + 120);
+
+ assert!(meeting_soon(&paths, &Seen::default(), "2027-01-15", now, false, false).is_empty());
+ // Nor during a meeting that is already being recorded, which is the worst moment for a
+ // notification and the one where a screen is most likely shared.
+ assert!(meeting_soon(&paths, &Seen::default(), "2027-01-15", now, true, true).is_empty());
+ }
+
+ #[test]
+ fn a_meeting_that_is_not_close_is_left_alone() {
+ let (_dir, paths) = vault(&[]);
+ let now = 1_800_000_000;
+ calendar(&paths, now + 3_600);
+ assert!(meeting_soon(&paths, &Seen::default(), "2027-01-15", now, false, true).is_empty());
+
+ // And one that has been running for half an hour: whoever is in it is in it.
+ let (_dir2, later) = vault(&[]);
+ calendar(&later, now - 1_800);
+ assert!(meeting_soon(&later, &Seen::default(), "2027-01-15", now, false, true).is_empty());
+ }
+
+ #[test]
+ fn the_same_meeting_is_only_asked_about_once() {
+ let (_dir, paths) = vault(&[]);
+ let now = 1_800_000_000;
+ calendar(&paths, now + 120);
+
+ let mut seen = Seen::default();
+ let due = meeting_soon(&paths, &seen, "2027-01-15", now, false, true);
+ record(&paths, &mut seen, &due, "2027-01-15").unwrap();
+ assert!(meeting_soon(&paths, &seen, "2027-01-15", now, false, true).is_empty());
+ }
+
fn vault(meetings: &[(&str, &str, &str)]) -> (TempDir, Paths) {
let dir = TempDir::new().unwrap();
let paths = Paths::at(dir.path());
diff --git a/crates/summo-engine/src/server.rs b/crates/summo-engine/src/server.rs
index cd558cf..7d9907a 100644
--- a/crates/summo-engine/src/server.rs
+++ b/crates/summo-engine/src/server.rs
@@ -75,6 +75,12 @@ struct AppState {
/// both — and `port: 0` means the OS chooses. So the one handler that needs to *tell* the page
/// which port it is on reads it from here rather than from a value that would have been zero.
port: std::sync::Arc,
+ /// Notified when something asks the daemon to stop.
+ ///
+ /// A background daemon has no terminal to press Ctrl-C in, so `summo stop` has to reach it the
+ /// only way anything reaches it: over HTTP, with the token. The process still decides to exit
+ /// itself rather than being signalled, which is what lets it finish writing a recording first.
+ stopping: std::sync::Arc,
}
impl AppState {
@@ -108,6 +114,7 @@ pub struct Server {
addr: SocketAddr,
token: SessionToken,
handle: tokio::task::JoinHandle<()>,
+ stopping: std::sync::Arc,
}
impl Server {
@@ -121,8 +128,10 @@ impl Server {
token: token.clone(),
allow_loopback_origins: cfg.allow_loopback_origins,
port: std::sync::Arc::new(std::sync::atomic::AtomicU16::new(0)),
+ stopping: std::sync::Arc::new(tokio::sync::Notify::new()),
};
let port_slot = state.port.clone();
+ let stopping = state.stopping.clone();
if cfg.allow_loopback_origins {
tracing::warn!(
@@ -140,6 +149,7 @@ impl Server {
.route("/settings/models", post(set_models))
.route("/agent/run", post(run_errand))
.route("/status", get(status))
+ .route("/shutdown", post(shutdown))
.route("/storage", get(storage))
.route("/storage/prune", post(prune_storage))
.route("/meetings/{id}/audio", axum::routing::delete(forget_audio))
@@ -161,6 +171,8 @@ impl Server {
.route("/meetings/{id}/draft/refine", post(refine_draft))
.route("/meetings/{id}/draft/chat", post(chat_draft))
.route("/meetings/{id}/draft/confirm", post(confirm_draft))
+ .route("/meetings/{id}/compose", post(compose_message))
+ .route("/meetings/{id}/compose/save", post(save_composed))
.route("/meetings/{id}/draft", axum::routing::delete(discard_draft))
.route("/tasks/{id}", post(update_task))
.route("/tasks/{id}/run", post(run_task))
@@ -178,7 +190,9 @@ impl Server {
.route("/meetings/{id}/comments/{comment}/react", post(react_comment))
.route("/agenda", get(agenda))
.route("/agenda/suggest", get(suggest_meeting))
- .route("/calendars", post(add_calendar))
+ .route("/calendars", get(list_calendars).post(add_calendar))
+ .route("/calendars/subscribe", post(subscribe_calendar))
+ .route("/calendars/refresh", post(refresh_calendars))
.route("/calendars/{name}", axum::routing::delete(remove_calendar))
.route("/onboarding", get(onboarding))
.route("/onboarding/complete", post(complete_onboarding))
@@ -251,6 +265,31 @@ impl Server {
port_slot.store(addr.port(), std::sync::atomic::Ordering::Relaxed);
tracing::info!(%addr, bundled = crate::assets::bundled(), "engine listening");
+ // Calendars, kept current while the daemon runs.
+ //
+ // A subscription that only refreshed when somebody opened the agenda would be useless for
+ // the one thing it is for: telling the user a meeting is starting before it starts. Fetched
+ // on startup and then on a timer, and only when there is something to fetch — a user with
+ // no subscriptions makes no requests to anybody.
+ {
+ let paths = engine.paths().clone();
+ tokio::spawn(async move {
+ loop {
+ match crate::calsync::list(&paths) {
+ Ok(subscriptions) if !subscriptions.is_empty() => {
+ if let Err(e) = crate::calsync::refresh(&paths, None).await {
+ tracing::warn!(error = %e, "calendar sync failed");
+ }
+ }
+ Ok(_) => {}
+ Err(e) => tracing::warn!(error = %e, "cannot read calendar subscriptions"),
+ }
+ tokio::time::sleep(std::time::Duration::from_secs(crate::calsync::REFRESH_S))
+ .await;
+ }
+ });
+ }
+
let handle = tokio::spawn(async move {
if let Err(e) = axum::serve(listener, app).await {
tracing::error!(error = %e, "server stopped");
@@ -261,6 +300,7 @@ impl Server {
addr,
token,
handle,
+ stopping,
})
}
@@ -277,6 +317,14 @@ impl Server {
pub fn shutdown(self) {
self.handle.abort();
}
+
+ /// Resolves when something asked the daemon to stop over HTTP.
+ ///
+ /// Awaited beside Ctrl-C, so a daemon started in the background and one started in a terminal
+ /// stop the same way and run the same cleanup.
+ pub async fn stop_requested(&self) {
+ self.stopping.notified().await;
+ }
}
/// Tell the browser what the daemon already decided.
@@ -1549,6 +1597,63 @@ async fn generate_draft(
)
}
+/// Draft something to send out of a meeting: an email, a chat message, a recap, a list of actions.
+///
+/// Nothing is sent. The draft comes back to the screen with a `mailto:` link for the user's own
+/// mail application — see [`crate::compose`].
+async fn compose_message(
+ State(state): State,
+ headers: HeaderMap,
+ Path(id): Path,
+ Query(q): Query,
+ Json(body): Json,
+) -> impl IntoResponse {
+ if let Err(rejection) = state.guard(&headers, q.token.as_deref()) {
+ return rejection.into_response();
+ }
+ let client = match llm_client(&state) {
+ Ok(client) => client,
+ Err(e) => return as_response(Err::(e)),
+ };
+ as_response(
+ crate::compose::compose(
+ state.engine.paths(),
+ &client,
+ &summo_core::MeetingId::from(id),
+ &body,
+ )
+ .await,
+ )
+}
+
+#[derive(Debug, Deserialize)]
+struct SaveComposedBody {
+ title: String,
+ body: String,
+}
+
+/// Keep a draft as a note, so it outlives the tab it was written in.
+async fn save_composed(
+ State(state): State,
+ headers: HeaderMap,
+ Path(id): Path,
+ Query(q): Query,
+ Json(body): Json,
+) -> impl IntoResponse {
+ if let Err(rejection) = state.guard(&headers, q.token.as_deref()) {
+ return rejection.into_response();
+ }
+ as_response(
+ crate::compose::save(
+ state.engine.paths(),
+ &summo_core::MeetingId::from(id),
+ &body.title,
+ &body.body,
+ )
+ .map(|note| serde_json::json!({ "note": note.to_string() })),
+ )
+}
+
#[derive(Debug, Deserialize)]
struct RefineBody {
heading: String,
@@ -1663,15 +1768,32 @@ async fn nudges(
let paths = state.engine.paths();
let result = (|| {
+ let recording = state.engine.status().is_recording();
let mut seen = crate::nudge::load(paths)?;
- let due = crate::nudge::due(
+ let mut due = crate::nudge::due(
paths,
&seen,
&today,
now.hour(),
now.date().weekday().number_from_monday(),
- state.engine.status().is_recording(),
+ recording,
)?;
+ // The calendar prompt goes first: it is about the next five minutes, and everything else
+ // here is about a day that has already happened.
+ let suggest = summo_core::Settings::load(&paths.settings())
+ .unwrap_or_default()
+ .recording
+ .suggest_on_meeting;
+ let mut soon = crate::nudge::meeting_soon(
+ paths,
+ &seen,
+ &today,
+ now.unix_timestamp(),
+ recording,
+ suggest,
+ );
+ soon.append(&mut due);
+ let due = soon;
crate::nudge::record(paths, &mut seen, &due, &today)?;
Ok(due)
})();
@@ -2433,6 +2555,11 @@ async fn read_note(
serde_json::json!({
"title": doc.title,
"body": doc.body,
+ // Everything under the title, which is what the editor edits. `body` alone stops
+ // at the first `##`, so a note with headings — one written in Obsidian, one
+ // started from a template, one saved out of a composed message — opened with most
+ // of itself missing and was then saved back that way.
+ "text": summo_vault::note::as_text(&doc),
"frontmatter": doc.frontmatter,
"sections": doc.sections.iter().map(|s| serde_json::json!({
"heading": s.heading,
@@ -2708,10 +2835,116 @@ async fn remove_calendar(
if let Err(rejection) = state.guard(&headers, q.token.as_deref()) {
return rejection.into_response();
}
- as_response(
- crate::agenda::forget(state.engine.paths(), &name)
- .map(|removed| serde_json::json!({ "removed": removed })),
- )
+ let paths = state.engine.paths();
+ // A subscription owns its file, so unsubscribing removes both. A calendar that was copied in
+ // has no subscription and only the file to remove — and asking in that order means a
+ // subscription never leaves its `.ics` behind to keep filling the agenda.
+ let result = crate::calsync::unsubscribe(paths, &name).and_then(|removed| {
+ if removed {
+ Ok(true)
+ } else {
+ crate::agenda::forget(paths, &name)
+ }
+ });
+ as_response(result.map(|removed| serde_json::json!({ "removed": removed })))
+}
+
+#[derive(Debug, Default, Deserialize)]
+struct ShutdownBody {
+ /// Stop even though a recording is in progress.
+ #[serde(default)]
+ force: bool,
+}
+
+/// Ask the daemon to stop.
+///
+/// Refused while recording unless forced. `summo stop` in a terminal cannot see that a meeting is
+/// being recorded in the tray, and a daemon that exits on request would end that meeting with no
+/// question asked — the answer being "yes, obviously" often enough that it must still be asked.
+async fn shutdown(
+ State(state): State,
+ headers: HeaderMap,
+ Query(q): Query,
+ body: Option>,
+) -> impl IntoResponse {
+ if let Err(rejection) = state.guard(&headers, q.token.as_deref()) {
+ return rejection.into_response();
+ }
+ let force = body.is_some_and(|Json(b)| b.force);
+ if state.engine.status().is_recording() && !force {
+ let refused: Result = Err(Error::msg(
+ "daemon.recording",
+ "đang ghi âm — dừng buổi ghi trước, hoặc dùng --force",
+ ));
+ return as_response(refused);
+ }
+ // `notify_one` rather than `notify_waiters`: it leaves a permit behind, so a request that
+ // arrives in the instant before the waiter is registered still stops the daemon.
+ state.stopping.notify_one();
+ as_response(Ok(serde_json::json!({ "stopping": true })))
+}
+
+/// Every calendar the app knows about: subscriptions with their sync state, and files copied in.
+async fn list_calendars(
+ State(state): State,
+ headers: HeaderMap,
+ Query(q): Query,
+) -> impl IntoResponse {
+ if let Err(rejection) = state.guard(&headers, q.token.as_deref()) {
+ return rejection.into_response();
+ }
+ let paths = state.engine.paths();
+ let result = crate::calsync::list(paths).map(|subscriptions| {
+ let subscribed: std::collections::HashSet<_> =
+ subscriptions.iter().map(|s| s.name.clone()).collect();
+ // Files the user copied in themselves, which have no URL and never refresh. Listed anyway:
+ // an agenda showing meetings from a source with no row in Settings is a mystery.
+ let files: Vec<_> = crate::agenda::load(paths)
+ .into_iter()
+ .filter(|(name, _)| !subscribed.contains(name))
+ .map(|(name, events)| serde_json::json!({ "name": name, "events": events.len() }))
+ .collect();
+ serde_json::json!({ "subscriptions": subscriptions, "files": files })
+ });
+ as_response(result)
+}
+
+#[derive(Debug, Deserialize)]
+struct SubscribeBody {
+ title: String,
+ url: String,
+}
+
+async fn subscribe_calendar(
+ State(state): State,
+ headers: HeaderMap,
+ Query(q): Query,
+ Json(body): Json,
+) -> impl IntoResponse {
+ if let Err(rejection) = state.guard(&headers, q.token.as_deref()) {
+ return rejection.into_response();
+ }
+ as_response(crate::calsync::subscribe(state.engine.paths(), &body.title, &body.url).await)
+}
+
+#[derive(Debug, Default, Deserialize)]
+struct RefreshBody {
+ /// One calendar, or every one of them.
+ #[serde(default)]
+ name: Option,
+}
+
+async fn refresh_calendars(
+ State(state): State,
+ headers: HeaderMap,
+ Query(q): Query,
+ body: Option>,
+) -> impl IntoResponse {
+ if let Err(rejection) = state.guard(&headers, q.token.as_deref()) {
+ return rejection.into_response();
+ }
+ let name = body.and_then(|Json(b)| b.name);
+ as_response(crate::calsync::refresh(state.engine.paths(), name.as_deref()).await)
}
/// Everyone Summo can recognise.
@@ -3892,7 +4125,16 @@ mod tests {
seed_audio(&tmp, "01A", "mic", 100);
std::fs::write(tmp.path().join("secret.opus"), b"do not serve me").unwrap();
- for lane in ["..%2F..%2Fsecret", "..", "mic%2F..%2F..%2Fsecret"] {
+ // A lane that is *only* dots — `..` or `%2E%2E` — is not in this list, and cannot be: the
+ // URL parser in the client collapses both before a request is sent, so the daemon is asked
+ // for `/meetings/01A/` and answers it correctly. What is tested here is the case that does
+ // arrive intact, a single path segment with an encoded separator inside it, which is what
+ // would reach the file system if the lane were joined onto a path unchecked.
+ for lane in [
+ "..%2F..%2Fsecret",
+ "mic%2F..%2F..%2Fsecret",
+ "%2E%2E%2Fsecret",
+ ] {
let resp = client()
.get(format!(
"http://{}/meetings/01A/audio/{lane}",
diff --git a/crates/summo-llm/src/prompt.rs b/crates/summo-llm/src/prompt.rs
index 1503142..095c57b 100644
--- a/crates/summo-llm/src/prompt.rs
+++ b/crates/summo-llm/src/prompt.rs
@@ -275,6 +275,133 @@ pub fn answer(question: &str, context: &str, language: &str) -> Vec {
]
}
+/// What to write out of a meeting.
+///
+/// Not a free-text "style" field: each of these wants a different *shape*, and a shape is what the
+/// model gets wrong when asked politely. An email needs a subject line and a greeting; a chat
+/// message must fit in a glance or nobody reads it; a recap is for people who were not there and
+/// therefore cannot be told "as discussed".
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "lowercase")]
+pub enum Compose {
+ /// An email: subject line, greeting, body, sign-off.
+ Email,
+ /// A short message for a chat app — Slack, Teams, Zalo.
+ Message,
+ /// A recap for people who were not in the meeting.
+ Recap,
+ /// The decisions and who owes what, as a list.
+ Actions,
+}
+
+impl Compose {
+ fn shape(self) -> &'static str {
+ match self {
+ Self::Email => {
+ "Write an email. First line: `Subject: …` and nothing else on it. Then a blank \
+ line, then a greeting, the body in short paragraphs, and a sign-off. Do not sign a \
+ name — leave the sign-off as a greeting the sender completes."
+ }
+ Self::Message => {
+ "Write a chat message for Slack or Teams: under 80 words, no subject line, no \
+ greeting, no sign-off. Lead with the outcome. Bullets only if there is genuinely \
+ a list."
+ }
+ Self::Recap => {
+ "Write a recap for people who were not at the meeting. No subject line. Say what \
+ was decided and what happens next. Never write \"as discussed\" or \"as you \
+ know\" — the reader was not there."
+ }
+ Self::Actions => {
+ "List the decisions, then the actions as `- [ ] @person — what — when`. One line \
+ each. Only people who actually took something on. No preamble and no closing \
+ paragraph."
+ }
+ }
+ }
+}
+
+/// How it should sound.
+#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "lowercase")]
+pub enum Tone {
+ #[default]
+ Neutral,
+ Friendly,
+ Formal,
+}
+
+impl Tone {
+ fn describe(self) -> &'static str {
+ match self {
+ Self::Neutral => "Plain and direct. No enthusiasm the facts do not support.",
+ Self::Friendly => "Warm and colloquial, the way a colleague writes. Still brief.",
+ Self::Formal => "Formal and precise, for a client or an executive. No contractions.",
+ }
+ }
+}
+
+/// Draft something to send, out of what a meeting contained.
+///
+/// The hardest rule to get a model to keep here is the one that matters most: **invent nothing**. A
+/// model asked to write a follow-up email will cheerfully add a deadline nobody agreed to, and the
+/// user sends it, because a fluent email does not look like a wrong one. So the instruction is not
+/// "be accurate" — it is to leave a marker where it wanted a fact it does not have, which is
+/// visible in the draft and impossible to send by accident.
+#[must_use]
+pub fn compose(
+ kind: Compose,
+ tone: Tone,
+ audience: Option<&str>,
+ notes: &str,
+ language: &str,
+) -> Vec {
+ let audience = match audience.map(str::trim).filter(|a| !a.is_empty()) {
+ Some(who) => format!("The reader is: {who}. Write for them.\n\n"),
+ None => String::new(),
+ };
+ // Deliberately not [`GROUND_RULES`]. Those end with "cite the timestamp in the form [t=MM:SS]",
+ // which is right for a summary of a recording and absurd in an email to a customer — the first
+ // draft this produced was a perfectly polite message with `[t=03:12]` in the middle of it. What
+ // carries over is the part that matters everywhere: invent nothing.
+ vec![
+ Message::system(format!(
+ "You are writing a message on behalf of somebody who was at a meeting. Its notes are \
+ below. Never invent a fact, a name, a number, a date or a commitment that is not in \
+ them. If something is needed and missing, write `[…]` in its place rather than \
+ guessing, so the sender can see what to fill in. Never write a timestamp or a \
+ citation — this is a message to a person, not a summary of a recording.\n\nWrite in \
+ {language}.\n\n{}\n\n{}\n\n{audience}Return only the message itself — no \
+ explanation of what you wrote and no Markdown code fence around it.",
+ kind.shape(),
+ tone.describe(),
+ )),
+ Message::user(format!("Notes from the meeting:\n\n{notes}")),
+ ]
+}
+
+/// Pull `Subject: …` off the front of a composed email.
+///
+/// Returns `(subject, body)`, and `None` for the subject when the model did not write one — which
+/// is correct for every kind but [`Compose::Email`], and which must not turn the first line of a
+/// chat message into a subject line.
+#[must_use]
+pub fn split_subject(text: &str) -> (Option, String) {
+ let text = text.trim();
+ let Some((first, rest)) = text.split_once('\n') else {
+ return (None, text.to_string());
+ };
+ for prefix in ["Subject:", "Tiêu đề:", "Chủ đề:", "件名:", "主题:"] {
+ if let Some(subject) = first.trim().strip_prefix(prefix) {
+ return (
+ Some(subject.trim().to_string()),
+ rest.trim_start().to_string(),
+ );
+ }
+ }
+ (None, text.to_string())
+}
+
/// Rewrite one selected passage, and nothing else.
///
/// The model is given the whole section for context but asked to return **only** the replacement
diff --git a/crates/summo-vault/src/note.rs b/crates/summo-vault/src/note.rs
index d680d82..6f8c76e 100644
--- a/crates/summo-vault/src/note.rs
+++ b/crates/summo-vault/src/note.rs
@@ -116,11 +116,76 @@ pub fn set_body(paths: &Paths, id: &MeetingId, body: &str, title: Option<&str>)
{
doc.title = title.trim().to_string();
}
- doc.body = body.trim().to_string();
+ // Headings in the text are headings in the document, not four literal characters at the start
+ // of a line. Assigning the whole editor buffer to `body` and leaving `sections` as they were —
+ // which is what this did — wrote the file with the new text *and* the old sections still under
+ // it, so a note with any `##` in it grew a duplicate of itself on every save. It went unnoticed
+ // because the editor only ever displayed `body`, so the duplicate was invisible in the one
+ // place somebody would have seen it.
+ let (lead, sections) = split_sections(body);
+ doc.body = lead;
+ doc.sections = sections;
crate::write::write_atomically(&path, doc.to_markdown()?.as_bytes())?;
Ok(path)
}
+/// Split editor text into the free part and the `##` sections under it.
+///
+/// The transcript heading is *not* special-cased here: notes have no transcript, and a note whose
+/// user typed a heading with that name has typed a heading.
+#[must_use]
+pub fn split_sections(text: &str) -> (String, Vec) {
+ let mut lead = String::new();
+ let mut sections: Vec = Vec::new();
+
+ for line in text.lines() {
+ if let Some(heading) = line.strip_prefix("## ") {
+ sections.push(crate::meeting::Section {
+ heading: heading.trim().to_string(),
+ body: String::new(),
+ });
+ continue;
+ }
+ match sections.last_mut() {
+ Some(section) => {
+ section.body.push_str(line);
+ section.body.push('\n');
+ }
+ None => {
+ lead.push_str(line);
+ lead.push('\n');
+ }
+ }
+ }
+
+ for section in &mut sections {
+ section.body = section.body.trim_end().to_string();
+ }
+ (lead.trim().to_string(), sections)
+}
+
+/// The whole note as one piece of text, the way the editor shows it: everything under the title.
+///
+/// The document keeps body and sections apart, which is right for a *meeting* — the summary's
+/// sections are addressable, and the draft machinery rewrites one of them at a time. A note is one
+/// document a person is typing in, and handing the editor only `body` is what made a note written
+/// in Obsidian open with most of itself missing.
+#[must_use]
+pub fn as_text(doc: &crate::meeting::MeetingDoc) -> String {
+ let mut text = doc.body.trim().to_string();
+ for section in &doc.sections {
+ if !text.is_empty() {
+ text.push_str("\n\n");
+ }
+ text.push_str(&format!(
+ "## {}\n{}",
+ section.heading,
+ section.body.trim_end()
+ ));
+ }
+ text
+}
+
/// Delete a note. `false` when there was nothing there.
pub fn remove(paths: &Paths, id: &MeetingId) -> Result {
let Ok(path) = find(paths, id) else {
@@ -150,6 +215,50 @@ fn unique(dir: &Path, stem: &str) -> PathBuf {
#[cfg(test)]
mod tests {
+ /// The bug this pair of functions exists for: a note with headings used to open with everything
+ /// below the first `##` missing, and saving it back wrote the file with the visible part *and*
+ /// the invisible sections still underneath — a duplicate of the note, growing on every save.
+ #[test]
+ fn a_note_with_headings_survives_a_round_trip_through_the_editor() {
+ let dir = tempfile::tempdir().unwrap();
+ let paths = Paths::at(dir.path());
+ let seed = "## Quyết định\nChốt giá 12 triệu.\n\n## Bối cảnh\nACME muốn thử hai tuần.";
+ let (id, path) = create(&paths, "Chốt giá", "2026-08-14", seed).unwrap();
+
+ let shown = as_text(&read(&paths, &id).unwrap());
+ assert!(
+ shown.contains("## Quyết định"),
+ "the editor sees the headings: {shown}"
+ );
+ assert!(shown.contains("## Bối cảnh"));
+ assert!(shown.contains("ACME muốn thử hai tuần."));
+
+ set_body(
+ &paths,
+ &id,
+ &format!("{shown}\n\nNgọc chốt."),
+ Some("Chốt giá"),
+ )
+ .unwrap();
+
+ let on_disk = std::fs::read_to_string(&path).unwrap();
+ assert_eq!(
+ on_disk.matches("## Quyết định").count(),
+ 1,
+ "saving must not duplicate the sections:\n{on_disk}"
+ );
+ assert!(on_disk.contains("Ngọc chốt."));
+ let again = as_text(&read(&paths, &id).unwrap());
+ assert!(again.contains("## Bối cảnh") && again.contains("Ngọc chốt."));
+ }
+
+ #[test]
+ fn text_with_no_headings_is_left_exactly_as_it_is() {
+ let (lead, sections) = split_sections("một dòng\nhai dòng");
+ assert_eq!(lead, "một dòng\nhai dòng");
+ assert!(sections.is_empty());
+ }
+
use super::*;
fn vault() -> (tempfile::TempDir, Paths) {
diff --git a/docs/adr/0007-cloud-without-a-cloud.md b/docs/adr/0007-cloud-without-a-cloud.md
new file mode 100644
index 0000000..ff71b9b
--- /dev/null
+++ b/docs/adr/0007-cloud-without-a-cloud.md
@@ -0,0 +1,98 @@
+# 0007 — What a Summo cloud would be, and what it must never become
+
+**Status:** accepted (design; nothing hosted is built)
+**Date:** 2026-08-14
+
+## The question
+
+Everything Summo does happens on the machine it runs on. That is the product, and it is why the
+transcript of a salary conversation never leaves the laptop. But the questions that keep arriving —
+"can my two machines share a vault?", "can my team see the meeting?", "can it use GPT-5 for the
+summary?" — are all reasonable, and answering them badly is how a local-first app becomes an
+ordinary SaaS with a privacy page.
+
+This is the decision about which of those we will do, in what order, and what the shape has to be
+so the first one does not force the rest.
+
+## What already exists
+
+Not a plan on paper — code, with tests:
+
+| | Where | What it does today |
+|---|---|---|
+| Sync | `summo-sync` | Scan by content hash, three-way merge, conflict copies, sealed blobs |
+| Encryption | `summo-sync/src/crypto.rs` | Names and contents encrypted before they leave |
+| Remotes | `summo-sync/src/remote.rs` | A `Remote` trait; one implementation, a directory |
+| Models | `summo-llm/src/provider.rs` | Ollama, OpenAI, Anthropic, Gemini, Groq, and any base URL |
+
+So "sync to a folder on a NAS, a synced drive or a USB stick" ships today: `summo sync --to`. And a
+cloud model is already reachable — it is a setting, not a feature to build.
+
+## Decisions
+
+### 1. A relay stores bytes it cannot read, and nothing else
+
+The `Remote` trait is the whole interface: `get`, `put`, `list`, `manifest`, `salt`. A hosted relay
+is one more implementation of it, speaking HTTP instead of the file system. Everything that
+understands what a vault *is* — hashing, planning, merging, sealing — stays on the client, where the
+key is.
+
+This is the load-bearing decision. A relay that could search transcripts, render a summary or send a
+notification about a meeting would be a relay that could read the meeting, and no amount of policy
+gets that back. If a feature cannot be built against `get`/`put` of opaque blobs, it does not go in
+the relay.
+
+What it still learns: how many objects there are, how big they are, and when they change. That is
+metadata and it is not nothing — it is the honest limit of this design, and it belongs in the
+documentation rather than in a claim that the relay knows nothing.
+
+### 2. Sync before sharing, and sharing before teams
+
+In order, because each one is a superset of the last:
+
+1. **One person, many machines.** Already the shape `summo-sync` implements. A hosted relay changes
+ where the bytes go, not what they are. Key derived from a passphrase the user keeps; lose it and
+ the data is gone, said plainly at the moment it is set.
+2. **Sharing one meeting.** A single note, sealed to a key in the link — the relay stores a blob and
+ never holds the key that opens it. Expiry, and revocation by deleting the blob.
+3. **A team vault.** Where it stops being a file-sync problem: two people editing the same note
+ within a minute of each other is normal, not an exception, and conflict copies are the wrong
+ answer at that rate. This needs a design of its own and is explicitly not in scope here.
+
+### 3. Cloud models are a setting, not a tier
+
+`summo-llm` already speaks to five providers and any OpenAI-compatible base URL. Nothing needs to be
+built; what needs to be kept is the property that the default is local, that the screen says which
+provider a summary was written by, and that choosing a hosted model is a decision the user makes per
+installation rather than a default they discover afterwards.
+
+The same applies in reverse to recognition: sending audio to a hosted recogniser is a bigger
+disclosure than sending a summary prompt, and if it is ever offered it needs its own consent, not
+inheritance from the model setting.
+
+### 4. No account for the local app
+
+Sync needs somewhere to put bytes, which needs paying for, which needs an account — for the *relay*.
+Recording, transcribing, summarising, searching and asking must keep working with no account, no
+network and no sign-in, forever. A local-first app that shows a login screen on first run has
+already lost the argument it exists to make.
+
+## Consequences
+
+- The relay is small enough to be worth self-hosting, and that should be documented, not
+ discouraged: the same protocol, `docker run`, done.
+- Server-side search, server-side summarisation and server-side notifications are ruled out by
+ decision 1. Anything wanted from them has to happen on a device.
+- Sizes and timing leak. Padding is possible later; the honest position now is that it is not done.
+- The passphrase is unrecoverable by design. Any future "reset my password" is a redesign, not a
+ feature.
+
+## What was rejected
+
+**Signing in with Google to read calendars.** It would mean an OAuth client secret inside an
+open-source binary that anyone can read, plus an account-wide scope, so that a notes app can learn
+what time the standup is. Summo takes a subscription URL instead — see `summo-engine/src/calsync.rs`
+— which grants exactly one calendar and is revocable from the calendar's own settings.
+
+**A hosted "just works" tier where the server holds the key.** It is what most of this category
+does, it would be less work, and it would make every other claim in this repository untrue.