From 90175cc4dc37fb638943ac876b6a786d6e8f862c Mon Sep 17 00:00:00 2001 From: Viet Nguyen Date: Fri, 14 Aug 2026 08:34:36 +0000 Subject: [PATCH] feat: one tree of pages, and an agent that remembers what you keep asking for MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three corrections, and they are the same correction. **Writing the follow-up email was not a feature.** It had a panel of its own with four shapes and three tones, which could only ever be wrong for the fifth thing somebody wanted. It is one of the things people ask for, and what comes back is a note. So `compose.rs`, its two routes, its prompt shapes and its panel are gone, replaced by a sentence box on the note itself. 470 lines deleted, and the thing it did still works — through the agent, which was always the right path for it. **Everything is a note.** A recording is a note that has audio and a transcript attached; that is the model the vault has always had, and the interface kept contradicting it by filing recordings in one place and typed notes in another. The sidebar now lists pages of both kinds in the folders the user made, the way pages sit in Notion: expand a folder, see what is in it, click to open, `+` to make one there or at the root. Unfiled pages sit at the top level rather than being invisible until somebody files them. **The agent remembers what you keep asking for.** `summo_agent::habits` writes down every instruction given to an agent — the date, the words, the note it was about. Anything asked twice becomes a habit: offered back as a button so the fourth report costs a click instead of a paragraph, and given to the model so the fourth report *looks* like the first three, which is the actual complaint people have about assistants. It is `vault/agents/HABITS.md`, ordinary Markdown, one bullet per ask. Delete a line and the habit is forgotten — asserted in a browser test, because a memory the user cannot delete is the kind nobody wants. Two is the threshold: one is a Tuesday, five arrives long after the person has given up and started doing it by hand. What is stored is what they typed, never a conclusion about them — a file of guesses nobody can check would quietly steer every answer afterwards. Notes also became linkable (`/notes?open=`), which is what lets the tree open one at all. Verified: full Rust suite, 297 web tests, 18 browser suites including a new `pages.mjs` that drives the tree, makes a page from it, and checks that a deleted line is a forgotten habit. --- apps/web/e2e/pages.mjs | 144 +++++++++ apps/web/package.json | 2 +- apps/web/src/components/meeting/Ask.tsx | 120 +++++++ apps/web/src/components/meeting/Compose.tsx | 185 ----------- apps/web/src/components/search/Palette.tsx | 2 +- apps/web/src/components/shell/AppShell.tsx | 16 + apps/web/src/components/shell/RootLayout.tsx | 75 ++++- apps/web/src/components/shell/Sidebar.tsx | 123 ++++++- apps/web/src/i18n/en.json | 41 +-- apps/web/src/i18n/ja.json | 41 +-- apps/web/src/i18n/vi.json | 41 +-- apps/web/src/i18n/zh.json | 41 +-- apps/web/src/lib/ask.ts | 47 +++ apps/web/src/lib/compose.ts | 90 ------ apps/web/src/router.tsx | 5 + apps/web/src/screens/HomeScreen.tsx | 4 +- apps/web/src/screens/MeetingScreen.tsx | 10 +- apps/web/src/screens/NotesScreen.tsx | 22 +- crates/summo-agent/src/habits.rs | 317 +++++++++++++++++++ crates/summo-agent/src/lib.rs | 1 + crates/summo-agent/src/run.rs | 19 +- crates/summo-cli/src/daemon.rs | 20 +- crates/summo-engine/src/compose.rs | 273 ---------------- crates/summo-engine/src/errand.rs | 10 +- crates/summo-engine/src/lib.rs | 1 - crates/summo-engine/src/server.rs | 81 ++--- crates/summo-llm/src/prompt.rs | 127 -------- 27 files changed, 986 insertions(+), 872 deletions(-) create mode 100644 apps/web/e2e/pages.mjs create mode 100644 apps/web/src/components/meeting/Ask.tsx delete mode 100644 apps/web/src/components/meeting/Compose.tsx create mode 100644 apps/web/src/lib/ask.ts delete mode 100644 apps/web/src/lib/compose.ts create mode 100644 crates/summo-agent/src/habits.rs delete mode 100644 crates/summo-engine/src/compose.rs diff --git a/apps/web/e2e/pages.mjs b/apps/web/e2e/pages.mjs new file mode 100644 index 0000000..95a3ab7 --- /dev/null +++ b/apps/web/e2e/pages.mjs @@ -0,0 +1,144 @@ +/** + * One tree, both kinds of page — and an assistant that remembers what you keep asking for. + * + * The model this checks is the one the app is built on and the interface kept contradicting: a + * recording *is* a note. It has audio and a transcript attached, and everything else about it — how + * it is filed, searched, titled, opened — is what a typed note does. So the sidebar lists them + * together, in the folders the user made, the way pages sit in Notion. + * + * And the second half: what somebody asks an agent to do is worth remembering. Ask twice and the + * words come back as a button, so the fourth report costs a click rather than a paragraph. That + * list is `vault/agents/HABITS.md` and deleting a line forgets it — asserted here, because a + * memory the user cannot delete is the kind nobody wants. + */ +import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; + +import { chromium } from "playwright"; + +import { boot } from "./daemon.mjs"; + +const problems = []; +const engine = await boot({ name: "pages" }); +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}#/`, { + waitUntil: "networkidle", +}); + +// ---- the tree holds recordings and notes alike ---------------------------- +{ + const tree = page.getByLabel("Thư mục"); + await tree.waitFor({ timeout: 10000 }); + + // The seeded vault has a meeting filed under a folder and a note. Expanding the folder must show + // the meeting as a page, not merely narrow a list somewhere else on screen. + const folder = tree.getByRole("button", { name: "khach-hang" }); + if ((await folder.count()) === 0) { + problems.push("the seeded folder is not in the tree"); + } else { + await tree.getByRole("button", { name: /Mở rộng khach-hang|Thu gọn khach-hang/ }).click(); + await page.waitForTimeout(400); + const listed = await tree.innerText(); + if (!/Demo khách hàng|Ý tưởng giá|Họp/.test(listed)) { + problems.push(`no pages appeared under the folder: ${JSON.stringify(listed)}`); + } + } +} + +// ---- a page in the tree opens --------------------------------------------- +{ + const tree = page.getByLabel("Thư mục"); + const anyPage = tree + .locator("button") + .filter({ hasText: /Họp|Ý tưởng|Demo/ }) + .first(); + if ((await anyPage.count()) === 0) { + problems.push("no page to open"); + } else { + await anyPage.click(); + await page.waitForTimeout(1200); + const url = page.url(); + if (!/#\/(meetings\/|notes\?)/.test(url)) { + problems.push(`clicking a page went nowhere useful: ${url}`); + } + } +} + +// ---- a new page, from the tree -------------------------------------------- +{ + await page.getByRole("button", { name: "Trang mới", exact: true }).click(); + await page.waitForTimeout(1500); + if (!/#\/notes\?open=/.test(page.url())) { + problems.push(`"new page" did not open the page it made: ${page.url()}`); + } +} + +// ---- what you keep asking for becomes a button ---------------------------- +{ + // Written straight into the vault rather than by asking twice through the interface: an agent run + // needs a language model, and this is a test about the memory, not about the model. + // The roster is seeded on first use, so on a vault nobody has asked anything of yet the + // directory is not there — which is exactly the state this is testing from. + mkdirSync(join(engine.home, "vault", "agents"), { recursive: true }); + const habits = join(engine.home, "vault", "agents", "HABITS.md"); + writeFileSync( + habits, + "# Thói quen\n\n- 2026-08-01 — viết báo cáo sau họp\n- 2026-08-08 — viết báo cáo sau họp\n" + + "- 2026-08-09 — chỉ nhờ một lần\n", + ); + + const response = await fetch(`${engine.url}/agent/habits?token=${engine.token}`); + const learned = await response.json(); + if (learned.length !== 1) { + problems.push(`expected one habit, got ${JSON.stringify(learned)}`); + } else if (learned[0].times !== 2) { + problems.push(`the habit was not counted: ${JSON.stringify(learned[0])}`); + } + + // And it reaches the meeting screen, where the asking happens. + const meeting = await (await fetch(`${engine.url}/library?token=${engine.token}`)).json(); + const first = meeting.groups.flatMap((g) => g.meetings).find((m) => m.kind === "meeting"); + await page.goto(`${engine.url}?port=${engine.port}&token=${engine.token}#/meetings/${first.id}`, { + waitUntil: "networkidle", + }); + const offered = page.getByTestId("ask-habits"); + await offered + .waitFor({ timeout: 10000 }) + .catch(() => problems.push("the habit was never offered on the meeting")); + if ((await offered.count()) > 0) { + const text = await offered.innerText(); + if (!/viết báo cáo sau họp/.test(text)) problems.push(`wrong habit offered: ${text}`); + if (/chỉ nhờ một lần/.test(text)) problems.push("asked once is not a habit, and was offered"); + } +} + +// ---- deleting a line forgets it ------------------------------------------- +{ + const habits = join(engine.home, "vault", "agents", "HABITS.md"); + const kept = readFileSync(habits, "utf8") + .split("\n") + .filter((line) => !line.includes("2026-08-08")) + .join("\n"); + writeFileSync(habits, kept); + + const learned = await (await fetch(`${engine.url}/agent/habits?token=${engine.token}`)).json(); + if (learned.length !== 0) { + problems.push(`deleting the line did not forget it: ${JSON.stringify(learned)}`); + } +} + +await browser.close(); +await engine.stop(); + +if (problems.length > 0) { + console.error(problems.map((p) => ` - ${p}`).join("\n")); + process.exit(1); +} +console.log("pages ok"); diff --git a/apps/web/package.json b/apps/web/package.json index 942dc53..1084614 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/calendar.mjs && node e2e/notes.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/pages.mjs && node e2e/density.mjs", "lint": "eslint . --max-warnings 0", "format": "prettier --write .", "format:check": "prettier --check .", diff --git a/apps/web/src/components/meeting/Ask.tsx b/apps/web/src/components/meeting/Ask.tsx new file mode 100644 index 0000000..921a032 --- /dev/null +++ b/apps/web/src/components/meeting/Ask.tsx @@ -0,0 +1,120 @@ +import { Sparkles } from "lucide-react"; +import { useCallback, useState } from "react"; + +import { useI18n } from "../../i18n/context"; +import { useEngine } from "../../lib/engine-context"; +import { useErrorText } from "../../lib/errors"; +import { askAgent, fetchHabits, type Habit } from "../../lib/ask"; +import { useLoad } from "../../lib/use-load"; +import { Button, Input } from "../ui"; + +/** + * Ask for something, about this note. + * + * There was a panel here with four buttons — email, message, recap, actions — and three tones, and + * it was the wrong shape for what it did. Writing the follow-up email is not a feature beside + * recording and summarising; it is one of the things a person asks for, and what comes back is a + * note like every other note. A fixed menu of four could only ever be wrong for the fifth thing. + * + * So: a sentence, in the user's own words, handed to the agent — and above it, the sentences they + * have used before. That list is not a guess. It is `vault/agents/HABITS.md`, the instructions they + * have typed more than once, offered back so the fourth report costs one click instead of one + * paragraph of typing. The agent is given the same list, so the fourth report also *looks* like the + * first three, which is the part people actually complain about. + */ +export function AskPanel({ meeting }: { meeting: string }) { + const { handshake } = useEngine(); + const { t } = useI18n(); + const say = useErrorText(); + + const [instruction, setInstruction] = useState(""); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const [done, setDone] = useState(false); + + const habits = useLoad( + useCallback(async () => fetchHabits(handshake), [handshake]), + [handshake], + ); + + const ask = async (text: string) => { + const wanted = text.trim(); + if (!wanted) return; + setBusy(true); + setError(null); + setDone(false); + try { + await askAgent(handshake, wanted, meeting); + setDone(true); + setInstruction(""); + // Asked once more is asked twice: re-read so a habit appears the moment it becomes one. + habits.reload(); + } catch (e) { + setError(say(e)); + } finally { + setBusy(false); + } + }; + + const usual: Habit[] = habits.data ?? []; + + return ( +
+
+
+

{t("ask.hint")}

+ + {usual.length > 0 && ( +
+ {t("ask.usual")} +
    + {usual.slice(0, 4).map((habit) => ( +
  • + +
  • + ))} +
+
+ )} + +
+ setInstruction(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") void ask(instruction); + }} + placeholder={t("ask.placeholder")} + className="min-w-[16rem] flex-1" + data-testid="ask-input" + /> + +
+ + {error && ( +

+ {error} +

+ )} + {done && !error &&

{t("ask.done")}

} +
+ ); +} diff --git a/apps/web/src/components/meeting/Compose.tsx b/apps/web/src/components/meeting/Compose.tsx deleted file mode 100644 index 06ec4d9..0000000 --- a/apps/web/src/components/meeting/Compose.tsx +++ /dev/null @@ -1,185 +0,0 @@ -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 ( -
-
-
- - {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")} - /> - - -
- - {error && ( -

- {error} -

- )} - - {draft && ( -
- {draft.kind === "email" && ( - - setSubject(e.target.value)} /> - - )} - -