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"); +});