From 67ef82fa9637325362ecb317e11c2bded9489284 Mon Sep 17 00:00:00 2001 From: Ivan Cheung Date: Wed, 2 Sep 2026 09:18:27 -0400 Subject: [PATCH] docs(lifeboard): user guide, bridge setup, QA guide, and the persistence audit Five documents and a serve script, so someone who did not write this can run it, use it, test it, and know what it does not do. - packages/lifeboard/README.md: what the app is, how to run it, how the fact log works and why it is shaped that way, and where each concern lives. - docs/lifeboard/user-guide.md: for the family. The boards, ticking things off, profiles, connecting an assistant, meals and the shopping list, calendar and mail, provenance, and privacy stated plainly. - docs/lifeboard/bridge.md: running turns through the AI subscriptions you already pay for, what the bridge will and will not do, and its security model. - docs/lifeboard/qa-guide.md: the automated checks with their expected counts, then a manual test plan in ten sections with steps and expected results. - docs/lifeboard/persistence-audit.md: the audit behind the previous commit. Every guide ends by naming what is NOT there. Four capabilities are implemented and unit-tested but have no UI reaching them -- sending email, starting a long job, sending a text, authoring a pack by asking -- and the QA guide lists them under "do not file these" so nobody wastes an afternoon. The largest known risk, that there is no export or backup, is stated in the user guide, the README and the audit rather than left for someone to discover. Adds a dependency-free static server behind `npm run serve`, because a guide that says "serve dist/ somehow" is a guide nobody follows. It binds all interfaces so an iPad on the same wifi can open it, normalises paths so a request cannot climb out of dist/, and says on start-up that offline support needs localhost or HTTPS. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 8 + docs/lifeboard/README.md | 12 +- docs/lifeboard/bridge.md | 96 ++++++++++++ docs/lifeboard/persistence-audit.md | 150 +++++++++++++++++++ docs/lifeboard/qa-guide.md | 209 +++++++++++++++++++++++++++ docs/lifeboard/user-guide.md | 182 +++++++++++++++++++++++ packages/lifeboard/README.md | 114 +++++++++++++++ packages/lifeboard/package.json | 1 + packages/lifeboard/scripts/serve.mjs | 59 ++++++++ 9 files changed, 830 insertions(+), 1 deletion(-) create mode 100644 docs/lifeboard/bridge.md create mode 100644 docs/lifeboard/persistence-audit.md create mode 100644 docs/lifeboard/qa-guide.md create mode 100644 docs/lifeboard/user-guide.md create mode 100644 packages/lifeboard/README.md create mode 100644 packages/lifeboard/scripts/serve.mjs diff --git a/README.md b/README.md index 7e229aad..77c3381c 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,14 @@ It ships as a **CLI** (`termchart`) plus a **Claude Code plugin** (skills + the --- +## lifeboard + +A family board built on the same canvas: your lists, calendar and recipes in the browser, with an +assistant that can read them, change them, and draw new boards. No server, works offline. + +[User guide](docs/lifeboard/user-guide.md) · [QA guide](docs/lifeboard/qa-guide.md) · +[Package](packages/lifeboard/) + ## Use it — just ask your agent You never write diagram syntax. Install once, then **ask in plain language** and your agent picks diff --git a/docs/lifeboard/README.md b/docs/lifeboard/README.md index 0abd3aa9..2c53f0b4 100644 --- a/docs/lifeboard/README.md +++ b/docs/lifeboard/README.md @@ -1,7 +1,17 @@ -# lifeboard — design deck +# lifeboard > **A dashboard that reshapes itself around your family — kept by an agent that works while you don't.** +| Guide | For | +|---|---| +| [User guide](./user-guide.md) | Using it: the boards, the assistant, meals, mail, privacy | +| [Bridge setup](./bridge.md) | Running turns through the AI subscriptions you already pay for | +| [QA guide](./qa-guide.md) | Testing it — automated checks and a manual test plan | +| [Persistence audit](./persistence-audit.md) | How state is stored, and what was wrong with it | +| [Package README](../../packages/lifeboard/README.md) | Building on it: architecture and layout | + +## The design deck + `deck.html` is a **single self-contained file**. Open it in a browser; navigation is built in. - **Sidebar contents** — generated from the deck itself, so it cannot drift out of sync. diff --git a/docs/lifeboard/bridge.md b/docs/lifeboard/bridge.md new file mode 100644 index 00000000..75b6201b --- /dev/null +++ b/docs/lifeboard/bridge.md @@ -0,0 +1,96 @@ +# The bridge + +The bridge is an optional program you run on your Mac. lifeboard works without it. With it: + +- turns run through the **AI subscriptions you already pay for** — Claude Pro, ChatGPT Plus, Google + AI — instead of a metered API key; +- **long research keeps going after you close the tab**, because your Mac is doing it, not the iPad; +- boards can read feeds a browser is not allowed to fetch directly. + +It is deliberately dull. It lists what is installed, runs a turn, owns long jobs, and sends a +message you have approved. It never holds your Google credentials. + +## Running it + +```bash +npx @ivanmkc/termchart bridge +``` + +It prints something like: + +``` +termchart bridge listening on http://127.0.0.1:8787 +token: 3Qb7… (copy this) +agents ready: Claude Code (Claude Pro) +Codex CLI is installed but signed out — run `codex login` +paste the token into lifeboard → Connect. Loopback only; nothing else on the network can reach it. +``` + +Then in the app: **Connect → Your Mac → Look for a bridge**, paste the token, and pick an agent. + +| Flag | What it does | +|------|--------------| +| `--port ` | Listen somewhere other than 8787 | +| `--token ` | Use a fixed token instead of a fresh one each start | +| `--allow-origin ` | Also trust this origin — needed for a tunnel, or a LAN address | +| `--schedule` | Also run the scheduled boards in `.termchart/boards` | + +## What it will use + +The bridge finds agents by asking them their version — a binary on the `PATH` that cannot run is not +an installed agent, and it is much better to find that out now than inside your first question. + +| Agent | Command | Sign in with | +|-------|---------|--------------| +| Claude Code | `claude` | `claude` (Claude Pro / Max) | +| Codex CLI | `codex` | `codex login` (ChatGPT Plus) | +| Gemini CLI | `gemini` | `gemini` (Google AI) | + +"Installed but signed out" is the most common state, and the fix is the command in the last column. + +## Security + +A program that can run your authenticated AI agents is worth breaking into, so: + +- **Loopback only.** It binds `127.0.0.1`, never `0.0.0.0`. Nothing else on the wifi can reach it. + A bridge on a café network would be a remote shell with extra steps. +- **A token**, generated per run, printed once, never written to disk, compared in constant time. +- **Origin checking** on anything that changes something. Any web page you visit can POST to + `127.0.0.1`, so the token alone is not enough — a page in your browser could have been handed one. + Only origins the bridge was started with get through. +- **Health is the only unauthenticated route**, and it says nothing a stranger could use. It exists + so the app can tell "no bridge" apart from "wrong token". + +Stop it and the app degrades quietly: no long jobs, and your API key is used again if you have one. + +## Long jobs + +Ask for something big and the bridge runs it, keeping the state. The Work board mirrors it: a card +appears immediately, shows what it is doing, and swaps in the result. Closing the tab does not stop +it; **Stop** does. + +Every job has a step budget. An agent asked to "research trips to Portugal" can go round in circles, +and every circle spends your subscription, so a job that isn't converging is stopped and says so. + +> **Not yet wired up:** nothing in the app *starts* a job. The bridge runs them and the board shows +> them, but the button to begin one has not been built. + +## Scheduled boards + +With `--schedule`, the bridge runs the board definitions in `.termchart/boards` — the same files, +and the same `termchart run`, that a crontab or a GitHub Action would use. The bridge is just +another host. + +It is off by default because **every tick costs money** on someone's subscription. Cadence is +reported in words next to the cron expression, since `0 7 * * *` does not tell anyone what it will +cost them. + +## When it does not work + +| What the app says | What to do | +|---|---| +| "No bridge running" | Start it. Check the port matches (8787 or 8788 are probed). | +| "The bridge did not recognise that token" | It prints a new one each start — paste the current one, or use `--token` | +| "The bridge is not paired with this address" | Restart with `--allow-origin ` | +| "Claude Code is installed but not signed in" | Run its login command in a terminal | +| "…did not finish in time" | The agent hung. Try a smaller question. | diff --git a/docs/lifeboard/persistence-audit.md b/docs/lifeboard/persistence-audit.md new file mode 100644 index 00000000..3faf4274 --- /dev/null +++ b/docs/lifeboard/persistence-audit.md @@ -0,0 +1,150 @@ +# Audit: the state persistence mechanism + +*2026-09-02. Scope: `src/log.ts`, `src/store.ts`, `src/transcript.ts`, `src/prefs.ts`, `src/sw.js` — +everything that decides whether a change a person makes is still there tomorrow.* + +## What the design is + +One append-only log of entries in a single IndexedDB object store (`entries`, keyed on `seq`). The +collections the UI renders are a fold over that log. Device preferences — the active profile, API +keys, tokens — live in `localStorage`, deliberately outside the log, because the log cannot forget +and a rotated key must actually disappear. + +The design is sound. Every finding below is an implementation defect, not an argument with the model. + +## Findings + +Severity is about what a person loses. + +| # | Severity | Finding | Status | +|---|----------|---------|--------| +| S1 | **High** | A second tab's writes were silently lost | **fixed** | +| S2 | **High** | A new build never reached a device that had the old one | **fixed** | +| S3 | Medium-high | The app did not start at all when storage was unavailable | **fixed** | +| S4 | Medium | The cost of a write grew with the age of the household | **fixed** | +| S5 | Medium | Transcript writes could reject with nobody listening | **fixed** | +| S6 | **High** | No export, no backup, no import | **open** | +| S7 | Low-medium | No schema version on entries; database pinned at version 1 | **open** | +| S8 | Low | `onblocked` / `onversionchange` unhandled — a boot that hangs forever | **fixed** | +| S9 | Low | The log never compacts; console chatter is permanent | **open** | +| S10 | Informational | Secrets in `localStorage` | accepted, mitigation recommended | + +--- + +### S1 — A second tab's writes were silently lost *(fixed)* + +`fromSeq` came from the in-memory tail, so two connections to the same database minted the same key. +IndexedDB rejected the duplicate and the entry never landed. + +Reproduced before the fix: + +``` +TAB-B-ERROR: Error: IndexedDB transaction failed +ROWS-ON-DISK: ["from A"] +``` + +What a person saw: in the second tab a tick reverted itself a moment after being tapped, with no +explanation — `postInteract` catches the failure and reverts optimistically. Console lines, written +without anyone awaiting them, became unhandled rejections (S5). + +**Fixed** by reading and appending in one transaction: a tab reads whatever landed after its own +tail, folds it in, and only then decides what its change means. An update against a record the tab +had never seen is now applied instead of dropped. Covered by three tests in `test/store.test.ts`. + +**Still open within this:** the two tabs do not update each other *live*. A tab notices the other's +writes on its next write, not as they happen. `BroadcastChannel` would close that; it is not urgent, +because no data is lost either way. + +### S2 — A new build never reached a device that had the old one *(fixed)* + +`app.js` is not content-hashed (`--entry-names=app`), and the service worker was cache-first for +every same-origin GET. A deployed change was therefore invisible until someone edited `CACHE` in +`sw.js` by hand. The code comment asserted that bumping the cache name "is what evicts the old one", +which is true and enforced by nothing. + +**Fixed** by splitting the strategy: the shell (`/`, `index.html`, `app.js`, `style.css`) is +network-first with a cache fallback; content-hashed chunks stay cache-first, where that is free and +correct. The offline e2e now serves a changed build mid-run and asserts it arrives. + +### S3 — The app did not start when storage was unavailable *(fixed)* + +`openStore` rejected, `startApp` awaited it, and `main.ts` had no catch: a private window got a blank +page. The rejection was also literally `undefined`, because `request()` passed `req.error` through +and that is null on several paths — so the console said nothing either. + +**Fixed:** a `Backend` seam with an in-memory implementation, `openStoreOrMemory`, a `durable` flag, +and a line in the console saying plainly that nothing is being saved. `prefs.ts` already degraded +this way; the store now matches it. + +### S4 — Write cost grew with the age of the household *(fixed)* + +`apply` called `materialise(this.entries)` — a full re-fold of the log, `structuredClone`ing every +record — on every append. Measured before the fix: + +| Log size | Cost of one write | +|---|---| +| 200 entries | 0.39 ms | +| 1,000 entries | 1.57 ms | +| 3,000 entries | 4.79 ms | + +Linear, and every tick writes twice (the change, then the transcript line). A household a year in +would feel it. **Fixed** by folding only the new entries into the existing view. + +### S5 — Writes that could reject with nobody listening *(fixed)* + +`persistLine`, `updateLine` and `markUndone` used `void store.apply(...)`. On a full disk — or on the +S1 collision — that is an unhandled rejection, which the browser reports as an uncaught error and +nobody acts on. **Fixed:** they catch and warn. Losing a line of narration is survivable; losing it +silently while the page logs an error is not. + +### S6 — No export, no backup, no import *(open — the largest remaining risk)* + +Everything lives in one browser's IndexedDB. "Clear site data", a lost device, or a browser that +evicts storage under pressure takes the family's calendar, lists and vaccination dates with it. + +For a product whose pitch is provenance and trust, this is the biggest single risk in the persistence +design, and it is the cheapest thing on this list to fix: the durable state *is* a JSON array of +entries. Export is `JSON.stringify(store.history())`. Import is replaying it. + +Recommended: an Export/Import pair in Connections, and a periodic reminder. Not done here because it +is a feature rather than a defect, and it deserves its own review. + +### S7 — No schema version *(open)* + +`indexedDB.open(name, 1)` is pinned at 1 and entries carry no version field. A change to a record's +shape would fold old entries into a new view with fields missing and no migration hook. Cheap +insurance: a `v` on each entry, and a documented rule that readers tolerate older shapes. + +### S8 — A boot that could hang forever *(fixed)* + +`request()` handled `onsuccess` and `onerror` but not `onblocked`. If a future version bump were ever +blocked by an old tab, the promise would neither resolve nor reject and the app would sit on a blank +screen with no error at all. Both `onblocked` and `db.onversionchange` are handled now. + +### S9 — The log never compacts *(open)* + +Every console line is a permanent entry. Superseded *values* are kept deliberately — that is the +point of the log — but transcript chatter has no such justification. A cap on retained `message` +entries, or moving the transcript to its own store, would bound growth. Not urgent at family scale. + +### S10 — Secrets in `localStorage` *(accepted)* + +API keys, the Google refresh token and the bridge token are in `localStorage` by design: the log +cannot forget, and a rotated key must actually disappear. The consequence is that any XSS is a full +credential compromise. + +There is no known injection path today — markdown is sanitised with DOMPurify, and component boards +render through React, which escapes. This is a defence-in-depth note, not a live vulnerability. +**Recommended:** serve the app with a Content-Security-Policy. Worth doing before anyone hosts this +somewhere public. + +## What holds up well + +- **Writes are serialised** through a promise queue, so concurrent `apply` calls within a tab cannot + interleave. That was itself a fix for a live bug. +- **Memory is updated only after the transaction commits**, so a failed write cannot leave the + in-memory view claiming something that is not on disk. +- **The failure mode of `prefs` is right** — a probe with a real write, then a silent fall back to + memory. That is the pattern the store now follows. +- **The log's shape earns its keep.** Undo, lineage, and "who changed this" are all reads over data + that had to be recorded anyway. diff --git a/docs/lifeboard/qa-guide.md b/docs/lifeboard/qa-guide.md new file mode 100644 index 00000000..ca862150 --- /dev/null +++ b/docs/lifeboard/qa-guide.md @@ -0,0 +1,209 @@ +# QA guide + +How to check that lifeboard works — what the machines cover, what a person still has to look at, and +what is already known to be missing so nobody files it twice. + +## 1. The automated checks + +From the repository root: + +```bash +npm install +npm test # every package +npx tsc --noEmit -p packages/lifeboard/tsconfig.json # and one per package +``` + +| Command | Covers | Expect | +|---|---|---| +| `npm test --workspace @ivanmkc/termchart-lifeboard` | log, store, views, agent, packs, recipes, mail, provenance, bridge client | 350 passing | +| `npm test --workspace @ivanmkc/termchart-canvas` | renderers, patch appliers, board lints | 473 passing | +| `npm test --workspace @ivanmkc/termchart` | CLI, including the bridge and scheduler | 320 passing | +| `npm test --workspace @ivanmkc/termchart-viewer` | the viewer | 278 passing | +| `npm run test:e2e --workspace @ivanmkc/termchart-lifeboard` | a real browser, network off | `offline.e2e: PASS`, 18 assertions | +| `npm run test:e2e --workspace @ivanmkc/termchart-viewer` | the viewer in a browser, 11 suites | all pass | + +A failing suite blocks. Two notes on reading failures: + +- **Zero console errors is an assertion**, not a nicety. The offline e2e fails if the page logs + anything at error level. That is how the sequence-collision bug was found. +- **Test counts are in the table on purpose.** A count that has gone *down* without a matching + deletion in the diff means tests were removed or skipped. + +### Running the browser test against a change you are making + +```bash +npm run test:e2e --workspace @ivanmkc/termchart-lifeboard +``` + +It builds first, serves `dist/` on an ephemeral port, drives Chromium, and switches the network off +partway through. It needs no network and no keys. + +## 2. Manual testing + +Start with: + +```bash +npm run serve --workspace @ivanmkc/termchart-lifeboard +``` + +and open . Use `localhost`, not a LAN address — service workers need a secure +context, and the offline behaviour will not install over plain HTTP to an IP. + +To start from nothing: DevTools → Application → Storage → **Clear site data**, then reload. + +--- + +### A. First run and offline + +| # | Steps | Expect | +|---|---|---| +| A1 | Clear site data, reload | A Today board with a few seeded items. Not a blank screen. | +| A2 | DevTools → Application → Service Workers | One activated worker | +| A3 | DevTools → Network → **Offline**, reload | The board still renders | +| A4 | While offline, tick something, reload | Still ticked | +| A5 | While offline, switch between rail items | All render; none blank | +| A6 | Console tab, throughout | No red errors | + +### B. Ticking and undo + +| # | Steps | Expect | +|---|---|---| +| B1 | Tick an item | Ticks instantly. A quiet line appears in the console. | +| B2 | Watch the network panel while ticking | **No request to any model.** A tick must cost nothing. | +| B3 | Click **Undo** on that line | The tick comes back off; the line goes grey | +| B4 | Reload, scroll the console back | The old lines are still there | +| B5 | Undo a line from *before* the reload | Still works | + +### C. Profiles + +| # | Steps | Expect | +|---|---|---| +| C1 | Create a second profile | A second chip, bottom left | +| C2 | Add a private item as person A, switch to B | B does not see it | +| C3 | Check a shared item | Both see it | +| C4 | Navigate somewhere as A, switch to B and back | A lands back where they were | +| C5 | Type in the console as A, switch to B | B's console is their own, not A's | + +### D. The assistant + +Needs a key or a bridge. Connect → add one. + +| # | Ask for | Expect | +|---|---|---| +| D1 | "add bread and butter to the shopping list" | Both appear; one quiet line with an Undo | +| D2 | Undo it | Both disappear | +| D3 | "compare three coffee grinders under £150" | A board with a title and a lede, not a wall of text | +| D4 | Reload after D3 | You are still on that board | +| D5 | Disconnect the assistant, ask anything | "No assistant is connected yet" — not a silent failure | +| D6 | Use a deliberately wrong API key | A message naming the provider and telling you to check Connections | + +### E. Meals and the shopping list — *the highest-value area* + +This is where competing products visibly get it wrong. Check it properly. + +| # | Steps | Expect | +|---|---|---| +| E1 | "plan a pancake dinner for six" | A recipe card with an action bar under it | +| E2 | **Add to shopping list** | Every ingredient appears, including vague ones ("a handful of parsley") | +| E3 | Look for salt, flour, oil | Present and marked as staples — **never silently dropped** | +| E4 | Set servings to 4, **Change servings** | Quantities *change in place*: "300 g" becomes "200 g" | +| E5 | Count the lines after E4 | **The same number as before.** A duplicate flour line is a bug. | +| E6 | Plan a second dish that also uses flour | One flour line, showing the total | +| E7 | Re-scale only the first dish | The total changes by that dish's share only | +| E8 | Add "1 cup butter" by hand, then a recipe wanting "100 g butter" | "100 g butter + more" — **not an invented total** | +| E9 | **Take off the list** | Lines stay, marked "no longer needed" — never deleted | +| E10 | **Send to a shop** | A search opens; staples and ticked items are left out | + +### F. Calendar and mail + +Needs Google connected (Connect → Google, with a Desktop-app client id). + +| # | Steps | Expect | +|---|---|---| +| F1 | Connect, then reload | Events appear on Week | +| F2 | Reload again | **No duplicates.** Events reconcile on Google's id. | +| F3 | A week containing a clock change | Seven distinct days; no day repeated or missing | +| F4 | An all-day event | Shows "all day", not a time | +| F5 | Send yourself an email with a calendar invitation | Appears in Inbox as a proposal, not on the board | +| F6 | **Accept** it | Now on the calendar, with an Undo | +| F7 | Send a reschedule of the same invitation | The existing event **moves**; no second event | +| 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 | + +### G. Where things came from + +| # | Steps | Expect | +|---|---|---| +| G1 | Sources, after F6 | The accepted fact is listed | +| G2 | **Where from?** | A diagram: fact ← step ← the email | +| G3 | The step | Names who did it (a model, by name) and how confident | +| G4 | Tap the document | Opens Gmail/Drive at the right document | +| G5 | Correct a fact by hand, look again | Both the correction **and** the original are in the chain | + +### H. The bridge + +See [the bridge guide](./bridge.md). + +| # | Steps | Expect | +|---|---|---| +| H1 | With no bridge: Connect → Your Mac | Tells you how to start one | +| H2 | Start it, **Look for a bridge**, paste the token | Lists your agents with their sign-in state | +| H3 | Pick one, ask something | The turn completes through it | +| H4 | Stop the bridge mid-session, ask again | A clear message. **No crash, no blank screen.** | +| H5 | Paste a wrong token | "did not recognise that token" | +| H6 | With no bridge, open Work | Says long research needs one | + +### I. Clashes + +| # | Steps | Expect | +|---|---|---| +| I1 | Two overlapping events on one day | One Inbox item naming both | +| I2 | An event ending 18:40 in one place, another at 19:00 elsewhere | Flagged, with the shortfall in minutes | +| I3 | Reload and look again | **Still one item.** Raised twice is a bug. | +| I4 | Two events back to back in the same place | Nothing raised — no travel needed | +| I5 | Decline a clash | Nothing on the calendar changes | + +### J. Persistence edge cases + +These are the ones that bite in the field. + +| # | Steps | Expect | +|---|---|---| +| J1 | Open the app in **two tabs**. Tick in one, then the other. | Both writes survive. Reload both: both changes present. | +| J2 | In tab two, change something tab one created | Applies — not silently dropped | +| J3 | Open in a **private window** | Boots, and says plainly it cannot save | +| J4 | Rebuild while a tab is open, reload | **The new build is served.** A stale app after a deploy is a bug. | +| J5 | Rebuild, go offline, reload | The last good build still loads | +| J6 | Tick ~50 things quickly | No dropped ticks, no lag | + +## 3. Before approving a change + +- [ ] `npm test` at the root — every package green +- [ ] `npm run test:e2e --workspace @ivanmkc/termchart-lifeboard` — `offline.e2e: PASS` +- [ ] `tsc --noEmit` clean for every package touched +- [ ] Section A, plus whichever section covers the change +- [ ] Section E if anything under `src/recipes/` or `src/packs/` moved +- [ ] Section J if anything under `src/store.ts`, `src/log.ts` or `src/sw.js` moved +- [ ] Console clean throughout + +## 4. Known gaps — do not file these + +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: + +- **No export or backup.** Clearing site data loses everything. This is the largest known risk in + the design and it is not yet addressed. +- **Mail is only read while the app is open.** Nothing runs overnight — a deliberate trade so that + no background process holds a key to your mailbox. +- **Instacart carts are unavailable.** The integration is written but needs an API key the app has + nowhere to keep; the shop handoff is a prefilled search instead. +- **A PIN is a speed bump**, stored in plain text. Not a security control. +- **Travel time is a flat estimate** — 30 minutes between any two named places. Real routing is not + wired in. diff --git a/docs/lifeboard/user-guide.md b/docs/lifeboard/user-guide.md new file mode 100644 index 00000000..5759d067 --- /dev/null +++ b/docs/lifeboard/user-guide.md @@ -0,0 +1,182 @@ +# lifeboard: a guide for the family + +lifeboard is a shared board for the things a household has to keep track of — what's on today, what +to buy, what's for dinner, what came in the post that somebody has to act on. + +It runs in a browser and keeps everything on the device. There is no account and no server. Two +consequences worth knowing up front: + +- **Nothing leaves the device unless you connect something.** No sign-up, nothing uploaded. +- **The device is where your data lives.** If you clear the browser's site data, it is gone. There + is no backup yet. Do not put the only copy of something important here. + +--- + +## Getting started + +Open the address you were given (usually `http://localhost:8123`). You will see a short starter list +so the board isn't empty. + +Down the left is the **rail**. Nine places: + +| | What it shows | +|---|---| +| **Home** | Today and the shopping list side by side, plus the week once you connect a calendar | +| **Today** | What is overdue, what is due today, and what to buy | +| **Week** | Seven days of the calendar | +| **Inbox** | Things the assistant did, and things waiting on your decision | +| **Meals** | Dinners you have planned | +| **Shopping** | The shared list | +| **Work** | Long jobs running on your Mac | +| **Sources** | Anything taken out of an email or a document, and where it came from | +| **Connect** | Assistants, your Mac, and Google | + +At the bottom left are the people in the household. Tap a face to switch. + +Along the bottom is the **console**. Everything that happens is written there, including the changes +you make yourself — that is how a board three people are touching stays explainable. + +## Ticking things off + +Tap a checkbox. It saves immediately, works with no internet, and never calls the assistant — so it +costs nothing and takes no time. + +Each tick leaves a quiet line in the console with an **Undo** next to it. The undo survives a +reload: come back tomorrow, scroll up, and it still works. + +## Family members + +Everyone gets a profile. Each person sees: + +- the **shared** space — the family calendar, the shopping list, anything meant for everyone; and +- their **own** space — private to them. + +Switching profiles switches the board, and each person keeps their own conversation with the +assistant. + +A profile can have a **PIN**. Be clear about what it is: a speed bump to stop a sibling poking +around, not security. It is stored on the device in plain text, and anyone who can read the device +can read both the PIN and the data. + +## Connecting an assistant + +Without one, the board and your lists work exactly as described above; you just cannot ask for +anything. **Connect** offers three routes. + +**Sign in with OpenRouter.** One button. It sends you to OpenRouter and back, and there is no key to +copy anywhere. Usage is billed to your OpenRouter account. + +**Paste an API key** for Claude, OpenAI or Gemini. The key stays on this device and goes only to the +provider you chose. + +**Use your Mac** — see [the bridge guide](./bridge.md). With it, the app runs turns through the AI +subscriptions you already pay for instead of a metered API, and can run long research that carries +on after you close the tab. + +### Asking for things + +Type into the console. The assistant can answer, change your records, or draw a board. + +- *"add milk, eggs and coffee to the shopping list"* — appears immediately, with an undo +- *"what's for dinner? something with the chicken in the fridge"* — writes a recipe and shows it +- *"compare three coffee grinders under £150"* — draws a board you can come back to + +Anything it changes leaves a line in the console with an **Undo**. Nothing is applied that you +cannot take back. + +## Meals and the shopping list + +Ask for a dinner and you get a recipe card. Under it are the things you can do: + +- **Add to shopping list** — puts every ingredient on the list +- **Change servings** — set a number; every quantity follows +- **Take off the list** — removes that recipe's claim on the list +- **Send to a shop** — opens a search at a supermarket + +Three rules the app holds to, because getting them wrong is worse than not having the feature: + +**Nothing is ever removed without showing you.** Things you probably already have — salt, flour, +oil — are added and *marked*, not silently dropped. You tick off what is in the cupboard. + +**The app does the arithmetic, never the assistant.** Language models produce plausible, wrong +totals. Scaling and adding up happen in code. + +**Re-scaling updates the list; it does not duplicate it.** Cooking for four instead of six changes +"300 g plain flour" to "200 g". It does not add a second flour. If two recipes both want flour, the +list shows the total, and changing one recipe changes only its share. + +When two amounts genuinely cannot be added — 100 g of butter and 1 cup of butter — the list says +"100 g butter + more" rather than inventing a number. + +## Calendar and email + +**Connect → Google.** You supply a client id from a Google Cloud "Desktop app" OAuth client; the app +does not ship one, which is what keeps a family install in Google's Testing mode and out of a +commercial audit. The screen tells you what to make. + +Once connected, opening lifeboard does a catch-up: it pulls the calendar and reads what is new. + +**What happens to a new email:** + +1. Obvious marketing is ignored without being read closely. +2. Anything that looks like a date, a task or a delivery is **proposed** — it appears in the Inbox + with Accept and Decline. Nothing reaches the board until you accept. +3. After you have accepted three things from the same sender, the app offers to apply their mail + automatically. It asks; it never assumes. +4. Even then, everything it does appears in the Inbox with an **Undo**. + +Two things it will not do: + +- **A cancellation is always proposed, never applied** — however trusted the sender. Being wrong + there means somebody doesn't turn up. +- **A rescheduled appointment updates the one already on the calendar.** It does not add a second. + +**Mail is read only while the app is open.** Nothing runs overnight. Close the iPad for a week and +nothing is triaged until you open it — deliberately, because the alternative is a program on your +Mac holding a key to your mailbox forever. + +## "Where did this come from?" + +**Sources** lists everything taken out of a document. Tap **Where from?** on any of them for the +chain: the fact, the step that produced it, who did it, how sure they were, and the document itself. +Tap the document to open it in Gmail or Drive at the right place. + +If a document changes after a fact was taken from it, the fact is **flagged** — never quietly +re-read. Nothing rewrites a date behind your back. + +Anything you correct by hand stays corrected. The original stays in the history, so you can see both. + +## Clashes + +The app watches for two things and says nothing otherwise: + +- two events overlapping, and +- not enough time to get from one to the next — *"you land at 18:40 and dinner is at 19:00 across + town"*. + +Each clash is raised **once**, in the Inbox, with a suggestion. Declining changes nothing. + +## Working offline + +Everything above works with no internet except the assistant itself. The board loads, ticks save, +and your changes are there when you reconnect. The first load must be online; after that, it isn't. + +## Privacy, plainly + +- Your records stay in this browser on this device. +- API keys and sign-ins are kept in the browser's local storage, not in the log — so that removing + one really removes it. +- The assistant is sent what it needs for the turn: today's date, your lists, your profile's notes, + and the message you typed. +- A PIN is a speed bump, not a lock. +- **There is no backup.** Clearing site data loses everything. + +## When something is wrong + +| What you see | What it means | +|---|---| +| "No assistant is connected yet" | Connect → add an assistant | +| "This device will not let lifeboard save anything" | Usually a private window. Changes will be lost on close. | +| "The bridge did not recognise that token" | The bridge was restarted; it prints a new token each time | +| "Its board would not render, so I showed the answer as a list" | The assistant's drawing was malformed; the answer is intact | +| Something is in the Inbox you did not expect | Decline it. Nothing was applied. | diff --git a/packages/lifeboard/README.md b/packages/lifeboard/README.md new file mode 100644 index 00000000..388a3a1b --- /dev/null +++ b/packages/lifeboard/README.md @@ -0,0 +1,114 @@ +# lifeboard + +A family board that runs entirely in the browser. Your lists, calendar and recipes live in +IndexedDB on the device; an assistant you connect can read them, change them, and draw new boards +onto the same canvas the [termchart viewer](../viewer) uses. + +There is no server. Nothing here talks to a backend we run, because there isn't one. + +**Guides:** [user guide](../../docs/lifeboard/user-guide.md) · +[bridge setup](../../docs/lifeboard/bridge.md) · +[QA guide](../../docs/lifeboard/qa-guide.md) + +## Quick start + +```bash +npm install # from the repo root +npm run serve --workspace @ivanmkc/termchart-lifeboard +``` + +Then open . The app seeds a short list on first run so there is something to +look at. It works with no assistant connected — connecting one is a separate step, described in the +[user guide](../../docs/lifeboard/user-guide.md). + +To build without serving: + +```bash +npm run build --workspace @ivanmkc/termchart-lifeboard # -> dist/ +``` + +`dist/` is a folder of static files. Any static host will serve it. Offline support needs a secure +context, which means `localhost` or HTTPS — over a plain LAN address the app still works, but the +service worker will not install. + +## How it fits together + +``` + rail board console + ┌──────┬───────────────────────────┬─────────────┐ + │ Home │ │ you: … │ the console is furniture: always + │ Today│ the only region the │ agent: … │ present, never redrawn by the agent + │ Week │ agent ever redraws │ ✓ Ticked… │ + │ … │ │ [Undo] │ + └──────┴───────────────────────────┴─────────────┘ + │ + ▼ + deterministic view builders ← pure functions: records in, board spec out + │ + ▼ + the fact log (IndexedDB) ← append-only; every entry says who and from what +``` + +### The fact log + +Everything durable is one append-only log of entries, not a table of rows: + +```ts +{ seq: 12, op: "assert", ref: "item/abc", value: {...}, by: { kind: "human", profile: "…" }, at: "…" } +{ seq: 13, op: "correct", ref: "item/abc", value: {...}, replaces: 12, by: { kind: "model", … } } +{ seq: 14, op: "retract", ref: "item/abc", by: { kind: "human", … } } +``` + +The collections you see — items, events, recipes, packs, proposals — are a fold over that log. This +costs a little indirection and buys three things a row store cannot give back once a row has been +overwritten: **undo**, an honest **"where did this come from?"**, and the ability to **re-derive +deterministic facts** while leaving model-authored ones alone. + +`from` on an entry names the refs a fact was derived from. That is what the lineage view walks. + +### Who may write what + +| Author | Written by | Re-derivable | +|--------|-----------|--------------| +| `human` | you, tapping | never — a human correction always wins | +| `code` | scaling, merging, sync | yes, deterministically | +| `model` | the assistant | never silently | + +The assistant may write `item`, `recipe`, `event` and `contact`. Not `profile`, `message` or +`derivation`: a model that could rewrite the transcript could rewrite its own history. + +### Layout + +| Path | What lives there | +|------|------------------| +| `src/log.ts`, `src/store.ts` | the fact log and its IndexedDB backend | +| `src/shell.ts`, `src/rail.ts`, `src/console.ts` | app furniture — never drawn by the agent | +| `src/views/` | deterministic board builders: records in, spec out | +| `src/agent/` | the envelope contract, the turn loop, provider adapters | +| `src/packs/` | packs (data, never code), capabilities, data binding | +| `src/recipes/` | unit-aware scaling and shopping-list merging | +| `src/google/`, `src/mail/` | calendar, Gmail, and mail triage | +| `src/provenance/` | sources, derivations, lineage | +| `src/bridge/`, `src/jobs/` | the optional local bridge, and its jobs mirrored | + +## Testing + +```bash +npm test --workspace @ivanmkc/termchart-lifeboard # unit + integration (fake IndexedDB) +npm run test:e2e --workspace @ivanmkc/termchart-lifeboard # builds, then drives a real browser offline +``` + +The e2e is the one that matters: it switches the network off and proves the app still renders, +accepts a tick, and remembers it across a reload. See the [QA guide](../../docs/lifeboard/qa-guide.md) +for what to check by hand. + +## What is not wired up yet + +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. diff --git a/packages/lifeboard/package.json b/packages/lifeboard/package.json index 0b5e025c..b3955d11 100644 --- a/packages/lifeboard/package.json +++ b/packages/lifeboard/package.json @@ -6,6 +6,7 @@ "type": "module", "scripts": { "build": "rm -rf dist && esbuild src/main.ts --bundle --platform=browser --format=esm --splitting --target=es2022 --outdir=dist --entry-names=app --chunk-names=[name]-[hash] --jsx=automatic --loader:.css=text && cp src/index.html src/style.css src/sw.js dist/", + "serve": "npm run build && node scripts/serve.mjs", "test": "vitest run", "test:e2e": "npm run build && node e2e/offline.e2e.mjs" }, diff --git a/packages/lifeboard/scripts/serve.mjs b/packages/lifeboard/scripts/serve.mjs new file mode 100644 index 00000000..ecb097e8 --- /dev/null +++ b/packages/lifeboard/scripts/serve.mjs @@ -0,0 +1,59 @@ +// A static server for the built app. No dependencies: lifeboard is a folder of files, and the whole +// point is that anything which can serve a folder can serve it. +// +// npm run serve --workspace @ivanmkc/termchart-lifeboard +// +// Binds all interfaces so an iPad on the same wifi can open it. That is the intended way to use +// this: build on a laptop, open on the tablet on the kitchen counter. + +import { createServer } from "node:http"; +import { readFile } from "node:fs/promises"; +import { extname, join, normalize } from "node:path"; +import { fileURLToPath } from "node:url"; +import { networkInterfaces } from "node:os"; + +const DIST = fileURLToPath(new URL("../dist/", import.meta.url)); +const PORT = Number(process.env.PORT ?? 8123); +const TYPES = { + ".html": "text/html; charset=utf-8", + ".js": "text/javascript; charset=utf-8", + ".css": "text/css; charset=utf-8", + ".json": "application/json", + ".svg": "image/svg+xml", + ".png": "image/png", +}; + +const server = createServer(async (req, res) => { + const raw = (req.url ?? "/").split("?")[0]; + // Normalise before joining: "/../../etc/passwd" must not escape the dist folder. + const path = normalize(raw === "/" ? "/index.html" : raw).replace(/^(\.\.[/\\])+/, ""); + try { + const body = await readFile(join(DIST, path)); + res.writeHead(200, { + "content-type": TYPES[extname(path)] ?? "application/octet-stream", + // The service worker keeps its own cache; letting the browser cache too makes a rebuild + // confusing to test. + "cache-control": "no-cache", + }); + res.end(body); + } catch { + // A single-page app: unknown paths are routes, not missing files. + try { + res.writeHead(200, { "content-type": TYPES[".html"] }); + res.end(await readFile(join(DIST, "index.html"))); + } catch { + res.writeHead(404).end("Run `npm run build` first — there is no dist/ yet."); + } + } +}); + +server.listen(PORT, () => { + const addresses = Object.values(networkInterfaces()) + .flat() + .filter((n) => n && n.family === "IPv4" && !n.internal) + .map((n) => `http://${n.address}:${PORT}/`); + process.stdout.write(`lifeboard: http://localhost:${PORT}/\n`); + for (const a of addresses) process.stdout.write(` on this network: ${a}\n`); + process.stdout.write("\nService workers need a secure context: localhost counts, a LAN IP does not.\n"); + process.stdout.write("Offline support will be inactive over the network address — everything else works.\n"); +});