Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions docs/lifeboard/qa-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,10 @@ Needs Google connected (Connect → Google, with a Desktop-app client id).
| F8 | Send a cancellation | **Proposed**, never applied — even from a sender you have trusted |
| F9 | **Decline** something | Nothing changes anywhere |
| F10 | Accept three things from one sender | An offer to trust them from now on |
| F11 | "text Sam that I am running late" | A draft in the Inbox showing the number and the whole message — **not sent** |
| F12 | **Send it** with no bridge | The phone's own message composer opens, prefilled |
| F13 | "email the teacher" without granting send permission | Says which permission is missing; the draft stays unsent |
| F14 | **Discard** a draft | Nothing is sent, and it leaves the waiting list |

### G. Where things came from

Expand Down Expand Up @@ -211,9 +215,7 @@ These are the ones that bite in the field.

Implemented and unit-tested, but with no way to reach them from the UI:

- **Sending email.** `sendMail` and its approval type exist; nothing offers a draft.
- **Starting a long job.** The bridge runs jobs and Work mirrors them; nothing starts one.
- **Sending a text message.** The bridge's `/notify` works; no screen calls it.
- **Authoring a pack by asking.** `draftPack`/`refinePack`/`installPack` work; there is no UI.

Known limits, working as designed:
Expand Down
10 changes: 10 additions & 0 deletions docs/lifeboard/user-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,16 @@ Once connected, opening lifeboard does a catch-up: it pulls the calendar and rea
automatically. It asks; it never assumes.
4. Even then, everything it does appears in the Inbox with an **Undo**.

### Telling someone something

Ask it to let someone know — *"text Sam I'm running 10 minutes late"* — and it writes the message and
puts it in the **Inbox**. It shows who it is going to and the whole message, not a preview, and
waits. Nothing is ever sent to anyone until you press send.

If there is no way to send it, the phone's own message app opens with the text already written.
Sending email needs a permission you grant separately, and the app says so rather than failing
quietly.

Two things it will not do:

- **A cancellation is always proposed, never applied** — however trusted the sender. Being wrong
Expand Down
2 changes: 0 additions & 2 deletions packages/lifeboard/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -137,9 +137,7 @@ for what to check by hand.

These are implemented and unit-tested, but nothing in the UI reaches them:

- **Sending email.** `sendMail` and its approval type exist; no screen offers a draft to approve.
- **Starting a long job.** The bridge runs jobs and the Work board mirrors them, but nothing starts one.
- **Sending a text message.** The bridge's `/notify` works; no screen calls it.
- **Authoring a pack in conversation.** `draftPack`/`refinePack`/`installPack` work; there is no UI.

They are libraries waiting for a screen, not features you can use today.
37 changes: 37 additions & 0 deletions packages/lifeboard/src/agent/envelope.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,21 @@ export interface BoardSpec {
space?: string;
}

/** A message the assistant wants sent. Proposed, never sent: it becomes something to approve. */
export interface Draft {
kind: "email" | "sms";
to: string;
subject?: string;
body: string;
/** Why it wants to send this, shown next to the draft. */
why?: string;
}

export interface Envelope {
reply: string;
mutations?: Mutation[];
board?: BoardSpec;
send?: Draft;
}

export type ParseResult = { ok: true; value: Envelope } | { ok: false; error: string };
Expand Down Expand Up @@ -61,6 +72,12 @@ export function parseEnvelope(raw: string, writable: ReadonlySet<CollectionName>
if (out.length > 0) env.mutations = out;
}

if (v.send !== undefined && v.send !== null) {
const r = parseDraft(v.send);
if (!r.ok) return r;
env.send = r.value;
}

if (v.board !== undefined && v.board !== null) {
const r = parseBoard(v.board);
if (!r.ok) return r;
Expand Down Expand Up @@ -95,6 +112,26 @@ function parseMutation(v: unknown, path: string, writable: ReadonlySet<Collectio
return { ok: false, error: `${path}.op must be "add", "update" or "delete" (got ${JSON.stringify(v.op)})` };
}

function parseDraft(v: unknown): { ok: true; value: Draft } | { ok: false; error: string } {
if (!isObj(v)) return { ok: false, error: "envelope.send must be an object" };
if (v.kind !== "email" && v.kind !== "sms")
return { ok: false, error: 'envelope.send.kind must be "email" or "sms"' };
if (typeof v.to !== "string" || !v.to.trim())
return { ok: false, error: "envelope.send.to is required — a message with no recipient cannot be approved" };
if (typeof v.body !== "string" || !v.body.trim())
return { ok: false, error: "envelope.send.body is required" };
return {
ok: true,
value: {
kind: v.kind,
to: v.to.trim(),
body: v.body.trim(),
...(typeof v.subject === "string" && v.subject ? { subject: v.subject } : {}),
...(typeof v.why === "string" && v.why ? { why: v.why } : {}),
},
};
}

function parseBoard(v: unknown): { ok: true; value: BoardSpec } | { ok: false; error: string } {
if (!isObj(v)) return { ok: false, error: "envelope.board must be an object" };
if (typeof v.type !== "string" || !PUSH_TYPES.has(v.type))
Expand Down
4 changes: 4 additions & 0 deletions packages/lifeboard/src/agent/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,10 @@ export function systemPrompt(ctx: PromptContext): string {
"## Arithmetic is not your job",
"Never total, scale, convert or merge quantities yourself. Name the ingredients and the app computes the amounts. A wrong number on a shopping list is worse than no list.",
"",
"## Telling someone something",
'To message a person, put it in `send` and say what you have drafted: {"send":{"kind":"sms","to":"+447700900000","body":"Running 10 minutes late","why":"You asked me to let them know"}}. `kind` is "email" or "sms".',
"It is never sent by you. It goes to the inbox with the recipient shown, and the person decides. Never invent an address or a number — use one you were given, or ask.",
"",
"## Boards",
'A board is {"type":"component","title":"…","content":{…}}. Prefer `component`. Content is a tree of {"type":"<Component>","props":{…},"children":[…]}.',
`Available components: ${KNOWN_COMPONENTS.join(", ")}.`,
Expand Down
2 changes: 2 additions & 0 deletions packages/lifeboard/src/mail/triage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ export interface Proposal {
state: "pending" | "accepted" | "declined";
/** Set on things applied without asking, so the inbox can show what happened overnight. */
applied?: boolean;
/** An outbound message waiting to be approved. Nothing sends it but a person. */
draft?: { kind: "email" | "sms"; to: string; subject?: string; body: string };
/** The entries an auto-apply produced, so one tap undoes it. */
entrySeqs?: number[];
createdAt: string;
Expand Down
68 changes: 68 additions & 0 deletions packages/lifeboard/src/shell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ import { buildFocusedBoard } from "./views/focused.js";
import { buildGridBoard } from "./views/grid.js";
import { actionFromHref, routeFromHref } from "./views/links.js";
import { acceptProposal, declineProposal, type Proposal } from "./mail/triage.js";
import { approve, sendMail } from "./google/gmail.js";
import { SCOPES, validToken } from "./google/oauth.js";
import { detectConflicts } from "./proactive/rules.js";
import { review, type Grade } from "./practice/schedule.js";
import type { Card } from "./types.js";
Expand Down Expand Up @@ -239,6 +241,45 @@ export async function startShell(deps: ShellDeps): Promise<Shell> {
* Run a pack action. The pack's declared `writes` is enforced here, once, rather than trusted in
* each capability: a pack that never said it writes recipes cannot write one, whatever it asks for.
*/
/**
* Carry out an approval. The approval object names the exact draft it approves, so this cannot be
* used to send something else — and every path that fails says what is missing rather than
* swallowing it, because "did it send?" is the worst question to leave unanswered.
*/
async function approveAndSend(proposalId: string): Promise<void> {
const proposal = store.get<Proposal>("proposal", proposalId);
const draft = proposal?.draft;
if (!proposal || proposal.state !== "pending" || !draft) return;

try {
if (draft.kind === "email") {
const token = await validToken({ clientId: prefs.get("lifeboard.googleClientId") ?? "", redirectUri: "", prefs });
if (!token || !token.scopes.includes(SCOPES.gmailSend))
throw new Error("Sending email needs permission that has not been given — Connect → Google, and tick “send email”.");
const mail = { to: draft.to, subject: draft.subject ?? "", body: draft.body };
await sendMail(mail, approve(mail, profile.name, now()), { accessToken: token.accessToken });
} else {
const client = deps.bridge?.() ?? null;
if (!client) {
// The phone can do this perfectly well on its own, and saying so beats failing.
openUrl(`sms:${encodeURIComponent(draft.to)}&body=${encodeURIComponent(draft.body)}`);
console_.post({ kind: "quiet", text: `Opened a text to ${draft.to} for you to send — there is no bridge to send it through.` });
await store.apply([update("proposal", proposalId, { state: "accepted" })], by());
drawBoard();
return;
}
const message = { to: draft.to, body: draft.body };
const result = await client.notify(message, { approved: message, by: profile.name, at: now().toISOString() });
if (!result.ok) throw new Error(result.error ?? "The bridge would not send it.");
}
await store.apply([update("proposal", proposalId, { state: "accepted" })], by());
console_.post({ kind: "quiet", text: `Sent to ${draft.to}.` });
} catch (e) {
console_.post({ kind: "error", text: e instanceof Error ? e.message : String(e) });
}
drawBoard();
}

async function runAction(pack: Pack, action: PackAction, targetId: string | undefined, params: { servings?: number }) {
try {
const result = await runCapability(action, {
Expand Down Expand Up @@ -342,6 +383,29 @@ export async function startShell(deps: ShellDeps): Promise<Shell> {
);
}

if (result.envelope.send) {
const draft = result.envelope.send;
const [saved] = await store.apply(
[add("proposal", {
space: profile.id,
summary: `${draft.kind === "email" ? "Email" : "Text"} ${draft.to}`,
why: draft.why ?? "The assistant drafted this.",
mutations: [],
messageId: `draft-${Date.now()}`,
sender: "assistant",
category: "outbound",
state: "pending",
draft,
})],
author,
);
console_.post({
kind: "quiet",
text: `Drafted a ${draft.kind === "email" ? "message" : "text"} to ${draft.to}. Nothing is sent until you approve it in the Inbox.`,
});
void parseRef(saved.ref);
}

if (result.envelope.board) {
const b = result.envelope.board;
const [saved] = await store.apply(
Expand Down Expand Up @@ -404,6 +468,10 @@ export async function startShell(deps: ShellDeps): Promise<Shell> {
drawBoard();
return;
}
if (name === "send") {
await approveAndSend(arg);
return;
}
if (name === "cancel-job") {
const client = deps.bridge?.() ?? null;
if (!client) return;
Expand Down
22 changes: 19 additions & 3 deletions packages/lifeboard/src/views/connections.ts
Original file line number Diff line number Diff line change
Expand Up @@ -198,7 +198,10 @@ export function renderConnections(el: HTMLElement, props: ConnectionsProps): voi

const granted = grantedScopes(prefs);
if (granted.size > 0) {
lede.textContent = `Connected. ${granted.has(SCOPES.calendar) ? "Calendar" : ""}${granted.has(SCOPES.calendar) && granted.has(SCOPES.gmailRead) ? " and " : ""}${granted.has(SCOPES.gmailRead) ? "Gmail" : ""} sync when you open lifeboard. Nothing is read while it is closed.`;
const what = [granted.has(SCOPES.calendar) ? "Calendar" : "", granted.has(SCOPES.gmailRead) ? "Gmail" : ""].filter(Boolean).join(" and ");
lede.textContent =
`Connected. ${what || "Nothing"} syncs when you open lifeboard; nothing is read while it is closed.` +
(granted.has(SCOPES.gmailSend) ? " Email can be sent, but only after you approve each one." : "");
const drop = document.createElement("button");
drop.type = "button";
drop.className = "lb-danger";
Expand Down Expand Up @@ -229,6 +232,15 @@ export function renderConnections(el: HTMLElement, props: ConnectionsProps): voi
mailBox.checked = true;
wantsMail.append(mailBox, document.createTextNode(" also read mail"));

// Sending is a separate, sensitive permission, and it is off unless asked for. Nothing sends
// without an approval either way; this only decides whether the option exists at all.
const wantsSend = document.createElement("label");
wantsSend.className = "lb-checkbox";
const sendBox = document.createElement("input");
sendBox.type = "checkbox";
sendBox.checked = false;
wantsSend.append(sendBox, document.createTextNode(" and send email I have approved"));

const go = document.createElement("button");
go.type = "submit";
go.className = "lb-primary";
Expand All @@ -238,10 +250,14 @@ export function renderConnections(el: HTMLElement, props: ConnectionsProps): voi
const id = clientId.value.trim();
if (!id) return;
prefs.set("lifeboard.googleClientId", id);
const scopes = [SCOPES.calendar, ...(mailBox.checked ? [SCOPES.gmailRead] : [])];
const scopes = [
SCOPES.calendar,
...(mailBox.checked ? [SCOPES.gmailRead] : []),
...(sendBox.checked ? [SCOPES.gmailSend] : []),
];
void beginGoogleAuth({ clientId: id, redirectUri: location.href.split("?")[0], scopes, prefs }).then(open);
});
form.append(clientId, wantsMail, go);
form.append(clientId, wantsMail, wantsSend, go);
box.append(form);
return box;
}
Expand Down
40 changes: 39 additions & 1 deletion packages/lifeboard/src/views/inbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ export function buildInboxBoard(ctx: BoardContext): Board {
props: {
title: "Inbox",
lede: summary(pending.length, applied.length),
...(pending.length ? { howToUse: "Accept puts it on the board. Decline leaves everything as it is." } : {}),
...(pending.length ? { howToUse: "Accept puts it on the board. Decline leaves everything as it is. Nothing is ever sent to anyone without you pressing send." } : {}),
},
},
...(pending.length ? [section("Waiting on you", pending.map((p) => proposalCard(p, true)))] : []),
Expand All @@ -52,7 +52,45 @@ const section = (title: string, children: unknown[]): unknown => ({
children: [{ type: "Title", props: { order: 5 }, children: title }, ...children],
});

function draftCard(p: Proposal): unknown {
const d = p.draft!;
return {
type: "Paper",
props: { withBorder: true, p: "sm" },
children: [
{
type: "Stack",
props: { gap: 6 },
children: [
{
type: "Group",
props: { justify: "space-between", align: "center" },
children: [
{ type: "Text", props: { fw: 600, size: "sm" }, children: `${d.kind === "email" ? "Email" : "Text"} to ${d.to}` },
{ type: "Badge", props: { color: "yellow", variant: "light" }, children: "not sent" },
],
},
...(d.subject ? [{ type: "Text", props: { size: "xs", c: "dimmed" }, children: `Subject: ${d.subject}` }] : []),
// The whole body, never a preview: approving something you have only seen half of is not
// approving it.
{ type: "Blockquote", props: { p: "xs" }, children: d.body },
{ type: "Text", props: { size: "xs", c: "dimmed" }, children: p.why },
{
type: "Group",
props: { gap: "xs" },
children: [
{ type: "Anchor", props: { href: actionHref("send", p.id), size: "sm" }, children: `Send it to ${d.to}` },
{ type: "Anchor", props: { href: actionHref("decline", p.id), size: "sm", c: "dimmed" }, children: "Discard" },
],
},
],
},
],
};
}

function proposalCard(p: Proposal, pending: boolean): unknown {
if (p.draft && pending) return draftCard(p);
return {
type: "Paper",
props: { withBorder: true, p: "sm" },
Expand Down
Loading
Loading